fix: make analytics practice view scrollable, fix heights of top elements to prevent jumping around (#5513)

This commit is contained in:
ggurdin 2026-01-29 12:45:08 -05:00 committed by GitHub
parent 3dddf1d8bd
commit 85a2b9efe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 331 additions and 261 deletions

View file

@ -1,6 +1,5 @@
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';
@ -50,42 +49,110 @@ class ExampleMessageUtil {
String? form,
PangeaMessageEvent messageEvent,
) {
PangeaToken? token;
String? text;
List<PangeaToken>? tokens;
int targetTokenIndex = -1;
if (messageEvent.isAudioMessage) {
final stt = messageEvent.getSpeechToTextLocal();
if (stt == null) return null;
final tokens = stt.transcript.sttTokens.map((t) => t.token).toList();
token = tokens.firstWhereOrNull(
(token) => token.text.content == form,
);
tokens = stt.transcript.sttTokens.map((t) => t.token).toList();
targetTokenIndex = tokens.indexWhere((t) => t.text.content == form);
text = stt.transcript.text;
} else {
final tokens = messageEvent.messageDisplayRepresentation?.tokens;
tokens = messageEvent.messageDisplayRepresentation?.tokens;
if (tokens == null || tokens.isEmpty) return null;
token = tokens.firstWhereOrNull(
(token) => token.text.content == form,
);
targetTokenIndex = tokens.indexWhere((t) => t.text.content == form);
text = messageEvent.messageDisplayText;
}
if (token == null) return null;
if (targetTokenIndex == -1) {
return null;
}
final before = text.characters.take(token.text.offset).toString();
final after = text.characters
.skip(token.text.offset + token.text.content.characters.length)
final targetToken = tokens[targetTokenIndex];
const maxContextChars = 100;
final targetStart = targetToken.text.offset;
final targetEnd = targetStart + targetToken.text.content.characters.length;
final totalChars = text.characters.length;
final beforeAvailable = targetStart;
final afterAvailable = totalChars - targetEnd;
// ---------- Dynamic budget split ----------
int beforeBudget = maxContextChars ~/ 2;
int afterBudget = maxContextChars - beforeBudget;
if (beforeAvailable < beforeBudget) {
afterBudget += beforeBudget - beforeAvailable;
beforeBudget = beforeAvailable;
} else if (afterAvailable < afterBudget) {
beforeBudget += afterBudget - afterAvailable;
afterBudget = afterAvailable;
}
// ---------- BEFORE ----------
int beforeStartOffset = 0;
bool trimmedBefore = false;
if (beforeAvailable > beforeBudget) {
final desiredStart = targetStart - beforeBudget;
for (int i = 0; i < targetTokenIndex; i++) {
final token = tokens[i];
final tokenEnd =
token.text.offset + token.text.content.characters.length;
if (tokenEnd > desiredStart) {
beforeStartOffset = token.text.offset;
trimmedBefore = true;
break;
}
}
}
final before = text.characters
.skip(beforeStartOffset)
.take(targetStart - beforeStartOffset)
.toString();
// ---------- AFTER ----------
int afterEndOffset = totalChars;
bool trimmedAfter = false;
if (afterAvailable > afterBudget) {
final desiredEnd = targetEnd + afterBudget;
for (int i = targetTokenIndex + 1; i < tokens.length; i++) {
final token = tokens[i];
if (token.text.offset >= desiredEnd) {
afterEndOffset = token.text.offset;
trimmedAfter = true;
break;
}
}
}
final after = text.characters
.skip(targetEnd)
.take(afterEndOffset - targetEnd)
.toString()
.trimRight();
return [
if (trimmedBefore) const TextSpan(text: ''),
TextSpan(text: before),
TextSpan(
text: token.text.content,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
text: targetToken.text.content,
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
];
}
}

View file

@ -112,110 +112,78 @@ class _AnalyticsActivityView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
final isColumnMode = FluffyThemes.isColumnMode(context);
TextStyle? titleStyle = isColumnMode
? Theme.of(context).textTheme.titleLarge
: Theme.of(context).textTheme.titleMedium;
titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold);
return ListView(
children: [
//per-activity instructions, add switch statement once there are more types
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.selectMeaning,
padding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
vertical: 8.0,
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
SizedBox(
height: 75.0,
child: ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
target.promptText(context),
textAlign: TextAlign.center,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (controller.widget.type == ConstructTypeEnum.vocab)
PhoneticTranscriptionWidget(
text:
target.target.tokens.first.vocabConstructID.lemma,
textLanguage: MatrixState
.pangeaController.userController.userL2!,
style: const TextStyle(fontSize: 14.0),
),
child: Column(
spacing: 12.0,
children: [
Text(
target.promptText(context),
textAlign: TextAlign.center,
style: FluffyThemes.isColumnMode(context)
? Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
)
: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (controller.widget.type ==
ConstructTypeEnum.vocab)
PhoneticTranscriptionWidget(
text: target
.target.tokens.first.vocabConstructID.lemma,
textLanguage: MatrixState
.pangeaController.userController.userL2!,
style: const TextStyle(fontSize: 14.0),
),
],
),
)
: const SizedBox.shrink(),
),
Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(
child: _AnalyticsPracticeCenterContent(
controller: controller,
),
),
),
Expanded(
child: _ActivityChoicesWidget(controller),
),
//reserve space for grammar category morph meaning to avoid shifting, but only in those questions
AnimatedBuilder(
animation: Listenable.merge([
controller.activityState,
controller.selectedMorphChoice,
]),
builder: (context, _) {
final activityState = controller.activityState.value;
final selectedChoice = controller.selectedMorphChoice.value;
final isGrammarCategory = activityState
is AsyncLoaded<MultipleChoicePracticeActivityModel> &&
activityState.value.activityType ==
ActivityTypeEnum.grammarCategory;
if (!isGrammarCategory) {
return const SizedBox.shrink();
}
return SizedBox(
height: 125.0,
child: selectedChoice == null
? const SizedBox.shrink()
: SingleChildScrollView(
child: MorphMeaningWidget(
feature: selectedChoice.feature,
tag: selectedChoice.tag,
blankErrorFeedback: true,
),
),
);
},
),
],
],
)
: const SizedBox.shrink(),
),
),
const SizedBox(height: 16.0),
Center(
child: _AnalyticsPracticeCenterContent(controller: controller),
),
const SizedBox(height: 16.0),
_ActivityChoicesWidget(controller),
const SizedBox(height: 16.0),
ListenableBuilder(
listenable: Listenable.merge([
controller.activityState,
controller.selectedMorphChoice,
]),
builder: (context, _) {
final activityState = controller.activityState.value;
final selectedChoice = controller.selectedMorphChoice.value;
if (activityState
is! AsyncLoaded<MultipleChoicePracticeActivityModel> ||
selectedChoice == null) {
return const SizedBox.shrink();
}
return MorphMeaningWidget(
feature: selectedChoice.feature,
tag: selectedChoice.tag,
blankErrorFeedback: true,
);
},
),
],
);
}
@ -234,32 +202,42 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
valueListenable: controller.activityTarget,
builder: (context, target, __) => switch (target?.target.activityType) {
null => const SizedBox(),
ActivityTypeEnum.grammarError => ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) => switch (state) {
AsyncLoaded(
value: final GrammarErrorPracticeActivityModel activity
) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ErrorBlankWidget(
activity: activity,
ActivityTypeEnum.grammarError => SizedBox(
height: 160.0,
child: SingleChildScrollView(
child: ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) => switch (state) {
AsyncLoaded(
value: final GrammarErrorPracticeActivityModel activity
) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ErrorBlankWidget(
activity: activity,
),
const SizedBox(height: 12),
_GrammarErrorTranslationButton(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
translation: activity.translation,
),
],
),
const SizedBox(height: 12),
_GrammarErrorTranslationButton(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
translation: activity.translation,
),
],
),
_ => const SizedBox(),
},
_ => const SizedBox(),
},
),
),
),
_ => _ExampleMessageWidget(
controller.getExampleMessage(target!.target),
_ => SizedBox(
height: 100.0,
child: Center(
child: _ExampleMessageWidget(
controller.getExampleMessage(target!.target),
),
),
),
},
);
@ -320,6 +298,51 @@ class _ErrorBlankWidget extends StatelessWidget {
final errorOffset = activity.errorOffset;
final errorLength = activity.errorLength;
const maxContextChars = 50;
final chars = text.characters;
final totalLength = chars.length;
// ---------- BEFORE ----------
int beforeStart = 0;
bool trimmedBefore = false;
if (errorOffset > maxContextChars) {
int desiredStart = errorOffset - maxContextChars;
// Snap left to nearest whitespace to avoid cutting words
while (desiredStart > 0 && chars.elementAt(desiredStart) != ' ') {
desiredStart--;
}
beforeStart = desiredStart;
trimmedBefore = true;
}
final before =
chars.skip(beforeStart).take(errorOffset - beforeStart).toString();
// ---------- AFTER ----------
int afterEnd = totalLength;
bool trimmedAfter = false;
final errorEnd = errorOffset + errorLength;
final afterChars = totalLength - errorEnd;
if (afterChars > maxContextChars) {
int desiredEnd = errorEnd + maxContextChars;
// Snap right to nearest whitespace
while (desiredEnd < totalLength && chars.elementAt(desiredEnd) != ' ') {
desiredEnd++;
}
afterEnd = desiredEnd;
trimmedAfter = true;
}
final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString();
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@ -339,8 +362,8 @@ class _ErrorBlankWidget extends StatelessWidget {
fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: [
if (errorOffset > 0)
TextSpan(text: text.characters.take(errorOffset).toString()),
if (trimmedBefore) const TextSpan(text: ''),
if (before.isNotEmpty) TextSpan(text: before),
WidgetSpan(
child: Container(
height: 4.0,
@ -351,10 +374,88 @@ class _ErrorBlankWidget extends StatelessWidget {
),
),
),
if (errorOffset + errorLength < text.length)
TextSpan(
text:
text.characters.skip(errorOffset + errorLength).toString(),
if (after.isNotEmpty) TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
],
),
),
);
}
}
class _GrammarErrorTranslationButton extends StatefulWidget {
final String translation;
const _GrammarErrorTranslationButton({
super.key,
required this.translation,
});
@override
State<_GrammarErrorTranslationButton> createState() =>
_GrammarErrorTranslationButtonState();
}
class _GrammarErrorTranslationButtonState
extends State<_GrammarErrorTranslationButton> {
bool _showTranslation = false;
void _toggleTranslation() {
if (_showTranslation) {
setState(() {
_showTranslation = false;
});
} else {
setState(() {
_showTranslation = true;
});
}
}
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: _toggleTranslation,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
if (_showTranslation)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
widget.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
),
),
)
else
ElevatedButton(
onPressed: _toggleTranslation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(8),
),
child: const Icon(
Icons.lightbulb_outline,
size: 20,
),
),
],
),
@ -398,43 +499,29 @@ class _ActivityChoicesWidget extends StatelessWidget {
],
),
AsyncLoaded<MultipleChoicePracticeActivityModel>(:final value) =>
LayoutBuilder(
builder: (context, constraints) {
ValueListenableBuilder(
valueListenable: controller.enableChoicesNotifier,
builder: (context, enabled, __) {
final choices = controller.filteredChoices(value);
final constrainedHeight =
constraints.maxHeight.clamp(0.0, 400.0);
final cardHeight = (constrainedHeight / (choices.length + 1))
.clamp(50.0, 80.0);
return ValueListenableBuilder(
valueListenable: controller.enableChoicesNotifier,
builder: (context, enabled, __) => Column(
children: [
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId: controller
.choiceTargetId(choice.choiceId),
choiceId: choice.choiceId,
onPressed: () => controller.onSelectChoice(
choice.choiceId,
),
cardHeight: cardHeight,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: enabled,
),
)
.toList(),
return Column(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId: controller.choiceTargetId(choice.choiceId),
choiceId: choice.choiceId,
onPressed: () => controller.onSelectChoice(
choice.choiceId,
),
cardHeight: 60.0,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: enabled,
),
),
],
),
)
.toList(),
);
},
),
@ -553,85 +640,3 @@ class _ChoiceCard extends StatelessWidget {
}
}
}
class _GrammarErrorTranslationButton extends StatefulWidget {
final String translation;
const _GrammarErrorTranslationButton({
super.key,
required this.translation,
});
@override
State<_GrammarErrorTranslationButton> createState() =>
_GrammarErrorTranslationButtonState();
}
class _GrammarErrorTranslationButtonState
extends State<_GrammarErrorTranslationButton> {
bool _showTranslation = false;
void _toggleTranslation() {
if (_showTranslation) {
setState(() {
_showTranslation = false;
});
} else {
setState(() {
_showTranslation = true;
});
}
}
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: _toggleTranslation,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
if (_showTranslation)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
widget.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
textAlign: TextAlign.center,
),
),
),
if (!_showTranslation)
ElevatedButton(
onPressed: _toggleTranslation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(8),
),
child: const Icon(
Icons.lightbulb_outline,
size: 20,
),
),
],
),
),
);
}
}

View file

@ -149,8 +149,6 @@ class _CardContainer extends StatelessWidget {
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,