4825 vocabulary practice (#4826)
* chore: move logic for lastUsedByActivityType into ConstructIdentifier * feat: vocab practice * add vocab activity progress bar * fix: shuffle audio practice choices * update UI of vocab practice Added buttons, increased text size and change position, cards flip over and turn red/green on click and respond to hover input * add xp sparkle, shimmering choice card placeholder * spacing changes fix padding, make choice cards spacing/sizing responsive to screen size, replace shimmer cards with stationary circle indicator * don't include duplicate lemma choices * use constructID and show lemma/emoji on choice cards add method to clear cache in case the results was an error, and add a retry button on error * gain xp immediately and take out continue session also refactor the choice cards to have separate widgets for each type and a parent widget to give each an id for xp sparkle * add practice finished page with analytics * Color tweaks on completed page and time card placeholder * add timer * give XP for bonuses and change timer to use stopwatch * simplify card logic, lock practice when few vocab words * merge analytics changes and fix bugs * reload on language change - derive XP data from new analytics - Don't allow any clicks after correct answer selected * small fixes, added tooltip, added copy to l10 * small tweaks and comments * formatting and import sorting --------- Co-authored-by: avashilling <165050625+avashilling@users.noreply.github.com>
This commit is contained in:
parent
5aa3d759e0
commit
af395d0aeb
29 changed files with 2322 additions and 81 deletions
|
|
@ -59,6 +59,7 @@ import 'package:fluffychat/pangea/login/pages/signup.dart';
|
|||
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
|
||||
import 'package:fluffychat/pangea/spaces/space_constants.dart';
|
||||
import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart';
|
||||
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
|
||||
import 'package:fluffychat/widgets/config_viewer.dart';
|
||||
import 'package:fluffychat/widgets/layouts/empty_page.dart';
|
||||
import 'package:fluffychat/widgets/layouts/two_column_layout.dart';
|
||||
|
|
@ -573,6 +574,16 @@ abstract class AppRoutes {
|
|||
),
|
||||
redirect: loggedOutRedirect,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'practice',
|
||||
pageBuilder: (context, state) {
|
||||
return defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const VocabPractice(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ':construct',
|
||||
pageBuilder: (context, state) {
|
||||
|
|
|
|||
|
|
@ -5008,6 +5008,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"constructUseCorLMDesc": "Correct vocab definition practice",
|
||||
"constructUseIncLMDesc": "Incorrect vocab definition practice",
|
||||
"constructUseCorLADesc": "Correct vocab audio practice",
|
||||
"constructUseIncLADesc": "Incorrect vocab audio practice",
|
||||
"constructUseBonus": "Bonus during vocab practice",
|
||||
"practiceVocab": "Practice vocabulary",
|
||||
"selectMeaning": "Select the meaning",
|
||||
"selectAudio": "Select the matching audio",
|
||||
"congratulations": "Congratulations!",
|
||||
"anotherRound": "Another round",
|
||||
"ssoDialogTitle": "Waiting for sign in to complete",
|
||||
"ssoDialogDesc": "We opened a new tab so you can sign in securely.",
|
||||
"ssoDialogHelpText": "🤔 If you didn't see the new tab, please check your popup blocker.",
|
||||
|
|
@ -5016,6 +5026,9 @@
|
|||
"recordingPermissionDenied": "Permission denied. Enable recording permissions to record audio messages.",
|
||||
"genericWebRecordingError": "Something went wrong. We recommend using the Chrome browser when recording messages.",
|
||||
"screenSizeWarning": "For the best experience using this application, please expand your screen size.",
|
||||
"noActivityRequest": "No current activity request.",
|
||||
"quit": "Quit",
|
||||
"congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.",
|
||||
"noSavedActivitiesYet": "Activities will appear here once they are completed and saved.",
|
||||
"practiceActivityCompleted": "Practice activity completed",
|
||||
"changeCourse": "Change course",
|
||||
|
|
@ -5026,5 +5039,7 @@
|
|||
"announcementsChatDesc": "Only space admin can post.",
|
||||
"inOngoingActivity": "You have an ongoing activity!",
|
||||
"activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic",
|
||||
"activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic"
|
||||
"activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic",
|
||||
"mustHave10Words" : "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!"
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class AnalyticsDataService {
|
|||
AnalyticsSyncController? _syncController;
|
||||
final ConstructMergeTable _mergeTable = ConstructMergeTable();
|
||||
|
||||
Completer<void> _initCompleter = Completer<void>();
|
||||
Completer<void> initCompleter = Completer<void>();
|
||||
|
||||
AnalyticsDataService(Client client) {
|
||||
updateDispatcher = AnalyticsUpdateDispatcher(this);
|
||||
|
|
@ -77,7 +77,7 @@ class AnalyticsDataService {
|
|||
return _analyticsClient!;
|
||||
}
|
||||
|
||||
bool get isInitializing => !_initCompleter.isCompleted;
|
||||
bool get isInitializing => !initCompleter.isCompleted;
|
||||
|
||||
Future<Room?> getAnalyticsRoom(LanguageModel l2) =>
|
||||
_analyticsClientGetter.client.getMyAnalyticsRoom(l2);
|
||||
|
|
@ -155,7 +155,7 @@ class AnalyticsDataService {
|
|||
Logs().e("Error initializing analytics: $e, $s");
|
||||
} finally {
|
||||
Logs().i("Analytics database initialized.");
|
||||
_initCompleter.complete();
|
||||
initCompleter.complete();
|
||||
updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([]));
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ class AnalyticsDataService {
|
|||
|
||||
Future<void> reinitialize() async {
|
||||
Logs().i("Reinitializing analytics database.");
|
||||
_initCompleter = Completer<void>();
|
||||
initCompleter = Completer<void>();
|
||||
await _clearDatabase();
|
||||
await _initDatabase(_analyticsClientGetter.client);
|
||||
}
|
||||
|
|
@ -192,7 +192,7 @@ class AnalyticsDataService {
|
|||
}
|
||||
|
||||
Future<void> _ensureInitialized() =>
|
||||
_initCompleter.isCompleted ? Future.value() : _initCompleter.future;
|
||||
initCompleter.isCompleted ? Future.value() : initCompleter.future;
|
||||
|
||||
int numConstructs(ConstructTypeEnum type) =>
|
||||
_mergeTable.uniqueConstructsByType(type);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/morph_analytics_list_view.dart';
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/morph_details_view.dart';
|
||||
|
|
@ -179,6 +182,72 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton:
|
||||
widget.view == ConstructTypeEnum.vocab && widget.construct == null
|
||||
? _buildVocabPracticeButton(context)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVocabPracticeButton(BuildContext context) {
|
||||
// Check if analytics is loaded first
|
||||
if (MatrixState
|
||||
.pangeaController.matrixState.analyticsDataService.isInitializing) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Loading vocabulary data...',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
label: Text(L10n.of(context).practiceVocab),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
);
|
||||
}
|
||||
|
||||
final vocabCount = MatrixState
|
||||
.pangeaController.matrixState.analyticsDataService
|
||||
.numConstructs(ConstructTypeEnum.vocab);
|
||||
final hasEnoughVocab = vocabCount >= 10;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: hasEnoughVocab
|
||||
? () {
|
||||
context.go(
|
||||
"/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice",
|
||||
);
|
||||
}
|
||||
: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
L10n.of(context).mustHave10Words,
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
backgroundColor:
|
||||
hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer,
|
||||
foregroundColor: hasEnoughVocab
|
||||
? null
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!hasEnoughVocab) ...[
|
||||
const Icon(Icons.lock_outline, size: 18),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(L10n.of(context).practiceVocab),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,8 +69,19 @@ enum ConstructUseTypeEnum {
|
|||
/// lemma collected by clicking on it
|
||||
click,
|
||||
|
||||
/// Bonus XP, ignored by level analytics page
|
||||
bonus,
|
||||
|
||||
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
|
||||
nan
|
||||
nan,
|
||||
|
||||
// vocab lemma definition activity
|
||||
corLM,
|
||||
incLM,
|
||||
|
||||
// vocab lemma audio activity
|
||||
corLA,
|
||||
incLA,
|
||||
}
|
||||
|
||||
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
||||
|
|
@ -140,8 +151,18 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
return L10n.of(context).constructUseIgnMmDesc;
|
||||
case ConstructUseTypeEnum.click:
|
||||
return L10n.of(context).constructUseCollected;
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
return L10n.of(context).constructUseBonus;
|
||||
case ConstructUseTypeEnum.nan:
|
||||
return L10n.of(context).constructUseNanDesc;
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
return L10n.of(context).constructUseCorLMDesc;
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
return L10n.of(context).constructUseIncLMDesc;
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
return L10n.of(context).constructUseCorLADesc;
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
return L10n.of(context).constructUseIncLADesc;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,10 +183,14 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.corPA:
|
||||
case ConstructUseTypeEnum.incPA:
|
||||
case ConstructUseTypeEnum.ignPA:
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
return ActivityTypeEnum.wordMeaning.icon;
|
||||
case ConstructUseTypeEnum.ignWL:
|
||||
case ConstructUseTypeEnum.incWL:
|
||||
case ConstructUseTypeEnum.corWL:
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
return ActivityTypeEnum.wordFocusListening.icon;
|
||||
case ConstructUseTypeEnum.incHWL:
|
||||
case ConstructUseTypeEnum.ignHWL:
|
||||
|
|
@ -192,6 +217,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
return Icons.help;
|
||||
case ConstructUseTypeEnum.click:
|
||||
return Icons.format_color_text;
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
return Icons.star;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +233,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.corPA:
|
||||
case ConstructUseTypeEnum.corWL:
|
||||
case ConstructUseTypeEnum.corM:
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
return 5;
|
||||
|
||||
case ConstructUseTypeEnum.pvm:
|
||||
|
|
@ -223,6 +252,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
|
||||
case ConstructUseTypeEnum.corIt:
|
||||
case ConstructUseTypeEnum.corMM:
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
return 1;
|
||||
|
||||
case ConstructUseTypeEnum.ignIt:
|
||||
|
|
@ -249,6 +279,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.incWL:
|
||||
case ConstructUseTypeEnum.incHWL:
|
||||
case ConstructUseTypeEnum.incL:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +321,11 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.em:
|
||||
case ConstructUseTypeEnum.click:
|
||||
case ConstructUseTypeEnum.nan:
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -313,6 +350,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.corHWL:
|
||||
case ConstructUseTypeEnum.ignHWL:
|
||||
case ConstructUseTypeEnum.incHWL:
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
return LearningSkillsEnum.hearing;
|
||||
case ConstructUseTypeEnum.corPA:
|
||||
case ConstructUseTypeEnum.ignPA:
|
||||
|
|
@ -328,9 +367,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.ignMM:
|
||||
case ConstructUseTypeEnum.em:
|
||||
case ConstructUseTypeEnum.click:
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
return LearningSkillsEnum.reading;
|
||||
case ConstructUseTypeEnum.pvm:
|
||||
return LearningSkillsEnum.speaking;
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
case ConstructUseTypeEnum.nan:
|
||||
return LearningSkillsEnum.other;
|
||||
}
|
||||
|
|
@ -354,6 +396,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.corM:
|
||||
case ConstructUseTypeEnum.em:
|
||||
case ConstructUseTypeEnum.corMM:
|
||||
case ConstructUseTypeEnum.corLM:
|
||||
case ConstructUseTypeEnum.corLA:
|
||||
return SpaceAnalyticsSummaryEnum.numChoicesCorrect;
|
||||
|
||||
case ConstructUseTypeEnum.incIt:
|
||||
|
|
@ -364,6 +408,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.incL:
|
||||
case ConstructUseTypeEnum.incM:
|
||||
case ConstructUseTypeEnum.incMM:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
return SpaceAnalyticsSummaryEnum.numChoicesIncorrect;
|
||||
|
||||
case ConstructUseTypeEnum.ignIt:
|
||||
|
|
@ -375,6 +421,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
case ConstructUseTypeEnum.ignM:
|
||||
case ConstructUseTypeEnum.ignMM:
|
||||
case ConstructUseTypeEnum.click:
|
||||
case ConstructUseTypeEnum.bonus:
|
||||
case ConstructUseTypeEnum.nan:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
|
||||
|
||||
class LevelPopupProgressBar extends StatefulWidget {
|
||||
|
|
@ -35,8 +34,6 @@ class LevelPopupProgressBarState extends State<LevelPopupProgressBar> {
|
|||
return AnimatedProgressBar(
|
||||
height: widget.height,
|
||||
widthPercent: width,
|
||||
barColor: AppConfig.goldLight,
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
duration: widget.duration,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ class AnimatedProgressBar extends StatelessWidget {
|
|||
final double widthPercent;
|
||||
|
||||
final Color barColor;
|
||||
final Color backgroundColor;
|
||||
final Color? backgroundColor;
|
||||
final Duration? duration;
|
||||
|
||||
const AnimatedProgressBar({
|
||||
required this.height,
|
||||
required this.widthPercent,
|
||||
required this.barColor,
|
||||
required this.backgroundColor,
|
||||
this.barColor = AppConfig.goldLight,
|
||||
this.backgroundColor,
|
||||
this.duration,
|
||||
super.key,
|
||||
});
|
||||
|
|
@ -38,7 +38,8 @@ class AnimatedProgressBar extends StatelessWidget {
|
|||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
color: backgroundColor,
|
||||
color: backgroundColor ??
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -71,6 +72,8 @@ abstract class AsyncLoader<T> {
|
|||
|
||||
T? get value => isLoaded ? (state.value as AsyncLoaded<T>).value : null;
|
||||
|
||||
final Completer<T> completer = Completer<T>();
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
state.dispose();
|
||||
|
|
@ -79,7 +82,7 @@ abstract class AsyncLoader<T> {
|
|||
Future<T> fetch();
|
||||
|
||||
Future<void> load() async {
|
||||
if (state.value is AsyncLoading || state.value is AsyncLoaded) {
|
||||
if (state.value is AsyncLoaded) {
|
||||
// If already loading or loaded, do nothing.
|
||||
return;
|
||||
}
|
||||
|
|
@ -90,19 +93,19 @@ abstract class AsyncLoader<T> {
|
|||
final result = await fetch();
|
||||
if (_disposed) return;
|
||||
state.value = AsyncState.loaded(result);
|
||||
} on HttpException catch (e) {
|
||||
completer.complete(result);
|
||||
} catch (e, s) {
|
||||
completer.completeError(e);
|
||||
if (!_disposed) {
|
||||
state.value = AsyncState.error(e);
|
||||
}
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {},
|
||||
);
|
||||
|
||||
if (!_disposed) {
|
||||
state.value = AsyncState.error(e);
|
||||
if (e is! HttpException) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ enum InstructionsEnum {
|
|||
noSavedActivitiesYet,
|
||||
setLemmaEmoji,
|
||||
disableLanguageTools,
|
||||
selectMeaning,
|
||||
}
|
||||
|
||||
extension InstructionsEnumExtension on InstructionsEnum {
|
||||
|
|
@ -41,6 +42,7 @@ extension InstructionsEnumExtension on InstructionsEnum {
|
|||
case InstructionsEnum.ttsDisabled:
|
||||
return l10n.ttsDisbledTitle;
|
||||
case InstructionsEnum.chooseWordAudio:
|
||||
case InstructionsEnum.selectMeaning:
|
||||
case InstructionsEnum.chooseEmoji:
|
||||
case InstructionsEnum.activityPlannerOverview:
|
||||
case InstructionsEnum.speechToText:
|
||||
|
|
@ -125,6 +127,8 @@ extension InstructionsEnumExtension on InstructionsEnum {
|
|||
return "";
|
||||
case InstructionsEnum.disableLanguageTools:
|
||||
return l10n.disableLanguageToolsDesc;
|
||||
case InstructionsEnum.selectMeaning:
|
||||
return l10n.selectMeaning;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,16 @@ class LemmaInfoRepo {
|
|||
}
|
||||
}
|
||||
|
||||
///clear cache of a specific request to retry if failed
|
||||
static void clearCache(LemmaInfoRequest request) {
|
||||
final key = request.hashCode.toString();
|
||||
_cache.remove(key);
|
||||
}
|
||||
|
||||
static void clearAllCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static Future<Result<LemmaInfoResponse>> _safeFetch(
|
||||
String token,
|
||||
LemmaInfoRequest request,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ enum ActivityTypeEnum {
|
|||
lemmaId,
|
||||
emoji,
|
||||
morphId,
|
||||
messageMeaning;
|
||||
messageMeaning,
|
||||
lemmaMeaning,
|
||||
lemmaAudio;
|
||||
|
||||
bool get includeTTSOnClick {
|
||||
switch (this) {
|
||||
|
|
@ -23,11 +25,13 @@ enum ActivityTypeEnum {
|
|||
return false;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ActivityTypeEnum fromString(String value) {
|
||||
static ActivityTypeEnum fromString(String value) {
|
||||
final split = value.split('.').last;
|
||||
switch (split) {
|
||||
// used to be called multiple_choice, but we changed it to word_meaning
|
||||
|
|
@ -52,6 +56,12 @@ enum ActivityTypeEnum {
|
|||
return ActivityTypeEnum.morphId;
|
||||
case 'message_meaning':
|
||||
return ActivityTypeEnum.messageMeaning; // TODO: Add to L10n
|
||||
case 'lemma_meaning':
|
||||
case 'lemmaMeaning':
|
||||
return ActivityTypeEnum.lemmaMeaning;
|
||||
case 'lemma_audio':
|
||||
case 'lemmaAudio':
|
||||
return ActivityTypeEnum.lemmaAudio;
|
||||
default:
|
||||
throw Exception('Unknown activity type: $split');
|
||||
}
|
||||
|
|
@ -96,7 +106,17 @@ enum ActivityTypeEnum {
|
|||
ConstructUseTypeEnum.corMM,
|
||||
ConstructUseTypeEnum.incMM,
|
||||
ConstructUseTypeEnum.ignMM,
|
||||
]; // TODO: Add to L10n
|
||||
];
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return [
|
||||
ConstructUseTypeEnum.corLA,
|
||||
ConstructUseTypeEnum.incLA,
|
||||
];
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return [
|
||||
ConstructUseTypeEnum.corLM,
|
||||
ConstructUseTypeEnum.incLM,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +136,10 @@ enum ActivityTypeEnum {
|
|||
return ConstructUseTypeEnum.corM;
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return ConstructUseTypeEnum.corMM;
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return ConstructUseTypeEnum.corLA;
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return ConstructUseTypeEnum.corLM;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,15 +159,21 @@ enum ActivityTypeEnum {
|
|||
return ConstructUseTypeEnum.incM;
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return ConstructUseTypeEnum.incMM;
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return ConstructUseTypeEnum.incLA;
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return ConstructUseTypeEnum.incLM;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return Icons.translate;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return Icons.volume_up;
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return Symbols.dictionary;
|
||||
|
|
@ -168,6 +198,8 @@ enum ActivityTypeEnum {
|
|||
case ActivityTypeEnum.hiddenWordListening:
|
||||
case ActivityTypeEnum.morphId:
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
|
|
@ -17,7 +18,7 @@ class LemmaActivityGenerator {
|
|||
debugger(when: kDebugMode && req.targetTokens.length != 1);
|
||||
|
||||
final token = req.targetTokens.first;
|
||||
final choices = await _lemmaActivityDistractors(token);
|
||||
final choices = await lemmaActivityDistractors(token);
|
||||
|
||||
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
|
||||
return MessageActivityResponse(
|
||||
|
|
@ -26,27 +27,25 @@ class LemmaActivityGenerator {
|
|||
targetTokens: [token],
|
||||
langCode: req.userL2,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
choices: choices,
|
||||
choices: choices.map((c) => c.lemma).toSet(),
|
||||
answers: {token.lemma.text},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Set<String>> _lemmaActivityDistractors(
|
||||
static Future<Set<ConstructIdentifier>> lemmaActivityDistractors(
|
||||
PangeaToken token,
|
||||
) async {
|
||||
final constructs = await MatrixState
|
||||
.pangeaController.matrixState.analyticsDataService
|
||||
.getAggregatedConstructs(ConstructTypeEnum.vocab);
|
||||
|
||||
final List<String> lemmas =
|
||||
constructs.values.map((c) => c.lemma).toSet().toList();
|
||||
|
||||
final List<ConstructIdentifier> constructIds = constructs.keys.toList();
|
||||
// Offload computation to an isolate
|
||||
final Map<String, int> distances =
|
||||
final Map<ConstructIdentifier, int> distances =
|
||||
await compute(_computeDistancesInIsolate, {
|
||||
'lemmas': lemmas,
|
||||
'lemmas': constructIds,
|
||||
'target': token.lemma.text,
|
||||
});
|
||||
|
||||
|
|
@ -54,29 +53,53 @@ class LemmaActivityGenerator {
|
|||
final sortedLemmas = distances.keys.toList()
|
||||
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
|
||||
|
||||
// Take the shortest 4
|
||||
final choices = sortedLemmas.take(4).toSet();
|
||||
if (choices.isEmpty) {
|
||||
return {token.lemma.text};
|
||||
// Skip the first 7 lemmas (to avoid very similar and conjugated forms of verbs) if we have enough lemmas
|
||||
final int startIndex = sortedLemmas.length > 11 ? 7 : 0;
|
||||
|
||||
// Take up to 4 lemmas ensuring uniqueness by lemma text
|
||||
final List<ConstructIdentifier> uniqueByLemma = [];
|
||||
for (int i = startIndex; i < sortedLemmas.length; i++) {
|
||||
final cid = sortedLemmas[i];
|
||||
if (!uniqueByLemma.any((c) => c.lemma == cid.lemma)) {
|
||||
uniqueByLemma.add(cid);
|
||||
if (uniqueByLemma.length == 4) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!choices.contains(token.lemma.text)) {
|
||||
choices.add(token.lemma.text);
|
||||
if (uniqueByLemma.isEmpty) {
|
||||
return {token.vocabConstructID};
|
||||
}
|
||||
return choices;
|
||||
|
||||
// Ensure the target lemma (token.vocabConstructID) is included while keeping unique lemma texts
|
||||
final int existingIndex = uniqueByLemma
|
||||
.indexWhere((c) => c.lemma == token.vocabConstructID.lemma);
|
||||
if (existingIndex >= 0) {
|
||||
uniqueByLemma[existingIndex] = token.vocabConstructID;
|
||||
} else {
|
||||
if (uniqueByLemma.length < 4) {
|
||||
uniqueByLemma.add(token.vocabConstructID);
|
||||
} else {
|
||||
uniqueByLemma[uniqueByLemma.length - 1] = token.vocabConstructID;
|
||||
}
|
||||
}
|
||||
|
||||
//shuffle so correct answer isn't always first
|
||||
uniqueByLemma.shuffle();
|
||||
|
||||
return uniqueByLemma.toSet();
|
||||
}
|
||||
|
||||
// isolate helper function
|
||||
static Map<String, int> _computeDistancesInIsolate(
|
||||
static Map<ConstructIdentifier, int> _computeDistancesInIsolate(
|
||||
Map<String, dynamic> params,
|
||||
) {
|
||||
final List<String> lemmas = params['lemmas'];
|
||||
final List<ConstructIdentifier> lemmas = params['lemmas'];
|
||||
final String target = params['target'];
|
||||
|
||||
// Calculate Levenshtein distances
|
||||
final Map<String, int> distances = {};
|
||||
final Map<ConstructIdentifier, int> distances = {};
|
||||
for (final lemma in lemmas) {
|
||||
distances[lemma] = _levenshteinDistanceSync(target, lemma);
|
||||
distances[lemma] = _levenshteinDistanceSync(target, lemma.lemma);
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,6 @@ class MessageActivityRequest {
|
|||
final String userL1;
|
||||
final String userL2;
|
||||
|
||||
final String messageText;
|
||||
final List<PangeaToken> messageTokens;
|
||||
|
||||
final List<PangeaToken> targetTokens;
|
||||
final ActivityTypeEnum targetType;
|
||||
final MorphFeaturesEnum? targetMorphFeature;
|
||||
|
|
@ -63,8 +60,6 @@ class MessageActivityRequest {
|
|||
MessageActivityRequest({
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
required this.messageText,
|
||||
required this.messageTokens,
|
||||
required this.activityQualityFeedback,
|
||||
required this.targetTokens,
|
||||
required this.targetType,
|
||||
|
|
@ -79,8 +74,6 @@ class MessageActivityRequest {
|
|||
return {
|
||||
'user_l1': userL1,
|
||||
'user_l2': userL2,
|
||||
'message_text': messageText,
|
||||
'message_tokens': messageTokens.map((e) => e.toJson()).toList(),
|
||||
'activity_quality_feedback': activityQualityFeedback?.toJson(),
|
||||
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
|
||||
'target_type': targetType.name,
|
||||
|
|
@ -93,7 +86,6 @@ class MessageActivityRequest {
|
|||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MessageActivityRequest &&
|
||||
other.messageText == messageText &&
|
||||
other.targetType == targetType &&
|
||||
other.activityQualityFeedback?.feedbackText ==
|
||||
activityQualityFeedback?.feedbackText &&
|
||||
|
|
@ -103,8 +95,7 @@ class MessageActivityRequest {
|
|||
|
||||
@override
|
||||
int get hashCode {
|
||||
return messageText.hashCode ^
|
||||
targetType.hashCode ^
|
||||
return targetType.hashCode ^
|
||||
activityQualityFeedback.hashCode ^
|
||||
targetTokens.hashCode ^
|
||||
targetMorphFeature.hashCode;
|
||||
|
|
|
|||
|
|
@ -53,8 +53,7 @@ class PracticeActivityModel {
|
|||
);
|
||||
|
||||
bool onMultipleChoiceSelect(
|
||||
PangeaToken token,
|
||||
PracticeChoice choice,
|
||||
String choiceContent,
|
||||
) {
|
||||
if (multipleChoiceContent == null) {
|
||||
debugger(when: kDebugMode);
|
||||
|
|
@ -68,24 +67,23 @@ class PracticeActivityModel {
|
|||
|
||||
if (practiceTarget.isComplete ||
|
||||
practiceTarget.record.alreadyHasMatchResponse(
|
||||
choice.form.cId,
|
||||
choice.choiceContent,
|
||||
targetTokens.first.vocabConstructID,
|
||||
choiceContent,
|
||||
)) {
|
||||
// the user has already selected this choice
|
||||
// so we don't want to record it again
|
||||
return false;
|
||||
}
|
||||
|
||||
final bool isCorrect =
|
||||
multipleChoiceContent!.isCorrect(choice.choiceContent);
|
||||
final bool isCorrect = multipleChoiceContent!.isCorrect(choiceContent);
|
||||
|
||||
// NOTE: the response is associated with the contructId of the choice, not the selected token
|
||||
// example: the user selects the word "cat" to match with the emoji 🐶
|
||||
// the response is associated with correct word "dog", not the word "cat"
|
||||
practiceTarget.record.addResponse(
|
||||
cId: choice.form.cId,
|
||||
cId: targetTokens.first.vocabConstructID,
|
||||
target: practiceTarget,
|
||||
text: choice.choiceContent,
|
||||
text: choiceContent,
|
||||
score: isCorrect ? 1 : 0,
|
||||
);
|
||||
|
||||
|
|
@ -165,8 +163,7 @@ class PracticeActivityModel {
|
|||
|
||||
return PracticeActivityModel(
|
||||
langCode: json['lang_code'] as String,
|
||||
activityType:
|
||||
ActivityTypeEnum.wordMeaning.fromString(json['activity_type']),
|
||||
activityType: ActivityTypeEnum.fromString(json['activity_type']),
|
||||
multipleChoiceContent: json['content'] != null
|
||||
? MultipleChoiceActivity.fromJson(contentMap)
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d
|
|||
import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart';
|
||||
import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart';
|
||||
import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// Represents an item in the completion cache.
|
||||
|
|
@ -70,7 +72,7 @@ class PracticeRepo {
|
|||
messageInfo: messageInfo,
|
||||
);
|
||||
|
||||
_setCached(req, res);
|
||||
await _setCached(req, res);
|
||||
return Result.value(res.activity);
|
||||
} on HttpException catch (e, s) {
|
||||
return Result.error(e, s);
|
||||
|
|
@ -119,6 +121,10 @@ class PracticeRepo {
|
|||
return EmojiActivityGenerator.get(req, messageInfo: messageInfo);
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return LemmaActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return VocabMeaningActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return VocabAudioActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.morphId:
|
||||
return MorphActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
|
|
@ -161,16 +167,15 @@ class PracticeRepo {
|
|||
return null;
|
||||
}
|
||||
|
||||
static void _setCached(
|
||||
static Future<void> _setCached(
|
||||
MessageActivityRequest req,
|
||||
MessageActivityResponse res,
|
||||
) {
|
||||
_storage.write(
|
||||
req.hashCode.toString(),
|
||||
_RequestCacheItem(
|
||||
practiceActivity: res.activity,
|
||||
timestamp: DateTime.now(),
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
) =>
|
||||
_storage.write(
|
||||
req.hashCode.toString(),
|
||||
_RequestCacheItem(
|
||||
practiceActivity: res.activity,
|
||||
timestamp: DateTime.now(),
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,9 +100,6 @@ class PracticeController with ChangeNotifier {
|
|||
final req = MessageActivityRequest(
|
||||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
messageText: pangeaMessageEvent.messageDisplayText,
|
||||
messageTokens:
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens ?? [],
|
||||
activityQualityFeedback: null,
|
||||
targetTokens: target.tokens,
|
||||
targetType: target.activityType,
|
||||
|
|
@ -156,7 +153,9 @@ class PracticeController with ChangeNotifier {
|
|||
if (_activity == null) return;
|
||||
|
||||
final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId
|
||||
? _activity!.onMultipleChoiceSelect(token, choice)
|
||||
? _activity!.onMultipleChoiceSelect(
|
||||
choice.choiceContent,
|
||||
)
|
||||
: _activity!.onMatch(token, choice);
|
||||
|
||||
final targetId =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart';
|
||||
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// Displays an audio button with a select label in a row layout
|
||||
/// TODO: needs a better design and button handling
|
||||
class AudioChoiceCard extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
final bool isCorrect;
|
||||
final double height;
|
||||
final bool isEnabled;
|
||||
|
||||
const AudioChoiceCard({
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
required this.isCorrect,
|
||||
this.height = 72.0,
|
||||
this.isEnabled = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GameChoiceCard(
|
||||
shouldFlip: false,
|
||||
transformId: text,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: height,
|
||||
isEnabled: isEnabled,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: WordAudioButton(
|
||||
text: text,
|
||||
uniqueID: "vocab_practice_choice_$text",
|
||||
langCode:
|
||||
MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
),
|
||||
),
|
||||
Text(L10n.of(context).select),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/pangea/vocab_practice/choice_cards/game_choice_card.dart
Normal file
186
lib/pangea/vocab_practice/choice_cards/game_choice_card.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets
|
||||
class GameChoiceCard extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Widget? altChild;
|
||||
final VoidCallback onPressed;
|
||||
final bool isCorrect;
|
||||
final double height;
|
||||
final bool shouldFlip;
|
||||
final String? transformId;
|
||||
final bool isEnabled;
|
||||
|
||||
const GameChoiceCard({
|
||||
required this.child,
|
||||
this.altChild,
|
||||
required this.onPressed,
|
||||
required this.isCorrect,
|
||||
this.height = 72.0,
|
||||
this.shouldFlip = false,
|
||||
this.transformId,
|
||||
this.isEnabled = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GameChoiceCard> createState() => _GameChoiceCardState();
|
||||
}
|
||||
|
||||
class _GameChoiceCardState extends State<GameChoiceCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnim;
|
||||
bool _flipped = false;
|
||||
bool _isHovered = false;
|
||||
bool _useAltChild = false;
|
||||
bool _clicked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.shouldFlip) {
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnim = Tween<double>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.shouldFlip) {
|
||||
_controller.removeListener(_onAnimationUpdate);
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
if (!widget.isEnabled) return;
|
||||
|
||||
if (widget.shouldFlip) {
|
||||
if (_flipped) return;
|
||||
// Animate forward (shrink), then reverse (expand)
|
||||
await _controller.forward();
|
||||
await _controller.reverse();
|
||||
} else {
|
||||
if (_clicked) return;
|
||||
setState(() => _clicked = true);
|
||||
}
|
||||
|
||||
widget.onPressed();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final Color baseColor = colorScheme.surfaceContainerHighest;
|
||||
final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08);
|
||||
final Color 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
|
||||
? tintColor
|
||||
: (_isHovered ? hoverColor : Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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
|
||||
? tintColor
|
||||
: (_isHovered ? hoverColor : Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
|
||||
|
||||
/// Choice card for meaning activity with emoji, and alt text on flip
|
||||
class MeaningChoiceCard extends StatelessWidget {
|
||||
final String choiceId;
|
||||
final String displayText;
|
||||
final String? emoji;
|
||||
final VoidCallback onPressed;
|
||||
final bool isCorrect;
|
||||
final double height;
|
||||
final bool isEnabled;
|
||||
|
||||
const MeaningChoiceCard({
|
||||
required this.choiceId,
|
||||
required this.displayText,
|
||||
this.emoji,
|
||||
required this.onPressed,
|
||||
required this.isCorrect,
|
||||
this.height = 72.0,
|
||||
this.isEnabled = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseTextSize =
|
||||
(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) *
|
||||
(height / 72.0).clamp(1.0, 1.4);
|
||||
final emojiSize = baseTextSize * 1.2;
|
||||
|
||||
return GameChoiceCard(
|
||||
shouldFlip: true,
|
||||
transformId: choiceId,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: height,
|
||||
isEnabled: isEnabled,
|
||||
altChild: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (emoji != null && emoji!.isNotEmpty)
|
||||
SizedBox(
|
||||
width: height * .7,
|
||||
height: height,
|
||||
child: Center(
|
||||
child: Text(
|
||||
emoji!,
|
||||
style: TextStyle(fontSize: emojiSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
ConstructIdentifier.fromString(choiceId)!.lemma,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: baseTextSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (emoji != null && emoji!.isNotEmpty)
|
||||
SizedBox(
|
||||
width: height * .7,
|
||||
height: height,
|
||||
child: Center(
|
||||
child: Text(
|
||||
emoji!,
|
||||
style: TextStyle(fontSize: emojiSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: baseTextSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
292
lib/pangea/vocab_practice/completed_activity_session_view.dart
Normal file
292
lib/pangea/vocab_practice/completed_activity_session_view.dart
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
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_page.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:fluffychat/widgets/mxc_image.dart';
|
||||
|
||||
class CompletedActivitySessionView extends StatefulWidget {
|
||||
final VocabPracticeState controller;
|
||||
const CompletedActivitySessionView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
State<CompletedActivitySessionView> createState() =>
|
||||
_CompletedActivitySessionViewState();
|
||||
}
|
||||
|
||||
class _CompletedActivitySessionViewState
|
||||
extends State<CompletedActivitySessionView> {
|
||||
late final Future<Map<String, double>> 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<String, double> 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
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<Map<String, double>>(
|
||||
future: progressChangeFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Initialize progress when data is available
|
||||
if (currentProgress == 0.0 && !shouldShowRain) {
|
||||
_onProgressChangeLoaded(snapshot.data!);
|
||||
}
|
||||
|
||||
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: 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),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (shouldShowRain)
|
||||
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 {
|
||||
if (elapsedSeconds <= timeForBonus) return 5;
|
||||
if (elapsedSeconds <= timeForBonus * 1.5) return 4;
|
||||
if (elapsedSeconds <= timeForBonus * 2) return 3;
|
||||
if (elapsedSeconds <= timeForBonus * 2.5) return 2;
|
||||
return 1; // anything above 2.5x timeForBonus
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
5,
|
||||
(index) => Icon(
|
||||
index < starCount ? Icons.star : Icons.star_outline,
|
||||
color: AppConfig.goldLight,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/pangea/vocab_practice/percent_marker_bar.dart
Normal file
71
lib/pangea/vocab_practice/percent_marker_bar.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
|
||||
// A progress bar with a rounded marker indicating a percentage position
|
||||
|
||||
class PercentMarkerBar extends StatelessWidget {
|
||||
final double height;
|
||||
final double widthPercent;
|
||||
final double markerWidth;
|
||||
final Color markerColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const PercentMarkerBar({
|
||||
required this.height,
|
||||
required this.widthPercent,
|
||||
this.markerWidth = 10.0,
|
||||
this.markerColor = AppConfig.goldLight,
|
||||
this.backgroundColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
final halfMarker = markerWidth / 2;
|
||||
|
||||
// Calculate the center position of the marker
|
||||
final targetPosition = totalWidth * widthPercent.clamp(0.0, 1.0);
|
||||
|
||||
// Calculate the start position, clamping to keep marker within bounds
|
||||
final markerStart =
|
||||
(targetPosition - halfMarker).clamp(0.0, totalWidth - markerWidth);
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
// Background bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Container(
|
||||
height: height,
|
||||
width: constraints.maxWidth,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
color: backgroundColor ??
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Marker circle
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(left: markerStart),
|
||||
height: height,
|
||||
width: markerWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: markerColor,
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/pangea/vocab_practice/stat_card.dart
Normal file
70
lib/pangea/vocab_practice/stat_card.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
|
||||
class StatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final String achievementText;
|
||||
final Widget child;
|
||||
final bool isAchievement;
|
||||
|
||||
const StatCard({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.achievementText,
|
||||
required this.child,
|
||||
this.isAchievement = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final backgroundColor = isAchievement
|
||||
? Color.alphaBlend(
|
||||
Theme.of(context).colorScheme.surface.withAlpha(170),
|
||||
AppConfig.goldLight,
|
||||
)
|
||||
: colorScheme.surfaceContainer;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (isAchievement) ...[
|
||||
const Spacer(),
|
||||
Text(
|
||||
achievementText,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.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';
|
||||
|
||||
class VocabAudioActivityGenerator {
|
||||
static Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final token = req.targetTokens.first;
|
||||
final choices =
|
||||
await LemmaActivityGenerator.lemmaActivityDistractors(token);
|
||||
|
||||
final choicesList = choices.map((c) => c.lemma).toList();
|
||||
choicesList.shuffle();
|
||||
|
||||
return MessageActivityResponse(
|
||||
activity: PracticeActivityModel(
|
||||
activityType: req.targetType,
|
||||
targetTokens: [token],
|
||||
langCode: req.userL2,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
choices: choicesList.toSet(),
|
||||
answers: {token.lemma.text},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.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';
|
||||
|
||||
class VocabMeaningActivityGenerator {
|
||||
static Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final token = req.targetTokens.first;
|
||||
final choices =
|
||||
await LemmaActivityGenerator.lemmaActivityDistractors(token);
|
||||
|
||||
if (!choices.contains(token.vocabConstructID)) {
|
||||
choices.add(token.vocabConstructID);
|
||||
}
|
||||
|
||||
final Set<String> constructIdChoices = choices.map((c) => c.string).toSet();
|
||||
|
||||
return MessageActivityResponse(
|
||||
activity: PracticeActivityModel(
|
||||
activityType: req.targetType,
|
||||
targetTokens: [token],
|
||||
langCode: req.userL2,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
choices: constructIdChoices,
|
||||
answers: {token.vocabConstructID.string},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
430
lib/pangea/vocab_practice/vocab_practice_page.dart
Normal file
430
lib/pangea/vocab_practice/vocab_practice_page.dart
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.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/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/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.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 SessionLoader extends AsyncLoader<VocabPracticeSessionModel> {
|
||||
@override
|
||||
Future<VocabPracticeSessionModel> fetch() =>
|
||||
VocabPracticeSessionRepo.currentSession;
|
||||
}
|
||||
|
||||
class VocabPractice extends StatefulWidget {
|
||||
const VocabPractice({super.key});
|
||||
|
||||
@override
|
||||
VocabPracticeState createState() => VocabPracticeState();
|
||||
}
|
||||
|
||||
class VocabPracticeState extends State<VocabPractice> {
|
||||
SessionLoader sessionLoader = SessionLoader();
|
||||
PracticeActivityModel? currentActivity;
|
||||
bool isLoadingActivity = true;
|
||||
bool isAwaitingNextActivity = false;
|
||||
String? activityError;
|
||||
|
||||
bool isLoadingLemmaInfo = false;
|
||||
final Map<String, String> _choiceTexts = {};
|
||||
final Map<String, String?> _choiceEmojis = {};
|
||||
|
||||
StreamSubscription<void>? _languageStreamSubscription;
|
||||
bool _sessionClearedDueToLanguageChange = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startSession();
|
||||
_listenToLanguageChanges();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_languageStreamSubscription?.cancel();
|
||||
if (isComplete) {
|
||||
VocabPracticeSessionRepo.clearSession();
|
||||
} else if (!_sessionClearedDueToLanguageChange) {
|
||||
//don't save if session was cleared due to language change
|
||||
_saveCurrentTime();
|
||||
}
|
||||
sessionLoader.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _saveCurrentTime() {
|
||||
if (sessionLoader.isLoaded) {
|
||||
VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<void> _waitForAnalytics() async {
|
||||
if (!MatrixState.pangeaController.matrixState.analyticsDataService
|
||||
.initCompleter.isCompleted) {
|
||||
MatrixState.pangeaController.initControllers();
|
||||
await MatrixState.pangeaController.matrixState.analyticsDataService
|
||||
.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<void> _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<bool> _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<void> completeActivitySession() async {
|
||||
if (!sessionLoader.isLoaded) return;
|
||||
|
||||
_saveCurrentTime();
|
||||
sessionLoader.value!.finishSession();
|
||||
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> 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();
|
||||
},
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<InlineSpan>?> 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),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> loadActivity() async {
|
||||
if (!sessionLoader.isLoaded) {
|
||||
try {
|
||||
await sessionLoader.completer.future;
|
||||
} catch (_) {
|
||||
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;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await PracticeRepo.getPracticeActivity(
|
||||
activityRequest,
|
||||
messageInfo: {},
|
||||
);
|
||||
if (result.isError) {
|
||||
activityError = L10n.of(context).oopsSomethingWentWrong;
|
||||
} else {
|
||||
currentActivity = result.result!;
|
||||
}
|
||||
|
||||
// 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 (mounted) {
|
||||
setState(() => isLoadingActivity = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onSelectChoice(String choice) async {
|
||||
if (currentActivity == null) return;
|
||||
final activity = currentActivity!;
|
||||
|
||||
activity.onMultipleChoiceSelect(choice);
|
||||
final correct = activity.multipleChoiceContent!.isCorrect(choice);
|
||||
|
||||
// Submit answer immediately (records use and gives XP)
|
||||
sessionLoader.value!.submitAnswer(activity, correct);
|
||||
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
|
||||
|
||||
final transformTargetId =
|
||||
'vocab-choice-card-${choice.replaceAll(' ', '_')}';
|
||||
if (correct) {
|
||||
OverlayUtil.showPointsGained(transformTargetId, 5, context);
|
||||
} else {
|
||||
OverlayUtil.showPointsGained(transformTargetId, -2, context);
|
||||
}
|
||||
if (!correct) return;
|
||||
|
||||
// display the fact that the choice was correct before loading the next activity
|
||||
setState(() => isAwaitingNextActivity = true);
|
||||
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!);
|
||||
|
||||
if (isComplete) {
|
||||
await completeActivitySession();
|
||||
}
|
||||
|
||||
await loadActivity();
|
||||
}
|
||||
|
||||
Future<Map<String, double>> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@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<void> _prefetchLemmaInfo(List<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => isLoadingLemmaInfo = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
253
lib/pangea/vocab_practice/vocab_practice_session_model.dart
Normal file
253
lib/pangea/vocab_practice/vocab_practice_session_model.dart
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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';
|
||||
|
||||
class VocabPracticeSessionModel {
|
||||
final DateTime startedAt;
|
||||
final List<ConstructIdentifier> sortedConstructIds;
|
||||
final List<ActivityTypeEnum> activityTypes;
|
||||
final String userL1;
|
||||
final String userL2;
|
||||
|
||||
int currentIndex;
|
||||
int currentGroup;
|
||||
|
||||
final List<OneConstructUse> completedUses;
|
||||
bool finished;
|
||||
int elapsedSeconds;
|
||||
|
||||
VocabPracticeSessionModel({
|
||||
required this.startedAt,
|
||||
required this.sortedConstructIds,
|
||||
required this.activityTypes,
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
required this.completedUses,
|
||||
this.currentIndex = 0,
|
||||
this.currentGroup = 0,
|
||||
this.finished = false,
|
||||
this.elapsedSeconds = 0,
|
||||
}) : assert(
|
||||
activityTypes.every(
|
||||
(t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio}
|
||||
.contains(t),
|
||||
),
|
||||
),
|
||||
assert(
|
||||
activityTypes.length == practiceGroupSize,
|
||||
);
|
||||
|
||||
static const int practiceGroupSize = 10;
|
||||
|
||||
int get currentAvailableActivities => min(
|
||||
((currentGroup + 1) * practiceGroupSize),
|
||||
sortedConstructIds.length,
|
||||
);
|
||||
|
||||
bool get hasCompletedCurrentGroup =>
|
||||
currentIndex >= currentAvailableActivities;
|
||||
|
||||
int get timeForBonus => 60;
|
||||
|
||||
double get progress =>
|
||||
(currentIndex / currentAvailableActivities).clamp(0.0, 1.0);
|
||||
|
||||
List<ConstructIdentifier> get currentPracticeGroup => sortedConstructIds
|
||||
.skip(currentGroup * practiceGroupSize)
|
||||
.take(practiceGroupSize)
|
||||
.toList();
|
||||
|
||||
ConstructIdentifier? get currentConstructId {
|
||||
if (currentIndex < 0 || hasCompletedCurrentGroup) {
|
||||
return null;
|
||||
}
|
||||
return currentPracticeGroup[currentIndex % practiceGroupSize];
|
||||
}
|
||||
|
||||
ActivityTypeEnum? get currentActivityType {
|
||||
if (currentIndex < 0 || hasCompletedCurrentGroup) {
|
||||
return null;
|
||||
}
|
||||
return activityTypes[currentIndex % practiceGroupSize];
|
||||
}
|
||||
|
||||
MessageActivityRequest? get currentActivityRequest {
|
||||
final constructId = currentConstructId;
|
||||
if (constructId == null || currentActivityType == null) return null;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp);
|
||||
|
||||
double get accuracy {
|
||||
if (completedUses.isEmpty) return 0.0;
|
||||
final correct = completedUses.where((use) => use.xp > 0).length;
|
||||
final result = correct / completedUses.length;
|
||||
return (result * 100).truncateToDouble();
|
||||
}
|
||||
|
||||
void finishSession() {
|
||||
finished = true;
|
||||
|
||||
// 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();
|
||||
|
||||
MatrixState
|
||||
.pangeaController.matrixState.analyticsDataService.updateService
|
||||
.addAnalytics(
|
||||
null,
|
||||
bonusUses,
|
||||
);
|
||||
}
|
||||
|
||||
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<String, dynamic> json) {
|
||||
return VocabPracticeSessionModel(
|
||||
startedAt: DateTime.parse(json['startedAt'] as String),
|
||||
sortedConstructIds: (json['sortedConstructIds'] as List<dynamic>)
|
||||
.map((e) => ConstructIdentifier.fromJson(e))
|
||||
.whereType<ConstructIdentifier>()
|
||||
.toList(),
|
||||
activityTypes: (json['activityTypes'] as List<dynamic>)
|
||||
.map(
|
||||
(e) => ActivityTypeEnum.values.firstWhere(
|
||||
(at) => at.name == (e as String),
|
||||
),
|
||||
)
|
||||
.whereType<ActivityTypeEnum>()
|
||||
.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<dynamic>?)
|
||||
?.map((e) => OneConstructUse.fromJson(e))
|
||||
.whereType<OneConstructUse>()
|
||||
.toList() ??
|
||||
[],
|
||||
finished: json['finished'] as bool? ?? false,
|
||||
elapsedSeconds: json['elapsedSeconds'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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(),
|
||||
'finished': finished,
|
||||
'elapsedSeconds': elapsedSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
102
lib/pangea/vocab_practice/vocab_practice_session_repo.dart
Normal file
102
lib/pangea/vocab_practice/vocab_practice_session_repo.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:math';
|
||||
|
||||
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/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<VocabPracticeSessionModel> get currentSession async {
|
||||
final cached = _getCached();
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final r = Random();
|
||||
final activityTypes = [
|
||||
ActivityTypeEnum.lemmaMeaning,
|
||||
//ActivityTypeEnum.lemmaAudio,
|
||||
];
|
||||
|
||||
final types = List.generate(
|
||||
VocabPracticeSessionModel.practiceGroupSize,
|
||||
(_) => activityTypes[r.nextInt(activityTypes.length)],
|
||||
);
|
||||
|
||||
final targets = await _fetch();
|
||||
final session = VocabPracticeSessionModel(
|
||||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
startedAt: DateTime.now(),
|
||||
sortedConstructIds: targets,
|
||||
activityTypes: types,
|
||||
completedUses: [],
|
||||
);
|
||||
await _setCached(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
static Future<void> updateSession(
|
||||
VocabPracticeSessionModel session,
|
||||
) =>
|
||||
_setCached(session);
|
||||
|
||||
static Future<VocabPracticeSessionModel> reloadSession() async {
|
||||
_storage.erase();
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
static Future<void> clearSession() => _storage.erase();
|
||||
|
||||
static Future<List<ConstructIdentifier>> _fetch() async {
|
||||
final constructs = await MatrixState
|
||||
.pangeaController.matrixState.analyticsDataService
|
||||
.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<ConstructIdentifier, DateTime?> constructLastUseMap = {};
|
||||
final List<ConstructIdentifier> 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];
|
||||
if (dateA == null && dateB == null) return 0;
|
||||
if (dateA == null) return -1;
|
||||
if (dateB == null) return 1;
|
||||
return dateA.compareTo(dateB);
|
||||
});
|
||||
|
||||
return sortedTargetIds;
|
||||
}
|
||||
|
||||
static VocabPracticeSessionModel? _getCached() {
|
||||
final keys = List<String>.from(_storage.getKeys());
|
||||
if (keys.isEmpty) return null;
|
||||
try {
|
||||
final json = _storage.read(keys.first) as Map<String, dynamic>;
|
||||
return VocabPracticeSessionModel.fromJson(json);
|
||||
} catch (e) {
|
||||
_storage.remove(keys.first);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _setCached(VocabPracticeSessionModel session) async {
|
||||
await _storage.erase();
|
||||
await _storage.write(
|
||||
session.startedAt.toIso8601String(),
|
||||
session.toJson(),
|
||||
);
|
||||
}
|
||||
}
|
||||
334
lib/pangea/vocab_practice/vocab_practice_view.dart
Normal file
334
lib/pangea/vocab_practice/vocab_practice_view.dart
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
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/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';
|
||||
import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.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/pangea/vocab_practice/vocab_timer_widget.dart';
|
||||
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
|
||||
|
||||
class VocabPracticeView extends StatelessWidget {
|
||||
final VocabPracticeState controller;
|
||||
|
||||
const VocabPracticeView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
//keep track of state to update timer
|
||||
ValueListenableBuilder(
|
||||
valueListenable: controller.sessionLoader.state,
|
||||
builder: (context, state, __) {
|
||||
if (state is AsyncLoaded<VocabPracticeSessionModel>) {
|
||||
return VocabTimerWidget(
|
||||
key: ValueKey(state.value.startedAt),
|
||||
initialSeconds: state.value.elapsedSeconds,
|
||||
onTimeUpdate: controller.updateElapsedTime,
|
||||
isRunning: !controller.isComplete,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: MaxWidthBody(
|
||||
withScrolling: false,
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
showBorder: false,
|
||||
child: Expanded(
|
||||
child: controller.isComplete
|
||||
? CompletedActivitySessionView(controller)
|
||||
: _OngoingActivitySessionView(controller),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<VocabPracticeSessionModel>(:final error) =>
|
||||
ErrorIndicator(message: error.toString()),
|
||||
AsyncLoaded<VocabPracticeSessionModel>(: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,
|
||||
);
|
||||
|
||||
@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),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ExampleMessageWidget(controller, constructId),
|
||||
Flexible(
|
||||
child: _ActivityChoicesWidget(
|
||||
controller,
|
||||
activityType,
|
||||
constructId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExampleMessageWidget extends StatelessWidget {
|
||||
final VocabPracticeState controller;
|
||||
final ConstructIdentifier constructId;
|
||||
|
||||
const _ExampleMessageWidget(this.controller, this.constructId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<InlineSpan>?>(
|
||||
future: controller.getExampleMessage(constructId),
|
||||
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,
|
||||
),
|
||||
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 _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(choiceId),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChoiceCard({
|
||||
required activity,
|
||||
required String choiceId,
|
||||
required double cardHeight,
|
||||
required bool isEnabled,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId);
|
||||
|
||||
switch (activity.activityType) {
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return MeaningChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_meaning_$choiceId',
|
||||
),
|
||||
choiceId: choiceId,
|
||||
displayText: controller.getChoiceText(choiceId),
|
||||
emoji: controller.getChoiceEmoji(choiceId),
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: isEnabled,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return AudioChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_audio_$choiceId',
|
||||
),
|
||||
text: choiceId,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: isEnabled,
|
||||
);
|
||||
|
||||
default:
|
||||
return GameChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_basic_$choiceId',
|
||||
),
|
||||
shouldFlip: false,
|
||||
transformId: choiceId,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: isEnabled,
|
||||
child: Text(controller.getChoiceText(choiceId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
lib/pangea/vocab_practice/vocab_timer_widget.dart
Normal file
91
lib/pangea/vocab_practice/vocab_timer_widget.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class VocabTimerWidget extends StatefulWidget {
|
||||
final int initialSeconds;
|
||||
final ValueChanged<int> onTimeUpdate;
|
||||
final bool isRunning;
|
||||
|
||||
const VocabTimerWidget({
|
||||
required this.initialSeconds,
|
||||
required this.onTimeUpdate,
|
||||
this.isRunning = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
VocabTimerWidgetState createState() => VocabTimerWidgetState();
|
||||
}
|
||||
|
||||
class VocabTimerWidgetState extends State<VocabTimerWidget> {
|
||||
final Stopwatch _stopwatch = Stopwatch();
|
||||
late int _initialSeconds;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialSeconds = widget.initialSeconds;
|
||||
if (widget.isRunning) {
|
||||
_startTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VocabTimerWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isRunning && !widget.isRunning) {
|
||||
_stopTimer();
|
||||
} else if (!oldWidget.isRunning && widget.isRunning) {
|
||||
_startTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_stopwatch.start();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final currentSeconds = _getCurrentSeconds();
|
||||
setState(() {});
|
||||
widget.onTimeUpdate(currentSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_stopwatch.stop();
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
int _getCurrentSeconds() {
|
||||
return _initialSeconds + (_stopwatch.elapsedMilliseconds / 1000).round();
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.alarm, size: 20),
|
||||
const SizedBox(width: 4.0),
|
||||
Text(
|
||||
_formatTime(_getCurrentSeconds()),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue