Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fix: user avatar does not update on challenge completion #348

Merged
merged 32 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3fc2b6e
feat: add userID to widgets and data classes to display user avatar
CHOOSEIT May 25, 2024
8854a79
feat(user_centauri_points_vm): add user centauri points view model an…
CHOOSEIT May 25, 2024
c2c31c9
refactor(user_avatar_color): move user avatar color computation to a …
CHOOSEIT May 25, 2024
6a0cd24
feat(user_centauri_points_vm): add refresh function
CHOOSEIT May 25, 2024
6c86325
feat(challenge_view_model): refresh on complete the centauri point of…
CHOOSEIT May 25, 2024
dbb909a
feat(user_centauri_points_vm): add refresh with given centauri points
CHOOSEIT May 25, 2024
59929da
feat(users_ranking_vm): refresh centauri points of users on loading
CHOOSEIT May 25, 2024
1e29b84
test(user_centauri_points_vm): add unit tests
CHOOSEIT May 25, 2024
5d897c1
test(user_centauri_points_vm): add integration test
CHOOSEIT May 25, 2024
1cb8153
docs(user_avatar_details): improve class argument documention
CHOOSEIT May 25, 2024
355fa2a
feat(users_ranking_vm): replace centauri points refresh to use top users
CHOOSEIT May 25, 2024
4476279
Merge branch 'main' into refreshable-user-avatar-color
CHOOSEIT May 25, 2024
7a9eeaf
feat(user_avatar_details): change fromUserData to fromUser
CHOOSEIT May 25, 2024
4d15cbb
fix(user_avatar_color): remove class UserAvatarColor
CHOOSEIT May 25, 2024
f5fc5a7
feat(user_centauri_points_vm): make refreshWithCentauriPointsNumber n…
CHOOSEIT May 25, 2024
cefc851
test(comment_details): use isNot(equals()) matcher for inequality
CHOOSEIT May 25, 2024
c7be8de
test(ranking_element_details): use isNot(equals()) matcher for inequa…
CHOOSEIT May 25, 2024
7126752
test(user_centauri_points_vm): remove used user repository in unit tests
CHOOSEIT May 25, 2024
3347ba7
feat(users_ranking_vm): regroup centauri points updates
CHOOSEIT May 26, 2024
243304f
feat(user_centauri_points_vm): remove refreshWithCentauriPointsNumber
CHOOSEIT May 26, 2024
18b20c1
test(post_comment): fix mock comment details according to the associa…
CHOOSEIT May 26, 2024
6b09986
test(provider_ranking): wrap MaterialApp with UncontrolledProviderScope
CHOOSEIT May 26, 2024
54b80e6
feat(override_user_centauri_points_vm): add addtional documentation
CHOOSEIT May 26, 2024
ad5cdda
feat(user_avatar): improve ui logic & handle error in centauri points vm
CHOOSEIT May 26, 2024
fb67777
fix(user_avatar): remove unused import
CHOOSEIT May 26, 2024
5154d96
feat(user_centauri_points_vm): use loading async state
CHOOSEIT May 26, 2024
95611ec
Merge branch 'main' into refreshable-user-avatar-color
CHOOSEIT May 26, 2024
7850437
feat(user_centauri_points_vm): remove rethrow error
CHOOSEIT May 26, 2024
0e38ef4
feat(dynamic_user_avatar): move user avatar color refreshing in vm
CHOOSEIT May 26, 2024
cdee78d
feat(users_ranking_vm): refresh the user avatar of the displayed users
CHOOSEIT May 26, 2024
730e0ab
feat(ranking_card): use dynamic user avatar for consistency
CHOOSEIT May 26, 2024
3b336a8
Merge branch 'main' into refreshable-user-avatar-color
CHOOSEIT May 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/models/ui/comment_details.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import "package:flutter/foundation.dart";
import "package:proxima/models/database/comment/comment_data.dart";
import "package:proxima/models/database/user/user_firestore.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";

/// This class contains all the details of a comment
/// that are needed to display it in the UI.
@immutable
class CommentDetails {
final String content;
final String ownerDisplayName;
final String ownerUsername;
final UserIdFirestore ownerUserID;
final int ownerCentauriPoints;
final DateTime publicationDate;

const CommentDetails({
required this.content,
required this.ownerDisplayName,
required this.ownerUsername,
required this.ownerUserID,
required this.ownerCentauriPoints,
required this.publicationDate,
});
Expand All @@ -26,6 +31,7 @@ class CommentDetails {
other.content == content &&
other.ownerDisplayName == ownerDisplayName &&
other.ownerUsername == ownerUsername &&
other.ownerUserID == ownerUserID &&
other.ownerCentauriPoints == ownerCentauriPoints &&
other.publicationDate == publicationDate;
}
Expand All @@ -36,6 +42,7 @@ class CommentDetails {
content,
ownerDisplayName,
ownerUsername,
ownerUserID,
ownerCentauriPoints,
publicationDate,
);
Expand All @@ -53,6 +60,7 @@ class CommentDetails {
content: commentData.content,
ownerDisplayName: ownerData.displayName,
ownerUsername: ownerData.username,
ownerUserID: owner.uid,
ownerCentauriPoints: ownerData.centauriPoints,
publicationDate: DateTime.fromMillisecondsSinceEpoch(
commentData.publicationTime.millisecondsSinceEpoch,
Expand Down
6 changes: 6 additions & 0 deletions lib/models/ui/post_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "package:geoflutterfire_plus/geoflutterfire_plus.dart";
import "package:proxima/models/database/post/post_firestore.dart";
import "package:proxima/models/database/post/post_id_firestore.dart";
import "package:proxima/models/database/user/user_firestore.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";

@immutable

Expand All @@ -15,6 +16,7 @@ class PostDetails {
final int commentNumber;
final String ownerDisplayName;
final String ownerUsername;
final UserIdFirestore ownerUserID;
final int ownerCentauriPoints;
final DateTime publicationDate;
final int distance; // in meters
Expand All @@ -28,6 +30,7 @@ class PostDetails {
required this.commentNumber,
required this.ownerDisplayName,
required this.ownerUsername,
required this.ownerUserID,
required this.ownerCentauriPoints,
required this.publicationDate,
required this.distance,
Expand All @@ -46,6 +49,7 @@ class PostDetails {
other.commentNumber == commentNumber &&
other.ownerDisplayName == ownerDisplayName &&
other.ownerUsername == ownerUsername &&
other.ownerUserID == ownerUserID &&
other.ownerCentauriPoints == ownerCentauriPoints &&
other.publicationDate == publicationDate &&
other.distance == distance &&
Expand All @@ -62,6 +66,7 @@ class PostDetails {
commentNumber,
ownerDisplayName,
ownerUsername,
ownerUserID,
ownerCentauriPoints,
publicationDate,
distance,
Expand All @@ -86,6 +91,7 @@ class PostDetails {
commentNumber: postFirestore.data.commentCount,
ownerDisplayName: userFirestore.data.displayName,
ownerUsername: userFirestore.data.username,
ownerUserID: userFirestore.uid,
ownerCentauriPoints: userFirestore.data.centauriPoints,
publicationDate: postFirestore.data.publicationTime.toDate(),
distance: (geoFirePoint.distanceBetweenInKm(
Expand Down
7 changes: 7 additions & 0 deletions lib/models/ui/ranking/ranking_element_details.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "package:flutter/foundation.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";

/// A class that stores data for each ranking UI element.
/// The [userRank] is nullable to allow the current user to not have a rank.
Expand All @@ -7,6 +8,7 @@ class RankingElementDetails {
const RankingElementDetails({
required this.userDisplayName,
required this.userUserName,
required this.userID,
required this.centauriPoints,
required this.userRank,
});
Expand All @@ -17,6 +19,9 @@ class RankingElementDetails {
/// Username of the user.
final String userUserName;

/// ID of the user.
final UserIdFirestore userID;

/// Centauri points of the user.
final int centauriPoints;

Expand All @@ -30,6 +35,7 @@ class RankingElementDetails {
return other is RankingElementDetails &&
other.userDisplayName == userDisplayName &&
other.userUserName == userUserName &&
other.userID == userID &&
other.centauriPoints == centauriPoints &&
other.userRank == userRank;
}
Expand All @@ -38,6 +44,7 @@ class RankingElementDetails {
int get hashCode => Object.hash(
userDisplayName,
userUserName,
userID,
centauriPoints,
userRank,
);
Expand Down
26 changes: 15 additions & 11 deletions lib/models/ui/user_avatar_details.dart
Original file line number Diff line number Diff line change
@@ -1,42 +1,46 @@
import "package:flutter/foundation.dart";
import "package:proxima/models/database/user/user_data.dart";
import "package:proxima/models/database/user/user_firestore.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";

/// A class that stores data for a user avatar UI.
@immutable
class UserAvatarDetails {
final String displayName;
final int? userCentauriPoints;
final UserIdFirestore? userID;

/// Creates a [UserAvatarDetails] object.
/// [displayName] is the user's display name, of which
/// the first letter is displayed on the avatar.
/// [userCentauriPoints] is the user's centauri points,
/// which is used to color the avatar background.
/// [userID] is the user's ID. Can be null
/// to allow creating a user avatar without a user which
/// is useful for loading states.
const UserAvatarDetails({
required this.displayName,
required this.userCentauriPoints,
required this.userID,
});

/// Converts a [UserData] object, [userData], to a [UserAvatarDetails] object.
factory UserAvatarDetails.fromUserData(UserData userData) {
/// Converts a [UserFirestore] object, [user], to a [UserAvatarDetails] object.
factory UserAvatarDetails.fromUser(
UserFirestore user,
) {
return UserAvatarDetails(
displayName: userData.displayName,
userCentauriPoints: userData.centauriPoints,
displayName: user.data.displayName,
userID: user.uid,
);
}

@override
bool operator ==(Object other) {
return other is UserAvatarDetails &&
other.displayName == displayName &&
other.userCentauriPoints == userCentauriPoints;
other.userID == userID;
}

@override
int get hashCode {
return Object.hash(
displayName,
userCentauriPoints,
userID,
);
}
}
46 changes: 46 additions & 0 deletions lib/utils/user_avatar_color.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import "package:flutter/material.dart";
import "package:proxima/models/ui/linear_segmented_hsv_colormap.dart";
import "package:proxima/services/database/challenge_repository_service.dart";

/// Stops for the colormap used to color the user's avatar based on their centauri points.
JoachimFavre marked this conversation as resolved.
Show resolved Hide resolved
/// The stops are defined as the number of challenges completed.
const _challengesStops = [
// sqrt(10) ~= 3, which is the approximate step between each stop
0,
10, // ~ 3 days of daily challenge
30,
100,
300, // ~ 3 months of daily challenge
1000,
3000, // ~ 3 years of daily challenge
10000,
];

/// Color used when the user's centauri points are null.
const loadingUserAvatarColor = Colors.transparent;

/// Converts some amount of [centauriPoints] to a color, based on a uniform
/// [LinearSegmentedHSVColormap] (defined by _challengesStop). [brightness]
/// defines the value and saturation of the colormap.
/// If [centauriPoints] is null, the color is transparent.
Color centauriToUserAvatarColor(int? centauriPoints, Brightness brightness) {
if (centauriPoints == null) return loadingUserAvatarColor;

final hsvValue = switch (brightness) {
Brightness.light => 0.9,
Brightness.dark => 0.5,
};
final hsvSaturation = switch (brightness) {
Brightness.light => 0.4,
Brightness.dark => 0.8,
};

const chalReward = ChallengeRepositoryService.soloChallengeReward;
final colorMap = LinearSegmentedHSVColormap.uniform(
_challengesStops.map((nChallenges) => nChallenges * chalReward).toList(),
value: hsvValue,
saturation: hsvSaturation,
);

return colorMap(centauriPoints).toColor();
}
6 changes: 6 additions & 0 deletions lib/viewmodels/challenge_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import "package:proxima/services/database/post_repository_service.dart";
import "package:proxima/services/sensors/geolocation_service.dart";
import "package:proxima/viewmodels/login_view_model.dart";
import "package:proxima/viewmodels/map/map_pin_view_model.dart";
import "package:proxima/viewmodels/user_centauri_points_view_model.dart";

/// This viewmodel is used to fetch the list of challenges that are displayed in
/// the challenge feed. It fetches the challenges from the database and sorts
Expand Down Expand Up @@ -98,6 +99,11 @@ class ChallengeViewModel

// Refresh the map pins after challenge completion
ref.read(mapPinViewModelProvider.notifier).refresh();

// Refresh the user centauri points after challenge completion
ref
.read(userCentauriPointsViewModelProvider(currentUser).notifier)
.refresh();
}

return pointsAwarded;
Expand Down
56 changes: 7 additions & 49 deletions lib/viewmodels/dynamic_user_avatar_view_model.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import "dart:async";

import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:proxima/models/database/user/user_firestore.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";
import "package:proxima/models/ui/linear_segmented_hsv_colormap.dart";
import "package:proxima/models/ui/user_avatar_details.dart";
import "package:proxima/services/database/challenge_repository_service.dart";
import "package:proxima/services/database/user_repository_service.dart";
import "package:proxima/viewmodels/login_view_model.dart";

Expand All @@ -17,65 +14,26 @@ class DynamicUserAvatarViewModel extends AutoDisposeFamilyAsyncNotifier<
UserAvatarDetails, UserIdFirestore?> {
DynamicUserAvatarViewModel();

/// Stops for the colormap used to color the user's avatar based on their centauri points.
/// The stops are defined as the number of challenges completed.
static const _challengesStops = [
// sqrt(10) ~= 3, which is the approximate step between each stop
0,
10, // ~ 3 days of daily challenge
30,
100,
300, // ~ 3 months of daily challenge
1000,
3000, // ~ 3 years of daily challenge
10000,
];

/// Converts some amount of [centauriPoints] to a color, based on a uniform
/// [LinearSegmentedHSVColormap] (defined by _challengesStop). [brightness]
/// defines the value and saturation of the colormap.
/// If [centauriPoints] is null, the color is transparent.
static Color centauriToColor(int? centauriPoints, Brightness brightness) {
if (centauriPoints == null) return Colors.transparent;

final hsvValue = switch (brightness) {
Brightness.light => 0.9,
Brightness.dark => 0.5,
};
final hsvSaturation = switch (brightness) {
Brightness.light => 0.4,
Brightness.dark => 0.8,
};

const chalReward = ChallengeRepositoryService.soloChallengeReward;
final colorMap = LinearSegmentedHSVColormap.uniform(
_challengesStops.map((nChallenges) => nChallenges * chalReward).toList(),
value: hsvValue,
saturation: hsvSaturation,
);

return colorMap(centauriPoints).toColor();
}

@override
Future<UserAvatarDetails> build(UserIdFirestore? arg) async {
final userID = arg;
final currentUID = ref.watch(loggedInUserIdProvider);
final userDataBase = ref.watch(userRepositoryServiceProvider);

late final UserFirestore user;
late final UserIdFirestore userID;

if (userID == null) {
if (arg == null) {
if (currentUID == null) {
throw Exception("User is not logged in.");
}

user = await userDataBase.getUser(currentUID);
userID = currentUID;
} else {
user = await userDataBase.getUser(userID);
userID = arg;
}

return UserAvatarDetails.fromUserData(user.data);
user = await userDataBase.getUser(userID);

return UserAvatarDetails.fromUser(user);
}
}

Expand Down
41 changes: 41 additions & 0 deletions lib/viewmodels/user_centauri_points_view_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import "dart:async";

import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:proxima/models/database/user/user_id_firestore.dart";
import "package:proxima/services/database/user_repository_service.dart";

/// A view model that provides the number of centauri points of a user
/// given its user id. Allow a null [arg] (user id) to return null, which
/// is useful for loading states.
class UserCentauriPointsViewModel
extends FamilyAsyncNotifier<int?, UserIdFirestore?> {
UserCentauriPointsViewModel();

@override
Future<int?> build(UserIdFirestore? arg) async {
final userDataBase = ref.watch(userRepositoryServiceProvider);

if (arg == null) {
return null;
}
CHOOSEIT marked this conversation as resolved.
Show resolved Hide resolved
final userID = arg;

final user = await userDataBase.getUser(userID);
final userCentauri = user.data.centauriPoints;
return userCentauri;
}

/// Refresh the number of centauri points of the user.
Future<void> refresh() async {
state = await AsyncValue.guard(() => build(arg));
CHOOSEIT marked this conversation as resolved.
Show resolved Hide resolved
}

/// Refresh the number of centauri points of the user.
void refreshWithCentauriPointsNumber(int centauriPoints) =>
state = AsyncValue.data(centauriPoints);
JoachimFavre marked this conversation as resolved.
Show resolved Hide resolved
}

final userCentauriPointsViewModelProvider = AsyncNotifierProvider.family<
UserCentauriPointsViewModel, int?, UserIdFirestore?>(
() => UserCentauriPointsViewModel(),
);
Loading