From 95b1df39da1a65eb2af8c27f086f8af4b020eaa8 Mon Sep 17 00:00:00 2001 From: Rafael <1024481@stud.hs-mannheim.de> Date: Fri, 24 May 2024 00:30:08 +0200 Subject: [PATCH] Swipe able cards (a first draft) --- lib/components/my_drawer.dart | 14 +- lib/constants.dart | 14 ++ lib/models/location.dart | 19 +- lib/models/user_profile.dart | 52 ++++++ lib/pages/user_profile_page.dart | 308 +++++++++++++++++++++++++++++++ lib/services/user_service.dart | 10 +- pubspec.lock | 16 ++ pubspec.yaml | 1 + 8 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 lib/models/user_profile.dart create mode 100644 lib/pages/user_profile_page.dart diff --git a/lib/components/my_drawer.dart b/lib/components/my_drawer.dart index bab8103..1146f3e 100644 --- a/lib/components/my_drawer.dart +++ b/lib/components/my_drawer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../pages/home_page.dart'; import '../pages/user_data_page.dart'; import '../pages/settings_page.dart'; +import '../pages/user_profile_page.dart'; import '../services/auth/auth_service.dart'; import 'feedback_dialog.dart'; @@ -57,7 +58,18 @@ class MyDrawer extends StatelessWidget { child: ListTile( title: const Text("Find Matches"), leading: const Icon(Icons.person_search), - onTap: () {}, // TODO + onTap: () { + // pop the drawer + Navigator.pop(context); + // Navigate to UserProfile + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + const UserProfilePage(), + ), + ); + }, ), ), diff --git a/lib/constants.dart b/lib/constants.dart index f93cefe..99d5fca 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -14,6 +14,20 @@ class Constants { static const String dbDocMainLocation = 'main'; static const String dbDocSecondLocation = 'secondary'; + static const String dbFieldLocationLatitude = 'latitude'; + static const String dbFieldLocationLongitude = 'longitude'; + static const String dbFieldLocationStreet = 'street'; + static const String dbFieldLocationCountry = 'country'; + static const String dbFieldLocationArea = 'administrativeArea'; + static const String dbFieldLocationLocality = 'locality'; + static const String dbFieldLocationSubLocality = 'subLocality'; + static const String dbFieldLocationPostalCode = 'postalCode'; + + static const String dbFieldUsersID = 'uid'; + static const String dbFieldUsersEmail = 'email'; + static const String dbFieldUsersName = 'name'; + static const String dbFieldUsersFirstName = 'firstname'; + static const String dbFieldUsersLastName = 'lastname'; static const String dbFieldUsersGender = 'gender'; static const String dbFieldUsersYearBorn = 'born'; static const String dbFieldUsersSkills = 'skills'; diff --git a/lib/models/location.dart b/lib/models/location.dart index 4d25568..b50ce7c 100644 --- a/lib/models/location.dart +++ b/lib/models/location.dart @@ -1,3 +1,4 @@ +import '../constants.dart'; import '../helper.dart'; class MyLocation { @@ -28,14 +29,14 @@ class MyLocation { // convert to a map Map toMap() { return { - 'street': street, - 'country': country, - 'administrativeArea': administrativeArea, - 'locality': locality, - 'subLocality': subLocality, - 'postalCode': postalCode, - 'latitude': latitude, - 'longitude': longitude, + Constants.dbFieldLocationStreet: street, + Constants.dbFieldLocationCountry: country, + Constants.dbFieldLocationArea: administrativeArea, + Constants.dbFieldLocationLocality: locality, + Constants.dbFieldLocationSubLocality: subLocality, + Constants.dbFieldLocationPostalCode: postalCode, + Constants.dbFieldLocationLatitude: latitude, + Constants.dbFieldLocationLongitude: longitude, }; } @@ -51,8 +52,8 @@ class MyLocation { } } - @override /// Returns: locality, country + @override String toString() { return '$locality, $country'; } diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..47dc9e5 --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,52 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +import '../constants.dart'; +import 'language.dart'; +import 'location.dart'; + +class UserProfile { + final String id; + final String uid; + final String email; + final String name; + final String firstName; + final String lastName; + final String risk; + final List skills; + final List skillsSought; + final List languages; + final Map locations; + + UserProfile({ + required this.id, + required this.uid, + required this.email, + required this.name, + required this.firstName, + required this.lastName, + required this.risk, + required this.skills, + required this.skillsSought, + required this.languages, + required this.locations, + }); + + factory UserProfile.fromDocument(DocumentSnapshot doc) { + Map data = doc.data() as Map; + + return UserProfile( + id: doc.id, + email: data[Constants.dbFieldUsersEmail] ?? '', + name: data[Constants.dbFieldUsersName] ?? '', + firstName: data[Constants.dbFieldUsersFirstName] ?? '', + lastName: data[Constants.dbFieldUsersLastName] ?? '', + uid: data[Constants.dbFieldUsersID] ?? '', + skills: List.from(data[Constants.dbFieldUsersSkills] ?? []), + skillsSought: + List.from(data[Constants.dbFieldUsersSkillsSought] ?? []), + risk: data[Constants.dbFieldUsersRiskTolerance] ?? '', + languages: [], + locations: {}, + ); + } +} diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart new file mode 100644 index 0000000..081fb95 --- /dev/null +++ b/lib/pages/user_profile_page.dart @@ -0,0 +1,308 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:swipable_stack/swipable_stack.dart'; + +import '../constants.dart'; +import '../models/language.dart'; +import '../models/location.dart'; +import '../models/user_profile.dart'; + +class UserProfilePage extends StatefulWidget { + const UserProfilePage({super.key}); + + @override + UserProfilePageState createState() => UserProfilePageState(); +} + +class UserProfilePageState extends State { + List userProfiles = []; + late final SwipableStackController _controller; + + void _listenController() => setState(() {}); + + @override + void initState() { + super.initState(); + _controller = SwipableStackController()..addListener(_listenController); + _fetchUserProfiles(); + } + + @override + void dispose() { + _controller + ..removeListener(_listenController) + ..dispose(); + super.dispose(); + } + + Future _fetchUserProfiles() async { + final querySnapshot = await FirebaseFirestore.instance + .collection(Constants.dbCollectionUsers) + .get(); + final users = await Future.wait(querySnapshot.docs.map((doc) async { + final languagesSnapshot = + await doc.reference.collection(Constants.dbCollectionLanguages).get(); + final locationsSnapshot = + await doc.reference.collection(Constants.dbCollectionLocations).get(); + + final languages = languagesSnapshot.docs.map((doc) { + final data = doc.data(); + return Language( + code: data['code'], + name: data['name'], + nativeName: data['nativeName'], + iconFile: data['iconFile'], + ); + }).toList(); + + final mainDoc = locationsSnapshot.docs.firstWhereOrNull( + (doc) => doc.id == Constants.dbDocMainLocation, + ); + final secondaryDoc = locationsSnapshot.docs.firstWhereOrNull( + (doc) => doc.id == Constants.dbDocSecondLocation, + ); + + final locations = { + Constants.dbDocMainLocation: + mainDoc != null ? _createLocationFromDoc(mainDoc.data()) : null, + Constants.dbDocSecondLocation: secondaryDoc != null + ? _createLocationFromDoc(secondaryDoc.data()) + : null, + }; + + final data = doc.data(); + return UserProfile( + id: doc.id, + uid: data[Constants.dbFieldUsersID] ?? '', + email: data[Constants.dbFieldUsersEmail] ?? '', + name: data[Constants.dbFieldUsersName] ?? '', + firstName: data[Constants.dbFieldUsersFirstName] ?? '', + lastName: data[Constants.dbFieldUsersLastName] ?? '', + skills: List.from(data[Constants.dbFieldUsersSkills] ?? []), + skillsSought: + List.from(data[Constants.dbFieldUsersSkillsSought] ?? []), + risk: data[Constants.dbFieldUsersRiskTolerance] ?? '', + languages: languages, + locations: locations, + ); + }).toList()); + + setState(() { + userProfiles = users; + }); + } + + MyLocation? _createLocationFromDoc(Map? data) { + if (data == null || data.isEmpty) return null; + + return MyLocation( + street: data[Constants.dbFieldLocationStreet], + country: data[Constants.dbFieldLocationCountry], + administrativeArea: data[Constants.dbFieldLocationArea], + locality: data[Constants.dbFieldLocationLocality], + subLocality: data[Constants.dbFieldLocationSubLocality], + postalCode: data[Constants.dbFieldLocationPostalCode], + latitude: data[Constants.dbFieldLocationLatitude], + longitude: data[Constants.dbFieldLocationLongitude], + ); + } + + void _swipeLeft() { + _saveSwipeAction(userProfiles[_controller.currentIndex].id, 'dislike'); + _controller.next( + swipeDirection: SwipeDirection.left, duration: Durations.extralong4); + } + + void _swipeRight() { + _saveSwipeAction(userProfiles[_controller.currentIndex].id, 'like'); + _controller.next( + swipeDirection: SwipeDirection.right, duration: Durations.extralong4); + } + + void _skip() { + _controller.next( + swipeDirection: SwipeDirection.up, duration: Durations.extralong2); + } + + void _saveSwipeAction(String userId, String action) { +/* FirebaseFirestore.instance.collection('swipes').add({ + 'userId': userId, + 'action': action, + 'timestamp': FieldValue.serverTimestamp(), + });*/ + } + + @override + Widget build(BuildContext context) { + if (userProfiles.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('User Profiles')), + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('User Profiles')), + body: SafeArea( + top: false, + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(8), + child: SwipableStack( + detectableSwipeDirections: const { + SwipeDirection.right, + SwipeDirection.left, + SwipeDirection.up, + }, + controller: _controller, + stackClipBehaviour: Clip.none, + onSwipeCompleted: (index, direction) { + if (index >= userProfiles.length) { + setState(() { + _controller.currentIndex = 0; // again from the start + }); + } + }, + horizontalSwipeThreshold: 0.8, + verticalSwipeThreshold: 0.8, + builder: (context, properties) { + final userProfile = + userProfiles[properties.index % userProfiles.length]; + return Container( + alignment: Alignment.center, + color: Colors.tealAccent, + child: Stack( + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(userProfile.name, + style: const TextStyle(fontSize: 24)), + Text(userProfile.email, + style: const TextStyle(fontSize: 24)), + const SizedBox(height: 8), + Text( + 'Has skills and experience in: ${userProfile.skills.join(', ')}'), + Text( + 'Seeks someone with skills in: ${userProfile.skillsSought.join(', ')}'), + Text('Risk type: ${userProfile.risk}'), + Text( + 'Speaks: ${userProfile.languages.map((lang) => lang.name).join(', ')}'), + Text( + 'Lives in: ${userProfile.locations['main']?.locality ?? 'N/A'}'), + Text( + 'Second home: ${userProfile.locations['secondary']?.locality ?? 'N/A'}'), + ], + ), + ), + ), + ), + if (properties.stackIndex == 0 && + properties.direction != null) + CardOverlay( + swipeProgress: properties.swipeProgress, + direction: properties.direction!, + ), + ], + ), + ); + }, + ), + ), + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + FloatingActionButton( + tooltip: 'Undo last action', + shape: const CircleBorder(), + onPressed: () { + _controller.rewind(duration: Durations.extralong4); + }, + child: const Icon(Icons.undo), + ), + SizedBox( + width: 72, + height: 72, + child: FloatingActionButton( + shape: const CircleBorder(), + onPressed: _swipeLeft, + child: + const Icon(Icons.cancel, color: Colors.red, size: 64), + ), + ), + SizedBox( + width: 72, + height: 72, + child: FloatingActionButton( + shape: const CircleBorder(), + onPressed: _swipeRight, + child: const Icon(Icons.check_circle, + color: Colors.green, size: 64), + ), + ), + FloatingActionButton( + tooltip: 'Skip profile', + shape: const CircleBorder(), + onPressed: _skip, + child: const Icon(Icons.skip_next), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class CardOverlay extends StatelessWidget { + final double swipeProgress; + final SwipeDirection direction; + + const CardOverlay({ + super.key, + required this.swipeProgress, + required this.direction, + }); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + bottom: 300, + child: Opacity( + opacity: swipeProgress.abs().clamp(0.0, 1.0), + child: Align( + alignment: direction == SwipeDirection.right + ? Alignment.centerLeft + : (direction == SwipeDirection.left + ? Alignment.centerRight + : Alignment.center), + child: Icon( + direction == SwipeDirection.right + ? Icons.check_circle + : (direction == SwipeDirection.left + ? Icons.cancel + : Icons.skip_next), + size: 100, + color: direction == SwipeDirection.right + ? Colors.green + : (direction == SwipeDirection.left ? Colors.red : Colors.blue), + ), + ), + ), + ); + } +} diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index fe393e1..a82d1ea 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -21,11 +21,11 @@ class UserService { .doc(userCredential.user!.uid) .set( { - 'uid': userCredential.user!.uid, - 'email': email, - 'firstname': firstname, - 'lastname': lastname, - 'name': fullName, + Constants.dbFieldUsersID: userCredential.user!.uid, + Constants.dbFieldUsersEmail: email, + Constants.dbFieldUsersFirstName: firstname, + Constants.dbFieldUsersLastName: lastname, + Constants.dbFieldUsersName: fullName, }, ); } diff --git a/pubspec.lock b/pubspec.lock index 87489d2..ca8ebfc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -413,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sprung: + dependency: transitive + description: + name: sprung + sha256: "54322638f5e393d2b808175f7eadbaa4836a4425456e98d93c3d676dc56ebdf1" + url: "https://pub.dev" + source: hosted + version: "3.0.1" stack_trace: dependency: transitive description: @@ -437,6 +445,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + swipable_stack: + dependency: "direct main" + description: + name: swipable_stack + sha256: b04eef070455e868b68fdd5ae98f6718e561c877348acd017c07805f1439ef19 + url: "https://pub.dev" + source: hosted + version: "2.0.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8ace6a3..23cde6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: geolocator: ^11.0.0 geocoding: ^3.0.0 collection: ^1.18.0 + swipable_stack: ^2.0.0 dev_dependencies: flutter_test: