From 8a8ca1026a04427dd507d304628a7274471043b5 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:54:27 -0500 Subject: [PATCH] Vocab practice updates (#5180) * reorganization, reload on language change * make choice card widget * make completed activity view stateless * use analytics updater mixin to display points gained animation * simplify animation in game card * better encapsulate practice session data * reset session loader instead of dispose * simplify practice session model * queue activities * visually remove duplicate answers without editing activity content * review updates * don't shuffle filtered choices --- .../client_analytics_extension.dart | 36 + .../analytics_misc/example_message_util.dart | 80 +++ lib/pangea/common/utils/async_state.dart | 8 +- .../constructs/construct_identifier.dart | 14 + lib/pangea/lemmas/lemma_info_repo.dart | 4 - .../choice_cards/audio_choice_card.dart | 4 +- .../choice_cards/game_choice_card.dart | 206 +++--- .../choice_cards/meaning_choice_card.dart | 4 +- .../completed_activity_session_view.dart | 371 ++++------ .../vocab_practice_constants.dart | 4 + .../vocab_practice/vocab_practice_page.dart | 677 ++++++++---------- .../vocab_practice_session_model.dart | 323 ++++----- .../vocab_practice_session_repo.dart | 52 +- .../vocab_practice/vocab_practice_view.dart | 363 +++++----- 14 files changed, 1046 insertions(+), 1100 deletions(-) create mode 100644 lib/pangea/analytics_misc/example_message_util.dart create mode 100644 lib/pangea/vocab_practice/vocab_practice_constants.dart diff --git a/lib/pangea/analytics_misc/client_analytics_extension.dart b/lib/pangea/analytics_misc/client_analytics_extension.dart index 05bc2a25a..ba2be21d1 100644 --- a/lib/pangea/analytics_misc/client_analytics_extension.dart +++ b/lib/pangea/analytics_misc/client_analytics_extension.dart @@ -6,10 +6,12 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -177,4 +179,38 @@ extension AnalyticsClientExtension on Client { ) .isNotEmpty; } + + Future getEventByConstructUse( + OneConstructUse use, + ) async { + if (use.metadata.eventId == null || use.metadata.roomId == null) { + return null; + } + + final room = getRoomById(use.metadata.roomId!); + if (room == null) return null; + + try { + final event = await room.getEventById(use.metadata.eventId!); + if (event == null) return null; + + final timeline = await room.getTimeline(); + return PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == userID, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": use.metadata.roomId, + "eventID": use.metadata.eventId, + "userID": userID, + }, + ); + return null; + } + } } diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart new file mode 100644 index 000000000..8fec79f8a --- /dev/null +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; + +class ExampleMessageUtil { + static Future?> getExampleMessage( + ConstructUses construct, + Client client, + ) async { + for (final use in construct.cappedUses) { + final event = await client.getEventByConstructUse(use); + if (event == null) continue; + + final spans = _buildExampleMessage(use.form, event); + if (spans != null) return spans; + } + + return null; + } + + static Future>> getExampleMessages( + ConstructUses construct, + Client client, + int maxMessages, + ) async { + final List> allSpans = []; + for (final use in construct.cappedUses) { + if (allSpans.length >= maxMessages) break; + final event = await client.getEventByConstructUse(use); + if (event == null) continue; + + final spans = _buildExampleMessage(use.form, event); + if (spans != null) { + allSpans.add(spans); + } + } + return allSpans; + } + + static List? _buildExampleMessage( + String? form, + PangeaMessageEvent messageEvent, + ) { + final tokens = messageEvent.messageDisplayRepresentation?.tokens; + if (tokens == null || tokens.isEmpty) return null; + final token = tokens.firstWhereOrNull( + (token) => token.text.content == form, + ); + if (token == null) return null; + + final text = messageEvent.messageDisplayText; + final tokenText = token.text.content; + int tokenIndex = text.indexOf(tokenText); + if (tokenIndex == -1) return null; + + final beforeSubstring = text.substring(0, tokenIndex); + if (beforeSubstring.length != beforeSubstring.characters.length) { + tokenIndex = beforeSubstring.characters.length; + } + + final int tokenLength = tokenText.characters.length; + final before = text.characters.take(tokenIndex).toString(); + final after = text.characters.skip(tokenIndex + tokenLength).toString(); + return [ + TextSpan(text: before), + TextSpan( + text: tokenText, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: after), + ]; + } +} diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart index a2f15adbe..908f9e021 100644 --- a/lib/pangea/common/utils/async_state.dart +++ b/lib/pangea/common/utils/async_state.dart @@ -72,7 +72,7 @@ abstract class AsyncLoader { T? get value => isLoaded ? (state.value as AsyncLoaded).value : null; - final Completer completer = Completer(); + Completer completer = Completer(); void dispose() { _disposed = true; @@ -109,4 +109,10 @@ abstract class AsyncLoader { } } } + + void reset() { + if (_disposed) return; + state.value = AsyncState.idle(); + completer = Completer(); + } } diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index ffc7136bb..63943c074 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -11,7 +11,10 @@ import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/languages/language_constants.dart'; +import 'package:fluffychat/pangea/lemmas/lemma.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; @@ -190,4 +193,15 @@ class ConstructIdentifier { category: category, ); } + + PangeaToken get asToken => PangeaToken( + lemma: Lemma( + text: lemma, + saveVocab: true, + form: lemma, + ), + pos: category, + text: PangeaTokenText.fromString(lemma), + morph: {}, + ); } diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 00b95922a..0ed7b408b 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -89,10 +89,6 @@ class LemmaInfoRepo { _cache.remove(key); } - static void clearAllCache() { - _cache.clear(); - } - static Future> _safeFetch( String token, LemmaInfoRequest request, diff --git a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart index e80359415..dce47c6ee 100644 --- a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart +++ b/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart @@ -9,6 +9,7 @@ import 'package:fluffychat/widgets/matrix.dart'; /// TODO: needs a better design and button handling class AudioChoiceCard extends StatelessWidget { final String text; + final String targetId; final VoidCallback onPressed; final bool isCorrect; final double height; @@ -16,6 +17,7 @@ class AudioChoiceCard extends StatelessWidget { const AudioChoiceCard({ required this.text, + required this.targetId, required this.onPressed, required this.isCorrect, this.height = 72.0, @@ -27,7 +29,7 @@ class AudioChoiceCard extends StatelessWidget { Widget build(BuildContext context) { return GameChoiceCard( shouldFlip: false, - transformId: text, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: height, diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart index 5d08a4105..c4bed1767 100644 --- a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart +++ b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// A unified choice card that handles flipping, color tinting, hovering, and alt widgets @@ -11,17 +12,17 @@ class GameChoiceCard extends StatefulWidget { final bool isCorrect; final double height; final bool shouldFlip; - final String? transformId; + final String targetId; final bool isEnabled; const GameChoiceCard({ required this.child, - this.altChild, required this.onPressed, required this.isCorrect, + required this.targetId, + this.altChild, this.height = 72.0, this.shouldFlip = false, - this.transformId, this.isEnabled = true, super.key, }); @@ -32,49 +33,30 @@ class GameChoiceCard extends StatefulWidget { class _GameChoiceCardState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnim; - bool _flipped = false; - bool _isHovered = false; - bool _useAltChild = false; + late final AnimationController _controller; + late final Animation _scaleAnim; + bool _clicked = false; + bool _revealed = false; @override void initState() { super.initState(); - if (widget.shouldFlip) { - _controller = AnimationController( - duration: const Duration(milliseconds: 220), - vsync: this, - ); + _controller = AnimationController( + duration: const Duration(milliseconds: 220), + vsync: this, + ); - _scaleAnim = Tween(begin: 1.0, end: 0.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - - _controller.addListener(_onAnimationUpdate); - } - } - - void _onAnimationUpdate() { - // Swap to altChild when card is almost fully shrunk - if (_controller.value >= 0.95 && !_useAltChild && widget.altChild != null) { - setState(() => _useAltChild = true); - } - - // Mark as flipped when card is fully shrunk - if (_controller.value >= 0.95 && !_flipped) { - setState(() => _flipped = true); - } + _scaleAnim = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ).drive(Tween(begin: 1.0, end: 0.0)); } @override void dispose() { - if (widget.shouldFlip) { - _controller.removeListener(_onAnimationUpdate); - _controller.dispose(); - } + _controller.dispose(); super.dispose(); } @@ -82,9 +64,10 @@ class _GameChoiceCardState extends State if (!widget.isEnabled) return; if (widget.shouldFlip) { - if (_flipped) return; - // Animate forward (shrink), then reverse (expand) + if (_controller.isAnimating || _revealed) return; + await _controller.forward(); + setState(() => _revealed = true); await _controller.reverse(); } else { if (_clicked) return; @@ -96,91 +79,90 @@ class _GameChoiceCardState extends State @override Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; + final colorScheme = Theme.of(context).colorScheme; - final Color baseColor = colorScheme.surfaceContainerHighest; - final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08); - final Color tintColor = widget.isCorrect + final baseColor = colorScheme.surfaceContainerHighest; + final hoverColor = colorScheme.onSurface.withValues(alpha: 0.08); + final tintColor = widget.isCorrect ? AppConfig.success.withValues(alpha: 0.3) : AppConfig.error.withValues(alpha: 0.3); - Widget card = MouseRegion( - onEnter: - widget.isEnabled ? ((_) => setState(() => _isHovered = true)) : null, - onExit: - widget.isEnabled ? ((_) => setState(() => _isHovered = false)) : null, - child: SizedBox( - width: double.infinity, - height: widget.height, - child: GestureDetector( - onTap: _handleTap, - child: widget.shouldFlip - ? AnimatedBuilder( - animation: _scaleAnim, - builder: (context, child) { - final bool showContent = _scaleAnim.value > 0.1; - return Transform.scale( - scaleY: _scaleAnim.value, - child: Container( - decoration: BoxDecoration( - color: baseColor, - borderRadius: BorderRadius.circular(16), - ), - foregroundDecoration: BoxDecoration( - color: _flipped + return CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId).link, + child: HoverBuilder( + builder: (context, hovered) => SizedBox( + width: double.infinity, + height: widget.height, + child: GestureDetector( + onTap: _handleTap, + child: widget.shouldFlip + ? AnimatedBuilder( + animation: _scaleAnim, + builder: (context, _) { + final scale = _scaleAnim.value; + final showAlt = scale < 0.1 && widget.altChild != null; + final showContent = scale > 0.05; + + return Transform.scale( + scaleY: scale, + child: _CardContainer( + height: widget.height, + baseColor: baseColor, + overlayColor: _revealed ? tintColor - : (_isHovered ? hoverColor : Colors.transparent), - borderRadius: BorderRadius.circular(16), + : (hovered ? hoverColor : Colors.transparent), + child: Opacity( + opacity: showContent ? 1 : 0, + child: showAlt ? widget.altChild! : widget.child, + ), ), - margin: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 0, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - height: widget.height, - alignment: Alignment.center, - child: Opacity( - opacity: showContent ? 1.0 : 0.0, - child: _useAltChild && widget.altChild != null - ? widget.altChild! - : widget.child, - ), - ), - ); - }, - ) - : Container( - decoration: BoxDecoration( - color: baseColor, - borderRadius: BorderRadius.circular(16), - ), - foregroundDecoration: BoxDecoration( - color: _clicked + ); + }, + ) + : _CardContainer( + height: widget.height, + baseColor: baseColor, + overlayColor: _clicked ? tintColor - : (_isHovered ? hoverColor : Colors.transparent), - borderRadius: BorderRadius.circular(16), + : (hovered ? hoverColor : Colors.transparent), + child: widget.child, ), - margin: - const EdgeInsets.symmetric(vertical: 6, horizontal: 0), - padding: const EdgeInsets.symmetric(horizontal: 16), - height: widget.height, - alignment: Alignment.center, - child: widget.child, - ), + ), ), ), ); - - // Wrap with transform target if transformId is provided - if (widget.transformId != null) { - final transformTargetId = - 'vocab-choice-card-${widget.transformId!.replaceAll(' ', '_')}'; - card = CompositedTransformTarget( - link: MatrixState.pAnyState.layerLinkAndKey(transformTargetId).link, - child: card, - ); - } - - return card; + } +} + +class _CardContainer extends StatelessWidget { + final double height; + final Color baseColor; + final Color overlayColor; + final Widget child; + + const _CardContainer({ + required this.height, + required this.baseColor, + required this.overlayColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.center, + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(16), + ), + foregroundDecoration: BoxDecoration( + color: overlayColor, + borderRadius: BorderRadius.circular(16), + ), + child: child, + ); } } diff --git a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart index 5faeb0398..d4d004074 100644 --- a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart +++ b/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.d /// Choice card for meaning activity with emoji, and alt text on flip class MeaningChoiceCard extends StatelessWidget { final String choiceId; + final String targetId; final String displayText; final String? emoji; final VoidCallback onPressed; @@ -15,6 +16,7 @@ class MeaningChoiceCard extends StatelessWidget { const MeaningChoiceCard({ required this.choiceId, + required this.targetId, required this.displayText, this.emoji, required this.onPressed, @@ -33,7 +35,7 @@ class MeaningChoiceCard extends StatelessWidget { return GameChoiceCard( shouldFlip: true, - transformId: choiceId, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: height, diff --git a/lib/pangea/vocab_practice/completed_activity_session_view.dart b/lib/pangea/vocab_practice/completed_activity_session_view.dart index 177b6b6eb..5b9761f1f 100644 --- a/lib/pangea/vocab_practice/completed_activity_session_view.dart +++ b/lib/pangea/vocab_practice/completed_activity_session_view.dart @@ -6,59 +6,20 @@ import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart' import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart'; import 'package:fluffychat/pangea/vocab_practice/stat_card.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; -class CompletedActivitySessionView extends StatefulWidget { +class CompletedActivitySessionView extends StatelessWidget { + final VocabPracticeSessionModel session; final VocabPracticeState controller; - const CompletedActivitySessionView(this.controller, {super.key}); - - @override - State createState() => - _CompletedActivitySessionViewState(); -} - -class _CompletedActivitySessionViewState - extends State { - late final Future> progressChangeFuture; - double currentProgress = 0.0; - Uri? avatarUrl; - bool shouldShowRain = false; - - @override - void initState() { - super.initState(); - - // Fetch avatar URL - final client = Matrix.of(context).client; - client.fetchOwnProfile().then((profile) { - if (mounted) { - setState(() => avatarUrl = profile.avatarUrl); - } - }); - - progressChangeFuture = widget.controller.calculateProgressChange( - widget.controller.sessionLoader.value!.totalXpGained, - ); - } - - void _onProgressChangeLoaded(Map progressChange) { - //start with before progress - currentProgress = progressChange['before'] ?? 0.0; - - //switch to after progress after first frame, to activate animation - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - currentProgress = progressChange['after'] ?? 0.0; - // Start the star rain - shouldShowRain = true; - }); - } - }); - } + const CompletedActivitySessionView( + this.session, + this.controller, { + super.key, + }); String _formatTime(int seconds) { final minutes = seconds ~/ 60; @@ -70,204 +31,174 @@ class _CompletedActivitySessionViewState Widget build(BuildContext context) { final username = Matrix.of(context).client.userID?.split(':').first.substring(1) ?? ''; - final bool accuracyAchievement = - widget.controller.sessionLoader.value!.accuracy == 100; - final bool timeAchievement = - widget.controller.sessionLoader.value!.elapsedSeconds <= 60; - final int numBonusPoints = widget - .controller.sessionLoader.value!.completedUses - .where((use) => use.xp > 0) - .length; - //give double bonus for both, single for one, none for zero - final int bonusXp = (accuracyAchievement && timeAchievement) - ? numBonusPoints * 2 - : (accuracyAchievement || timeAchievement) - ? numBonusPoints - : 0; - return FutureBuilder>( - future: progressChangeFuture, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox.shrink(); - } + final double accuracy = session.state.accuracy; + final int elapsedSeconds = session.state.elapsedSeconds; - // Initialize progress when data is available - if (currentProgress == 0.0 && !shouldShowRain) { - _onProgressChangeLoaded(snapshot.data!); - } + final bool accuracyAchievement = accuracy == 100; + final bool timeAchievement = elapsedSeconds <= 60; - return Stack( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16), - child: Column( - children: [ - Text( - L10n.of(context).congratulationsYouveCompletedPractice, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16), + child: Column( + children: [ + Text( + L10n.of(context).congratulationsYouveCompletedPractice, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: FutureBuilder( + future: Matrix.of(context).client.fetchOwnProfile(), + builder: (context, snapshot) { + final avatarUrl = snapshot.data?.avatarUrl; + return Avatar( + name: username, + showPresence: false, + size: 100, + mxContent: avatarUrl, + userId: Matrix.of(context).client.userID, + ); + }, + ), + ), + Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), - child: avatarUrl == null - ? Avatar( - name: username, - showPresence: false, - size: 100, - ) - : ClipOval( - child: MxcImage( - uri: avatarUrl, - width: 100, - height: 100, - ), - ), - ), - Column( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 16.0, - bottom: 16.0, - ), - child: AnimatedProgressBar( - height: 20.0, - widthPercent: currentProgress, - backgroundColor: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - duration: const Duration(milliseconds: 500), - ), + padding: const EdgeInsets.only( + top: 16.0, + bottom: 16.0, + ), + child: FutureBuilder( + future: controller.derivedAnalyticsData, + builder: (context, snapshot) => AnimatedProgressBar( + height: 20.0, + widthPercent: snapshot.hasData + ? snapshot.data!.levelProgress + : 0.0, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + duration: const Duration(milliseconds: 500), ), - Text( - "+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP", - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( + ), + ), + Text( + "+ ${session.state.allXPGained} XP", + style: + Theme.of(context).textTheme.titleLarge?.copyWith( color: AppConfig.goldLight, fontWeight: FontWeight.bold, ), - ), - ], - ), - StatCard( - icon: Icons.my_location, - text: - "${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%", - isAchievement: accuracyAchievement, - achievementText: "+ $numBonusPoints XP", - child: PercentMarkerBar( - height: 20.0, - widthPercent: widget - .controller.sessionLoader.value!.accuracy / - 100.0, - markerWidth: 20.0, - markerColor: AppConfig.success, - backgroundColor: !(widget.controller.sessionLoader - .value!.accuracy == - 100) - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - : Color.alphaBlend( - AppConfig.goldLight.withValues(alpha: 0.3), - Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), - ), - ), - StatCard( - icon: Icons.alarm, - text: - "${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}", - isAchievement: timeAchievement, - achievementText: "+ $numBonusPoints XP", - child: TimeStarsWidget( - elapsedSeconds: widget - .controller.sessionLoader.value!.elapsedSeconds, - timeForBonus: widget - .controller.sessionLoader.value!.timeForBonus, - ), - ), - Column( - children: [ - //expanded row button - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - ), - onPressed: () => - widget.controller.reloadSession(), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - L10n.of(context).anotherRound, - ), - ], - ), - ), - const SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - ), - onPressed: () => Navigator.of(context).pop(), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - L10n.of(context).quit, - ), - ], - ), - ), - ], ), ], ), - ), - ], + StatCard( + icon: Icons.my_location, + text: "${L10n.of(context).accuracy}: $accuracy%", + isAchievement: accuracyAchievement, + achievementText: "+ ${session.state.accuracyBonusXP} XP", + child: PercentMarkerBar( + height: 20.0, + widthPercent: accuracy / 100.0, + markerWidth: 20.0, + markerColor: AppConfig.success, + backgroundColor: !accuracyAchievement + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + : Color.alphaBlend( + AppConfig.goldLight.withValues(alpha: 0.3), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + ), + StatCard( + icon: Icons.alarm, + text: + "${L10n.of(context).time}: ${_formatTime(elapsedSeconds)}", + isAchievement: timeAchievement, + achievementText: "+ ${session.state.timeBonusXP} XP", + child: TimeStarsWidget( + elapsedSeconds: elapsedSeconds, + ), + ), + Column( + children: [ + //expanded row button + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () => controller.reloadSession(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).anotherRound, + ), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () => Navigator.of(context).pop(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).quit, + ), + ], + ), + ), + ], + ), + ], + ), ), - ), - if (shouldShowRain) - const StarRainWidget( - showBlast: true, - rainDuration: Duration(seconds: 5), - ), - ], - ); - }, + ], + ), + ), + const StarRainWidget( + showBlast: true, + rainDuration: Duration(seconds: 5), + ), + ], ); } } class TimeStarsWidget extends StatelessWidget { final int elapsedSeconds; - final int timeForBonus; const TimeStarsWidget({ required this.elapsedSeconds, - required this.timeForBonus, super.key, }); int get starCount { + const timeForBonus = VocabPracticeConstants.timeForBonus; if (elapsedSeconds <= timeForBonus) return 5; if (elapsedSeconds <= timeForBonus * 1.5) return 4; if (elapsedSeconds <= timeForBonus * 2) return 3; diff --git a/lib/pangea/vocab_practice/vocab_practice_constants.dart b/lib/pangea/vocab_practice/vocab_practice_constants.dart new file mode 100644 index 000000000..70bcaf6bc --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_practice_constants.dart @@ -0,0 +1,4 @@ +class VocabPracticeConstants { + static const int timeForBonus = 60; + static const int practiceGroupSize = 10; +} diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/vocab_practice/vocab_practice_page.dart index f05026c46..fe379e494 100644 --- a/lib/pangea/vocab_practice/vocab_practice_page.dart +++ b/lib/pangea/vocab_practice/vocab_practice_page.dart @@ -1,30 +1,48 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; -import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; +class VocabPracticeChoice { + final String choiceId; + final String choiceText; + final String? choiceEmoji; + + const VocabPracticeChoice({ + required this.choiceId, + required this.choiceText, + this.choiceEmoji, + }); +} + class SessionLoader extends AsyncLoader { @override - Future fetch() => - VocabPracticeSessionRepo.currentSession; + Future fetch() => VocabPracticeSessionRepo.get(); } class VocabPractice extends StatefulWidget { @@ -34,445 +52,370 @@ class VocabPractice extends StatefulWidget { VocabPracticeState createState() => VocabPracticeState(); } -class VocabPracticeState extends State { - SessionLoader sessionLoader = SessionLoader(); - PracticeActivityModel? currentActivity; - bool isLoadingActivity = true; - bool isAwaitingNextActivity = false; - String? activityError; +class VocabPracticeState extends State with AnalyticsUpdater { + final SessionLoader _sessionLoader = SessionLoader(); - bool isLoadingLemmaInfo = false; - final Map _choiceTexts = {}; - final Map _choiceEmojis = {}; + final ValueNotifier> activityState = + ValueNotifier(const AsyncState.idle()); + + final Queue>> + _queue = Queue(); + + final ValueNotifier activityConstructId = + ValueNotifier(null); + + final ValueNotifier progressNotifier = ValueNotifier(0.0); + + final Map> _choiceTexts = {}; + final Map> _choiceEmojis = {}; StreamSubscription? _languageStreamSubscription; - bool _sessionClearedDueToLanguageChange = false; @override void initState() { super.initState(); _startSession(); - _listenToLanguageChanges(); + _languageStreamSubscription = MatrixState + .pangeaController.userController.languageStream.stream + .listen((_) => _onLanguageUpdate()); } @override void dispose() { _languageStreamSubscription?.cancel(); - if (isComplete) { - VocabPracticeSessionRepo.clearSession(); - } else if (!_sessionClearedDueToLanguageChange) { - //don't save if session was cleared due to language change - _saveCurrentTime(); + if (_isComplete) { + VocabPracticeSessionRepo.clear(); + } else { + _saveSession(); } - sessionLoader.dispose(); + _sessionLoader.dispose(); + activityState.dispose(); + activityConstructId.dispose(); + progressNotifier.dispose(); super.dispose(); } - void _saveCurrentTime() { - if (sessionLoader.isLoaded) { - VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + PracticeActivityModel? get _currentActivity => + activityState.value is AsyncLoaded + ? (activityState.value as AsyncLoaded).value + : null; + + bool get _isComplete => _sessionLoader.value?.isComplete ?? false; + + ValueNotifier> get sessionState => + _sessionLoader.state; + + AnalyticsDataService get _analyticsService => + Matrix.of(context).analyticsDataService; + + List filteredChoices( + PracticeTarget target, + MultipleChoiceActivity activity, + ) { + final choices = activity.choices.toList(); + final answer = activity.answers.first; + final filtered = []; + + final seenTexts = {}; + for (final id in choices) { + final text = getChoiceText(target, id); + + if (seenTexts.contains(text)) { + if (id != answer) { + continue; + } + + final index = filtered.indexWhere( + (choice) => choice.choiceText == text, + ); + if (index != -1) { + filtered[index] = VocabPracticeChoice( + choiceId: id, + choiceText: text, + choiceEmoji: getChoiceEmoji(target, id), + ); + } + continue; + } + + seenTexts.add(text); + filtered.add( + VocabPracticeChoice( + choiceId: id, + choiceText: text, + choiceEmoji: getChoiceEmoji(target, id), + ), + ); + } + + return filtered; + } + + String getChoiceText(PracticeTarget target, String choiceId) { + if (_choiceTexts.containsKey(target) && + _choiceTexts[target]!.containsKey(choiceId)) { + return _choiceTexts[target]![choiceId]!; + } + final cId = ConstructIdentifier.fromString(choiceId); + return cId?.lemma ?? choiceId; + } + + String? getChoiceEmoji(PracticeTarget target, String choiceId) => + _choiceEmojis[target]?[choiceId]; + + String choiceTargetId(String choiceId) => + 'vocab-choice-card-${choiceId.replaceAll(' ', '_')}'; + + void _resetActivityState() { + activityState.value = const AsyncState.loading(); + activityConstructId.value = null; + } + + void _resetSessionState() { + progressNotifier.value = 0.0; + _queue.clear(); + _choiceTexts.clear(); + _choiceEmojis.clear(); + activityState.value = const AsyncState.idle(); + } + + void updateElapsedTime(int seconds) { + if (_sessionLoader.isLoaded) { + _sessionLoader.value!.setElapsedSeconds(seconds); } } - /// Resets all session state without disposing the widget - void _resetState() { - currentActivity = null; - isLoadingActivity = true; - isAwaitingNextActivity = false; - activityError = null; - isLoadingLemmaInfo = false; - _choiceTexts.clear(); - _choiceEmojis.clear(); - } - - bool get isComplete => - sessionLoader.isLoaded && sessionLoader.value!.hasCompletedCurrentGroup; - - double get progress => - sessionLoader.isLoaded ? sessionLoader.value!.progress : 0.0; - - int get availableActivities => sessionLoader.isLoaded - ? sessionLoader.value!.currentAvailableActivities - : 0; - - int get completedActivities => - sessionLoader.isLoaded ? sessionLoader.value!.currentIndex : 0; - - int get elapsedSeconds => - sessionLoader.isLoaded ? sessionLoader.value!.elapsedSeconds : 0; - - void updateElapsedTime(int seconds) { - if (sessionLoader.isLoaded) { - sessionLoader.value!.elapsedSeconds = seconds; + Future _saveSession() async { + if (_sessionLoader.isLoaded) { + await VocabPracticeSessionRepo.update(_sessionLoader.value!); } } Future _waitForAnalytics() async { - if (!MatrixState.pangeaController.matrixState.analyticsDataService - .initCompleter.isCompleted) { + if (!_analyticsService.initCompleter.isCompleted) { MatrixState.pangeaController.initControllers(); - await MatrixState.pangeaController.matrixState.analyticsDataService - .initCompleter.future; + await _analyticsService.initCompleter.future; } } - void _listenToLanguageChanges() { - _languageStreamSubscription = MatrixState - .pangeaController.userController.languageStream.stream - .listen((_) async { - // If language changed, clear session and back out of vocab practice - if (await _shouldReloadSession()) { - _sessionClearedDueToLanguageChange = true; - await VocabPracticeSessionRepo.clearSession(); - if (mounted) { - Navigator.of(context).pop(); - } + Future _onLanguageUpdate() async { + try { + _resetActivityState(); + _resetSessionState(); + await _analyticsService + .updateDispatcher.constructUpdateStream.stream.first + .timeout(const Duration(seconds: 10)); + await reloadSession(); + } catch (e) { + if (mounted) { + activityState.value = AsyncState.error( + L10n.of(context).oopsSomethingWentWrong, + ); } - }); + } } Future _startSession() async { await _waitForAnalytics(); - await sessionLoader.load(); - - // If user languages have changed since last session, clear session - if (await _shouldReloadSession()) { - await VocabPracticeSessionRepo.clearSession(); - sessionLoader.dispose(); - sessionLoader = SessionLoader(); - await sessionLoader.load(); - } - - loadActivity(); - } - - // check if current l1 and l2 have changed from those of the loaded session - Future _shouldReloadSession() async { - if (!sessionLoader.isLoaded) return false; - - final session = sessionLoader.value!; - final currentL1 = - MatrixState.pangeaController.userController.userL1?.langCode; - final currentL2 = - MatrixState.pangeaController.userController.userL2?.langCode; - - if (session.userL1 != currentL1 || session.userL2 != currentL2) { - return true; - } - return false; - } - - Future completeActivitySession() async { - if (!sessionLoader.isLoaded) return; - - _saveCurrentTime(); - sessionLoader.value!.finishSession(); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); - - setState(() {}); + await _sessionLoader.load(); + progressNotifier.value = _sessionLoader.value!.progress; + await _continueSession(); } Future reloadSession() async { - await showFutureLoadingDialog( - context: context, - future: () async { - // Clear current session storage, dispose old session loader, and clear state variables - await VocabPracticeSessionRepo.clearSession(); - sessionLoader.dispose(); - sessionLoader = SessionLoader(); - _resetState(); - await _startSession(); - }, - ); + _resetActivityState(); + _resetSessionState(); + await VocabPracticeSessionRepo.clear(); + _sessionLoader.reset(); + await _startSession(); + } - if (mounted) { - setState(() {}); + Future _completeSession() async { + _sessionLoader.value!.finishSession(); + setState(() {}); + + final bonus = _sessionLoader.value!.state.allBonusUses; + await _analyticsService.updateService.addAnalytics(null, bonus); + await _saveSession(); + } + + bool _continuing = false; + + Future _continueSession() async { + if (_continuing) return; + _continuing = true; + + try { + if (activityState.value is AsyncIdle) { + await _initActivityData(); + } else if (_queue.isEmpty) { + await _completeSession(); + } else { + activityState.value = const AsyncState.loading(); + final nextActivityCompleter = _queue.removeFirst(); + activityConstructId.value = nextActivityCompleter.key; + final activity = await nextActivityCompleter.value.future; + activityState.value = AsyncState.loaded(activity); + } + } catch (e) { + activityState.value = AsyncState.error(e); + } finally { + _continuing = false; } } - Future?> getExampleMessage( - ConstructIdentifier construct, - ) async { - final ConstructUses constructUse = await Matrix.of(context) - .analyticsDataService - .getConstructUse(construct); - for (final use in constructUse.cappedUses) { - if (use.metadata.eventId == null || use.metadata.roomId == null) { - continue; - } - - final room = MatrixState.pangeaController.matrixState.client - .getRoomById(use.metadata.roomId!); - if (room == null) continue; - - final event = await room.getEventById(use.metadata.eventId!); - if (event == null) continue; - - final timeline = await room.getTimeline(); - final pangeaMessageEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: event.senderId == - MatrixState.pangeaController.matrixState.client.userID, - ); - - final tokens = pangeaMessageEvent.messageDisplayRepresentation?.tokens; - if (tokens == null || tokens.isEmpty) continue; - final token = tokens.firstWhereOrNull( - (token) => token.text.content == use.form, - ); - if (token == null) continue; - - final text = pangeaMessageEvent.messageDisplayText; - final tokenText = token.text.content; - int tokenIndex = text.indexOf(tokenText); - if (tokenIndex == -1) continue; - - final beforeSubstring = text.substring(0, tokenIndex); - if (beforeSubstring.length != beforeSubstring.characters.length) { - tokenIndex = beforeSubstring.characters.length; - } - - final int tokenLength = tokenText.characters.length; - final before = text.characters.take(tokenIndex).toString(); - final after = text.characters.skip(tokenIndex + tokenLength).toString(); - return [ - TextSpan(text: before), - TextSpan( - text: tokenText, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: after), - ]; + Future _initActivityData() async { + final requests = _sessionLoader.value!.activityRequests; + if (requests.isEmpty) { + throw L10n.of(context).noActivityRequest; } - return null; - } + try { + activityState.value = const AsyncState.loading(); - Future loadActivity() async { - if (!sessionLoader.isLoaded) { - try { - await sessionLoader.completer.future; - } catch (_) { - return; - } - } + final req = requests.first; + final res = await _fetchActivity(req); + if (!mounted) return; - if (!mounted) return; - - setState(() { - isAwaitingNextActivity = false; - currentActivity = null; - isLoadingActivity = true; - activityError = null; - _choiceTexts.clear(); - _choiceEmojis.clear(); - }); - - final session = sessionLoader.value!; - final activityRequest = session.currentActivityRequest; - if (activityRequest == null) { - setState(() { - activityError = L10n.of(context).noActivityRequest; - isLoadingActivity = false; - }); + activityConstructId.value = req.targetTokens.first.vocabConstructID; + activityState.value = AsyncState.loaded(res); + } catch (e) { + if (!mounted) return; + activityState.value = AsyncState.error(e); return; } + _fillActivityQueue(requests.skip(1).toList()); + } + + Future _fillActivityQueue(List requests) async { + for (final request in requests) { + final completer = Completer(); + _queue.add( + MapEntry( + request.targetTokens.first.vocabConstructID, + completer, + ), + ); + try { + final res = await _fetchActivity(request); + if (!mounted) return; + completer.complete(res); + } catch (e) { + if (!mounted) return; + completer.completeError(e); + break; + } + } + } + + Future _fetchActivity( + MessageActivityRequest req, + ) async { final result = await PracticeRepo.getPracticeActivity( - activityRequest, + req, messageInfo: {}, ); if (result.isError) { - activityError = L10n.of(context).oopsSomethingWentWrong; - } else { - currentActivity = result.result!; + throw L10n.of(context).oopsSomethingWentWrong; } // Prefetch lemma info for meaning activities before marking ready - if (currentActivity != null && - currentActivity!.activityType == ActivityTypeEnum.lemmaMeaning) { - final choices = currentActivity!.multipleChoiceContent!.choices.toList(); - await _prefetchLemmaInfo(choices); + if (result.result!.activityType == ActivityTypeEnum.lemmaMeaning) { + final choices = result.result!.multipleChoiceContent!.choices.toList(); + await _fetchLemmaInfo(result.result!.practiceTarget, choices); } - if (mounted) { - setState(() => isLoadingActivity = false); + return result.result!; + } + + Future _fetchLemmaInfo( + PracticeTarget target, + List choiceIds, + ) async { + final texts = {}; + final emojis = {}; + + for (final id in choiceIds) { + final cId = ConstructIdentifier.fromString(id); + if (cId == null) continue; + + final res = await cId.getLemmaInfo({}); + if (res.isError) { + LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({})); + throw L10n.of(context).oopsSomethingWentWrong; + } + + texts[id] = res.result!.meaning; + emojis[id] = res.result!.emoji.firstOrNull; } + + _choiceTexts.putIfAbsent(target, () => {}); + _choiceEmojis.putIfAbsent(target, () => {}); + + _choiceTexts[target]!.addAll(texts); + _choiceEmojis[target]!.addAll(emojis); } Future onSelectChoice( ConstructIdentifier choiceConstruct, String choiceContent, ) async { - if (currentActivity == null) return; - final activity = currentActivity!; + if (_currentActivity == null) return; + final activity = _currentActivity!; + // Update activity record activity.onMultipleChoiceSelect(choiceConstruct, choiceContent); final correct = activity.multipleChoiceContent!.isCorrect(choiceContent); - // Submit answer immediately (records use and gives XP) - sessionLoader.value!.submitAnswer(activity, correct); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + // Update session model and analytics + final useType = correct + ? activity.activityType.correctUse + : activity.activityType.incorrectUse; - final transformTargetId = - 'vocab-choice-card-${choiceContent.replaceAll(' ', '_')}'; - if (correct) { - OverlayUtil.showPointsGained(transformTargetId, 5, context); - } else { - OverlayUtil.showPointsGained(transformTargetId, -1, context); - } + final use = OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: activity.targetTokens.first.pos, + lemma: activity.targetTokens.first.lemma.text, + form: activity.targetTokens.first.lemma.text, + xp: useType.pointValue, + ); + + _sessionLoader.value!.submitAnswer(use); + await _analyticsService.updateService + .addAnalytics(choiceTargetId(choiceContent), [use]); + + await _saveSession(); if (!correct) return; - // display the fact that the choice was correct before loading the next activity - setState(() => isAwaitingNextActivity = true); + // Display the fact that the choice was correct before loading the next activity await Future.delayed(const Duration(milliseconds: 1000)); - setState(() => isAwaitingNextActivity = false); - // Only move to next activity when answer is correct - sessionLoader.value!.completeActivity(activity); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + // Then mark this activity as completed, and either load the next or complete the session + _sessionLoader.value!.completeActivity(); + progressNotifier.value = _sessionLoader.value!.progress; + await _saveSession(); - if (isComplete) { - await completeActivitySession(); - } - - await loadActivity(); + _isComplete ? await _completeSession() : await _continueSession(); } - Future> calculateProgressChange(int xpGained) async { - final derivedData = await MatrixState - .pangeaController.matrixState.analyticsDataService.derivedData; - final currentLevel = derivedData.level; - final currentXP = derivedData.totalXP; - - final minXPForCurrentLevel = - DerivedAnalyticsDataModel.calculateXpWithLevel(currentLevel); - final minXPForNextLevel = derivedData.minXPForNextLevel; - - final xpRange = minXPForNextLevel - minXPForCurrentLevel; - - final progressBefore = - ((currentXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); - - final newTotalXP = currentXP + xpGained; - final progressAfter = - ((newTotalXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); - - return { - 'before': progressBefore, - 'after': progressAfter, - }; + Future?> getExampleMessage( + ConstructIdentifier construct, + ) async { + return ExampleMessageUtil.getExampleMessage( + await _analyticsService.getConstructUse(construct), + Matrix.of(context).client, + ); } + Future get derivedAnalyticsData => + _analyticsService.derivedData; + @override Widget build(BuildContext context) => VocabPracticeView(this); - - String getChoiceText(String choiceId) { - if (_choiceTexts.containsKey(choiceId)) return _choiceTexts[choiceId]!; - final cId = ConstructIdentifier.fromString(choiceId); - return cId?.lemma ?? choiceId; - } - - String? getChoiceEmoji(String choiceId) => _choiceEmojis[choiceId]; - - //fetches display info for all choices from constructIDs - Future _prefetchLemmaInfo(List choiceIds) async { - if (!mounted) return; - setState(() => isLoadingLemmaInfo = true); - - final results = await Future.wait( - choiceIds.map((id) async { - final cId = ConstructIdentifier.fromString(id); - if (cId == null) { - return null; - } - try { - final result = await cId.getLemmaInfo({}); - return result; - } catch (e) { - return null; - } - }), - ); - - // Check if any result is an error - for (int i = 0; i < results.length; i++) { - final res = results[i]; - if (res != null && res.isError) { - // Clear cache for failed items so retry will fetch fresh - final failedId = choiceIds[i]; - final cId = ConstructIdentifier.fromString(failedId); - if (cId != null) { - LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({})); - } - - if (mounted) { - setState(() { - activityError = L10n.of(context).oopsSomethingWentWrong; - isLoadingLemmaInfo = false; - }); - } - return; - } - // Update choice texts/emojis if successful - if (res != null && !res.isError) { - final id = choiceIds[i]; - final info = res.result!; - _choiceTexts[id] = info.meaning; - _choiceEmojis[id] = _choiceEmojis[id] ?? info.emoji.firstOrNull; - } - } - - // Check for duplicate choice texts and remove duplicates - _removeDuplicateChoices(); - - if (mounted) { - setState(() => isLoadingLemmaInfo = false); - } - } - - /// Removes duplicate choice texts, keeping the correct answer if it's a duplicate, or the first otherwise - void _removeDuplicateChoices() { - if (currentActivity?.multipleChoiceContent == null) return; - - final activity = currentActivity!.multipleChoiceContent!; - final correctAnswers = activity.answers; - - final Map> textToIds = {}; - - for (final id in _choiceTexts.keys) { - final text = _choiceTexts[id]!; - textToIds.putIfAbsent(text, () => []).add(id); - } - - // Find duplicates and remove them - final Set idsToRemove = {}; - for (final entry in textToIds.entries) { - final duplicateIds = entry.value; - if (duplicateIds.length > 1) { - // Find if any of the duplicates is the correct answer - final correctId = duplicateIds.firstWhereOrNull( - (id) => correctAnswers.contains(id), - ); - - // Remove all duplicates except one - if (correctId != null) { - idsToRemove.addAll(duplicateIds.where((id) => id != correctId)); - } else { - idsToRemove.addAll(duplicateIds.skip(1)); - } - } - } - - if (idsToRemove.isNotEmpty) { - activity.choices.removeAll(idsToRemove); - for (final id in idsToRemove) { - _choiceTexts.remove(id); - _choiceEmojis.remove(id); - } - } - } } diff --git a/lib/pangea/vocab_practice/vocab_practice_session_model.dart b/lib/pangea/vocab_practice/vocab_practice_session_model.dart index 0cbe7f270..d0437ebe2 100644 --- a/lib/pangea/vocab_practice/vocab_practice_session_model.dart +++ b/lib/pangea/vocab_practice/vocab_practice_session_model.dart @@ -1,112 +1,108 @@ import 'dart:math'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; -import 'package:fluffychat/pangea/lemmas/lemma.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; class VocabPracticeSessionModel { final DateTime startedAt; - final List sortedConstructIds; - final List activityTypes; + final List practiceTargets; final String userL1; final String userL2; - int currentIndex; - int currentGroup; - - final List completedUses; - bool finished; - int elapsedSeconds; + VocabPracticeSessionState state; VocabPracticeSessionModel({ required this.startedAt, - required this.sortedConstructIds, - required this.activityTypes, + required this.practiceTargets, required this.userL1, required this.userL2, - required this.completedUses, - this.currentIndex = 0, - this.currentGroup = 0, - this.finished = false, - this.elapsedSeconds = 0, + VocabPracticeSessionState? state, }) : assert( - activityTypes.every( + practiceTargets.every( (t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio} - .contains(t), + .contains(t.activityType), ), ), - assert( - activityTypes.length == practiceGroupSize, - ); + state = state ?? const VocabPracticeSessionState(); - static const int practiceGroupSize = 10; - - int get currentAvailableActivities => min( - ((currentGroup + 1) * practiceGroupSize), - sortedConstructIds.length, + int get _availableActivities => min( + VocabPracticeConstants.practiceGroupSize, + practiceTargets.length, ); - bool get hasCompletedCurrentGroup => - currentIndex >= currentAvailableActivities; - - int get timeForBonus => 60; + bool get isComplete => state.currentIndex >= _availableActivities; double get progress => - (currentIndex / currentAvailableActivities).clamp(0.0, 1.0); + (state.currentIndex / _availableActivities).clamp(0.0, 1.0); - List get currentPracticeGroup => sortedConstructIds - .skip(currentGroup * practiceGroupSize) - .take(practiceGroupSize) - .toList(); - - ConstructIdentifier? get currentConstructId { - if (currentIndex < 0 || hasCompletedCurrentGroup) { - return null; - } - return currentPracticeGroup[currentIndex % practiceGroupSize]; + List get activityRequests { + return practiceTargets.map((target) { + return MessageActivityRequest( + userL1: userL1, + userL2: userL2, + activityQualityFeedback: null, + targetTokens: target.tokens, + targetType: target.activityType, + targetMorphFeature: null, + ); + }).toList(); } - ActivityTypeEnum? get currentActivityType { - if (currentIndex < 0 || hasCompletedCurrentGroup) { - return null; - } - return activityTypes[currentIndex % practiceGroupSize]; - } + void setElapsedSeconds(int seconds) => + state = state.copyWith(elapsedSeconds: seconds); - MessageActivityRequest? get currentActivityRequest { - final constructId = currentConstructId; - if (constructId == null || currentActivityType == null) return null; + void finishSession() => state = state.copyWith(finished: true); - final activityType = currentActivityType; - return MessageActivityRequest( - userL1: userL1, - userL2: userL2, - activityQualityFeedback: null, - targetTokens: [ - PangeaToken( - lemma: Lemma( - text: constructId.lemma, - saveVocab: true, - form: constructId.lemma, - ), - pos: constructId.category, - text: PangeaTokenText.fromString(constructId.lemma), - morph: {}, - ), - ], - targetType: activityType!, - targetMorphFeature: null, + void completeActivity() => + state = state.copyWith(currentIndex: state.currentIndex + 1); + + void submitAnswer(OneConstructUse use) => state = state.copyWith( + completedUses: [...state.completedUses, use], + ); + + factory VocabPracticeSessionModel.fromJson(Map json) { + return VocabPracticeSessionModel( + startedAt: DateTime.parse(json['startedAt'] as String), + practiceTargets: (json['practiceTargets'] as List) + .map((e) => PracticeTarget.fromJson(e)) + .whereType() + .toList(), + userL1: json['userL1'] as String, + userL2: json['userL2'] as String, + state: VocabPracticeSessionState.fromJson( + json, + ), ); } + Map toJson() { + return { + 'startedAt': startedAt.toIso8601String(), + 'practiceTargets': practiceTargets.map((e) => e.toJson()).toList(), + 'userL1': userL1, + 'userL2': userL2, + ...state.toJson(), + }; + } +} + +class VocabPracticeSessionState { + final List completedUses; + final int currentIndex; + final bool finished; + final int elapsedSeconds; + + const VocabPracticeSessionState({ + this.completedUses = const [], + this.currentIndex = 0, + this.finished = false, + this.elapsedSeconds = 0, + }); + int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp); double get accuracy { @@ -116,138 +112,75 @@ class VocabPracticeSessionModel { return (result * 100).truncateToDouble(); } - void finishSession() { - finished = true; + bool get _giveAccuracyBonus => accuracy >= 100.0; - // give bonus XP uses for each construct if earned - if (accuracy >= 100) { - final bonusUses = completedUses - .where((use) => use.xp > 0) - .map( - (use) => OneConstructUse( - useType: ConstructUseTypeEnum.bonus, - constructType: use.constructType, - metadata: ConstructUseMetaData( - roomId: use.metadata.roomId, - timeStamp: DateTime.now(), - ), - category: use.category, - lemma: use.lemma, - form: use.form, - xp: ConstructUseTypeEnum.bonus.pointValue, - ), - ) - .toList(); + bool get _giveTimeBonus => + elapsedSeconds <= VocabPracticeConstants.timeForBonus; - MatrixState - .pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - bonusUses, + int get bonusXP => accuracyBonusXP + timeBonusXP; + + int get accuracyBonusXP => _giveAccuracyBonus ? _bonusXP : 0; + + int get timeBonusXP => _giveTimeBonus ? _bonusXP : 0; + + int get _bonusXP => _bonusUses.fold(0, (sum, use) => sum + use.xp); + + int get allXPGained => totalXpGained + bonusXP; + + List get _bonusUses => + completedUses.where((use) => use.xp > 0).map(_bonusUse).toList(); + + List get allBonusUses => [ + if (_giveAccuracyBonus) ..._bonusUses, + if (_giveTimeBonus) ..._bonusUses, + ]; + + OneConstructUse _bonusUse(OneConstructUse originalUse) => OneConstructUse( + useType: ConstructUseTypeEnum.bonus, + constructType: originalUse.constructType, + metadata: ConstructUseMetaData( + roomId: originalUse.metadata.roomId, + timeStamp: DateTime.now(), + ), + category: originalUse.category, + lemma: originalUse.lemma, + form: originalUse.form, + xp: ConstructUseTypeEnum.bonus.pointValue, ); - } - if (elapsedSeconds <= timeForBonus) { - final bonusUses = completedUses - .where((use) => use.xp > 0) - .map( - (use) => OneConstructUse( - useType: ConstructUseTypeEnum.bonus, - constructType: use.constructType, - metadata: ConstructUseMetaData( - roomId: use.metadata.roomId, - timeStamp: DateTime.now(), - ), - category: use.category, - lemma: use.lemma, - form: use.form, - xp: ConstructUseTypeEnum.bonus.pointValue, - ), - ) - .toList(); - - MatrixState - .pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - bonusUses, - ); - } - } - - void submitAnswer(PracticeActivityModel activity, bool isCorrect) { - final useType = isCorrect - ? activity.activityType.correctUse - : activity.activityType.incorrectUse; - - final use = OneConstructUse( - useType: useType, - constructType: ConstructTypeEnum.vocab, - metadata: ConstructUseMetaData( - roomId: null, - timeStamp: DateTime.now(), - ), - category: activity.targetTokens.first.pos, - lemma: activity.targetTokens.first.lemma.text, - form: activity.targetTokens.first.lemma.text, - xp: useType.pointValue, - ); - - completedUses.add(use); - - // Give XP immediately - MatrixState.pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - [use], - ); - } - - void completeActivity(PracticeActivityModel activity) { - currentIndex += 1; - } - - factory VocabPracticeSessionModel.fromJson(Map json) { - return VocabPracticeSessionModel( - startedAt: DateTime.parse(json['startedAt'] as String), - sortedConstructIds: (json['sortedConstructIds'] as List) - .map((e) => ConstructIdentifier.fromJson(e)) - .whereType() - .toList(), - activityTypes: (json['activityTypes'] as List) - .map( - (e) => ActivityTypeEnum.values.firstWhere( - (at) => at.name == (e as String), - ), - ) - .whereType() - .toList(), - userL1: json['userL1'] as String, - userL2: json['userL2'] as String, - currentIndex: json['currentIndex'] as int, - currentGroup: json['currentGroup'] as int, - completedUses: (json['completedUses'] as List?) - ?.map((e) => OneConstructUse.fromJson(e)) - .whereType() - .toList() ?? - [], - finished: json['finished'] as bool? ?? false, - elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, + VocabPracticeSessionState copyWith({ + List? completedUses, + int? currentIndex, + bool? finished, + int? elapsedSeconds, + }) { + return VocabPracticeSessionState( + completedUses: completedUses ?? this.completedUses, + currentIndex: currentIndex ?? this.currentIndex, + finished: finished ?? this.finished, + elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds, ); } Map toJson() { return { - 'startedAt': startedAt.toIso8601String(), - 'sortedConstructIds': sortedConstructIds.map((e) => e.toJson()).toList(), - 'activityTypes': activityTypes.map((e) => e.name).toList(), - 'userL1': userL1, - 'userL2': userL2, - 'currentIndex': currentIndex, - 'currentGroup': currentGroup, 'completedUses': completedUses.map((e) => e.toJson()).toList(), + 'currentIndex': currentIndex, 'finished': finished, 'elapsedSeconds': elapsedSeconds, }; } + + factory VocabPracticeSessionState.fromJson(Map json) { + return VocabPracticeSessionState( + completedUses: (json['completedUses'] as List?) + ?.map((e) => OneConstructUse.fromJson(e)) + .whereType() + .toList() ?? + [], + currentIndex: json['currentIndex'] as int? ?? 0, + finished: json['finished'] as bool? ?? false, + elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, + ); + } } diff --git a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart index 955b65d98..2ce376e37 100644 --- a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart +++ b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart @@ -5,13 +5,15 @@ import 'package:get_storage/get_storage.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; class VocabPracticeSessionRepo { static final GetStorage _storage = GetStorage('vocab_practice_session'); - static Future get currentSession async { + static Future get() async { final cached = _getCached(); if (cached != null) { return cached; @@ -24,34 +26,36 @@ class VocabPracticeSessionRepo { ]; final types = List.generate( - VocabPracticeSessionModel.practiceGroupSize, + VocabPracticeConstants.practiceGroupSize, (_) => activityTypes[r.nextInt(activityTypes.length)], ); - final targets = await _fetch(); + final constructs = await _fetch(); + final targetCount = min(constructs.length, types.length); + final targets = [ + for (var i = 0; i < targetCount; i++) + PracticeTarget( + tokens: [constructs[i].asToken], + activityType: types[i], + ), + ]; + final session = VocabPracticeSessionModel( userL1: MatrixState.pangeaController.userController.userL1!.langCode, userL2: MatrixState.pangeaController.userController.userL2!.langCode, startedAt: DateTime.now(), - sortedConstructIds: targets, - activityTypes: types, - completedUses: [], + practiceTargets: targets, ); await _setCached(session); return session; } - static Future updateSession( + static Future update( VocabPracticeSessionModel session, ) => _setCached(session); - static Future reloadSession() async { - _storage.erase(); - return currentSession; - } - - static Future clearSession() => _storage.erase(); + static Future clear() => _storage.erase(); static Future> _fetch() async { final constructs = await MatrixState @@ -59,25 +63,21 @@ class VocabPracticeSessionRepo { .getAggregatedConstructs(ConstructTypeEnum.vocab) .then((map) => map.values.toList()); - // maintain a Map of ConstructIDs to last use dates and a sorted list of ConstructIDs - // based on last use. Update the map / list on practice completion - final Map constructLastUseMap = {}; - final List sortedTargetIds = []; - for (final construct in constructs) { - constructLastUseMap[construct.id] = construct.lastUsed; - sortedTargetIds.add(construct.id); - } - - sortedTargetIds.sort((a, b) { - final dateA = constructLastUseMap[a]; - final dateB = constructLastUseMap[b]; + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; if (dateA == null && dateB == null) return 0; if (dateA == null) return -1; if (dateB == null) return 1; return dateA.compareTo(dateB); }); - return sortedTargetIds; + return constructs + .where((construct) => construct.lemma.isNotEmpty) + .take(VocabPracticeConstants.practiceGroupSize) + .map((construct) => construct.id) + .toList(); } static VocabPracticeSessionModel? _getCached() { diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/vocab_practice/vocab_practice_view.dart index 258251bd0..a196e4186 100644 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ b/lib/pangea/vocab_practice/vocab_practice_view.dart @@ -5,10 +5,10 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/vocab_practice/choice_cards/audio_choice_card.dart'; import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart'; @@ -25,28 +25,40 @@ class VocabPracticeView extends StatelessWidget { @override Widget build(BuildContext context) { + const loading = Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), + ); return Scaffold( appBar: AppBar( title: Row( spacing: 8.0, children: [ Expanded( - child: AnimatedProgressBar( - height: 20.0, - widthPercent: controller.progress, - barColor: Theme.of(context).colorScheme.primary, + child: ValueListenableBuilder( + valueListenable: controller.progressNotifier, + builder: (context, progress, __) { + return AnimatedProgressBar( + height: 20.0, + widthPercent: progress, + barColor: Theme.of(context).colorScheme.primary, + ); + }, ), ), //keep track of state to update timer ValueListenableBuilder( - valueListenable: controller.sessionLoader.state, + valueListenable: controller.sessionState, builder: (context, state, __) { if (state is AsyncLoaded) { return VocabTimerWidget( key: ValueKey(state.value.startedAt), - initialSeconds: state.value.elapsedSeconds, + initialSeconds: state.value.state.elapsedSeconds, onTimeUpdate: controller.updateElapsedTime, - isRunning: !controller.isComplete, + isRunning: !state.value.isComplete, ); } return const SizedBox.shrink(); @@ -57,64 +69,34 @@ class VocabPracticeView extends StatelessWidget { ), body: MaxWidthBody( withScrolling: false, - padding: const EdgeInsets.all(0.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), showBorder: false, - child: controller.isComplete - ? CompletedActivitySessionView(controller) - : _OngoingActivitySessionView(controller), + child: ValueListenableBuilder( + valueListenable: controller.sessionState, + builder: (context, state, __) { + return switch (state) { + AsyncError(:final error) => + ErrorIndicator(message: error.toString()), + AsyncLoaded(:final value) => + value.isComplete + ? CompletedActivitySessionView(state.value, controller) + : _VocabActivityView(controller), + _ => loading, + }; + }, + ), ), ); } } -class _OngoingActivitySessionView extends StatelessWidget { - final VocabPracticeState controller; - const _OngoingActivitySessionView(this.controller); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.sessionLoader.state, - builder: (context, state, __) { - return switch (state) { - AsyncError(:final error) => - ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => - value.currentConstructId != null && - value.currentActivityType != null - ? _VocabActivityView( - value.currentConstructId!, - value.currentActivityType!, - controller, - ) - : const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive(), - ), - ), - _ => const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive(), - ), - ), - }; - }, - ); - } -} - class _VocabActivityView extends StatelessWidget { - final ConstructIdentifier constructId; - final ActivityTypeEnum activityType; final VocabPracticeState controller; const _VocabActivityView( - this.constructId, - this.activityType, this.controller, ); @@ -125,30 +107,48 @@ class _VocabActivityView extends StatelessWidget { //per-activity instructions, add switch statement once there are more types const InstructionsInlineTooltip( instructionsEnum: InstructionsEnum.selectMeaning, - padding: EdgeInsets.symmetric(horizontal: 16.0), + padding: EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), ), Expanded( child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + spacing: 16.0, children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - constructId.lemma, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + Expanded( + flex: 1, + child: ValueListenableBuilder( + valueListenable: controller.activityConstructId, + builder: (context, constructId, __) => constructId != null + ? Text( + constructId.lemma, + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ) + : const SizedBox(), ), ), - _ExampleMessageWidget(controller, constructId), - Flexible( - child: _ActivityChoicesWidget( - controller, - activityType, - constructId, + Expanded( + flex: 2, + child: Center( + child: ValueListenableBuilder( + valueListenable: controller.activityConstructId, + builder: (context, constructId, __) => constructId != null + ? _ExampleMessageWidget( + controller.getExampleMessage(constructId), + ) + : const SizedBox(), + ), ), ), + Expanded( + flex: 6, + child: _ActivityChoicesWidget(controller), + ), ], ), ), @@ -158,44 +158,38 @@ class _VocabActivityView extends StatelessWidget { } class _ExampleMessageWidget extends StatelessWidget { - final VocabPracticeState controller; - final ConstructIdentifier constructId; + final Future?> future; - const _ExampleMessageWidget(this.controller, this.constructId); + const _ExampleMessageWidget(this.future); @override Widget build(BuildContext context) { return FutureBuilder?>( - future: controller.getExampleMessage(constructId), + future: future, builder: (context, snapshot) { if (!snapshot.hasData || snapshot.data == null) { return const SizedBox(); } - return Padding( - //styling like sent message bubble - padding: const EdgeInsets.all(16.0), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Color.alphaBlend( + Colors.white.withAlpha(180), + ThemeData.dark().colorScheme.primary, ), - decoration: BoxDecoration( - color: Color.alphaBlend( - Colors.white.withAlpha(180), - ThemeData.dark().colorScheme.primary, - ), - borderRadius: BorderRadius.circular(16), - ), - child: RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, - fontSize: - AppConfig.fontSizeFactor * AppConfig.messageFontSize, - ), - children: snapshot.data!, + borderRadius: BorderRadius.circular(16), + ), + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, ), + children: snapshot.data!, ), ), ); @@ -206,87 +200,111 @@ class _ExampleMessageWidget extends StatelessWidget { class _ActivityChoicesWidget extends StatelessWidget { final VocabPracticeState controller; - final ActivityTypeEnum activityType; - final ConstructIdentifier constructId; const _ActivityChoicesWidget( this.controller, - this.activityType, - this.constructId, ); @override Widget build(BuildContext context) { - if (controller.activityError != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - //allow try to reload activity in case of error - ErrorIndicator(message: controller.activityError!), - const SizedBox(height: 16), - TextButton.icon( - onPressed: controller.loadActivity, - icon: const Icon(Icons.refresh), - label: Text(L10n.of(context).tryAgain), - ), - ], - ); - } - - final activity = controller.currentActivity; - if (controller.isLoadingActivity || - activity == null || - (activity.activityType == ActivityTypeEnum.lemmaMeaning && - controller.isLoadingLemmaInfo)) { - return Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - final choices = activity.multipleChoiceContent!.choices.toList(); - return LayoutBuilder( - builder: (context, constraints) { - //Constrain max height to keep choices together on large screens, and allow shrinking to fit on smaller screens - final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0); - final cardHeight = - (constrainedHeight / (choices.length + 1)).clamp(50.0, 80.0); - - return Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: choices.map((choiceId) { - final bool isEnabled = !controller.isAwaitingNextActivity; - return _buildChoiceCard( - activity: activity, - choiceId: choiceId, - cardHeight: cardHeight, - isEnabled: isEnabled, - onPressed: () => - controller.onSelectChoice(constructId, choiceId), - ); - }).toList(), + return ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) { + return switch (state) { + AsyncLoading() => const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), ), - ), - ); + AsyncError(:final error) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //allow try to reload activity in case of error + ErrorIndicator(message: error.toString()), + const SizedBox(height: 16), + TextButton.icon( + onPressed: controller.reloadSession, + icon: const Icon(Icons.refresh), + label: Text(L10n.of(context).tryAgain), + ), + ], + ), + AsyncLoaded(:final value) => LayoutBuilder( + builder: (context, constraints) { + final choices = controller.filteredChoices( + value.practiceTarget, + value.multipleChoiceContent!, + ); + final constrainedHeight = + constraints.maxHeight.clamp(0.0, 400.0); + final cardHeight = (constrainedHeight / (choices.length + 1)) + .clamp(50.0, 80.0); + + return Container( + constraints: const BoxConstraints(maxHeight: 400.0), + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: choices + .map( + (choice) => _ChoiceCard( + activity: value, + targetId: + controller.choiceTargetId(choice.choiceId), + choiceId: choice.choiceId, + onPressed: () => controller.onSelectChoice( + value.targetTokens.first.vocabConstructID, + choice.choiceId, + ), + cardHeight: cardHeight, + choiceText: choice.choiceText, + choiceEmoji: choice.choiceEmoji, + ), + ) + .toList(), + ), + ); + }, + ), + _ => Container( + constraints: const BoxConstraints(maxHeight: 400.0), + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + }; }, ); } +} - Widget _buildChoiceCard({ - required activity, - required String choiceId, - required double cardHeight, - required bool isEnabled, - required VoidCallback onPressed, - }) { +class _ChoiceCard extends StatelessWidget { + final PracticeActivityModel activity; + final String choiceId; + final String targetId; + final VoidCallback onPressed; + final double cardHeight; + + final String choiceText; + final String? choiceEmoji; + + const _ChoiceCard({ + required this.activity, + required this.choiceId, + required this.targetId, + required this.onPressed, + required this.cardHeight, + required this.choiceText, + required this.choiceEmoji, + }); + + @override + Widget build(BuildContext context) { final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId); + final activityType = activity.activityType; + final constructId = activity.targetTokens.first.vocabConstructID; switch (activity.activityType) { case ActivityTypeEnum.lemmaMeaning: @@ -295,12 +313,12 @@ class _ActivityChoicesWidget extends StatelessWidget { '${constructId.string}_${activityType.name}_meaning_$choiceId', ), choiceId: choiceId, - displayText: controller.getChoiceText(choiceId), - emoji: controller.getChoiceEmoji(choiceId), + targetId: targetId, + displayText: choiceText, + emoji: choiceEmoji, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, - isEnabled: isEnabled, ); case ActivityTypeEnum.lemmaAudio: @@ -309,10 +327,10 @@ class _ActivityChoicesWidget extends StatelessWidget { '${constructId.string}_${activityType.name}_audio_$choiceId', ), text: choiceId, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, - isEnabled: isEnabled, ); default: @@ -321,12 +339,11 @@ class _ActivityChoicesWidget extends StatelessWidget { '${constructId.string}_${activityType.name}_basic_$choiceId', ), shouldFlip: false, - transformId: choiceId, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, - isEnabled: isEnabled, - child: Text(controller.getChoiceText(choiceId)), + child: Text(choiceText), ); } }