import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:swipable_stack/swipable_stack.dart'; import '../components/card_overlay.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/helper.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[Constants.dbFieldSwipesLike] == true) .map((doc) => doc.id) .toSet(); final DateTime thresholdDate = DateTime.now().subtract(const Duration(hours: 24)); final Set dislikedUserIds = swipesSnapshot.docs .where((doc) => doc[Constants.dbFieldSwipesLike] == false && (doc[Constants.dbFieldSwipesTimestamp] 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(); // get languages 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(); // get locations final locationsSnapshot = await userDoc.reference .collection(Constants.dbCollectionLocations) .get(); 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, }; // create userProfile including the data above UserProfile userProfile = UserProfile.fromDocument(userDoc); userProfile.locations.addAll(locations); userProfile.languages.addAll(languages); // 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( { Constants.dbFieldSwipesSwipedId: swipedUserId, Constants.dbFieldSwipesLike: direction == SwipeDirection.right, Constants.dbFieldSwipesTimestamp: 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(String swipedUserId) async { String currentUserId = _authService.getCurrentUser()!.uid; final QuerySnapshot matchSnapshot = await _firestore .collection(Constants.dbCollectionUsers) .doc(swipedUserId) .collection(Constants.dbCollectionSwipes) .where(Constants.dbFieldSwipesSwipedId, isEqualTo: currentUserId) .where(Constants.dbFieldSwipesLike, isEqualTo: true) .get(); if (matchSnapshot.docs.isNotEmpty) { // save match for both users // using set method (with UserID) instead of add (with autogenerated ID) final matchesCurrentUser = _firestore .collection(Constants.dbCollectionUsers) .doc(currentUserId) .collection(Constants.dbCollectionMatches); final matchesSwipedUser = _firestore .collection(Constants.dbCollectionUsers) .doc(swipedUserId) .collection(Constants.dbCollectionMatches); await matchesCurrentUser.doc(swipedUserId).set({ 'otherUserId': swipedUserId, 'timestamp': FieldValue.serverTimestamp(), }); await matchesSwipedUser.doc(currentUserId).set({ 'otherUserId': currentUserId, 'timestamp': FieldValue.serverTimestamp(), }); // // TODO Notify other user? // showMatchedScreen(currentUserId, swipedUserId); // Remove matched user from the list of potential users potentialUserProfiles.removeWhere((x) => x.uid == swipedUserId); } } showMatchedScreen(String currentUserId, String swipedUserId) { UserProfile currentUser = _getUserProfile(currentUserId); UserProfile swipedUser = _getUserProfile(swipedUserId); Navigator.push( context, MaterialPageRoute( builder: (context) => MatchedScreen( currentUserName: currentUser.name, otherUserName: swipedUser.name, currentUserImageUrl: currentUser.profilePictureUrl ?? '', otherUserImageUrl: swipedUser.profilePictureUrl ?? '', onMessageButtonPressed: () { Navigator.pop(context); // Close the MatchedScreen Navigator.push( context, MaterialPageRoute( builder: (context) => ChatPage( receiverEmail: swipedUser.email, receiverID: swipedUser.uid, chatTitle: swipedUser.name, ), ), ); }, onContinueButtonPressed: () { Navigator.pop(context); // Close the MatchedScreen }, ), ), ); } Color _getProgressColor(double percentage) { if (percentage >= 85) { return Colors.green.shade500; // 100 - 85 } else if (percentage >= 70) { return Colors.green.shade400; // 84 - 70 } else if (percentage >= 55) { return Colors.lightGreen.shade400; // 69 - 55 } else if (percentage >= 40) { return Colors.amber.shade200; // 54 - 40 } else if (percentage >= 20) { return Colors.orange.shade200; // 39 - 20 } else { return Colors.orange.shade300; // 19 - 0 } } @override Widget build(BuildContext context) { if (potentialUserProfiles.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Find your Match')), 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) { String? profileImageUrl = userProfile.profilePictureUrl; // Sort the languages according to the given order below List sortedLanguages = List.from(userProfile.languages); sortedLanguages.sort((a, b) { if (a.code == 'de') return -1; // German first if (b.code == 'de') return 1; // German first if (a.code == 'en') return -1; // English second if (b.code == 'en') return 1; // English second return a.name.compareTo(b.name); // All others by name ascending }); String pronoun = getPronoun(userProfile.gender); double shortDist = shortestDistanceBetweenUsers(currentUserProfile!, userProfile); double matchScore = calculateMatchScore(currentUserProfile!, userProfile); return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Stack( alignment: Alignment.bottomCenter, children: [ CircularPercentIndicator( radius: 55.0, lineWidth: 5.0, animation: true, percent: matchScore / 100, header: Text( "${matchScore.toStringAsFixed(2)}%", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16.0), ), circularStrokeCap: CircularStrokeCap.round, progressColor: _getProgressColor(matchScore), ), Positioned( bottom: 5, // Manually adjusted avatar position child: CircleAvatar( radius: 50, backgroundImage: ((profileImageUrl != null && profileImageUrl.isNotEmpty)) ? NetworkImage(profileImageUrl) : null, child: (profileImageUrl == null || profileImageUrl.isEmpty) ? const Icon(Icons.person_pin, size: 50) : null, ), ), ], ), ), const SizedBox(height: 8), Center( child: Text( '${userProfile.name} ${ageInfo(userProfile.born)}'.trim(), style: const TextStyle(fontSize: 24)), ), const SizedBox(height: 8), Text( 'Would like to team up with someone who has experience in ' '${userProfile.skillsSought.map((x) => x.displayName).join(', ')}.', ), Text( '$pronoun brings skills and experience in ' '${userProfile.skills.map((x) => x.displayName).join(', ')}', ), Text( 'and is willing to commit in ' '${userProfile.availability.commitmentText}.', ), Text( 'Lives in ${userProfile.locations[Constants.dbDocMainLocation]?.toString() ?? 'N/A'}' ' and ${userProfile.locations[Constants.dbDocSecondLocation]?.toString() ?? 'N/A'}' ' which is ${shortDist <= 20 ? 'only ' : ''}about ${shortDist.toStringAsFixed(0)} km away from you.', ), const SizedBox(height: 8), const Row( children: [ Text('Spoken languages '), Expanded(child: Divider()), ], ), const SizedBox(height: 4), Wrap( children: sortedLanguages.map( (language) { return Row( mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( language.iconFile, height: 12.0, ), const SizedBox(width: 4.0), Text(language.name), // Space between each language icon pair const SizedBox(width: 8.0), ], ); }, ).toList(), ), //Text('Risk type: ${userProfile.risk.displayName}'), ], ), ), ), ); } 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), ), ], ), ); } }