diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart index bbbc3b76a..f9c5713aa 100644 --- a/lib/pangea/analytics_misc/example_message_util.dart +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -1,6 +1,5 @@ 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'; @@ -50,42 +49,110 @@ class ExampleMessageUtil { String? form, PangeaMessageEvent messageEvent, ) { - PangeaToken? token; String? text; + List? tokens; + int targetTokenIndex = -1; if (messageEvent.isAudioMessage) { final stt = messageEvent.getSpeechToTextLocal(); if (stt == null) return null; - final tokens = stt.transcript.sttTokens.map((t) => t.token).toList(); - token = tokens.firstWhereOrNull( - (token) => token.text.content == form, - ); + + tokens = stt.transcript.sttTokens.map((t) => t.token).toList(); + targetTokenIndex = tokens.indexWhere((t) => t.text.content == form); text = stt.transcript.text; } else { - final tokens = messageEvent.messageDisplayRepresentation?.tokens; + tokens = messageEvent.messageDisplayRepresentation?.tokens; if (tokens == null || tokens.isEmpty) return null; - token = tokens.firstWhereOrNull( - (token) => token.text.content == form, - ); + + targetTokenIndex = tokens.indexWhere((t) => t.text.content == form); text = messageEvent.messageDisplayText; } - if (token == null) return null; + if (targetTokenIndex == -1) { + return null; + } - final before = text.characters.take(token.text.offset).toString(); - final after = text.characters - .skip(token.text.offset + token.text.content.characters.length) + final targetToken = tokens[targetTokenIndex]; + + const maxContextChars = 100; + + final targetStart = targetToken.text.offset; + final targetEnd = targetStart + targetToken.text.content.characters.length; + + final totalChars = text.characters.length; + + final beforeAvailable = targetStart; + final afterAvailable = totalChars - targetEnd; + + // ---------- Dynamic budget split ---------- + int beforeBudget = maxContextChars ~/ 2; + int afterBudget = maxContextChars - beforeBudget; + + if (beforeAvailable < beforeBudget) { + afterBudget += beforeBudget - beforeAvailable; + beforeBudget = beforeAvailable; + } else if (afterAvailable < afterBudget) { + beforeBudget += afterBudget - afterAvailable; + afterBudget = afterAvailable; + } + + // ---------- BEFORE ---------- + int beforeStartOffset = 0; + bool trimmedBefore = false; + + if (beforeAvailable > beforeBudget) { + final desiredStart = targetStart - beforeBudget; + + for (int i = 0; i < targetTokenIndex; i++) { + final token = tokens[i]; + final tokenEnd = + token.text.offset + token.text.content.characters.length; + + if (tokenEnd > desiredStart) { + beforeStartOffset = token.text.offset; + trimmedBefore = true; + break; + } + } + } + + final before = text.characters + .skip(beforeStartOffset) + .take(targetStart - beforeStartOffset) .toString(); + // ---------- AFTER ---------- + int afterEndOffset = totalChars; + bool trimmedAfter = false; + + if (afterAvailable > afterBudget) { + final desiredEnd = targetEnd + afterBudget; + + for (int i = targetTokenIndex + 1; i < tokens.length; i++) { + final token = tokens[i]; + if (token.text.offset >= desiredEnd) { + afterEndOffset = token.text.offset; + trimmedAfter = true; + break; + } + } + } + + final after = text.characters + .skip(targetEnd) + .take(afterEndOffset - targetEnd) + .toString() + .trimRight(); + return [ + if (trimmedBefore) const TextSpan(text: '… '), TextSpan(text: before), TextSpan( - text: token.text.content, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), + text: targetToken.text.content, + style: const TextStyle(fontWeight: FontWeight.bold), ), TextSpan(text: after), + if (trimmedAfter) const TextSpan(text: '…'), ]; } } diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 36ebddc35..72058e7c4 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -112,110 +112,78 @@ class _AnalyticsActivityView extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + final isColumnMode = FluffyThemes.isColumnMode(context); + TextStyle? titleStyle = isColumnMode + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.titleMedium; + titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold); + + return ListView( children: [ //per-activity instructions, add switch statement once there are more types const InstructionsInlineTooltip( instructionsEnum: InstructionsEnum.selectMeaning, padding: EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 24.0, + vertical: 8.0, ), ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ValueListenableBuilder( - valueListenable: controller.activityTarget, - builder: (context, target, __) => target != null - ? Padding( - padding: const EdgeInsets.only( - left: 16.0, - right: 16.0, - top: 16.0, + SizedBox( + height: 75.0, + child: ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) => target != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + target.promptText(context), + textAlign: TextAlign.center, + style: titleStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (controller.widget.type == ConstructTypeEnum.vocab) + PhoneticTranscriptionWidget( + text: + target.target.tokens.first.vocabConstructID.lemma, + textLanguage: MatrixState + .pangeaController.userController.userL2!, + style: const TextStyle(fontSize: 14.0), ), - child: Column( - spacing: 12.0, - children: [ - Text( - target.promptText(context), - textAlign: TextAlign.center, - style: FluffyThemes.isColumnMode(context) - ? Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ) - : Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - if (controller.widget.type == - ConstructTypeEnum.vocab) - PhoneticTranscriptionWidget( - text: target - .target.tokens.first.vocabConstructID.lemma, - textLanguage: MatrixState - .pangeaController.userController.userL2!, - style: const TextStyle(fontSize: 14.0), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - Flexible( - fit: FlexFit.loose, - child: SingleChildScrollView( - child: _AnalyticsPracticeCenterContent( - controller: controller, - ), - ), - ), - Expanded( - child: _ActivityChoicesWidget(controller), - ), - //reserve space for grammar category morph meaning to avoid shifting, but only in those questions - AnimatedBuilder( - animation: Listenable.merge([ - controller.activityState, - controller.selectedMorphChoice, - ]), - builder: (context, _) { - final activityState = controller.activityState.value; - final selectedChoice = controller.selectedMorphChoice.value; - - final isGrammarCategory = activityState - is AsyncLoaded && - activityState.value.activityType == - ActivityTypeEnum.grammarCategory; - - if (!isGrammarCategory) { - return const SizedBox.shrink(); - } - - return SizedBox( - height: 125.0, - child: selectedChoice == null - ? const SizedBox.shrink() - : SingleChildScrollView( - child: MorphMeaningWidget( - feature: selectedChoice.feature, - tag: selectedChoice.tag, - blankErrorFeedback: true, - ), - ), - ); - }, - ), - ], + ], + ) + : const SizedBox.shrink(), ), ), + const SizedBox(height: 16.0), + Center( + child: _AnalyticsPracticeCenterContent(controller: controller), + ), + const SizedBox(height: 16.0), + _ActivityChoicesWidget(controller), + const SizedBox(height: 16.0), + ListenableBuilder( + listenable: Listenable.merge([ + controller.activityState, + controller.selectedMorphChoice, + ]), + builder: (context, _) { + final activityState = controller.activityState.value; + final selectedChoice = controller.selectedMorphChoice.value; + + if (activityState + is! AsyncLoaded || + selectedChoice == null) { + return const SizedBox.shrink(); + } + + return MorphMeaningWidget( + feature: selectedChoice.feature, + tag: selectedChoice.tag, + blankErrorFeedback: true, + ); + }, + ), ], ); } @@ -234,32 +202,42 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { valueListenable: controller.activityTarget, builder: (context, target, __) => switch (target?.target.activityType) { null => const SizedBox(), - ActivityTypeEnum.grammarError => ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, __) => switch (state) { - AsyncLoaded( - value: final GrammarErrorPracticeActivityModel activity - ) => - Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ErrorBlankWidget( - activity: activity, + ActivityTypeEnum.grammarError => SizedBox( + height: 160.0, + child: SingleChildScrollView( + child: ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) => switch (state) { + AsyncLoaded( + value: final GrammarErrorPracticeActivityModel activity + ) => + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ErrorBlankWidget( + activity: activity, + ), + const SizedBox(height: 12), + _GrammarErrorTranslationButton( + key: ValueKey( + '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', + ), + translation: activity.translation, + ), + ], ), - const SizedBox(height: 12), - _GrammarErrorTranslationButton( - key: ValueKey( - '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', - ), - translation: activity.translation, - ), - ], - ), - _ => const SizedBox(), - }, + _ => const SizedBox(), + }, + ), + ), ), - _ => _ExampleMessageWidget( - controller.getExampleMessage(target!.target), + _ => SizedBox( + height: 100.0, + child: Center( + child: _ExampleMessageWidget( + controller.getExampleMessage(target!.target), + ), + ), ), }, ); @@ -320,6 +298,51 @@ class _ErrorBlankWidget extends StatelessWidget { final errorOffset = activity.errorOffset; final errorLength = activity.errorLength; + const maxContextChars = 50; + + final chars = text.characters; + final totalLength = chars.length; + + // ---------- BEFORE ---------- + int beforeStart = 0; + bool trimmedBefore = false; + + if (errorOffset > maxContextChars) { + int desiredStart = errorOffset - maxContextChars; + + // Snap left to nearest whitespace to avoid cutting words + while (desiredStart > 0 && chars.elementAt(desiredStart) != ' ') { + desiredStart--; + } + + beforeStart = desiredStart; + trimmedBefore = true; + } + + final before = + chars.skip(beforeStart).take(errorOffset - beforeStart).toString(); + + // ---------- AFTER ---------- + int afterEnd = totalLength; + bool trimmedAfter = false; + + final errorEnd = errorOffset + errorLength; + final afterChars = totalLength - errorEnd; + + if (afterChars > maxContextChars) { + int desiredEnd = errorEnd + maxContextChars; + + // Snap right to nearest whitespace + while (desiredEnd < totalLength && chars.elementAt(desiredEnd) != ' ') { + desiredEnd++; + } + + afterEnd = desiredEnd; + trimmedAfter = true; + } + + final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString(); + return Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -339,8 +362,8 @@ class _ErrorBlankWidget extends StatelessWidget { fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, ), children: [ - if (errorOffset > 0) - TextSpan(text: text.characters.take(errorOffset).toString()), + if (trimmedBefore) const TextSpan(text: '…'), + if (before.isNotEmpty) TextSpan(text: before), WidgetSpan( child: Container( height: 4.0, @@ -351,10 +374,88 @@ class _ErrorBlankWidget extends StatelessWidget { ), ), ), - if (errorOffset + errorLength < text.length) - TextSpan( - text: - text.characters.skip(errorOffset + errorLength).toString(), + if (after.isNotEmpty) TextSpan(text: after), + if (trimmedAfter) const TextSpan(text: '…'), + ], + ), + ), + ); + } +} + +class _GrammarErrorTranslationButton extends StatefulWidget { + final String translation; + + const _GrammarErrorTranslationButton({ + super.key, + required this.translation, + }); + + @override + State<_GrammarErrorTranslationButton> createState() => + _GrammarErrorTranslationButtonState(); +} + +class _GrammarErrorTranslationButtonState + extends State<_GrammarErrorTranslationButton> { + bool _showTranslation = false; + + void _toggleTranslation() { + if (_showTranslation) { + setState(() { + _showTranslation = false; + }); + } else { + setState(() { + _showTranslation = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: GestureDetector( + onTap: _toggleTranslation, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + if (_showTranslation) + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Color.alphaBlend( + Colors.white.withAlpha(180), + ThemeData.dark().colorScheme.primary, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + widget.translation, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: + AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + ), + ), + ) + else + ElevatedButton( + onPressed: _toggleTranslation, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(8), + ), + child: const Icon( + Icons.lightbulb_outline, + size: 20, + ), ), ], ), @@ -398,43 +499,29 @@ class _ActivityChoicesWidget extends StatelessWidget { ], ), AsyncLoaded(:final value) => - LayoutBuilder( - builder: (context, constraints) { + ValueListenableBuilder( + valueListenable: controller.enableChoicesNotifier, + builder: (context, enabled, __) { final choices = controller.filteredChoices(value); - final constrainedHeight = - constraints.maxHeight.clamp(0.0, 400.0); - final cardHeight = (constrainedHeight / (choices.length + 1)) - .clamp(50.0, 80.0); - - return ValueListenableBuilder( - valueListenable: controller.enableChoicesNotifier, - builder: (context, enabled, __) => Column( - children: [ - Expanded( - child: Column( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: choices - .map( - (choice) => _ChoiceCard( - activity: value, - targetId: controller - .choiceTargetId(choice.choiceId), - choiceId: choice.choiceId, - onPressed: () => controller.onSelectChoice( - choice.choiceId, - ), - cardHeight: cardHeight, - choiceText: choice.choiceText, - choiceEmoji: choice.choiceEmoji, - enabled: enabled, - ), - ) - .toList(), + return Column( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: choices + .map( + (choice) => _ChoiceCard( + activity: value, + targetId: controller.choiceTargetId(choice.choiceId), + choiceId: choice.choiceId, + onPressed: () => controller.onSelectChoice( + choice.choiceId, + ), + cardHeight: 60.0, + choiceText: choice.choiceText, + choiceEmoji: choice.choiceEmoji, + enabled: enabled, ), - ), - ], - ), + ) + .toList(), ); }, ), @@ -553,85 +640,3 @@ class _ChoiceCard extends StatelessWidget { } } } - -class _GrammarErrorTranslationButton extends StatefulWidget { - final String translation; - - const _GrammarErrorTranslationButton({ - super.key, - required this.translation, - }); - - @override - State<_GrammarErrorTranslationButton> createState() => - _GrammarErrorTranslationButtonState(); -} - -class _GrammarErrorTranslationButtonState - extends State<_GrammarErrorTranslationButton> { - bool _showTranslation = false; - - void _toggleTranslation() { - if (_showTranslation) { - setState(() { - _showTranslation = false; - }); - } else { - setState(() { - _showTranslation = true; - }); - } - } - - @override - Widget build(BuildContext context) { - return Center( - child: GestureDetector( - onTap: _toggleTranslation, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - if (_showTranslation) - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Color.alphaBlend( - Colors.white.withAlpha(180), - ThemeData.dark().colorScheme.primary, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - widget.translation, - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, - fontSize: - AppConfig.fontSizeFactor * AppConfig.messageFontSize, - ), - textAlign: TextAlign.center, - ), - ), - ), - if (!_showTranslation) - ElevatedButton( - onPressed: _toggleTranslation, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(8), - ), - child: const Icon( - Icons.lightbulb_outline, - size: 20, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart index 58c5095a1..88ee9b7f9 100644 --- a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart @@ -149,8 +149,6 @@ class _CardContainer extends StatelessWidget { 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,