import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.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/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; const AnalyticsPracticeView(this.controller, {super.key}); @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: 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.sessionState, builder: (context, state, __) { if (state is AsyncLoaded) { return PracticeTimerWidget( key: ValueKey(state.value.startedAt), initialSeconds: state.value.state.elapsedSeconds, onTimeUpdate: controller.updateElapsedTime, isRunning: !state.value.isComplete, ); } return const SizedBox.shrink(); }, ), ], ), ), body: Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 24.0, ), child: MaxWidthBody( withScrolling: false, showBorder: false, child: ValueListenableBuilder( valueListenable: controller.sessionState, builder: (context, state, __) { return switch (state) { AsyncError(:final error) => ErrorIndicator( message: error.toLocalizedString(context), ), AsyncLoaded(:final value) => value.isComplete ? CompletedActivitySessionView(state.value, controller) : _AnalyticsActivityView(controller), _ => loading, }; }, ), ), ), ); } } class _AnalyticsActivityView extends StatelessWidget { final AnalyticsPracticeState controller; const _AnalyticsActivityView( this.controller, ); @override Widget build(BuildContext context) { return Column( 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, ), ), Expanded( child: Column( spacing: 16.0, children: [ Expanded( flex: 1, child: ValueListenableBuilder( valueListenable: controller.activityTarget, builder: (context, target, __) => target != null ? Column( spacing: 12.0, children: [ Text( target.promptText(context), textAlign: TextAlign.center, style: Theme.of(context) .textTheme .titleLarge ?.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(), ), ), Expanded( flex: 2, child: Center( child: _AnalyticsPracticeCenterContent( controller: controller, ), ), ), Expanded( flex: 6, child: _ActivityChoicesWidget(controller), ), ], ), ), ], ); } } class _AnalyticsPracticeCenterContent extends StatelessWidget { final AnalyticsPracticeState controller; const _AnalyticsPracticeCenterContent({ required this.controller, }); @override Widget build(BuildContext context) { return ValueListenableBuilder( 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 activity) => _ErrorBlankWidget( activity: activity as GrammarErrorPracticeActivityModel, ), _ => const SizedBox(), }, ), _ => _ExampleMessageWidget( controller.getExampleMessage(target!.target), ), }, ); } } class _ExampleMessageWidget extends StatelessWidget { final Future?> future; const _ExampleMessageWidget(this.future); @override Widget build(BuildContext context) { return FutureBuilder?>( future: future, builder: (context, snapshot) { if (!snapshot.hasData || snapshot.data == null) { return const SizedBox(); } return 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: RichText( text: TextSpan( style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, ), children: snapshot.data!, ), ), ); }, ); } } class _ErrorBlankWidget extends StatelessWidget { final GrammarErrorPracticeActivityModel activity; const _ErrorBlankWidget({ required this.activity, }); @override Widget build(BuildContext context) { final text = activity.text; final errorOffset = activity.errorOffset; final errorLength = activity.errorLength; return 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: RichText( text: TextSpan( style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, ), children: [ if (errorOffset > 0) TextSpan(text: text.characters.take(errorOffset).toString()), WidgetSpan( child: Container( height: 4.0, width: (errorLength * 8).toDouble(), padding: const EdgeInsets.only(bottom: 2.0), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, ), ), ), if (errorOffset + errorLength < text.length) TextSpan( text: text.characters.skip(errorOffset + errorLength).toString(), ), ], ), ), ); } } class _ActivityChoicesWidget extends StatelessWidget { final AnalyticsPracticeState controller; const _ActivityChoicesWidget( this.controller, ); @override Widget build(BuildContext context) { 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); 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: ValueListenableBuilder( valueListenable: controller.enableChoicesNotifier, builder: (context, enabled, __) => 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( choice.choiceId, ), cardHeight: cardHeight, choiceText: choice.choiceText, choiceEmoji: choice.choiceEmoji, enabled: enabled, ), ) .toList(), ), ), ); }, ), _ => Container( constraints: const BoxConstraints(maxHeight: 400.0), child: const Center( child: CircularProgressIndicator.adaptive(), ), ), }; }, ); } } class _ChoiceCard extends StatelessWidget { final MultipleChoicePracticeActivityModel activity; final String choiceId; final String targetId; final VoidCallback onPressed; final double cardHeight; final String choiceText; final String? choiceEmoji; final bool enabled; const _ChoiceCard({ required this.activity, required this.choiceId, required this.targetId, required this.onPressed, required this.cardHeight, required this.choiceText, required this.choiceEmoji, this.enabled = true, }); @override Widget build(BuildContext context) { final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId); final activityType = activity.activityType; final constructId = activity.tokens.first.vocabConstructID; switch (activity.activityType) { case ActivityTypeEnum.lemmaMeaning: return MeaningChoiceCard( key: ValueKey( '${constructId.string}_${activityType.name}_meaning_$choiceId', ), choiceId: choiceId, targetId: targetId, displayText: choiceText, emoji: choiceEmoji, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, isEnabled: enabled, ); case ActivityTypeEnum.lemmaAudio: return AudioChoiceCard( key: ValueKey( '${constructId.string}_${activityType.name}_audio_$choiceId', ), text: choiceId, targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, isEnabled: enabled, ); case ActivityTypeEnum.grammarCategory: return GrammarChoiceCard( key: ValueKey( '${constructId.string}_${activityType.name}_grammar_$choiceId', ), choiceId: choiceId, targetId: targetId, feature: (activity as MorphPracticeActivityModel).morphFeature, tag: choiceText, onPressed: onPressed, isCorrect: isCorrect, enabled: enabled, ); case ActivityTypeEnum.grammarError: final activity = this.activity as GrammarErrorPracticeActivityModel; return GameChoiceCard( key: ValueKey( '${activity.errorLength}_${activity.errorOffset}_${activity.eventID}_${activityType.name}_grammar_error_$choiceId', ), shouldFlip: false, targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, isEnabled: enabled, child: Text(choiceText), ); default: return GameChoiceCard( key: ValueKey( '${constructId.string}_${activityType.name}_basic_$choiceId', ), shouldFlip: false, targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, isEnabled: enabled, child: Text(choiceText), ); } } }