diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index f0d0294df..f48cdd233 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4814,8 +4814,18 @@ "home": "Home", "join": "Join", "learnByTexting": "Learn by texting", + "levelSummaryTrigger": "View summary", + "levelSummaryPopupTitle": "Level {level} Summary", + "@levelSummaryPopupTitle": { + "type": "String", + "placeholders": { + "level": { + "type": "int" + } + } + }, "startChatting": "Start chatting", "referFriends": "Refer friends", "referFriendDialogTitle": "Invite a friend to your conversation", "referFriendDialogDesc": "Do you have a friend who is excited to learn a new language with you? Then copy and send this invitation link to join and start chatting with you today." -} +} \ No newline at end of file diff --git a/assets/l10n/intl_vi.arb b/assets/l10n/intl_vi.arb index 2089d4d59..705bf9d3b 100644 --- a/assets/l10n/intl_vi.arb +++ b/assets/l10n/intl_vi.arb @@ -3793,5 +3793,15 @@ "@createASpace": { "type": "String", "placeholders": {} + }, + "levelSummaryTrigger": "Đọc báo cáo", + "levelSummaryPopupTitle": "Tóm tắt cấp {level}", + "@levelSummaryPopupTitle": { + "type": "String", + "placeholders": { + "level": { + "type": "int" + } + } } } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index aec4ee17a..c72b92a63 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -365,18 +365,30 @@ class ChatController extends State _levelSubscription = pangeaController.getAnalytics.stateStream .where( - (update) => - update is Map && update['level_up'] != null, - ) + (update) => update is Map && update['level_up'] != null, + ) + // .listen( + // (update) => Future.delayed( + // const Duration(milliseconds: 500), + // () => LevelUpUtil.showLevelUpDialog( + // update['level_up'], + // context, + // ), + // ), + // ) .listen( - (update) => Future.delayed( - const Duration(milliseconds: 500), - () => LevelUpUtil.showLevelUpDialog( - update['level_up'], - context, - ), - ), + // remove delay now that GetAnalyticsController._onLevelUp + // is async is should take roughly 500ms to make requests anyway + (update) { + LevelUpUtil.showLevelUpDialog( + update['level_up'], + update['analytics_room_id'], + update["construct_summary_state_event_id"], + update['construct_summary'], + context, ); + }, + ); // Pangea# _tryLoadTimeline(); if (kIsWeb) { diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index f9b787dfc..7d8f3229f 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -18,6 +18,8 @@ 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/constructs/construct_repo.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; @@ -141,8 +143,12 @@ class GetAnalyticsController extends BaseController { if (analyticsUpdate.type == AnalyticsUpdateType.server) { await _getConstructs(forceUpdate: true); } - if (oldLevel < constructListModel.level) _onLevelUp(); - if (oldLevel > constructListModel.level) await _onLevelDown(oldLevel); + if (oldLevel < constructListModel.level) { + await _onLevelUp(oldLevel, constructListModel.level); + } + if (oldLevel > constructListModel.level) { + await _onLevelDown(constructListModel.level, oldLevel); + } _updateAnalyticsStream(origin: analyticsUpdate.origin); // Update public profile each time that new analytics are added. // If the level hasn't changed, this will not send an update to the server. @@ -158,13 +164,22 @@ class GetAnalyticsController extends BaseController { }) => analyticsStream.add(AnalyticsStreamUpdate(origin: origin)); - void _onLevelUp() { - setState({'level_up': constructListModel.level}); + Future _onLevelUp(final int lowerLevel, final int upperLevel) async { + final result = await _generateLevelUpAnalyticsAndSaveToStateEvent( + lowerLevel, + upperLevel, + ); + setState({ + 'level_up': constructListModel.level, + 'analytics_room_id': _client.analyticsRoomLocal(_l2!)?.id, + "construct_summary_state_event_id": result?.stateEventId, + "construct_summary": result?.summary, + }); } - Future _onLevelDown(final prevLevel) async { + Future _onLevelDown(final int lowerLevel, final int upperLevel) async { final offset = - _calculateMinXpForLevel(prevLevel) - constructListModel.totalXP; + _calculateMinXpForLevel(lowerLevel) - constructListModel.totalXP; await _pangeaController.userController.addXPOffset(offset); constructListModel.updateConstructs( [], @@ -344,6 +359,90 @@ class GetAnalyticsController extends BaseController { ); _cache.add(entry); } + + Future + _generateLevelUpAnalyticsAndSaveToStateEvent( + final int lowerLevel, + final int upperLevel, + ) async { + // generate level up analytics as a construct summary + ConstructSummary summary; + try { + final int maxXP = _calculateMinXpForLevel(upperLevel); + final int minXP = _calculateMinXpForLevel(lowerLevel); + int diffXP = maxXP - minXP; + if (diffXP < 0) diffXP = 0; + + // compute construct use of current level + final List constructUseOfCurrentLevel = []; + int score = 0; + for (final use in constructListModel.uses) { + constructUseOfCurrentLevel.add(use); + score += use.pointValue; + if (score >= diffXP) break; + } + + // extract construct use message bodies for analytics + List? constructUseMessageContentBodies = []; + for (final use in constructUseOfCurrentLevel) { + try { + final useMessage = await use.getEvent(_client); + final useMessageBody = useMessage?.content["body"]; + if (useMessageBody is String) { + constructUseMessageContentBodies.add(useMessageBody); + } else { + constructUseMessageContentBodies.add(null); + } + } catch (e) { + constructUseMessageContentBodies.add(null); + } + } + if (constructUseMessageContentBodies.length != + constructUseOfCurrentLevel.length) { + constructUseMessageContentBodies = null; + } + + final request = ConstructSummaryRequest( + constructs: constructUseOfCurrentLevel, + constructUseMessageContentBodies: constructUseMessageContentBodies, + language: _l2!.langCodeShort, + upperLevel: upperLevel, + lowerLevel: lowerLevel, + ); + + final response = await ConstructRepo.generateConstructSummary(request); + summary = response.summary; + } catch (e) { + debugPrint("Error generating level up analytics: $e"); + ErrorHandler.logError(e: e, data: {'e': e}); + return null; + } + String stateEventId; + try { + final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); + if (analyticsRoom == null) { + ErrorHandler.logError( + e: e, + data: {'e': e, 'message': "Analytics room not found for user"}, + ); + return null; + } + stateEventId = await _client.setRoomStateWithKey( + analyticsRoom.id, + PangeaEventTypes.constructSummary, + '', + summary.toJson(), + ); + } catch (e) { + debugPrint("Error saving construct summary room: $e"); + ErrorHandler.logError(e: e, data: {'e': e}); + return null; + } + return GenerateConstructSummaryResult( + stateEventId: stateEventId, + summary: summary, + ); + } } class AnalyticsCacheEntry { diff --git a/lib/pangea/analytics_misc/level_summary.dart b/lib/pangea/analytics_misc/level_summary.dart new file mode 100644 index 000000000..04e0e6c21 --- /dev/null +++ b/lib/pangea/analytics_misc/level_summary.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +// New component renamed to ConstructSummaryAlertDialog with a max width +class ConstructSummaryAlertDialog extends StatelessWidget { + final String title; + final String content; + + const ConstructSummaryAlertDialog({ + super.key, + required this.title, + required this.content, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Text(content), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(L10n.of(context).close), + ), + ], + ); + } +} + +class LevelSummaryDialog extends StatelessWidget { + final int level; + final String analyticsRoomId; + final String summaryStateEventId; + final ConstructSummary? constructSummary; + + const LevelSummaryDialog({ + super.key, + required this.analyticsRoomId, + required this.level, + required this.summaryStateEventId, + this.constructSummary, + }); + + @override + Widget build(BuildContext context) { + final Client client = Matrix.of(context).client; + final futureSummary = client + .getOneRoomEvent(analyticsRoomId, summaryStateEventId) + .then((rawEvent) => ConstructSummary.fromJson(rawEvent.content)); + if (constructSummary != null) { + return ConstructSummaryAlertDialog( + title: L10n.of(context).levelSummaryPopupTitle(level), + content: constructSummary!.textSummary, + ); + } else { + return FutureBuilder( + future: futureSummary, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return ConstructSummaryAlertDialog( + title: L10n.of(context).levelSummaryPopupTitle(level), + content: L10n.of(context).error502504Desc, + ); + } else if (snapshot.hasData) { + final constructSummary = snapshot.data!; + return ConstructSummaryAlertDialog( + title: L10n.of(context).levelSummaryPopupTitle(level), + content: constructSummary.textSummary, + ); + } else { + return const SizedBox.shrink(); + } + }, + ); + } + } +} diff --git a/lib/pangea/analytics_misc/level_up.dart b/lib/pangea/analytics_misc/level_up.dart index b99d70ffa..aed360642 100644 --- a/lib/pangea/analytics_misc/level_up.dart +++ b/lib/pangea/analytics_misc/level_up.dart @@ -9,10 +9,15 @@ import 'package:http/http.dart' as http; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'level_summary.dart'; class LevelUpUtil { static void showLevelUpDialog( int level, + String? analyticsRoomId, + String? summaryStateEventId, + ConstructSummary? constructSummary, BuildContext context, ) { final player = AudioPlayer(); @@ -26,6 +31,9 @@ class LevelUpUtil { context: context, builder: (context) => LevelUpAnimation( level: level, + analyticsRoomId: analyticsRoomId, + summaryStateEventId: summaryStateEventId, + constructSummary: constructSummary, ), ).then((_) => player.dispose()); } @@ -33,9 +41,17 @@ class LevelUpUtil { class LevelUpAnimation extends StatefulWidget { final int level; + final String? analyticsRoomId; + final String? summary; + final String? summaryStateEventId; + final ConstructSummary? constructSummary; const LevelUpAnimation({ required this.level, + required this.analyticsRoomId, + this.summary, + this.summaryStateEventId, + this.constructSummary, super.key, }); @@ -43,11 +59,7 @@ class LevelUpAnimation extends StatefulWidget { LevelUpAnimationState createState() => LevelUpAnimationState(); } -class LevelUpAnimationState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _slideAnimation; - +class LevelUpAnimationState extends State { Uint8List? bytes; final imageURL = "${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpImageFileName}"; @@ -55,52 +67,13 @@ class LevelUpAnimationState extends State @override void initState() { super.initState(); - _loadImageData().then((resp) { - if (bytes == null) return; - _animationController.forward().then((_) { - if (mounted) Navigator.of(context).pop(); - }); - }).catchError((e) { + _loadImageData().catchError((e) { if (mounted) Navigator.of(context).pop(); }); - - _animationController = AnimationController( - duration: const Duration(milliseconds: 2500), - vsync: this, - ); - - _slideAnimation = TweenSequence( - >[ - // Slide up from the bottom of the screen to the middle - TweenSequenceItem( - tween: Tween(begin: const Offset(0, 2), end: Offset.zero) - .chain(CurveTween(curve: Curves.easeInOut)), - weight: 2.0, // Adjust weight for the duration of the slide-up - ), - // Pause in the middle - TweenSequenceItem( - tween: Tween(begin: Offset.zero, end: Offset.zero) - .chain(CurveTween(curve: Curves.linear)), - weight: 8.0, // Adjust weight for the pause duration - ), - // Slide up and off the screen - TweenSequenceItem( - tween: Tween(begin: Offset.zero, end: const Offset(0, -2)) - .chain(CurveTween(curve: Curves.easeInOut)), - weight: 2.0, // Adjust weight for the slide-off duration - ), - ], - ).animate( - CurvedAnimation( - parent: _animationController, - curve: Curves.linear, // Keep overall animation smooth - ), - ); } @override void dispose() { - _animationController.dispose(); super.dispose(); } @@ -119,45 +92,67 @@ class LevelUpAnimationState extends State return const SizedBox(); } - Widget content = Image.memory( - bytes!, - height: kIsWeb ? 350 : 250, - ); - - if (!kIsWeb) { - content = OverflowBox( - maxWidth: double.infinity, - child: content, - ); - } - - return GestureDetector( - onDoubleTap: Navigator.of(context).pop, - child: Dialog.fullscreen( - backgroundColor: Colors.transparent, - child: Center( - child: SlideTransition( - position: _slideAnimation, - child: Stack( - alignment: Alignment.center, - children: [ - content, - Padding( - padding: const EdgeInsets.only(top: 100), - child: Text( - L10n.of(context).levelPopupTitle(widget.level), - style: const TextStyle( - fontSize: kIsWeb ? 40 : 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), + return Dialog( + backgroundColor: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + // Banner image + Image.memory( + bytes!, + height: kIsWeb ? 350 : 250, + width: double.infinity, + fit: BoxFit.cover, ), - ), + // Overlay: centered title and close button + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + top: kIsWeb ? 200 : 100, + ), // Added hardcoded padding above the text + child: Text( + L10n.of(context).levelPopupTitle(widget.level), + style: const TextStyle( + fontSize: kIsWeb ? 40 : 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(L10n.of(context).close), + ), + const SizedBox(width: 16), + if (widget.summaryStateEventId != null && + widget.analyticsRoomId != null) + // Show summary button + ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => LevelSummaryDialog( + level: widget.level, + analyticsRoomId: widget.analyticsRoomId!, + summaryStateEventId: widget.summaryStateEventId!, + constructSummary: widget.constructSummary, + ), + ); + }, + child: Text(L10n.of(context).levelSummaryTrigger), + ), + ], + ), + ], + ), + ], ), ); } diff --git a/lib/pangea/analytics_misc/put_analytics_controller.dart b/lib/pangea/analytics_misc/put_analytics_controller.dart index 06aab4ce8..693792d47 100644 --- a/lib/pangea/analytics_misc/put_analytics_controller.dart +++ b/lib/pangea/analytics_misc/put_analytics_controller.dart @@ -304,18 +304,13 @@ class PutAnalyticsController extends BaseController { sendLocalAnalyticsToAnalyticsRoom(); return; } - - final int newLevel = - _pangeaController.getAnalytics.constructListModel.level; - newLevel > prevLevel - ? sendLocalAnalyticsToAnalyticsRoom() - : analyticsUpdateStream.add( - AnalyticsUpdate( - AnalyticsUpdateType.local, - newConstructs, - origin: origin, - ), - ); + analyticsUpdateStream.add( + AnalyticsUpdate( + AnalyticsUpdateType.local, + newConstructs, + origin: origin, + ), + ); } /// Clears the local cache of recently sent constructs. Called before updating analytics diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index 72335b64d..71e875646 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -76,6 +76,8 @@ class PApiUrls { "${PApiUrls.choreoEndpoint}/activity_plan/search"; static String morphFeaturesAndTags = "${PApiUrls.choreoEndpoint}/morphs"; + static String constructSummary = + "${PApiUrls.choreoEndpoint}/construct_summary"; ///-------------------------------- revenue cat -------------------------- static String rcAppsChoreo = "${PApiUrls.subscriptionEndpoint}/app_ids"; diff --git a/lib/pangea/constructs/construct_repo.dart b/lib/pangea/constructs/construct_repo.dart new file mode 100644 index 000000000..38adf6a6e --- /dev/null +++ b/lib/pangea/constructs/construct_repo.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/analytics_misc/constructs_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 ConstructSummary { + final int upperLevel; + final int lowerLevel; + final String language; + final String textSummary; + + ConstructSummary({ + required this.upperLevel, + required this.lowerLevel, + required this.language, + required this.textSummary, + }); + + Map toJson() { + return { + 'upper_level': upperLevel, + 'lower_level': lowerLevel, + 'language': language, + 'text_summary': textSummary, + }; + } + + factory ConstructSummary.fromJson(Map json) { + return ConstructSummary( + upperLevel: json['upper_level'], + lowerLevel: json['lower_level'], + language: json['language'], + textSummary: json['text_summary'], + ); + } +} + +class ConstructSummaryRequest { + final List constructs; + final List? constructUseMessageContentBodies; + final String language; + final int upperLevel; + final int lowerLevel; + + ConstructSummaryRequest({ + required this.constructs, + this.constructUseMessageContentBodies, + required this.language, + required this.upperLevel, + required this.lowerLevel, + }); + + Map toJson() { + return { + 'constructs': constructs.map((construct) => construct.toJson()).toList(), + 'construct_use_message_content_bodies': constructUseMessageContentBodies, + 'language': language, + 'upper_level': upperLevel, + 'lower_level': lowerLevel, + }; + } + + factory ConstructSummaryRequest.fromJson(Map json) { + return ConstructSummaryRequest( + constructs: (json['constructs'] as List) + .map((construct) => OneConstructUse.fromJson(construct)) + .toList(), + constructUseMessageContentBodies: + List.from(json['construct_use_message_content_bodies']), + language: json['language'], + upperLevel: json['upper_level'], + lowerLevel: json['lower_level'], + ); + } +} + +class ConstructSummaryResponse { + final ConstructSummary summary; + + ConstructSummaryResponse({ + required this.summary, + }); + + Map toJson() { + return { + 'summary': summary.toJson(), + }; + } + + factory ConstructSummaryResponse.fromJson(Map json) { + return ConstructSummaryResponse( + summary: ConstructSummary.fromJson(json['summary']), + ); + } +} + +class GenerateConstructSummaryResult { + final String stateEventId; + final ConstructSummary summary; + + GenerateConstructSummaryResult({ + required this.stateEventId, + required this.summary, + }); +} + +class ConstructRepo { + static Future generateConstructSummary( + ConstructSummaryRequest request, + ) async { + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + final Response res = + await req.post(url: PApiUrls.constructSummary, body: request.toJson()); + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ConstructSummaryResponse.fromJson(decodedBody); + return response; + } +} diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index e2840835a..3e0030d0e 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/constants/pangea_event_types.dart @@ -8,6 +8,7 @@ class PangeaEventTypes { // static const studentAnalyticsSummary = "pangea.usranalytics"; static const summaryAnalytics = "pangea.summaryAnalytics"; static const construct = "pangea.construct"; + static const constructSummary = "pangea.construct_summary"; static const userChosenEmoji = "p.emoji"; static const translation = "pangea.translation";