diff --git a/lib/components/my_drawer.dart b/lib/components/my_drawer.dart index 278e77e..b06fd4e 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_matching_page.dart'; import '../pages/user_profile_page.dart'; import '../services/auth/auth_service.dart'; import 'feedback_dialog.dart'; @@ -67,7 +68,7 @@ class MyDrawer extends StatelessWidget { context, MaterialPageRoute( builder: (BuildContext context) => - const UserProfilePage(), + const UserMatchingPage(), ), ); }, @@ -103,11 +104,29 @@ class MyDrawer extends StatelessWidget { ), ), + Padding( + padding: const EdgeInsets.only(left: 25), + child: ListTile( + title: const Text("My Profile Settings"), + leading: const Icon(Icons.edit_note), + onTap: () { + // pop the drawer first, then navigate to destination + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const UserProfilePage(), + ), + ); + }, + ), + ), + // TODO TESTING - user data tile Padding( padding: const EdgeInsets.only(left: 25), child: ListTile( - title: const Text("User Data"), + title: const Text("TESTING - User Data"), leading: const Icon(Icons.supervised_user_circle), onTap: () { // pop the drawer first, then navigate to destination diff --git a/lib/constants.dart b/lib/constants.dart index ed2664e..7e36b6b 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -32,8 +32,10 @@ class Constants { static const String dbFieldUsersName = 'name'; static const String dbFieldUsersFirstName = 'firstname'; static const String dbFieldUsersLastName = 'lastname'; + static const String dbFieldUsersBio = 'bio'; static const String dbFieldUsersGender = 'gender'; static const String dbFieldUsersYearBorn = 'born'; + static const String dbFieldUsersProfilePic = 'profilePictureUrl'; static const String dbFieldUsersSkills = 'skills'; static const String dbFieldUsersSkillsSought = 'skills_sought'; static const String dbFieldUsersAvailability = 'availability'; @@ -43,5 +45,7 @@ class Constants { static const String dbFieldUsersCommunication = 'communication'; static const String dbFieldUsersRiskTolerance = 'risk_tolerance'; + static const String dbStoragePathProfiles = 'profile_images'; + static const String pathLanguagesJson = 'lib/assets/languages.json'; } diff --git a/lib/forms/skills_form.dart b/lib/forms/skills_form.dart index d27b898..8d9379e 100644 --- a/lib/forms/skills_form.dart +++ b/lib/forms/skills_form.dart @@ -1,8 +1,8 @@ -import 'package:cofounderella/pages/user_vision_page.dart'; import 'package:flutter/material.dart'; import '../../enumerations.dart'; import '../../services/auth/auth_service.dart'; import '../../services/user_service.dart'; +import '../pages/user_vision_page.dart'; import 'profile_category_form.dart'; class SkillsForm extends StatelessWidget { @@ -21,7 +21,7 @@ class SkillsForm extends StatelessWidget { @override Widget build(BuildContext context) { return FutureBuilder>( - future: UserService().getSkillsFromFirebase( + future: UserService.getSkillsFromFirebase( skillsSought, _authService.getCurrentUser()!.uid, ), // Fetch skills from Firebase @@ -49,7 +49,7 @@ class SkillsForm extends StatelessWidget { userSkills ?? [], // Pass pre-selected skills to the form onSave: (selectedOptions) async { // Handle saving selected options - bool success = await UserService().saveSkillsToFirebase( + bool success = await UserService.saveSkillsToFirebase( selectedOptions.cast(), skillsSought, _authService.getCurrentUser()!.uid, @@ -63,7 +63,8 @@ class SkillsForm extends StatelessWidget { context, MaterialPageRoute( // set following registration page HERE - builder: (context) => MatchingForm(isRegProcess: isRegProcess), + builder: (context) => + MatchingForm(isRegProcess: isRegProcess), ), ); } else if (isRegProcess) { diff --git a/lib/models/language.dart b/lib/models/language.dart index fe872a8..694370f 100644 --- a/lib/models/language.dart +++ b/lib/models/language.dart @@ -1,3 +1,5 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + class Language { final String code; final String name; @@ -21,6 +23,16 @@ class Language { }; } + factory Language.fromDocument(DocumentSnapshot doc) { + Map data = doc.data() as Map; + return Language( + code: data['code'] ?? '', + name: data['name'] ?? '', + nativeName: data['nativeName'] ?? '', + iconFile: data['iconFile'] ?? '', + ); + } + @override int get hashCode => code.hashCode; diff --git a/lib/models/location.dart b/lib/models/location.dart index dc35995..d3f5639 100644 --- a/lib/models/location.dart +++ b/lib/models/location.dart @@ -1,3 +1,4 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import '../constants.dart'; import '../utils/math.dart'; @@ -26,6 +27,20 @@ class MyLocation { required this.longitude, }); + factory MyLocation.fromDocument(DocumentSnapshot doc) { + Map data = doc.data() as Map; + 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] as num?)?.toDouble(), + longitude: (data[Constants.dbFieldLocationLongitude] as num?)?.toDouble(), + ); + } + // convert to a map Map toMap() { return { diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart new file mode 100644 index 0000000..fb66d73 --- /dev/null +++ b/lib/pages/edit_profile_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; + +import '../constants.dart'; + +class EditProfilePage extends StatefulWidget { + final Map userData; + + const EditProfilePage({super.key, required this.userData}); + + @override + EditProfilePageState createState() => EditProfilePageState(); +} + +class EditProfilePageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _nameController; + late TextEditingController _bioController; + late String? profileImageUrl; + File? _profileImage; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: widget.userData[Constants.dbFieldUsersName]); + _bioController = + TextEditingController(text: widget.userData[Constants.dbFieldUsersBio]); + if (widget.userData[Constants.dbFieldUsersProfilePic] != null) { + profileImageUrl = widget.userData[Constants.dbFieldUsersProfilePic]; + } + } + + Future _pickImage() async { + final pickedFile = + await ImagePicker().pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + setState(() { + _profileImage = File(pickedFile.path); + }); + } + } + + void _clearProfileImage() { + setState(() { + _profileImage = null; + widget.userData[Constants.dbFieldUsersProfilePic] = null; + }); + } + + Future _saveProfile() async { + if (_formKey.currentState!.validate()) { + String uid = FirebaseAuth.instance.currentUser!.uid; + + if (_profileImage != null) { + final storageRef = FirebaseStorage.instance + .ref() + .child(Constants.dbStoragePathProfiles) + .child(uid); // filename = userid + await storageRef.putFile(_profileImage!); + profileImageUrl = await storageRef.getDownloadURL(); + } else { + profileImageUrl = null; + } + + String name = _nameController.text; + String bio = _bioController.text; + + await FirebaseFirestore.instance + .collection(Constants.dbCollectionUsers) + .doc(uid) + .update({ + Constants.dbFieldUsersName: name, + Constants.dbFieldUsersBio: bio, + //if (profileImageUrl != null) + Constants.dbFieldUsersProfilePic: profileImageUrl, + }); + + Navigator.pop(context, { + Constants.dbFieldUsersProfilePic: profileImageUrl, + Constants.dbFieldUsersName: name, + Constants.dbFieldUsersBio: bio, + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Edit Profile'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: ListView( + children: [ + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + GestureDetector( + onTap: _pickImage, + child: Stack( + children: [ + CircleAvatar( + radius: 50, + backgroundImage: _profileImage != null + ? FileImage(_profileImage!) as ImageProvider + : (widget.userData[ + Constants.dbFieldUsersProfilePic] != + null + ? NetworkImage(widget.userData[ + Constants.dbFieldUsersProfilePic]) + as ImageProvider + : null), + child: ClipOval( + child: _profileImage == null && + widget.userData[Constants + .dbFieldUsersProfilePic] == + null + ? const Icon(Icons.person, size: 50) + : SizedBox( + width: 100, + height: 100, + child: _profileImage != null + ? Image.file( + _profileImage!, + fit: BoxFit.cover, + ) + : (widget.userData[Constants + .dbFieldUsersProfilePic] != + null + ? Image.network( + widget.userData[Constants + .dbFieldUsersProfilePic], + fit: BoxFit.cover, + ) + : null), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: IconButton( + icon: Ink( + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: const CircleBorder(), + ), + child: const Icon(Icons.edit)), + onPressed: _pickImage, + ), + ), + ], + ), + ), + if (_profileImage != null) + IconButton( + icon: Ink( + decoration: const ShapeDecoration( + shape: CircleBorder(), + ), + child: const Icon( + Icons.delete, + color: Colors.red, + size: 32, + ), + ), + onPressed: _clearProfileImage, + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a name'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _bioController, + decoration: const InputDecoration(labelText: 'Bio'), + maxLines: 3, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _saveProfile, + child: const Text('Save'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/register_page.dart b/lib/pages/register_page.dart index 948e1b0..8cff07c 100644 --- a/lib/pages/register_page.dart +++ b/lib/pages/register_page.dart @@ -31,7 +31,7 @@ class RegisterPage extends StatelessWidget { .signUpWithEmailPassword( _emailController.text, _passwordController.text) .then((userCredential) { - UserService().saveUserData(userCredential, _emailController.text, + UserService.saveUserData(userCredential, _emailController.text, _nameController.text, _lastnameController.text); }); } on FirebaseAuthException catch (e) { diff --git a/lib/pages/user_matching_page.dart b/lib/pages/user_matching_page.dart new file mode 100644 index 0000000..1f5d672 --- /dev/null +++ b/lib/pages/user_matching_page.dart @@ -0,0 +1,604 @@ +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 '../forms/matched_screen.dart'; +import '../models/language.dart'; +import '../models/location.dart'; +import '../models/user_profile.dart'; +import '../services/auth/auth_service.dart'; +import '../utils/math.dart'; +import 'chat_page.dart'; + +class UserMatchingPage extends StatefulWidget { + const UserMatchingPage({super.key}); + + @override + UserMatchingPageState createState() => UserMatchingPageState(); +} + +class UserMatchingPageState extends State { + /// The current's user profile + UserProfile? currentUserProfile; + + /// List with [all] user profiles + List userProfiles = []; + + /// List of user profiles to show. + List potentialUserProfiles = []; + + late final SwipableStackController _controller; + + // get instance of firestore and auth + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final AuthService _authService = AuthService(); + + 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 String currentUserId = _authService.getCurrentUser()!.uid; + List allUsers = []; + List showProfiles = []; + + final usersSnapshot = await FirebaseFirestore.instance + .collection(Constants.dbCollectionUsers) + .get(); + + // Fetch the list of profiles the current user has already swiped + final QuerySnapshot swipesSnapshot = await FirebaseFirestore.instance + .collection(Constants.dbCollectionUsers) + .doc(currentUserId) + .collection(Constants.dbCollectionSwipes) + .get(); + + final Set likedUserIds = swipesSnapshot.docs + .where((doc) => doc['liked'] == true) + .map((doc) => doc.id) + .toSet(); + + final DateTime thresholdDate = + DateTime.now().subtract(const Duration(hours: 24)); + final Set dislikedUserIds = swipesSnapshot.docs + .where((doc) => + doc['liked'] == false && + (doc['timestamp'] as Timestamp).toDate().isAfter(thresholdDate)) + .map((doc) => doc.id) + .toSet(); + + for (var userDoc in usersSnapshot.docs) { + final languagesSnapshot = await userDoc.reference + .collection(Constants.dbCollectionLanguages) + .get(); + final locationsSnapshot = await userDoc.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 = userDoc.data(); + UserProfile userProfile = UserProfile( + id: userDoc.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, + ); + + // add profiles accordingly + allUsers.add(userProfile); + // Exclude (1) the current user's profile, (2) the already liked profiles + // and (3) users that were disliked less than 24 hours ago + if (userDoc.id != currentUserId && + !likedUserIds.contains(userDoc.id) && + !dislikedUserIds.contains(userDoc.id)) { + showProfiles.add(userProfile); + } + } // end for + + setState(() { + userProfiles = allUsers; + potentialUserProfiles = showProfiles; + currentUserProfile = + allUsers.firstWhereOrNull((x) => x.uid == currentUserId); + }); + } + + 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() { + _controller.next( + swipeDirection: SwipeDirection.left, duration: Durations.extralong1); + } + + void _swipeRight() { + _controller.next( + swipeDirection: SwipeDirection.right, duration: Durations.extralong4); + } + + void _skip() { + _controller.next( + swipeDirection: SwipeDirection.up, duration: Durations.long4); + } + + /// Save swipe status to database + Future _saveSwipeAction( + String swipedUserId, SwipeDirection direction) async { + await _firestore + .collection(Constants.dbCollectionUsers) + .doc(_authService.getCurrentUser()!.uid) + .collection(Constants.dbCollectionSwipes) + .doc(swipedUserId) // UserID instead of autogenerated ID + .set( + { + 'swipedId': swipedUserId, + 'liked': direction == SwipeDirection.right, + 'timestamp': FieldValue.serverTimestamp(), + }, + ); + } + + UserProfile getUserProfile(String userId) { + return userProfiles.firstWhere((x) => x.uid == userId); + } + + /// Check whether the swiped user has also swiped to the right + Future _checkForMatch(swipedUserId) async { + String currentUserId = _authService.getCurrentUser()!.uid; + + final QuerySnapshot matchSnapshot = await _firestore + .collection(Constants.dbCollectionUsers) + .doc(swipedUserId) + .collection(Constants.dbCollectionSwipes) + .where('swipedId', isEqualTo: currentUserId) + .where('liked', isEqualTo: true) + .get(); + + if (matchSnapshot.docs.isNotEmpty) { + // save match for both users + final matchesCurrentUser = _firestore + .collection(Constants.dbCollectionUsers) + .doc(currentUserId) + .collection(Constants.dbCollectionMatches); + final matchesSwipedUser = _firestore + .collection(Constants.dbCollectionUsers) + .doc(swipedUserId) + .collection(Constants.dbCollectionMatches); + + await matchesCurrentUser.add({ + 'otherUserId': swipedUserId, + 'timestamp': FieldValue.serverTimestamp(), + }); + + await matchesSwipedUser.add({ + 'otherUserId': currentUserId, + 'timestamp': FieldValue.serverTimestamp(), + }); + + // + // TODO Notify other user? + // + showMatchedScreen(currentUserId, swipedUserId); + } + } + + showMatchedScreen(String currentUserId, String swipedUserId) { + UserProfile currentUser = getUserProfile(currentUserId); + UserProfile swipedUser = getUserProfile(swipedUserId); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MatchedScreen( + user1Name: swipedUser.name, + user2Name: currentUser.name, + user1ImageUrl: '', // swipedUser.profilePicture, + user2ImageUrl: '', // currentUser.profilePicture, + onMessageButtonPressed: () { + Navigator.pop(context); // Close the MatchedScreen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPage( + receiverEmail: swipedUser.email, + receiverID: swipedUser.uid, + ), + ), + ); + }, + onContinueButtonPressed: () { + Navigator.pop(context); // Close the MatchedScreen + }, + ), + ), + ); + } + + double _shortestDistanceBetweenUsers( + UserProfile currentUser, UserProfile otherUser) { + try { + if (currentUser.locations.isEmpty || otherUser.locations.isEmpty) { + return double.nan; + } + + double shortestDistance = double.nan; + // locations currentUser + for (var loc1 in currentUser.locations.values) { + if (loc1 != null && loc1.latitude != null && loc1.longitude != null) { + for (var loc2 in otherUser.locations.values) { + if (loc2 != null && + loc2.latitude != null && + loc2.longitude != null) { + double distance = calculateDistance(loc1.latitude!, + loc1.longitude!, loc2.latitude!, loc2.longitude!); + if (shortestDistance.isNaN || distance < shortestDistance) { + shortestDistance = distance; + } + } + } + } + } + + return shortestDistance; + } catch (e) { + return double.nan; + } + } + + @override + Widget build(BuildContext context) { + if (potentialUserProfiles.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('User Profiles')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.person_search, size: 64), + const SizedBox(height: 20), + // Loading... + if (userProfiles.isEmpty) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 20), + const Text( + 'Loading data, please wait...', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ] else ...[ + Text( + userProfiles.length > 1 + ? 'No new profiles available yet.' + : 'No profiles available at the moment.', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(height: 60), + const Text( + 'Please check back later, perhaps tomorrow.', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ], + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Find your Match')), + 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, + swipeAnchor: SwipeAnchor.bottom, + // item count +1 for additional end page + itemCount: potentialUserProfiles.length + 1, + onSwipeCompleted: (index, direction) { + // + // Swipe logic + // + String swipedUserId = + potentialUserProfiles[_controller.currentIndex].id; + if (direction == SwipeDirection.right) { + _saveSwipeAction(swipedUserId, SwipeDirection.right); + _checkForMatch(swipedUserId); + } else if (direction == SwipeDirection.left) { + _saveSwipeAction(swipedUserId, SwipeDirection.left); + } + }, + horizontalSwipeThreshold: 0.8, + verticalSwipeThreshold: 0.8, + builder: (context, properties) { + if (properties.index == potentialUserProfiles.length) { + return Center( + child: _buildLastCard(), // last card + ); + } + + final userProfile = potentialUserProfiles[ + properties.index % potentialUserProfiles.length]; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25.0), + color: Colors.blue, + ), + child: Stack( + children: [ + _buildUserCard(userProfile), + if (properties.stackIndex == 0 && + properties.direction != null) + CardOverlay( + swipeProgress: properties.swipeProgress, + direction: properties.direction!, + ), + ], + ), + ); + }, + ), + ), + ), + _buildSwipeButtons(), + ], + ), + ), + ); + } + + Widget _buildUserCard(UserProfile userProfile) { + return 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[Constants.dbDocMainLocation]?.locality ?? 'N/A'}'), + Text( + 'Coordinates: ${userProfile.locations[Constants.dbDocMainLocation]?.latitude} ${userProfile.locations[Constants.dbDocMainLocation]?.longitude}'), + Text( + 'Second home: ${userProfile.locations[Constants.dbDocSecondLocation]?.locality ?? 'N/A'}'), + Text( + 'Shortest distance: ${_shortestDistanceBetweenUsers(currentUserProfile!, userProfile).toStringAsFixed(0)} km'), + ], + ), + ), + ), + ); + } + + Widget _buildLastCard() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + // Show reached end message and restart button + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.person_search, size: 64), + const SizedBox(height: 20), + const Text('You\'ve viewed all available profiles.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 60), + const Text( + 'Would you like to do another run and see the remaining profiles again?', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + setState(() { + // Restart swiping from the beginning + _controller.currentIndex = 0; + }); + }, + child: const Text('Another run'), + ), + ], + ), + ), + ); + } + + Widget _buildSwipeButtons() { + return Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + FloatingActionButton( + heroTag: 'myFABUndo', + 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( + heroTag: 'myFABSwipeLeft', + shape: const CircleBorder(), + onPressed: _swipeLeft, + child: Stack( + // to deal with icons inner transparency + alignment: Alignment.center, + children: [ + Container( + width: 48.0, + height: 48.0, + decoration: const BoxDecoration( + color: Colors.white70, + shape: BoxShape.circle, + ), + ), + const Icon(Icons.cancel, color: Colors.red, size: 72), + ], + ), + ), + ), + SizedBox( + width: 72, + height: 72, + child: FloatingActionButton( + heroTag: 'myFABSwipeRight', + shape: const CircleBorder(), + onPressed: _swipeRight, + child: Stack( + // to deal with icons inner transparency + alignment: Alignment.center, + children: [ + Container( + width: 48.0, + height: 48.0, + decoration: const BoxDecoration( + color: Colors.white70, + shape: BoxShape.circle, + ), + ), + const Icon(Icons.check_circle, color: Colors.green, size: 72), + ], + ), + ), + ), + FloatingActionButton( + tooltip: 'Skip profile', + heroTag: 'myFABSkip', + 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.thumb_up + : (direction == SwipeDirection.left + ? Icons.thumb_down + : Icons.skip_next), + size: 100, + color: direction == SwipeDirection.right + ? Colors.green + : (direction == SwipeDirection.left ? Colors.red : Colors.blue), + ), + ), + ), + ); + } +} diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart index 8302d00..da3db94 100644 --- a/lib/pages/user_profile_page.dart +++ b/lib/pages/user_profile_page.dart @@ -1,568 +1,118 @@ -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 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import '../constants.dart'; -import '../forms/matched_screen.dart'; -import '../models/language.dart'; -import '../models/location.dart'; -import '../models/user_profile.dart'; -import '../services/auth/auth_service.dart'; -import 'chat_page.dart'; +import 'edit_profile_page.dart'; class UserProfilePage extends StatefulWidget { const UserProfilePage({super.key}); @override - UserProfilePageState createState() => UserProfilePageState(); + State createState() => _UserProfilePageState(); } -class UserProfilePageState extends State { - /// The current's user profile - UserProfile? currentUserProfile; - - /// List with [all] user profiles - List userProfiles = []; - - /// List of user profiles to show. - List potentialUserProfiles = []; - - late final SwipableStackController _controller; - - // get instance of firestore and auth - final FirebaseFirestore _firestore = FirebaseFirestore.instance; - final AuthService _authService = AuthService(); - - void _listenController() => setState(() {}); +class _UserProfilePageState extends State { + String? profileImageUrl; // Track the profile image URL + bool isLoading = true; + late Map userData; @override void initState() { super.initState(); - _controller = SwipableStackController()..addListener(_listenController); - _fetchUserProfiles(); + _loadUserData(); // Load user data on initialization } - @override - void dispose() { - _controller - ..removeListener(_listenController) - ..dispose(); - super.dispose(); - } - - Future _fetchUserProfiles() async { - final String currentUserId = _authService.getCurrentUser()!.uid; - List allUsers = []; - List showProfiles = []; - - final usersSnapshot = await FirebaseFirestore.instance + Future _loadUserData() async { + DocumentSnapshot userDoc = await FirebaseFirestore.instance .collection(Constants.dbCollectionUsers) + .doc(FirebaseAuth.instance.currentUser!.uid) .get(); - // Fetch the list of profiles the current user has already swiped - final QuerySnapshot swipesSnapshot = await FirebaseFirestore.instance - .collection(Constants.dbCollectionUsers) - .doc(currentUserId) - .collection(Constants.dbCollectionSwipes) - .get(); - - final Set likedUserIds = swipesSnapshot.docs - .where((doc) => doc['liked'] == true) - .map((doc) => doc.id) - .toSet(); - - final DateTime thresholdDate = - DateTime.now().subtract(const Duration(hours: 24)); - final Set dislikedUserIds = swipesSnapshot.docs - .where((doc) => - doc['liked'] == false && - (doc['timestamp'] as Timestamp).toDate().isAfter(thresholdDate)) - .map((doc) => doc.id) - .toSet(); - - for (var userDoc in usersSnapshot.docs) { - final languagesSnapshot = await userDoc.reference - .collection(Constants.dbCollectionLanguages) - .get(); - final locationsSnapshot = await userDoc.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 = userDoc.data(); - UserProfile userProfile = UserProfile( - id: userDoc.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, - ); - - // add profiles accordingly - allUsers.add(userProfile); - // Exclude (1) the current user's profile, (2) the already liked profiles - // and (3) users that were disliked less than 24 hours ago - if (userDoc.id != currentUserId && - !likedUserIds.contains(userDoc.id) && - !dislikedUserIds.contains(userDoc.id)) { - showProfiles.add(userProfile); - } - } // end for - setState(() { - userProfiles = allUsers; - potentialUserProfiles = showProfiles; - currentUserProfile = - allUsers.firstWhereOrNull((x) => x.uid == currentUserId); + userData = userDoc.data() as Map; + // Initialize the profile image URL + profileImageUrl = userData[Constants.dbFieldUsersProfilePic]; + // Set loading to false once data is loaded + isLoading = false; }); } - 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() { - _controller.next( - swipeDirection: SwipeDirection.left, duration: Durations.extralong1); - } - - void _swipeRight() { - _controller.next( - swipeDirection: SwipeDirection.right, duration: Durations.extralong4); - } - - void _skip() { - _controller.next( - swipeDirection: SwipeDirection.up, duration: Durations.long4); - } - - /// Save swipe status to database - Future _saveSwipeAction( - String swipedUserId, SwipeDirection direction) async { - await _firestore - .collection(Constants.dbCollectionUsers) - .doc(_authService.getCurrentUser()!.uid) - .collection(Constants.dbCollectionSwipes) - .doc(swipedUserId) // UserID instead of autogenerated ID - .set( - { - 'swipedId': swipedUserId, - 'liked': direction == SwipeDirection.right, - 'timestamp': FieldValue.serverTimestamp(), - }, - ); - } - - UserProfile getUserProfile(String userId) { - return userProfiles.firstWhere((x) => x.uid == userId); - } - - /// Check whether the swiped user has also swiped to the right - Future _checkForMatch(swipedUserId) async { - String currentUserId = _authService.getCurrentUser()!.uid; - - final QuerySnapshot matchSnapshot = await _firestore - .collection(Constants.dbCollectionUsers) - .doc(swipedUserId) - .collection(Constants.dbCollectionSwipes) - .where('swipedId', isEqualTo: currentUserId) - .where('liked', isEqualTo: true) - .get(); - - if (matchSnapshot.docs.isNotEmpty) { - // save match for both users - final matchesCurrentUser = _firestore - .collection(Constants.dbCollectionUsers) - .doc(currentUserId) - .collection(Constants.dbCollectionMatches); - final matchesSwipedUser = _firestore - .collection(Constants.dbCollectionUsers) - .doc(swipedUserId) - .collection(Constants.dbCollectionMatches); - - await matchesCurrentUser.add({ - 'otherUserId': swipedUserId, - 'timestamp': FieldValue.serverTimestamp(), - }); - - await matchesSwipedUser.add({ - 'otherUserId': currentUserId, - 'timestamp': FieldValue.serverTimestamp(), - }); - - // - // TODO Notify other user? - // - showMatchedScreen(currentUserId, swipedUserId); - } - } - - showMatchedScreen(String currentUserId, String swipedUserId) { - UserProfile currentUser = getUserProfile(currentUserId); - UserProfile swipedUser = getUserProfile(swipedUserId); - - Navigator.push( + void editNameInfo() async { + final updatedUserData = await Navigator.push( context, MaterialPageRoute( - builder: (context) => MatchedScreen( - user1Name: swipedUser.name, - user2Name: currentUser.name, - user1ImageUrl: '', // swipedUser.profilePicture, - user2ImageUrl: '', // currentUser.profilePicture, - onMessageButtonPressed: () { - Navigator.pop(context); // Close the MatchedScreen - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatPage( - receiverEmail: swipedUser.email, - receiverID: swipedUser.uid, - ), - ), - ); - }, - onContinueButtonPressed: () { - Navigator.pop(context); // Close the MatchedScreen - }, - ), + builder: (context) => EditProfilePage(userData: userData), ), ); + + if (updatedUserData != null) { + setState(() { + profileImageUrl = updatedUserData[Constants.dbFieldUsersProfilePic]; + userData[Constants.dbFieldUsersName] = + updatedUserData[Constants.dbFieldUsersName]; + userData[Constants.dbFieldUsersBio] = + updatedUserData[Constants.dbFieldUsersBio]; + }); + } } @override Widget build(BuildContext context) { - if (potentialUserProfiles.isEmpty) { - return Scaffold( - appBar: AppBar(title: const Text('User Profiles')), - body: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.person_search, size: 64), - const SizedBox(height: 20), - // Loading... - if (userProfiles.isEmpty) ...[ - const CircularProgressIndicator(), - const SizedBox(height: 20), - const Text( - 'Loading data, please wait...', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - ] else ...[ - Text( - userProfiles.length > 1 - ? 'No new profiles available yet.' - : 'No profiles available at the moment.', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const SizedBox(height: 60), - const Text( - 'Please check back later, perhaps tomorrow.', - textAlign: TextAlign.center, - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - ], - ], - ), - ), - ), - ); - } - return Scaffold( - appBar: AppBar(title: const Text('Find your Match')), - 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, - swipeAnchor: SwipeAnchor.bottom, - // item count +1 for additional end page - itemCount: potentialUserProfiles.length + 1, - onSwipeCompleted: (index, direction) { - // - // Swipe logic - // - String swipedUserId = - potentialUserProfiles[_controller.currentIndex].id; - if (direction == SwipeDirection.right) { - _saveSwipeAction(swipedUserId, SwipeDirection.right); - _checkForMatch(swipedUserId); - } else if (direction == SwipeDirection.left) { - _saveSwipeAction(swipedUserId, SwipeDirection.left); - } - }, - horizontalSwipeThreshold: 0.8, - verticalSwipeThreshold: 0.8, - builder: (context, properties) { - if (properties.index == potentialUserProfiles.length) { - return Center( - child: _buildLastCard(), // last card - ); - } - - final userProfile = potentialUserProfiles[ - properties.index % potentialUserProfiles.length]; - return Container( - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25.0), - color: Colors.blue, + appBar: AppBar( + title: const Text('User Profile'), + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + //crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.bottomRight, + child: OutlinedButton.icon( + label: const Text('Edit'), + icon: const Icon(Icons.edit), + onPressed: editNameInfo, ), - child: Stack( + ), + CircleAvatar( + radius: 50, + backgroundImage: profileImageUrl != null + ? NetworkImage(profileImageUrl!) + : null, + child: profileImageUrl == null + ? const Icon(Icons.person, size: 50) + : null, + ), + const SizedBox(height: 16), + Text(userData[Constants.dbFieldUsersName] ?? 'Name', + style: const TextStyle(fontSize: 24)), + Text(userData[Constants.dbFieldUsersEmail] ?? 'Email', + style: const TextStyle(fontSize: 16)), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildUserCard(userProfile), - if (properties.stackIndex == 0 && - properties.direction != null) - CardOverlay( - swipeProgress: properties.swipeProgress, - direction: properties.direction!, - ), + const Text('Bio'), + Text(userData[Constants.dbFieldUsersBio] ?? 'N/A', + style: const TextStyle(fontSize: 16)), ], ), - ); - }, + ), + const SizedBox(height: 16), + Divider( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + ], ), ), ), - _buildSwipeButtons(), - ], - ), - ), - ); - } - - Widget _buildUserCard(UserProfile userProfile) { - return 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[Constants.dbDocMainLocation]?.locality ?? 'N/A'}'), - Text( - 'Second home: ${userProfile.locations[Constants.dbDocSecondLocation]?.locality ?? 'N/A'}'), - ], - ), - ), - ), - ); - } - - Widget _buildLastCard() { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - // Show reached end message and restart button - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.person_search, size: 64), - const SizedBox(height: 20), - const Text('You\'ve viewed all available profiles.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 60), - const Text( - 'Would you like to do another run and see the remaining profiles again?', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - setState(() { - // Restart swiping from the beginning - _controller.currentIndex = 0; - }); - }, - child: const Text('Another run'), - ), - ], - ), - ), - ); - } - - Widget _buildSwipeButtons() { - return Positioned( - bottom: 16, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - FloatingActionButton( - heroTag: 'myFABUndo', - 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( - heroTag: 'myFABSwipeLeft', - shape: const CircleBorder(), - onPressed: _swipeLeft, - child: Stack( - // to deal with icons inner transparency - alignment: Alignment.center, - children: [ - Container( - width: 48.0, - height: 48.0, - decoration: const BoxDecoration( - color: Colors.white70, - shape: BoxShape.circle, - ), - ), - const Icon(Icons.cancel, color: Colors.red, size: 72), - ], - ), - ), - ), - SizedBox( - width: 72, - height: 72, - child: FloatingActionButton( - heroTag: 'myFABSwipeRight', - shape: const CircleBorder(), - onPressed: _swipeRight, - child: Stack( - // to deal with icons inner transparency - alignment: Alignment.center, - children: [ - Container( - width: 48.0, - height: 48.0, - decoration: const BoxDecoration( - color: Colors.white70, - shape: BoxShape.circle, - ), - ), - const Icon(Icons.check_circle, color: Colors.green, size: 72), - ], - ), - ), - ), - FloatingActionButton( - tooltip: 'Skip profile', - heroTag: 'myFABSkip', - 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.thumb_up - : (direction == SwipeDirection.left - ? Icons.thumb_down - : 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 a82d1ea..b135d19 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -2,11 +2,14 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import '../constants.dart'; import '../enumerations.dart'; +import '../models/language.dart'; +import '../models/location.dart'; +import '../models/user_profile.dart'; class UserService { - final FirebaseFirestore _firestore = FirebaseFirestore.instance; + UserService._(); // Private constructor to prevent instantiation - Future saveUserData(UserCredential userCredential, String email, + static Future saveUserData(UserCredential userCredential, String email, String firstname, String lastname) async { // create full name String fullName = (firstname.isNotEmpty && lastname.isNotEmpty) @@ -16,7 +19,7 @@ class UserService { : (lastname.isNotEmpty ? lastname : '')); // save user info to users document - await _firestore + await FirebaseFirestore.instance .collection(Constants.dbCollectionUsers) .doc(userCredential.user!.uid) .set( @@ -30,10 +33,10 @@ class UserService { ); } - Future> getSkillsFromFirebase( + static Future> getSkillsFromFirebase( bool skillsSought, String userId) async { // Fetch skills from Firestore - DocumentSnapshot userDoc = await _firestore + DocumentSnapshot userDoc = await FirebaseFirestore.instance .collection(Constants.dbCollectionUsers) .doc(userId) .get(); @@ -63,7 +66,7 @@ class UserService { return []; } - Future saveSkillsToFirebase(List selectedOptions, + static Future saveSkillsToFirebase(List selectedOptions, bool skillsSought, String userId) async { try { // Convert enum values to strings, removing leading EnumType with split @@ -76,7 +79,7 @@ class UserService { ? Constants.dbFieldUsersSkillsSought : Constants.dbFieldUsersSkills; - _firestore + FirebaseFirestore.instance .collection(Constants.dbCollectionUsers) .doc(userId) .update({keyToUpdate: skills}); @@ -86,4 +89,48 @@ class UserService { return false; } } + + /// Get UserProfile for given [userId] + static Future getUserProfileById(String userId) async { + DocumentSnapshot doc = await FirebaseFirestore.instance + .collection(Constants.dbCollectionUsers) + .doc(userId) + .get(); + return UserProfile.fromDocument(doc); + } + + static Future getDataById(String userId) async { + FirebaseFirestore firestore = FirebaseFirestore.instance; + + DocumentSnapshot userDoc = await firestore + .collection(Constants.dbCollectionUsers) + .doc(userId) + .get(); + + QuerySnapshot languagesSnapshot = await firestore + .collection(Constants.dbCollectionUsers) + .doc(userId) + .collection(Constants.dbCollectionLanguages) + .get(); + List languages = languagesSnapshot.docs + .map((doc) => Language.fromDocument(doc)) + .toList(); + + QuerySnapshot locationsSnapshot = await firestore + .collection(Constants.dbCollectionUsers) + .doc(userId) + .collection(Constants.dbCollectionLocations) + .get(); + Map locations = { + for (var doc in locationsSnapshot.docs) + doc.id: MyLocation.fromDocument(doc) + }; + + // Fill UserProfile including its sub collections + UserProfile userProfile = UserProfile.fromDocument(userDoc); + userProfile.languages.addAll(languages); + userProfile.locations.addAll(locations); + + return userProfile; + } } diff --git a/pubspec.lock b/pubspec.lock index ca8ebfc..65259f1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "3dee3db3468c5f4640a4e8aa9c1e22561c298976d8c39ed2fdd456a9a3db26e1" + sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7" url: "https://pub.dev" source: hosted - version: "1.3.32" + version: "1.3.35" args: dependency: transitive description: @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + url: "https://pub.dev" + source: hosted + version: "0.3.4+1" crypto: dependency: transitive description: @@ -105,6 +113,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" firebase_auth: dependency: "direct main" description: @@ -133,10 +173,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "4aef2a23d0f3265545807d68fbc2f76a6b994ca3c778d88453b99325abd63284" + sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c" url: "https://pub.dev" source: hosted - version: "2.30.1" + version: "2.32.0" firebase_core_platform_interface: dependency: transitive description: @@ -149,10 +189,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "67f2fcc600fc78c2f731c370a3a5e6c87ee862e3a2fba6f951eca6d5dafe5c29" + sha256: "43d9e951ac52b87ae9cc38ecdcca1e8fa7b52a1dd26a96085ba41ce5108db8e9" url: "https://pub.dev" source: hosted - version: "2.16.0" + version: "2.17.0" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: "2ae478ceec9f458c1bcbf0ee3e0100e4e909708979e83f16d5d9fba35a5b42c1" + url: "https://pub.dev" + source: hosted + version: "11.7.7" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: "4e18662e6a66e2e0e181c06f94707de06d5097d70cfe2b5141bf64660c5b5da9" + url: "https://pub.dev" + source: hosted + version: "5.1.22" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: "3a44aacd38a372efb159f6fe36bb4a7d79823949383816457fd43d3d47602a53" + url: "https://pub.dev" + source: hosted + version: "3.9.7" fixnum: dependency: transitive description: @@ -174,6 +238,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" + url: "https://pub.dev" + source: hosted + version: "2.0.19" flutter_svg: dependency: "direct main" description: @@ -288,6 +360,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "33974eca2e87e8b4e3727f1b94fa3abcb25afe80b6bc2c4d449a0e150aedf720" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "0f57fee1e8bfadf8cc41818bbcd7f72e53bb768a54d9496355d5e8a5681a19f1" + url: "https://pub.dev" + source: hosted + version: "0.8.12+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + url: "https://pub.dev" + source: hosted + version: "3.0.4" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4824d8c7f6f89121ef0122ff79bb00b009607faecc8545b86bca9ab5ce1e95bf" + url: "https://pub.dev" + source: hosted + version: "0.8.11+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" leak_tracker: dependency: transitive description: @@ -344,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" nested: dependency: transitive description: @@ -543,4 +687,4 @@ packages: version: "6.5.0" sdks: dart: ">=3.3.3 <4.0.0" - flutter: ">=3.7.0-0" + flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml index 23cde6d..b1d779b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: geocoding: ^3.0.0 collection: ^1.18.0 swipable_stack: ^2.0.0 + image_picker: ^1.1.1 + firebase_storage: ^11.7.7 dev_dependencies: flutter_test: