diff --git a/lib/components/card_overlay.dart b/lib/components/card_overlay.dart new file mode 100644 index 0000000..77d1ed8 --- /dev/null +++ b/lib/components/card_overlay.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:swipable_stack/swipable_stack.dart'; + +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 : null), + size: 100, + color: direction == SwipeDirection.right + ? Colors.green + : (direction == SwipeDirection.left ? Colors.red : Colors.blue), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/location_selector.dart b/lib/components/location_selector.dart index 84872b3..a3d97c5 100644 --- a/lib/components/location_selector.dart +++ b/lib/components/location_selector.dart @@ -26,38 +26,40 @@ class LocationSelectorState extends State { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - // onSubmitted: (loc) => {_searchLocation2(loc)}, - controller: _locationController, - decoration: InputDecoration( - labelText: 'Enter location', - suffixIcon: IconButton( - icon: const Icon(Icons.search), - onPressed: _searchLocation, + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + // onSubmitted: (loc) => {_searchLocation2(loc)}, + controller: _locationController, + decoration: InputDecoration( + labelText: 'Enter location', + suffixIcon: IconButton( + icon: const Icon(Icons.search), + onPressed: _searchLocation, + ), ), ), - ), - Text( - errorText != null ? '$errorText' : '', - style: const TextStyle(color: Colors.red), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: _getCurrentLocation, - child: const Text('Use Current Position'), - ), - const SizedBox(height: 20), - Text('Country: $_country'), - Text('City: $_city'), - Text('Postal Code: $_postalCode'), - Text('Street: $_street'), - Text('Administrative Area: $_administrativeArea'), - Text('Latitude: $_latitude'), - Text('Longitude: $_longitude'), - ], + Text( + errorText != null ? '$errorText' : '', + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _getCurrentLocation, + child: const Text('Use Current Position'), + ), + const SizedBox(height: 20), + Text('Country: $_country'), + Text('City: $_city'), + Text('Postal Code: $_postalCode'), + Text('Street: $_street'), + Text('Administrative Area: $_administrativeArea'), + Text('Latitude: $_latitude'), + Text('Longitude: $_longitude'), + ], + ), ); } diff --git a/lib/pages/user_matching_page.dart b/lib/pages/user_matching_page.dart index 9017d42..6547b9d 100644 --- a/lib/pages/user_matching_page.dart +++ b/lib/pages/user_matching_page.dart @@ -1,16 +1,18 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.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'; @@ -270,6 +272,22 @@ class UserMatchingPageState extends State { ); } + 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) { @@ -401,9 +419,11 @@ class UserMatchingPageState extends State { return a.name.compareTo(b.name); // All others by name ascending }); - String shortDist = - shortestDistanceBetweenUsers(currentUserProfile!, userProfile) - .toStringAsFixed(0); + String pronoun = getPronoun(userProfile.gender); + + double shortDist = + shortestDistanceBetweenUsers(currentUserProfile!, userProfile); + double matchScore = calculateMatchScore(currentUserProfile!, userProfile); return Card( child: Padding( @@ -413,24 +433,38 @@ class UserMatchingPageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (kDebugMode) - Center( - child: Text( - userProfile.email, - style: const TextStyle(fontSize: 12), - ), - ), - Center( - 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, + 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), @@ -445,7 +479,7 @@ class UserMatchingPageState extends State { '${userProfile.skillsSought.map((x) => x.displayName).join(', ')}.', ), Text( - 'He/She brings skills and experience in ' + '$pronoun brings skills and experience in ' '${userProfile.skills.map((x) => x.displayName).join(', ')}', ), Text( @@ -455,7 +489,7 @@ class UserMatchingPageState extends State { Text( 'Lives in ${userProfile.locations[Constants.dbDocMainLocation]?.toString() ?? 'N/A'}' ' and ${userProfile.locations[Constants.dbDocSecondLocation]?.toString() ?? 'N/A'}' - ' which is only/about $shortDist km away from you.', + ' which is ${shortDist <= 20 ? 'only ' : ''}about ${shortDist.toStringAsFixed(0)} km away from you.', ), const SizedBox(height: 8), const Row( @@ -604,40 +638,3 @@ class UserMatchingPageState extends State { ); } } - -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 : null), - size: 100, - color: direction == SwipeDirection.right - ? Colors.green - : (direction == SwipeDirection.left ? Colors.red : Colors.blue), - ), - ), - ), - ); - } -} diff --git a/lib/utils/helper.dart b/lib/utils/helper.dart index 9d7d1a7..ec7e5d5 100644 --- a/lib/utils/helper.dart +++ b/lib/utils/helper.dart @@ -2,6 +2,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import '../enumerations.dart'; + /// /// Compare two lists by their content ignoring their elements order. /// @@ -61,6 +63,21 @@ String formatTimestamp(Timestamp timestamp) { } } +/// +/// Get pronoun for given [userGender]. +/// Returns [He] if male, [She] if female, else [He/She]. +/// +String getPronoun(Gender? userGender) { + switch (userGender) { + case Gender.male: + return 'He'; + case Gender.female: + return 'She'; + default: + return 'He/She'; + } +} + /// /// Get the [displayName] of our own Enumerations. /// diff --git a/lib/utils/math.dart b/lib/utils/math.dart index 612977c..3ae126f 100644 --- a/lib/utils/math.dart +++ b/lib/utils/math.dart @@ -1,5 +1,6 @@ import 'dart:math'; - +import 'package:flutter/foundation.dart' show kDebugMode, debugPrint; +import '../enumerations.dart'; import '../models/user_profile.dart'; /// Approximate determination of age @@ -97,3 +98,193 @@ double shortestDistanceBetweenUsers( return double.nan; } } + +/// Calculates the matching score of [otherUser] for [currentUser]. +double calculateMatchScore(UserProfile currentUser, UserProfile otherUser) { + // weights + const double distanceWeight = 0.55; + const double skillWeight = 0.25; + const double availabilityWeight = 0.065; + const double visionWeight = 0.04; + const double riskWeight = 0.035; + const double workWeight = 0.025; + const double cultureWeight = 0.02; + const double communicationWeight = 0.015; + + if (kDebugMode) { + double weightSum = (distanceWeight + + skillWeight + + availabilityWeight + + visionWeight + + riskWeight + + workWeight + + cultureWeight + + communicationWeight); + if (weightSum != 1) { + debugPrint( + 'Warning --> calculateMatchScore : Weights Sum $weightSum != 1'); + } + } + + // Score on locations distance + double distance = shortestDistanceBetweenUsers(currentUser, otherUser); + double distanceScore = _distanceToPercentage(distance); + + // Score on skills + int matchingSkillsSought = currentUser.skillsSought + .toSet() + .intersection(otherUser.skills.toSet()) + .length; + int matchingSkillsOffered = otherUser.skills + .toSet() + .intersection(currentUser.skillsSought.toSet()) + .length; + + int skillsSought = currentUser.skillsSought.length; + int skillsOffered = otherUser.skills.length; + + // Idea: Calculate sum of matching skills divided by amount of skills listed. + // As each list can have up to 3 skills max, this gives the following equation + // with 3 skills: 0 1/3 2/3 3/3; with 2 skills: 0 1/2 2/2; with 1 skill: 0 1. + // In total this will result in a total of 5 different states: + // [0], [1/3], [1/2], [2/3], and [1]. + + double valueSought = matchingSkillsSought / skillsSought; + int scoreSought; + if (valueSought == 1) { + scoreSought = 4; + } else if (valueSought == 2 / 3) { + scoreSought = 3; + } else if (valueSought == 1 / 2) { + scoreSought = 2; + } else if (valueSought == 1 / 3) { + scoreSought = 1; + } else { + scoreSought = 0; + } + + double valueOffered = matchingSkillsOffered / skillsOffered; + int scoreOffered; + if (valueOffered == 1) { + scoreOffered = 4; + } else if (valueOffered == 2 / 3) { + scoreOffered = 3; + } else if (valueOffered == 1 / 2) { + scoreOffered = 2; + } else if (valueOffered == 1 / 3) { + scoreOffered = 1; + } else { + scoreOffered = 0; + } + + int skillsScore = scoreSought + scoreOffered; // 8 points max + + // Score on availability + int availabilityScore; + AvailabilityOption currentAvail = currentUser.availability; + AvailabilityOption otherAvail = otherUser.availability; + + if (currentAvail == AvailabilityOption.flexible || + otherAvail == AvailabilityOption.flexible || + currentAvail == otherAvail) { + availabilityScore = 3; + } else { + int availabilityDifference = (currentAvail.index - otherAvail.index).abs(); + availabilityScore = 3 - availabilityDifference; + } + + // Score on Vision + List currentVisions = currentUser.visions; + List otherVisions = otherUser.visions; + int matchingVisions = + currentVisions.toSet().intersection(otherVisions.toSet()).length; + + int visionScore; + if (matchingVisions == 4 || + ((currentVisions.length == otherVisions.length) && + (matchingVisions == currentVisions.length))) { + visionScore = 4; // full match, max score + } else if (matchingVisions > 1) { + visionScore = 3; // some match + } else if (matchingVisions == 1) { + visionScore = 2; // one match + } else { + visionScore = 0; // no match + } + + // Score on WorkValues + List currentWorks = currentUser.workValues; + List otherWorks = otherUser.workValues; + int matchingWorks = + currentWorks.toSet().intersection(otherWorks.toSet()).length; + + int workScore; + if (matchingWorks == 2 || + ((currentWorks.length == otherWorks.length) && (matchingWorks == 1))) { + workScore = 4; // full match, max score + } else if (matchingWorks == 1) { + workScore = 2; // semi match + } else { + workScore = 0; // no match + } + + // Score on Risk + int riskScore = 0; + if (currentUser.risk == otherUser.risk) { + riskScore = 1; + } + + // Score on CorporateCulture + int cultureScore = 0; + if (currentUser.culture == otherUser.culture) { + cultureScore = 1; + } + + // Score on Communication + int communicationScore = 0; + if (currentUser.communication == otherUser.communication) { + communicationScore = 1; + } + + // Calc total score. Sum of each score divided by its own max score value, + // multiplied with its own weight. + double totalScore = (distanceWeight * distanceScore / 100) + + (skillWeight * skillsScore / 8) + + (availabilityWeight * availabilityScore / 3) + + (visionWeight * visionScore / 4) + + (workWeight * workScore / 4) + + (riskWeight * riskScore) + + (cultureWeight * cultureScore) + + (communicationWeight * communicationScore); + + return totalScore * 100; +} + +double _distanceToPercentage(double distance) { + // Self predefined data points + final List distances = [0, 5, 50, 100, 200]; + final List percentages = [100, 99.5, 95, 90, 50]; + + if (distance.isNaN || distance >= distances.last) { + return percentages.last; + } else if (distance <= distances[0]) { + return percentages[0]; + } + + // Linear interpolation + for (int i = 1; i < distances.length; i++) { + if (distances[i] >= distance) { + double x0 = distances[i - 1]; + double y0 = percentages[i - 1]; + double x1 = distances[i]; + double y1 = percentages[i]; + + // Interpolate + double percentage = y0 + (y1 - y0) * (distance - x0) / (x1 - x0); + return percentage; + } + } + + // Fallback return value, though code should never reach here + return percentages.last; // 50 +} diff --git a/pubspec.lock b/pubspec.lock index f13ead9..83ab765 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -544,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: c37099ad833a883c9d71782321cb65c3a848c21b6939b6185f0ff6640d05814c + url: "https://pub.dev" + source: hosted + version: "4.2.3" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0e29a02..c9c3de8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: image_picker: ^1.1.1 firebase_storage: ^11.7.7 image_cropper: ^6.0.0 + percent_indicator: ^4.2.3 dev_dependencies: flutter_test: