Added Match Score Percent Indicator
parent
4f4abffb5b
commit
e057d428a5
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -26,38 +26,40 @@ class LocationSelectorState extends State<LocationSelector> {
|
|||
|
||||
@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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<UserMatchingPage> {
|
|||
);
|
||||
}
|
||||
|
||||
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<UserMatchingPage> {
|
|||
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<UserMatchingPage> {
|
|||
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<UserMatchingPage> {
|
|||
'${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<UserMatchingPage> {
|
|||
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<UserMatchingPage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
///
|
||||
|
|
|
@ -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<VisionOption> currentVisions = currentUser.visions;
|
||||
List<VisionOption> 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<WorkValueOption> currentWorks = currentUser.workValues;
|
||||
List<WorkValueOption> 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<double> distances = [0, 5, 50, 100, 200];
|
||||
final List<double> 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
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue