diff --git a/lib/app/app_router/app_router.dart b/lib/app/app_router/app_router.dart index b0c371f..653cf22 100644 --- a/lib/app/app_router/app_router.dart +++ b/lib/app/app_router/app_router.dart @@ -3,6 +3,9 @@ import 'package:go_router/go_router.dart'; import 'package:magic_yeti/app/app_router/app_route.dart'; import 'package:magic_yeti/app/app_router/go_router_refresh_stream.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/friends_list_page.dart'; +import 'package:magic_yeti/friends_list/requests/friend_request_page.dart'; +import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; import 'package:magic_yeti/home/home_page.dart'; import 'package:magic_yeti/life_counter/view/game_over_page.dart'; import 'package:magic_yeti/life_counter/view/view.dart'; @@ -74,6 +77,30 @@ class AppRouter { child: MatchDetailsPage.pageBuilder(context, state), ), ), + AppRoute( + name: FriendsListPage.routeName, + path: FriendsListPage.routePath, + pageBuilder: (context, state) => NoTransitionPage( + name: FriendsListPage.routeName, + child: FriendsListPage.pageBuilder(context, state), + ), + ), + AppRoute( + name: FriendRequestsPage.routeName, + path: FriendRequestsPage.routePath, + pageBuilder: (context, state) => NoTransitionPage( + name: FriendRequestsPage.routeName, + child: FriendRequestsPage.pageBuilder(context, state), + ), + ), + AppRoute( + name: SearchUserPage.routeName, + path: SearchUserPage.routePath, + pageBuilder: (context, state) => NoTransitionPage( + name: SearchUserPage.routeName, + child: SearchUserPage.pageBuilder(context, state), + ), + ), AppRoute( name: GamePage.routeName, path: GamePage.routePath, diff --git a/lib/friends_list/friends_list/bloc/friend_list_bloc.dart b/lib/friends_list/friends_list/bloc/friend_list_bloc.dart new file mode 100644 index 0000000..ccd0975 --- /dev/null +++ b/lib/friends_list/friends_list/bloc/friend_list_bloc.dart @@ -0,0 +1,54 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; + +part 'friend_list_event.dart'; +part 'friend_list_state.dart'; + +/// Bloc implementation for managing the user's friends list. +/// It handles loading the list of friends and removing friends. +/// +/// Key features: +/// - Loads friends from Firestore +/// - Removes friends with confirmation +/// +/// @dependencies +/// - FirebaseDatabaseRepository: For interacting with Firestore +/// - Flutter Bloc: For state management +/// +/// @notes +/// - Implements error handling for network issues +/// - Ensures real-time updates using Firestore sync + +class FriendBloc extends Bloc { + FriendBloc({required this.repository}) : super(FriendsLoading()) { + on(_onLoadFriends); + on(_onRemoveFriend); + } + + final FirebaseDatabaseRepository repository; + + Future _onLoadFriends( + LoadFriends event, + Emitter emit, + ) async { + try { + final friends = await repository.getFriends(event.userId); + emit(FriendsLoaded(friends)); + } catch (e) { + emit(FriendsError('Failed to load friends: $e')); + } + } + + Future _onRemoveFriend( + RemoveFriend event, + Emitter emit, + ) async { + try { + await repository.removeFriend(event.userId, event.friendId); + add(LoadFriends(event.userId)); // Reload friends after removal + } catch (e) { + emit(FriendsError('Failed to remove friend: $e')); + } + } +} diff --git a/lib/friends_list/friends_list/bloc/friend_list_event.dart b/lib/friends_list/friends_list/bloc/friend_list_event.dart new file mode 100644 index 0000000..f4036bc --- /dev/null +++ b/lib/friends_list/friends_list/bloc/friend_list_event.dart @@ -0,0 +1,32 @@ +part of 'friend_list_bloc.dart'; + +/// Events for the FriendsBloc. +/// Defines the actions that can be performed on the friends list. +/// +/// Key events: +/// - LoadFriends: Triggered to load the friends list +/// - RemoveFriend: Triggered to remove a friend + +sealed class FriendEvent extends Equatable { + const FriendEvent(); + + @override + List get props => []; +} + +class LoadFriends extends FriendEvent { + const LoadFriends(this.userId); + final String userId; + + @override + List get props => [userId]; +} + +class RemoveFriend extends FriendEvent { + const RemoveFriend(this.userId, this.friendId); + final String userId; + final String friendId; + + @override + List get props => [userId, friendId]; +} diff --git a/lib/friends_list/friends_list/bloc/friend_list_state.dart b/lib/friends_list/friends_list/bloc/friend_list_state.dart new file mode 100644 index 0000000..04ffb64 --- /dev/null +++ b/lib/friends_list/friends_list/bloc/friend_list_state.dart @@ -0,0 +1,34 @@ +part of 'friend_list_bloc.dart'; + +/// States for the FriendsBloc. +/// Represents the different states of the friends list. +/// +/// Key states: +/// - FriendsLoading: Indicates loading state +/// - FriendsLoaded: Indicates friends are successfully loaded +/// - FriendsError: Indicates an error occurred + +abstract class FriendState extends Equatable { + const FriendState(); + + @override + List get props => []; +} + +class FriendsLoading extends FriendState {} + +class FriendsLoaded extends FriendState { + const FriendsLoaded(this.friends); + final List friends; + + @override + List get props => [friends]; +} + +class FriendsError extends FriendState { + const FriendsError(this.message); + final String message; + + @override + List get props => [message]; +} diff --git a/lib/friends_list/friends_list/friends_list.dart b/lib/friends_list/friends_list/friends_list.dart new file mode 100644 index 0000000..1b372eb --- /dev/null +++ b/lib/friends_list/friends_list/friends_list.dart @@ -0,0 +1,104 @@ +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; + +/// This file implements the UI for displaying and managing the user's friends list. +/// It allows users to view their friends and remove them if desired. +/// +/// Key features: +/// - Display a list of current friends +/// - Remove friends with confirmation +/// +/// @dependencies +/// - Flutter Bloc: For state management +/// - Firebase Database Repository: To interact with Firestore +/// +/// @notes +/// - Handles network errors gracefully +/// - Ensures real-time updates using Firestore sync + +class FriendsList extends StatelessWidget { + const FriendsList({super.key}); + + @override + Widget build(BuildContext context) { + final userId = context.read().state.user.id; + return Scaffold( + body: BlocProvider( + create: (context) => FriendBloc( + repository: context.read(), + )..add(LoadFriends(userId)), + child: const FriendsListView(), + ), + ); + } +} + +class FriendsListView extends StatelessWidget { + const FriendsListView({super.key}); + + @override + Widget build(BuildContext context) { + final userId = context.read().state.user.id; + return BlocBuilder( + builder: (context, state) { + if (state is FriendsLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is FriendsLoaded) { + return state.friends.isEmpty + ? const Center(child: Text('No friends found')) + : ListView.builder( + itemCount: state.friends.length, + itemBuilder: (context, index) { + final friend = state.friends[index]; + return ListTile( + title: Text(friend.username), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => + _confirmRemoveFriend(context, friend, userId), + ), + ); + }, + ); + } else if (state is FriendsError) { + return const Center(child: Text('Failed to load friends')); + } + return const Center(child: Text('No friends found')); + }, + ); + } + + void _confirmRemoveFriend( + BuildContext context, + FriendModel friend, + String userId, + ) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Remove Friend'), + content: Text('Are you sure you want to remove ${friend.username}?'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text('Remove'), + onPressed: () { + context + .read() + .add(RemoveFriend(userId, friend.userId)); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/friends_list/friends_list_page.dart b/lib/friends_list/friends_list_page.dart new file mode 100644 index 0000000..7055e3a --- /dev/null +++ b/lib/friends_list/friends_list_page.dart @@ -0,0 +1,57 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:magic_yeti/friends_list/friends_list/friends_list.dart'; +import 'package:magic_yeti/friends_list/requests/friend_request_page.dart'; +import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; + +class FriendsListPage extends StatelessWidget { + const FriendsListPage({super.key}); + factory FriendsListPage.pageBuilder(_, __) { + return const FriendsListPage(key: Key('friends_list_page')); + } + + static const routeName = 'friendsListPage'; + static const routePath = '/friendsListPage'; + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + backgroundColor: AppColors.quaternary, + title: Text( + l10n.friendsTitle, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppColors.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + bottom: TabBar( + tabs: [ + Tab(text: l10n.friendsTitle), + Tab(text: l10n.friendRequestsTitle), + ], + indicatorColor: AppColors.tertiary, + labelColor: AppColors.onSurfaceVariant, + ), + ), + body: const TabBarView( + children: [ + FriendsList(), + FriendRequestsPage(), + ], + ), + floatingActionButton: FloatingActionButton( + foregroundColor: AppColors.white, + backgroundColor: AppColors.tertiary, + onPressed: () => context.go(SearchUserPage.routePath), + child: const Icon(Icons.add), + ), + ), + ); + } +} diff --git a/lib/friends_list/requests/bloc/friend_request_bloc.dart b/lib/friends_list/requests/bloc/friend_request_bloc.dart new file mode 100644 index 0000000..0b43a53 --- /dev/null +++ b/lib/friends_list/requests/bloc/friend_request_bloc.dart @@ -0,0 +1,61 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; + +part 'friend_request_event.dart'; +part 'friend_request_state.dart'; + +/// Bloc implementation for managing friend requests. +/// +/// Handles: +/// - Loading friend requests from Firestore. +/// - Accepting and declining friend requests. +/// +/// @dependencies +/// - Firebase Firestore: For data storage and retrieval. +/// - Flutter Bloc: For state management. +class FriendRequestBloc extends Bloc { + FriendRequestBloc({required this.repository}) + : super(FriendRequestLoading()) { + on(_onLoadFriendRequests); + on(_onAcceptFriendRequest); + on(_onDeclineFriendRequest); + } + final FirebaseDatabaseRepository repository; + + Future _onLoadFriendRequests( + LoadFriendRequests event, + Emitter emit, + ) async { + try { + final requests = await repository.getFriendRequests(event.userId); + emit(FriendRequestLoaded(requests)); + } catch (e) { + emit(const FriendRequestError('Failed to load friend requests')); + } + } + + Future _onAcceptFriendRequest( + AcceptFriendRequest event, + Emitter emit, + ) async { + try { + await repository.acceptFriendRequest(event.request, event.userId); + add(LoadFriendRequests(event.request.senderId)); + } catch (e) { + emit(const FriendRequestError('Failed to accept friend request')); + } + } + + Future _onDeclineFriendRequest( + DeclineFriendRequest event, + Emitter emit, + ) async { + try { + await repository.declineFriendRequest(event.request.id); + add(LoadFriendRequests(event.request.senderId)); + } catch (e) { + emit(const FriendRequestError('Failed to decline friend request')); + } + } +} diff --git a/lib/friends_list/requests/bloc/friend_request_event.dart b/lib/friends_list/requests/bloc/friend_request_event.dart new file mode 100644 index 0000000..6975c7f --- /dev/null +++ b/lib/friends_list/requests/bloc/friend_request_event.dart @@ -0,0 +1,44 @@ +part of 'friend_request_bloc.dart'; + +/// Defines the events for the FriendRequestBloc. +/// +/// Events: +/// - LoadFriendRequests: Triggered to load friend requests from Firestore. +/// - AcceptFriendRequest: Triggered to accept a friend request. +/// - DeclineFriendRequest: Triggered to decline a friend request. + +sealed class FriendRequestEvent extends Equatable { + const FriendRequestEvent(); + + @override + List get props => []; +} + +class LoadFriendRequests extends FriendRequestEvent { + const LoadFriendRequests(this.userId); + final String userId; + + @override + List get props => [userId]; +} + +/// Triggered to accept a friend request. +/// +/// [request] is the friend request to accept. +/// [userId] is the ID of the user accepting the request. +class AcceptFriendRequest extends FriendRequestEvent { + const AcceptFriendRequest(this.request, this.userId); + final FriendRequestModel request; + final String userId; + + @override + List get props => [request, userId]; +} + +class DeclineFriendRequest extends FriendRequestEvent { + const DeclineFriendRequest(this.request); + final FriendRequestModel request; + + @override + List get props => [request]; +} diff --git a/lib/friends_list/requests/bloc/friend_request_state.dart b/lib/friends_list/requests/bloc/friend_request_state.dart new file mode 100644 index 0000000..2441c26 --- /dev/null +++ b/lib/friends_list/requests/bloc/friend_request_state.dart @@ -0,0 +1,33 @@ +part of 'friend_request_bloc.dart'; + +/// Defines the states for the FriendRequestBloc. +/// +/// States: +/// - FriendRequestLoading: Indicates loading state. +/// - FriendRequestLoaded: Indicates successful loading of friend requests. +/// - FriendRequestError: Indicates an error occurred while loading requests. + +abstract class FriendRequestState extends Equatable { + const FriendRequestState(); + + @override + List get props => []; +} + +class FriendRequestLoading extends FriendRequestState {} + +class FriendRequestLoaded extends FriendRequestState { + const FriendRequestLoaded(this.requests); + final List requests; + + @override + List get props => [requests]; +} + +class FriendRequestError extends FriendRequestState { + const FriendRequestError(this.message); + final String message; + + @override + List get props => [message]; +} diff --git a/lib/friends_list/requests/friend_request_page.dart b/lib/friends_list/requests/friend_request_page.dart new file mode 100644 index 0000000..7d3620c --- /dev/null +++ b/lib/friends_list/requests/friend_request_page.dart @@ -0,0 +1,96 @@ +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/requests/bloc/friend_request_bloc.dart'; + +/// This file implements the UI and logic for managing friend requests. +/// It allows users to send and accept friend requests within the app. +/// +/// Key features: +/// - Search for users to send friend requests +/// - View incoming friend requests +/// - Accept or decline friend requests +/// +/// @dependencies +/// - Flutter Bloc: For state management +/// - Firebase Firestore: For storing and retrieving friend requests +/// +/// @notes +/// - Handles network errors and provides user feedback +/// - Ensures real-time updates with Firestore +class FriendRequestsPage extends StatelessWidget { + const FriendRequestsPage({super.key}); + + factory FriendRequestsPage.pageBuilder(_, __) { + return const FriendRequestsPage(key: Key('friend_requests_page')); + } + + static const routeName = 'friendRequests'; + static const routePath = '/friendRequests'; + + @override + Widget build(BuildContext context) { + final userId = context.read().state.user.id; + return Scaffold( + body: BlocProvider( + create: (context) => FriendRequestBloc( + repository: context.read(), + )..add(LoadFriendRequests(userId)), + child: const FriendRequestView(), + ), + ); + } +} + +class FriendRequestView extends StatelessWidget { + const FriendRequestView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is FriendRequestLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is FriendRequestLoaded) { + return ListView.builder( + itemCount: state.requests.length, + itemBuilder: (context, index) { + final request = state.requests[index]; + return ListTile( + title: Text(request.senderName), + subtitle: Text('Request from: ${request.senderName}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.check), + onPressed: () { + final userId = context.read().state.user.id; + context + .read() + .add(AcceptFriendRequest(request, userId)); + }, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + context + .read() + .add(DeclineFriendRequest(request)); + }, + ), + ], + ), + ); + }, + ); + } else if (state is FriendRequestError) { + return Center( + child: Text('Failed to load requests: ${state.message}')); + } + return Container(); + }, + ); + } +} diff --git a/lib/friends_list/search_user/bloc/search_bloc.dart b/lib/friends_list/search_user/bloc/search_bloc.dart new file mode 100644 index 0000000..2ba5f98 --- /dev/null +++ b/lib/friends_list/search_user/bloc/search_bloc.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'search_event.dart'; +part 'search_state.dart'; + +/// This file implements the Bloc pattern for managing the state of user search functionality. +/// It handles the search logic, including loading, success, and error states using the FirebaseDatabaseRepository. +/// +/// Key features: +/// - Event-driven architecture for search actions +/// - State management for search results and errors +/// - Integration with FirebaseDatabaseRepository for data fetching +/// +/// @dependencies +/// - FirebaseDatabaseRepository: Used for querying user data +/// - Flutter Bloc: Used for managing state +/// +/// @notes +/// - Implements robust error handling for network issues +/// - Ensures real-time updates using the repository + +class SearchBloc extends Bloc { + SearchBloc({required this.repository}) : super(SearchInitial()) { + on(_onSearchUsers); + on(_onAddFriendRequest); + } + final FirebaseDatabaseRepository repository; + + Future _onSearchUsers( + SearchUsers event, + Emitter emit, + ) async { + emit(SearchLoading()); + try { + final users = await repository.searchUsers(event.query); + emit(SearchLoaded(users)); + } catch (e) { + emit(SearchError('Failed to fetch users: $e')); + } + } + + Future _onAddFriendRequest( + AddFriendRequest event, + Emitter emit, + ) async { + emit(SearchLoading()); + try { + await repository.addFriendRequest( + event.senderId, + event.senderName, + event.receiverId, + ); + emit(const SearchLoaded([])); + } catch (e) { + emit(SearchError('Failed to add friend request: $e')); + } + } +} diff --git a/lib/friends_list/search_user/bloc/search_event.dart b/lib/friends_list/search_user/bloc/search_event.dart new file mode 100644 index 0000000..20fd6d9 --- /dev/null +++ b/lib/friends_list/search_user/bloc/search_event.dart @@ -0,0 +1,30 @@ +part of 'search_bloc.dart'; + +sealed class SearchEvent extends Equatable { + const SearchEvent(); + + @override + List get props => []; +} + +class SearchUsers extends SearchEvent { + const SearchUsers(this.query); + final String query; + + @override + List get props => [query]; +} + +class AddFriendRequest extends SearchEvent { + const AddFriendRequest( + this.senderId, + this.senderName, + this.receiverId, + ); + final String senderId; + final String senderName; + final String receiverId; + + @override + List get props => [senderId, senderName, receiverId]; +} diff --git a/lib/friends_list/search_user/bloc/search_state.dart b/lib/friends_list/search_user/bloc/search_state.dart new file mode 100644 index 0000000..c027950 --- /dev/null +++ b/lib/friends_list/search_user/bloc/search_state.dart @@ -0,0 +1,28 @@ +part of 'search_bloc.dart'; + +sealed class SearchState extends Equatable { + const SearchState(); + + @override + List get props => []; +} + +class SearchInitial extends SearchState {} + +class SearchLoading extends SearchState {} + +class SearchLoaded extends SearchState { + const SearchLoaded(this.users); + final List users; + + @override + List get props => [users]; +} + +class SearchError extends SearchState { + const SearchError(this.message); + final String message; + + @override + List get props => [message]; +} diff --git a/lib/friends_list/search_user/search_user_page.dart b/lib/friends_list/search_user/search_user_page.dart new file mode 100644 index 0000000..fb82297 --- /dev/null +++ b/lib/friends_list/search_user/search_user_page.dart @@ -0,0 +1,125 @@ +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/search_user/bloc/search_bloc.dart'; +import 'package:magic_yeti/home/home_page.dart'; + +/// This file implements the user search functionality for the friends list feature. +/// It allows users to search for other users by username or email. +/// +/// Key features: +/// - Search input for username or email +/// - Display search results with user details +/// - Handle empty search results and errors +/// +/// @dependencies +/// - Firebase Firestore: Used for querying user data +/// - Flutter Bloc: Used for managing state +/// +/// @notes +/// - Ensures real-time search updates using Firestore +/// - Implements error handling for network issues and invalid inputs +class SearchUserPage extends StatelessWidget { + const SearchUserPage({super.key}); + + factory SearchUserPage.pageBuilder(_, __) { + return const SearchUserPage(key: Key('search_user_page')); + } + + static const routeName = 'searchUser'; + static const routePath = '/searchUser'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Search Users'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go(HomePage.routeName), + ), + ), + body: BlocProvider( + create: (context) => SearchBloc( + repository: context.read(), + ), + child: const SearchUserForm(), + ), + ); + } +} + +class SearchUserForm extends StatefulWidget { + const SearchUserForm({super.key}); + + @override + SearchUserFormState createState() => SearchUserFormState(); +} + +class SearchUserFormState extends State { + final _searchController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + labelText: 'Search by username or email', + border: OutlineInputBorder(), + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + BlocProvider.of(context).add(SearchUsers(value)); + } + }, + ), + ), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is SearchLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is SearchLoaded) { + return ListView.builder( + itemCount: state.users.length, + itemBuilder: (context, index) { + final user = state.users[index]; + return ListTile( + title: Text(user.username ?? ''), + subtitle: Text(user.email ?? ''), + onTap: () { + BlocProvider.of(context).add( + AddFriendRequest( + context.read().state.user.id, + user.username ?? '', + user.id, + ), + ); + }, + ); + }, + ); + } else if (state is SearchError) { + return Center(child: Text('Error: ${state.message}')); + } else { + return const Center(child: Text('No results found.')); + } + }, + ), + ), + ], + ); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } +} diff --git a/lib/home/home_page.dart b/lib/home/home_page.dart index b6ce83c..484887a 100644 --- a/lib/home/home_page.dart +++ b/lib/home/home_page.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:app_ui/app_ui.dart'; import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; import 'package:magic_yeti/app/utils/device_info_provider.dart'; +import 'package:magic_yeti/friends_list/friends_list_page.dart'; import 'package:magic_yeti/game/bloc/game_bloc.dart'; import 'package:magic_yeti/home/match_history_bloc/match_history_bloc.dart'; import 'package:magic_yeti/l10n/arb/app_localizations.dart'; @@ -63,7 +65,12 @@ class _TabletView extends StatelessWidget { Expanded( child: Column( children: [ - SectionHeader(title: l10n.matchHistoryTitle), + SectionHeader( + title: l10n.matchHistoryTitle, + onMorePressed: () => kDebugMode + ? context.push(FriendsListPage.routePath) + : null, + ), const Expanded( child: MatchHistoryPanel(), ), @@ -923,6 +930,13 @@ class _PhoneView extends StatelessWidget { indicatorColor: AppColors.tertiary, labelColor: AppColors.onSurfaceVariant, ), + actions: [ + IconButton( + onPressed: () => + kDebugMode ? context.push(FriendsListPage.routePath) : null, + icon: const Icon(Icons.person), + ), + ], ), body: const TabBarView( children: [ diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2e38faa..205c701 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -300,6 +300,18 @@ "type": "text", "placeholders": {} }, + "friendsTitle": "Friends", + "@friendsTitle": { + "description": "Title for the friends section", + "type": "text", + "placeholders": {} + }, + "friendRequestsTitle": "Friend Requests", + "@friendRequestsTitle": { + "description": "Title for the friend requests section", + "type": "text", + "placeholders": {} + }, "thisIsMe": "This is me", "@thisIsMe": { "description": "Button text for claiming a player", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 8df10ef..2097ef9 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -395,6 +395,18 @@ abstract class AppLocalizations { /// **'Player'** String get playerColumnHeader; + /// Title for the friends section + /// + /// In en, this message translates to: + /// **'Friends'** + String get friendsTitle; + + /// Title for the friend requests section + /// + /// In en, this message translates to: + /// **'Friend Requests'** + String get friendRequestsTitle; + /// Button text for claiming a player /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 25ae63f..e4fcb8f 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -164,6 +164,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get playerColumnHeader => 'Player'; + @override + String get friendsTitle => 'Friends'; + + @override + String get friendRequestsTitle => 'Friend Requests'; + @override String get thisIsMe => 'This is me'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 0302738..047b952 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -164,6 +164,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get playerColumnHeader => 'Player'; + @override + String get friendsTitle => 'Friends'; + + @override + String get friendRequestsTitle => 'Friend Requests'; + @override String get thisIsMe => 'This is me'; diff --git a/lib/login/view/login_view.dart b/lib/login/view/login_view.dart index e3c00fb..a1ff59f 100644 --- a/lib/login/view/login_view.dart +++ b/lib/login/view/login_view.dart @@ -11,10 +11,21 @@ import 'package:magic_yeti/l10n/l10n.dart'; import 'package:magic_yeti/login/login.dart'; import 'package:magic_yeti/reset_password/reset_password.dart'; import 'package:magic_yeti/sign_up/sign_up.dart'; +import 'package:magic_yeti/app/utils/device_info_provider.dart'; class LoginView extends StatelessWidget { const LoginView({super.key}); + @override + Widget build(BuildContext context) { + final isPhone = DeviceInfoProvider.of(context).isPhone; + return isPhone ? const _PhoneLoginView() : const _TabletLoginView(); + } +} + +class _TabletLoginView extends StatelessWidget { + const _TabletLoginView(); + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -64,6 +75,52 @@ class LoginView extends StatelessWidget { } } +class _PhoneLoginView extends StatelessWidget { + const _PhoneLoginView(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go(HomePage.routeName), + ), + ), + body: BlocListener( + listener: (context, state) { + if (state.status.isSuccess) { + context.go(HomePage.routeName); + } + if (state.status.isFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.authenticationFailure)), + ); + } + }, + child: SafeArea( + minimum: const EdgeInsets.all(AppSpacing.xlg), + child: ScrollableColumn( + children: [ + const _LoginContent(), + const _LoginActions(), + Expanded( + child: Image.asset( + 'assets/icon/icon.png', + fit: BoxFit.cover, + ), + ), + ], + ), + ), + ), + ); + } +} + class _LoginContent extends StatelessWidget { const _LoginContent(); diff --git a/lib/main_production.dart b/lib/main_production.dart index e204138..05961ad 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -18,6 +18,7 @@ void main() { final firebaseDatabaseRepository = FirebaseDatabaseRepository( firebase: FirebaseFirestore.instance, ); + final userRepository = UserRepository( authenticationClient: authenticationClient, firebaseDatabaseRepository: firebaseDatabaseRepository, diff --git a/lib/stats_overview/stats_overview_bloc/stats_overview_bloc.dart b/lib/stats_overview/stats_overview_bloc/stats_overview_bloc.dart index a6fc7fe..ff86fe7 100644 --- a/lib/stats_overview/stats_overview_bloc/stats_overview_bloc.dart +++ b/lib/stats_overview/stats_overview_bloc/stats_overview_bloc.dart @@ -90,15 +90,18 @@ class StatsOverviewBloc extends Bloc { int uniqueCommanderCount, String userId, ) { - if (games.isEmpty) return ''; + if (games.isEmpty) return 'No games'; final commanders = []; for (final game in games) { final player = _findPlayerInGame(game, userId); + if (player.commander == null) return 'No commanders'; commanders.add(player.commander?.name ?? ''); } - commanders.removeWhere((commander) => commander.isEmpty); + commanders + .removeWhere((commander) => commander.isEmpty || commander == 'null'); final mostPlayedCommander = commanders.reduce((current, next) { + if (commanders.isEmpty) return 'No commanders'; return commanders.where((element) => element == current).length > commanders.where((element) => element == next).length ? current diff --git a/packages/firebase_database_repository/lib/helpers/timestamp_converter.dart b/packages/firebase_database_repository/lib/helpers/timestamp_converter.dart new file mode 100644 index 0000000..47ab85b --- /dev/null +++ b/packages/firebase_database_repository/lib/helpers/timestamp_converter.dart @@ -0,0 +1,20 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:json_annotation/json_annotation.dart'; + +/// {@template timestamp_converter} +/// Converts between [DateTime] and [Timestamp] for JSON serialization. +/// {@endtemplate} +class TimestampConverter implements JsonConverter { + /// {@macro timestamp_converter} + const TimestampConverter(); + + @override + DateTime fromJson(Timestamp timestamp) { + return timestamp.toDate(); + } + + @override + Timestamp toJson(DateTime dateTime) { + return Timestamp.fromDate(dateTime); + } +} diff --git a/packages/firebase_database_repository/lib/models/friend_model.dart b/packages/firebase_database_repository/lib/models/friend_model.dart new file mode 100644 index 0000000..242a919 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/friend_model.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'friend_model.g.dart'; + +/// {@template FriendModel} +/// This model represents a friend in the application, encapsulating +/// all necessary fields and providing methods for serialization and deserialization. +/// +/// Key features: +/// - Type-safe representation of friend data +/// - Methods for converting to and from Firestore documents +/// +/// @dependencies +/// - None +/// +/// @notes +/// - Ensure that all fields are properly validated before using this model +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class FriendModel extends Equatable { + /// Constructor for FriendModel. + /// + /// @param userId The unique identifier for the friend. + /// @param username The name of the friend. + /// @param profilePictureUrl The URL of the friend's profile picture. + const FriendModel({ + required this.userId, + required this.username, + required this.profilePictureUrl, + }); + + /// Converts a Firestore document snapshot to a FriendModel. + factory FriendModel.fromJson(Map json) => + _$FriendModelFromJson(json); + + /// Converts the FriendModel to a Map for Firestore storage. + Map toJson() => _$FriendModelToJson(this); + + /// The unique identifier for the friend. + final String userId; + + /// The name of the friend. + final String username; + + /// The URL of the friend's profile picture. + final String profilePictureUrl; + + @override + List get props => [ + userId, + username, + profilePictureUrl, + ]; +} diff --git a/packages/firebase_database_repository/lib/models/friend_model.g.dart b/packages/firebase_database_repository/lib/models/friend_model.g.dart new file mode 100644 index 0000000..f278b1e --- /dev/null +++ b/packages/firebase_database_repository/lib/models/friend_model.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'friend_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FriendModel _$FriendModelFromJson(Map json) => FriendModel( + userId: json['userId'] as String, + username: json['username'] as String, + profilePictureUrl: json['profilePictureUrl'] as String, + ); + +Map _$FriendModelToJson(FriendModel instance) => + { + 'userId': instance.userId, + 'username': instance.username, + 'profilePictureUrl': instance.profilePictureUrl, + }; diff --git a/packages/firebase_database_repository/lib/models/friend_request_model.dart b/packages/firebase_database_repository/lib/models/friend_request_model.dart new file mode 100644 index 0000000..2aa6d91 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/friend_request_model.dart @@ -0,0 +1,97 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/helpers/timestamp_converter.dart'; +import 'package:json_annotation/json_annotation.dart'; +// ignore: directives_ordering +import 'package:cloud_firestore/cloud_firestore.dart'; + +part 'friend_request_model.g.dart'; + +/// {@template FriendRequestModel} +/// This model represents a friend request in the application, encapsulating +/// all necessary fields and providing methods for serialization and deserialization. +/// +/// Key features: +/// - Type-safe representation of friend request data +/// - Methods for converting to and from Firestore documents +/// +/// @dependencies +/// - None +/// +/// @notes +/// - Ensure that all fields are properly validated before using this model +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +@TimestampConverter() +class FriendRequestModel extends Equatable { + /// Constructor for FriendRequestModel. + /// + /// @param requestId The unique identifier for the friend request. + /// @param senderId The ID of the user sending the request. + /// @param receiverId The ID of the user receiving the request. + /// @param status The current status of the friend request (e.g., 'pending', 'accepted'). + /// @param timestamp The time when the request was created. + const FriendRequestModel({ + required this.id, + required this.senderId, + required this.receiverId, + required this.senderName, + required this.status, + required this.timestamp, + }); + + /// Converts a Firestore document snapshot to a FriendRequestModel. + factory FriendRequestModel.fromJson(Map json) => + _$FriendRequestModelFromJson(json); + + /// Converts the FriendRequestModel to a Map for Firestore storage. + Map toJson() => _$FriendRequestModelToJson(this); + + /// The unique identifier for the friend request. + final String id; + + /// The ID of the user sending the request. + final String senderId; + + /// The ID of the user receiving the request. + final String receiverId; + + /// The name of the user sending the request. + final String senderName; + + /// The current status of the friend request (e.g., 'pending', 'accepted'). + final String status; + + /// The time when the request was created. + @TimestampConverter() + final DateTime timestamp; + + /// Creates a copy of this FriendRequestModel with the given fields replaced with the + /// new values. + FriendRequestModel copyWith({ + String? id, + String? senderId, + String? receiverId, + String? senderName, + String? status, + DateTime? timestamp, + }) { + return FriendRequestModel( + id: id ?? this.id, + senderId: senderId ?? this.senderId, + receiverId: receiverId ?? this.receiverId, + senderName: senderName ?? this.senderName, + status: status ?? this.status, + timestamp: timestamp ?? this.timestamp, + ); + } + + @override + List get props => [ + id, + senderId, + receiverId, + senderName, + status, + timestamp, + ]; +} diff --git a/packages/firebase_database_repository/lib/models/friend_request_model.g.dart b/packages/firebase_database_repository/lib/models/friend_request_model.g.dart new file mode 100644 index 0000000..c6873ff --- /dev/null +++ b/packages/firebase_database_repository/lib/models/friend_request_model.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'friend_request_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FriendRequestModel _$FriendRequestModelFromJson(Map json) => + FriendRequestModel( + id: json['id'] as String, + senderId: json['senderId'] as String, + receiverId: json['receiverId'] as String, + senderName: json['senderName'] as String, + status: json['status'] as String, + timestamp: + const TimestampConverter().fromJson(json['timestamp'] as Timestamp), + ); + +Map _$FriendRequestModelToJson(FriendRequestModel instance) => + { + 'id': instance.id, + 'senderId': instance.senderId, + 'receiverId': instance.receiverId, + 'senderName': instance.senderName, + 'status': instance.status, + 'timestamp': const TimestampConverter().toJson(instance.timestamp), + }; diff --git a/packages/firebase_database_repository/lib/models/models.dart b/packages/firebase_database_repository/lib/models/models.dart index ea3e0c5..bb1846a 100644 --- a/packages/firebase_database_repository/lib/models/models.dart +++ b/packages/firebase_database_repository/lib/models/models.dart @@ -1,2 +1,4 @@ +export 'friend_model.dart'; +export 'friend_request_model.dart'; export 'game_model.dart'; export 'user_profile_model.dart'; diff --git a/packages/firebase_database_repository/lib/src/firebase_database_repository.dart b/packages/firebase_database_repository/lib/src/firebase_database_repository.dart index e02dfa9..9f1ed26 100644 --- a/packages/firebase_database_repository/lib/src/firebase_database_repository.dart +++ b/packages/firebase_database_repository/lib/src/firebase_database_repository.dart @@ -149,6 +149,9 @@ class FirebaseDatabaseRepository { final FirebaseFirestore _firebase; + CollectionReference get _friendCollection => + _firebase.collection('friendRequests'); + /// Check if a game ID already exists Future checkIfGameIdExists(String gameId) async { try { @@ -315,4 +318,165 @@ class FirebaseDatabaseRepository { ); } } + + /// Adds a friend request to the Firestore database. + /// + /// @param senderId The ID of the user sending the request. + /// @param receiverId The ID of the user receiving the request. + /// @returns Future + /// @throws Exception if the request cannot be added. + Future addFriendRequest( + String senderId, + String senderName, + String receiverId, + ) async { + try { + // Generate a new document reference, which creates a unique ID + final newRequestRef = _friendCollection.doc(); + final documentId = newRequestRef.id; + await newRequestRef.set({ + 'id': documentId, + 'senderId': senderId, + 'senderName': senderName, + 'receiverId': receiverId, + 'status': 'pending', + 'timestamp': FieldValue.serverTimestamp(), + }); + } catch (e) { + throw Exception('Failed to add friend request: $e'); + } + } + + /// Accepts a friend request by updating its status in the Firestore database. + /// + /// @param requestId The ID of the friend request to accept. + /// @returns Future + /// @throws Exception if the request cannot be updated. + Future acceptFriendRequest( + FriendRequestModel request, + String userId, + ) async { + try { + await _firebase.collection('friends').doc(userId).set({ + 'id': userId, + 'friends': FieldValue.arrayUnion([request.senderId]), + }); + await _friendCollection.doc(request.id).delete(); + } catch (e) { + throw Exception('Failed to accept friend request: $e'); + } + } + + /// Removes a friend from the Firestore database. + /// + /// @param userId The ID of the user whose friend is being removed. + /// @param friendId The ID of the friend to remove. + /// @returns Future + /// @throws Exception if the friend cannot be removed. + Future removeFriend(String userId, String friendId) async { + try { + final snapshot = await _friendCollection + .where('userId', isEqualTo: userId) + .where('friendId', isEqualTo: friendId) + .get(); + + for (final doc in snapshot.docs) { + await doc.reference.delete(); + } + } catch (e) { + throw Exception('Failed to remove friend: $e'); + } + } + + /// Retrieves the list of friends for a given user. + /// + /// @param userId The ID of the user whose friends are being retrieved. + /// @returns Future> A list of friend IDs. + /// @throws Exception if the friends cannot be retrieved. + Future> getFriends(String userId) async { + try { + final snapshot = await _firebase.collection('friends').doc(userId).get(); + if (snapshot.exists) { + return snapshot + .data()! + .entries + .map((friendId) => + FriendModel.fromJson(friendId as Map)) + .toList(); + } else { + return []; + } + } catch (e) { + throw Exception('Failed to retrieve friends: $e'); + } + } + + /// Searches for users by username or email. + /// + /// @param searchTerm The term to search for in usernames or emails. + /// @returns Future>> A list of user data maps. + /// @throws Exception if the search fails. + Future> searchUsers(String searchTerm) async { + try { + final QuerySnapshot usernameSnapshot = await _firebase + .collection('users') + .where('username', isEqualTo: searchTerm) + .get(); + + final QuerySnapshot emailSnapshot = await _firebase + .collection('users') + .where('email', isEqualTo: searchTerm) + .get(); + + final users = []; + + for (final doc in usernameSnapshot.docs) { + users.add( + UserProfileModel.fromJson(doc.data()! as Map)); + } + + for (final doc in emailSnapshot.docs) { + users.add( + UserProfileModel.fromJson(doc.data()! as Map)); + } + + return users; + } catch (e) { + throw Exception('Failed to search users: $e'); + } + } + + /// Retrieves all incoming friend requests for a given user. + /// + /// @param userId The ID of the user whose incoming friend requests are being retrieved. + /// @returns Future> A list of friend request data. + /// @throws Exception if the friend requests cannot be retrieved. + Future> getFriendRequests(String userId) async { + try { + final snapshot = await _friendCollection + .where('receiverId', isEqualTo: userId) + .where('status', isEqualTo: 'pending') + .get(); + + return snapshot.docs + .map((doc) => + FriendRequestModel.fromJson(doc.data()! as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to retrieve friend requests: $e'); + } + } + + /// Declines a friend request by removing it from the Firestore database. + /// + /// @param requestId The ID of the friend request to decline. + /// @returns Future + /// @throws Exception if the request cannot be removed. + Future declineFriendRequest(String requestId) async { + try { + await _friendCollection.doc(requestId).delete(); + } catch (e) { + throw Exception('Failed to decline friend request: $e'); + } + } } diff --git a/packages/firebase_database_repository/pubspec.yaml b/packages/firebase_database_repository/pubspec.yaml index 78f17bf..8213185 100644 --- a/packages/firebase_database_repository/pubspec.yaml +++ b/packages/firebase_database_repository/pubspec.yaml @@ -8,7 +8,7 @@ environment: dev_dependencies: build_runner: ^2.4.7 - json_serializable: ^6.7.1 + json_serializable: ^6.9.4 mocktail: ^1.0.0 test: ^1.19.2 very_good_analysis: ^5.1.0 @@ -16,7 +16,7 @@ dev_dependencies: dependencies: cloud_firestore: ^5.5.0 equatable: ^2.0.5 - json_annotation: ^4.8.1 + json_annotation: ^4.9.0 player_repository: path: ../player_repository uuid: ^4.2.1 diff --git a/pubspec.yaml b/pubspec.yaml index bc4f31d..e0070fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: magic_yeti description: Magic The Gathering Tracking App -version: 1.1.1+2 +version: 1.1.2+1 publish_to: none environment: