Merge pull request #4861 from pangeachat/4858-play-test-1216

4858 play test 1216
This commit is contained in:
ggurdin 2025-12-16 16:26:04 -05:00 committed by GitHub
commit 69c64c1e45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 608 additions and 471 deletions

View file

@ -446,8 +446,10 @@ class HtmlMessage extends StatelessWidget {
enabled: token.lemma.saveVocab,
targetId: overlayController!.tokenEmojiPopupKey(token),
selectModeNotifier: overlayController!.selectedMode,
selectedTokenNotifier:
overlayController!.selectedTokenNotifier,
onTap: () =>
overlayController!.onClickOverlayMessageToken(token),
constructEmojiNotifier: overlayController!
.selectModeController.constructEmojiNotifier,
),
if (renderer.showCenterStyling &&
token != null &&
@ -946,9 +948,10 @@ class HtmlMessage extends StatelessWidget {
// Use TokenEmojiButton to ensure consistent vertical alignment for non-token elements (e.g., emojis) in practice mode.
TokenEmojiButton(
selectModeNotifier: overlayController!.selectedMode,
selectedTokenNotifier:
overlayController!.selectedTokenNotifier,
onTap: () {},
enabled: false,
constructEmojiNotifier: overlayController!
.selectModeController.constructEmojiNotifier,
),
RichText(
text: TextSpan(

View file

@ -601,7 +601,7 @@ class Message extends StatelessWidget {
color: color,
visible:
isButton && !noBubble,
child:
builder: (context, _, __) =>
// Pangea#
Container(
decoration: BoxDecoration(

View file

@ -4,13 +4,12 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart';
import 'package:fluffychat/pangea/analytics_details_popup/vocab_details_emoji_selector.dart';
import 'package:fluffychat/pangea/analytics_details_popup/word_text_with_audio_button.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/common/widgets/shrinkable_text.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_widget.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
@ -51,146 +50,136 @@ class VocabDetailsView extends StatelessWidget {
? _construct.lemmaCategory.color(context)
: _construct.lemmaCategory.darkColor(context));
return LemmaMeaningBuilder(
langCode: _userL2!,
constructId: _construct.id,
builder: (context, controller) {
return AnalyticsDetailsViewContent(
title: Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
return ShrinkableText(
text: _construct.lemma,
maxWidth: constraints.maxWidth - 40.0,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: textColor,
),
);
},
),
if (MatrixState.pangeaController.userController.showTranscription)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: PhoneticTranscriptionWidget(
text: _construct.lemma,
textLanguage:
MatrixState.pangeaController.userController.userL2!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: textColor.withAlpha((0.7 * 255).toInt()),
fontSize: 18,
),
iconSize: _iconSize * 0.8,
),
),
],
),
subtitle: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
Text(
getGrammarCopy(
category: "POS",
lemma: _construct.category,
context: context,
) ??
_construct.lemma,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: textColor,
),
),
SizedBox(
width: _iconSize,
height: _iconSize,
child: MorphIcon(
morphFeature: MorphFeaturesEnum.Pos,
morphTag: _construct.category,
return AnalyticsDetailsViewContent(
title: Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
return ShrinkableText(
text: _construct.lemma,
maxWidth: constraints.maxWidth - 40.0,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: textColor,
),
),
],
);
},
),
if (MatrixState.pangeaController.userController.showTranscription)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: PhoneticTranscriptionWidget(
text: _construct.lemma,
textLanguage:
MatrixState.pangeaController.userController.userL2!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: textColor.withAlpha((0.7 * 255).toInt()),
fontSize: 18,
),
iconSize: _iconSize * 0.8,
),
const SizedBox(height: 16.0),
),
],
),
subtitle: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
Text(
L10n.of(context).vocabEmoji,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontStyle: FontStyle.italic,
getGrammarCopy(
category: "POS",
lemma: _construct.category,
context: context,
) ??
_construct.lemma,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: textColor,
),
),
LemmaHighlightEmojiRow(
controller: controller,
cId: constructId,
SizedBox(
width: _iconSize,
height: _iconSize,
child: MorphIcon(
morphFeature: MorphFeaturesEnum.Pos,
morphTag: _construct.category,
),
),
],
),
headerContent: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
spacing: 8.0,
children: [
Align(
alignment: Alignment.topLeft,
child: _userL2 == null
? Text(L10n.of(context).meaningNotFound)
: LemmaMeaningWidget(
controller: controller,
constructUse: _construct,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16.0),
Text(
L10n.of(context).vocabEmoji,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontStyle: FontStyle.italic,
color: textColor,
),
Align(
alignment: Alignment.topLeft,
child: Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
L10n.of(context).formSectionHeader,
),
Padding(
padding: const EdgeInsets.all(16.0),
child: VocabDetailsEmojiSelector(constructId),
),
],
),
headerContent: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
spacing: 8.0,
children: [
Align(
alignment: Alignment.topLeft,
child: _userL2 == null
? Text(L10n.of(context).meaningNotFound)
: LemmaMeaningWidget(
constructId: constructId,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6.0),
...forms.mapIndexed(
(i, form) => Row(
mainAxisSize: MainAxisSize.min,
children: [
WordTextWithAudioButton(
text: form,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
),
),
Align(
alignment: Alignment.topLeft,
child: Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
L10n.of(context).formSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6.0),
...forms.mapIndexed(
(i, form) => Row(
mainAxisSize: MainAxisSize.min,
children: [
WordTextWithAudioButton(
text: form,
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(
color: textColor,
),
uniqueID: "$form-${_construct.lemma}-$i",
langCode: _userL2!,
),
if (i != forms.length - 1) const Text(", "),
],
uniqueID: "$form-${_construct.lemma}-$i",
langCode: _userL2!,
),
),
],
if (i != forms.length - 1) const Text(", "),
],
),
),
),
],
],
),
),
),
xpIcon: _construct.lemmaCategory.icon(_iconSize + 6.0),
constructId: constructId,
);
},
],
),
),
xpIcon: _construct.lemmaCategory.icon(_iconSize + 6.0),
constructId: constructId,
);
}
}

View file

@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabDetailsEmojiSelector extends StatefulWidget {
final ConstructIdentifier constructId;
const VocabDetailsEmojiSelector(
this.constructId, {
super.key,
});
@override
State<VocabDetailsEmojiSelector> createState() =>
VocabDetailsEmojiSelectorState();
}
class VocabDetailsEmojiSelectorState extends State<VocabDetailsEmojiSelector>
with LemmaEmojiSetter {
String? selectedEmoji;
@override
void initState() {
super.initState();
_setInitialEmoji();
}
@override
void didUpdateWidget(covariant VocabDetailsEmojiSelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId) {
_setInitialEmoji();
}
}
void _setInitialEmoji() {
setState(
() {
selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull;
},
);
}
Future<void> _setEmoji(String emoji) async {
setState(() => selectedEmoji = emoji);
await setLemmaEmoji(
widget.constructId,
emoji,
"emoji-choice-item-$emoji-${widget.constructId.lemma}",
);
showLemmaEmojiSnackbar(context, widget.constructId, emoji);
}
@override
Widget build(BuildContext context) {
return LemmaHighlightEmojiRow(
cId: widget.constructId,
langCode: MatrixState.pangeaController.userController.userL2Code!,
emoji: selectedEmoji,
onEmojiSelected: _setEmoji,
);
}
}

View file

