From f64b60aff7a4e90b298ebe6d4c774044ef7485f7 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 2 Apr 2024 22:16:02 -0700 Subject: [PATCH 01/18] added add_friend and get all friends routes --- georeal/routes.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/georeal/routes.py b/georeal/routes.py index c711545..ecf5194 100644 --- a/georeal/routes.py +++ b/georeal/routes.py @@ -46,7 +46,51 @@ def login(): 'username': user.username, }), 200 else: - return jsonify({'message': 'Invalid email or password'}), 401 + return jsonify({'message': 'Invalid email or password'}), 401 + +@api.route('/users', methods=['GET']) +def get_users(): + users = User.query.all() + users_list = [] + for user in users: + user_data = { + 'id': user.id, + 'username': user.username, + 'email': user.email + } + users_list.append(user_data) + + return jsonify(users_list), 200 + +@api.route('/add_friend', methods=['POST']) +def add_friend(): + data = request.get_json() + username = data.get('username') + friend_username = data.get('friend_username') + + if not username or not friend_username: + return jsonify({'message': 'Missing username or friend_username'}), 400 + + if username == friend_username: + return jsonify({'message': 'Cannot friend yourself'}), 400 + + user = User.query.filter_by(username=username).first() + friend = User.query.filter_by(username=friend_username).first() + + if not user or not friend: + return jsonify({'message': 'User not found'}), 404 + + # Check if the friendship already exists + if user.friends.filter_by(username=friend_username).first() is not None: + return jsonify({'message': 'Already friends'}), 409 + + # Add the friend relationship + user.friends.append(friend) + friend.friends.append(user) # Assuming friendships are bidirectional + + db.session.commit() + + return jsonify({'message': f'{username} and {friend_username} are now friends'}), 200 class Geofence(db.Model): From dafcca1eb6cc009b1fe1f6258a58c3ed161eaba7 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Fri, 5 Apr 2024 17:08:05 -0700 Subject: [PATCH 02/18] search page displays available users --- .../features/friends/user_search_widget.dart | 31 +++++ .../features/friends/view/friend_service.dart | 32 +++++ .../friends/view/friend_view_model.dart | 25 ++++ .../features/friends/view/friends_screen.dart | 83 +++++++++++- .../features/home/screens/home_screen.dart | 126 +++++++++--------- .../features/view_models/user_view_model.dart | 2 +- client/lib/global_variables.dart | 2 +- client/lib/home_router.dart | 14 +- client/lib/main.dart | 2 + client/lib/models/user.dart | 8 +- georeal/routes.py | 4 +- 11 files changed, 248 insertions(+), 81 deletions(-) create mode 100644 client/lib/features/friends/user_search_widget.dart create mode 100644 client/lib/features/friends/view/friend_service.dart create mode 100644 client/lib/features/friends/view/friend_view_model.dart diff --git a/client/lib/features/friends/user_search_widget.dart b/client/lib/features/friends/user_search_widget.dart new file mode 100644 index 0000000..6189617 --- /dev/null +++ b/client/lib/features/friends/user_search_widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class UserSearchWidget extends StatelessWidget { + final String username; + const UserSearchWidget({super.key, required this.username}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.white, + child: Icon( + Icons.person, + color: Colors.black, + ), + ), + const SizedBox(width: 10), + Text( + username, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} diff --git a/client/lib/features/friends/view/friend_service.dart b/client/lib/features/friends/view/friend_service.dart new file mode 100644 index 0000000..da2aa62 --- /dev/null +++ b/client/lib/features/friends/view/friend_service.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:georeal/constants/env_variables.dart'; +import 'package:georeal/models/user.dart'; +import 'package:http/http.dart' as http; + +class UserService { + static Future> getAllUsers() async { + try { + final response = await http.get(Uri.parse('${EnvVariables.uri}/users')); + + if (response.statusCode == 200) { + log('Users fetched: ${response.body}'); + final List usersJson = json.decode(response.body); + log('Users fetched2: $usersJson'); + for (var user in usersJson) { + log('User: $user'); + } + List users = usersJson.map((user) => User.fromMap(user)).toList(); + log('Users fetched3: $users'); + return users; + } else { + throw Exception( + 'Failed to load users with status code: ${response.statusCode}'); + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to load users: $e'); + } + } +} diff --git a/client/lib/features/friends/view/friend_view_model.dart b/client/lib/features/friends/view/friend_view_model.dart new file mode 100644 index 0000000..05e2094 --- /dev/null +++ b/client/lib/features/friends/view/friend_view_model.dart @@ -0,0 +1,25 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/view/friend_service.dart'; +import 'package:georeal/models/user.dart'; + +class FriendViewModel extends ChangeNotifier { + List _users = []; + + List get friends => _users; + + FriendViewModel() { + fetchUsers(); + } + + void fetchUsers() async { + try { + _users = await UserService.getAllUsers(); + log('Users fetched: $_users'); + notifyListeners(); + } catch (e) { + log(e.toString()); + } + } +} diff --git a/client/lib/features/friends/view/friends_screen.dart b/client/lib/features/friends/view/friends_screen.dart index e175bb3..4599315 100644 --- a/client/lib/features/friends/view/friends_screen.dart +++ b/client/lib/features/friends/view/friends_screen.dart @@ -1,12 +1,91 @@ import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/user_search_widget.dart'; +import 'package:georeal/features/friends/view/friend_view_model.dart'; +import 'package:provider/provider.dart'; class FriendsScreen extends StatelessWidget { const FriendsScreen({super.key}); @override Widget build(BuildContext context) { - return const SafeArea( - child: Text("Hello"), + Provider.of(context, listen: false).fetchUsers(); + return SafeArea( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), + child: TextField( + decoration: InputDecoration( + hintText: 'Search by username', + hintStyle: TextStyle( + color: Colors.white + .withOpacity(0.5)), // Adjust opacity as needed + prefixIcon: const Icon(Icons.search, color: Colors.white), + filled: false, + + contentPadding: const EdgeInsets.symmetric(vertical: 0), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + ), + style: const TextStyle(color: Colors.white), // Text color + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 20), + child: TextButton( + onPressed: () { + // Cancel button action + }, + style: TextButton.styleFrom( + backgroundColor: + Colors.black, // Cancel button background color + ), + child: const Text( + 'Search', + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + Expanded( + child: Consumer( + builder: (context, model, child) { + if (model.friends.isEmpty) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + return ListView.builder( + itemCount: model.friends.length, + itemBuilder: (context, index) { + final friend = model.friends[index]; + return UserSearchWidget(username: friend.username); + }, + ); + } + }, + ), + ) + ], + ), ); } } diff --git a/client/lib/features/home/screens/home_screen.dart b/client/lib/features/home/screens/home_screen.dart index af767bd..2a3c932 100644 --- a/client/lib/features/home/screens/home_screen.dart +++ b/client/lib/features/home/screens/home_screen.dart @@ -14,73 +14,71 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - const Column( - children: [ - Expanded( - child: CustomMap(), - ), - ], - ), - Positioned( - top: 10, - left: 10, - child: Consumer( - builder: (context, geoSphereViewModel, child) { - return SafeArea( - child: GestureDetector( - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - Container( - height: 50, - width: 50, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50), - color: const Color.fromARGB(255, 0, 0, 0), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - spreadRadius: 4, - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - ), - Icon( - Icons.camera_alt_outlined, - size: 28, - color: geoSphereViewModel.inGeoSphere - ? Colors.green - : Colors.red, + return Stack( + children: [ + const Column( + children: [ + Expanded( + child: CustomMap(), + ), + ], + ), + Positioned( + top: 10, + left: 10, + child: Consumer( + builder: (context, geoSphereViewModel, child) { + return SafeArea( + child: GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + Container( + height: 50, + width: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: const Color.fromARGB(255, 0, 0, 0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + spreadRadius: 4, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - onTap: () => { - log('${geoSphereViewModel.inGeoSphere}'), - if (geoSphereViewModel.inGeoSphere) - { - showDialog( - context: context, - builder: (context) { - return PhotoPrompt( - geosphere: - geoSphereViewModel.geoSpheres.last); - }) - } - else - {} - }, + ), + Icon( + Icons.camera_alt_outlined, + size: 28, + color: geoSphereViewModel.inGeoSphere + ? Colors.green + : Colors.red, + ), + ], ), - ); - }, - ), + onTap: () => { + log('${geoSphereViewModel.inGeoSphere}'), + if (geoSphereViewModel.inGeoSphere) + { + showDialog( + context: context, + builder: (context) { + return PhotoPrompt( + geosphere: + geoSphereViewModel.geoSpheres.last); + }) + } + else + {} + }, + ), + ); + }, ), - ], - ), + ), + ], ); } } diff --git a/client/lib/features/view_models/user_view_model.dart b/client/lib/features/view_models/user_view_model.dart index 61c4288..f5d1eb6 100644 --- a/client/lib/features/view_models/user_view_model.dart +++ b/client/lib/features/view_models/user_view_model.dart @@ -5,7 +5,7 @@ import '../../models/user.dart'; class UserViewModel extends ChangeNotifier { User _user = User( id: '', - name: '', + username: '', email: '', ); diff --git a/client/lib/global_variables.dart b/client/lib/global_variables.dart index 17f8784..09a7018 100644 --- a/client/lib/global_variables.dart +++ b/client/lib/global_variables.dart @@ -9,7 +9,7 @@ class GlobalVariables { // COLORS static const accentColor = Colors.red; - static const backgroundColor = Color.fromRGBO(12, 12, 12, 1); + static const backgroundColor = Color.fromRGBO(14, 21, 33, 1); static const foregroundColor = Color.fromRGBO(33, 33, 33, 1); static const primaryColor = Color.fromRGBO(2, 11, 30, 1); static const secondaryColor = Color.fromRGBO(63, 114, 175, 1); diff --git a/client/lib/home_router.dart b/client/lib/home_router.dart index 8243aa6..9bf3bc2 100644 --- a/client/lib/home_router.dart +++ b/client/lib/home_router.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/view/friends_screen.dart'; import 'package:georeal/features/geo_sphere/views/geo_spheres_view.dart'; import 'package:georeal/features/home/screens/home_screen.dart'; import 'package:georeal/global_variables.dart'; -import 'features/geo_sphere/widgets/add_geo_sphere_modal.dart'; - /// HomeRouter serves as the main navigation hub of the app class HomeRouter extends StatefulWidget { @@ -30,6 +29,7 @@ class _HomeRouterState extends State { final screens = [ const HomeScreen(), + const FriendsScreen(), const GeoSphereView(), /* FriendsScreen() */ ]; @@ -38,8 +38,9 @@ class _HomeRouterState extends State { Widget build(BuildContext context) { return Scaffold( extendBody: true, + backgroundColor: GlobalVariables.backgroundColor, floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, - floatingActionButton: FloatingActionButton( + /*floatingActionButton: FloatingActionButton( foregroundColor: Colors.black, //Color.fromARGB(255, 3, 179, 0), backgroundColor: GlobalVariables.secondaryColor, shape: const RoundedRectangleBorder( @@ -64,17 +65,18 @@ class _HomeRouterState extends State { ); }, child: const Icon(Icons.add), - ), + ),*/ bottomNavigationBar: BottomAppBar( height: 64, - color: const Color.fromARGB(255, 12, 12, 12), + color: Colors.black, shape: const CircularNotchedRectangle(), child: Container( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ buildBottomNavItem(Icons.map_outlined, 'Map', 0), - buildBottomNavItem(Icons.language, 'Spaces', 1), + buildBottomNavItem(Icons.search, 'Friends', 1), + buildBottomNavItem(Icons.language, 'Spaces', 2), ], ), ), diff --git a/client/lib/main.dart b/client/lib/main.dart index 85df5df..a949cf4 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:georeal/features/auth/view/auth_screen.dart'; +import 'package:georeal/features/friends/view/friend_view_model.dart'; import 'package:georeal/features/gallery/services/gallery_service.dart'; import 'package:georeal/features/gallery/view_model/gallery_view_model.dart'; import 'package:georeal/features/geo_sphere/services/geo_sphere_service.dart'; @@ -40,6 +41,7 @@ Future main() async { ChangeNotifierProvider( create: (context) => GalleryViewModel(), ), + ChangeNotifierProvider(create: (context) => FriendViewModel()), ], child: const MyApp(), ), diff --git a/client/lib/models/user.dart b/client/lib/models/user.dart index beb6245..289d898 100644 --- a/client/lib/models/user.dart +++ b/client/lib/models/user.dart @@ -2,19 +2,19 @@ import 'dart:convert'; class User { String id; - String name; + String username; String email; User({ required this.id, - required this.name, + required this.username, required this.email, }); Map toMap() { return { 'id': id, - 'name': name, + 'username': username, 'email': email, }; } @@ -22,7 +22,7 @@ class User { factory User.fromMap(Map map) { return User( id: map['_id'] ?? '', - name: map['name'] ?? '', + username: map['username'] ?? '', email: map['email'] ?? '', ); } diff --git a/georeal/routes.py b/georeal/routes.py index ecf5194..5c95204 100644 --- a/georeal/routes.py +++ b/georeal/routes.py @@ -80,13 +80,11 @@ def add_friend(): if not user or not friend: return jsonify({'message': 'User not found'}), 404 - # Check if the friendship already exists if user.friends.filter_by(username=friend_username).first() is not None: return jsonify({'message': 'Already friends'}), 409 - # Add the friend relationship user.friends.append(friend) - friend.friends.append(user) # Assuming friendships are bidirectional + friend.friends.append(user) db.session.commit() From b00488731d19b157558a398d2b5e00ce74653a85 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Mon, 29 Apr 2024 19:19:36 -0700 Subject: [PATCH 03/18] User profile search navigation --- .../features/auth/services/auth_service.dart | 2 + .../lib/features/auth/view/auth_screen.dart | 78 ++++++++++++++----- .../auth/widgets/auth_text_field.dart | 16 +++- .../friends/{view => }/friend_service.dart | 20 +++++ .../friends/{view => }/friend_view_model.dart | 14 +++- .../features/friends/user_search_widget.dart | 44 ++++++----- .../features/friends/view/friends_screen.dart | 10 ++- .../friends/view/user_profile_screen.dart | 29 +++++++ client/lib/global_variables.dart | 6 +- client/lib/main.dart | 2 +- georeal/routes.py | 20 ++++- 11 files changed, 191 insertions(+), 50 deletions(-) rename client/lib/features/friends/{view => }/friend_service.dart (62%) rename client/lib/features/friends/{view => }/friend_view_model.dart (55%) create mode 100644 client/lib/features/friends/view/user_profile_screen.dart diff --git a/client/lib/features/auth/services/auth_service.dart b/client/lib/features/auth/services/auth_service.dart index b973644..7b8d452 100644 --- a/client/lib/features/auth/services/auth_service.dart +++ b/client/lib/features/auth/services/auth_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:georeal/constants/env_variables.dart'; import 'package:http/http.dart' as http; @@ -32,6 +33,7 @@ class AuthService { static Future register( String name, String email, String password) async { try { + log('Registering user...', name: 'AuthService'); var uri = Uri.parse('${EnvVariables.uri}/register'); var response = await http.post( uri, diff --git a/client/lib/features/auth/view/auth_screen.dart b/client/lib/features/auth/view/auth_screen.dart index 569859d..4b951a0 100644 --- a/client/lib/features/auth/view/auth_screen.dart +++ b/client/lib/features/auth/view/auth_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:georeal/features/auth/widgets/auth_text_field.dart'; import 'package:georeal/features/view_models/user_view_model.dart'; @@ -31,20 +33,32 @@ class AuthScreen extends StatelessWidget { ); }; return Scaffold( + backgroundColor: GlobalVariables.backgroundColor, body: SafeArea( child: Center( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: EdgeInsets.all(8.0), child: Center( child: Text( - "Welcome to Geo Real", + "Places", style: GlobalVariables.headerStyle, ), ), ), + const Padding( + padding: EdgeInsets.only(bottom: 20.0), + child: Text( + "Caputre the moment, map your memories", + style: GlobalVariables.bodyStyle, + textAlign: TextAlign.center, + ), + ), + + /* const Padding( padding: EdgeInsets.only(top: 20.0), child: Icon( @@ -52,16 +66,26 @@ class AuthScreen extends StatelessWidget { size: 200, ), ), + */ if (viewModel.authMode == Auth.signin) Container( child: Column( children: [ - const Padding( - padding: EdgeInsets.all(8.0), - child: Center( - child: Text( + Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: BoxDecoration( + border: + Border.all(color: Colors.white, width: 2), + ), + child: const Text( "Sign In", - style: GlobalVariables.headerStyle, + textAlign: TextAlign.start, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -99,9 +123,9 @@ class AuthScreen extends StatelessWidget { } }, style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 40), - foregroundColor: Colors.white, - backgroundColor: Colors.black, + minimumSize: const Size(double.infinity, 45), + foregroundColor: Colors.black, + backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(20), @@ -113,7 +137,11 @@ class AuthScreen extends StatelessWidget { ), TextButton( onPressed: viewModel.toggleAuthMode, - child: const Text("Sign up with a new account"), + child: const Text( + "Sign up with a new account", + style: TextStyle( + color: GlobalVariables.secondaryColor), + ), ), ], ), @@ -124,11 +152,9 @@ class AuthScreen extends StatelessWidget { children: [ const Padding( padding: EdgeInsets.all(8.0), - child: Center( - child: Text( - "Sign Up", - style: GlobalVariables.headerStyle, - ), + child: Text( + "Sign Up", + style: GlobalVariables.headerStyle, ), ), Padding( @@ -138,6 +164,13 @@ class AuthScreen extends StatelessWidget { hintText: "Name", isTextHidden: false), ), + Padding( + padding: const EdgeInsets.all(8.0), + child: AuthTextField( + controller: viewModel.nameController, + hintText: "Username", + isTextHidden: false), + ), Padding( padding: const EdgeInsets.all(8.0), child: AuthTextField( @@ -156,8 +189,10 @@ class AuthScreen extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: ElevatedButton( onPressed: () async { + log('Registering'); final success = await viewModel.register( - Provider.of(context)); + Provider.of(context, + listen: false)); if (success) { Navigator.pushReplacement( context, @@ -178,9 +213,9 @@ class AuthScreen extends StatelessWidget { } }, style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 40), - foregroundColor: Colors.white, - backgroundColor: Colors.black, + minimumSize: const Size(double.infinity, 45), + foregroundColor: Colors.black, + backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(20), @@ -193,7 +228,10 @@ class AuthScreen extends StatelessWidget { TextButton( onPressed: viewModel.toggleAuthMode, child: const Text( - "Sign in with an existing account"), + "Sign in with an existing account", + style: TextStyle( + color: GlobalVariables.secondaryColor), + ), ), ], ), diff --git a/client/lib/features/auth/widgets/auth_text_field.dart b/client/lib/features/auth/widgets/auth_text_field.dart index 210b5cc..5b7f040 100644 --- a/client/lib/features/auth/widgets/auth_text_field.dart +++ b/client/lib/features/auth/widgets/auth_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:georeal/global_variables.dart'; class AuthTextField extends StatelessWidget { final TextEditingController controller; @@ -14,20 +15,29 @@ class AuthTextField extends StatelessWidget { Widget build(BuildContext context) { return TextFormField( controller: controller, + style: const TextStyle(color: Colors.white), decoration: InputDecoration( filled: true, - fillColor: Colors.white, + fillColor: GlobalVariables.backgroundColor, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide( + color: GlobalVariables.secondaryColor, + ), + ), hintText: hintText, + hintStyle: + const TextStyle(color: Colors.white), // Adjust opacity as needed border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: const BorderSide( - color: Colors.black, + color: Colors.white, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: const BorderSide( - color: Color.fromARGB(55, 0, 0, 0), + color: Color.fromARGB(255, 255, 255, 255), ))), validator: (value) { if (value == null || value.isEmpty) { diff --git a/client/lib/features/friends/view/friend_service.dart b/client/lib/features/friends/friend_service.dart similarity index 62% rename from client/lib/features/friends/view/friend_service.dart rename to client/lib/features/friends/friend_service.dart index da2aa62..b79f1fa 100644 --- a/client/lib/features/friends/view/friend_service.dart +++ b/client/lib/features/friends/friend_service.dart @@ -29,4 +29,24 @@ class UserService { throw Exception('Failed to load users: $e'); } } + + static Future getUserByUsername(String username) async { + try { + final response = await http.get( + Uri.parse('${EnvVariables.uri}/users/$username'), + ); + + if (response.statusCode == 200) { + log('User fetched: ${response.body}'); + final userJson = json.decode(response.body); + return User.fromMap(userJson); + } else { + throw Exception( + 'Failed to load user with status code: ${response.statusCode}'); + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to load user: $e'); + } + } } diff --git a/client/lib/features/friends/view/friend_view_model.dart b/client/lib/features/friends/friend_view_model.dart similarity index 55% rename from client/lib/features/friends/view/friend_view_model.dart rename to client/lib/features/friends/friend_view_model.dart index 05e2094..7317a2b 100644 --- a/client/lib/features/friends/view/friend_view_model.dart +++ b/client/lib/features/friends/friend_view_model.dart @@ -1,13 +1,15 @@ import 'dart:developer'; import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/view/friend_service.dart'; +import 'package:georeal/features/friends/friend_service.dart'; import 'package:georeal/models/user.dart'; class FriendViewModel extends ChangeNotifier { List _users = []; + User? _searchedUser; List get friends => _users; + User? get searchedUser => _searchedUser; FriendViewModel() { fetchUsers(); @@ -22,4 +24,14 @@ class FriendViewModel extends ChangeNotifier { log(e.toString()); } } + + void getUserByUsername(String username) async { + try { + _searchedUser = await UserService.getUserByUsername(username); + log('Users fetched: $_users'); + notifyListeners(); + } catch (e) { + log(e.toString()); + } + } } diff --git a/client/lib/features/friends/user_search_widget.dart b/client/lib/features/friends/user_search_widget.dart index 6189617..3549746 100644 --- a/client/lib/features/friends/user_search_widget.dart +++ b/client/lib/features/friends/user_search_widget.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/friend_view_model.dart'; +import 'package:provider/provider.dart'; class UserSearchWidget extends StatelessWidget { final String username; @@ -6,26 +8,32 @@ class UserSearchWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const CircleAvatar( - radius: 20, - backgroundColor: Colors.white, - child: Icon( - Icons.person, - color: Colors.black, + return GestureDetector( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.white, + child: Icon( + Icons.person, + color: Colors.black, + ), ), - ), - const SizedBox(width: 10), - Text( - username, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ], + const SizedBox(width: 10), + Text( + username, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ], + ), ), + onTap: () { + var viewModel = Provider.of(context, listen: false); + viewModel.getUserByUsername(username); + }, ); } } diff --git a/client/lib/features/friends/view/friends_screen.dart b/client/lib/features/friends/view/friends_screen.dart index 4599315..198bcb3 100644 --- a/client/lib/features/friends/view/friends_screen.dart +++ b/client/lib/features/friends/view/friends_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/friend_view_model.dart'; import 'package:georeal/features/friends/user_search_widget.dart'; -import 'package:georeal/features/friends/view/friend_view_model.dart'; +import 'package:georeal/features/friends/view/user_profile_screen.dart'; import 'package:provider/provider.dart'; class FriendsScreen extends StatelessWidget { @@ -68,6 +69,13 @@ class FriendsScreen extends StatelessWidget { Expanded( child: Consumer( builder: (context, model, child) { + if (model.searchedUser != null) { + Future.microtask(() => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + UserProfileScreen(user: model.searchedUser!)))); + } if (model.friends.isEmpty) { return const Center( child: CircularProgressIndicator(), diff --git a/client/lib/features/friends/view/user_profile_screen.dart b/client/lib/features/friends/view/user_profile_screen.dart new file mode 100644 index 0000000..2df639a --- /dev/null +++ b/client/lib/features/friends/view/user_profile_screen.dart @@ -0,0 +1,29 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:flutter/material.dart'; +import 'package:georeal/models/user.dart'; + +class UserProfileScreen extends StatelessWidget { + User user; + UserProfileScreen({ + super.key, + required this.user, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back)), + ], + ), + Text(user.username), + ], + ), + ); + } +} diff --git a/client/lib/global_variables.dart b/client/lib/global_variables.dart index 09a7018..25b4ed2 100644 --- a/client/lib/global_variables.dart +++ b/client/lib/global_variables.dart @@ -12,13 +12,13 @@ class GlobalVariables { static const backgroundColor = Color.fromRGBO(14, 21, 33, 1); static const foregroundColor = Color.fromRGBO(33, 33, 33, 1); static const primaryColor = Color.fromRGBO(2, 11, 30, 1); - static const secondaryColor = Color.fromRGBO(63, 114, 175, 1); + static const secondaryColor = Color.fromRGBO(73, 140, 221, 1); // FONTS static const headerStyle = TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: Colors.black, + color: Colors.white, ); static const headerStyleRegular = TextStyle( fontSize: 24, @@ -28,7 +28,7 @@ class GlobalVariables { static const bodyStyle = TextStyle( fontSize: 16, fontWeight: FontWeight.bold, - color: Colors.black, + color: Colors.white, ); static const bodyStyleRegular = TextStyle( fontSize: 16, diff --git a/client/lib/main.dart b/client/lib/main.dart index a949cf4..4acaca1 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:georeal/features/auth/view/auth_screen.dart'; -import 'package:georeal/features/friends/view/friend_view_model.dart'; +import 'package:georeal/features/friends/friend_view_model.dart'; import 'package:georeal/features/gallery/services/gallery_service.dart'; import 'package:georeal/features/gallery/view_model/gallery_view_model.dart'; import 'package:georeal/features/geo_sphere/services/geo_sphere_service.dart'; diff --git a/georeal/routes.py b/georeal/routes.py index 5c95204..e4d1e84 100644 --- a/georeal/routes.py +++ b/georeal/routes.py @@ -17,15 +17,16 @@ def register(): username = data.get('username') email = data.get('email') plain_password = data.get('password') - + print(username, email, plain_password) # Hash password pw_hash = bcrypt.generate_password_hash(plain_password).decode('utf-8') - + print("ok") # Check if the user already exists by email or username user = User.query.filter((User.username == username) | (User.email == email)).first() if user: + print("User already exists") return jsonify({'message': 'User already exists'}), 400 - + new_user = User(username=username, email=email, password_hash=pw_hash) db.session.add(new_user) db.session.commit() @@ -62,6 +63,19 @@ def get_users(): return jsonify(users_list), 200 +@api.route('/users/', methods=['GET']) +def get_user_by_username(username): + user = User.query.filter_by(username=username).first() + if not user: + return jsonify({'error': 'User not found'}), 404 + + user_data = { + 'id': user.id, + 'username': user.username, + 'email': user.email + } + return jsonify(user_data), 200 + @api.route('/add_friend', methods=['POST']) def add_friend(): data = request.get_json() From 28655c93f97c6edee3921888c43d8d1866e0f4e1 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Wed, 1 May 2024 20:04:35 -0700 Subject: [PATCH 04/18] friends search and basic profile page --- client/ios/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../{ => services}/friend_service.dart | 0 .../features/friends/user_search_widget.dart | 39 --------------- .../features/friends/view/friends_screen.dart | 19 ++++--- .../friends/view/user_profile_screen.dart | 35 +++++++++---- .../{ => view_model}/friend_view_model.dart | 2 +- .../friends/widgets/user_search_widget.dart | 50 +++++++++++++++++++ client/lib/main.dart | 2 +- georeal/models.py | 10 ++++ georeal/routes/auth.py | 45 +++++++++++++++++ georeal/routes/friends.py | 41 +++++++++++++++ georeal/{ => routes}/routes.py | 32 ++---------- georeal/server.py | 6 ++- 14 files changed, 192 insertions(+), 93 deletions(-) rename client/lib/features/friends/{ => services}/friend_service.dart (100%) delete mode 100644 client/lib/features/friends/user_search_widget.dart rename client/lib/features/friends/{ => view_model}/friend_view_model.dart (91%) create mode 100644 client/lib/features/friends/widgets/user_search_widget.dart create mode 100644 georeal/routes/auth.py create mode 100644 georeal/routes/friends.py rename georeal/{ => routes}/routes.py (86%) diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj index 19792ec..b04a54e 100644 --- a/client/ios/Runner.xcodeproj/project.pbxproj +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -218,7 +218,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { diff --git a/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 87131a0..8e3ca5d 100644 --- a/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ (context, listen: false); - viewModel.getUserByUsername(username); - }, - ); - } -} diff --git a/client/lib/features/friends/view/friends_screen.dart b/client/lib/features/friends/view/friends_screen.dart index 198bcb3..a958315 100644 --- a/client/lib/features/friends/view/friends_screen.dart +++ b/client/lib/features/friends/view/friends_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/friend_view_model.dart'; -import 'package:georeal/features/friends/user_search_widget.dart'; import 'package:georeal/features/friends/view/user_profile_screen.dart'; +import 'package:georeal/features/friends/view_model/friend_view_model.dart'; +import 'package:georeal/features/friends/widgets/user_search_widget.dart'; import 'package:provider/provider.dart'; class FriendsScreen extends StatelessWidget { @@ -69,13 +69,6 @@ class FriendsScreen extends StatelessWidget { Expanded( child: Consumer( builder: (context, model, child) { - if (model.searchedUser != null) { - Future.microtask(() => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - UserProfileScreen(user: model.searchedUser!)))); - } if (model.friends.isEmpty) { return const Center( child: CircularProgressIndicator(), @@ -85,7 +78,13 @@ class FriendsScreen extends StatelessWidget { itemCount: model.friends.length, itemBuilder: (context, index) { final friend = model.friends[index]; - return UserSearchWidget(username: friend.username); + return UserSearchWidget( + username: friend.username, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => UserProfileScreen( + user: model.searchedUser!)))); }, ); } diff --git a/client/lib/features/friends/view/user_profile_screen.dart b/client/lib/features/friends/view/user_profile_screen.dart index 2df639a..e9e8414 100644 --- a/client/lib/features/friends/view/user_profile_screen.dart +++ b/client/lib/features/friends/view/user_profile_screen.dart @@ -1,5 +1,6 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:flutter/material.dart'; +import 'package:georeal/global_variables.dart'; import 'package:georeal/models/user.dart'; class UserProfileScreen extends StatelessWidget { @@ -12,17 +13,31 @@ class UserProfileScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Column( - children: [ - Row( - children: [ - IconButton( + backgroundColor: GlobalVariables.backgroundColor, + body: SafeArea( + child: Column( + children: [ + Row( + children: [ + IconButton( onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back)), - ], - ), - Text(user.username), - ], + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + ], + ), + Text( + user.username, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold), + ), + ElevatedButton( + onPressed: () {}, + child: const Text("Add Friend"), + ), + ], + ), ), ); } diff --git a/client/lib/features/friends/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart similarity index 91% rename from client/lib/features/friends/friend_view_model.dart rename to client/lib/features/friends/view_model/friend_view_model.dart index 7317a2b..941a64e 100644 --- a/client/lib/features/friends/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -1,7 +1,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/friend_service.dart'; +import 'package:georeal/features/friends/services/friend_service.dart'; import 'package:georeal/models/user.dart'; class FriendViewModel extends ChangeNotifier { diff --git a/client/lib/features/friends/widgets/user_search_widget.dart b/client/lib/features/friends/widgets/user_search_widget.dart new file mode 100644 index 0000000..60ebb57 --- /dev/null +++ b/client/lib/features/friends/widgets/user_search_widget.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/view_model/friend_view_model.dart'; +import 'package:georeal/global_variables.dart'; +import 'package:provider/provider.dart'; + +class UserSearchWidget extends StatelessWidget { + final String username; + final VoidCallback onTap; + const UserSearchWidget( + {super.key, required this.username, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: MediaQuery.of(context).size.width - 40, + decoration: BoxDecoration( + border: + Border.all(color: GlobalVariables.backgroundColor, width: 1), + ), + child: Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.white, + child: Icon( + Icons.person, + color: Colors.black, + ), + ), + const SizedBox(width: 10), + Text( + username, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + onTap: () { + var viewModel = Provider.of(context, listen: false); + viewModel.getUserByUsername(username); + onTap(); + }, + ); + } +} diff --git a/client/lib/main.dart b/client/lib/main.dart index 4acaca1..d693a25 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:georeal/features/auth/view/auth_screen.dart'; -import 'package:georeal/features/friends/friend_view_model.dart'; +import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/features/gallery/services/gallery_service.dart'; import 'package:georeal/features/gallery/view_model/gallery_view_model.dart'; import 'package:georeal/features/geo_sphere/services/geo_sphere_service.dart'; diff --git a/georeal/models.py b/georeal/models.py index 5fc9b19..422ecb2 100644 --- a/georeal/models.py +++ b/georeal/models.py @@ -41,3 +41,13 @@ class Location(db.Model): latitude = db.Column(db.Float, nullable=False) longitude = db.Column(db.Float, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True, nullable=False) + +class FriendRequest(db.Model): + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + receiver_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + accepted = db.Column(db.Boolean, default=False, nullable=False) + + sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_requests') + receiver = db.relationship('User', foreign_keys=[receiver_id], backref='received_requests') + diff --git a/georeal/routes/auth.py b/georeal/routes/auth.py new file mode 100644 index 0000000..99edde3 --- /dev/null +++ b/georeal/routes/auth.py @@ -0,0 +1,45 @@ +from flask import Blueprint, jsonify, request + +from ..extensions import bcrypt +from ..models import User, db + +auth = Blueprint('auth', __name__) + + +@auth.route('/register', methods=['POST']) +def register(): + data = request.get_json() + username = data.get('username') + email = data.get('email') + plain_password = data.get('password') + print(username, email, plain_password) + # Hash password + pw_hash = bcrypt.generate_password_hash(plain_password).decode('utf-8') + print("ok") + # Check if the user already exists by email or username + user = User.query.filter((User.username == username) | (User.email == email)).first() + if user: + print("User already exists") + return jsonify({'message': 'User already exists'}), 400 + + new_user = User(username=username, email=email, password_hash=pw_hash) + db.session.add(new_user) + db.session.commit() + + return jsonify({'message': 'User created successfully'}), 201 + +@auth.route('/login', methods=['POST']) +def login(): + data = request.get_json() + email = data.get('email') + plain_password = data.get('password') + + user = User.query.filter_by(email=email).first() + if user and bcrypt.check_password_hash(user.password_hash, plain_password): + # Success + return jsonify({ + 'message': 'Login successful', + 'username': user.username, + }), 200 + else: + return jsonify({'message': 'Invalid email or password'}), 401 diff --git a/georeal/routes/friends.py b/georeal/routes/friends.py new file mode 100644 index 0000000..662e0e6 --- /dev/null +++ b/georeal/routes/friends.py @@ -0,0 +1,41 @@ +from flask import Blueprint, jsonify, request + +from georeal.models import FriendRequest, User, db + +friends = Blueprint('friends', __name__) + +@friends.route('/friend_request', methods=['POST']) +def send_friend_request(): + data = request.get_json() + username = data.get('username') + friend_username = data.get('friend_username') + + if not username or not friend_username: + return jsonify({'message': 'Missing username or friend_username'}), 400 + + if username == friend_username: + return jsonify({'message': 'Cannot send a friend request to yourself'}), 400 + + user = User.query.filter_by(username=username).first() + friend = User.query.filter_by(username=friend_username).first() + + if not user or not friend: + return jsonify({'message': 'User not found'}), 404 + + # Check if a friend request already exists + existing_request = FriendRequest.query.filter( + ((FriendRequest.sender == user) & (FriendRequest.receiver == friend)) | + ((FriendRequest.sender == friend) & (FriendRequest.receiver == user)) + ).first() + + if existing_request: + return jsonify({'message': 'Friend request already sent or received'}), 409 + + # Create a new friend request + new_request = FriendRequest(sender=user, receiver=friend) + db.session.add(new_request) + db.session.commit() + + return jsonify({'message': f'Friend request sent from {username} to {friend_username}'}), 200 + + diff --git a/georeal/routes.py b/georeal/routes/routes.py similarity index 86% rename from georeal/routes.py rename to georeal/routes/routes.py index e4d1e84..32ee088 100644 --- a/georeal/routes.py +++ b/georeal/routes/routes.py @@ -4,9 +4,9 @@ from geojson import Feature, Polygon from werkzeug.utils import secure_filename -from .extensions import bcrypt -from .models import User, db -from .utils import GIT_COMMIT_HASH, allowed_file, logger +from ..extensions import bcrypt +from ..models import User, db +from ..utils import GIT_COMMIT_HASH, allowed_file, logger api = Blueprint('api', __name__) @@ -76,33 +76,7 @@ def get_user_by_username(username): } return jsonify(user_data), 200 -@api.route('/add_friend', methods=['POST']) -def add_friend(): - data = request.get_json() - username = data.get('username') - friend_username = data.get('friend_username') - - if not username or not friend_username: - return jsonify({'message': 'Missing username or friend_username'}), 400 - - if username == friend_username: - return jsonify({'message': 'Cannot friend yourself'}), 400 - - user = User.query.filter_by(username=username).first() - friend = User.query.filter_by(username=friend_username).first() - - if not user or not friend: - return jsonify({'message': 'User not found'}), 404 - - if user.friends.filter_by(username=friend_username).first() is not None: - return jsonify({'message': 'Already friends'}), 409 - - user.friends.append(friend) - friend.friends.append(user) - - db.session.commit() - return jsonify({'message': f'{username} and {friend_username} are now friends'}), 200 class Geofence(db.Model): diff --git a/georeal/server.py b/georeal/server.py index 287568e..5fac3ed 100644 --- a/georeal/server.py +++ b/georeal/server.py @@ -52,9 +52,13 @@ # ) -from .routes import api +from .routes.auth import auth +from .routes.friends import friends +from .routes.routes import api app.register_blueprint(api) +app.register_blueprint(friends) +app.register_blueprint(auth) @app.cli.command("bootstrap") def bootstrap_table() -> None: From 1ab90b631caa63ac9e6c324b1e32f86837c75592 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Wed, 1 May 2024 20:25:42 -0700 Subject: [PATCH 05/18] feat: friend request --- .../lib/features/auth/view/auth_screen.dart | 2 +- .../friends/services/friend_service.dart | 25 +++++++++++++++++++ .../friends/view/user_profile_screen.dart | 12 ++++++++- .../friends/view_model/friend_view_model.dart | 10 ++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/client/lib/features/auth/view/auth_screen.dart b/client/lib/features/auth/view/auth_screen.dart index 4b951a0..a5f6c02 100644 --- a/client/lib/features/auth/view/auth_screen.dart +++ b/client/lib/features/auth/view/auth_screen.dart @@ -22,7 +22,7 @@ class AuthScreen extends StatelessWidget { builder: (context, viewModel, child) { viewModel.onAuthSuccess = () { Provider.of(context, listen: false).setUser({ - 'name': viewModel.nameController.text, + 'username': viewModel.nameController.text, 'email': viewModel.emailController.text, }); Navigator.pushReplacement( diff --git a/client/lib/features/friends/services/friend_service.dart b/client/lib/features/friends/services/friend_service.dart index b79f1fa..f492777 100644 --- a/client/lib/features/friends/services/friend_service.dart +++ b/client/lib/features/friends/services/friend_service.dart @@ -49,4 +49,29 @@ class UserService { throw Exception('Failed to load user: $e'); } } + + static Future sendFriendRequest( + String senderUsername, String receiverUsername) async { + try { + var body = json.encode( + {'username': senderUsername, 'friend_username': receiverUsername}); + log('Sending friend request with body: $body'); + http.Response response = await http.post( + Uri.parse('${EnvVariables.uri}/friend_request'), + headers: {'Content-Type': 'application/json'}, + body: body, + ); + + if (response.statusCode == 200) { + log('Friend request sent: ${response.body}'); + return 'Friend request sent successfully'; + } else { + log('Failed to send friend request with status code: ${response.statusCode}'); + return 'Failed to send friend request'; + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to send friend request: $e'); + } + } } diff --git a/client/lib/features/friends/view/user_profile_screen.dart b/client/lib/features/friends/view/user_profile_screen.dart index e9e8414..a647bf0 100644 --- a/client/lib/features/friends/view/user_profile_screen.dart +++ b/client/lib/features/friends/view/user_profile_screen.dart @@ -1,7 +1,10 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:flutter/material.dart'; +import 'package:georeal/features/friends/view_model/friend_view_model.dart'; +import 'package:georeal/features/view_models/user_view_model.dart'; import 'package:georeal/global_variables.dart'; import 'package:georeal/models/user.dart'; +import 'package:provider/provider.dart'; class UserProfileScreen extends StatelessWidget { User user; @@ -33,7 +36,14 @@ class UserProfileScreen extends StatelessWidget { fontWeight: FontWeight.bold), ), ElevatedButton( - onPressed: () {}, + onPressed: () { + final username = + Provider.of(context, listen: false) + .user + .username; + Provider.of(context, listen: false) + .sendFriendRequest(username, user.username); + }, child: const Text("Add Friend"), ), ], diff --git a/client/lib/features/friends/view_model/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart index 941a64e..4a0f42d 100644 --- a/client/lib/features/friends/view_model/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -34,4 +34,14 @@ class FriendViewModel extends ChangeNotifier { log(e.toString()); } } + + void sendFriendRequest(String userId, String username) async { + log("test"); + try { + await UserService.sendFriendRequest(userId, username); + log('Friend request sent to $username'); + } catch (e) { + log(e.toString()); + } + } } From f8e410ecd7a99d43334a8fac43d871dc3153e7a8 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Thu, 2 May 2024 19:21:56 -0700 Subject: [PATCH 06/18] new map navbar --- .../features/auth/services/auth_service.dart | 3 +- .../features/home/screens/home_screen.dart | 121 +++++++-------- client/lib/features/home/widgets/map.dart | 7 +- .../lib/features/home/widgets/map_navbar.dart | 145 ++++++++++++++++++ client/macos/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- georeal/routes/friends.py | 2 +- 7 files changed, 214 insertions(+), 68 deletions(-) create mode 100644 client/lib/features/home/widgets/map_navbar.dart diff --git a/client/lib/features/auth/services/auth_service.dart b/client/lib/features/auth/services/auth_service.dart index 7b8d452..88601d4 100644 --- a/client/lib/features/auth/services/auth_service.dart +++ b/client/lib/features/auth/services/auth_service.dart @@ -48,7 +48,8 @@ class AuthService { ); if (response.statusCode == 200) { - return json.decode(response.body); // Return the user object (or token + return json.decode(response.body); + } else if (response.statusCode == 400) { } else { throw Exception('Failed to register. Please try again'); } diff --git a/client/lib/features/home/screens/home_screen.dart b/client/lib/features/home/screens/home_screen.dart index 2a3c932..c43ac0e 100644 --- a/client/lib/features/home/screens/home_screen.dart +++ b/client/lib/features/home/screens/home_screen.dart @@ -1,84 +1,79 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; -import 'package:georeal/features/gallery/widgets/photo_prompt.dart'; import 'package:georeal/features/geo_sphere/view_model/geo_sphere_view_model.dart'; +import 'package:georeal/features/geo_sphere/widgets/geo_sphere_widget.dart'; +import 'package:georeal/features/home/widgets/map_navbar.dart'; +import 'package:georeal/global_variables.dart'; import 'package:provider/provider.dart'; import '../widgets/map.dart'; /// HomeScreen is the main screen of the app which contains the Map - -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool isMapPressed = true; @override Widget build(BuildContext context) { - return Stack( - children: [ - const Column( - children: [ - Expanded( - child: CustomMap(), - ), - ], - ), - Positioned( - top: 10, - left: 10, - child: Consumer( - builder: (context, geoSphereViewModel, child) { - return SafeArea( - child: GestureDetector( - child: Stack( - alignment: AlignmentDirectional.center, + return Scaffold( + body: Stack( + children: [ + isMapPressed + ? const CustomMap() + : Container( + padding: const EdgeInsets.only(top: 75), + color: GlobalVariables.backgroundColor, + child: Column( children: [ - Container( - height: 50, - width: 50, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50), - color: const Color.fromARGB(255, 0, 0, 0), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - spreadRadius: 4, - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], + Expanded( + child: Consumer( + builder: (context, geoSphereViewModel, child) { + return ListView.builder( + itemCount: geoSphereViewModel.geoSpheres.length, + itemBuilder: (context, index) { + var geoSphere = + geoSphereViewModel.geoSpheres[index]; + return Padding( + padding: + const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: GeoSphereWidget(geoSphere: geoSphere), + ); + }, + ); + }, ), ), - Icon( - Icons.camera_alt_outlined, - size: 28, - color: geoSphereViewModel.inGeoSphere - ? Colors.green - : Colors.red, - ), ], ), - onTap: () => { - log('${geoSphereViewModel.inGeoSphere}'), - if (geoSphereViewModel.inGeoSphere) - { - showDialog( - context: context, - builder: (context) { - return PhotoPrompt( - geosphere: - geoSphereViewModel.geoSpheres.last); - }) - } - else - {} - }, ), - ); - }, + SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Consumer( + builder: (context, geoSphereViewModel, child) { + return MapNavbar( + model: geoSphereViewModel, + onMapPressed: () { + setState(() { + isMapPressed = true; + }); + }, + onFeedPressed: () { + setState(() { + isMapPressed = false; + }); + }, + ); + }, + ), + ), ), - ), - ], + ], + ), ); } } diff --git a/client/lib/features/home/widgets/map.dart b/client/lib/features/home/widgets/map.dart index d0fb5c6..9f616a5 100644 --- a/client/lib/features/home/widgets/map.dart +++ b/client/lib/features/home/widgets/map.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:georeal/global_variables.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:location/location.dart'; import 'package:provider/provider.dart'; @@ -92,9 +93,13 @@ class _CustomMapState extends State { ).toSet(); return Scaffold( + backgroundColor: GlobalVariables.backgroundColor, resizeToAvoidBottomInset: false, body: currentLocation == null - ? const Center(child: CircularProgressIndicator()) + ? const Center( + child: CircularProgressIndicator( + color: GlobalVariables.secondaryColor, + )) : GoogleMap( onMapCreated: (GoogleMapController controller) { _controller = controller; diff --git a/client/lib/features/home/widgets/map_navbar.dart b/client/lib/features/home/widgets/map_navbar.dart new file mode 100644 index 0000000..79370fd --- /dev/null +++ b/client/lib/features/home/widgets/map_navbar.dart @@ -0,0 +1,145 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:georeal/features/gallery/widgets/photo_prompt.dart'; +import 'package:georeal/features/geo_sphere/view_model/geo_sphere_view_model.dart'; + +class MapNavbar extends StatelessWidget { + final GeoSphereViewModel model; + final VoidCallback onMapPressed; + final VoidCallback onFeedPressed; + const MapNavbar( + {super.key, + required this.model, + required this.onMapPressed, + required this.onFeedPressed}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + Container( + height: 50, + width: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: Colors.white.withOpacity(0.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + spreadRadius: 4, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + ), + Icon( + Icons.camera_alt_outlined, + size: 28, + color: model.inGeoSphere + ? Colors.green + : const Color.fromARGB(255, 255, 255, 255), + ), + ], + ), + onTap: () => { + log('${model.inGeoSphere}'), + if (model.inGeoSphere) + { + showDialog( + context: context, + builder: (context) { + return PhotoPrompt(geosphere: model.geoSpheres.last); + }) + } + else + {} + }, + ), + Row( + children: [ + TextButton( + onPressed: onMapPressed, + child: const Text( + "Map", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20), + ), + ), + Container( + margin: const EdgeInsets.only(left: 10, right: 10), + height: MediaQuery.of(context).size.height * 0.05, + width: 1, + decoration: const BoxDecoration(color: Colors.white), + ), + TextButton( + onPressed: onFeedPressed, + child: const Text( + "Feed", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + ], + ), + GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + Container( + height: 50, + width: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: Colors.white.withOpacity(0.4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + spreadRadius: 4, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + ), + Icon( + Icons.add, + size: 28, + color: model.inGeoSphere + ? Colors.green + : const Color.fromARGB(255, 255, 255, 255), + ), + ], + ), + onTap: () => { + log('${model.inGeoSphere}'), + if (model.inGeoSphere) + { + showDialog( + context: context, + builder: (context) { + return PhotoPrompt(geosphere: model.geoSpheres.last); + }) + } + else + {} + }, + ), + ], + ), + ); + } +} diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index 6c8da36..697b26a 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -259,7 +259,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/client/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/client/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 41cd340..246f6d1 100644 --- a/client/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/client/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Date: Thu, 2 May 2024 20:21:43 -0700 Subject: [PATCH 07/18] Profile header --- client/lib/common/profile_photo.dart | 18 ++++ .../friends/widgets/user_search_widget.dart | 8 +- .../profile/views/profile_screen.dart | 84 +++++++++++++++++ client/lib/home_router.dart | 6 +- client/lib/main.dart | 5 +- client/lib/util/theme.dart | 94 +++++++++++++++++++ 6 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 client/lib/common/profile_photo.dart create mode 100644 client/lib/features/profile/views/profile_screen.dart create mode 100644 client/lib/util/theme.dart diff --git a/client/lib/common/profile_photo.dart b/client/lib/common/profile_photo.dart new file mode 100644 index 0000000..7b276cc --- /dev/null +++ b/client/lib/common/profile_photo.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class ProfilePhoto extends StatelessWidget { + final double radius; + const ProfilePhoto({super.key, required this.radius}); + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.white, + child: const Icon( + Icons.person, + color: Colors.black, + ), + ); + } +} diff --git a/client/lib/features/friends/widgets/user_search_widget.dart b/client/lib/features/friends/widgets/user_search_widget.dart index 60ebb57..3d3656f 100644 --- a/client/lib/features/friends/widgets/user_search_widget.dart +++ b/client/lib/features/friends/widgets/user_search_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:georeal/common/profile_photo.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/global_variables.dart'; import 'package:provider/provider.dart'; @@ -22,13 +23,8 @@ class UserSearchWidget extends StatelessWidget { ), child: Row( children: [ - const CircleAvatar( + const ProfilePhoto( radius: 20, - backgroundColor: Colors.white, - child: Icon( - Icons.person, - color: Colors.black, - ), ), const SizedBox(width: 10), Text( diff --git a/client/lib/features/profile/views/profile_screen.dart b/client/lib/features/profile/views/profile_screen.dart new file mode 100644 index 0000000..fab372d --- /dev/null +++ b/client/lib/features/profile/views/profile_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:georeal/common/profile_photo.dart'; +import 'package:georeal/global_variables.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: GlobalVariables.backgroundColor, + body: SafeArea( + child: Column( + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ProfilePhoto(radius: 40), + Column( + children: [ + Text( + "147", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Memories"), + ], + ), + Column( + children: [ + Text( + "7", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Spaces"), + ], + ), + Column( + children: [ + Text( + "26", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Friends"), + ], + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + children: [ + Text( + "First Name", + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + ElevatedButton( + onPressed: () {}, + child: const Text("Edit profile"), + ), + ], + ), + ), + const Divider(), + ], + ), + ), + ); + } +} diff --git a/client/lib/home_router.dart b/client/lib/home_router.dart index 9bf3bc2..e676447 100644 --- a/client/lib/home_router.dart +++ b/client/lib/home_router.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:georeal/features/friends/view/friends_screen.dart'; -import 'package:georeal/features/geo_sphere/views/geo_spheres_view.dart'; import 'package:georeal/features/home/screens/home_screen.dart'; +import 'package:georeal/features/profile/views/profile_screen.dart'; import 'package:georeal/global_variables.dart'; /// HomeRouter serves as the main navigation hub of the app @@ -30,7 +30,7 @@ class _HomeRouterState extends State { final screens = [ const HomeScreen(), const FriendsScreen(), - const GeoSphereView(), /* FriendsScreen() */ + const ProfileScreen(), /* FriendsScreen() */ ]; @override @@ -76,7 +76,7 @@ class _HomeRouterState extends State { children: [ buildBottomNavItem(Icons.map_outlined, 'Map', 0), buildBottomNavItem(Icons.search, 'Friends', 1), - buildBottomNavItem(Icons.language, 'Spaces', 2), + buildBottomNavItem(Icons.person, 'Profile', 2), ], ), ), diff --git a/client/lib/main.dart b/client/lib/main.dart index d693a25..9aae843 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -6,6 +6,7 @@ import 'package:georeal/features/gallery/services/gallery_service.dart'; import 'package:georeal/features/gallery/view_model/gallery_view_model.dart'; import 'package:georeal/features/geo_sphere/services/geo_sphere_service.dart'; import 'package:georeal/features/view_models/user_view_model.dart'; +import 'package:georeal/util/theme.dart'; import 'package:provider/provider.dart'; import 'features/geo_sphere/view_model/geo_sphere_view_model.dart'; @@ -54,9 +55,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - theme: ThemeData( - primarySwatch: Colors.blue, - ), + theme: theme, debugShowCheckedModeBanner: false, title: 'Flutter Demo', home: const AuthScreen(), diff --git a/client/lib/util/theme.dart b/client/lib/util/theme.dart new file mode 100644 index 0000000..ee88d84 --- /dev/null +++ b/client/lib/util/theme.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +class EdeeColors { + static const Color edeePurple = Color.fromRGBO(97, 52, 131, 1); + static const Color edeeYellow = Color.fromRGBO(255, 216, 119, 1); + static const backgroundColor = Color.fromRGBO(14, 21, 33, 1); + static const Color edeeOrange = Color.fromRGBO(251, 176, 59, 1); + static const Color edeeBlack = Color.fromRGBO(26, 26, 26, 1); + static const Color edeePurple10Percent = Color.fromRGBO(97, 52, 131, 0.1); +} + +extension TextStyleHelpers on TextStyle { + TextStyle weight(FontWeight w) { + return copyWith(fontWeight: w); + } + + TextStyle color(Color c) { + return copyWith(color: c); + } +} + +class TextStyles { + static const TextStyle heading4Small = TextStyle( + fontSize: 12, + color: Colors.white, + ); + static const TextStyle heading3Medium = TextStyle( + fontSize: 16, + color: Colors.white, + ); + static const TextStyle heading2Large = TextStyle( + fontSize: 24, + color: Colors.white, + ); + static const TextStyle labelSmall = TextStyle( + fontSize: 8, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); + static const TextStyle labelMedium = TextStyle( + fontSize: 10, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); + static const TextStyle labelLarge = TextStyle( + fontSize: 12, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); + static const TextStyle bodySmall = TextStyle( + fontSize: 8, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); + static const TextStyle bodyMedium = TextStyle( + fontSize: 10, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); + static const TextStyle bodyLarge = TextStyle( + fontSize: 12, + color: Colors.white, + fontFamily: 'Alberta Sans', + ); +} + +ThemeData theme = ThemeData( + brightness: Brightness.light, + primaryColor: EdeeColors.edeePurple, + scaffoldBackgroundColor: EdeeColors.backgroundColor, + cardColor: EdeeColors.edeeYellow, + hintColor: EdeeColors.edeePurple10Percent, + fontFamily: 'Kreadon', + textTheme: const TextTheme( + titleLarge: TextStyles.heading2Large, + titleMedium: TextStyles.heading3Medium, + titleSmall: TextStyles.heading4Small, + bodyMedium: TextStyles.bodyMedium, + bodySmall: TextStyles.bodySmall, + bodyLarge: TextStyles.bodyLarge, + labelLarge: TextStyles.labelLarge, + labelMedium: TextStyles.labelMedium, + labelSmall: TextStyles.labelSmall, + ), + + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderSide: BorderSide(color: EdeeColors.edeeBlack), + ), + labelStyle: TextStyle(color: EdeeColors.edeePurple), + hintStyle: TextStyle(color: Colors.black38), + ), + // Define other theme properties as needed. +); From c2da2b7c786dfddf61b6994a53444a4bb81858b1 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 13:29:47 -0700 Subject: [PATCH 08/18] added event listeners for user data aggregations (posts, friends, spaces) --- georeal/event_listeners.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 georeal/event_listeners.py diff --git a/georeal/event_listeners.py b/georeal/event_listeners.py new file mode 100644 index 0000000..6409bf5 --- /dev/null +++ b/georeal/event_listeners.py @@ -0,0 +1,41 @@ +from Flask import event +# event_listeners.py +from models import Place, Post, User, db, friends +from sqlalchemy import event + + +def increment_count(mapper, connection, target): + if isinstance(target, Place): + user = db.session.query(User).get(target.creator_id) + user.num_places += 1 + elif isinstance(target, Post): + user = db.session.query(User).get(target.author_id) + user.num_posts += 1 + db.session.commit() + +def decrement_count(mapper, connection, target): + if isinstance(target, Place): + user = db.session.query(User).get(target.creator_id) + user.num_places -= 1 + elif isinstance(target, Post): + user = db.session.query(User).get(target.author_id) + user.num_posts -= 1 + db.session.commit() + +# Register event listeners +event.listen(Place, 'after_insert', increment_count) +event.listen(Place, 'after_delete', decrement_count) +event.listen(Post, 'after_insert', increment_count) +event.listen(Post, 'after_delete', decrement_count) + +@event.listens_for(friends, 'after_insert') +def increment_friends(mapper, connection, target): + db.session.query(User).filter_by(id=target.friend_id).update({'num_friends': User.num_friends + 1}) + db.session.query(User).filter_by(id=target.friended_id).update({'num_friends': User.num_friends + 1}) + db.session.commit() + +@event.listens_for(friends, 'after_delete') +def decrement_friends(mapper, connection, target): + db.session.query(User).filter_by(id=target.friend_id).update({'num_friends': User.num_friends - 1}) + db.session.query(User).filter_by(id=target.friended_id).update({'num_friends': User.num_friends - 1}) + db.session.commit() From faeff26ed95e117659ca01d82ce1cb696d7ff29d Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 14:27:26 -0700 Subject: [PATCH 09/18] Refactored routes to itegrate aggregations --- georeal/models.py | 3 ++ georeal/routes/users.py | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 georeal/routes/users.py diff --git a/georeal/models.py b/georeal/models.py index 422ecb2..e937f69 100644 --- a/georeal/models.py +++ b/georeal/models.py @@ -19,6 +19,9 @@ class User(db.Model): primaryjoin=(friends.c.friend_id == id), secondaryjoin=(friends.c.friended_id == id), backref=db.backref('friended', lazy='dynamic'), lazy='dynamic') + num_places = db.Column(db.Integer, default=0) + num_posts = db.Column(db.Integer, default=0) + num_friends = db.Column(db.Integer, default=0) class Place(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/georeal/routes/users.py b/georeal/routes/users.py new file mode 100644 index 0000000..2bcca21 --- /dev/null +++ b/georeal/routes/users.py @@ -0,0 +1,109 @@ +from flask import Blueprint, jsonify, request + +from georeal.models import FriendRequest, User, db, friends + +users = Blueprint('users', __name__) + +# @users.route('/users', methods=['GET']) +# def get_users(): +# users = User.query.all() +# users_list = [] +# for user in users: +# user_data = { +# 'id': user.id, +# 'username': user.username, +# } +# users_list.append(user_data) + +# return jsonify(users_list), 200 + +@users.route('/user', methods=['GET']) +def get_user_details(): + + search_username = request.args.get('username') + if not search_username: + return jsonify({'error': 'Missing username parameter'}), 400 + + user = User.query.filter_by(username=search_username).first() + if not user: + return jsonify({'error': 'User not found'}), 404 + + user_details = { + 'user_id': user.id, + 'username': user.username, + 'num_places': user.num_places, + 'num_posts': user.num_posts, + 'num_friends': user.num_friends + } + + return jsonify(user_details), 200 + + + +# @users.route('/user', methods=['GET']) +# def get_user(): +# # Extract usernames from query parameters +# sender_username = request.args.get('sender_username') +# receiver_username = request.args.get('receiver_username') + +# if not sender_username or not receiver_username: +# return jsonify({'error': 'Missing sender or receiver username'}), 400 + +# # Retrieve both users from the database +# sender = User.query.filter_by(username=sender_username).first() +# receiver = User.query.filter_by(username=receiver_username).first() + +# if not sender or not receiver: +# return jsonify({'error': 'Sender or receiver not found'}), 404 + +# isFriend = User.query(friends).filter( +# db.or_( +# db.and_(friends.c.friend_id == user_id1, friends.c.friended_id == user_id2), +# db.and_(friends.c.friend_id == user_id2, friends.c.friended_id == user_id1) +# ) +# ).first() is not None + +# user_data = { +# 'sender_id': sender.id, +# 'sender_username': sender.username, +# 'receiver_id': receiver.id, +# 'receiver_username': receiver.username, +# 'isFriend': isFriend, +# } +# return jsonify(user_data), 200 + +@users.route('/users/send_request', methods=['POST']) +def send_friend_request(): + data = request.get_json() + username = data.get('username') + friend_username = data.get('friend_username') + + if not username or not friend_username: + return jsonify({'message': 'Missing username or friend_username'}), 400 + + if username == friend_username: + return jsonify({'message': 'Cannot send a friend request to yourself'}), 400 + + user = User.query.filter_by(username=username).first() + friend = User.query.filter_by(username=friend_username).first() + + if not user or not friend: + return jsonify({'message': 'User not found'}), 404 + + # Check if a friend request already exists + existing_request = FriendRequest.query.filter( + ((FriendRequest.sender == user) & (FriendRequest.receiver == friend)) | + ((FriendRequest.sender == friend) & (FriendRequest.receiver == user)) + ).first() + + if existing_request: + return jsonify({'message': 'Friend request already sent or received'}), 409 + + # Create a new friend request + new_request = FriendRequest(sender=user, receiver=friend) + db.session.add(new_request) + db.session.commit() + + return jsonify({'message': f'Friend request sent from {username} to {friend_username}'}), 200 + + From 5d7c167e7c58547456a0a01e90bcdb3613763531 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 14:27:47 -0700 Subject: [PATCH 10/18] refactored friends and moved to users route --- georeal/routes/friends.py | 41 --------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 georeal/routes/friends.py diff --git a/georeal/routes/friends.py b/georeal/routes/friends.py deleted file mode 100644 index 72b6fd0..0000000 --- a/georeal/routes/friends.py +++ /dev/null @@ -1,41 +0,0 @@ -from flask import Blueprint, jsonify, request - -from georeal.models import FriendRequest, User, db - -friends = Blueprint('friends', __name__) - -@friends.route('/friends/send_request', methods=['POST']) -def send_friend_request(): - data = request.get_json() - username = data.get('username') - friend_username = data.get('friend_username') - - if not username or not friend_username: - return jsonify({'message': 'Missing username or friend_username'}), 400 - - if username == friend_username: - return jsonify({'message': 'Cannot send a friend request to yourself'}), 400 - - user = User.query.filter_by(username=username).first() - friend = User.query.filter_by(username=friend_username).first() - - if not user or not friend: - return jsonify({'message': 'User not found'}), 404 - - # Check if a friend request already exists - existing_request = FriendRequest.query.filter( - ((FriendRequest.sender == user) & (FriendRequest.receiver == friend)) | - ((FriendRequest.sender == friend) & (FriendRequest.receiver == user)) - ).first() - - if existing_request: - return jsonify({'message': 'Friend request already sent or received'}), 409 - - # Create a new friend request - new_request = FriendRequest(sender=user, receiver=friend) - db.session.add(new_request) - db.session.commit() - - return jsonify({'message': f'Friend request sent from {username} to {friend_username}'}), 200 - - From 47e84ed2bb6d77eb5fa24a9725a592965400b99f Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 14:28:10 -0700 Subject: [PATCH 11/18] removed dead code --- georeal/routes/routes.py | 68 ---------------------------------------- 1 file changed, 68 deletions(-) diff --git a/georeal/routes/routes.py b/georeal/routes/routes.py index 32ee088..499fbe4 100644 --- a/georeal/routes/routes.py +++ b/georeal/routes/routes.py @@ -11,74 +11,6 @@ api = Blueprint('api', __name__) -@api.route('/register', methods=['POST']) -def register(): - data = request.get_json() - username = data.get('username') - email = data.get('email') - plain_password = data.get('password') - print(username, email, plain_password) - # Hash password - pw_hash = bcrypt.generate_password_hash(plain_password).decode('utf-8') - print("ok") - # Check if the user already exists by email or username - user = User.query.filter((User.username == username) | (User.email == email)).first() - if user: - print("User already exists") - return jsonify({'message': 'User already exists'}), 400 - - new_user = User(username=username, email=email, password_hash=pw_hash) - db.session.add(new_user) - db.session.commit() - - return jsonify({'message': 'User created successfully'}), 201 - -@api.route('/login', methods=['POST']) -def login(): - data = request.get_json() - email = data.get('email') - plain_password = data.get('password') - - user = User.query.filter_by(email=email).first() - if user and bcrypt.check_password_hash(user.password_hash, plain_password): - # Success - return jsonify({ - 'message': 'Login successful', - 'username': user.username, - }), 200 - else: - return jsonify({'message': 'Invalid email or password'}), 401 - -@api.route('/users', methods=['GET']) -def get_users(): - users = User.query.all() - users_list = [] - for user in users: - user_data = { - 'id': user.id, - 'username': user.username, - 'email': user.email - } - users_list.append(user_data) - - return jsonify(users_list), 200 - -@api.route('/users/', methods=['GET']) -def get_user_by_username(username): - user = User.query.filter_by(username=username).first() - if not user: - return jsonify({'error': 'User not found'}), 404 - - user_data = { - 'id': user.id, - 'username': user.username, - 'email': user.email - } - return jsonify(user_data), 200 - - - - class Geofence(db.Model): """ The model for a region on the map. From 7b3bb62ad417a1a021c8dc580ab3bd7b46cdb494 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 15:31:18 -0700 Subject: [PATCH 12/18] adding friends routes --- georeal/models.py | 1 - georeal/routes/auth.py | 3 +- georeal/routes/users.py | 152 ++++++++++++++++++++++------------------ georeal/server.py | 5 +- 4 files changed, 87 insertions(+), 74 deletions(-) diff --git a/georeal/models.py b/georeal/models.py index e937f69..dda2689 100644 --- a/georeal/models.py +++ b/georeal/models.py @@ -49,7 +49,6 @@ class FriendRequest(db.Model): id = db.Column(db.Integer, primary_key=True) sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) receiver_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - accepted = db.Column(db.Boolean, default=False, nullable=False) sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_requests') receiver = db.relationship('User', foreign_keys=[receiver_id], backref='received_requests') diff --git a/georeal/routes/auth.py b/georeal/routes/auth.py index 99edde3..fba7673 100644 --- a/georeal/routes/auth.py +++ b/georeal/routes/auth.py @@ -21,8 +21,9 @@ def register(): if user: print("User already exists") return jsonify({'message': 'User already exists'}), 400 - + print(1) new_user = User(username=username, email=email, password_hash=pw_hash) + print(2) db.session.add(new_user) db.session.commit() diff --git a/georeal/routes/users.py b/georeal/routes/users.py index 2bcca21..9b62b72 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -1,22 +1,24 @@ from flask import Blueprint, jsonify, request -from georeal.models import FriendRequest, User, db, friends +from georeal.models import FriendRequest, User, db users = Blueprint('users', __name__) -# @users.route('/users', methods=['GET']) -# def get_users(): -# users = User.query.all() -# users_list = [] -# for user in users: -# user_data = { -# 'id': user.id, -# 'username': user.username, -# } -# users_list.append(user_data) - -# return jsonify(users_list), 200 - +# Get all users +@users.route('/users', methods=['GET']) +def get_all_users(): + users = User.query.all() + users_list = [] + for user in users: + user_data = { + 'id': user.id, + 'username': user.username, + } + users_list.append(user_data) + + return jsonify(users_list), 200 + +# Get user details for a specific user @users.route('/user', methods=['GET']) def get_user_details(): @@ -38,72 +40,82 @@ def get_user_details(): return jsonify(user_details), 200 - - -# @users.route('/user', methods=['GET']) -# def get_user(): -# # Extract usernames from query parameters -# sender_username = request.args.get('sender_username') -# receiver_username = request.args.get('receiver_username') - -# if not sender_username or not receiver_username: -# return jsonify({'error': 'Missing sender or receiver username'}), 400 - -# # Retrieve both users from the database -# sender = User.query.filter_by(username=sender_username).first() -# receiver = User.query.filter_by(username=receiver_username).first() - -# if not sender or not receiver: -# return jsonify({'error': 'Sender or receiver not found'}), 404 - -# isFriend = User.query(friends).filter( -# db.or_( -# db.and_(friends.c.friend_id == user_id1, friends.c.friended_id == user_id2), -# db.and_(friends.c.friend_id == user_id2, friends.c.friended_id == user_id1) -# ) -# ).first() is not None - -# user_data = { -# 'sender_id': sender.id, -# 'sender_username': sender.username, -# 'receiver_id': receiver.id, -# 'receiver_username': receiver.username, -# 'isFriend': isFriend, -# } -# return jsonify(user_data), 200 - -@users.route('/users/send_request', methods=['POST']) -def send_friend_request(): - data = request.get_json() - username = data.get('username') - friend_username = data.get('friend_username') +@users.route('/users/friend_request', methods=['POST']) +def create_friend_request(): + sender_id = request.args.get('sender_id') + receiver_id = request.args.get('receiver_id') - if not username or not friend_username: - return jsonify({'message': 'Missing username or friend_username'}), 400 - - if username == friend_username: - return jsonify({'message': 'Cannot send a friend request to yourself'}), 400 + if not sender_id or not receiver_id: + return jsonify({'error': 'Missing sender_id or receiver_id'}), 400 - user = User.query.filter_by(username=username).first() - friend = User.query.filter_by(username=friend_username).first() - - if not user or not friend: - return jsonify({'message': 'User not found'}), 404 + if sender_id == receiver_id: + return jsonify({'error': 'Cannot send a friend request to oneself'}), 400 + + sender = User.query.get(sender_id) + receiver = User.query.get(receiver_id) + if not sender or not receiver: + return jsonify({'error': 'Sender or receiver not found'}), 404 - # Check if a friend request already exists existing_request = FriendRequest.query.filter( - ((FriendRequest.sender == user) & (FriendRequest.receiver == friend)) | - ((FriendRequest.sender == friend) & (FriendRequest.receiver == user)) + ((FriendRequest.sender_id == sender_id) & (FriendRequest.receiver_id == receiver_id)) | + ((FriendRequest.sender_id == receiver_id) & (FriendRequest.receiver_id == sender_id)) ).first() if existing_request: - return jsonify({'message': 'Friend request already sent or received'}), 409 + return jsonify({'error': 'Friend request already exists'}), 409 - # Create a new friend request - new_request = FriendRequest(sender=user, receiver=friend) + new_request = FriendRequest(sender_id=sender_id, receiver_id=receiver_id) db.session.add(new_request) db.session.commit() - return jsonify({'message': f'Friend request sent from {username} to {friend_username}'}), 200 + return jsonify({'message': f'Friend request sent from {sender_id} to {receiver_id}'}), 201 + +@users.route('/users//friend_requests', methods=['GET']) +def get_all_friend_requests(user_id): + + friend_requests = FriendRequest.query \ + .join(User, User.id == FriendRequest.sender_id) \ + .add_columns( + FriendRequest.id, + FriendRequest.sender_id, + User.username.label('sender_username'), + FriendRequest.receiver_id, + ) \ + .filter(FriendRequest.receiver_id == user_id).all() + + result = [{ + 'request_id': fr.id, + 'sender_id': fr.sender_id, + 'sender_username': fr.sender_username, + 'receiver_id': fr.receiver_id, + } for fr in friend_requests] + + return jsonify(result), 200 + +@users.route('/users/friend_requests//accept', methods=['POST']) +def accept_friend_request(request_id): + friend_request = FriendRequest.query.get(request_id) + + if not friend_request: + return jsonify({'error': 'Friend request not found'}), 404 + + # Manually increment the num_friends counter for both users and delete the friend request + sender = User.query.get(friend_request.sender_id) + receiver = User.query.get(friend_request.receiver_id) + if sender and receiver: + + sender.num_friends += 1 + receiver.num_friends += 1 + + sender.friends.append(receiver) + receiver.friends.append(sender) + + db.session.delete(friend_request) + db.session.commit() + + return jsonify({'message': 'Friend request accepted, users are now friends'}), 200 + else: + db.session.rollback() + return jsonify({'error': 'Sender or receiver not found'}), 404 diff --git a/georeal/server.py b/georeal/server.py index 5fac3ed..516d7da 100644 --- a/georeal/server.py +++ b/georeal/server.py @@ -53,11 +53,11 @@ from .routes.auth import auth -from .routes.friends import friends from .routes.routes import api +from .routes.users import users app.register_blueprint(api) -app.register_blueprint(friends) +app.register_blueprint(users) app.register_blueprint(auth) @app.cli.command("bootstrap") @@ -87,6 +87,7 @@ def bootstrap_table() -> None: with app.app_context(): logger.info("Creating database...") + # db.drop_all() db.create_all() From 7384b6dfe6b1558f8803bfb2fa82d49c8d85b154 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 16:59:48 -0700 Subject: [PATCH 13/18] fixed auth to updated user provider singleton --- .../features/auth/services/auth_service.dart | 14 +- .../lib/features/auth/view/auth_screen.dart | 395 ++++++++---------- .../auth/view_model/auth_view_model.dart | 46 +- client/lib/main.dart | 10 +- client/lib/models/user.dart | 44 +- client/lib/providers/user_provider | 17 + georeal/routes/auth.py | 17 +- georeal/routes/users.py | 25 +- 8 files changed, 285 insertions(+), 283 deletions(-) create mode 100644 client/lib/providers/user_provider diff --git a/client/lib/features/auth/services/auth_service.dart b/client/lib/features/auth/services/auth_service.dart index 88601d4..68a30f8 100644 --- a/client/lib/features/auth/services/auth_service.dart +++ b/client/lib/features/auth/services/auth_service.dart @@ -2,10 +2,11 @@ import 'dart:convert'; import 'dart:developer'; import 'package:georeal/constants/env_variables.dart'; +import 'package:georeal/models/user.dart'; import 'package:http/http.dart' as http; class AuthService { - static Future login(String email, String password) async { + static Future login(String email, String password) async { try { var uri = Uri.parse('${EnvVariables.uri}/login'); var response = await http.post( @@ -20,7 +21,9 @@ class AuthService { ); if (response.statusCode == 200) { - return json.decode(response.body); // Return the user object (or token + final userJson = json.decode(response.body); + log('User fetched: $userJson'); + return User.fromMap(userJson); // Return the user object (or token } else { throw Exception( 'Failed to login. Please check your credentials and try again.'); @@ -30,7 +33,7 @@ class AuthService { } } - static Future register( + static Future register( String name, String email, String password) async { try { log('Registering user...', name: 'AuthService'); @@ -48,8 +51,9 @@ class AuthService { ); if (response.statusCode == 200) { - return json.decode(response.body); - } else if (response.statusCode == 400) { + final userJson = json.decode(response.body); + log('User fetched: $userJson'); + return User.fromMap(userJson); } else { throw Exception('Failed to register. Please try again'); } diff --git a/client/lib/features/auth/view/auth_screen.dart b/client/lib/features/auth/view/auth_screen.dart index a5f6c02..cf9a911 100644 --- a/client/lib/features/auth/view/auth_screen.dart +++ b/client/lib/features/auth/view/auth_screen.dart @@ -1,248 +1,183 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; +import 'package:georeal/features/auth/view_model/auth_view_model.dart'; import 'package:georeal/features/auth/widgets/auth_text_field.dart'; -import 'package:georeal/features/view_models/user_view_model.dart'; import 'package:georeal/global_variables.dart'; import 'package:georeal/home_router.dart'; +import 'package:georeal/models/user.dart'; import 'package:provider/provider.dart'; -import '../view_model/auth_view_model.dart'; - -/// Authentication screen - class AuthScreen extends StatelessWidget { const AuthScreen({super.key}); @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => AuthViewModel(), - child: Consumer( - builder: (context, viewModel, child) { - viewModel.onAuthSuccess = () { - Provider.of(context, listen: false).setUser({ - 'username': viewModel.nameController.text, - 'email': viewModel.emailController.text, - }); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => const HomeRouter(), - ), - ); - }; - return Scaffold( - backgroundColor: GlobalVariables.backgroundColor, - body: SafeArea( - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Padding( - padding: EdgeInsets.all(8.0), - child: Center( - child: Text( - "Places", - style: GlobalVariables.headerStyle, - ), - ), - ), - const Padding( - padding: EdgeInsets.only(bottom: 20.0), - child: Text( - "Caputre the moment, map your memories", - style: GlobalVariables.bodyStyle, - textAlign: TextAlign.center, - ), - ), + return Scaffold( + backgroundColor: GlobalVariables.backgroundColor, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Consumer( + builder: (context, model, child) { + return model.authMode == Auth.signin + ? _signInForm(context, model) + : _signUpForm(context, model); + }, + ), + ), + ), + ); + } - /* - const Padding( - padding: EdgeInsets.only(top: 20.0), - child: Icon( - Icons.public, - size: 200, - ), - ), - */ - if (viewModel.authMode == Auth.signin) - Container( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - decoration: BoxDecoration( - border: - Border.all(color: Colors.white, width: 2), - ), - child: const Text( - "Sign In", - textAlign: TextAlign.start, - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.emailController, - hintText: "Email", - isTextHidden: false), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.passwordController, - hintText: "Password", - isTextHidden: true), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - onPressed: () async { - final success = await viewModel.login(); + Widget _signInForm(BuildContext context, AuthViewModel model) { + return Column( + children: [ + const Text( + "Sign In", + style: GlobalVariables.headerStyle, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.emailController, + hintText: "Email", + isTextHidden: false, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.passwordController, + hintText: "Password", + isTextHidden: true, + ), + const SizedBox( + height: 16, + ), + GestureDetector( + child: Container( + height: 50, + width: MediaQuery.of(context).size.width - 40, + decoration: BoxDecoration( + color: Theme.of(context).hintColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: GlobalVariables.secondaryColor), + ), + child: Center( + child: Text( + "Sign Up", + style: TextStyle( + color: Theme.of(context).scaffoldBackgroundColor, + fontSize: 16, + fontWeight: FontWeight.bold), + ), + ), + ), + onTap: () async { + await _authenticateUser(context, model.login); + }, + ), + TextButton( + onPressed: model.toggleAuthMode, + child: const Text( + "Don't have an Account? Sign up.", + style: TextStyle(color: GlobalVariables.secondaryColor), + ), + ), + ], + ); + } - if (success) { - } else { - if (viewModel.errorMessage != null) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: - Text(viewModel.errorMessage!), - ), - ); - } - } - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 45), - foregroundColor: Colors.black, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(20), - ), - ), - ), - child: const Text("Sign In"), - ), - ), - TextButton( - onPressed: viewModel.toggleAuthMode, - child: const Text( - "Sign up with a new account", - style: TextStyle( - color: GlobalVariables.secondaryColor), - ), - ), - ], - ), - ), - if (viewModel.authMode == Auth.signup) - Container( - child: Column( - children: [ - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Sign Up", - style: GlobalVariables.headerStyle, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.nameController, - hintText: "Name", - isTextHidden: false), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.nameController, - hintText: "Username", - isTextHidden: false), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.emailController, - hintText: "Email", - isTextHidden: false), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AuthTextField( - controller: viewModel.passwordController, - hintText: "Password", - isTextHidden: true), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - onPressed: () async { - log('Registering'); - final success = await viewModel.register( - Provider.of(context, - listen: false)); - if (success) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - const HomeRouter(), - )); - } else { - if (viewModel.errorMessage != null) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: - Text(viewModel.errorMessage!), - ), - ); - } - } - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 45), - foregroundColor: Colors.black, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(20), - ), - ), - ), - child: const Text("Sign Up"), - ), - ), - TextButton( - onPressed: viewModel.toggleAuthMode, - child: const Text( - "Sign in with an existing account", - style: TextStyle( - color: GlobalVariables.secondaryColor), - ), - ), - ], - ), - ), - ], - ), + Widget _signUpForm(BuildContext context, AuthViewModel model) { + return Column( + children: [ + const Text( + "Sign Up", + style: GlobalVariables.headerStyle, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.nameController, + hintText: "Name", + isTextHidden: false, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.nameController, + hintText: "Username", + isTextHidden: false, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.emailController, + hintText: "Email", + isTextHidden: false, + ), + const SizedBox( + height: 16, + ), + AuthTextField( + controller: model.passwordController, + hintText: "Password", + isTextHidden: true, + ), + const SizedBox( + height: 16, + ), + GestureDetector( + child: Container( + height: 50, + width: MediaQuery.of(context).size.width - 40, + decoration: BoxDecoration( + color: Theme.of(context).hintColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: GlobalVariables.secondaryColor), + ), + child: Center( + child: Text( + "Sign Up", + style: TextStyle( + color: Theme.of(context).scaffoldBackgroundColor, + fontSize: 16, + fontWeight: FontWeight.bold), ), ), - ); - }, - ), + ), + onTap: () async { + await _authenticateUser(context, model.register); + }, + ), + TextButton( + onPressed: model.toggleAuthMode, + child: const Text( + "Already have an Account? Log In.", + style: TextStyle(color: GlobalVariables.secondaryColor), + ), + ), + ], ); } + + Future _authenticateUser(BuildContext context, Function action) async { + try { + User? user = await action(); + if (context.mounted && user != null) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomeRouter()), + ); + } + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error')), + ); + } + } + } } diff --git a/client/lib/features/auth/view_model/auth_view_model.dart b/client/lib/features/auth/view_model/auth_view_model.dart index 8607304..4ad2fce 100644 --- a/client/lib/features/auth/view_model/auth_view_model.dart +++ b/client/lib/features/auth/view_model/auth_view_model.dart @@ -1,6 +1,8 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:georeal/features/auth/services/auth_service.dart'; -import 'package:georeal/features/view_models/user_view_model.dart'; +import 'package:georeal/models/user.dart'; /// handles all data and logic for the authentication process @@ -16,6 +18,7 @@ class AuthViewModel with ChangeNotifier { final TextEditingController _passwordController = TextEditingController(); String? _errorMessage; VoidCallback? onAuthSuccess; + User? user; Auth get authMode => _authMode; @@ -23,6 +26,7 @@ class AuthViewModel with ChangeNotifier { TextEditingController get nameController => _nameController; TextEditingController get emailController => _emailController; TextEditingController get passwordController => _passwordController; + User get currentUser => user!; void setErrorMessage(String? message) { _errorMessage = message; @@ -38,43 +42,43 @@ class AuthViewModel with ChangeNotifier { notifyListeners(); } - Future login() async { + Future login() async { try { - var response = await AuthService.login( + User? user = await AuthService.login( _emailController.text, _passwordController.text, ); - _nameController.text = response['username']; - onAuthSuccess?.call(); - return true; + + if (user != null) { + log('USER: ${user.toString()}'); + return user; + } else { + return null; + } } catch (e) { - setErrorMessage(e.toString()); - return false; + log('SignIn failed: $e'); + rethrow; } } - Future register(UserViewModel user) async { + Future register() async { try { - await AuthService.register( + User? user = await AuthService.register( _nameController.text, _emailController.text, _passwordController.text, ); - onAuthSuccess?.call(); - return true; + if (user != null) { + return user; + } else { + return null; + } } catch (e) { - setErrorMessage(e.toString()); - return false; + log('Register failed: $e'); + rethrow; } } - void _setUser(UserViewModel user) { - user.setUser({ - 'name': _nameController.text, - 'email': _emailController.text, - }); - } - @override void dispose() { _nameController.dispose(); diff --git a/client/lib/main.dart b/client/lib/main.dart index 9aae843..ab9fac4 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:georeal/features/auth/view/auth_screen.dart'; +import 'package:georeal/features/auth/view_model/auth_view_model.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/features/gallery/services/gallery_service.dart'; import 'package:georeal/features/gallery/view_model/gallery_view_model.dart'; import 'package:georeal/features/geo_sphere/services/geo_sphere_service.dart'; -import 'package:georeal/features/view_models/user_view_model.dart'; +import 'package:georeal/providers/user_provider'; import 'package:georeal/util/theme.dart'; import 'package:provider/provider.dart'; @@ -27,7 +28,10 @@ Future main() async { MultiProvider( providers: [ ChangeNotifierProvider( - create: (context) => UserViewModel(), + create: (context) => UserProvider(), + ), + ChangeNotifierProvider( + create: (context) => AuthViewModel(), ), ChangeNotifierProvider( create: (_) => locationViewModel, @@ -55,7 +59,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - theme: theme, + theme: CustomTheme.theme, debugShowCheckedModeBanner: false, title: 'Flutter Demo', home: const AuthScreen(), diff --git a/client/lib/models/user.dart b/client/lib/models/user.dart index 289d898..f1e7c99 100644 --- a/client/lib/models/user.dart +++ b/client/lib/models/user.dart @@ -1,34 +1,40 @@ -import 'dart:convert'; - class User { - String id; - String username; - String email; + final int id; + final String username; + final String email; + final int numPlaces; + final int numPosts; + final int numFriends; + // Assuming lastLocation, places, posts, and friends are managed separately or not directly included in this model User({ required this.id, required this.username, required this.email, + this.numPlaces = 0, + this.numPosts = 0, + this.numFriends = 0, }); + factory User.fromMap(Map data) { + return User( + id: data['id'], + username: data['username'], + email: data['email'], + numPlaces: data['num_places'] ?? 0, + numPosts: data['num_posts'] ?? 0, + numFriends: data['num_friends'] ?? 0, + ); + } + Map toMap() { - return { + return { 'id': id, 'username': username, 'email': email, + 'num_places': numPlaces, + 'num_posts': numPosts, + 'num_friends': numFriends, }; } - - factory User.fromMap(Map map) { - return User( - id: map['_id'] ?? '', - username: map['username'] ?? '', - email: map['email'] ?? '', - ); - } - - String toJson() => json.encode(toMap()); - - factory User.fromJson(String source) => - User.fromMap(json.decode(source) as Map); } diff --git a/client/lib/providers/user_provider b/client/lib/providers/user_provider new file mode 100644 index 0000000..f4309fa --- /dev/null +++ b/client/lib/providers/user_provider @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:georeal/models/user.dart'; + + +class UserProvider with ChangeNotifier { + User? _user; + + User? get user => _user; + + void setUser(User newUser) { + _user = newUser; + notifyListeners(); + } + +} diff --git a/georeal/routes/auth.py b/georeal/routes/auth.py index fba7673..7ca852b 100644 --- a/georeal/routes/auth.py +++ b/georeal/routes/auth.py @@ -27,7 +27,14 @@ def register(): db.session.add(new_user) db.session.commit() - return jsonify({'message': 'User created successfully'}), 201 + return jsonify({ + 'id': new_user.id, + 'username': new_user.username, + 'email': new_user.email, + 'num_places': new_user.num_places, + 'num_posts': new_user.num_posts, + 'num_friends': new_user.num_friends + }), 200 @auth.route('/login', methods=['POST']) def login(): @@ -39,8 +46,12 @@ def login(): if user and bcrypt.check_password_hash(user.password_hash, plain_password): # Success return jsonify({ - 'message': 'Login successful', - 'username': user.username, + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'num_places': user.num_places, + 'num_posts': user.num_posts, + 'num_friends': user.num_friends, }), 200 else: return jsonify({'message': 'Invalid email or password'}), 401 diff --git a/georeal/routes/users.py b/georeal/routes/users.py index 9b62b72..a2d3a08 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -21,21 +21,42 @@ def get_all_users(): # Get user details for a specific user @users.route('/user', methods=['GET']) def get_user_details(): - search_username = request.args.get('username') + querying_user_id = request.args.get('user_id', type=int) + if not search_username: return jsonify({'error': 'Missing username parameter'}), 400 + if not querying_user_id: + return jsonify({'error': 'Missing user ID parameter'}), 400 user = User.query.filter_by(username=search_username).first() if not user: return jsonify({'error': 'User not found'}), 404 + # Check friendship and friend request status + is_friend = False + friend_request_sent = None + if querying_user_id: + if user in User.query.get(querying_user_id).friends: + is_friend = True + else: + # Check for existing friend request in either direction + friend_request = FriendRequest.query.filter( + db.or_( + db.and_(FriendRequest.sender_id == querying_user_id, FriendRequest.receiver_id == user.id), + db.and_(FriendRequest.receiver_id == querying_user_id, FriendRequest.sender_id == user.id) + ) + ).first() + friend_request_sent = 'sent' if friend_request and friend_request.sender_id == querying_user_id else 'received' + user_details = { 'user_id': user.id, 'username': user.username, 'num_places': user.num_places, 'num_posts': user.num_posts, - 'num_friends': user.num_friends + 'num_friends': user.num_friends, + 'is_friend': is_friend, + 'friend_request_status': friend_request_sent } return jsonify(user_details), 200 From 9ba53a51b19596efc2db8358e4a7f6a3b09ca63b Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 17:45:48 -0700 Subject: [PATCH 14/18] Implemented users page with new user provider --- .../lib/features/auth/view/auth_screen.dart | 2 + ...{friend_service.dart => user_service.dart} | 64 ++++++++-- .../features/friends/view/friends_screen.dart | 20 ++- .../friends/view/user_profile_screen.dart | 39 +++--- .../friends/view_model/friend_view_model.dart | 32 ++--- .../friends/widgets/profile_layout.dart | 120 ++++++++++++++++++ .../friends/widgets/user_search_widget.dart | 40 +++++- .../profile/views/profile_screen.dart | 11 +- .../features/view_models/user_view_model.dart | 18 --- client/lib/models/friend_request.dart | 27 ++++ client/lib/models/other_user.dart | 43 +++++++ client/lib/util/theme.dart | 72 ++++++----- georeal/routes/users.py | 8 +- 13 files changed, 377 insertions(+), 119 deletions(-) rename client/lib/features/friends/services/{friend_service.dart => user_service.dart} (50%) create mode 100644 client/lib/features/friends/widgets/profile_layout.dart delete mode 100644 client/lib/features/view_models/user_view_model.dart create mode 100644 client/lib/models/friend_request.dart create mode 100644 client/lib/models/other_user.dart diff --git a/client/lib/features/auth/view/auth_screen.dart b/client/lib/features/auth/view/auth_screen.dart index cf9a911..a46339a 100644 --- a/client/lib/features/auth/view/auth_screen.dart +++ b/client/lib/features/auth/view/auth_screen.dart @@ -4,6 +4,7 @@ import 'package:georeal/features/auth/widgets/auth_text_field.dart'; import 'package:georeal/global_variables.dart'; import 'package:georeal/home_router.dart'; import 'package:georeal/models/user.dart'; +import 'package:georeal/providers/user_provider'; import 'package:provider/provider.dart'; class AuthScreen extends StatelessWidget { @@ -168,6 +169,7 @@ class AuthScreen extends StatelessWidget { try { User? user = await action(); if (context.mounted && user != null) { + Provider.of(context, listen: false).setUser(user); Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => const HomeRouter()), ); diff --git a/client/lib/features/friends/services/friend_service.dart b/client/lib/features/friends/services/user_service.dart similarity index 50% rename from client/lib/features/friends/services/friend_service.dart rename to client/lib/features/friends/services/user_service.dart index f492777..014b0db 100644 --- a/client/lib/features/friends/services/friend_service.dart +++ b/client/lib/features/friends/services/user_service.dart @@ -2,11 +2,12 @@ import 'dart:convert'; import 'dart:developer'; import 'package:georeal/constants/env_variables.dart'; -import 'package:georeal/models/user.dart'; +import 'package:georeal/models/friend_request.dart'; +import 'package:georeal/models/other_user.dart'; import 'package:http/http.dart' as http; class UserService { - static Future> getAllUsers() async { + static Future> getAllUsers() async { try { final response = await http.get(Uri.parse('${EnvVariables.uri}/users')); @@ -14,10 +15,10 @@ class UserService { log('Users fetched: ${response.body}'); final List usersJson = json.decode(response.body); log('Users fetched2: $usersJson'); - for (var user in usersJson) { - log('User: $user'); - } - List users = usersJson.map((user) => User.fromMap(user)).toList(); + + List users = + usersJson.map((user) => OtherUser.fromMap(user)).toList(); + log('Users fetched3: $users'); return users; } else { @@ -30,16 +31,19 @@ class UserService { } } - static Future getUserByUsername(String username) async { + static Future getUserByUsername( + String username, int userId) async { try { + log('Fetching user with username: $username'); final response = await http.get( - Uri.parse('${EnvVariables.uri}/users/$username'), + Uri.parse( + '${EnvVariables.uri}/user?username=${Uri.encodeComponent(username)}&user_id=${Uri.encodeComponent(userId.toString())}'), + headers: {"Accept": "application/json"}, ); if (response.statusCode == 200) { - log('User fetched: ${response.body}'); final userJson = json.decode(response.body); - return User.fromMap(userJson); + return OtherUser.fromMap(userJson); } else { throw Exception( 'Failed to load user with status code: ${response.statusCode}'); @@ -74,4 +78,44 @@ class UserService { throw Exception('Failed to send friend request: $e'); } } + + static Future> getAllFriendRequests(int userId) async { + try { + final response = await http.get( + Uri.parse('${EnvVariables.uri}/users/$userId/friend_requests'), + ); + + if (response.statusCode == 200) { + List requestsJson = json.decode(response.body); + return requestsJson.map((data) => FriendRequest.fromMap(data)).toList(); + } else { + throw Exception( + 'Failed to load friend requests with status code: ${response.statusCode}'); + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to load friend requests: $e'); + } + } + + static Future acceptFriendRequest(int requestId) async { + try { + http.Response response = await http.post( + Uri.parse( + '${EnvVariables.uri}/users/friend_requests/$requestId/accept'), + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + log('Friend request accepted: ${response.body}'); + return true; + } else { + log('Failed to accept friend request with status code: ${response.statusCode}'); + return false; + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to accept friend request: $e'); + } + } } diff --git a/client/lib/features/friends/view/friends_screen.dart b/client/lib/features/friends/view/friends_screen.dart index a958315..aefde34 100644 --- a/client/lib/features/friends/view/friends_screen.dart +++ b/client/lib/features/friends/view/friends_screen.dart @@ -1,5 +1,5 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/view/user_profile_screen.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/features/friends/widgets/user_search_widget.dart'; import 'package:provider/provider.dart'; @@ -69,22 +69,20 @@ class FriendsScreen extends StatelessWidget { Expanded( child: Consumer( builder: (context, model, child) { - if (model.friends.isEmpty) { + if (model.searchedUsers.isEmpty) { return const Center( - child: CircularProgressIndicator(), + child: CupertinoActivityIndicator( + color: Colors.white, + ), ); } else { return ListView.builder( - itemCount: model.friends.length, + itemCount: model.searchedUsers.length, itemBuilder: (context, index) { - final friend = model.friends[index]; + final friend = model.searchedUsers[index]; return UserSearchWidget( - username: friend.username, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => UserProfileScreen( - user: model.searchedUser!)))); + username: friend.username, + ); }, ); } diff --git a/client/lib/features/friends/view/user_profile_screen.dart b/client/lib/features/friends/view/user_profile_screen.dart index a647bf0..9a879a2 100644 --- a/client/lib/features/friends/view/user_profile_screen.dart +++ b/client/lib/features/friends/view/user_profile_screen.dart @@ -1,13 +1,14 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:flutter/material.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; -import 'package:georeal/features/view_models/user_view_model.dart'; +import 'package:georeal/features/friends/widgets/profile_layout.dart'; import 'package:georeal/global_variables.dart'; -import 'package:georeal/models/user.dart'; +import 'package:georeal/models/other_user.dart'; +import 'package:georeal/providers/user_provider'; import 'package:provider/provider.dart'; class UserProfileScreen extends StatelessWidget { - User user; + OtherUser user; UserProfileScreen({ super.key, required this.user, @@ -16,33 +17,29 @@ class UserProfileScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + backgroundColor: GlobalVariables.backgroundColor, + title: Text( + user.username, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), backgroundColor: GlobalVariables.backgroundColor, body: SafeArea( child: Column( children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: Colors.white), - ), - ], - ), - Text( - user.username, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold), - ), + const ProfileLayout(), ElevatedButton( onPressed: () { final username = - Provider.of(context, listen: false) + Provider.of(context, listen: false) .user - .username; + ?.username; Provider.of(context, listen: false) - .sendFriendRequest(username, user.username); + .sendFriendRequest(username!, user.username); }, child: const Text("Add Friend"), ), diff --git a/client/lib/features/friends/view_model/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart index 4a0f42d..706edd4 100644 --- a/client/lib/features/friends/view_model/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -1,45 +1,39 @@ import 'dart:developer'; import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/services/friend_service.dart'; -import 'package:georeal/models/user.dart'; +import 'package:georeal/features/friends/services/user_service.dart'; +import 'package:georeal/models/other_user.dart'; class FriendViewModel extends ChangeNotifier { - List _users = []; - User? _searchedUser; + List _searchedUsers = []; + OtherUser? _selectedUser; - List get friends => _users; - User? get searchedUser => _searchedUser; - - FriendViewModel() { - fetchUsers(); - } + List get searchedUsers => _searchedUsers; + OtherUser? get selectedUser => _selectedUser; void fetchUsers() async { try { - _users = await UserService.getAllUsers(); - log('Users fetched: $_users'); + _searchedUsers = await UserService.getAllUsers(); notifyListeners(); } catch (e) { log(e.toString()); } } - void getUserByUsername(String username) async { + Future getUserByUsername(String username, int userId) async { try { - _searchedUser = await UserService.getUserByUsername(username); - log('Users fetched: $_users'); + _selectedUser = await UserService.getUserByUsername(username, userId); + log('User fetched: $_selectedUser'); notifyListeners(); } catch (e) { log(e.toString()); } } - void sendFriendRequest(String userId, String username) async { - log("test"); + void sendFriendRequest(String senderUsername, String receiverUsername) async { try { - await UserService.sendFriendRequest(userId, username); - log('Friend request sent to $username'); + await UserService.sendFriendRequest(senderUsername, receiverUsername); + log('Friend request sent to $receiverUsername'); } catch (e) { log(e.toString()); } diff --git a/client/lib/features/friends/widgets/profile_layout.dart b/client/lib/features/friends/widgets/profile_layout.dart new file mode 100644 index 0000000..d3c9f23 --- /dev/null +++ b/client/lib/features/friends/widgets/profile_layout.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:georeal/common/profile_photo.dart'; +import 'package:georeal/features/friends/view_model/friend_view_model.dart'; +import 'package:provider/provider.dart'; + +class ProfileLayout extends StatefulWidget { + const ProfileLayout({super.key}); + + @override + State createState() => _ProfileLayoutState(); +} + +class _ProfileLayoutState extends State { + bool isRequested = false; + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ProfilePhoto(radius: 40), + Column( + children: [ + Text( + "147", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Memories"), + ], + ), + Column( + children: [ + Text( + "7", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Spaces"), + ], + ), + Column( + children: [ + Text( + "26", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text("Friends"), + ], + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + children: [ + Text( + "First Name", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + GestureDetector( + onTap: () { + setState(() { + isRequested = !isRequested; + final viewModel = + Provider.of(context, listen: false); + // viewModel.sendFriendRequest(senderUsername, user) + }); + }, + child: Container( + width: 100, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).hintColor, + ), + borderRadius: BorderRadius.circular(5), + color: Theme.of(context).primaryColor, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: isRequested + ? Center( + child: Text( + "Requested", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .scaffoldBackgroundColor), + ), + ) + : const Center( + child: Text( + "Add Friend", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ), + ), + ], + ), + ), + const Divider(), + ], + ); + } +} diff --git a/client/lib/features/friends/widgets/user_search_widget.dart b/client/lib/features/friends/widgets/user_search_widget.dart index 3d3656f..10ac73b 100644 --- a/client/lib/features/friends/widgets/user_search_widget.dart +++ b/client/lib/features/friends/widgets/user_search_widget.dart @@ -1,14 +1,17 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:georeal/common/profile_photo.dart'; +import 'package:georeal/features/friends/view/user_profile_screen.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/global_variables.dart'; +import 'package:georeal/providers/user_provider'; import 'package:provider/provider.dart'; class UserSearchWidget extends StatelessWidget { final String username; - final VoidCallback onTap; - const UserSearchWidget( - {super.key, required this.username, required this.onTap}); + + const UserSearchWidget({super.key, required this.username}); @override Widget build(BuildContext context) { @@ -36,10 +39,35 @@ class UserSearchWidget extends StatelessWidget { ), ), ), - onTap: () { + onTap: () async { var viewModel = Provider.of(context, listen: false); - viewModel.getUserByUsername(username); - onTap(); + var userProvider = Provider.of(context, listen: false); + if (userProvider.user == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('User data is not available.'), + duration: Duration(seconds: 2), + )); + return; + } + log('username: $username'); + log('username: ${userProvider.user!.id}'); + await viewModel.getUserByUsername(username, userProvider.user!.id); + + if (viewModel.selectedUser == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Selected user data is not available.'), + duration: Duration(seconds: 2), + )); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + UserProfileScreen(user: viewModel.selectedUser!), + ), + ); }, ); } diff --git a/client/lib/features/profile/views/profile_screen.dart b/client/lib/features/profile/views/profile_screen.dart index fab372d..f6c6c0f 100644 --- a/client/lib/features/profile/views/profile_screen.dart +++ b/client/lib/features/profile/views/profile_screen.dart @@ -8,6 +8,15 @@ class ProfileScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + backgroundColor: GlobalVariables.backgroundColor, + title: const Text( + "Profile", + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), backgroundColor: GlobalVariables.backgroundColor, body: SafeArea( child: Column( @@ -15,7 +24,7 @@ class ProfileScreen extends StatelessWidget { const Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - ProfilePhoto(radius: 40), + ProfilePhoto(radius: 30), Column( children: [ Text( diff --git a/client/lib/features/view_models/user_view_model.dart b/client/lib/features/view_models/user_view_model.dart deleted file mode 100644 index f5d1eb6..0000000 --- a/client/lib/features/view_models/user_view_model.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../models/user.dart'; - -class UserViewModel extends ChangeNotifier { - User _user = User( - id: '', - username: '', - email: '', - ); - - User get user => _user; - - void setUser(Map user) { - _user = User.fromMap(user); - notifyListeners(); - } -} diff --git a/client/lib/models/friend_request.dart b/client/lib/models/friend_request.dart new file mode 100644 index 0000000..1e43d36 --- /dev/null +++ b/client/lib/models/friend_request.dart @@ -0,0 +1,27 @@ +class FriendRequest { + final int id; + final int senderId; + final String senderUsername; + final int receiverId; + + FriendRequest({ + required this.id, + required this.senderId, + required this.senderUsername, + required this.receiverId, + }); + + factory FriendRequest.fromMap(Map json) => FriendRequest( + id: json['request_id'], + senderId: json['sender_id'], + senderUsername: json['sender_username'], + receiverId: json['receiver_id'], + ); + + Map toMap() => { + 'request_id': id, + 'sender_id': senderId, + 'sender_username': senderUsername, + 'receiver_id': receiverId, + }; +} diff --git a/client/lib/models/other_user.dart b/client/lib/models/other_user.dart new file mode 100644 index 0000000..35e161b --- /dev/null +++ b/client/lib/models/other_user.dart @@ -0,0 +1,43 @@ +class OtherUser { + final int id; + final String username; + final int numPlaces; + final int numPosts; + final int numFriends; + final bool isFriend; + final String? friendRequestStatus; + + OtherUser({ + required this.id, + required this.username, + required this.numPlaces, + required this.numPosts, + required this.numFriends, + required this.isFriend, + this.friendRequestStatus, + }); + + factory OtherUser.fromMap(Map json) { + return OtherUser( + id: json['user_id'] as int, + username: json['username'] as String, + numPlaces: json['num_places'] as int, + numPosts: json['num_posts'] as int, + numFriends: json['num_friends'] as int, + isFriend: json['is_friend'] as bool, + friendRequestStatus: json['friend_request_status'] as String?, + ); + } + + Map toMap() { + return { + 'user_id': id, + 'username': username, + 'num_places': numPlaces, + 'numPosts': numPosts, + 'num_friends': numFriends, + 'is_friend': isFriend, + 'friend_request_status': friendRequestStatus, + }; + } +} diff --git a/client/lib/util/theme.dart b/client/lib/util/theme.dart index ee88d84..62a80b3 100644 --- a/client/lib/util/theme.dart +++ b/client/lib/util/theme.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; class EdeeColors { - static const Color edeePurple = Color.fromRGBO(97, 52, 131, 1); + static const Color blue = Color.fromRGBO(87, 131, 209, 1); static const Color edeeYellow = Color.fromRGBO(255, 216, 119, 1); static const backgroundColor = Color.fromRGBO(14, 21, 33, 1); static const Color edeeOrange = Color.fromRGBO(251, 176, 59, 1); static const Color edeeBlack = Color.fromRGBO(26, 26, 26, 1); static const Color edeePurple10Percent = Color.fromRGBO(97, 52, 131, 0.1); + static const Color white = Colors.white; } extension TextStyleHelpers on TextStyle { @@ -48,47 +49,56 @@ class TextStyles { fontFamily: 'Alberta Sans', ); static const TextStyle bodySmall = TextStyle( - fontSize: 8, + fontSize: 12, color: Colors.white, fontFamily: 'Alberta Sans', ); static const TextStyle bodyMedium = TextStyle( - fontSize: 10, + fontSize: 14, color: Colors.white, fontFamily: 'Alberta Sans', ); static const TextStyle bodyLarge = TextStyle( - fontSize: 12, + fontSize: 16, color: Colors.white, fontFamily: 'Alberta Sans', ); } -ThemeData theme = ThemeData( - brightness: Brightness.light, - primaryColor: EdeeColors.edeePurple, - scaffoldBackgroundColor: EdeeColors.backgroundColor, - cardColor: EdeeColors.edeeYellow, - hintColor: EdeeColors.edeePurple10Percent, - fontFamily: 'Kreadon', - textTheme: const TextTheme( - titleLarge: TextStyles.heading2Large, - titleMedium: TextStyles.heading3Medium, - titleSmall: TextStyles.heading4Small, - bodyMedium: TextStyles.bodyMedium, - bodySmall: TextStyles.bodySmall, - bodyLarge: TextStyles.bodyLarge, - labelLarge: TextStyles.labelLarge, - labelMedium: TextStyles.labelMedium, - labelSmall: TextStyles.labelSmall, - ), - - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder( - borderSide: BorderSide(color: EdeeColors.edeeBlack), +class CustomTheme { + static final ThemeData theme = ThemeData( + brightness: Brightness.light, + primaryColor: EdeeColors.blue, + scaffoldBackgroundColor: EdeeColors.backgroundColor, + cardColor: EdeeColors.edeeYellow, + hintColor: EdeeColors.white, + fontFamily: 'Kreadon', + textTheme: const TextTheme( + titleLarge: TextStyles.heading2Large, + titleMedium: TextStyles.heading3Medium, + titleSmall: TextStyles.heading4Small, + bodyMedium: TextStyles.bodyMedium, + bodySmall: TextStyles.bodySmall, + bodyLarge: TextStyles.bodyLarge, + labelLarge: TextStyles.labelLarge, + labelMedium: TextStyles.labelMedium, + labelSmall: TextStyles.labelSmall, + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderSide: BorderSide(color: EdeeColors.edeeBlack), + ), + labelStyle: TextStyle(color: EdeeColors.blue), + hintStyle: TextStyle(color: Colors.black38), + ), + appBarTheme: const AppBarTheme( + color: Colors.white, + titleTextStyle: TextStyles.heading3Medium, + toolbarTextStyle: TextStyles.heading3Medium, + iconTheme: IconThemeData( + color: EdeeColors.white, + ), + elevation: BorderSide.strokeAlignCenter, ), - labelStyle: TextStyle(color: EdeeColors.edeePurple), - hintStyle: TextStyle(color: Colors.black38), - ), - // Define other theme properties as needed. -); + ); +} diff --git a/georeal/routes/users.py b/georeal/routes/users.py index a2d3a08..7e389f0 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -11,8 +11,12 @@ def get_all_users(): users_list = [] for user in users: user_data = { - 'id': user.id, - 'username': user.username, + 'user_id': user.id, + 'username': user.username, + 'num_places': user.num_places, + 'num_posts': user.num_posts, + 'num_friends': user.num_friends, + 'is_friend': False, } users_list.append(user_data) From 2f4227303a52181af8da1911c943caae5cabf891 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 19:10:41 -0700 Subject: [PATCH 15/18] feat: friend request --- .../friends/services/user_service.dart | 11 +++--- .../friends/view/user_profile_screen.dart | 18 ++-------- .../friends/view_model/friend_view_model.dart | 5 ++- .../friends/widgets/profile_layout.dart | 34 ++++++++++++------- georeal/routes/users.py | 6 ++-- 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/client/lib/features/friends/services/user_service.dart b/client/lib/features/friends/services/user_service.dart index 014b0db..aba47f3 100644 --- a/client/lib/features/friends/services/user_service.dart +++ b/client/lib/features/friends/services/user_service.dart @@ -54,16 +54,13 @@ class UserService { } } - static Future sendFriendRequest( - String senderUsername, String receiverUsername) async { + static Future sendFriendRequest(int senderId, int receiverId) async { try { - var body = json.encode( - {'username': senderUsername, 'friend_username': receiverUsername}); - log('Sending friend request with body: $body'); + log('Sending friend request from $senderId to $receiverId'); http.Response response = await http.post( - Uri.parse('${EnvVariables.uri}/friend_request'), + Uri.parse( + '${EnvVariables.uri}/users/friend_request?sender_id=${Uri.encodeComponent(senderId.toString())}&receiver_id=${Uri.encodeComponent(receiverId.toString())}'), headers: {'Content-Type': 'application/json'}, - body: body, ); if (response.statusCode == 200) { diff --git a/client/lib/features/friends/view/user_profile_screen.dart b/client/lib/features/friends/view/user_profile_screen.dart index 9a879a2..2fe1d36 100644 --- a/client/lib/features/friends/view/user_profile_screen.dart +++ b/client/lib/features/friends/view/user_profile_screen.dart @@ -1,11 +1,8 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:flutter/material.dart'; -import 'package:georeal/features/friends/view_model/friend_view_model.dart'; import 'package:georeal/features/friends/widgets/profile_layout.dart'; import 'package:georeal/global_variables.dart'; import 'package:georeal/models/other_user.dart'; -import 'package:georeal/providers/user_provider'; -import 'package:provider/provider.dart'; class UserProfileScreen extends StatelessWidget { OtherUser user; @@ -28,21 +25,10 @@ class UserProfileScreen extends StatelessWidget { ), ), backgroundColor: GlobalVariables.backgroundColor, - body: SafeArea( + body: const SafeArea( child: Column( children: [ - const ProfileLayout(), - ElevatedButton( - onPressed: () { - final username = - Provider.of(context, listen: false) - .user - ?.username; - Provider.of(context, listen: false) - .sendFriendRequest(username!, user.username); - }, - child: const Text("Add Friend"), - ), + ProfileLayout(), ], ), ), diff --git a/client/lib/features/friends/view_model/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart index 706edd4..93286f6 100644 --- a/client/lib/features/friends/view_model/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -30,10 +30,9 @@ class FriendViewModel extends ChangeNotifier { } } - void sendFriendRequest(String senderUsername, String receiverUsername) async { + void sendFriendRequest(int senderId, int receiverId) async { try { - await UserService.sendFriendRequest(senderUsername, receiverUsername); - log('Friend request sent to $receiverUsername'); + await UserService.sendFriendRequest(senderId, receiverId); } catch (e) { log(e.toString()); } diff --git a/client/lib/features/friends/widgets/profile_layout.dart b/client/lib/features/friends/widgets/profile_layout.dart index d3c9f23..cc4e7c9 100644 --- a/client/lib/features/friends/widgets/profile_layout.dart +++ b/client/lib/features/friends/widgets/profile_layout.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:georeal/common/profile_photo.dart'; import 'package:georeal/features/friends/view_model/friend_view_model.dart'; +import 'package:georeal/providers/user_provider'; import 'package:provider/provider.dart'; class ProfileLayout extends StatefulWidget { @@ -12,48 +13,52 @@ class ProfileLayout extends StatefulWidget { class _ProfileLayoutState extends State { bool isRequested = false; + @override Widget build(BuildContext context) { + final FriendViewModel viewModel = + Provider.of(context, listen: false); + return Column( children: [ - const Row( + Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - ProfilePhoto(radius: 40), + const ProfilePhoto(radius: 40), Column( children: [ Text( - "147", - style: TextStyle( + viewModel.selectedUser?.numPosts.toString() ?? "", + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Memories"), + const Text("Memories"), ], ), Column( children: [ Text( - "7", - style: TextStyle( + viewModel.selectedUser?.numPlaces.toString() ?? "", + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Spaces"), + const Text("Spaces"), ], ), Column( children: [ Text( - "26", - style: TextStyle( + viewModel.selectedUser?.numFriends.toString() ?? "", + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Friends"), + const Text("Friends"), ], ), ], @@ -77,7 +82,12 @@ class _ProfileLayoutState extends State { isRequested = !isRequested; final viewModel = Provider.of(context, listen: false); - // viewModel.sendFriendRequest(senderUsername, user) + final senderUsername = + Provider.of(context, listen: false) + .user! + .id; + viewModel.sendFriendRequest( + senderUsername, viewModel.selectedUser!.id); }); }, child: Container( diff --git a/georeal/routes/users.py b/georeal/routes/users.py index 7e389f0..98fb784 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -65,6 +65,7 @@ def get_user_details(): return jsonify(user_details), 200 +# Creates a friend request from sender to receiver @users.route('/users/friend_request', methods=['POST']) def create_friend_request(): sender_id = request.args.get('sender_id') @@ -75,10 +76,11 @@ def create_friend_request(): if sender_id == receiver_id: return jsonify({'error': 'Cannot send a friend request to oneself'}), 400 - + sender = User.query.get(sender_id) receiver = User.query.get(receiver_id) if not sender or not receiver: + print("EHLLO") return jsonify({'error': 'Sender or receiver not found'}), 404 existing_request = FriendRequest.query.filter( @@ -93,7 +95,7 @@ def create_friend_request(): db.session.add(new_request) db.session.commit() - return jsonify({'message': f'Friend request sent from {sender_id} to {receiver_id}'}), 201 + return jsonify({'message': f'Friend request sent from {sender_id} to {receiver_id}'}), 200 @users.route('/users//friend_requests', methods=['GET']) def get_all_friend_requests(user_id): From c00f6e91095cab2d456b787c479ae8a8cc672d5a Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 20:28:57 -0700 Subject: [PATCH 16/18] feat: UI for handling friend requests --- .../friends/services/user_service.dart | 21 +++ .../profile/views/friend_request_screen.dart | 156 ++++++++++++++++++ .../profile/views/profile_screen.dart | 39 +++-- georeal/routes/users.py | 11 +- 4 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 client/lib/features/profile/views/friend_request_screen.dart diff --git a/client/lib/features/friends/services/user_service.dart b/client/lib/features/friends/services/user_service.dart index aba47f3..e1a2e4e 100644 --- a/client/lib/features/friends/services/user_service.dart +++ b/client/lib/features/friends/services/user_service.dart @@ -115,4 +115,25 @@ class UserService { throw Exception('Failed to accept friend request: $e'); } } + + static Future rejectFriendRequest(int requestId) async { + try { + http.Response response = await http.post( + Uri.parse( + '${EnvVariables.uri}/users/friend_requests/$requestId/reject'), + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + log('Friend request rejected: ${response.body}'); + return true; + } else { + log('Failed to reject friend request with status code: ${response.statusCode}'); + return false; + } + } catch (e) { + log(e.toString()); + throw Exception('Failed to reject friend request: $e'); + } + } } diff --git a/client/lib/features/profile/views/friend_request_screen.dart b/client/lib/features/profile/views/friend_request_screen.dart new file mode 100644 index 0000000..facf935 --- /dev/null +++ b/client/lib/features/profile/views/friend_request_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:georeal/common/profile_photo.dart'; +import 'package:georeal/features/friends/services/user_service.dart'; +import 'package:georeal/models/friend_request.dart'; +import 'package:georeal/providers/user_provider'; +import 'package:provider/provider.dart'; + +class FriendRequestScreen extends StatelessWidget { + const FriendRequestScreen({super.key}); + + @override + Widget build(BuildContext context) { + final user = Provider.of(context, listen: false).user; + if (user == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(); // or redirect to login screen + }); + return const Scaffold(body: Center(child: Text('User not logged in'))); + } + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + title: const Text("Friend Requests"), + ), + body: FutureBuilder>( + future: UserService.getAllFriendRequests(user.id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + return FriendRequestItem(request: snapshot.data![index]); + }, + ); + } else { + return const Center(child: Text("No friend requests")); + } + }, + ), + ); + } +} + +class FriendRequestItem extends StatefulWidget { + final FriendRequest request; + const FriendRequestItem({super.key, required this.request}); + + @override + State createState() => _FriendRequestItemState(); +} + +class _FriendRequestItemState extends State { + bool _isProcessing = false; + String? _actionResult; + + void _handleFriendRequest(bool accept) async { + setState(() => _isProcessing = true); + try { + if (accept) { + await UserService.acceptFriendRequest(widget.request.id); + _actionResult = "Accepted"; + } else { + await UserService.rejectFriendRequest(widget.request.id); + _actionResult = "Rejected"; + } + } catch (e) { + _actionResult = "Error"; + } + setState(() => _isProcessing = false); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Row( + children: [ + const ProfilePhoto(radius: 20), + const SizedBox(width: 10), + Text( + widget.request.senderUsername, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + trailing: _isProcessing + ? const CupertinoActivityIndicator() + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_actionResult == null) + GestureDetector( + onTap: () => _handleFriendRequest(true), + child: Container( + width: 70, + height: 25, + decoration: BoxDecoration( + border: Border.all(color: Colors.green), + borderRadius: BorderRadius.circular(4)), + child: const Center( + child: Text( + "Accept", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + if (_actionResult == null) + GestureDetector( + onTap: () => _handleFriendRequest(false), + child: Container( + width: 70, + height: 25, + decoration: BoxDecoration( + border: Border.all(color: Colors.red), + borderRadius: BorderRadius.circular(4)), + child: const Center( + child: Text( + "Delete", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ), + if (_actionResult != null) + Text( + _actionResult!, + style: TextStyle( + color: _actionResult == "Accepted" + ? Colors.green + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/features/profile/views/profile_screen.dart b/client/lib/features/profile/views/profile_screen.dart index f6c6c0f..bdbaefa 100644 --- a/client/lib/features/profile/views/profile_screen.dart +++ b/client/lib/features/profile/views/profile_screen.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:georeal/common/profile_photo.dart'; +import 'package:georeal/features/profile/views/friend_request_screen.dart'; import 'package:georeal/global_variables.dart'; +import 'package:georeal/providers/user_provider'; +import 'package:provider/provider.dart'; class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @override Widget build(BuildContext context) { + final user = Provider.of(context, listen: false).user!; return Scaffold( appBar: AppBar( backgroundColor: GlobalVariables.backgroundColor, @@ -16,49 +20,62 @@ class ProfileScreen extends StatelessWidget { fontWeight: FontWeight.bold, ), ), + actions: [ + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const FriendRequestScreen(), + ), + ); + }, + icon: const Icon(Icons.mail), + ), + ], ), backgroundColor: GlobalVariables.backgroundColor, body: SafeArea( child: Column( children: [ - const Row( + Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - ProfilePhoto(radius: 30), + const ProfilePhoto(radius: 30), Column( children: [ Text( - "147", - style: TextStyle( + user.numPosts.toString(), + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Memories"), + const Text("Memories"), ], ), Column( children: [ Text( - "7", - style: TextStyle( + user.numPlaces.toString(), + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Spaces"), + const Text("Spaces"), ], ), Column( children: [ Text( - "26", - style: TextStyle( + user.numFriends.toString(), + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), - Text("Friends"), + const Text("Friends"), ], ), ], diff --git a/georeal/routes/users.py b/georeal/routes/users.py index 98fb784..1fb36c9 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -80,7 +80,6 @@ def create_friend_request(): sender = User.query.get(sender_id) receiver = User.query.get(receiver_id) if not sender or not receiver: - print("EHLLO") return jsonify({'error': 'Sender or receiver not found'}), 404 existing_request = FriendRequest.query.filter( @@ -145,4 +144,14 @@ def accept_friend_request(request_id): db.session.rollback() return jsonify({'error': 'Sender or receiver not found'}), 404 +@users.route('/users/friend_requests//reject', methods=['POST']) +def reject_friend_request(request_id): + friend_request = FriendRequest.query.get(request_id) + + if not friend_request: + return jsonify({'error': 'Friend request not found'}), 404 + + db.session.delete(friend_request) + db.session.commit() + return jsonify({'message': 'Friend request rejected'}), 200 From 3d3b9dc6e05ca31340f6cc21b19cd0e4b0c210a6 Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Tue, 7 May 2024 20:48:43 -0700 Subject: [PATCH 17/18] add friend button has progress indicator --- .../friends/view_model/friend_view_model.dart | 2 +- .../friends/widgets/profile_layout.dart | 82 +++++++++---------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/client/lib/features/friends/view_model/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart index 93286f6..ab6fc67 100644 --- a/client/lib/features/friends/view_model/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -30,7 +30,7 @@ class FriendViewModel extends ChangeNotifier { } } - void sendFriendRequest(int senderId, int receiverId) async { + Future sendFriendRequest(int senderId, int receiverId) async { try { await UserService.sendFriendRequest(senderId, receiverId); } catch (e) { diff --git a/client/lib/features/friends/widgets/profile_layout.dart b/client/lib/features/friends/widgets/profile_layout.dart index cc4e7c9..eb7f8ee 100644 --- a/client/lib/features/friends/widgets/profile_layout.dart +++ b/client/lib/features/friends/widgets/profile_layout.dart @@ -12,7 +12,26 @@ class ProfileLayout extends StatefulWidget { } class _ProfileLayoutState extends State { - bool isRequested = false; + bool _isRequested = false; + bool _isProcessing = false; + + Future _handleAddFriend() async { + setState(() => _isProcessing = true); + try { + final viewModel = Provider.of(context, listen: false); + final senderUsername = + Provider.of(context, listen: false).user?.id; + if (senderUsername != null && viewModel.selectedUser?.id != null) { + await viewModel.sendFriendRequest( + senderUsername, viewModel.selectedUser!.id); + setState(() => _isRequested = true); + } else { + // Handle error or invalid state + } + } finally { + setState(() => _isProcessing = false); + } + } @override Widget build(BuildContext context) { @@ -28,11 +47,9 @@ class _ProfileLayoutState extends State { Column( children: [ Text( - viewModel.selectedUser?.numPosts.toString() ?? "", + viewModel.selectedUser?.numPosts.toString() ?? "0", style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + fontWeight: FontWeight.bold, fontSize: 16), ), const Text("Memories"), ], @@ -40,11 +57,9 @@ class _ProfileLayoutState extends State { Column( children: [ Text( - viewModel.selectedUser?.numPlaces.toString() ?? "", + viewModel.selectedUser?.numPlaces.toString() ?? "0", style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + fontWeight: FontWeight.bold, fontSize: 16), ), const Text("Spaces"), ], @@ -52,11 +67,9 @@ class _ProfileLayoutState extends State { Column( children: [ Text( - viewModel.selectedUser?.numFriends.toString() ?? "", + viewModel.selectedUser?.numFriends.toString() ?? "0", style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + fontWeight: FontWeight.bold, fontSize: 16), ), const Text("Friends"), ], @@ -68,54 +81,33 @@ class _ProfileLayoutState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Column( - children: [ - Text( - "First Name", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - ], + const Text( + "First Name", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), GestureDetector( - onTap: () { - setState(() { - isRequested = !isRequested; - final viewModel = - Provider.of(context, listen: false); - final senderUsername = - Provider.of(context, listen: false) - .user! - .id; - viewModel.sendFriendRequest( - senderUsername, viewModel.selectedUser!.id); - }); - }, + onTap: _isRequested || _isProcessing ? null : _handleAddFriend, child: Container( width: 100, decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).hintColor, - ), + border: Border.all(color: Theme.of(context).hintColor), borderRadius: BorderRadius.circular(5), color: Theme.of(context).primaryColor, ), child: Padding( padding: const EdgeInsets.all(8.0), - child: isRequested - ? Center( + child: _isProcessing + ? const Center( + child: + CircularProgressIndicator(color: Colors.white)) + : Center( child: Text( - "Requested", + _isRequested ? "Requested" : "Add Friend", style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context) .scaffoldBackgroundColor), ), - ) - : const Center( - child: Text( - "Add Friend", - style: TextStyle(fontWeight: FontWeight.bold), - ), ), ), ), From 64585b25da28998eed02da87bb79a790815c8aeb Mon Sep 17 00:00:00 2001 From: oltimaloku Date: Thu, 9 May 2024 22:22:22 -0700 Subject: [PATCH 18/18] feat: search functionality --- .../friends/services/user_service.dart | 18 +++++++++++++++ .../features/friends/view/friends_screen.dart | 6 ++++- .../friends/view_model/friend_view_model.dart | 11 ++++++++++ georeal/routes/users.py | 22 +++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/client/lib/features/friends/services/user_service.dart b/client/lib/features/friends/services/user_service.dart index e1a2e4e..b72532a 100644 --- a/client/lib/features/friends/services/user_service.dart +++ b/client/lib/features/friends/services/user_service.dart @@ -136,4 +136,22 @@ class UserService { throw Exception('Failed to reject friend request: $e'); } } + + static Future> searchUsers(String query) async { + try { + http.Response response = await http.get( + Uri.parse( + '${EnvVariables.uri}/users/search?query=${Uri.encodeComponent(query)}'), + ); + List users = []; + log('Searching users: ${response.body}'); + for (var user in json.decode(response.body)) { + users.add(OtherUser.fromMap(user)); + } + return users; + } catch (e) { + log(e.toString()); + throw Exception('Failed to search users: $e'); + } + } } diff --git a/client/lib/features/friends/view/friends_screen.dart b/client/lib/features/friends/view/friends_screen.dart index aefde34..fd27666 100644 --- a/client/lib/features/friends/view/friends_screen.dart +++ b/client/lib/features/friends/view/friends_screen.dart @@ -19,6 +19,9 @@ class FriendsScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), child: TextField( + controller: + Provider.of(context, listen: false) + .searchController, decoration: InputDecoration( hintText: 'Search by username', hintStyle: TextStyle( @@ -52,7 +55,8 @@ class FriendsScreen extends StatelessWidget { padding: const EdgeInsets.only(right: 20), child: TextButton( onPressed: () { - // Cancel button action + Provider.of(context, listen: false) + .searchUsers(); }, style: TextButton.styleFrom( backgroundColor: diff --git a/client/lib/features/friends/view_model/friend_view_model.dart b/client/lib/features/friends/view_model/friend_view_model.dart index ab6fc67..e3a3774 100644 --- a/client/lib/features/friends/view_model/friend_view_model.dart +++ b/client/lib/features/friends/view_model/friend_view_model.dart @@ -7,6 +7,7 @@ import 'package:georeal/models/other_user.dart'; class FriendViewModel extends ChangeNotifier { List _searchedUsers = []; OtherUser? _selectedUser; + TextEditingController searchController = TextEditingController(); List get searchedUsers => _searchedUsers; OtherUser? get selectedUser => _selectedUser; @@ -37,4 +38,14 @@ class FriendViewModel extends ChangeNotifier { log(e.toString()); } } + + Future searchUsers() async { + try { + _searchedUsers = await UserService.searchUsers(searchController.text); + log(_searchedUsers.toString(), name: 'Searched users'); + notifyListeners(); + } catch (e) { + log(e.toString()); + } + } } diff --git a/georeal/routes/users.py b/georeal/routes/users.py index 1fb36c9..bedd81a 100644 --- a/georeal/routes/users.py +++ b/georeal/routes/users.py @@ -155,3 +155,25 @@ def reject_friend_request(request_id): db.session.commit() return jsonify({'message': 'Friend request rejected'}), 200 + + +@users.route('/users/search', methods=['GET']) +def search_users(): + search_query = request.args.get('query') + if not search_query: + return jsonify({'error': 'Missing query parameter'}), 400 + + users = User.query.filter(User.username.ilike(f'%{search_query}%')).all() + users_list = [] + for user in users: + user_data = { + 'user_id': user.id, + 'username': user.username, + 'num_places': user.num_places, + 'num_posts': user.num_posts, + 'num_friends': user.num_friends, + 'is_friend': False, + } + users_list.append(user_data) + + return jsonify(users_list), 200