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,7 +26,8 @@ class LocationSelectorState extends State<LocationSelector> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
|
@ -58,6 +59,7 @@ class LocationSelectorState extends State<LocationSelector> {
|
||||||
Text('Latitude: $_latitude'),
|
Text('Latitude: $_latitude'),
|
||||||
Text('Longitude: $_longitude'),
|
Text('Longitude: $_longitude'),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/svg.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
import 'package:percent_indicator/circular_percent_indicator.dart';
|
||||||
import 'package:swipable_stack/swipable_stack.dart';
|
import 'package:swipable_stack/swipable_stack.dart';
|
||||||
|
|
||||||
|
import '../components/card_overlay.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
import '../forms/matched_screen.dart';
|
import '../forms/matched_screen.dart';
|
||||||
import '../models/language.dart';
|
import '../models/language.dart';
|
||||||
import '../models/location.dart';
|
import '../models/location.dart';
|
||||||
import '../models/user_profile.dart';
|
import '../models/user_profile.dart';
|
||||||
import '../services/auth/auth_service.dart';
|
import '../services/auth/auth_service.dart';
|
||||||
|
import '../utils/helper.dart';
|
||||||
import '../utils/math.dart';
|
import '../utils/math.dart';
|
||||||
import 'chat_page.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (potentialUserProfiles.isEmpty) {
|
if (potentialUserProfiles.isEmpty) {
|
||||||
|
@ -401,9 +419,11 @@ class UserMatchingPageState extends State<UserMatchingPage> {
|
||||||
return a.name.compareTo(b.name); // All others by name ascending
|
return a.name.compareTo(b.name); // All others by name ascending
|
||||||
});
|
});
|
||||||
|
|
||||||
String shortDist =
|
String pronoun = getPronoun(userProfile.gender);
|
||||||
shortestDistanceBetweenUsers(currentUserProfile!, userProfile)
|
|
||||||
.toStringAsFixed(0);
|
double shortDist =
|
||||||
|
shortestDistanceBetweenUsers(currentUserProfile!, userProfile);
|
||||||
|
double matchScore = calculateMatchScore(currentUserProfile!, userProfile);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
@ -413,26 +433,40 @@ class UserMatchingPageState extends State<UserMatchingPage> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (kDebugMode)
|
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Stack(
|
||||||
userProfile.email,
|
alignment: Alignment.bottomCenter,
|
||||||
style: const TextStyle(fontSize: 12),
|
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(
|
||||||
Center(
|
bottom: 5, // Manually adjusted avatar position
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 50,
|
radius: 50,
|
||||||
backgroundImage:
|
backgroundImage: ((profileImageUrl != null &&
|
||||||
((profileImageUrl != null && profileImageUrl.isNotEmpty))
|
profileImageUrl.isNotEmpty))
|
||||||
? NetworkImage(profileImageUrl)
|
? NetworkImage(profileImageUrl)
|
||||||
: null,
|
: null,
|
||||||
child: (profileImageUrl == null || profileImageUrl.isEmpty)
|
child:
|
||||||
|
(profileImageUrl == null || profileImageUrl.isEmpty)
|
||||||
? const Icon(Icons.person_pin, size: 50)
|
? const Icon(Icons.person_pin, size: 50)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
@ -445,7 +479,7 @@ class UserMatchingPageState extends State<UserMatchingPage> {
|
||||||
'${userProfile.skillsSought.map((x) => x.displayName).join(', ')}.',
|
'${userProfile.skillsSought.map((x) => x.displayName).join(', ')}.',
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'He/She brings skills and experience in '
|
'$pronoun brings skills and experience in '
|
||||||
'${userProfile.skills.map((x) => x.displayName).join(', ')}',
|
'${userProfile.skills.map((x) => x.displayName).join(', ')}',
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
|
@ -455,7 +489,7 @@ class UserMatchingPageState extends State<UserMatchingPage> {
|
||||||
Text(
|
Text(
|
||||||
'Lives in ${userProfile.locations[Constants.dbDocMainLocation]?.toString() ?? 'N/A'}'
|
'Lives in ${userProfile.locations[Constants.dbDocMainLocation]?.toString() ?? 'N/A'}'
|
||||||
' and ${userProfile.locations[Constants.dbDocSecondLocation]?.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 SizedBox(height: 8),
|
||||||
const Row(
|
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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../enumerations.dart';
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Compare two lists by their content ignoring their elements order.
|
/// 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.
|
/// Get the [displayName] of our own Enumerations.
|
||||||
///
|
///
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
||||||
|
import '../enumerations.dart';
|
||||||
import '../models/user_profile.dart';
|
import '../models/user_profile.dart';
|
||||||
|
|
||||||
/// Approximate determination of age
|
/// Approximate determination of age
|
||||||
|
@ -97,3 +98,193 @@ double shortestDistanceBetweenUsers(
|
||||||
return double.nan;
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -47,6 +47,7 @@ dependencies:
|
||||||
image_picker: ^1.1.1
|
image_picker: ^1.1.1
|
||||||
firebase_storage: ^11.7.7
|
firebase_storage: ^11.7.7
|
||||||
image_cropper: ^6.0.0
|
image_cropper: ^6.0.0
|
||||||
|
percent_indicator: ^4.2.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in New Issue