From 7c03c70105656402b6f870aa96b60bc8a5e2f4a7 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:43:00 -0400 Subject: [PATCH] 3517 non local storage of bookmarked activities (#3761) --- lib/pages/new_group/new_group.dart | 37 --- .../activity_plan_card.dart | 202 +++++-------- .../activity_planner_builder.dart | 105 +++++-- .../activity_planner_page.dart | 5 +- .../bookmarked_activities_repo.dart | 57 ---- .../bookmarked_activity_list.dart | 70 +++-- .../activity_room_extension.dart | 3 - .../activity_plan_repo.dart | 79 ++++++ .../activity_suggestion_card.dart | 81 ++---- .../activity_suggestion_dialog.dart | 18 ++ .../activity_suggestions_area.dart | 27 +- .../get_analytics_controller.dart | 8 +- .../analytics_misc/level_display_name.dart | 2 +- .../put_analytics_controller.dart | 2 +- .../pages/pangea_chat_details.dart | 2 +- lib/pangea/common/constants/model_keys.dart | 2 + .../common/controllers/pangea_controller.dart | 1 + lib/pangea/common/network/requests.dart | 23 ++ lib/pangea/common/network/urls.dart | 9 +- .../events/constants/pangea_event_types.dart | 1 + lib/pangea/login/pages/user_settings.dart | 2 +- .../space_analytics/space_analytics.dart | 6 +- .../spaces/utils/load_participants_util.dart | 12 +- .../widgets/leaderboard_participant_list.dart | 3 +- .../user/controllers/user_controller.dart | 267 ++++++++++++------ .../user/models/activities_profile_model.dart | 55 ++++ ...odel.dart => analytics_profile_model.dart} | 10 +- 27 files changed, 624 insertions(+), 465 deletions(-) delete mode 100644 lib/pangea/activity_planner/bookmarked_activities_repo.dart create mode 100644 lib/pangea/activity_suggestions/activity_plan_repo.dart create mode 100644 lib/pangea/user/models/activities_profile_model.dart rename lib/pangea/user/models/{profile_model.dart => analytics_profile_model.dart} (95%) diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index a4c89a2ce..cb5df6b51 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -8,8 +8,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/new_group/new_group_view.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; @@ -42,10 +40,6 @@ class NewGroupController extends State { // #Pangea // bool publicGroup = false; // bool groupCanBeFound = false; - ActivityPlanModel? selectedActivity; - Uint8List? selectedActivityImage; - String? selectedActivityImageFilename; - final GlobalKey formKey = GlobalKey(); final FocusNode focusNode = FocusNode(); @@ -72,22 +66,6 @@ class NewGroupController extends State { // void setPublicGroup(bool b) => // setState(() => publicGroup = groupCanBeFound = b); - void setSelectedActivity( - ActivityPlanModel? activity, - Uint8List? image, - String? imageFilename, - ) { - setState(() { - selectedActivity = activity; - selectedActivityImage = image; - selectedActivityImageFilename = imageFilename; - if (avatar == null) { - avatar = image; - avatarUrl = null; - } - }); - } - @override void initState() { super.initState(); @@ -189,21 +167,6 @@ class NewGroupController extends State { ); } } - - if (selectedActivity != null) { - try { - await room.sendActivityPlan( - selectedActivity!, - avatar: selectedActivityImage, - filename: selectedActivityImageFilename, - ); - } catch (err) { - ErrorHandler.logError( - e: "Failed to send activity plan", - data: {"roomId": roomId, "error": err}, - ); - } - } context.go('/rooms/$roomId/invite'); // Pangea# } diff --git a/lib/pangea/activity_generator/activity_plan_card.dart b/lib/pangea/activity_generator/activity_plan_card.dart index 621a5a6a0..bd574d305 100644 --- a/lib/pangea/activity_generator/activity_plan_card.dart +++ b/lib/pangea/activity_generator/activity_plan_card.dart @@ -1,6 +1,3 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -9,19 +6,16 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; -class ActivityPlanCard extends StatefulWidget { +class ActivityPlanCard extends StatelessWidget { final VoidCallback regenerate; final ActivityPlannerBuilderState controller; @@ -31,73 +25,31 @@ class ActivityPlanCard extends StatefulWidget { required this.controller, }); - @override - ActivityPlanCardState createState() => ActivityPlanCardState(); -} - -class ActivityPlanCardState extends State { static const double itemPadding = 12; - Future _addBookmark(ActivityPlanModel activity) async { - try { - return BookmarkedActivitiesRepo.save(activity); - } catch (e, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: stack, data: activity.toJson()); - return activity; // Return the original activity in case of error - } finally { - if (mounted) { - setState(() {}); - } - } - } - - Future _removeBookmark() async { - try { - BookmarkedActivitiesRepo.remove( - widget.controller.updatedActivity.bookmarkId, - ); - } catch (e, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: e, - s: stack, - data: widget.controller.updatedActivity.toJson(), - ); - } finally { - if (mounted) { - setState(() {}); - } - } - } - - Future _onLaunch() async { + Future _onLaunch(BuildContext context) async { final resp = await showFutureLoadingDialog( context: context, future: () async { - if (!widget.controller.room.isSpace) { + if (!controller.room.isSpace) { throw Exception( "Cannot launch activity in a non-space room", ); } - final ids = await widget.controller.launchToSpace(); + final ids = await controller.launchToSpace(); ids.length == 1 ? context.go("/rooms/${ids.first}") - : context.go("/rooms?spaceId=${widget.controller.room.id}"); + : context.go("/rooms?spaceId=${controller.room.id}"); Navigator.of(context).pop(); }, ); if (!resp.isError) { - context.go("/rooms?spaceId=${widget.controller.room.id}"); + context.go("/rooms?spaceId=${controller.room.id}"); } } - bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked( - widget.controller.updatedActivity, - ); - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -108,7 +60,7 @@ class ActivityPlanCardState extends State { child: Card( margin: const EdgeInsets.symmetric(vertical: itemPadding), child: Form( - key: widget.controller.formKey, + key: controller.formKey, child: Column( children: [ AnimatedSize( @@ -124,11 +76,10 @@ class ActivityPlanCardState extends State { ), clipBehavior: Clip.hardEdge, alignment: Alignment.center, - child: widget.controller.isLaunching + child: controller.isLaunching ? Avatar( - mxContent: widget.controller.room.avatar, - name: widget.controller.room - .getLocalizedDisplayname( + mxContent: controller.room.avatar, + name: controller.room.getLocalizedDisplayname( MatrixLocals( L10n.of(context), ), @@ -136,15 +87,14 @@ class ActivityPlanCardState extends State { borderRadius: BorderRadius.circular(12.0), size: 200.0, ) - : widget.controller.imageURL != null || - widget.controller.avatar != null + : controller.imageURL != null || + controller.avatar != null ? ClipRRect( borderRadius: BorderRadius.circular(20.0), - child: widget.controller.avatar == null + child: controller.avatar == null ? CachedNetworkImage( fit: BoxFit.cover, - imageUrl: - widget.controller.imageURL!, + imageUrl: controller.imageURL!, placeholder: (context, url) { return const Center( child: @@ -158,7 +108,7 @@ class ActivityPlanCardState extends State { }, ) : Image.memory( - widget.controller.avatar!, + controller.avatar!, fit: BoxFit.cover, ), ) @@ -166,10 +116,10 @@ class ActivityPlanCardState extends State { padding: EdgeInsets.all(28.0), ), ), - if (widget.controller.isEditing) + if (controller.isEditing) InkWell( borderRadius: BorderRadius.circular(90), - onTap: widget.controller.selectAvatar, + onTap: controller.selectAvatar, child: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.secondary, @@ -188,15 +138,14 @@ class ActivityPlanCardState extends State { padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: widget.controller.isLaunching + children: controller.isLaunching ? [ Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Avatar( - mxContent: widget.controller.room.avatar, - name: widget.controller.room - .getLocalizedDisplayname( + mxContent: controller.room.avatar, + name: controller.room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)), ), size: 24.0, @@ -205,8 +154,7 @@ class ActivityPlanCardState extends State { const SizedBox(width: itemPadding), Expanded( child: Text( - widget.controller.room - .getLocalizedDisplayname( + controller.room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)), ), style: @@ -219,29 +167,26 @@ class ActivityPlanCardState extends State { Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - widget.controller.updatedActivity.imageURL != - null + controller.updatedActivity.imageURL != null ? ClipRRect( borderRadius: BorderRadius.circular(4.0), - child: widget.controller.updatedActivity - .imageURL! + child: controller + .updatedActivity.imageURL! .startsWith("mxc") ? MxcImage( uri: Uri.parse( - widget - .controller - .updatedActivity + controller.updatedActivity .imageURL!, ), width: 24.0, height: 24.0, - cacheKey: widget.controller + cacheKey: controller .updatedActivity.bookmarkId, fit: BoxFit.cover, ) : CachedNetworkImage( - imageUrl: widget.controller + imageUrl: controller .updatedActivity.imageURL!, fit: BoxFit.cover, width: 24.0, @@ -269,7 +214,7 @@ class ActivityPlanCardState extends State { const SizedBox(width: itemPadding), Expanded( child: Text( - widget.controller.updatedActivity.title, + controller.updatedActivity.title, style: Theme.of(context).textTheme.bodyLarge, ), @@ -286,7 +231,7 @@ class ActivityPlanCardState extends State { child: Text( L10n.of(context) .maximumActivityParticipants( - widget.controller.updatedActivity.req + controller.updatedActivity.req .numberOfParticipants, ), style: @@ -315,9 +260,8 @@ class ActivityPlanCardState extends State { .bodyLarge, ), NumberCounter( - count: widget.controller.numActivities, - update: - widget.controller.setNumActivities, + count: controller.numActivities, + update: controller.setNumActivities, min: 1, max: 5, ), @@ -337,7 +281,7 @@ class ActivityPlanCardState extends State { horizontal: 12.0, ), ), - onPressed: _onLaunch, + onPressed: () => _onLaunch(context), child: Row( children: [ const Icon(Icons.send_outlined), @@ -358,10 +302,10 @@ class ActivityPlanCardState extends State { const Icon(Icons.event_note_outlined), const SizedBox(width: itemPadding), Expanded( - child: widget.controller.isEditing + child: controller.isEditing ? TextField( controller: - widget.controller.titleController, + controller.titleController, decoration: InputDecoration( labelText: L10n.of(context).activityTitle, @@ -369,22 +313,18 @@ class ActivityPlanCardState extends State { maxLines: null, ) : Text( - widget - .controller.updatedActivity.title, + controller.updatedActivity.title, style: Theme.of(context) .textTheme .bodyLarge, ), ), - if (!widget.controller.isEditing) + if (!controller.isEditing) IconButton( - onPressed: _isBookmarked - ? () => _removeBookmark() - : () => _addBookmark( - widget.controller.updatedActivity, - ), + onPressed: + controller.toggleBookmarkedActivity, icon: Icon( - _isBookmarked + controller.isBookmarked ? Icons.save : Icons.save_outlined, ), @@ -401,9 +341,9 @@ class ActivityPlanCardState extends State { ), const SizedBox(width: itemPadding), Expanded( - child: widget.controller.isEditing + child: controller.isEditing ? TextField( - controller: widget.controller + controller: controller .learningObjectivesController, decoration: InputDecoration( labelText: @@ -412,7 +352,7 @@ class ActivityPlanCardState extends State { maxLines: null, ) : Text( - widget.controller.updatedActivity + controller.updatedActivity .learningObjective, style: Theme.of(context) .textTheme @@ -431,18 +371,18 @@ class ActivityPlanCardState extends State { ), const SizedBox(width: itemPadding), Expanded( - child: widget.controller.isEditing + child: controller.isEditing ? TextField( - controller: widget.controller - .instructionsController, + controller: + controller.instructionsController, decoration: InputDecoration( labelText: l10n.instructions, ), maxLines: null, ) : Text( - widget.controller.updatedActivity - .instructions, + controller + .updatedActivity.instructions, style: Theme.of(context) .textTheme .bodyMedium, @@ -460,16 +400,16 @@ class ActivityPlanCardState extends State { ), const SizedBox(width: itemPadding), Expanded( - child: widget.controller.isEditing + child: controller.isEditing ? LanguageLevelDropdown( initialLevel: - widget.controller.languageLevel, - onChanged: widget - .controller.setLanguageLevel, + controller.languageLevel, + onChanged: + controller.setLanguageLevel, ) : Text( - widget.controller.updatedActivity.req - .cefrLevel + controller + .updatedActivity.req.cefrLevel .title(context), style: Theme.of(context) .textTheme @@ -479,7 +419,7 @@ class ActivityPlanCardState extends State { ], ), const SizedBox(height: itemPadding), - if (widget.controller.vocab.isNotEmpty) ...[ + if (controller.vocab.isNotEmpty) ...[ Row( children: [ Icon( @@ -493,16 +433,13 @@ class ActivityPlanCardState extends State { spacing: 4.0, runSpacing: 4.0, children: List.generate( - widget.controller.vocab.length, - (int index) { - return widget.controller.isEditing + controller.vocab.length, (int index) { + return controller.isEditing ? Chip( label: Text( - widget.controller.vocab[index] - .lemma, + controller.vocab[index].lemma, ), - onDeleted: () => widget - .controller + onDeleted: () => controller .removeVocab(index), backgroundColor: Colors.transparent, @@ -516,8 +453,7 @@ class ActivityPlanCardState extends State { ) : Chip( label: Text( - widget.controller.vocab[index] - .lemma, + controller.vocab[index].lemma, ), backgroundColor: Colors.transparent, @@ -535,7 +471,7 @@ class ActivityPlanCardState extends State { ], ), ], - if (widget.controller.isEditing) ...[ + if (controller.isEditing) ...[ const SizedBox(height: itemPadding), Padding( padding: @@ -544,19 +480,18 @@ class ActivityPlanCardState extends State { children: [ Expanded( child: TextField( - controller: - widget.controller.vocabController, + controller: controller.vocabController, decoration: InputDecoration( labelText: l10n.addVocabulary, ), onSubmitted: (value) { - widget.controller.addVocab(); + controller.addVocab(); }, ), ), IconButton( icon: const Icon(Icons.add), - onPressed: widget.controller.addVocab, + onPressed: controller.addVocab, ), ], ), @@ -576,7 +511,7 @@ class ActivityPlanCardState extends State { horizontal: 12.0, ), ), - onPressed: widget.controller.saveEdits, + onPressed: controller.saveEdits, child: Row( children: [ const Icon(Icons.save), @@ -601,7 +536,7 @@ class ActivityPlanCardState extends State { horizontal: 12.0, ), ), - onPressed: widget.controller.clearEdits, + onPressed: controller.clearEdits, child: Row( children: [ const Icon(Icons.cancel), @@ -636,8 +571,7 @@ class ActivityPlanCardState extends State { horizontal: 12.0, ), ), - onPressed: - widget.controller.startEditing, + onPressed: controller.startEditing, child: Row( children: [ const Icon(Icons.edit), @@ -662,7 +596,7 @@ class ActivityPlanCardState extends State { horizontal: 12.0, ), ), - onPressed: widget.regenerate, + onPressed: regenerate, child: Row( children: [ const Icon( @@ -694,7 +628,7 @@ class ActivityPlanCardState extends State { ), ), onPressed: () { - widget.controller.setLaunchState( + controller.setLaunchState( ActivityLaunchState.launching, ); }, diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart index b16fa6c17..6e3f62391 100644 --- a/lib/pangea/activity_planner/activity_planner_builder.dart +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide Visibility; @@ -8,13 +10,14 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_plan_repo.dart'; import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/pangea/user/controllers/user_controller.dart'; import 'package:fluffychat/utils/client_download_content_extension.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -64,6 +67,8 @@ class ActivityPlannerBuilderState extends State { final GlobalKey formKey = GlobalKey(); + final StreamController stateStream = StreamController.broadcast(); + @override void initState() { super.initState(); @@ -77,9 +82,17 @@ class ActivityPlannerBuilderState extends State { instructionsController.dispose(); vocabController.dispose(); participantsController.dispose(); + stateStream.close(); super.dispose(); } + void update() { + if (mounted) setState(() {}); + if (!stateStream.isClosed) { + stateStream.add(null); + } + } + Room get room => widget.room; bool get isEditing => launchState == ActivityLaunchState.editing; @@ -129,7 +142,8 @@ class ActivityPlannerBuilderState extends State { if (widget.initialActivity.imageURL != null) { await _setAvatarByURL(widget.initialActivity.imageURL!); } - if (mounted) setState(() {}); + + update(); } Future overrideActivity(ActivityPlanModel override) async { @@ -148,18 +162,21 @@ class ActivityPlannerBuilderState extends State { if (override.imageURL != null) { await _setAvatarByURL(override.imageURL!); } - if (mounted) setState(() {}); + + update(); } - void startEditing() => setLaunchState(ActivityLaunchState.editing); + void startEditing() { + setLaunchState(ActivityLaunchState.editing); + } void setLaunchState(ActivityLaunchState state) { if (state == ActivityLaunchState.launching) { - BookmarkedActivitiesRepo.save(updatedActivity); + _addBookmarkedActivity(); } launchState = state; - if (mounted) setState(() {}); + update(); } void addVocab() { @@ -171,37 +188,35 @@ class ActivityPlannerBuilderState extends State { ), ); vocabController.clear(); - if (mounted) setState(() {}); + update(); } void removeVocab(int index) { vocab.removeAt(index); - if (mounted) setState(() {}); + update(); } void setLanguageLevel(LanguageLevelTypeEnum level) { languageLevel = level; - if (mounted) setState(() {}); + update(); } - void selectAvatar() async { + Future selectAvatar() async { final photo = await selectFiles( context, type: FileSelectorType.images, allowMultiple: false, ); final bytes = await photo.singleOrNull?.readAsBytes(); - if (mounted) { - setState(() { - avatar = bytes; - imageURL = null; - filename = photo.singleOrNull?.name; - }); - } + avatar = bytes; + imageURL = null; + filename = photo.singleOrNull?.name; + update(); } void setNumActivities(int count) { - if (mounted) setState(() => numActivities = count); + numActivities = count; + update(); } Future _setAvatarByURL(String url) async { @@ -240,10 +255,8 @@ class ActivityPlannerBuilderState extends State { avatar!, filename: filename, ); - if (!mounted) return; - setState(() { - imageURL = url.toString(); - }); + imageURL = url.toString(); + update(); } Future saveEdits() async { @@ -251,9 +264,8 @@ class ActivityPlannerBuilderState extends State { await updateImageURL(); setLaunchState(ActivityLaunchState.base); - await BookmarkedActivitiesRepo.remove(widget.initialActivity.bookmarkId); - await BookmarkedActivitiesRepo.save(updatedActivity); - if (mounted) setState(() {}); + await _updateBookmarkedActivity(); + update(); } Future clearEdits() async { @@ -261,6 +273,49 @@ class ActivityPlannerBuilderState extends State { setLaunchState(ActivityLaunchState.base); } + UserController get _userController => + MatrixState.pangeaController.userController; + + bool get isBookmarked => + _userController.isBookmarked(updatedActivity.bookmarkId); + + Future toggleBookmarkedActivity() async { + isBookmarked + ? await _removeBookmarkedActivity() + : await _addBookmarkedActivity(); + update(); + } + + Future _addBookmarkedActivity() async { + await _userController.addBookmarkedActivity( + activityId: updatedActivity.bookmarkId, + ); + await ActivityPlanRepo.set(updatedActivity); + } + + Future _updateBookmarkedActivity() async { + // save updates locally, in case choreo results in error + await ActivityPlanRepo.set(updatedActivity); + + // prevent an error or delay from the choreo endpoint bubbling up + // in the UI, since the changes are still stored locally + ActivityPlanRepo.update( + updatedActivity, + ).then((resp) { + _userController.updateBookmarkedActivity( + activityId: widget.initialActivity.bookmarkId, + newActivityId: resp.bookmarkId, + ); + }); + } + + Future _removeBookmarkedActivity() async { + await _userController.removeBookmarkedActivity( + activityId: updatedActivity.bookmarkId, + ); + await ActivityPlanRepo.remove(updatedActivity.bookmarkId); + } + Future> launchToSpace() async { final List activityRoomIDs = []; try { diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index 53c824f72..9429df245 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -43,10 +43,7 @@ class ActivityPlannerPageState extends State { switch (pageMode) { case PageMode.savedActivities: if (room != null) { - body = BookmarkedActivitiesList( - room: room!, - controller: this, - ); + body = BookmarkedActivitiesList(room: room!); } break; case PageMode.featuredActivities: diff --git a/lib/pangea/activity_planner/bookmarked_activities_repo.dart b/lib/pangea/activity_planner/bookmarked_activities_repo.dart deleted file mode 100644 index c7e1aefeb..000000000 --- a/lib/pangea/activity_planner/bookmarked_activities_repo.dart +++ /dev/null @@ -1,57 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'package:get_storage/get_storage.dart'; - -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; - -class BookmarkedActivitiesRepo { - static final GetStorage _bookStorage = GetStorage('bookmarked_activities'); - - /// save an activity to the list of bookmarked activities - /// returns the activity with a bookmarkId - static Future save( - ActivityPlanModel activity, - ) async { - await _bookStorage.write( - activity.bookmarkId, - activity.toJson(), - ); - - //now it has a bookmarkId - return activity; - } - - static Future remove(String bookmarkId) => - _bookStorage.remove(bookmarkId); - - static bool isBookmarked(ActivityPlanModel activity) { - return _bookStorage.read(activity.bookmarkId) != null; - } - - static List get() { - final List keys = List.from(_bookStorage.getKeys()); - if (keys.isEmpty) return []; - - final List activities = []; - for (final key in keys) { - final json = _bookStorage.read(key); - if (json == null) continue; - - ActivityPlanModel? activity; - try { - activity = ActivityPlanModel.fromJson(json); - } catch (e) { - _bookStorage.remove(key); - continue; - } - - if (key != activity.bookmarkId) { - _bookStorage.remove(key); - _bookStorage.write(activity.bookmarkId, activity.toJson()); - } - activities.add(activity); - } - - return activities; - } -} diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart index b706fe291..8860b7009 100644 --- a/lib/pangea/activity_planner/bookmarked_activity_list.dart +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -6,20 +6,17 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/user/controllers/user_controller.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class BookmarkedActivitiesList extends StatefulWidget { final Room room; - - final ActivityPlannerPageState controller; - const BookmarkedActivitiesList({ super.key, required this.room, - required this.controller, }); @override @@ -28,17 +25,51 @@ class BookmarkedActivitiesList extends StatefulWidget { } class BookmarkedActivitiesListState extends State { + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadBookmarkedActivities(); + } + List get _bookmarkedActivities => - BookmarkedActivitiesRepo.get(); + _userController.getBookmarkedActivitiesSync(); bool get _isColumnMode => FluffyThemes.isColumnMode(context); double get cardHeight => _isColumnMode ? 325.0 : 250.0; double get cardWidth => _isColumnMode ? 225.0 : 150.0; + UserController get _userController => + MatrixState.pangeaController.userController; + + Future _loadBookmarkedActivities() async { + try { + setState(() => _loading = true); + await _userController.getBookmarkedActivities(); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': widget.room.id, + }, + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + @override Widget build(BuildContext context) { final l10n = L10n.of(context); + if (_loading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + if (_bookmarkedActivities.isEmpty) { return Center( child: Container( @@ -60,16 +91,16 @@ class BookmarkedActivitiesListState extends State { runSpacing: 16.0, spacing: 4.0, children: _bookmarkedActivities.map((activity) { - return ActivitySuggestionCard( - activity: activity, - onPressed: () { - showDialog( - context: context, - builder: (context) { - return ActivityPlannerBuilder( - initialActivity: activity, - room: widget.room, - builder: (controller) { + return ActivityPlannerBuilder( + initialActivity: activity, + room: widget.room, + builder: (controller) { + return ActivitySuggestionCard( + controller: controller, + onPressed: () { + showDialog( + context: context, + builder: (context) { return ActivitySuggestionDialog( controller: controller, buttonText: l10n.launchActivityButton, @@ -77,11 +108,10 @@ class BookmarkedActivitiesListState extends State { }, ); }, + width: cardWidth, + height: cardHeight, ); }, - width: cardWidth, - height: cardHeight, - onChange: () => setState(() {}), ); }).toList(), ), diff --git a/lib/pangea/activity_sessions/activity_room_extension.dart b/lib/pangea/activity_sessions/activity_room_extension.dart index a160caa61..2770c072c 100644 --- a/lib/pangea/activity_sessions/activity_room_extension.dart +++ b/lib/pangea/activity_sessions/activity_room_extension.dart @@ -6,7 +6,6 @@ import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart'; @@ -25,8 +24,6 @@ extension ActivityRoomExtension on Room { Uint8List? avatar, String? filename, }) async { - BookmarkedActivitiesRepo.save(activity); - if (canChangeStateEvent(PangeaEventTypes.activityPlan)) { await client.setRoomStateWithKey( id, diff --git a/lib/pangea/activity_suggestions/activity_plan_repo.dart b/lib/pangea/activity_suggestions/activity_plan_repo.dart new file mode 100644 index 000000000..96461dcf3 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_plan_repo.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ActivityPlanRepo { + static final GetStorage _activityPlanStorage = + GetStorage('activity_plan_by_id_storage'); + + static ActivityPlanModel? getCached(String id) { + final cachedJson = _activityPlanStorage.read(id); + if (cachedJson == null) return null; + + try { + return ActivityPlanModel.fromJson(cachedJson); + } catch (e) { + _removeCached(id); + return null; + } + } + + static Future _setCached(ActivityPlanModel response) => + _activityPlanStorage.write(response.bookmarkId, response.toJson()); + + static Future _removeCached(String id) => + _activityPlanStorage.remove(id); + + static Future set(ActivityPlanModel activity) => _setCached(activity); + + static Future get(String id) async { + final cached = getCached(id); + if (cached != null) return cached; + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.get( + url: "${PApiUrls.activityPlan}/$id", + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ActivityPlanModel.fromJson(decodedBody["plan"]); + + _setCached(response); + return response; + } + + static Future update( + ActivityPlanModel update, + ) async { + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.patch( + url: "${PApiUrls.activityPlan}/${update.bookmarkId}", + body: update.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ActivityPlanModel.fromJson(decodedBody["plan"]); + + _removeCached(update.bookmarkId); + _setCached(response); + + return response; + } + + static Future remove(String id) => _removeCached(id); +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_card.dart index 62a097f11..8d40b8321 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_card.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_card.dart @@ -1,43 +1,32 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; class ActivitySuggestionCard extends StatelessWidget { - final ActivityPlanModel activity; - final Uint8List? image; - final VoidCallback? onPressed; - + final ActivityPlannerBuilderState controller; + final VoidCallback onPressed; final double width; final double height; - final bool selected; - - final VoidCallback onChange; const ActivitySuggestionCard({ super.key, - required this.activity, + required this.controller, required this.onPressed, required this.width, required this.height, - required this.onChange, - this.selected = false, - this.image, }); + ActivityPlanModel get activity => controller.updatedActivity; + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isBookmarked = BookmarkedActivitiesRepo.isBookmarked(activity); - return PressableButton( - depressed: selected || onPressed == null, onPressed: onPressed, borderRadius: BorderRadius.circular(24.0), color: theme.brightness == Brightness.dark @@ -46,11 +35,6 @@ class ActivitySuggestionCard extends StatelessWidget { colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2, child: Container( decoration: BoxDecoration( - border: selected - ? Border.all( - color: theme.colorScheme.primary, - ) - : null, borderRadius: BorderRadius.circular(24.0), ), height: height, @@ -76,27 +60,25 @@ class ActivitySuggestionCard extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(24.0), - child: image != null - ? Image.memory(image!, fit: BoxFit.cover) - : activity.imageURL != null - ? activity.imageURL!.startsWith("mxc") - ? MxcImage( - uri: Uri.parse(activity.imageURL!), - width: width, - height: width, - cacheKey: activity.bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: activity.imageURL!, - placeholder: (context, url) => const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => - const SizedBox(), - fit: BoxFit.cover, - ) - : null, + child: activity.imageURL != null + ? activity.imageURL!.startsWith("mxc") + ? MxcImage( + uri: Uri.parse(activity.imageURL!), + width: width, + height: width, + cacheKey: activity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: activity.imageURL!, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => + const SizedBox(), + fit: BoxFit.cover, + ) + : null, ), ), Expanded( @@ -180,19 +162,10 @@ class ActivitySuggestionCard extends StatelessWidget { right: 4.0, child: IconButton( icon: Icon( - isBookmarked ? Icons.save : Icons.save_outlined, + controller.isBookmarked ? Icons.save : Icons.save_outlined, color: Theme.of(context).colorScheme.onPrimaryContainer, ), - onPressed: onPressed != null - ? () async { - await (isBookmarked - ? BookmarkedActivitiesRepo.remove( - activity.bookmarkId, - ) - : BookmarkedActivitiesRepo.save(activity)); - onChange(); - } - : null, + onPressed: controller.toggleBookmarkedActivity, style: IconButton.styleFrom( backgroundColor: Theme.of(context) .colorScheme diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index cdbb39bbc..49f3e29cd 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -40,6 +42,22 @@ class ActivitySuggestionDialogState extends State { ? 400.0 : MediaQuery.of(context).size.width; + StreamSubscription? _stateSubscription; + + @override + void initState() { + super.initState(); + _stateSubscription = widget.controller.stateStream.stream.listen((state) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _stateSubscription?.cancel(); + super.dispose(); + } + Future launchActivity() async { try { if (!widget.controller.room.isSpace) { diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index e7a537592..6fc81cac1 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -185,16 +185,16 @@ class ActivitySuggestionsAreaState extends State { }) : _activityItems .mapIndexed((index, activity) { - return ActivitySuggestionCard( - activity: activity, - onPressed: () { - showDialog( - context: context, - builder: (context) { - return ActivityPlannerBuilder( - initialActivity: activity, - room: widget.room, - builder: (controller) { + return ActivityPlannerBuilder( + initialActivity: activity, + room: widget.room, + builder: (controller) { + return ActivitySuggestionCard( + controller: controller, + onPressed: () { + showDialog( + context: context, + builder: (context) { return ActivitySuggestionDialog( controller: controller, buttonText: L10n.of(context).saveAndLaunch, @@ -204,13 +204,10 @@ class ActivitySuggestionsAreaState extends State { }, ); }, + width: cardWidth, + height: cardHeight, ); }, - width: cardWidth, - height: cardHeight, - onChange: () { - if (mounted) setState(() {}); - }, ); }) .cast() diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index ab9212e1a..04bab9704 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -93,7 +93,7 @@ class GetAnalyticsController extends BaseController { await _getConstructs(); final offset = - _pangeaController.userController.publicProfile?.xpOffset ?? 0; + _pangeaController.userController.analyticsProfile?.xpOffset ?? 0; constructListModel.updateConstructs( [ ...(_getConstructsLocal() ?? []), @@ -149,7 +149,7 @@ class GetAnalyticsController extends BaseController { final oldLevel = constructListModel.level; final offset = - _pangeaController.userController.publicProfile?.xpOffset ?? 0; + _pangeaController.userController.analyticsProfile?.xpOffset ?? 0; final prevUnlockedMorphs = constructListModel .unlockedLemmas( @@ -203,7 +203,7 @@ class GetAnalyticsController extends BaseController { // If the level hasn't changed, this will not send an update to the server. // Do this on all updates (not just on level updates) to account for cases // of target language updates being missed (https://github.com/pangeachat/client/issues/2006) - _pangeaController.userController.updatePublicProfile( + _pangeaController.userController.updateAnalyticsProfile( level: constructListModel.level, ); } @@ -237,7 +237,7 @@ class GetAnalyticsController extends BaseController { await _pangeaController.userController.addXPOffset(offset); constructListModel.updateConstructs( [], - _pangeaController.userController.publicProfile!.xpOffset!, + _pangeaController.userController.analyticsProfile!.xpOffset!, ); } diff --git a/lib/pangea/analytics_misc/level_display_name.dart b/lib/pangea/analytics_misc/level_display_name.dart index c0f8bd995..e5903aa38 100644 --- a/lib/pangea/analytics_misc/level_display_name.dart +++ b/lib/pangea/analytics_misc/level_display_name.dart @@ -23,7 +23,7 @@ class LevelDisplayName extends StatelessWidget { ), child: FutureBuilder( future: MatrixState.pangeaController.userController - .getPublicProfile(userId), + .getPublicAnalyticsProfile(userId), builder: (context, snapshot) { return Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/pangea/analytics_misc/put_analytics_controller.dart b/lib/pangea/analytics_misc/put_analytics_controller.dart index 60e6035a9..0df72b647 100644 --- a/lib/pangea/analytics_misc/put_analytics_controller.dart +++ b/lib/pangea/analytics_misc/put_analytics_controller.dart @@ -151,7 +151,7 @@ class PutAnalyticsController extends BaseController { ); _pangeaController.resetAnalytics().then((_) { final level = _pangeaController.getAnalytics.constructListModel.level; - _pangeaController.userController.updatePublicProfile(level: level); + _pangeaController.userController.updateAnalyticsProfile(level: level); }); } diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 578073c2a..8ace4beec 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -738,7 +738,7 @@ class RoomParticipantsSection extends StatelessWidget { Membership.leave => null, }; - final publicProfile = participantsLoader.getPublicProfile( + final publicProfile = participantsLoader.getAnalyticsProfile( user.id, ); diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index d85fbdefa..3cfec96e3 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -181,4 +181,6 @@ class ModelKey { static const String autoIGC = "auto_igc"; static const String roomIds = "room_ids"; + + static const String bookmarkedActivities = "bookmarked_activities"; } diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 58ac834e2..ddb5a9dc5 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -111,6 +111,7 @@ class PangeaController { static final List _storageKeys = [ 'mode_list_storage', 'activity_plan_storage', + 'activity_plan_by_id_storage', 'bookmarked_activities', 'objective_list_storage', 'topic_list_storage', diff --git a/lib/pangea/common/network/requests.dart b/lib/pangea/common/network/requests.dart index 8da609734..9e3162814 100644 --- a/lib/pangea/common/network/requests.dart +++ b/lib/pangea/common/network/requests.dart @@ -66,6 +66,29 @@ class Requests { return response; } + Future patch({ + required String url, + required Map body, + }) async { + body[ModelKey.cefrLevel] = MatrixState + .pangeaController.userController.profile.userSettings.cefrLevel.string; + + dynamic encoded; + encoded = jsonEncode(body); + + debugPrint(baseUrl! + url); + + final http.Response response = await http.patch( + _uriBuilder(url), + body: encoded, + headers: _headers, + ); + + handleError(response, body: body); + + return response; + } + Future get({required String url, String objectId = ""}) async { final http.Response response = await http.get(_uriBuilder(url + objectId), headers: _headers); diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index ad387d769..d64a48d8a 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -58,13 +58,16 @@ class PApiUrls { "${PApiUrls.choreoEndpoint}/lemma_definition/edit"; static String morphDictionary = "${PApiUrls.choreoEndpoint}/morph_meaning"; + static String activityPlan = "${PApiUrls.choreoEndpoint}/activity_plan"; static String activityPlanGeneration = - "${PApiUrls.choreoEndpoint}/activity_plan"; + "${PApiUrls.choreoEndpoint}/activity_plan/generate"; + static String activityPlanSearch = + "${PApiUrls.choreoEndpoint}/activity_plan/search"; + static String activityModeList = "${PApiUrls.choreoEndpoint}/modes"; static String objectiveList = "${PApiUrls.choreoEndpoint}/objectives"; static String topicList = "${PApiUrls.choreoEndpoint}/topics"; - static String activityPlanSearch = - "${PApiUrls.choreoEndpoint}/activity_plan/search"; + static String activitySummary = "${PApiUrls.choreoEndpoint}/activity_summary"; static String morphFeaturesAndTags = "${PApiUrls.choreoEndpoint}/morphs"; diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index 93349ae11..795e5db70 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/constants/pangea_event_types.dart @@ -46,6 +46,7 @@ class PangeaEventTypes { /// Profile information related to a user's analytics static const profileAnalytics = "pangea.analytics_profile"; + static const profileActivities = "pangea.activities_profile"; static const activityRoomIds = "pangea.activity_room_ids"; } diff --git a/lib/pangea/login/pages/user_settings.dart b/lib/pangea/login/pages/user_settings.dart index a0e4d213d..88f3d5d5c 100644 --- a/lib/pangea/login/pages/user_settings.dart +++ b/lib/pangea/login/pages/user_settings.dart @@ -225,7 +225,7 @@ class UserSettingsState extends State { }, waitForDataInSync: true, ), - _pangeaController.userController.updatePublicProfile( + _pangeaController.userController.updateAnalyticsProfile( targetLanguage: selectedTargetLanguage, baseLanguage: _systemLanguage, level: 1, diff --git a/lib/pangea/space_analytics/space_analytics.dart b/lib/pangea/space_analytics/space_analytics.dart index 528b62486..71ff28a56 100644 --- a/lib/pangea/space_analytics/space_analytics.dart +++ b/lib/pangea/space_analytics/space_analytics.dart @@ -19,7 +19,7 @@ import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum. import 'package:fluffychat/pangea/space_analytics/space_analytics_inactive_dialog.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics_request_dialog.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics_view.dart'; -import 'package:fluffychat/pangea/user/models/profile_model.dart'; +import 'package:fluffychat/pangea/user/models/analytics_profile_model.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -119,7 +119,7 @@ class SpaceAnalyticsState extends State { Map downloads = {}; DateTime? _lastUpdated; - final Map _profiles = {}; + final Map _profiles = {}; final Map> _langsToUsers = {}; Room? get room => Matrix.of(context).client.getRoomById(widget.roomId); @@ -233,7 +233,7 @@ class SpaceAnalyticsState extends State { Future _loadProfiles() async { final futures = _availableUsers.map((u) async { final resp = await MatrixState.pangeaController.userController - .getPublicProfile(u.id); + .getPublicAnalyticsProfile(u.id); _profiles[u] = resp; if (resp.languageAnalytics == null) return; diff --git a/lib/pangea/spaces/utils/load_participants_util.dart b/lib/pangea/spaces/utils/load_participants_util.dart index 68615f09e..78faca705 100644 --- a/lib/pangea/spaces/utils/load_participants_util.dart +++ b/lib/pangea/spaces/utils/load_participants_util.dart @@ -5,7 +5,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/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/user/models/profile_model.dart'; +import 'package:fluffychat/pangea/user/models/analytics_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; class LoadParticipantsUtil extends StatefulWidget { @@ -26,7 +26,7 @@ class LoadParticipantsUtilState extends State { bool loading = true; String? error; - final Map _levelsCache = {}; + final Map _levelsCache = {}; List get participants => widget.space.getParticipants(); @@ -92,8 +92,8 @@ class LoadParticipantsUtilState extends State { return -1; } - final PublicProfileModel? aProfile = _levelsCache[a.id]; - final PublicProfileModel? bProfile = _levelsCache[b.id]; + final AnalyticsProfileModel? aProfile = _levelsCache[a.id]; + final AnalyticsProfileModel? bProfile = _levelsCache[b.id]; return (bProfile?.level ?? 0).compareTo(aProfile?.level ?? 0); }); @@ -106,12 +106,12 @@ class LoadParticipantsUtilState extends State { if (_levelsCache[user.id] == null && user.membership == Membership.join) { _levelsCache[user.id] = await MatrixState .pangeaController.userController - .getPublicProfile(user.id); + .getPublicAnalyticsProfile(user.id); } } } - PublicProfileModel? getPublicProfile(String userId) { + AnalyticsProfileModel? getAnalyticsProfile(String userId) { return _levelsCache[userId]; } diff --git a/lib/pangea/spaces/widgets/leaderboard_participant_list.dart b/lib/pangea/spaces/widgets/leaderboard_participant_list.dart index a1bcdde8d..8dec7a6b0 100644 --- a/lib/pangea/spaces/widgets/leaderboard_participant_list.dart +++ b/lib/pangea/spaces/widgets/leaderboard_participant_list.dart @@ -71,7 +71,8 @@ class LeaderboardParticipantListState itemCount: participants.length, itemBuilder: (context, i) { final user = participants[i]; - final publicProfile = participantsLoader.getPublicProfile( + final publicProfile = + participantsLoader.getAnalyticsProfile( user.id, ); diff --git a/lib/pangea/user/controllers/user_controller.dart b/lib/pangea/user/controllers/user_controller.dart index 3c5b191c5..e69bbe007 100644 --- a/lib/pangea/user/controllers/user_controller.dart +++ b/lib/pangea/user/controllers/user_controller.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:jwt_decode/jwt_decode.dart'; import 'package:matrix/matrix.dart' as matrix; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; @@ -15,7 +17,8 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.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/learning_settings/utils/p_language_store.dart'; -import 'package:fluffychat/pangea/user/models/profile_model.dart'; +import 'package:fluffychat/pangea/user/models/activities_profile_model.dart'; +import 'package:fluffychat/pangea/user/models/analytics_profile_model.dart'; import '../models/user_model.dart'; class LanguageUpdate { @@ -54,7 +57,8 @@ class UserController { /// to be read in from client's account data each time it is accessed. Profile? _cachedProfile; - PublicProfileModel? publicProfile; + AnalyticsProfileModel? analyticsProfile; + ActivitiesProfileModel? activitiesProfile; /// Listens for account updates and updates the cached profile StreamSubscription? _profileListener; @@ -146,6 +150,7 @@ class UserController { _initializing = true; try { + await GetStorage.init('activity_plan_by_id_storage'); await _initialize(); _addProfileListener(); _addAnalyticsRoomIdsToPublicProfile(); @@ -184,21 +189,25 @@ class UserController { if (client.userID == null) return; try { final resp = await client.getUserProfile(client.userID!); - publicProfile = PublicProfileModel.fromJson(resp.additionalProperties); + analyticsProfile = + AnalyticsProfileModel.fromJson(resp.additionalProperties); + activitiesProfile = + ActivitiesProfileModel.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(); + analyticsProfile = AnalyticsProfileModel(); + activitiesProfile = ActivitiesProfileModel.empty; } // 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) { + if (analyticsProfile!.isEmpty) { _pangeaController.getAnalytics.initCompleter.future .timeout(const Duration(seconds: 10)) .then((_) { - updatePublicProfile( + updateAnalyticsProfile( level: _pangeaController.getAnalytics.constructListModel.level, ); }).catchError((e, s) { @@ -206,7 +215,7 @@ class UserController { e: e, s: s, data: { - "publicProfile": publicProfile?.toJson(), + "publicProfile": analyticsProfile?.toJson(), "userId": client.userID, }, level: @@ -231,85 +240,6 @@ class UserController { await initialize(); } - Future updatePublicProfile({ - required int level, - LanguageModel? baseLanguage, - LanguageModel? targetLanguage, - }) async { - targetLanguage ??= _pangeaController.languageController.userL2; - baseLanguage ??= _pangeaController.languageController.userL1; - if (targetLanguage == null || publicProfile == null) return; - - final analyticsRoom = - _pangeaController.matrixState.client.analyticsRoomLocal(targetLanguage); - - if (publicProfile!.targetLanguage == targetLanguage && - publicProfile!.baseLanguage == baseLanguage && - publicProfile!.languageAnalytics?[targetLanguage]?.level == level && - publicProfile!.analyticsRoomIdByLanguage(targetLanguage) == - analyticsRoom?.id) { - return; - } - - publicProfile!.baseLanguage = baseLanguage; - publicProfile!.targetLanguage = targetLanguage; - publicProfile!.setLanguageInfo( - targetLanguage, - level, - analyticsRoom?.id, - ); - await _savePublicProfile(); - } - - Future _addAnalyticsRoomIdsToPublicProfile() async { - if (publicProfile?.languageAnalytics == null) return; - final analyticsRooms = - _pangeaController.matrixState.client.allMyAnalyticsRooms; - - if (analyticsRooms.isEmpty) return; - for (final analyticsRoom in analyticsRooms) { - final lang = analyticsRoom.madeForLang?.split("-").first; - if (lang == null || publicProfile?.languageAnalytics == null) continue; - final langKey = publicProfile!.languageAnalytics!.keys.firstWhereOrNull( - (l) => l.langCodeShort == lang, - ); - - if (langKey == null) continue; - if (publicProfile!.languageAnalytics![langKey]!.analyticsRoomId == - analyticsRoom.id) { - continue; - } - - publicProfile!.setLanguageInfo( - langKey, - publicProfile!.languageAnalytics![langKey]!.level, - analyticsRoom.id, - ); - } - - await _savePublicProfile(); - } - - Future addXPOffset(int offset) async { - final targetLanguage = _pangeaController.languageController.userL2; - if (targetLanguage == null || publicProfile == null) return; - - publicProfile!.addXPOffset( - targetLanguage, - offset, - _pangeaController.matrixState.client - .analyticsRoomLocal(targetLanguage) - ?.id, - ); - await _savePublicProfile(); - } - - Future _savePublicProfile() async => 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); @@ -421,14 +351,171 @@ class UserController { return email?.address; } - Future getPublicProfile(String userId) async { + Future _savePublicProfileUpdate( + String type, + Map content, + ) async => + client.setUserProfile( + client.userID!, + type, + content, + ); + + Future updateAnalyticsProfile({ + required int level, + LanguageModel? baseLanguage, + LanguageModel? targetLanguage, + }) async { + targetLanguage ??= _pangeaController.languageController.userL2; + baseLanguage ??= _pangeaController.languageController.userL1; + if (targetLanguage == null || analyticsProfile == null) return; + + final analyticsRoom = + _pangeaController.matrixState.client.analyticsRoomLocal(targetLanguage); + + if (analyticsProfile!.targetLanguage == targetLanguage && + analyticsProfile!.baseLanguage == baseLanguage && + analyticsProfile!.languageAnalytics?[targetLanguage]?.level == level && + analyticsProfile!.analyticsRoomIdByLanguage(targetLanguage) == + analyticsRoom?.id) { + return; + } + + analyticsProfile!.baseLanguage = baseLanguage; + analyticsProfile!.targetLanguage = targetLanguage; + analyticsProfile!.setLanguageInfo( + targetLanguage, + level, + analyticsRoom?.id, + ); + await _savePublicProfileUpdate( + PangeaEventTypes.profileAnalytics, + analyticsProfile!.toJson(), + ); + } + + Future _addAnalyticsRoomIdsToPublicProfile() async { + if (analyticsProfile?.languageAnalytics == null) return; + final analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + if (analyticsRooms.isEmpty) return; + for (final analyticsRoom in analyticsRooms) { + final lang = analyticsRoom.madeForLang?.split("-").first; + if (lang == null || analyticsProfile?.languageAnalytics == null) continue; + final langKey = + analyticsProfile!.languageAnalytics!.keys.firstWhereOrNull( + (l) => l.langCodeShort == lang, + ); + + if (langKey == null) continue; + if (analyticsProfile!.languageAnalytics![langKey]!.analyticsRoomId == + analyticsRoom.id) { + continue; + } + + analyticsProfile!.setLanguageInfo( + langKey, + analyticsProfile!.languageAnalytics![langKey]!.level, + analyticsRoom.id, + ); + } + + await _savePublicProfileUpdate( + PangeaEventTypes.profileAnalytics, + analyticsProfile!.toJson(), + ); + } + + Future addXPOffset(int offset) async { + final targetLanguage = _pangeaController.languageController.userL2; + if (targetLanguage == null || analyticsProfile == null) return; + + analyticsProfile!.addXPOffset( + targetLanguage, + offset, + _pangeaController.matrixState.client + .analyticsRoomLocal(targetLanguage) + ?.id, + ); + await _savePublicProfileUpdate( + PangeaEventTypes.profileAnalytics, + analyticsProfile!.toJson(), + ); + } + + Future addBookmarkedActivity({ + required String activityId, + }) async { + if (activitiesProfile == null) { + throw Exception("Activities profile is not initialized"); + } + + activitiesProfile!.addBookmark(activityId); + await _savePublicProfileUpdate( + PangeaEventTypes.profileActivities, + activitiesProfile!.toJson(), + ); + } + + Future> getBookmarkedActivities() async { + if (activitiesProfile == null) { + throw Exception("Activities profile is not initialized"); + } + + return activitiesProfile!.getBookmarkedActivities(); + } + + List getBookmarkedActivitiesSync() { + if (activitiesProfile == null) { + throw Exception("Activities profile is not initialized"); + } + + return activitiesProfile!.getBookmarkedActivitiesSync(); + } + + Future updateBookmarkedActivity({ + required String activityId, + required String newActivityId, + }) async { + if (activitiesProfile == null) { + throw Exception("Activities profile is not initialized"); + } + + activitiesProfile!.removeBookmark(activityId); + activitiesProfile!.addBookmark(newActivityId); + await _savePublicProfileUpdate( + PangeaEventTypes.profileActivities, + activitiesProfile!.toJson(), + ); + } + + Future removeBookmarkedActivity({ + required String activityId, + }) async { + if (activitiesProfile == null) { + throw Exception("Activities profile is not initialized"); + } + + activitiesProfile!.removeBookmark(activityId); + await _savePublicProfileUpdate( + PangeaEventTypes.profileActivities, + activitiesProfile!.toJson(), + ); + } + + bool isBookmarked(String id) => activitiesProfile?.isBookmarked(id) ?? false; + + Future getPublicAnalyticsProfile( + String userId, + ) async { try { if (userId == BotName.byEnvironment) { - return PublicProfileModel(); + return AnalyticsProfileModel(); } final resp = await client.getUserProfile(userId); - return PublicProfileModel.fromJson(resp.additionalProperties); + return AnalyticsProfileModel.fromJson(resp.additionalProperties); } catch (e, s) { ErrorHandler.logError( e: e, @@ -437,7 +524,7 @@ class UserController { userId: userId, }, ); - return PublicProfileModel(); + return AnalyticsProfileModel(); } } } diff --git a/lib/pangea/user/models/activities_profile_model.dart b/lib/pangea/user/models/activities_profile_model.dart new file mode 100644 index 000000000..2dfca0e28 --- /dev/null +++ b/lib/pangea/user/models/activities_profile_model.dart @@ -0,0 +1,55 @@ +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_plan_repo.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; + +class ActivitiesProfileModel { + final List _bookmarkedActivities; + + ActivitiesProfileModel({ + required List bookmarkedActivities, + }) : _bookmarkedActivities = bookmarkedActivities; + + static ActivitiesProfileModel get empty => ActivitiesProfileModel( + bookmarkedActivities: [], + ); + + bool isBookmarked(String id) => _bookmarkedActivities.contains(id); + + void addBookmark(String activityId) { + if (!_bookmarkedActivities.contains(activityId)) { + _bookmarkedActivities.add(activityId); + } + } + + void removeBookmark(String activityId) { + _bookmarkedActivities.remove(activityId); + } + + Future> getBookmarkedActivities() => Future.wait( + _bookmarkedActivities.map((id) => ActivityPlanRepo.get(id)).toList(), + ); + + List getBookmarkedActivitiesSync() => _bookmarkedActivities + .map((id) => ActivityPlanRepo.getCached(id)) + .whereType() + .toList(); + + static ActivitiesProfileModel fromJson(Map json) { + if (!json.containsKey(PangeaEventTypes.profileActivities)) { + return ActivitiesProfileModel.empty; + } + + final profileJson = json[PangeaEventTypes.profileActivities]; + return ActivitiesProfileModel( + bookmarkedActivities: + List.from(profileJson[ModelKey.bookmarkedActivities] ?? []), + ); + } + + Map toJson() { + return { + ModelKey.bookmarkedActivities: _bookmarkedActivities, + }; + } +} diff --git a/lib/pangea/user/models/profile_model.dart b/lib/pangea/user/models/analytics_profile_model.dart similarity index 95% rename from lib/pangea/user/models/profile_model.dart rename to lib/pangea/user/models/analytics_profile_model.dart index 5bc8e3d5d..127e7d3c9 100644 --- a/lib/pangea/user/models/profile_model.dart +++ b/lib/pangea/user/models/analytics_profile_model.dart @@ -3,20 +3,20 @@ 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/p_language_store.dart'; -class PublicProfileModel { +class AnalyticsProfileModel { LanguageModel? baseLanguage; LanguageModel? targetLanguage; Map? languageAnalytics; - PublicProfileModel({ + AnalyticsProfileModel({ this.baseLanguage, this.targetLanguage, this.languageAnalytics, }); - factory PublicProfileModel.fromJson(Map json) { + factory AnalyticsProfileModel.fromJson(Map json) { if (!json.containsKey(PangeaEventTypes.profileAnalytics)) { - return PublicProfileModel(); + return AnalyticsProfileModel(); } final profileJson = json[PangeaEventTypes.profileAnalytics]; @@ -47,7 +47,7 @@ class PublicProfileModel { } } - final profile = PublicProfileModel( + final profile = AnalyticsProfileModel( baseLanguage: baseLanguage, targetLanguage: targetLanguage, languageAnalytics: languageAnalytics,