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:
parent
8c2cd7d022
commit
8a8ca1026a
14 changed files with 1046 additions and 1100 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
lib/pangea/analytics_misc/example_message_util.dart
Normal file
80
lib/pangea/analytics_misc/example_message_util.dart
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,10 +89,6 @@ class LemmaInfoRepo {
|
|||
_cache.remove(key);
|
||||
}
|
||||
|
||||
static void clearAllCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static Future<Result<LemmaInfoResponse>> _safeFetch(
|
||||
String token,
|
||||
LemmaInfoRequest request,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4
lib/pangea/vocab_practice/vocab_practice_constants.dart
Normal file
4
lib/pangea/vocab_practice/vocab_practice_constants.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class VocabPracticeConstants {
|
||||
static const int timeForBonus = 60;
|
||||
static const int practiceGroupSize = 10;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue