Added Match Score Percent Indicator

master
Rafael 2024-06-12 00:39:29 +02:00
parent 4f4abffb5b
commit e057d428a5
7 changed files with 346 additions and 91 deletions

View File

@ -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),
),
),
),
);
}
}

View File

@ -26,7 +26,8 @@ class LocationSelectorState extends State<LocationSelector> {
@override
Widget build(BuildContext context) {
return Column(
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
@ -58,6 +59,7 @@ class LocationSelectorState extends State<LocationSelector> {
Text('Latitude: $_latitude'),
Text('Longitude: $_longitude'),
],
),
);
}

View File

@ -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,26 +433,40 @@ class UserMatchingPageState extends State<UserMatchingPage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (kDebugMode)
Center(
child: Text(
userProfile.email,
style: const TextStyle(fontSize: 12),
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),
),
Center(
Positioned(
bottom: 5, // Manually adjusted avatar position
child: CircleAvatar(
radius: 50,
backgroundImage:
((profileImageUrl != null && profileImageUrl.isNotEmpty))
backgroundImage: ((profileImageUrl != null &&
profileImageUrl.isNotEmpty))
? NetworkImage(profileImageUrl)
: null,
child: (profileImageUrl == null || profileImageUrl.isEmpty)
child:
(profileImageUrl == null || profileImageUrl.isEmpty)
? const Icon(Icons.person_pin, size: 50)
: null,
),
),
],
),
),
const SizedBox(height: 8),
Center(
child: Text(
@ -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),
),
),
),
);
}
}

View File

@ -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.
///

View File

@ -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
}

View File

@ -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:

View File

@ -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: