Vocab practice updates (#5180)

* reorganization, reload on language change

* make choice card widget

* make completed activity view stateless

* use analytics updater mixin to display points gained animation

* simplify animation in game card

* better encapsulate practice session data

* reset session loader instead of dispose

* simplify practice session model

* queue activities

* visually remove duplicate answers without editing activity content

* review updates

* don't shuffle filtered choices
This commit is contained in:
ggurdin 2026-01-14 12:54:27 -05:00 committed by GitHub
parent 8c2cd7d022
commit 8a8ca1026a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1046 additions and 1100 deletions

View file

@ -6,10 +6,12 @@ import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -177,4 +179,38 @@ extension AnalyticsClientExtension on Client {
)
.isNotEmpty;
}
Future<PangeaMessageEvent?> getEventByConstructUse(
OneConstructUse use,
) async {
if (use.metadata.eventId == null || use.metadata.roomId == null) {
return null;
}
final room = getRoomById(use.metadata.roomId!);
if (room == null) return null;
try {
final event = await room.getEventById(use.metadata.eventId!);
if (event == null) return null;
final timeline = await room.getTimeline();
return PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId == userID,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": use.metadata.roomId,
"eventID": use.metadata.eventId,
"userID": userID,
},
);
return null;
}
}
}

View file

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
class ExampleMessageUtil {
static Future<List<InlineSpan>?> getExampleMessage(
ConstructUses construct,
Client client,
) async {
for (final use in construct.cappedUses) {
final event = await client.getEventByConstructUse(use);
if (event == null) continue;
final spans = _buildExampleMessage(use.form, event);
if (spans != null) return spans;
}
return null;
}
static Future<List<List<InlineSpan>>> getExampleMessages(
ConstructUses construct,
Client client,
int maxMessages,
) async {
final List<List<InlineSpan>> allSpans = [];
for (final use in construct.cappedUses) {
if (allSpans.length >= maxMessages) break;
final event = await client.getEventByConstructUse(use);
if (event == null) continue;
final spans = _buildExampleMessage(use.form, event);
if (spans != null) {
allSpans.add(spans);
}
}
return allSpans;
}
static List<InlineSpan>? _buildExampleMessage(
String? form,
PangeaMessageEvent messageEvent,
) {
final tokens = messageEvent.messageDisplayRepresentation?.tokens;
if (tokens == null || tokens.isEmpty) return null;
final token = tokens.firstWhereOrNull(
(token) => token.text.content == form,
);
if (token == null) return null;
final text = messageEvent.messageDisplayText;
final tokenText = token.text.content;
int tokenIndex = text.indexOf(tokenText);
if (tokenIndex == -1) return null;
final beforeSubstring = text.substring(0, tokenIndex);
if (beforeSubstring.length != beforeSubstring.characters.length) {
tokenIndex = beforeSubstring.characters.length;
}
final int tokenLength = tokenText.characters.length;
final before = text.characters.take(tokenIndex).toString();
final after = text.characters.skip(tokenIndex + tokenLength).toString();
return [
TextSpan(text: before),
TextSpan(
text: tokenText,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: after),
];
}
}

View file

@ -72,7 +72,7 @@ abstract class AsyncLoader<T> {
T? get value => isLoaded ? (state.value as AsyncLoaded<T>).value : null;
final Completer<T> completer = Completer<T>();
Completer<T> completer = Completer<T>();
void dispose() {
_disposed = true;
@ -109,4 +109,10 @@ abstract class AsyncLoader<T> {
}
}
}
void reset() {
if (_disposed) return;
state.value = AsyncState.idle();
completer = Completer<T>();
}
}

View file

@ -11,7 +11,10 @@ import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/languages/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
@ -190,4 +193,15 @@ class ConstructIdentifier {
category: category,
);
}
PangeaToken get asToken => PangeaToken(
lemma: Lemma(
text: lemma,
saveVocab: true,
form: lemma,
),
pos: category,
text: PangeaTokenText.fromString(lemma),
morph: {},
);
}

View file

@ -89,10 +89,6 @@ class LemmaInfoRepo {
_cache.remove(key);
}
static void clearAllCache() {
_cache.clear();
}
static Future<Result<LemmaInfoResponse>> _safeFetch(
String token,
LemmaInfoRequest request,

View file

@ -9,6 +9,7 @@ import 'package:fluffychat/widgets/matrix.dart';
/// TODO: needs a better design and button handling
class AudioChoiceCard extends StatelessWidget {
final String text;
final String targetId;
final VoidCallback onPressed;
final bool isCorrect;
final double height;
@ -16,6 +17,7 @@ class AudioChoiceCard extends StatelessWidget {
const AudioChoiceCard({
required this.text,
required this.targetId,
required this.onPressed,
required this.isCorrect,
this.height = 72.0,
@ -27,7 +29,7 @@ class AudioChoiceCard extends StatelessWidget {
Widget build(BuildContext context) {
return GameChoiceCard(
shouldFlip: false,
transformId: text,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: height,

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets
@ -11,17 +12,17 @@ class GameChoiceCard extends StatefulWidget {
final bool isCorrect;
final double height;
final bool shouldFlip;
final String? transformId;
final String targetId;
final bool isEnabled;
const GameChoiceCard({
required this.child,
this.altChild,
required this.onPressed,
required this.isCorrect,
required this.targetId,
this.altChild,
this.height = 72.0,
this.shouldFlip = false,
this.transformId,
this.isEnabled = true,
super.key,
});
@ -32,49 +33,30 @@ class GameChoiceCard extends StatefulWidget {
class _GameChoiceCardState extends State<GameChoiceCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
bool _flipped = false;
bool _isHovered = false;
bool _useAltChild = false;
late final AnimationController _controller;
late final Animation<double> _scaleAnim;
bool _clicked = false;
bool _revealed = false;
@override
void initState() {
super.initState();
if (widget.shouldFlip) {
_controller = AnimationController(
duration: const Duration(milliseconds: 220),
vsync: this,
);
_controller = AnimationController(
duration: const Duration(milliseconds: 220),
vsync: this,
);
_scaleAnim = Tween<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);
}
_scaleAnim = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
).drive(Tween(begin: 1.0, end: 0.0));
}
@override
void dispose() {
if (widget.shouldFlip) {
_controller.removeListener(_onAnimationUpdate);
_controller.dispose();
}
_controller.dispose();
super.dispose();
}
@ -82,9 +64,10 @@ class _GameChoiceCardState extends State<GameChoiceCard>
if (!widget.isEnabled) return;
if (widget.shouldFlip) {
if (_flipped) return;
// Animate forward (shrink), then reverse (expand)
if (_controller.isAnimating || _revealed) return;
await _controller.forward();
setState(() => _revealed = true);
await _controller.reverse();
} else {
if (_clicked) return;
@ -96,91 +79,90 @@ class _GameChoiceCardState extends State<GameChoiceCard>
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
final colorScheme = Theme.of(context).colorScheme;
final Color baseColor = colorScheme.surfaceContainerHighest;
final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08);
final Color tintColor = widget.isCorrect
final baseColor = colorScheme.surfaceContainerHighest;
final hoverColor = colorScheme.onSurface.withValues(alpha: 0.08);
final tintColor = widget.isCorrect
? AppConfig.success.withValues(alpha: 0.3)
: AppConfig.error.withValues(alpha: 0.3);
Widget card = MouseRegion(
onEnter:
widget.isEnabled ? ((_) => setState(() => _isHovered = true)) : null,
onExit:
widget.isEnabled ? ((_) => setState(() => _isHovered = false)) : null,
child: SizedBox(
width: double.infinity,
height: widget.height,
child: GestureDetector(
onTap: _handleTap,
child: widget.shouldFlip
? AnimatedBuilder(
animation: _scaleAnim,
builder: (context, child) {
final bool showContent = _scaleAnim.value > 0.1;
return Transform.scale(
scaleY: _scaleAnim.value,
child: Container(
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),
),
foregroundDecoration: BoxDecoration(
color: _flipped
return CompositedTransformTarget(
link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId).link,
child: HoverBuilder(
builder: (context, hovered) => SizedBox(
width: double.infinity,
height: widget.height,
child: GestureDetector(
onTap: _handleTap,
child: widget.shouldFlip
? AnimatedBuilder(
animation: _scaleAnim,
builder: (context, _) {
final scale = _scaleAnim.value;
final showAlt = scale < 0.1 && widget.altChild != null;
final showContent = scale > 0.05;
return Transform.scale(
scaleY: scale,
child: _CardContainer(
height: widget.height,
baseColor: baseColor,
overlayColor: _revealed
? tintColor
: (_isHovered ? hoverColor : Colors.transparent),
borderRadius: BorderRadius.circular(16),
: (hovered ? hoverColor : Colors.transparent),
child: Opacity(
opacity: showContent ? 1 : 0,
child: showAlt ? widget.altChild! : widget.child,
),
),
margin: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 0,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: widget.height,
alignment: Alignment.center,
child: Opacity(
opacity: showContent ? 1.0 : 0.0,
child: _useAltChild && widget.altChild != null
? widget.altChild!
: widget.child,
),
),
);
},
)
: Container(
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),
),
foregroundDecoration: BoxDecoration(
color: _clicked
);
},
)
: _CardContainer(
height: widget.height,
baseColor: baseColor,
overlayColor: _clicked
? tintColor
: (_isHovered ? hoverColor : Colors.transparent),
borderRadius: BorderRadius.circular(16),
: (hovered ? hoverColor : Colors.transparent),
child: widget.child,
),
margin:
const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: widget.height,
alignment: Alignment.center,
child: widget.child,
),
),
),
),
);
// Wrap with transform target if transformId is provided
if (widget.transformId != null) {
final transformTargetId =
'vocab-choice-card-${widget.transformId!.replaceAll(' ', '_')}';
card = CompositedTransformTarget(
link: MatrixState.pAnyState.layerLinkAndKey(transformTargetId).link,
child: card,
);
}
return card;
}
}
class _CardContainer extends StatelessWidget {
final double height;
final Color baseColor;
final Color overlayColor;
final Widget child;
const _CardContainer({
required this.height,
required this.baseColor,
required this.overlayColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.center,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),
),
foregroundDecoration: BoxDecoration(
color: overlayColor,
borderRadius: BorderRadius.circular(16),
),
child: child,
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.d
/// Choice card for meaning activity with emoji, and alt text on flip
class MeaningChoiceCard extends StatelessWidget {
final String choiceId;
final String targetId;
final String displayText;
final String? emoji;
final VoidCallback onPressed;
@ -15,6 +16,7 @@ class MeaningChoiceCard extends StatelessWidget {
const MeaningChoiceCard({
required this.choiceId,
required this.targetId,
required this.displayText,
this.emoji,
required this.onPressed,
@ -33,7 +35,7 @@ class MeaningChoiceCard extends StatelessWidget {
return GameChoiceCard(
shouldFlip: true,
transformId: choiceId,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: height,

View file

@ -6,59 +6,20 @@ import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/stat_card.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class CompletedActivitySessionView extends StatefulWidget {
class CompletedActivitySessionView extends StatelessWidget {
final VocabPracticeSessionModel session;
final VocabPracticeState controller;
const CompletedActivitySessionView(this.controller, {super.key});
@override
State<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;
});
}
});
}
const CompletedActivitySessionView(
this.session,
this.controller, {
super.key,
});
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
@ -70,204 +31,174 @@ class _CompletedActivitySessionViewState
Widget build(BuildContext context) {
final username =
Matrix.of(context).client.userID?.split(':').first.substring(1) ?? '';
final bool accuracyAchievement =
widget.controller.sessionLoader.value!.accuracy == 100;
final bool timeAchievement =
widget.controller.sessionLoader.value!.elapsedSeconds <= 60;
final int numBonusPoints = widget
.controller.sessionLoader.value!.completedUses
.where((use) => use.xp > 0)
.length;
//give double bonus for both, single for one, none for zero
final int bonusXp = (accuracyAchievement && timeAchievement)
? numBonusPoints * 2
: (accuracyAchievement || timeAchievement)
? numBonusPoints
: 0;
return FutureBuilder<Map<String, double>>(
future: progressChangeFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final double accuracy = session.state.accuracy;
final int elapsedSeconds = session.state.elapsedSeconds;
// Initialize progress when data is available
if (currentProgress == 0.0 && !shouldShowRain) {
_onProgressChangeLoaded(snapshot.data!);
}
final bool accuracyAchievement = accuracy == 100;
final bool timeAchievement = elapsedSeconds <= 60;
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16),
child: Column(
children: [
Text(
L10n.of(context).congratulationsYouveCompletedPractice,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16),
child: Column(
children: [
Text(
L10n.of(context).congratulationsYouveCompletedPractice,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: FutureBuilder(
future: Matrix.of(context).client.fetchOwnProfile(),
builder: (context, snapshot) {
final avatarUrl = snapshot.data?.avatarUrl;
return Avatar(
name: username,
showPresence: false,
size: 100,
mxContent: avatarUrl,
userId: Matrix.of(context).client.userID,
);
},
),
),
Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: avatarUrl == null
? Avatar(
name: username,
showPresence: false,
size: 100,
)
: ClipOval(
child: MxcImage(
uri: avatarUrl,
width: 100,
height: 100,
),
),
),
Column(
children: [
Padding(
padding: const EdgeInsets.only(
top: 16.0,
bottom: 16.0,
),
child: AnimatedProgressBar(
height: 20.0,
widthPercent: currentProgress,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
duration: const Duration(milliseconds: 500),
),
padding: const EdgeInsets.only(
top: 16.0,
bottom: 16.0,
),
child: FutureBuilder(
future: controller.derivedAnalyticsData,
builder: (context, snapshot) => AnimatedProgressBar(
height: 20.0,
widthPercent: snapshot.hasData
? snapshot.data!.levelProgress
: 0.0,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
duration: const Duration(milliseconds: 500),
),
Text(
"+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP",
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
),
),
Text(
"+ ${session.state.allXPGained} XP",
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppConfig.goldLight,
fontWeight: FontWeight.bold,
),
),
],
),
StatCard(
icon: Icons.my_location,
text:
"${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%",
isAchievement: accuracyAchievement,
achievementText: "+ $numBonusPoints XP",
child: PercentMarkerBar(
height: 20.0,
widthPercent: widget
.controller.sessionLoader.value!.accuracy /
100.0,
markerWidth: 20.0,
markerColor: AppConfig.success,
backgroundColor: !(widget.controller.sessionLoader
.value!.accuracy ==
100)
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
: Color.alphaBlend(
AppConfig.goldLight.withValues(alpha: 0.3),
Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
),
),
StatCard(
icon: Icons.alarm,
text:
"${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}",
isAchievement: timeAchievement,
achievementText: "+ $numBonusPoints XP",
child: TimeStarsWidget(
elapsedSeconds: widget
.controller.sessionLoader.value!.elapsedSeconds,
timeForBonus: widget
.controller.sessionLoader.value!.timeForBonus,
),
),
Column(
children: [
//expanded row button
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () =>
widget.controller.reloadSession(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).anotherRound,
),
],
),
),
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () => Navigator.of(context).pop(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).quit,
),
],
),
),
],
),
],
),
),
],
StatCard(
icon: Icons.my_location,
text: "${L10n.of(context).accuracy}: $accuracy%",
isAchievement: accuracyAchievement,
achievementText: "+ ${session.state.accuracyBonusXP} XP",
child: PercentMarkerBar(
height: 20.0,
widthPercent: accuracy / 100.0,
markerWidth: 20.0,
markerColor: AppConfig.success,
backgroundColor: !accuracyAchievement
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
: Color.alphaBlend(
AppConfig.goldLight.withValues(alpha: 0.3),
Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
),
),
StatCard(
icon: Icons.alarm,
text:
"${L10n.of(context).time}: ${_formatTime(elapsedSeconds)}",
isAchievement: timeAchievement,
achievementText: "+ ${session.state.timeBonusXP} XP",
child: TimeStarsWidget(
elapsedSeconds: elapsedSeconds,
),
),
Column(
children: [
//expanded row button
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () => controller.reloadSession(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).anotherRound,
),
],
),
),
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () => Navigator.of(context).pop(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).quit,
),
],
),
),
],
),
],
),
),
),
if (shouldShowRain)
const StarRainWidget(
showBlast: true,
rainDuration: Duration(seconds: 5),
),
],
);
},
],
),
),
const StarRainWidget(
showBlast: true,
rainDuration: Duration(seconds: 5),
),
],
);
}
}
class TimeStarsWidget extends StatelessWidget {
final int elapsedSeconds;
final int timeForBonus;
const TimeStarsWidget({
required this.elapsedSeconds,
required this.timeForBonus,
super.key,
});
int get starCount {
const timeForBonus = VocabPracticeConstants.timeForBonus;
if (elapsedSeconds <= timeForBonus) return 5;
if (elapsedSeconds <= timeForBonus * 1.5) return 4;
if (elapsedSeconds <= timeForBonus * 2) return 3;

View file

@ -0,0 +1,4 @@
class VocabPracticeConstants {
static const int timeForBonus = 60;
static const int practiceGroupSize = 10;
}

View file

@ -1,30 +1,48 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeChoice {
final String choiceId;
final String choiceText;
final String? choiceEmoji;
const VocabPracticeChoice({
required this.choiceId,
required this.choiceText,
this.choiceEmoji,
});
}
class SessionLoader extends AsyncLoader<VocabPracticeSessionModel> {
@override
Future<VocabPracticeSessionModel> fetch() =>
VocabPracticeSessionRepo.currentSession;
Future<VocabPracticeSessionModel> fetch() => VocabPracticeSessionRepo.get();
}
class VocabPractice extends StatefulWidget {
@ -34,445 +52,370 @@ class VocabPractice extends StatefulWidget {
VocabPracticeState createState() => VocabPracticeState();
}
class VocabPracticeState extends State<VocabPractice> {
SessionLoader sessionLoader = SessionLoader();
PracticeActivityModel? currentActivity;
bool isLoadingActivity = true;
bool isAwaitingNextActivity = false;
String? activityError;
class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
final SessionLoader _sessionLoader = SessionLoader();
bool isLoadingLemmaInfo = false;
final Map<String, String> _choiceTexts = {};
final Map<String, String?> _choiceEmojis = {};
final ValueNotifier<AsyncState<PracticeActivityModel>> activityState =
ValueNotifier(const AsyncState.idle());
final Queue<MapEntry<ConstructIdentifier, Completer<PracticeActivityModel>>>
_queue = Queue();
final ValueNotifier<ConstructIdentifier?> activityConstructId =
ValueNotifier<ConstructIdentifier?>(null);
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
final Map<PracticeTarget, Map<String, String>> _choiceTexts = {};
final Map<PracticeTarget, Map<String, String?>> _choiceEmojis = {};
StreamSubscription<void>? _languageStreamSubscription;
bool _sessionClearedDueToLanguageChange = false;
@override
void initState() {
super.initState();
_startSession();
_listenToLanguageChanges();
_languageStreamSubscription = MatrixState
.pangeaController.userController.languageStream.stream
.listen((_) => _onLanguageUpdate());
}
@override
void dispose() {
_languageStreamSubscription?.cancel();
if (isComplete) {
VocabPracticeSessionRepo.clearSession();
} else if (!_sessionClearedDueToLanguageChange) {
//don't save if session was cleared due to language change
_saveCurrentTime();
if (_isComplete) {
VocabPracticeSessionRepo.clear();
} else {
_saveSession();
}
sessionLoader.dispose();
_sessionLoader.dispose();
activityState.dispose();
activityConstructId.dispose();
progressNotifier.dispose();
super.dispose();
}
void _saveCurrentTime() {
if (sessionLoader.isLoaded) {
VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
PracticeActivityModel? get _currentActivity =>
activityState.value is AsyncLoaded<PracticeActivityModel>
? (activityState.value as AsyncLoaded<PracticeActivityModel>).value
: null;
bool get _isComplete => _sessionLoader.value?.isComplete ?? false;
ValueNotifier<AsyncState<VocabPracticeSessionModel>> get sessionState =>
_sessionLoader.state;
AnalyticsDataService get _analyticsService =>
Matrix.of(context).analyticsDataService;
List<VocabPracticeChoice> filteredChoices(
PracticeTarget target,
MultipleChoiceActivity activity,
) {
final choices = activity.choices.toList();
final answer = activity.answers.first;
final filtered = <VocabPracticeChoice>[];
final seenTexts = <String>{};
for (final id in choices) {
final text = getChoiceText(target, id);
if (seenTexts.contains(text)) {
if (id != answer) {
continue;
}
final index = filtered.indexWhere(
(choice) => choice.choiceText == text,
);
if (index != -1) {
filtered[index] = VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(target, id),
);
}
continue;
}
seenTexts.add(text);
filtered.add(
VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(target, id),
),
);
}
return filtered;
}
String getChoiceText(PracticeTarget target, String choiceId) {
if (_choiceTexts.containsKey(target) &&
_choiceTexts[target]!.containsKey(choiceId)) {
return _choiceTexts[target]![choiceId]!;
}
final cId = ConstructIdentifier.fromString(choiceId);
return cId?.lemma ?? choiceId;
}
String? getChoiceEmoji(PracticeTarget target, String choiceId) =>
_choiceEmojis[target]?[choiceId];
String choiceTargetId(String choiceId) =>
'vocab-choice-card-${choiceId.replaceAll(' ', '_')}';
void _resetActivityState() {
activityState.value = const AsyncState.loading();
activityConstructId.value = null;
}
void _resetSessionState() {
progressNotifier.value = 0.0;
_queue.clear();
_choiceTexts.clear();
_choiceEmojis.clear();
activityState.value = const AsyncState.idle();
}
void updateElapsedTime(int seconds) {
if (_sessionLoader.isLoaded) {
_sessionLoader.value!.setElapsedSeconds(seconds);
}
}
/// Resets all session state without disposing the widget
void _resetState() {
currentActivity = null;
isLoadingActivity = true;
isAwaitingNextActivity = false;
activityError = null;
isLoadingLemmaInfo = false;
_choiceTexts.clear();
_choiceEmojis.clear();
}
bool get isComplete =>
sessionLoader.isLoaded && sessionLoader.value!.hasCompletedCurrentGroup;
double get progress =>
sessionLoader.isLoaded ? sessionLoader.value!.progress : 0.0;
int get availableActivities => sessionLoader.isLoaded
? sessionLoader.value!.currentAvailableActivities
: 0;
int get completedActivities =>
sessionLoader.isLoaded ? sessionLoader.value!.currentIndex : 0;
int get elapsedSeconds =>
sessionLoader.isLoaded ? sessionLoader.value!.elapsedSeconds : 0;
void updateElapsedTime(int seconds) {
if (sessionLoader.isLoaded) {
sessionLoader.value!.elapsedSeconds = seconds;
Future<void> _saveSession() async {
if (_sessionLoader.isLoaded) {
await VocabPracticeSessionRepo.update(_sessionLoader.value!);
}
}
Future<void> _waitForAnalytics() async {
if (!MatrixState.pangeaController.matrixState.analyticsDataService
.initCompleter.isCompleted) {
if (!_analyticsService.initCompleter.isCompleted) {
MatrixState.pangeaController.initControllers();
await MatrixState.pangeaController.matrixState.analyticsDataService
.initCompleter.future;
await _analyticsService.initCompleter.future;
}
}
void _listenToLanguageChanges() {
_languageStreamSubscription = MatrixState
.pangeaController.userController.languageStream.stream
.listen((_) async {
// If language changed, clear session and back out of vocab practice
if (await _shouldReloadSession()) {
_sessionClearedDueToLanguageChange = true;
await VocabPracticeSessionRepo.clearSession();
if (mounted) {
Navigator.of(context).pop();
}
Future<void> _onLanguageUpdate() async {
try {
_resetActivityState();
_resetSessionState();
await _analyticsService
.updateDispatcher.constructUpdateStream.stream.first
.timeout(const Duration(seconds: 10));
await reloadSession();
} catch (e) {
if (mounted) {
activityState.value = AsyncState.error(
L10n.of(context).oopsSomethingWentWrong,
);
}
});
}
}
Future<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(() {});
await _sessionLoader.load();
progressNotifier.value = _sessionLoader.value!.progress;
await _continueSession();
}
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();
},
);
_resetActivityState();
_resetSessionState();
await VocabPracticeSessionRepo.clear();
_sessionLoader.reset();
await _startSession();
}
if (mounted) {
setState(() {});
Future<void> _completeSession() async {
_sessionLoader.value!.finishSession();
setState(() {});
final bonus = _sessionLoader.value!.state.allBonusUses;
await _analyticsService.updateService.addAnalytics(null, bonus);
await _saveSession();
}
bool _continuing = false;
Future<void> _continueSession() async {
if (_continuing) return;
_continuing = true;
try {
if (activityState.value is AsyncIdle<PracticeActivityModel>) {
await _initActivityData();
} else if (_queue.isEmpty) {
await _completeSession();
} else {
activityState.value = const AsyncState.loading();
final nextActivityCompleter = _queue.removeFirst();
activityConstructId.value = nextActivityCompleter.key;
final activity = await nextActivityCompleter.value.future;
activityState.value = AsyncState.loaded(activity);
}
} catch (e) {
activityState.value = AsyncState.error(e);
} finally {
_continuing = false;
}
}
Future<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),
];
Future<void> _initActivityData() async {
final requests = _sessionLoader.value!.activityRequests;
if (requests.isEmpty) {
throw L10n.of(context).noActivityRequest;
}
return null;
}
try {
activityState.value = const AsyncState.loading();
Future<void> loadActivity() async {
if (!sessionLoader.isLoaded) {
try {
await sessionLoader.completer.future;
} catch (_) {
return;
}
}
final req = requests.first;
final res = await _fetchActivity(req);
if (!mounted) return;
if (!mounted) return;
setState(() {
isAwaitingNextActivity = false;
currentActivity = null;
isLoadingActivity = true;
activityError = null;
_choiceTexts.clear();
_choiceEmojis.clear();
});
final session = sessionLoader.value!;
final activityRequest = session.currentActivityRequest;
if (activityRequest == null) {
setState(() {
activityError = L10n.of(context).noActivityRequest;
isLoadingActivity = false;
});
activityConstructId.value = req.targetTokens.first.vocabConstructID;
activityState.value = AsyncState.loaded(res);
} catch (e) {
if (!mounted) return;
activityState.value = AsyncState.error(e);
return;
}
_fillActivityQueue(requests.skip(1).toList());
}
Future<void> _fillActivityQueue(List<MessageActivityRequest> requests) async {
for (final request in requests) {
final completer = Completer<PracticeActivityModel>();
_queue.add(
MapEntry(
request.targetTokens.first.vocabConstructID,
completer,
),
);
try {
final res = await _fetchActivity(request);
if (!mounted) return;
completer.complete(res);
} catch (e) {
if (!mounted) return;
completer.completeError(e);
break;
}
}
}
Future<PracticeActivityModel> _fetchActivity(
MessageActivityRequest req,
) async {
final result = await PracticeRepo.getPracticeActivity(
activityRequest,
req,
messageInfo: {},
);
if (result.isError) {
activityError = L10n.of(context).oopsSomethingWentWrong;
} else {
currentActivity = result.result!;
throw L10n.of(context).oopsSomethingWentWrong;
}
// Prefetch lemma info for meaning activities before marking ready
if (currentActivity != null &&
currentActivity!.activityType == ActivityTypeEnum.lemmaMeaning) {
final choices = currentActivity!.multipleChoiceContent!.choices.toList();
await _prefetchLemmaInfo(choices);
if (result.result!.activityType == ActivityTypeEnum.lemmaMeaning) {
final choices = result.result!.multipleChoiceContent!.choices.toList();
await _fetchLemmaInfo(result.result!.practiceTarget, choices);
}
if (mounted) {
setState(() => isLoadingActivity = false);
return result.result!;
}
Future<void> _fetchLemmaInfo(
PracticeTarget target,
List<String> choiceIds,
) async {
final texts = <String, String>{};
final emojis = <String, String?>{};
for (final id in choiceIds) {
final cId = ConstructIdentifier.fromString(id);
if (cId == null) continue;
final res = await cId.getLemmaInfo({});
if (res.isError) {
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
throw L10n.of(context).oopsSomethingWentWrong;
}
texts[id] = res.result!.meaning;
emojis[id] = res.result!.emoji.firstOrNull;
}
_choiceTexts.putIfAbsent(target, () => {});
_choiceEmojis.putIfAbsent(target, () => {});
_choiceTexts[target]!.addAll(texts);
_choiceEmojis[target]!.addAll(emojis);
}
Future<void> onSelectChoice(
ConstructIdentifier choiceConstruct,
String choiceContent,
) async {
if (currentActivity == null) return;
final activity = currentActivity!;
if (_currentActivity == null) return;
final activity = _currentActivity!;
// Update activity record
activity.onMultipleChoiceSelect(choiceConstruct, choiceContent);
final correct = activity.multipleChoiceContent!.isCorrect(choiceContent);
// Submit answer immediately (records use and gives XP)
sessionLoader.value!.submitAnswer(activity, correct);
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
// Update session model and analytics
final useType = correct
? activity.activityType.correctUse
: activity.activityType.incorrectUse;
final transformTargetId =
'vocab-choice-card-${choiceContent.replaceAll(' ', '_')}';
if (correct) {
OverlayUtil.showPointsGained(transformTargetId, 5, context);
} else {
OverlayUtil.showPointsGained(transformTargetId, -1, context);
}
final use = OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: activity.targetTokens.first.pos,
lemma: activity.targetTokens.first.lemma.text,
form: activity.targetTokens.first.lemma.text,
xp: useType.pointValue,
);
_sessionLoader.value!.submitAnswer(use);
await _analyticsService.updateService
.addAnalytics(choiceTargetId(choiceContent), [use]);
await _saveSession();
if (!correct) return;
// display the fact that the choice was correct before loading the next activity
setState(() => isAwaitingNextActivity = true);
// Display the fact that the choice was correct before loading the next activity
await Future.delayed(const Duration(milliseconds: 1000));
setState(() => isAwaitingNextActivity = false);
// Only move to next activity when answer is correct
sessionLoader.value!.completeActivity(activity);
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
// Then mark this activity as completed, and either load the next or complete the session
_sessionLoader.value!.completeActivity();
progressNotifier.value = _sessionLoader.value!.progress;
await _saveSession();
if (isComplete) {
await completeActivitySession();
}
await loadActivity();
_isComplete ? await _completeSession() : await _continueSession();
}
Future<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,
};
Future<List<InlineSpan>?> getExampleMessage(
ConstructIdentifier construct,
) async {
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct),
Matrix.of(context).client,
);
}
Future<DerivedAnalyticsDataModel> get derivedAnalyticsData =>
_analyticsService.derivedData;
@override
Widget build(BuildContext context) => VocabPracticeView(this);
String getChoiceText(String choiceId) {
if (_choiceTexts.containsKey(choiceId)) return _choiceTexts[choiceId]!;
final cId = ConstructIdentifier.fromString(choiceId);
return cId?.lemma ?? choiceId;
}
String? getChoiceEmoji(String choiceId) => _choiceEmojis[choiceId];
//fetches display info for all choices from constructIDs
Future<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;
}
}
// Check for duplicate choice texts and remove duplicates
_removeDuplicateChoices();
if (mounted) {
setState(() => isLoadingLemmaInfo = false);
}
}
/// Removes duplicate choice texts, keeping the correct answer if it's a duplicate, or the first otherwise
void _removeDuplicateChoices() {
if (currentActivity?.multipleChoiceContent == null) return;
final activity = currentActivity!.multipleChoiceContent!;
final correctAnswers = activity.answers;
final Map<String, List<String>> textToIds = {};
for (final id in _choiceTexts.keys) {
final text = _choiceTexts[id]!;
textToIds.putIfAbsent(text, () => []).add(id);
}
// Find duplicates and remove them
final Set<String> idsToRemove = {};
for (final entry in textToIds.entries) {
final duplicateIds = entry.value;
if (duplicateIds.length > 1) {
// Find if any of the duplicates is the correct answer
final correctId = duplicateIds.firstWhereOrNull(
(id) => correctAnswers.contains(id),
);
// Remove all duplicates except one
if (correctId != null) {
idsToRemove.addAll(duplicateIds.where((id) => id != correctId));
} else {
idsToRemove.addAll(duplicateIds.skip(1));
}
}
}
if (idsToRemove.isNotEmpty) {
activity.choices.removeAll(idsToRemove);
for (final id in idsToRemove) {
_choiceTexts.remove(id);
_choiceEmojis.remove(id);
}
}
}
}

