1547 level indicator for all users (#1722)

* feat: publicly viewable target language and level indicator

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
pull/1593/head
ggurdin 9 months ago committed by GitHub
parent 5347b4764f
commit b98f2d3283
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -349,13 +349,16 @@ class ChatController extends State<ChatPageWithRoom>
}
});
_levelSubscription = pangeaController.getAnalytics.analyticsStream.stream
.where((update) => update.levelUp)
_levelSubscription = pangeaController.getAnalytics.stateStream
.where(
(update) =>
update is Map<String, dynamic> && update['level_up'] != null,
)
.listen(
(update) => Future.delayed(
const Duration(milliseconds: 500),
() => LevelUpUtil.showLevelUpDialog(
pangeaController.getAnalytics.constructListModel.level,
update['level_up'],
context,
),
),

@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/user/widgets/public_level_indicator.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/url_launcher.dart';
@ -194,6 +195,9 @@ class UserBottomSheetView extends StatelessWidget {
);
},
),
// #Pangea
PublicLevelIndicator(userId: userId),
// Pangea#
],
),
),

@ -14,12 +14,13 @@ import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/common/constants/local.key.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController {
class GetAnalyticsController extends BaseController {
late PangeaController _pangeaController;
late MessageAnalyticsController perMessage;
@ -31,6 +32,7 @@ class GetAnalyticsController {
ConstructListModel constructListModel = ConstructListModel(uses: []);
Completer<void> initCompleter = Completer<void>();
bool _initializing = false;
GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
@ -69,7 +71,8 @@ class GetAnalyticsController {
}
Future<void> initialize() async {
if (initCompleter.isCompleted) return;
if (_initializing || initCompleter.isCompleted) return;
_initializing = true;
try {
_client.updateAnalyticsRoomVisibility();
@ -100,10 +103,12 @@ class GetAnalyticsController {
} finally {
_updateAnalyticsStream();
if (!initCompleter.isCompleted) initCompleter.complete();
_initializing = false;
}
}
/// Clear all cached analytics data.
@override
void dispose() {
constructListModel.dispose();
_analyticsUpdateSubscription?.cancel();
@ -124,19 +129,21 @@ class GetAnalyticsController {
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await _getConstructs(forceUpdate: true);
}
_updateAnalyticsStream(
origin: analyticsUpdate.origin,
levelUp: oldLevel < constructListModel.level,
);
_updateAnalyticsStream(origin: analyticsUpdate.origin);
if (oldLevel < constructListModel.level) _onLevelUp();
}
void _updateAnalyticsStream({
bool levelUp = false,
AnalyticsUpdateOrigin? origin,
}) {
analyticsStream.add(
AnalyticsStreamUpdate(origin: origin, levelUp: levelUp),
}) =>
analyticsStream.add(AnalyticsStreamUpdate(origin: origin));
void _onLevelUp() {
_pangeaController.userController.updatePublicProfile(
level: constructListModel.level,
);
setState({'level_up': constructListModel.level});
}
/// A local cache of eventIds and construct uses for messages sent since the last update.
@ -209,7 +216,9 @@ class GetAnalyticsController {
}) async {
// if the user isn't logged in, return an empty list
if (_client.userID == null) return [];
await _client.roomsLoading;
if (_client.prevBatch == null) {
await _client.onSync.stream.first;
}
// don't try to get constructs until last updated time has been loaded
await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
@ -342,10 +351,8 @@ class AnalyticsCacheEntry {
class AnalyticsStreamUpdate {
final AnalyticsUpdateOrigin? origin;
final bool levelUp;
AnalyticsStreamUpdate({
this.origin,
this.levelUp = false,
});
}

@ -151,7 +151,10 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
await sendLocalAnalyticsToAnalyticsRoom(
l2Override: previousL2,
);
_pangeaController.resetAnalytics();
_pangeaController.resetAnalytics().then((_) {
final level = _pangeaController.getAnalytics.constructListModel.level;
_pangeaController.userController.updatePublicProfile(level: level);
});
}
void addDraftUses(
@ -361,6 +364,8 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
String? l2Override,
}) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (_pangeaController.getAnalytics.messagesSinceUpdate.isEmpty) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
return;

@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/common/constants/local.key.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -52,7 +53,14 @@ class AppVersionUtil {
final currentBuildNumber = packageInfo.buildNumber;
final accessToken = MatrixState.pangeaController.userController.accessToken;
final AppVersionResponse resp = await _getAppVersion(accessToken);
AppVersionResponse? resp;
try {
resp = await _getAppVersion(accessToken);
} catch (err, s) {
ErrorHandler.logError(e: err, s: s, data: {});
return;
}
final remoteVersion = resp.latestVersion;
final remoteBuildNumber = resp.latestBuildNumber;

@ -152,4 +152,7 @@ class ModelKey {
static const String latestBuildNumber = "latest_build_number";
static const String mandatoryUpdate = "mandatory_update";
static const String emoji = "emoji";
static const String analytics = "analytics";
static const String level = "level";
}

@ -158,14 +158,14 @@ class PangeaController {
case LoginState.loggedOut:
case LoginState.softLoggedOut:
// Reset cached analytics data
MatrixState.pangeaController.putAnalytics.dispose();
MatrixState.pangeaController.getAnalytics.dispose();
putAnalytics.dispose();
getAnalytics.dispose();
_languageStream?.cancel();
break;
case LoginState.loggedIn:
// Initialize analytics data
MatrixState.pangeaController.putAnalytics.initialize();
MatrixState.pangeaController.getAnalytics.initialize();
putAnalytics.initialize();
getAnalytics.initialize();
break;
}
if (state != LoginState.loggedIn) {
@ -186,7 +186,7 @@ class PangeaController {
putAnalytics.dispose();
getAnalytics.dispose();
putAnalytics.initialize();
getAnalytics.initialize();
await getAnalytics.initialize();
}
void startChatWithBotIfNotPresent() {

@ -36,4 +36,7 @@ class PangeaEventTypes {
/// A record of completion of an activity. There
/// can be one per user per activity.
static const activityRecord = "pangea.activity_completion";
/// Profile information related to a user's analytics
static const profileAnalytics = "pangea.analytics_profile";
}

@ -46,6 +46,7 @@ class SettingsLearningController extends State<SettingsLearning> {
context: context,
future: () async => pangeaController.userController.updateProfile(
(_) => _profile,
waitForDataInSync: true,
),
);
Navigator.of(context).pop();

@ -241,7 +241,10 @@ class SignupPageController extends State<SignupPage> {
data: {},
);
}
error = (e).toLocalizedString(context);
if (mounted) {
error = (e).toLocalizedString(context);
}
} finally {
if (mounted) {
setState(() => loadingSignup = false);

@ -215,6 +215,10 @@ class UserSettingsState extends State<UserSettingsPage> {
},
waitForDataInSync: true,
),
_pangeaController.userController.updatePublicProfile(
targetLanguage: selectedTargetLanguage,
level: 1,
),
];
await Future.wait(updateFuture).timeout(
const Duration(seconds: 30),

@ -8,7 +8,10 @@ import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/user/models/profile_model.dart';
import '../models/user_model.dart';
/// Controller that manages saving and reading of user/profile information
@ -18,23 +21,26 @@ class UserController extends BaseController {
_pangeaController = pangeaController;
}
matrix.Client get client => _pangeaController.matrixState.client;
/// Convenience function that returns the user ID currently stored in the client.
String? get userId => _pangeaController.matrixState.client.userID;
String? get userId => client.userID;
/// Cached version of the user profile, so it doesn't have
/// to be read in from client's account data each time it is accessed.
Profile? _cachedProfile;
PublicProfileModel? publicProfile;
/// Listens for account updates and updates the cached profile
StreamSubscription? _profileListener;
/// Listen for updates to account data in syncs and update the cached profile
void addProfileListener() {
_profileListener ??= _pangeaController.matrixState.client.onSync.stream
_profileListener ??= client.onSync.stream
.where((sync) => sync.accountData != null)
.listen((sync) {
final profileData = _pangeaController
.matrixState.client.accountData[ModelKey.userProfile]?.content;
final profileData = client.accountData[ModelKey.userProfile]?.content;
final Profile? fromAccountData = Profile.fromAccountData(profileData);
if (fromAccountData != null) {
_cachedProfile = fromAccountData;
@ -50,14 +56,13 @@ class UserController extends BaseController {
if (_cachedProfile != null) return _cachedProfile!;
/// if account data is empty, return an empty profile
if (_pangeaController.matrixState.client.accountData.isEmpty) {
if (client.accountData.isEmpty) {
return Profile.emptyProfile;
}
/// try to get the account data in the up-to-date format
final Profile? fromAccountData = Profile.fromAccountData(
_pangeaController
.matrixState.client.accountData[ModelKey.userProfile]?.content,
client.accountData[ModelKey.userProfile]?.content,
);
if (fromAccountData != null) {
@ -85,16 +90,6 @@ class UserController extends BaseController {
}
}
/// Creates a new profile for the user with the given date of birth.
Future<void> createProfile({DateTime? dob}) async {
final userSettings = UserSettings(
dateOfBirth: dob,
createdAt: DateTime.now(),
);
final newProfile = Profile(userSettings: userSettings);
await newProfile.saveProfileData(waitForDataInSync: true);
}
/// A completer for the profile model of a user.
Completer<void>? _profileCompleter;
@ -136,8 +131,31 @@ class UserController extends BaseController {
Future<void> _initialize() async {
// wait for account data to load
// as long as it's not null, then this we've already migrated the profile
if (_pangeaController.matrixState.client.prevBatch == null) {
await _pangeaController.matrixState.client.onSync.stream.first;
if (client.prevBatch == null) {
await client.onSync.stream.first;
}
if (client.userID == null) return;
try {
final resp = await client.getUserProfile(client.userID!);
publicProfile = PublicProfileModel.fromJson(resp.additionalProperties);
} catch (e) {
// getting a 404 error for some users without pre-existing profile
// still want to set other properties, so catch this error
publicProfile = PublicProfileModel();
}
// Do not await. This function pulls level from analytics,
// so it waits for analytics to finish initializing. Analytics waits for user controller to
// finish initializing, so this would cause a deadlock.
if (publicProfile!.isEmpty) {
_pangeaController.getAnalytics.initCompleter.future
.timeout(const Duration(seconds: 10))
.then((_) {
updatePublicProfile(
level: _pangeaController.getAnalytics.constructListModel.level,
);
});
}
}
@ -149,12 +167,36 @@ class UserController extends BaseController {
await initialize();
}
Future<void> updatePublicProfile({
LanguageModel? targetLanguage,
int? level,
}) async {
targetLanguage ??= _pangeaController.languageController.userL2;
if (targetLanguage == null || publicProfile == null) return;
if (publicProfile!.targetLanguage == targetLanguage &&
publicProfile!.languageAnalytics?[targetLanguage]?.level == level) {
return;
}
publicProfile!.targetLanguage = targetLanguage;
if (level != null) {
publicProfile!.setLevel(targetLanguage, level);
}
await client.setUserProfile(
client.userID!,
PangeaEventTypes.profileAnalytics,
publicProfile!.toJson(),
);
}
/// Returns a boolean value indicating whether a new JWT (JSON Web Token) is needed.
bool needNewJWT(String token) => Jwt.isExpired(token);
/// Retrieves matrix access token.
String get accessToken {
final token = _pangeaController.matrixState.client.accessToken;
final token = client.accessToken;
if (token == null) {
throw ("Trying to get accessToken with null token. User is not logged in.");
}
@ -251,11 +293,27 @@ class UserController extends BaseController {
/// is found.
Future<String?> get userEmail async {
final List<matrix.ThirdPartyIdentifier>? identifiers =
await _pangeaController.matrixState.client.getAccount3PIDs();
await client.getAccount3PIDs();
final matrix.ThirdPartyIdentifier? email = identifiers?.firstWhereOrNull(
(identifier) =>
identifier.medium == matrix.ThirdPartyIdentifierMedium.email,
);
return email?.address;
}
Future<PublicProfileModel> getPublicProfile(String userId) async {
try {
final resp = await client.getUserProfile(userId);
return PublicProfileModel.fromJson(resp.additionalProperties);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
userId: userId,
},
);
return PublicProfileModel();
}
}
}

@ -0,0 +1,75 @@
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/language_list_util.dart';
class PublicProfileModel {
LanguageModel? targetLanguage;
Map<LanguageModel, LanguageAnalyticsProfileEntry>? languageAnalytics;
PublicProfileModel({this.targetLanguage, this.languageAnalytics});
factory PublicProfileModel.fromJson(Map<String, dynamic> json) {
if (!json.containsKey(PangeaEventTypes.profileAnalytics)) {
return PublicProfileModel();
}
final profileJson = json[PangeaEventTypes.profileAnalytics];
final targetLanguage = profileJson[ModelKey.userTargetLanguage] != null
? PangeaLanguage.byLangCode(profileJson[ModelKey.userTargetLanguage])
: null;
final languageAnalytics = <LanguageModel, LanguageAnalyticsProfileEntry>{};
if (profileJson[ModelKey.analytics] != null &&
profileJson[ModelKey.analytics]!.isNotEmpty) {
for (final entry in profileJson[ModelKey.analytics].entries) {
final lang = PangeaLanguage.byLangCode(entry.key);
if (lang == null) continue;
final level = entry.value[ModelKey.level];
languageAnalytics[lang] = LanguageAnalyticsProfileEntry(level);
}
}
final profile = PublicProfileModel(
targetLanguage: targetLanguage,
languageAnalytics: languageAnalytics,
);
return profile;
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (targetLanguage != null) {
json[ModelKey.userTargetLanguage] = targetLanguage!.langCode;
}
final analytics = {};
if (languageAnalytics != null && languageAnalytics!.isNotEmpty) {
for (final entry in languageAnalytics!.entries) {
analytics[entry.key.langCode] = {ModelKey.level: entry.value.level};
}
}
json[ModelKey.analytics] = analytics;
return json;
}
bool get isEmpty =>
targetLanguage == null &&
(languageAnalytics == null || languageAnalytics!.isEmpty);
void setLevel(LanguageModel language, int level) {
languageAnalytics ??= {};
languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(0);
languageAnalytics![language]!.level = level;
}
int? get level => languageAnalytics?[targetLanguage]?.level;
}
class LanguageAnalyticsProfileEntry {
int level;
LanguageAnalyticsProfileEntry(this.level);
}

@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/user/models/profile_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PublicLevelIndicator extends StatelessWidget {
final String userId;
const PublicLevelIndicator({
required this.userId,
super.key,
});
@override
Widget build(BuildContext context) {
final profileFuture =
MatrixState.pangeaController.userController.getPublicProfile(userId);
return FutureBuilder<PublicProfileModel>(
future: profileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16),
child: LinearProgressIndicator(),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: Theme.of(context).colorScheme.surfaceBright,
),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
size: 14,
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
weight: 1000,
),
const SizedBox(width: 5),
Text(
L10n.of(context).oopsSomethingWentWrong,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
if (snapshot.hasData &&
snapshot.data!.targetLanguage == null &&
snapshot.data!.level == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (snapshot.data?.targetLanguage != null)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: Theme.of(context).colorScheme.surfaceBright,
),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
size: 14,
Icons.language,
color: Theme.of(context).colorScheme.primary,
weight: 1000,
),
const SizedBox(width: 5),
Text(
snapshot.data!.targetLanguage!.displayName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
const SizedBox(width: 12),
if (snapshot.data?.level != null)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: Theme.of(context).colorScheme.surfaceBright,
),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
backgroundColor: AppConfig.gold,
radius: 8,
child: Icon(
size: 12,
Icons.star,
color: Theme.of(context).colorScheme.surfaceBright,
weight: 1000,
),
),
const SizedBox(width: 4),
Text(
L10n.of(context).levelShort(snapshot.data!.level!),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
],
),
);
},
);
}
}

@ -153,14 +153,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.0"
base58check:
dependency: transitive
description:
name: base58check
sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
blurhash_dart:
dependency: "direct main"
description:
@ -193,14 +185,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.3"
canonical_json:
dependency: transitive
description:
name: canonical_json
sha256: d6be1dd66b420c6ac9f42e3693e09edf4ff6edfee26cb4c28c1c019fdb8c0c15
url: "https://pub.dev"
source: hosted
version: "1.1.2"
characters:
dependency: "direct main"
description:
@ -433,14 +417,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.9"
enhanced_enum:
dependency: transitive
description:
name: enhanced_enum
sha256: "074c5a8b9664799ca91e1e8b68003b8694cb19998671cbafd9c7779c13fcdecf"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
equatable:
dependency: transitive
description:
@ -1119,14 +1095,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.4"
html_unescape:
dependency: transitive
description:
name: html_unescape
sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
http:
dependency: "direct main"
description:
@ -1465,7 +1433,7 @@ packages:
description:
path: "."
ref: main
resolved-ref: "03be44cc13cb15a1b6fa586589eb8c243979d381"
resolved-ref: e77d79ff2a9b91d5cd950a0742be2f74781cb19f
url: "https://github.com/pangeachat/matrix-dart-sdk.git"
source: git
version: "0.37.0"
@ -1525,14 +1493,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
olm:
dependency: transitive
description:
name: olm
sha256: "3306bf534ceb914fd148b3b4a3d603fb5e067b2e6da8304025b47c24cfdf6b46"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
open_file:
dependency: "direct main"
description:
@ -1885,14 +1845,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
random_string:
dependency: transitive
description:
name: random_string
sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
receive_sharing_intent:
dependency: "direct main"
description:
@ -2005,14 +1957,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
sdp_transform:
dependency: transitive
description:
name: sdp_transform
sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
sentry:
dependency: transitive
description:
@ -2490,14 +2434,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.2"
unorm_dart:
dependency: transitive
description:
name: unorm_dart
sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
url_launcher:
dependency: "direct main"
description:

Loading…
Cancel
Save