608 lines
21 KiB
Dart
608 lines
21 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:percent_indicator/circular_percent_indicator.dart';
|
|
import 'package:swipable_stack/swipable_stack.dart';
|
|
|
|
import '../components/card_overlay.dart';
|
|
import '../components/language_list.dart';
|
|
import '../components/text_bold.dart';
|
|
import '../components/text_with_bold.dart';
|
|
import '../constants.dart';
|
|
import '../forms/matched_screen.dart';
|
|
import '../models/user_profile.dart';
|
|
import '../services/auth/auth_service.dart';
|
|
import '../services/swipe_stream_service.dart';
|
|
import '../services/user_service.dart';
|
|
import '../utils/helper.dart';
|
|
import '../utils/list_utils.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
|
|
.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) =>
|
|
doc[Constants.dbFieldSwipesLike] == false &&
|
|
(doc[Constants.dbFieldSwipesTimestamp] as Timestamp)
|
|
.toDate()
|
|
.isAfter(thresholdDate))
|
|
.map((doc) => doc.id)
|
|
.toSet();
|
|
|
|
for (var userDoc in usersSnapshot.docs) {
|
|
UserProfile userProfile =
|
|
await UserService.getUserProfileFromDocument(userDoc);
|
|
|
|
// add profiles accordingly
|
|
allUsers.add(userProfile);
|
|
// Exclude (1) the current user's profile, (2) the already liked profiles
|
|
// (3) users that were disliked less than 24 hours ago
|
|
// and (4) not active profile
|
|
if (userDoc.id != currentUserId &&
|
|
!likedUserIds.contains(userDoc.id) &&
|
|
!dislikedUserIds.contains(userDoc.id) &&
|
|
(userProfile.active ?? false)) {
|
|
showProfiles.add(userProfile);
|
|
}
|
|
} // end for
|
|
|
|
setState(() {
|
|
userProfiles = allUsers;
|
|
potentialUserProfiles = showProfiles;
|
|
currentUserProfile =
|
|
allUsers.firstWhereOrNull((x) => x.uid == currentUserId);
|
|
});
|
|
}
|
|
|
|
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(
|
|
{
|
|
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<void> _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);
|
|
|
|
// current user swiped, so avoid local push notification
|
|
SwipeStreamService().addUser(swipedUserId);
|
|
|
|
await matchesCurrentUser.doc(swipedUserId).set({
|
|
'otherUserId': swipedUserId,
|
|
'timestamp': FieldValue.serverTimestamp(),
|
|
});
|
|
|
|
await matchesSwipedUser.doc(currentUserId).set({
|
|
'otherUserId': currentUserId,
|
|
'timestamp': FieldValue.serverTimestamp(),
|
|
});
|
|
|
|
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,
|
|
profileImageUrl: swipedUser.profilePictureUrl,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
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; // 39 - 20
|
|
} else {
|
|
return Colors.red.shade400; // 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 TextBold(text: 'Loading data, please wait...'),
|
|
] else ...[
|
|
TextBold(
|
|
text: userProfiles.length > 1
|
|
? 'No new profiles available yet.'
|
|
: 'No profiles available at the moment.',
|
|
),
|
|
const SizedBox(height: 60),
|
|
const TextBold(
|
|
text: 'Please check back later, perhaps tomorrow.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(16.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;
|
|
|
|
String pronoun = getPronoun(userProfile.gender);
|
|
String possAdjective = getPossessiveAdjective(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'}';
|
|
}
|
|
|
|
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: TextBold(
|
|
text: "${matchScore.toStringAsFixed(2)}%",
|
|
fontSize: 16.0,
|
|
),
|
|
circularStrokeCap: CircularStrokeCap.round,
|
|
progressColor: _getProgressColor(matchScore),
|
|
backgroundWidth: 2,
|
|
),
|
|
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),
|
|
TextWithBold(
|
|
leadingText:
|
|
'Lives in $location which is ${shortDist <= 20 ? 'only ' : ''}about ',
|
|
boldText: '${shortDist.toStringAsFixed(0)} km',
|
|
trailingText: ' away from you.',
|
|
),
|
|
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(', '),
|
|
),
|
|
Text(
|
|
'and is willing to commit in '
|
|
'${userProfile.availability.commitmentText}.',
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'$possAdjective Sectors of Interest ',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const Expanded(child: Divider()),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(sortSectorsList(userProfile.sectors)
|
|
.map((x) => x.displayName)
|
|
.join(' \u25CF ')),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Spoken languages ',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const Expanded(child: Divider()),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
MyLanguageList(
|
|
langList: userProfile.languages,
|
|
iconHeight: 12,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 TextBold(
|
|
text: 'You\'ve viewed all available profiles.',
|
|
textAlign: TextAlign.center,
|
|
fontSize: 20,
|
|
),
|
|
const SizedBox(height: 36),
|
|
const TextBold(
|
|
text: 'Would you like to do another run '
|
|
'and see the remaining profiles again?',
|
|
textAlign: TextAlign.center,
|
|
fontSize: 20,
|
|
),
|
|
const SizedBox(height: 20),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
// Restart swiping from the beginning
|
|
_controller.currentIndex = 0;
|
|
});
|
|
},
|
|
child: const Text('Another run'),
|
|
),
|
|
const SizedBox(height: 20), // additional space
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|