fluffychat/lib/pangea/vocab_practice/completed_activity_session_view.dart
ggurdin af395d0aeb
4825 vocabulary practice (#4826)
* chore: move logic for lastUsedByActivityType into ConstructIdentifier

* feat: vocab practice

* add vocab activity progress bar

* fix: shuffle audio practice choices

* update UI of vocab practice

Added buttons, increased text size and change position, cards flip over and turn red/green on click and respond to hover input

* add xp sparkle, shimmering choice card placeholder

* spacing changes

fix padding, make choice cards spacing/sizing responsive to screen size, replace shimmer cards with stationary circle indicator

* don't include duplicate lemma choices

* use constructID and show lemma/emoji on choice cards

add method to clear cache in case the results was an error, and add a retry button on error

* gain xp immediately and take out continue session

also refactor the choice cards to have separate widgets for each type and a parent widget to give each an id for xp sparkle

* add practice finished page with analytics

* Color tweaks on completed page and time card placeholder

* add timer

* give XP for bonuses and change timer to use stopwatch

* simplify card logic, lock practice when few vocab words

* merge analytics changes and fix bugs

* reload on language change

- derive XP data from new analytics
- Don't allow any clicks after correct answer selected

* small fixes, added tooltip, added copy to l10

* small tweaks and comments

* formatting and import sorting

---------

Co-authored-by: avashilling <165050625+avashilling@users.noreply.github.com>
2026-01-07 10:13:34 -05:00

292 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/stat_card.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class CompletedActivitySessionView extends StatefulWidget {
final VocabPracticeState controller;
const CompletedActivitySessionView(this.controller, {super.key});
@override
State<CompletedActivitySessionView> createState() =>
_CompletedActivitySessionViewState();
}
class _CompletedActivitySessionViewState
extends State<CompletedActivitySessionView> {
late final Future<Map<String, double>> progressChangeFuture;
double currentProgress = 0.0;
Uri? avatarUrl;
bool shouldShowRain = false;
@override
void initState() {
super.initState();
// Fetch avatar URL
final client = Matrix.of(context).client;
client.fetchOwnProfile().then((profile) {
if (mounted) {
setState(() => avatarUrl = profile.avatarUrl);
}
});
progressChangeFuture = widget.controller.calculateProgressChange(
widget.controller.sessionLoader.value!.totalXpGained,
);
}
void _onProgressChangeLoaded(Map<String, double> progressChange) {
//start with before progress
currentProgress = progressChange['before'] ?? 0.0;
//switch to after progress after first frame, to activate animation
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
currentProgress = progressChange['after'] ?? 0.0;
// Start the star rain
shouldShowRain = true;
});
}
});
}
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final username =
Matrix.of(context).client.userID?.split(':').first.substring(1) ?? '';
final bool accuracyAchievement =
widget.controller.sessionLoader.value!.accuracy == 100;
final bool timeAchievement =
widget.controller.sessionLoader.value!.elapsedSeconds <= 60;
final int numBonusPoints = widget
.controller.sessionLoader.value!.completedUses
.where((use) => use.xp > 0)
.length;
//give double bonus for both, single for one, none for zero
final int bonusXp = (accuracyAchievement && timeAchievement)
? numBonusPoints * 2
: (accuracyAchievement || timeAchievement)
? numBonusPoints
: 0;
return FutureBuilder<Map<String, double>>(
future: progressChangeFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
// Initialize progress when data is available
if (currentProgress == 0.0 && !shouldShowRain) {
_onProgressChangeLoaded(snapshot.data!);
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16),
child: Column(
children: [
Text(
L10n.of(context).congratulationsYouveCompletedPractice,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: avatarUrl == null
? Avatar(
name: username,
showPresence: false,
size: 100,
)
: ClipOval(
child: MxcImage(
uri: avatarUrl,
width: 100,
height: 100,
),
),
),
Column(
children: [
Padding(
padding: const EdgeInsets.only(
top: 16.0,
bottom: 16.0,
),
child: AnimatedProgressBar(
height: 20.0,
widthPercent: currentProgress,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
duration: const Duration(milliseconds: 500),
),
),
Text(
"+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP",
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
color: AppConfig.goldLight,
fontWeight: FontWeight.bold,
),
),
],
),
StatCard(
icon: Icons.my_location,
text:
"${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%",
isAchievement: accuracyAchievement,
achievementText: "+ $numBonusPoints XP",
child: PercentMarkerBar(
height: 20.0,
widthPercent: widget
.controller.sessionLoader.value!.accuracy /
100.0,
markerWidth: 20.0,
markerColor: AppConfig.success,
backgroundColor: !(widget.controller.sessionLoader
.value!.accuracy ==
100)
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
: Color.alphaBlend(
AppConfig.goldLight.withValues(alpha: 0.3),
Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
),
),
StatCard(
icon: Icons.alarm,
text:
"${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}",
isAchievement: timeAchievement,
achievementText: "+ $numBonusPoints XP",
child: TimeStarsWidget(
elapsedSeconds: widget
.controller.sessionLoader.value!.elapsedSeconds,
timeForBonus: widget
.controller.sessionLoader.value!.timeForBonus,
),
),
Column(
children: [
//expanded row button
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () =>
widget.controller.reloadSession(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).anotherRound,
),
],
),
),
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () => Navigator.of(context).pop(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).quit,
),
],
),
),
],
),
],
),
),
],
),
),
if (shouldShowRain)
const StarRainWidget(
showBlast: true,
rainDuration: Duration(seconds: 5),
),
],
);
},
);
}
}
class TimeStarsWidget extends StatelessWidget {
final int elapsedSeconds;
final int timeForBonus;
const TimeStarsWidget({
required this.elapsedSeconds,
required this.timeForBonus,
super.key,
});
int get starCount {
if (elapsedSeconds <= timeForBonus) return 5;
if (elapsedSeconds <= timeForBonus * 1.5) return 4;
if (elapsedSeconds <= timeForBonus * 2) return 3;
if (elapsedSeconds <= timeForBonus * 2.5) return 2;
return 1; // anything above 2.5x timeForBonus
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
5,
(index) => Icon(
index < starCount ? Icons.star : Icons.star_outline,
color: AppConfig.goldLight,
size: 36,
),
),
);
}
}