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:
parent
8086868b6c
commit
bfc6356247
6 changed files with 237 additions and 97 deletions
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue