cofounderella/lib/pages/user_matching_page.dart

638 lines
22 KiB
Dart
Raw Normal View History

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
2024-06-12 00:39:29 +02:00
import 'package:percent_indicator/circular_percent_indicator.dart';
import 'package:swipable_stack/swipable_stack.dart';
2024-06-12 00:39:29 +02:00
import '../components/card_overlay.dart';
2024-06-14 14:28:59 +02:00
import '../components/language_list.dart';
import '../components/text_with_bold.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';
2024-06-12 00:39:29 +02:00
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<UserMatchingPage> {
/// The current's user profile
UserProfile? currentUserProfile;
/// List with [all] user profiles
List<UserProfile> userProfiles = [];
/// List of user profiles to show.
List<UserProfile> 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<void> _fetchUserProfiles() async {
final String currentUserId = _authService.getCurrentUser()!.uid;
List<UserProfile> allUsers = [];
List<UserProfile> 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<String> likedUserIds = swipesSnapshot.docs
2024-06-09 00:04:13 +02:00
.where((doc) => doc[Constants.dbFieldSwipesLike] == true)
.map((doc) => doc.id)
.toSet();
final DateTime thresholdDate =
DateTime.now().subtract(const Duration(hours: 24));
final Set<String> dislikedUserIds = swipesSnapshot.docs
.where((doc) =>
2024-06-09 00:04:13 +02:00
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();
2024-05-30 16:37:34 +02:00
// 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();
2024-05-30 16:37:34 +02:00
// 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,
};
2024-05-30 16:37:34 +02:00
// 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<String, dynamic>? 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<void> _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(
{
2024-06-09 00:04:13 +02:00
Constants.dbFieldSwipesSwipedId: swipedUserId,
Constants.dbFieldSwipesLike: direction == SwipeDirection.right,
Constants.dbFieldSwipesTimestamp: FieldValue.serverTimestamp(),
},
);
}
2024-05-30 16:37:34 +02:00
UserProfile _getUserProfile(String userId) {
return userProfiles.firstWhere((x) => x.uid == userId);
}
/// Check whether the swiped user has also swiped to the right
2024-05-30 16:37:34 +02:00
Future<void> _checkForMatch(String swipedUserId) async {
String currentUserId = _authService.getCurrentUser()!.uid;
final QuerySnapshot matchSnapshot = await _firestore
.collection(Constants.dbCollectionUsers)
.doc(swipedUserId)
.collection(Constants.dbCollectionSwipes)
2024-06-09 00:04:13 +02:00
.where(Constants.dbFieldSwipesSwipedId, isEqualTo: currentUserId)
.where(Constants.dbFieldSwipesLike, isEqualTo: true)
.get();
if (matchSnapshot.docs.isNotEmpty) {
// save match for both users
2024-05-30 16:37:34 +02:00
// 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);
2024-05-30 16:37:34 +02:00
await matchesCurrentUser.doc(swipedUserId).set({
'otherUserId': swipedUserId,
'timestamp': FieldValue.serverTimestamp(),
});
2024-05-30 16:37:34 +02:00
await matchesSwipedUser.doc(currentUserId).set({
'otherUserId': currentUserId,
'timestamp': FieldValue.serverTimestamp(),
});
//
// TODO Notify other user?
//
showMatchedScreen(currentUserId, swipedUserId);
2024-05-30 16:37:34 +02:00
// Remove matched user from the list of potential users
potentialUserProfiles.removeWhere((x) => x.uid == swipedUserId);
}
}
showMatchedScreen(String currentUserId, String swipedUserId) {
2024-05-30 16:37:34 +02:00
UserProfile currentUser = _getUserProfile(currentUserId);
UserProfile swipedUser = _getUserProfile(swipedUserId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MatchedScreen(
2024-05-30 16:37:34 +02:00
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
},
),
),
);
}
2024-06-12 00:39:29 +02:00
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(
2024-06-07 16:58:29 +02:00
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) {
2024-06-09 00:04:13 +02:00
String? profileImageUrl = userProfile.profilePictureUrl;
2024-06-12 00:39:29 +02:00
String pronoun = getPronoun(userProfile.gender);
String location =
userProfile.locations[Constants.dbDocMainLocation]?.toString() ?? 'N/A';
if (userProfile.locations.containsKey(Constants.dbDocSecondLocation) &&
userProfile.locations[Constants.dbDocSecondLocation] != null) {
location =
'$location and ${userProfile.locations[Constants.dbDocSecondLocation]?.toString() ?? 'N/A'}';
}
2024-06-12 00:39:29 +02:00
double shortDist =
shortestDistanceBetweenUsers(currentUserProfile!, userProfile);
double matchScore = calculateMatchScore(currentUserProfile!, userProfile);
2024-06-09 00:04:13 +02:00
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
2024-06-09 00:04:13 +02:00
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2024-06-09 00:04:13 +02:00
Center(
2024-06-12 00:39:29 +02:00
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,
),
2024-06-12 00:39:29 +02:00
),
circularStrokeCap: CircularStrokeCap.round,
progressColor: _getProgressColor(matchScore),
backgroundWidth: 2,
2024-06-12 00:39:29 +02:00
),
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,
),
),
],
2024-06-09 00:04:13 +02:00
),
),
const SizedBox(height: 8),
Center(
child: Text(
'${userProfile.name} ${ageInfo(userProfile.born)}'.trim(),
style: const TextStyle(fontSize: 24),
),
2024-06-09 00:04:13 +02:00
),
const SizedBox(height: 8),
TextWithBold(
leadingText:
'Lives in $location which is ${shortDist <= 20 ? 'only ' : ''}about ',
boldText: '${shortDist.toStringAsFixed(0)} km',
trailingText: ' away from you.',
2024-06-09 00:04:13 +02:00
),
const SizedBox(height: 6),
TextWithBold(
leadingText:
'Would like to team up with someone who has experience in ',
boldText: userProfile.skillsSought
.map((x) => x.displayName)
.join(', '),
trailingText: '.',
),
const SizedBox(height: 8),
TextWithBold(
leadingText: '$pronoun brings skills and experience in ',
boldText:
userProfile.skills.map((x) => x.displayName).join(', '),
2024-06-09 00:04:13 +02:00
),
Text(
2024-06-09 00:04:13 +02:00
'and is willing to commit in '
'${userProfile.availability.commitmentText}.',
),
const SizedBox(height: 8),
const Row(
children: [
Text('Spoken languages '),
Expanded(child: Divider()),
],
),
const SizedBox(height: 4),
2024-06-14 14:28:59 +02:00
MyLanguageList(
langList: userProfile.languages,
iconHeight: 12,
2024-06-09 00:04:13 +02:00
),
],
),
),
),
);
}
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),
),
],
),
);
}
}