mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../data/models/user_model.dart' as app;
|
||||
import '../../../data/models/activity_model.dart';
|
||||
import '../../../data/repositories/social_repository.dart';
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
|
||||
class SocialController extends StateNotifier<SocialState> {
|
||||
final SocialRepository _repository;
|
||||
final String _currentUserId;
|
||||
|
||||
SocialController(this._repository, this._currentUserId)
|
||||
: super(const SocialState.initial());
|
||||
|
||||
Future<void> loadFollowers(String userId) async {
|
||||
state = const SocialState.loading();
|
||||
try {
|
||||
final followers = await _repository.getFollowers(userId);
|
||||
state = SocialState.followersLoaded(followers);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFollowing(String userId) async {
|
||||
state = const SocialState.loading();
|
||||
try {
|
||||
final following = await _repository.getFollowing(userId);
|
||||
state = SocialState.followingLoaded(following);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadActivityFeed(String userId) async {
|
||||
state = const SocialState.loading();
|
||||
try {
|
||||
final activities = await _repository.getActivityFeed(userId);
|
||||
state = SocialState.feedLoaded(activities);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> followUser(String targetUserId) async {
|
||||
try {
|
||||
await _repository.followUser(_currentUserId, targetUserId);
|
||||
await _repository.logActivity(
|
||||
userId: _currentUserId,
|
||||
type: 'followed_user',
|
||||
payload: {'target_user_id': targetUserId},
|
||||
);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unfollowUser(String targetUserId) async {
|
||||
try {
|
||||
await _repository.unfollowUser(_currentUserId, targetUserId);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isFollowing(String targetUserId) async {
|
||||
try {
|
||||
return await _repository.isFollowing(_currentUserId, targetUserId);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLeaderboard({required String sortBy, int limit = 50}) async {
|
||||
state = const SocialState.loading();
|
||||
try {
|
||||
final leaderboard = await _repository.getLeaderboard(
|
||||
sortBy: sortBy,
|
||||
limit: limit,
|
||||
);
|
||||
state = SocialState.leaderboardLoaded(leaderboard);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logGoalCompletion(String goalId) async {
|
||||
try {
|
||||
await _repository.logActivity(
|
||||
userId: _currentUserId,
|
||||
type: 'goal_completed',
|
||||
payload: {'goal_id': goalId},
|
||||
);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logMilestoneCompletion(String goalId, String milestone) async {
|
||||
try {
|
||||
await _repository.logActivity(
|
||||
userId: _currentUserId,
|
||||
type: 'milestone_completed',
|
||||
payload: {
|
||||
'goal_id': goalId,
|
||||
'milestone': milestone,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
state = SocialState.error(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SocialState {
|
||||
final bool isLoading;
|
||||
final List<app.User>? followers;
|
||||
final List<app.User>? following;
|
||||
final List<Activity>? feed;
|
||||
final List<app.User>? leaderboard;
|
||||
final String? error;
|
||||
|
||||
const SocialState({
|
||||
this.isLoading = false,
|
||||
this.followers,
|
||||
this.following,
|
||||
this.feed,
|
||||
this.leaderboard,
|
||||
this.error,
|
||||
});
|
||||
|
||||
const SocialState.initial() : isLoading = false, followers = null, following = null, feed = null, leaderboard = null, error = null;
|
||||
|
||||
const SocialState.loading() : isLoading = true, followers = null, following = null, feed = null, leaderboard = null, error = null;
|
||||
|
||||
const SocialState.followersLoaded(this.followers) : isLoading = false, following = null, feed = null, leaderboard = null, error = null;
|
||||
|
||||
const SocialState.followingLoaded(this.following) : isLoading = false, followers = null, feed = null, leaderboard = null, error = null;
|
||||
|
||||
const SocialState.feedLoaded(this.feed) : isLoading = false, followers = null, following = null, leaderboard = null, error = null;
|
||||
|
||||
const SocialState.leaderboardLoaded(this.leaderboard) : isLoading = false, followers = null, following = null, feed = null, error = null;
|
||||
|
||||
const SocialState.error(this.error) : isLoading = false, followers = null, following = null, feed = null, leaderboard = null;
|
||||
}
|
||||
|
||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||
return SocialRepository(supabaseClient);
|
||||
});
|
||||
|
||||
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
|
||||
final repository = ref.watch(socialRepositoryProvider);
|
||||
final authController = ref.read(authControllerProvider.notifier);
|
||||
final currentUserId = authController.currentUserId ?? '';
|
||||
|
||||
if (currentUserId.isEmpty) {
|
||||
return SocialController(repository, 'placeholder_user_id');
|
||||
}
|
||||
|
||||
return SocialController(repository, currentUserId);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// ignore_for_file: unused_field
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../data/repositories/notifications_repository.dart';
|
||||
import '../../../data/repositories/social_repository.dart';
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
|
||||
class SocialNotificationsState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final bool followNotificationsEnabled;
|
||||
final bool milestoneNotificationsEnabled;
|
||||
final bool leaderboardNotificationsEnabled;
|
||||
|
||||
const SocialNotificationsState({
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.followNotificationsEnabled = true,
|
||||
this.milestoneNotificationsEnabled = true,
|
||||
this.leaderboardNotificationsEnabled = true,
|
||||
});
|
||||
|
||||
SocialNotificationsState copyWith({
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool? followNotificationsEnabled,
|
||||
bool? milestoneNotificationsEnabled,
|
||||
bool? leaderboardNotificationsEnabled,
|
||||
}) {
|
||||
return SocialNotificationsState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
followNotificationsEnabled: followNotificationsEnabled ?? this.followNotificationsEnabled,
|
||||
milestoneNotificationsEnabled: milestoneNotificationsEnabled ?? this.milestoneNotificationsEnabled,
|
||||
leaderboardNotificationsEnabled: leaderboardNotificationsEnabled ?? this.leaderboardNotificationsEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SocialNotificationsController extends StateNotifier<SocialNotificationsState> {
|
||||
final NotificationsRepository _notificationsRepository;
|
||||
final SocialRepository _socialRepository;
|
||||
final AuthController _authController;
|
||||
|
||||
SocialNotificationsController(
|
||||
this._notificationsRepository,
|
||||
this._socialRepository,
|
||||
this._authController,
|
||||
) : super(const SocialNotificationsState());
|
||||
|
||||
Future<void> toggleFollowNotifications(bool enabled) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
try {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
followNotificationsEnabled: enabled,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleMilestoneNotifications(bool enabled) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
try {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
milestoneNotificationsEnabled: enabled,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleLeaderboardNotifications(bool enabled) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
try {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
leaderboardNotificationsEnabled: enabled,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendFollowNotification(String followerUserId, String followerUsername) async {
|
||||
if (!state.followNotificationsEnabled) return;
|
||||
|
||||
try {
|
||||
await _notificationsRepository.showNotification(
|
||||
id: DateTime.now().millisecondsSinceEpoch,
|
||||
title: 'New Follower!',
|
||||
body: '$followerUsername started following you',
|
||||
payload: 'follow_notification',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMilestoneNotification(
|
||||
String userId,
|
||||
String username,
|
||||
String goalTitle,
|
||||
) async {
|
||||
if (!state.milestoneNotificationsEnabled) return;
|
||||
|
||||
try {
|
||||
await _notificationsRepository.showNotification(
|
||||
id: DateTime.now().millisecondsSinceEpoch,
|
||||
title: 'Milestone Completed!',
|
||||
body: '$username completed: $goalTitle',
|
||||
payload: 'milestone_notification',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendLeaderboardNotification(String message) async {
|
||||
if (!state.leaderboardNotificationsEnabled) return;
|
||||
|
||||
try {
|
||||
await _notificationsRepository.showNotification(
|
||||
id: DateTime.now().millisecondsSinceEpoch,
|
||||
title: 'Leaderboard Update',
|
||||
body: message,
|
||||
payload: 'leaderboard_notification',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
}
|
||||
|
||||
final socialNotificationsControllerProvider =
|
||||
StateNotifierProvider<SocialNotificationsController, SocialNotificationsState>((ref) {
|
||||
final notificationsRepository = ref.watch(notificationsRepositoryProvider);
|
||||
final socialRepository = ref.watch(socialRepositoryProvider);
|
||||
final authController = ref.watch(authControllerProvider.notifier);
|
||||
|
||||
return SocialNotificationsController(
|
||||
notificationsRepository,
|
||||
socialRepository,
|
||||
authController,
|
||||
);
|
||||
});
|
||||
|
||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||
return SocialRepository(supabaseClient);
|
||||
});
|
||||
|
||||
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
||||
return NotificationsRepository();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/bottom_nav_scaffold.dart';
|
||||
import '../../../core/widgets/loading_indicator.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../data/models/user_model.dart' as app;
|
||||
import '../application/social_controller.dart';
|
||||
|
||||
class LeaderboardsScreen extends ConsumerStatefulWidget {
|
||||
const LeaderboardsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LeaderboardsScreen> createState() => _LeaderboardsScreenState();
|
||||
}
|
||||
|
||||
class _LeaderboardsScreenState extends ConsumerState<LeaderboardsScreen> {
|
||||
String _selectedSort = 'goals_completed';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadLeaderboard();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadLeaderboard() async {
|
||||
await ref.read(socialControllerProvider.notifier).loadLeaderboard(
|
||||
sortBy: _selectedSort,
|
||||
limit: 50,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSortChanged(String sortBy) {
|
||||
setState(() {
|
||||
_selectedSort = sortBy;
|
||||
});
|
||||
_loadLeaderboard();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final socialState = ref.watch(socialControllerProvider);
|
||||
|
||||
return BottomNavScaffold(
|
||||
child: AppScaffold(
|
||||
title: 'Leaderboards',
|
||||
body: Column(
|
||||
children: [
|
||||
_SortTabs(
|
||||
selectedSort: _selectedSort,
|
||||
onSortChanged: _onSortChanged,
|
||||
),
|
||||
Expanded(child: _buildBody(socialState)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(SocialState state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error: ${state.error}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadLeaderboard,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.leaderboard == null || state.leaderboard!.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.emoji_events_outlined,
|
||||
title: 'No Leaderboard Yet',
|
||||
subtitle: 'Be the first to complete goals and appear on the leaderboard!',
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadLeaderboard,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.leaderboard!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = state.leaderboard![index];
|
||||
return _LeaderboardEntry(
|
||||
user: user,
|
||||
rank: index + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SortTabs extends StatelessWidget {
|
||||
final String selectedSort;
|
||||
final Function(String) onSortChanged;
|
||||
|
||||
const _SortTabs({
|
||||
required this.selectedSort,
|
||||
required this.onSortChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 'goals_completed',
|
||||
label: Text('Goals'),
|
||||
icon: Icon(Icons.flag),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 'streak',
|
||||
label: Text('Streak'),
|
||||
icon: Icon(Icons.local_fire_department),
|
||||
),
|
||||
],
|
||||
selected: {selectedSort},
|
||||
onSelectionChanged: (Set<String> selected) {
|
||||
onSortChanged(selected.first);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeaderboardEntry extends StatelessWidget {
|
||||
final app.User user;
|
||||
final int rank;
|
||||
|
||||
const _LeaderboardEntry({
|
||||
required this.user,
|
||||
required this.rank,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color rankColor;
|
||||
IconData rankIcon;
|
||||
|
||||
if (rank == 1) {
|
||||
rankColor = Colors.amber;
|
||||
rankIcon = Icons.emoji_events;
|
||||
} else if (rank == 2) {
|
||||
rankColor = Colors.grey;
|
||||
rankIcon = Icons.military_tech;
|
||||
} else if (rank == 3) {
|
||||
rankColor = Colors.brown;
|
||||
rankIcon = Icons.workspace_premium;
|
||||
} else {
|
||||
rankColor = Theme.of(context).colorScheme.primary;
|
||||
rankIcon = Icons.numbers;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: rankColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
rankIcon,
|
||||
color: rankColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
user.username,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Member since ${user.createdAt.year}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'#$rank',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/loading_indicator.dart';
|
||||
import '../../../core/utils/date_time_utils.dart';
|
||||
import '../../../data/models/user_model.dart' as app;
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../application/social_controller.dart';
|
||||
import '../../profile/application/profile_controller.dart';
|
||||
|
||||
class PublicProfileScreen extends ConsumerStatefulWidget {
|
||||
final String userId;
|
||||
|
||||
const PublicProfileScreen({
|
||||
super.key,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PublicProfileScreen> createState() => _PublicProfileScreenState();
|
||||
}
|
||||
|
||||
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadProfile();
|
||||
_checkFollowingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
await ref.read(profileControllerProvider.notifier).loadProfile(widget.userId);
|
||||
}
|
||||
|
||||
Future<void> _checkFollowingStatus() async {
|
||||
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
|
||||
}
|
||||
|
||||
Future<void> _toggleFollow() async {
|
||||
final controller = ref.read(socialControllerProvider.notifier);
|
||||
final isFollowing = await controller.isFollowing(widget.userId);
|
||||
|
||||
if (isFollowing) {
|
||||
await controller.unfollowUser(widget.userId);
|
||||
} else {
|
||||
await controller.followUser(widget.userId);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profileState = ref.watch(profileControllerProvider);
|
||||
final authController = ref.watch(authControllerProvider);
|
||||
final isOwnProfile = authController?.id == widget.userId;
|
||||
|
||||
return AppScaffold(
|
||||
title: 'Profile',
|
||||
body: _buildBody(profileState, isOwnProfile),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ProfileState state, bool isOwnProfile) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (state.errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error: ${state.errorMessage}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final user = state.user;
|
||||
if (user == null) {
|
||||
return const Center(child: Text('User not found'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _loadProfile();
|
||||
await _checkFollowingStatus();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _ProfileHeader(
|
||||
user: user,
|
||||
isOwnProfile: isOwnProfile,
|
||||
onToggleFollow: isOwnProfile ? () {} : _toggleFollow,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _StatsSection(user: user),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeader extends ConsumerWidget {
|
||||
final app.User user;
|
||||
final bool isOwnProfile;
|
||||
final VoidCallback onToggleFollow;
|
||||
|
||||
const _ProfileHeader({
|
||||
required this.user,
|
||||
required this.isOwnProfile,
|
||||
required this.onToggleFollow,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
backgroundImage: user.avatarUrl != null
|
||||
? CachedNetworkImageProvider(user.avatarUrl!)
|
||||
: null,
|
||||
child: user.avatarUrl == null
|
||||
? Text(
|
||||
user.username.substring(0, 2).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
user.username,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (user.bio != null && user.bio!.isNotEmpty)
|
||||
Text(
|
||||
user.bio!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (user.isPublicProfile &&
|
||||
((user.twitterHandle != null && user.twitterHandle!.isNotEmpty) ||
|
||||
(user.instagramHandle != null && user.instagramHandle!.isNotEmpty) ||
|
||||
(user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty) ||
|
||||
(user.websiteUrl != null && user.websiteUrl!.isNotEmpty)))
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
if (user.twitterHandle != null && user.twitterHandle!.isNotEmpty)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.alternate_email, size: 16),
|
||||
label: Text(user.twitterHandle!),
|
||||
),
|
||||
if (user.instagramHandle != null && user.instagramHandle!.isNotEmpty)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.camera_alt_outlined, size: 16),
|
||||
label: Text(user.instagramHandle!),
|
||||
),
|
||||
if (user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.music_note_outlined, size: 16),
|
||||
label: Text(user.tiktokHandle!),
|
||||
),
|
||||
if (user.websiteUrl != null && user.websiteUrl!.isNotEmpty)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.link, size: 16),
|
||||
label: Text(user.websiteUrl!),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!isOwnProfile)
|
||||
_FollowButton(onToggleFollow: onToggleFollow),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FollowButton extends StatelessWidget {
|
||||
final VoidCallback onToggleFollow;
|
||||
|
||||
const _FollowButton({required this.onToggleFollow});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: onToggleFollow,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Follow'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(120, 40),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
|
||||
const _StatsSection({required this.user});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Journey Stats',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (user.countdownStartDate != null) ...[
|
||||
_StatCard(
|
||||
icon: Icons.timer,
|
||||
title: 'Challenge Started',
|
||||
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (user.daysRemaining != null)
|
||||
_StatCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
title: 'Days Remaining',
|
||||
value: '${user.daysRemaining} days',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_StatCard(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Member Since',
|
||||
value: DateTimeUtils.formatShortDate(user.createdAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String value;
|
||||
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
class SocialFeedScreen extends StatelessWidget {
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/bottom_nav_scaffold.dart';
|
||||
import '../../../core/widgets/loading_indicator.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../core/utils/date_time_utils.dart';
|
||||
import '../../../data/models/activity_model.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../application/social_controller.dart';
|
||||
|
||||
class SocialFeedScreen extends ConsumerStatefulWidget {
|
||||
const SocialFeedScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SocialFeedScreen> createState() => _SocialFeedScreenState();
|
||||
}
|
||||
|
||||
class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadFeed();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadFeed() async {
|
||||
final authController = ref.read(authControllerProvider.notifier);
|
||||
final userId = authController.currentUserId;
|
||||
if (userId != null) {
|
||||
await ref.read(socialControllerProvider.notifier).loadActivityFeed(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Social'),
|
||||
final socialState = ref.watch(socialControllerProvider);
|
||||
|
||||
return BottomNavScaffold(
|
||||
child: AppScaffold(
|
||||
title: 'Social Feed',
|
||||
body: _buildBody(socialState),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('Social Feed - Coming Soon'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(SocialState state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error: ${state.error}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadFeed,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.feed == null || state.feed!.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.feed_outlined,
|
||||
title: 'No Activity Yet',
|
||||
subtitle: 'Follow users to see their progress and milestones here.',
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadFeed,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.feed!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final activity = state.feed![index];
|
||||
return _ActivityCard(activity: activity);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityCard extends StatelessWidget {
|
||||
final Activity activity;
|
||||
|
||||
const _ActivityCard({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
IconData icon;
|
||||
Color iconColor;
|
||||
String title;
|
||||
String? subtitle;
|
||||
|
||||
switch (activity.type) {
|
||||
case 'goal_completed':
|
||||
icon = Icons.celebration;
|
||||
iconColor = Colors.green;
|
||||
title = 'Completed a goal!';
|
||||
subtitle = activity.payload?['goal_title'] as String?;
|
||||
break;
|
||||
case 'milestone_completed':
|
||||
icon = Icons.flag;
|
||||
iconColor = Colors.blue;
|
||||
title = 'Reached a milestone';
|
||||
subtitle = activity.payload?['milestone'] as String?;
|
||||
break;
|
||||
case 'followed_user':
|
||||
icon = Icons.person_add;
|
||||
iconColor = Colors.purple;
|
||||
title = 'Started following someone';
|
||||
break;
|
||||
case 'countdown_started':
|
||||
icon = Icons.timer;
|
||||
iconColor = Colors.orange;
|
||||
title = 'Started their 1356-day journey!';
|
||||
break;
|
||||
default:
|
||||
icon = Icons.info_outline;
|
||||
iconColor = Colors.grey;
|
||||
title = 'Activity';
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 28),
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis)
|
||||
: null,
|
||||
trailing: Text(
|
||||
DateTimeUtils.formatRelativeTime(activity.createdAt),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user