@ -10,7 +10,7 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
mixin LemmaEmojiSetter<T extends StatefulWidget> on State<T> {
mixin LemmaEmojiSetter {
Future<void> setLemmaEmoji(
ConstructIdentifier constructId,
String emoji,
@ -26,11 +26,13 @@ mixin LemmaEmojiSetter<T extends StatefulWidget> on State<T> {
await constructId.setUserLemmaInfo(
constructId.userLemmaInfo.copyWith(emojis: [emoji]),
);
_showSnackbar(constructId, emoji);
}
void _showSnackbar(ConstructIdentifier constructId, String emoji) {
void showLemmaEmojiSnackbar(
BuildContext context,
ConstructIdentifier constructId,
String emoji,
) {
if (InstructionsEnum.setLemmaEmoji.isToggledOff) return;
InstructionsEnum.setLemmaEmoji.setToggledOff(true);

View file

@ -11,7 +11,8 @@ class PressableButton extends StatefulWidget {
final double buttonHeight;
final bool depressed;
final Color color;
final Widget child;
final Widget Function(BuildContext context, bool depressed, Color shadowColor)
builder;
final void Function()? onPressed;
final Stream? triggerAnimation;
@ -22,7 +23,7 @@ class PressableButton extends StatefulWidget {
const PressableButton({
required this.borderRadius,
required this.child,
required this.builder,
required this.onPressed,
required this.color,
this.buttonHeight = 4,
@ -137,8 +138,15 @@ class PressableButtonState extends State<PressableButton>
@override
Widget build(BuildContext context) {
final shadowColor = Color.alphaBlend(
Colors.black.withAlpha(
(255 * widget.colorFactor).round(),
),
widget.color,
);
if (!widget.visible) {
return widget.child;
return widget.builder(context, _depressed, shadowColor);
}
return MouseRegion(
@ -160,12 +168,7 @@ class PressableButtonState extends State<PressableButton>
SizedBox(height: _tweenAnimation.value),
Container(
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.black.withAlpha(
(255 * widget.colorFactor).round(),
),
widget.color,
),
color: shadowColor,
borderRadius: widget.borderRadius,
),
padding: EdgeInsets.only(
@ -173,7 +176,16 @@ class PressableButtonState extends State<PressableButton>
? widget.buttonHeight - _tweenAnimation.value
: 0,
),
child: child,
child: Container(
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
),
child: widget.builder(
context,
_depressed || _tweenAnimation.value > 0,
shadowColor,
),
),
),
],
),
@ -181,12 +193,6 @@ class PressableButtonState extends State<PressableButton>
],
);
},
child: Container(
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
),
child: widget.child,
),
),
),
);

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/app_config.dart';
class ShimmerBackground extends StatelessWidget {
final Widget child;
final Color shimmerColor;
final bool enabled;
const ShimmerBackground({
super.key,
required this.child,
this.shimmerColor = AppConfig.goldLight,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (enabled)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Shimmer.fromColors(
baseColor: shimmerColor.withValues(alpha: 0.1),
highlightColor: shimmerColor.withValues(alpha: 0.6),
direction: ShimmerDirection.ltr,
child: Container(
decoration: BoxDecoration(
color: shimmerColor.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
),
),
),
),
],
);
}
}

View file

