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 'chat_page.dart'; class UserProfilePage extends StatefulWidget { const UserProfilePage({super.key}); @override UserProfilePageState createState() => UserProfilePageState(); } class UserProfilePageState extends State { /// List with [all] user profiles List userProfiles = []; /// The current's user profile UserProfile? currentUserProfile; /// Other users that yet did either not receive a swipe status List potentialUserProfiles = []; // potential users for matching 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 usersSnapshot = await FirebaseFirestore.instance .collection(Constants.dbCollectionUsers) .get(); final String currentUserId = _authService.getCurrentUser()!.uid; // 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(); List allUsers = []; List showProfiles = []; 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 the current user's profile and the already liked profiles if (userDoc.id != currentUserId && !likedUserIds.contains(userDoc.id)) { showProfiles.add(userProfile); } 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 }, ), ), ); } @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), Text( '${userProfiles.length > 1 ? 'No new' : '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('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, 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( '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), ), ), ), ); } }