3976 making emoji selection more of an activity (#4255)

* add shimmer in vocab page when no emoji selected

* fix shimmer in 2 column mode and add XP for first emoji selection

* add xp sparkle on emoji selection

* formatting, imports, widget name typo fix

* dont rebuild analytics page on every analytics stream update

* remove listener

* move animation and selection visual after slow function

for better visual flow and hopefully not noticeable stutter

* change transformTargetId into variable, update local display state before awaiting saving to analytics room

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
avashilling 2025-10-13 14:22:11 -04:00 committed by GitHub
parent 8086868b6c
commit bfc6356247
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 237 additions and 97 deletions

View file

@ -114,12 +114,9 @@ class VocabDetailsView extends StatelessWidget {
],
),
const SizedBox(height: 16.0),
LemmmaHighlightEmojiRow(
LemmaHighlightEmojiRow(
controller: controller,
isSelected: false,
cId: constructId,
onTapOverride: null,
iconSize: _iconSize,
),
],
),

View file

@ -161,8 +161,7 @@ class VocabAnalyticsListView extends StatelessWidget {
"/rooms/analytics/${vocabItem.id.type.string}/${vocabItem.id.string}",
),
constructUse: vocabItem,
emoji: vocabItem.id.userSetEmoji.firstOrNull ??
vocabItem.id.getLemmaInfoCached()?.emoji.firstOrNull,
emoji: vocabItem.id.userSetEmoji.firstOrNull,
);
},
childCount: _filteredVocab.length,

View file

@ -15,7 +15,7 @@ import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dar
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsPage extends StatelessWidget {
class AnalyticsPage extends StatefulWidget {
final ProgressIndicatorEnum? indicator;
final ConstructIdentifier? construct;
final bool isSidebar;
@ -27,62 +27,81 @@ class AnalyticsPage extends StatelessWidget {
this.isSidebar = false,
});
@override
State<AnalyticsPage> createState() => _AnalyticsPageState();
}
class _AnalyticsPageState extends State<AnalyticsPage> {
@override
void initState() {
super.initState();
final analytics = MatrixState.pangeaController.getAnalytics;
// Check if getAnalytics is initialized, if not wait for the first stream entry
if (!analytics.initCompleter.isCompleted) {
analytics.analyticsStream.stream.first.then((_) {
if (mounted) {
setState(() {});
}
});
}
}
@override
Widget build(BuildContext context) {
final analyticsRoomId = GoRouterState.of(context).pathParameters['roomid'];
return Scaffold(
appBar: construct != null ? AppBar() : null,
appBar: widget.construct != null ? AppBar() : null,
body: SafeArea(
child: Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSidebar ||
(!FluffyThemes.isColumnMode(context) && construct == null))
if (widget.isSidebar ||
(!FluffyThemes.isColumnMode(context) &&
widget.construct == null))
LearningProgressIndicators(
selected: indicator,
canSelect: indicator != ProgressIndicatorEnum.level,
selected: widget.indicator,
canSelect: widget.indicator != ProgressIndicatorEnum.level,
),
Expanded(
child: StreamBuilder(
stream: MatrixState
.pangeaController.getAnalytics.analyticsStream.stream,
builder: (context, _) {
if (indicator == ProgressIndicatorEnum.level) {
return const LevelDialogContent();
} else if (indicator == ProgressIndicatorEnum.morphsUsed) {
return ConstructAnalyticsView(
construct: construct,
view: ConstructTypeEnum.morph,
);
} else if (indicator == ProgressIndicatorEnum.wordsUsed) {
return ConstructAnalyticsView(
construct: construct,
view: ConstructTypeEnum.vocab,
);
} else if (indicator == ProgressIndicatorEnum.activities) {
return ActivityArchive(
selectedRoomId: analyticsRoomId,
);
}
child: () {
if (widget.indicator == ProgressIndicatorEnum.level) {
return const LevelDialogContent();
} else if (widget.indicator ==
ProgressIndicatorEnum.morphsUsed) {
return ConstructAnalyticsView(
construct: widget.construct,
view: ConstructTypeEnum.morph,
);
} else if (widget.indicator ==
ProgressIndicatorEnum.wordsUsed) {
return ConstructAnalyticsView(
construct: widget.construct,
view: ConstructTypeEnum.vocab,
);
} else if (widget.indicator ==
ProgressIndicatorEnum.activities) {
return ActivityArchive(
selectedRoomId: analyticsRoomId,
);
}
return Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, url, error) =>
const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
return Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, url, error) => const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
),
);
},
),
),
);
}(),
),
],
),

View file

@ -10,6 +10,8 @@ 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/construct_use_model.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/put_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/emojis/emoji_stack.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -175,6 +177,57 @@ class ConstructIdentifier {
}
}
/// Sets emoji and awards XP if it's a NEW emoji selection or from game
Future<void> setEmojiWithXP({
required String emoji,
bool isFromCorrectAnswer = false,
String? eventId,
String? roomId,
}) async {
final hadEmojiPreviously = userSetEmoji.isNotEmpty;
//correct answers already award xp so we don't here, but we do still need to set the emoji if it isn't already set
final shouldAwardXP = !hadEmojiPreviously && !isFromCorrectAnswer;
//Set emoji representation
await setUserLemmaInfo(UserSetLemmaInfo(emojis: [emoji]));
if (shouldAwardXP) {
await _recordEmojiAnalytics(
eventId: eventId,
roomId: roomId,
);
}
}
Future<void> _recordEmojiAnalytics({
String? eventId,
String? roomId,
}) async {
const useType = ConstructUseTypeEnum.em;
MatrixState.pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: eventId,
roomId: roomId,
constructs: [
OneConstructUse(
useType: useType,
lemma: lemma,
constructType: type,
metadata: ConstructUseMetaData(
roomId: roomId,
timeStamp: DateTime.now(),
eventId: eventId,
),
category: category,
form: lemma,
xp: useType.pointValue,
),
],
),
);
}
Future<void> setUserLemmaInfo(UserSetLemmaInfo newLemmaInfo) async {
final client = MatrixState.pangeaController.matrixState.client;
final l2 = MatrixState.pangeaController.languageController.userL2;

View file

@ -1,48 +1,65 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
import 'package:shimmer/shimmer.dart';
class LemmmaHighlightEmojiRow extends StatefulWidget {
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.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/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaHighlightEmojiRow extends StatefulWidget {
final LemmaMeaningBuilderState controller;
final ConstructIdentifier cId;
final VoidCallback? onTapOverride;
final bool isSelected;
final double? iconSize;
const LemmmaHighlightEmojiRow({
const LemmaHighlightEmojiRow({
super.key,
required this.controller,
required this.cId,
required this.onTapOverride,
required this.isSelected,
this.iconSize,
});
@override
LemmmaHighlightEmojiRowState createState() => LemmmaHighlightEmojiRowState();
LemmaHighlightEmojiRowState createState() => LemmaHighlightEmojiRowState();
}
class LemmmaHighlightEmojiRowState extends State<LemmmaHighlightEmojiRow> {
class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow> {
String? displayEmoji;
bool _showShimmer = true;
bool _hasShimmered = false;
@override
void initState() {
super.initState();
displayEmoji = widget.cId.userSetEmoji.firstOrNull;
_showShimmer = (displayEmoji == null);
}
void _startShimmer() {
if (!widget.controller.isLoading && _showShimmer) {
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
setState(() => _showShimmer = false);
setState(() => _hasShimmered = true);
}
});
}
}
@override
didUpdateWidget(LemmmaHighlightEmojiRow oldWidget) {
if (oldWidget.isSelected != widget.isSelected ||
widget.cId.userSetEmoji != oldWidget.cId.userSetEmoji) {
setState(() => displayEmoji = widget.cId.userSetEmoji.firstOrNull);
didUpdateWidget(LemmaHighlightEmojiRow oldWidget) {
//Reset shimmer state for diff constructs in 2 column mode
if (oldWidget.cId != widget.cId) {
setState(() {
displayEmoji = widget.cId.userSetEmoji.firstOrNull;
_showShimmer = (displayEmoji == null);
_hasShimmered = false;
});
}
super.didUpdateWidget(oldWidget);
}
@ -52,14 +69,33 @@ class LemmmaHighlightEmojiRowState extends State<LemmmaHighlightEmojiRow> {
super.dispose();
}
Future<void> setEmoji(String emoji) async {
String transformTargetId(String emoji) =>
"emoji-choice-item-$emoji-${widget.cId.lemma}";
Future<void> setEmoji(String emoji, BuildContext context) async {
try {
final String? userSetEmoji = widget.cId.userSetEmoji.firstOrNull;
setState(() => displayEmoji = emoji);
await widget.cId.setUserLemmaInfo(
UserSetLemmaInfo(
emojis: [emoji],
),
await widget.cId.setEmojiWithXP(
emoji: emoji,
isFromCorrectAnswer: false,
);
if (userSetEmoji == null) {
OverlayUtil.showOverlay(
overlayKey: "${transformTargetId(emoji)}_points",
followerAnchor: Alignment.bottomCenter,
targetAnchor: Alignment.bottomCenter,
context: context,
child: PointsGainedAnimation(
points: 2,
targetID: transformTargetId(emoji),
),
transformTargetId: transformTargetId(emoji),
closePrevOverlay: false,
backDropToDismiss: false,
ignorePointer: true,
);
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(data: widget.cId.toJson(), e: e, s: s);
@ -71,6 +107,7 @@ class LemmmaHighlightEmojiRowState extends State<LemmmaHighlightEmojiRow> {
if (widget.controller.isLoading) {
return const CircularProgressIndicator.adaptive();
}
_startShimmer();
final emojis = widget.controller.lemmaInfo?.emoji;
if (widget.controller.error != null || emojis == null || emojis.isEmpty) {
@ -92,10 +129,10 @@ class LemmmaHighlightEmojiRowState extends State<LemmmaHighlightEmojiRow> {
.map(
(emoji) => EmojiChoiceItem(
emoji: emoji,
onSelectEmoji: () => setEmoji(emoji),
// will highlight selected emoji, or the first emoji if none are selected
isDisplay: (displayEmoji == emoji ||
(displayEmoji == null && emoji == emojis.first)),
onSelectEmoji: () => setEmoji(emoji, context),
isDisplay: (displayEmoji == emoji),
showShimmer: (_showShimmer && !_hasShimmered),
transformTargetId: transformTargetId(emoji),
),
)
.toList(),
@ -110,12 +147,16 @@ class EmojiChoiceItem extends StatefulWidget {
final String emoji;
final VoidCallback onSelectEmoji;
final bool isDisplay;
final bool showShimmer;
final String transformTargetId;
const EmojiChoiceItem({
super.key,
required this.emoji,
required this.isDisplay,
required this.onSelectEmoji,
required this.showShimmer,
required this.transformTargetId,
});
@override
@ -125,6 +166,9 @@ class EmojiChoiceItem extends StatefulWidget {
class EmojiChoiceItemState extends State<EmojiChoiceItem> {
bool _isHovered = false;
LayerLink get layerLink =>
MatrixState.pAnyState.layerLinkAndKey(widget.transformTargetId).link;
@override
Widget build(BuildContext context) {
return MouseRegion(
@ -134,25 +178,50 @@ class EmojiChoiceItemState extends State<EmojiChoiceItem> {
onTap: widget.onSelectEmoji,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isHovered
? Theme.of(context).colorScheme.primary.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
border: widget.isDisplay
? Border.all(
color: AppConfig.goldLight,
width: 4,
)
: null,
),
child: Text(
widget.emoji,
style: Theme.of(context).textTheme.headlineSmall,
),
child: Stack(
children: [
CompositedTransformTarget(
link: layerLink,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isHovered
? Theme.of(context).colorScheme.primary.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
border: widget.isDisplay
? Border.all(
color: AppConfig.goldLight,
width: 4,
)
: null,
),
child: Text(
widget.emoji,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
if (widget.showShimmer)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Shimmer.fromColors(
baseColor: Colors.white.withValues(alpha: 0.1),
highlightColor: Colors.white.withValues(alpha: 0.6),
direction: ShimmerDirection.ltr,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
),
),
),
),
),
],
),
),
),

View file

@ -211,10 +211,13 @@ class PracticeActivityModel {
}
if (isCorrect) {
if (activityType == ActivityTypeEnum.emoji) {
// final allEmojis = ;
choice.form.cId
.setUserLemmaInfo(UserSetLemmaInfo(emojis: [choice.choiceContent]))
.setEmojiWithXP(
emoji: choice.choiceContent,
isFromCorrectAnswer: true,
eventId: event?.eventId,
roomId: event?.room.id,
)
.then((value) {
callback();
});