@ -1,41 +1,40 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaHighlightEmojiRow extends StatefulWidget {
final LemmaMeaningBuilderState controller;
final ConstructIdentifier cId;
final String langCode;
final Function(String) onEmojiSelected;
final String? emoji;
final Widget? selectedEmojiBadge;
const LemmaHighlightEmojiRow({
super.key,
required this.controller,
required this.cId,
required this.langCode,
required this.onEmojiSelected,
this.emoji,
this.selectedEmojiBadge,
});
@override
LemmaHighlightEmojiRowState createState() => LemmaHighlightEmojiRowState();
State<LemmaHighlightEmojiRow> createState() => LemmaHighlightEmojiRowState();
}
class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow>
with LemmaEmojiSetter {
bool _showShimmer = true;
String? _selectedEmoji;
class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow> {
late StreamSubscription<AnalyticsStreamUpdate> _analyticsSubscription;
Timer? _shimmerTimer;
@override
void initState() {
@ -43,157 +42,145 @@ class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow>
_analyticsSubscription = MatrixState
.pangeaController.getAnalytics.analyticsStream.stream
.listen(_onAnalyticsUpdate);
_setShimmer();
}
@override
void didUpdateWidget(LemmaHighlightEmojiRow oldWidget) {
if (oldWidget.cId != widget.cId) _setShimmer();
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_analyticsSubscription.cancel();
_shimmerTimer?.cancel();
super.dispose();
}
void _setShimmer() {
setState(() {
_selectedEmoji = widget.cId.userSetEmoji.firstOrNull;
_showShimmer = _selectedEmoji == null;
if (_showShimmer) {
_shimmerTimer?.cancel();
_shimmerTimer = Timer(const Duration(milliseconds: 1500), () {
if (mounted) {
setState(() {
_showShimmer = false;
_shimmerTimer?.cancel();
_shimmerTimer = null;
});
}
});
}
});
}
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID != null) {
OverlayUtil.showPointsGained(update.targetID!, update.points, context);
}
}
Future<void> _setEmoji(String emoji, BuildContext context) async {
try {
setState(() => _selectedEmoji = emoji);
await setLemmaEmoji(
widget.cId,
emoji,
"emoji-choice-item-$emoji-${widget.cId.lemma}",
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(data: widget.cId.toJson(), e: e, s: s);
}
}
@override
Widget build(BuildContext context) {
if (widget.controller.isLoading) {
return const CircularProgressIndicator.adaptive();
}
return LemmaMeaningBuilder(
langCode: widget.langCode,
constructId: widget.cId,
builder: (context, controller) {
if (controller.isLoading) {
return const CircularProgressIndicator.adaptive();
}
final emojis = widget.controller.lemmaInfo?.emoji;
if (widget.controller.error != null || emojis == null || emojis.isEmpty) {
return const SizedBox.shrink();
}
final emojis = controller.lemmaInfo?.emoji;
if (controller.error != null || emojis == null || emojis.isEmpty) {
return const SizedBox.shrink();
}
return Material(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Container(
padding: const EdgeInsets.all(8),
height: 80,
alignment: Alignment.center,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
return SizedBox(
height: 60.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: emojis
.map(
(emoji) => EmojiChoiceItem(
cId: widget.cId,
emoji: emoji,
onSelectEmoji: () => _setEmoji(emoji, context),
isDisplay: _selectedEmoji == emoji,
showShimmer: _showShimmer,
onSelectEmoji: () => widget.onEmojiSelected(emoji),
selected: widget.emoji == emoji,
transformTargetId:
"emoji-choice-item-$emoji-${widget.cId.lemma}",
badge: widget.emoji == emoji
? widget.selectedEmojiBadge
: null,
),
)
.toList(),
),
),
),
);
},
);
}
}
class EmojiChoiceItem extends StatefulWidget {
final ConstructIdentifier cId;
final String emoji;
final VoidCallback onSelectEmoji;
final bool isDisplay;
final bool showShimmer;
final bool selected;
final String transformTargetId;
final Widget? badge;
const EmojiChoiceItem({
super.key,
required this.cId,
required this.emoji,
required this.isDisplay,
required this.selected,
required this.onSelectEmoji,
required this.showShimmer,
required this.transformTargetId,
this.badge,
});
@override
EmojiChoiceItemState createState() => EmojiChoiceItemState();
State<EmojiChoiceItem> createState() => EmojiChoiceItemState();
}
class EmojiChoiceItemState extends State<EmojiChoiceItem> {
bool _isHovered = false;
bool shimmer = true;
Timer? _shimmerTimer;
@override
void initState() {
super.initState();
_showShimmer();
}
@override
void didUpdateWidget(covariant EmojiChoiceItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.emoji != widget.emoji) {
_showShimmer();
}
}
@override
void dispose() {
_shimmerTimer?.cancel();
super.dispose();
}
void _showShimmer() {
setState(() => shimmer = true);
_shimmerTimer?.cancel();
_shimmerTimer = Timer(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => shimmer = false);
});
}
LayerLink get layerLink =>
MatrixState.pAnyState.layerLinkAndKey(widget.transformTargetId).link;
@override
Widget build(BuildContext context) {
final shimmerColor = (Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Theme.of(context).colorScheme.primary;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
return HoverBuilder(
builder: (context, hovered) => GestureDetector(
onTap: widget.onSelectEmoji,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Stack(
children: [
CompositedTransformTarget(
child: Stack(
children: [
ShimmerBackground(
enabled: shimmer,
shimmerColor: (Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Theme.of(context).colorScheme.primary,
child: CompositedTransformTarget(
link: layerLink,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isHovered
color: hovered
? Theme.of(context).colorScheme.primary.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
border: widget.isDisplay
border: widget.selected
? Border.all(
color: AppConfig.goldLight,
width: 4,
color: AppConfig.goldLight.withAlpha(200),
width: 2,
)
: null,
),
@ -203,26 +190,14 @@ class EmojiChoiceItemState extends State<EmojiChoiceItem> {
),
),
),
if (widget.showShimmer)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Shimmer.fromColors(
baseColor: shimmerColor.withValues(alpha: 0.1),
highlightColor: shimmerColor.withValues(alpha: 0.6),
direction: ShimmerDirection.ltr,
child: Container(
decoration: BoxDecoration(
color: shimmerColor.withValues(alpha: 0.3),
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
),
),
),
),
),
],
),
),
if (widget.badge != null)
Positioned(
right: 0,
bottom: 0,
child: widget.badge!,
),
],
),
),
);

View file

@ -1,71 +1,74 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaMeaningWidget extends StatelessWidget {
final LemmaMeaningBuilderState controller;
final ConstructUses constructUse;
final ConstructIdentifier constructId;
final TextStyle? style;
final InlineSpan? leading;
const LemmaMeaningWidget({
super.key,
required this.controller,
required this.constructUse,
required this.constructId,
this.style,
this.leading,
});
@override
Widget build(BuildContext context) {
if (controller.isLoading) {
return const TextLoadingShimmer();
}
return LemmaMeaningBuilder(
langCode: MatrixState.pangeaController.userController.userL2!.langCode,
constructId: constructId,
builder: (context, controller) {
if (controller.isLoading) {
return const TextLoadingShimmer();
}
if (controller.error != null) {
if (controller.error is UnsubscribedException) {
return ErrorIndicator(
message: L10n.of(context).subscribeToUnlockDefinitions,
style: style,
onTap: () {
MatrixState.pangeaController.subscriptionController
.showPaywall(context);
},
);
}
return ErrorIndicator(
message: L10n.of(context).errorFetchingDefinition,
style: style,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: RichText(
textAlign: leading == null ? TextAlign.center : TextAlign.start,
text: TextSpan(
if (controller.error != null) {
if (controller.error is UnsubscribedException) {
return ErrorIndicator(
message: L10n.of(context).subscribeToUnlockDefinitions,
style: style,
children: [
if (leading != null) leading!,
if (leading != null)
const WidgetSpan(child: SizedBox(width: 6.0)),
TextSpan(
text: controller.lemmaInfo?.meaning,
onTap: () {
MatrixState.pangeaController.subscriptionController
.showPaywall(context);
},
);
}
return ErrorIndicator(
message: L10n.of(context).errorFetchingDefinition,
style: style,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: RichText(
textAlign: leading == null ? TextAlign.center : TextAlign.start,
text: TextSpan(
style: style,
children: [
if (leading != null) leading!,
if (leading != null)
const WidgetSpan(child: SizedBox(width: 6.0)),
TextSpan(
text: controller.lemmaInfo?.meaning,
),
],
),
],
),
),
),
),
],
],
);
},
);
}
}

View file

@ -44,13 +44,14 @@ class FullWidthButtonState extends State<FullWidthButton> {
onPressed: widget.onPressed,
borderRadius: BorderRadius.circular(36),
color: Theme.of(context).colorScheme.primary,
child: Container(
// internal padding
builder: (context, depressed, shadowColor) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 40,
decoration: BoxDecoration(
color: widget.enabled
? Theme.of(context).colorScheme.primary
? depressed
? shadowColor
: Theme.of(context).colorScheme.primary
: Theme.of(context).disabledColor,
borderRadius: BorderRadius.circular(36),
),

View file

@ -153,4 +153,13 @@ class PracticeTarget {
}
return null;
}
bool get hasAnyCorrectChoices {
for (final response in record.responses) {
if (response.isCorrect) {
return true;
}
}
return false;
}
}

View file

@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
enum MessagePracticeMode {
@ -70,4 +71,19 @@ enum MessagePracticeMode {
MessagePracticeMode.wordMeaning,
MessagePracticeMode.wordEmoji,
];
InstructionsEnum? get instruction {
switch (this) {
case MessagePracticeMode.listening:
return InstructionsEnum.chooseWordAudio;
case MessagePracticeMode.wordMeaning:
return InstructionsEnum.chooseLemmaMeaning;
case MessagePracticeMode.wordEmoji:
return InstructionsEnum.chooseEmoji;
case MessagePracticeMode.wordMorph:
return InstructionsEnum.chooseMorphs;
default:
return null;
}
}
}

View file

@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/common/widgets/choice_animation.dart';
import 'package:fluffychat/pangea/common/widgets/shimmer_background.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/practice_activities/practice_choice.dart';
@ -30,21 +31,36 @@ class MatchActivityCard extends StatelessWidget {
ActivityTypeEnum get activityType => currentActivity.activityType;
Widget choiceDisplayContent(
BuildContext context,
String choice,
double? fontSize,
) {
switch (activityType) {
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.wordMeaning:
return Text(
choice,
style: TextStyle(fontSize: fontSize),
textAlign: TextAlign.center,
return ShimmerBackground(
enabled: controller.selectedChoice == null &&
!currentActivity.practiceTarget.hasAnyCorrectChoices,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
choice,
style: TextStyle(fontSize: fontSize),
textAlign: TextAlign.center,
),
),
);
case ActivityTypeEnum.wordFocusListening:
return Icon(
Icons.volume_up,
size: fontSize,
return ShimmerBackground(
enabled: controller.selectedChoice == null &&
!currentActivity.practiceTarget.hasAnyCorrectChoices,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.volume_up,
size: fontSize,
),
),
);
default:
debugger(when: kDebugMode);
@ -90,7 +106,8 @@ class MatchActivityCard extends StatelessWidget {
isSelected: controller.selectedChoice == cf,
isCorrect: wasCorrect,
constructForm: cf,
content: choiceDisplayContent(cf.choiceContent, fontSize),
content:
choiceDisplayContent(context, cf.choiceContent, fontSize),
audioContent:
activityType == ActivityTypeEnum.wordFocusListening
? cf.choiceContent

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/practice_activity_card.dart';
@ -62,6 +63,15 @@ class ReadingAssistanceInputBarState extends State<ReadingAssistanceInputBar> {
),
],
),
if (widget.controller.practiceMode.instruction != null)
InstructionsInlineTooltip(
instructionsEnum: widget.controller.practiceMode.instruction!,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 4.0,
),
animate: false,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Material(

View file

@ -35,11 +35,11 @@ class ToolbarButton extends StatelessWidget {
playSound: true,
colorFactor:
Theme.of(context).brightness == Brightness.light ? 0.55 : 0.3,
child: Container(
builder: (context, depressed, shadowColor) => Container(
height: 40.0,
width: 40.0,
decoration: BoxDecoration(
color: color,
color: depressed ? shadowColor : color,
shape: BoxShape.circle,
),
child: Icon(

View file

@ -345,12 +345,15 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
playSound: enabled && mode != SelectMode.audio,
colorFactor:
theme.brightness == Brightness.light ? 0.55 : 0.3,
child: AnimatedContainer(
builder: (context, depressed, shadowColor) =>
AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: buttonSize,
width: buttonSize,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
color: depressed
? shadowColor
: theme.colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: _SelectModeButtonIcon(
@ -577,12 +580,12 @@ class _MoreButton extends StatelessWidget {
onPressed: () => _showMenu(context),
playSound: true,
colorFactor: theme.brightness == Brightness.light ? 0.55 : 0.3,
child: AnimatedContainer(
builder: (context, depressed, shadowColor) => AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: 40.0,
width: 40.0,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
color: depressed ? shadowColor : theme.colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: const Icon(

View file

@ -6,7 +6,9 @@ import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
@ -71,7 +73,7 @@ class _AudioLoader extends AsyncLoader<(PangeaAudioFile, File?)> {
}
}
class SelectModeController {
class SelectModeController with LemmaEmojiSetter {
final PangeaMessageEvent messageEvent;
final _TranscriptionLoader _transcriptLoader;
final _TranslationLoader _translationLoader;
@ -86,10 +88,14 @@ class SelectModeController {
_sttTranslationLoader = _STTTranslationLoader(messageEvent);
ValueNotifier<SelectMode?> selectedMode = ValueNotifier<SelectMode?>(null);
ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier =
ValueNotifier<(ConstructIdentifier, String)?>(null);
final StreamController contentChangedStream = StreamController.broadcast();
void dispose() {
selectedMode.dispose();
constructEmojiNotifier.dispose();
_transcriptLoader.dispose();
_translationLoader.dispose();
_sttTranslationLoader.dispose();
@ -185,6 +191,12 @@ class SelectModeController {
selectedMode.value = mode;
}
void setTokenEmoji(
ConstructIdentifier constructId,
String emoji,
) =>
constructEmojiNotifier.value = (constructId, emoji);
Future<void> fetchAudio() => _audioLoader.load();
Future<void> fetchTranslation() => _translationLoader.load();
Future<void> fetchTranscription() => _transcriptLoader.load();

View file

@ -1,19 +1,16 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/lemma_emoji_picker.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/select_mode_buttons.dart';
import 'package:fluffychat/widgets/matrix.dart';
class TokenEmojiButton extends StatefulWidget {
final ValueNotifier<SelectMode?> selectModeNotifier;
final ValueNotifier<PangeaToken?> selectedTokenNotifier;
final ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier;
final VoidCallback onTap;
final PangeaToken? token;
final String? targetId;
@ -22,7 +19,8 @@ class TokenEmojiButton extends StatefulWidget {
const TokenEmojiButton({
super.key,
required this.selectModeNotifier,
required this.selectedTokenNotifier,
required this.constructEmojiNotifier,
required this.onTap,
this.token,
this.targetId,
this.enabled = true,
@ -49,14 +47,14 @@ class TokenEmojiButtonState extends State<TokenEmojiButton>
_initAnimation();
_prevMode = widget.selectModeNotifier.value;
widget.selectModeNotifier.addListener(_onUpdateSelectMode);
widget.selectedTokenNotifier.addListener(_onSelectToken);
widget.constructEmojiNotifier.addListener(_onUpdateEmoji);
}
@override
void dispose() {
_controller?.dispose();
widget.selectModeNotifier.removeListener(_onUpdateSelectMode);
widget.selectedTokenNotifier.removeListener(_onSelectToken);
widget.constructEmojiNotifier.removeListener(_onUpdateEmoji);
super.dispose();
}
@ -87,68 +85,18 @@ class TokenEmojiButtonState extends State<TokenEmojiButton>
_prevMode = mode;
}
void _onSelectToken() {
final selected = widget.selectedTokenNotifier.value;
if (selected != null && selected == widget.token) {
showTokenEmojiPopup();
void _onUpdateEmoji() {
final value = widget.constructEmojiNotifier.value;
if (value == null) return;
final constructId = value.$1;
final emoji = value.$2;
if (mounted && constructId == widget.token?.vocabConstructID) {
setState(() => _emoji = emoji);
}
}
void showTokenEmojiPopup() {
if (widget.targetId == null || widget.token == null) return;
OverlayUtil.showPositionedCard(
overlayKey: "overlay_emoji_selector",
context: context,
cardToShow: LemmaMeaningBuilder(
langCode: MatrixState.pangeaController.userController.userL2Code!,
constructId: widget.token!.vocabConstructID,
builder: (context, controller) {
return Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: LemmaEmojiPicker(
emojis: controller.lemmaInfo?.emoji ?? [],
onSelect: (emoji) {
_setTokenEmoji(emoji);
MatrixState.pAnyState.closeOverlay("overlay_emoji_selector");
},
loading: controller.isLoading,
),
),
);
},
),
transformTargetId: widget.targetId!,
closePrevOverlay: false,
addBorder: false,
maxWidth: (40 * 5) + (4 * 5) + 16,
maxHeight: 60,
);
}
void _setTokenEmoji(String emoji) {
setState(() => _emoji = emoji);
if (widget.targetId == null || widget.token == null) return;
setLemmaEmoji(
widget.token!.vocabConstructID,
emoji,
widget.targetId,
).catchError((e, s) {
ErrorHandler.logError(
data: widget.token!.toJson(),
e: e,
s: s,
);
});
}
@override
Widget build(BuildContext context) {
if (_sizeAnimation == null) {
@ -183,7 +131,7 @@ class TokenEmojiButtonState extends State<TokenEmojiButton>
child: child,
builder: (context, child) {
return InkWell(
onTap: showTokenEmojiPopup,
onTap: widget.onTap,
borderRadius: BorderRadius.circular(99.0),
child: Container(
height: _sizeAnimation!.value,

View file

@ -3,64 +3,107 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/lemma_emoji_picker.dart';
import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaReactionPicker extends StatelessWidget {
class LemmaReactionPicker extends StatefulWidget {
final Event? event;
final ConstructIdentifier construct;
final ConstructIdentifier constructId;
final Function(String)? onSetEmoji;
final String langCode;
const LemmaReactionPicker({
super.key,
required this.construct,
required this.constructId,
required this.onSetEmoji,
required this.langCode,
this.event,
});
Future<void> setEmoji(
String emoji,
List<String> emojis,
) async {
if (event?.room.timeline == null) {
throw Exception("Timeline is null in reaction picker");
}
@override
State<LemmaReactionPicker> createState() => LemmaReactionPickerState();
}
final client = event!.room.client;
final userSentEmojis = event!
class LemmaReactionPickerState extends State<LemmaReactionPicker>
with LemmaEmojiSetter {
String? _selectedEmoji;
@override
void initState() {
super.initState();
_setInitialEmoji();
}
@override
void didUpdateWidget(covariant LemmaReactionPicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId) {
_setInitialEmoji();
}
}
void _setInitialEmoji() {
setState(
() {
_selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull;
},
);
}
Event? _sentReaction(String emoji) {
final userSentEmojis = widget.event!
.aggregatedEvents(
event!.room.timeline!,
widget.event!.room.timeline!,
RelationshipTypes.reaction,
)
.where(
(e) =>
e.senderId == client.userID &&
emojis.contains(e.content.tryGetMap('m.relates_to')?['key']),
(e) => e.senderId == Matrix.of(context).client.userID,
);
final reactionEvent = userSentEmojis.firstWhereOrNull(
return userSentEmojis.firstWhereOrNull(
(e) => e.content.tryGetMap('m.relates_to')?['key'] == emoji,
);
}
Future<void> _setEmoji(String emoji) async {
setState(() => _selectedEmoji = emoji);
widget.onSetEmoji?.call(emoji);
await setLemmaEmoji(
widget.constructId,
emoji,
"emoji-choice-item-$emoji-${widget.constructId.lemma}",
);
if (mounted) {
showLemmaEmojiSnackbar(context, widget.constructId, emoji);
}
}
Future<void> _sendOrRedactReaction(String emoji) async {
if (widget.event?.room.timeline == null) return;
try {
final reactionEvent = _sentReaction(emoji);
if (reactionEvent != null) {
await reactionEvent.redactEvent();
return;
}
await Future.wait([
...userSentEmojis.map((e) => e.redactEvent()),
event!.room.sendReaction(event!.eventId, emoji),
]);
await widget.event!.room.sendReaction(
widget.event!.eventId,
emoji,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'emoji': emoji,
'eventId': event?.eventId,
'eventId': widget.event?.eventId,
},
);
}
@ -68,44 +111,28 @@ class LemmaReactionPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LemmaMeaningBuilder(
langCode: langCode,
constructId: construct,
builder: (context, controller) {
final sentReactions = <String>{};
if (event?.room.timeline != null) {
sentReactions.addAll(
event!
.aggregatedEvents(
event!.room.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
}
return LemmaEmojiPicker(
emojis: controller.lemmaInfo?.emoji ?? [],
onSelect: event?.room.timeline != null
? (emoji) => setEmoji(
emoji,
controller.lemmaInfo?.emoji ?? [],
)
: null,
disabled: (emoji) => sentReactions.contains(emoji),
loading: controller.isLoading,
);
},
return LemmaHighlightEmojiRow(
cId: widget.constructId,
langCode: widget.langCode,
onEmojiSelected: (emoji) => emoji != _selectedEmoji
? _setEmoji(emoji)
: _sendOrRedactReaction(emoji),
emoji: _selectedEmoji,
selectedEmojiBadge: widget.event != null &&
_selectedEmoji != null &&
_sentReaction(_selectedEmoji!) == null
? CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
radius: 8.0,
child: Center(
child: Icon(
Icons.send,
size: 12.0,
color: Theme.of(context).colorScheme.surface,
),
),
)
: null,
);
}
}

View file

@ -51,6 +51,10 @@ class ReadingAssistanceContent extends StatelessWidget {
onClose: () => overlayController.updateSelectedSpan(null),
langCode: overlayController.pangeaMessageEvent.messageDisplayLangCode,
onDismissNewWordOverlay: () => overlayController.setState(() {}),
setEmoji: (emoji) => overlayController.selectModeController.setTokenEmoji(
overlayController.selectedToken!.vocabConstructID,
emoji,
),
onFlagTokenInfo: (LemmaInfoResponse lemmaInfo, String phonetics) {
if (selectedTokenIndex < 0) return;
final requestData = TokenInfoFeedbackRequestData(

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/toolbar/layout/message_selection_positioner.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/select_mode_buttons.dart';
import 'package:fluffychat/pangea/toolbar/word_card/reading_assistance_content.dart';
class WordCardSwitcher extends StatelessWidget {
@ -19,15 +18,13 @@ class WordCardSwitcher extends StatelessWidget {
? Alignment.bottomRight
: Alignment.bottomLeft,
duration: FluffyThemes.animationDuration,
child: mode == SelectMode.emoji
? const SizedBox()
: controller.widget.overlayController.selectedToken != null
? ReadingAssistanceContent(
overlayController: controller.widget.overlayController,
)
: MessageReactionPicker(
chatController: controller.widget.chatController,
),
child: controller.widget.overlayController.selectedToken != null
? ReadingAssistanceContent(
overlayController: controller.widget.overlayController,
)
: MessageReactionPicker(
chatController: controller.widget.chatController,
),
);
},
);

View file

@ -29,12 +29,14 @@ class WordZoomWidget extends StatelessWidget {
final VoidCallback? onDismissNewWordOverlay;
final Function(LemmaInfoResponse, String)? onFlagTokenInfo;
final Function(String)? setEmoji;
const WordZoomWidget({
super.key,
required this.token,
required this.construct,
required this.langCode,
this.setEmoji,
this.onClose,
this.wordIsNew = false,
this.event,
@ -141,8 +143,9 @@ class WordZoomWidget extends StatelessWidget {
iconSize: 24.0,
),
LemmaReactionPicker(
construct: construct,
constructId: construct,
langCode: langCode,
onSetEmoji: setEmoji,
event: event,
),
LemmaMeaningDisplay(