* 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>
292 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|