View file

@ -1,112 +1,108 @@
import 'dart:math';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
class VocabPracticeSessionModel {
final DateTime startedAt;
final List<ConstructIdentifier> sortedConstructIds;
final List<ActivityTypeEnum> activityTypes;
final List<PracticeTarget> practiceTargets;
final String userL1;
final String userL2;
int currentIndex;
int currentGroup;
final List<OneConstructUse> completedUses;
bool finished;
int elapsedSeconds;
VocabPracticeSessionState state;
VocabPracticeSessionModel({
required this.startedAt,
required this.sortedConstructIds,
required this.activityTypes,
required this.practiceTargets,
required this.userL1,
required this.userL2,
required this.completedUses,
this.currentIndex = 0,
this.currentGroup = 0,
this.finished = false,
this.elapsedSeconds = 0,
VocabPracticeSessionState? state,
}) : assert(
activityTypes.every(
practiceTargets.every(
(t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio}
.contains(t),
.contains(t.activityType),
),
),
assert(
activityTypes.length == practiceGroupSize,
);
state = state ?? const VocabPracticeSessionState();
static const int practiceGroupSize = 10;
int get currentAvailableActivities => min(
((currentGroup + 1) * practiceGroupSize),
sortedConstructIds.length,
int get _availableActivities => min(
VocabPracticeConstants.practiceGroupSize,
practiceTargets.length,
);
bool get hasCompletedCurrentGroup =>
currentIndex >= currentAvailableActivities;
int get timeForBonus => 60;
bool get isComplete => state.currentIndex >= _availableActivities;
double get progress =>
(currentIndex / currentAvailableActivities).clamp(0.0, 1.0);
(state.currentIndex / _availableActivities).clamp(0.0, 1.0);
List<ConstructIdentifier> get currentPracticeGroup => sortedConstructIds
.skip(currentGroup * practiceGroupSize)
.take(practiceGroupSize)
.toList();
ConstructIdentifier? get currentConstructId {
if (currentIndex < 0 || hasCompletedCurrentGroup) {
return null;
}
return currentPracticeGroup[currentIndex % practiceGroupSize];
List<MessageActivityRequest> get activityRequests {
return practiceTargets.map((target) {
return MessageActivityRequest(
userL1: userL1,
userL2: userL2,
activityQualityFeedback: null,
targetTokens: target.tokens,
targetType: target.activityType,
targetMorphFeature: null,
);
}).toList();
}
ActivityTypeEnum? get currentActivityType {
if (currentIndex < 0 || hasCompletedCurrentGroup) {
return null;
}
return activityTypes[currentIndex % practiceGroupSize];
}
void setElapsedSeconds(int seconds) =>
state = state.copyWith(elapsedSeconds: seconds);
MessageActivityRequest? get currentActivityRequest {
final constructId = currentConstructId;
if (constructId == null || currentActivityType == null) return null;
void finishSession() => state = state.copyWith(finished: true);
final activityType = currentActivityType;
return MessageActivityRequest(
userL1: userL1,
userL2: userL2,
activityQualityFeedback: null,
targetTokens: [
PangeaToken(
lemma: Lemma(
text: constructId.lemma,
saveVocab: true,
form: constructId.lemma,
),
pos: constructId.category,
text: PangeaTokenText.fromString(constructId.lemma),
morph: {},
),
],
targetType: activityType!,
targetMorphFeature: null,
void completeActivity() =>
state = state.copyWith(currentIndex: state.currentIndex + 1);
void submitAnswer(OneConstructUse use) => state = state.copyWith(
completedUses: [...state.completedUses, use],
);
factory VocabPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return VocabPracticeSessionModel(
startedAt: DateTime.parse(json['startedAt'] as String),
practiceTargets: (json['practiceTargets'] as List<dynamic>)
.map((e) => PracticeTarget.fromJson(e))
.whereType<PracticeTarget>()
.toList(),
userL1: json['userL1'] as String,
userL2: json['userL2'] as String,
state: VocabPracticeSessionState.fromJson(
json,
),
);
}
Map<String, dynamic> toJson() {
return {
'startedAt': startedAt.toIso8601String(),
'practiceTargets': practiceTargets.map((e) => e.toJson()).toList(),
'userL1': userL1,
'userL2': userL2,
...state.toJson(),
};
}
}
class VocabPracticeSessionState {
final List<OneConstructUse> completedUses;
final int currentIndex;
final bool finished;
final int elapsedSeconds;
const VocabPracticeSessionState({
this.completedUses = const [],
this.currentIndex = 0,
this.finished = false,
this.elapsedSeconds = 0,
});
int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp);
double get accuracy {
@ -116,138 +112,75 @@ class VocabPracticeSessionModel {
return (result * 100).truncateToDouble();
}
void finishSession() {
finished = true;
bool get _giveAccuracyBonus => accuracy >= 100.0;
// give bonus XP uses for each construct if earned
if (accuracy >= 100) {
final bonusUses = completedUses
.where((use) => use.xp > 0)
.map(
(use) => OneConstructUse(
useType: ConstructUseTypeEnum.bonus,
constructType: use.constructType,
metadata: ConstructUseMetaData(
roomId: use.metadata.roomId,
timeStamp: DateTime.now(),
),
category: use.category,
lemma: use.lemma,
form: use.form,
xp: ConstructUseTypeEnum.bonus.pointValue,
),
)
.toList();
bool get _giveTimeBonus =>
elapsedSeconds <= VocabPracticeConstants.timeForBonus;
MatrixState
.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
bonusUses,
int get bonusXP => accuracyBonusXP + timeBonusXP;
int get accuracyBonusXP => _giveAccuracyBonus ? _bonusXP : 0;
int get timeBonusXP => _giveTimeBonus ? _bonusXP : 0;
int get _bonusXP => _bonusUses.fold(0, (sum, use) => sum + use.xp);
int get allXPGained => totalXpGained + bonusXP;
List<OneConstructUse> get _bonusUses =>
completedUses.where((use) => use.xp > 0).map(_bonusUse).toList();
List<OneConstructUse> get allBonusUses => [
if (_giveAccuracyBonus) ..._bonusUses,
if (_giveTimeBonus) ..._bonusUses,
];
OneConstructUse _bonusUse(OneConstructUse originalUse) => OneConstructUse(
useType: ConstructUseTypeEnum.bonus,
constructType: originalUse.constructType,
metadata: ConstructUseMetaData(
roomId: originalUse.metadata.roomId,
timeStamp: DateTime.now(),
),
category: originalUse.category,
lemma: originalUse.lemma,
form: originalUse.form,
xp: ConstructUseTypeEnum.bonus.pointValue,
);
}
if (elapsedSeconds <= timeForBonus) {
final bonusUses = completedUses
.where((use) => use.xp > 0)
.map(
(use) => OneConstructUse(
useType: ConstructUseTypeEnum.bonus,
constructType: use.constructType,
metadata: ConstructUseMetaData(
roomId: use.metadata.roomId,
timeStamp: DateTime.now(),
),
category: use.category,
lemma: use.lemma,
form: use.form,
xp: ConstructUseTypeEnum.bonus.pointValue,
),
)
.toList();
MatrixState
.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
bonusUses,
);
}
}
void submitAnswer(PracticeActivityModel activity, bool isCorrect) {
final useType = isCorrect
? activity.activityType.correctUse
: activity.activityType.incorrectUse;
final use = OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: activity.targetTokens.first.pos,
lemma: activity.targetTokens.first.lemma.text,
form: activity.targetTokens.first.lemma.text,
xp: useType.pointValue,
);
completedUses.add(use);
// Give XP immediately
MatrixState.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
[use],
);
}
void completeActivity(PracticeActivityModel activity) {
currentIndex += 1;
}
factory VocabPracticeSessionModel.fromJson(Map<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,
VocabPracticeSessionState copyWith({
List<OneConstructUse>? completedUses,
int? currentIndex,
bool? finished,
int? elapsedSeconds,
}) {
return VocabPracticeSessionState(
completedUses: completedUses ?? this.completedUses,
currentIndex: currentIndex ?? this.currentIndex,
finished: finished ?? this.finished,
elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds,
);
}
Map<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(),
'currentIndex': currentIndex,
'finished': finished,
'elapsedSeconds': elapsedSeconds,
};
}
factory VocabPracticeSessionState.fromJson(Map<String, dynamic> json) {
return VocabPracticeSessionState(
completedUses: (json['completedUses'] as List<dynamic>?)
?.map((e) => OneConstructUse.fromJson(e))
.whereType<OneConstructUse>()
.toList() ??
[],
currentIndex: json['currentIndex'] as int? ?? 0,
finished: json['finished'] as bool? ?? false,
elapsedSeconds: json['elapsedSeconds'] as int? ?? 0,
);
}
}

View file

@ -5,13 +5,15 @@ import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeSessionRepo {
static final GetStorage _storage = GetStorage('vocab_practice_session');
static Future<VocabPracticeSessionModel> get currentSession async {
static Future<VocabPracticeSessionModel> get() async {
final cached = _getCached();
if (cached != null) {
return cached;
@ -24,34 +26,36 @@ class VocabPracticeSessionRepo {
];
final types = List.generate(
VocabPracticeSessionModel.practiceGroupSize,
VocabPracticeConstants.practiceGroupSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final targets = await _fetch();
final constructs = await _fetch();
final targetCount = min(constructs.length, types.length);
final targets = [
for (var i = 0; i < targetCount; i++)
PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
),
];
final session = VocabPracticeSessionModel(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
sortedConstructIds: targets,
activityTypes: types,
completedUses: [],
practiceTargets: targets,
);
await _setCached(session);
return session;
}
static Future<void> updateSession(
static Future<void> update(
VocabPracticeSessionModel session,
) =>
_setCached(session);
static Future<VocabPracticeSessionModel> reloadSession() async {
_storage.erase();
return currentSession;
}
static Future<void> clearSession() => _storage.erase();
static Future<void> clear() => _storage.erase();
static Future<List<ConstructIdentifier>> _fetch() async {
final constructs = await MatrixState
@ -59,25 +63,21 @@ class VocabPracticeSessionRepo {
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// maintain a Map of ConstructIDs to last use dates and a sorted list of ConstructIDs
// based on last use. Update the map / list on practice completion
final Map<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];
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
return sortedTargetIds;
return constructs
.where((construct) => construct.lemma.isNotEmpty)
.take(VocabPracticeConstants.practiceGroupSize)
.map((construct) => construct.id)
.toList();
}
static VocabPracticeSessionModel? _getCached() {

View file

@ -5,10 +5,10 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/audio_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart';
@ -25,28 +25,40 @@ class VocabPracticeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
const loading = Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
);
return Scaffold(
appBar: AppBar(
title: Row(
spacing: 8.0,
children: [
Expanded(
child: AnimatedProgressBar(
height: 20.0,
widthPercent: controller.progress,
barColor: Theme.of(context).colorScheme.primary,
child: ValueListenableBuilder(
valueListenable: controller.progressNotifier,
builder: (context, progress, __) {
return AnimatedProgressBar(
height: 20.0,
widthPercent: progress,
barColor: Theme.of(context).colorScheme.primary,
);
},
),
),
//keep track of state to update timer
ValueListenableBuilder(
valueListenable: controller.sessionLoader.state,
valueListenable: controller.sessionState,
builder: (context, state, __) {
if (state is AsyncLoaded<VocabPracticeSessionModel>) {
return VocabTimerWidget(
key: ValueKey(state.value.startedAt),
initialSeconds: state.value.elapsedSeconds,
initialSeconds: state.value.state.elapsedSeconds,
onTimeUpdate: controller.updateElapsedTime,
isRunning: !controller.isComplete,
isRunning: !state.value.isComplete,
);
}
return const SizedBox.shrink();
@ -57,64 +69,34 @@ class VocabPracticeView extends StatelessWidget {
),
body: MaxWidthBody(
withScrolling: false,
padding: const EdgeInsets.all(0.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
),
showBorder: false,
child: controller.isComplete
? CompletedActivitySessionView(controller)
: _OngoingActivitySessionView(controller),
child: ValueListenableBuilder(
valueListenable: controller.sessionState,
builder: (context, state, __) {
return switch (state) {
AsyncError<VocabPracticeSessionModel>(:final error) =>
ErrorIndicator(message: error.toString()),
AsyncLoaded<VocabPracticeSessionModel>(:final value) =>
value.isComplete
? CompletedActivitySessionView(state.value, controller)
: _VocabActivityView(controller),
_ => loading,
};
},
),
),
);
}
}
class _OngoingActivitySessionView extends StatelessWidget {
final VocabPracticeState controller;
const _OngoingActivitySessionView(this.controller);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.sessionLoader.state,
builder: (context, state, __) {
return switch (state) {
AsyncError<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,
);
@ -125,30 +107,48 @@ class _VocabActivityView extends StatelessWidget {
//per-activity instructions, add switch statement once there are more types
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.selectMeaning,
padding: EdgeInsets.symmetric(horizontal: 16.0),
padding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
constructId.lemma,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
Expanded(
flex: 1,
child: ValueListenableBuilder(
valueListenable: controller.activityConstructId,
builder: (context, constructId, __) => constructId != null
? Text(
constructId.lemma,
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
)
: const SizedBox(),
),
),
_ExampleMessageWidget(controller, constructId),
Flexible(
child: _ActivityChoicesWidget(
controller,
activityType,
constructId,
Expanded(
flex: 2,
child: Center(
child: ValueListenableBuilder(
valueListenable: controller.activityConstructId,
builder: (context, constructId, __) => constructId != null
? _ExampleMessageWidget(
controller.getExampleMessage(constructId),
)
: const SizedBox(),
),
),
),
Expanded(
flex: 6,
child: _ActivityChoicesWidget(controller),
),
],
),
),
@ -158,44 +158,38 @@ class _VocabActivityView extends StatelessWidget {
}
class _ExampleMessageWidget extends StatelessWidget {
final VocabPracticeState controller;
final ConstructIdentifier constructId;
final Future<List<InlineSpan>?> future;
const _ExampleMessageWidget(this.controller, this.constructId);
const _ExampleMessageWidget(this.future);
@override
Widget build(BuildContext context) {
return FutureBuilder<List<InlineSpan>?>(
future: controller.getExampleMessage(constructId),
future: future,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox();
}
return Padding(
//styling like sent message bubble
padding: const EdgeInsets.all(16.0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: snapshot.data!,
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: snapshot.data!,
),
),
);
@ -206,87 +200,111 @@ class _ExampleMessageWidget extends StatelessWidget {
class _ActivityChoicesWidget extends StatelessWidget {
final VocabPracticeState controller;
final ActivityTypeEnum activityType;
final ConstructIdentifier constructId;
const _ActivityChoicesWidget(
this.controller,
this.activityType,
this.constructId,
);
@override
Widget build(BuildContext context) {
if (controller.activityError != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//allow try to reload activity in case of error
ErrorIndicator(message: controller.activityError!),
const SizedBox(height: 16),
TextButton.icon(
onPressed: controller.loadActivity,
icon: const Icon(Icons.refresh),
label: Text(L10n.of(context).tryAgain),
),
],
);
}
final activity = controller.currentActivity;
if (controller.isLoadingActivity ||
activity == null ||
(activity.activityType == ActivityTypeEnum.lemmaMeaning &&
controller.isLoadingLemmaInfo)) {
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: const Center(
child: CircularProgressIndicator.adaptive(),
),
);
}
final choices = activity.multipleChoiceContent!.choices.toList();
return LayoutBuilder(
builder: (context, constraints) {
//Constrain max height to keep choices together on large screens, and allow shrinking to fit on smaller screens
final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0);
final cardHeight =
(constrainedHeight / (choices.length + 1)).clamp(50.0, 80.0);
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: choices.map((choiceId) {
final bool isEnabled = !controller.isAwaitingNextActivity;
return _buildChoiceCard(
activity: activity,
choiceId: choiceId,
cardHeight: cardHeight,
isEnabled: isEnabled,
onPressed: () =>
controller.onSelectChoice(constructId, choiceId),
);
}).toList(),
return ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) {
return switch (state) {
AsyncLoading<PracticeActivityModel>() => const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
),
),
);
AsyncError<PracticeActivityModel>(:final error) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//allow try to reload activity in case of error
ErrorIndicator(message: error.toString()),
const SizedBox(height: 16),
TextButton.icon(
onPressed: controller.reloadSession,
icon: const Icon(Icons.refresh),
label: Text(L10n.of(context).tryAgain),
),
],
),
AsyncLoaded<PracticeActivityModel>(:final value) => LayoutBuilder(
builder: (context, constraints) {
final choices = controller.filteredChoices(
value.practiceTarget,
value.multipleChoiceContent!,
);
final constrainedHeight =
constraints.maxHeight.clamp(0.0, 400.0);
final cardHeight = (constrainedHeight / (choices.length + 1))
.clamp(50.0, 80.0);
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId:
controller.choiceTargetId(choice.choiceId),
choiceId: choice.choiceId,
onPressed: () => controller.onSelectChoice(
value.targetTokens.first.vocabConstructID,
choice.choiceId,
),
cardHeight: cardHeight,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
),
)
.toList(),
),
);
},
),
_ => Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: const Center(
child: CircularProgressIndicator.adaptive(),
),
),
};
},
);
}
}
Widget _buildChoiceCard({
required activity,
required String choiceId,
required double cardHeight,
required bool isEnabled,
required VoidCallback onPressed,
}) {
class _ChoiceCard extends StatelessWidget {
final PracticeActivityModel activity;
final String choiceId;
final String targetId;
final VoidCallback onPressed;
final double cardHeight;
final String choiceText;
final String? choiceEmoji;
const _ChoiceCard({
required this.activity,
required this.choiceId,
required this.targetId,
required this.onPressed,
required this.cardHeight,
required this.choiceText,
required this.choiceEmoji,
});
@override
Widget build(BuildContext context) {
final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId);
final activityType = activity.activityType;
final constructId = activity.targetTokens.first.vocabConstructID;
switch (activity.activityType) {
case ActivityTypeEnum.lemmaMeaning:
@ -295,12 +313,12 @@ class _ActivityChoicesWidget extends StatelessWidget {
'${constructId.string}_${activityType.name}_meaning_$choiceId',
),
choiceId: choiceId,
displayText: controller.getChoiceText(choiceId),
emoji: controller.getChoiceEmoji(choiceId),
targetId: targetId,
displayText: choiceText,
emoji: choiceEmoji,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
);
case ActivityTypeEnum.lemmaAudio:
@ -309,10 +327,10 @@ class _ActivityChoicesWidget extends StatelessWidget {
'${constructId.string}_${activityType.name}_audio_$choiceId',
),
text: choiceId,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
);
default:
@ -321,12 +339,11 @@ class _ActivityChoicesWidget extends StatelessWidget {
'${constructId.string}_${activityType.name}_basic_$choiceId',
),
shouldFlip: false,
transformId: choiceId,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
child: Text(controller.getChoiceText(choiceId)),
child: Text(choiceText),
);
}
}