fix: use event stream for construct level animation

This commit is contained in:
Ava Shilling 2026-01-23 13:50:12 -05:00
parent 68a8733976
commit c3d6a9996e
8 changed files with 138 additions and 193 deletions

View file

@ -52,7 +52,6 @@ import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
@ -2344,36 +2343,6 @@ class ChatController extends State<ChatPageWithRoom>
ConstructTypeEnum.vocab,
);
final newLeafConstructs = await analyticsService.getLeveledUpConstructCount(
constructs,
ConstructLevelEnum.greens,
);
final newFlowerConstructs =
await analyticsService.getLeveledUpConstructCount(
constructs,
ConstructLevelEnum.flowers,
);
// Show all growth animations at once
final Map<ConstructLevelEnum, int> levelCounts = {};
if (newVocabConstructs > 0) {
levelCounts[ConstructLevelEnum.seeds] = newVocabConstructs;
}
if (newLeafConstructs > 0) {
levelCounts[ConstructLevelEnum.greens] = newLeafConstructs;
}
if (newFlowerConstructs > 0) {
levelCounts[ConstructLevelEnum.flowers] = newFlowerConstructs;
}
if (levelCounts.isNotEmpty) {
OverlayUtil.showGrowthOverlay(
context,
eventId,
levelCounts: levelCounts,
);
}
OverlayUtil.showOverlay(
overlayKey: "msg_analytics_feedback_$eventId",
followerAnchor: Alignment.bottomRight,

View file

@ -11,7 +11,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart';
import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
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';
@ -370,52 +369,6 @@ class AnalyticsDataService {
return newConstructCount;
}
Future<int> getLeveledUpConstructCount(
List<OneConstructUse> newConstructs,
ConstructLevelEnum targetLevel,
) async {
await _ensureInitialized();
final blocked = blockedConstructs;
final uses =
newConstructs.where((c) => !blocked.contains(c.identifier)).toList();
final Map<ConstructIdentifier, int> constructPoints = {};
for (final use in uses) {
constructPoints[use.identifier] ??= 0;
constructPoints[use.identifier] =
constructPoints[use.identifier]! + use.xp;
}
final constructs = await getConstructUses(constructPoints.keys.toList());
int leveledUpCount = 0;
for (final entry in constructPoints.entries) {
final construct = constructs[entry.key]!;
// Use uncapped points to correctly calculate previous level
// even when construct is already at max (flowers)
final uncapped = construct.uncappedPoints;
final prevUncapped = uncapped - entry.value;
final prevLevel = _getLevelFromPoints(prevUncapped);
final newLevel = _getLevelFromPoints(uncapped);
if (prevLevel.xpNeeded < targetLevel.xpNeeded &&
newLevel == targetLevel) {
leveledUpCount++;
}
}
return leveledUpCount;
}
ConstructLevelEnum _getLevelFromPoints(int points) {
if (points < AnalyticsConstants.xpForGreens) {
return ConstructLevelEnum.seeds;
} else if (points >= AnalyticsConstants.xpForFlower) {
return ConstructLevelEnum.flowers;
}
return ConstructLevelEnum.greens;
}
Future<void> updateXPOffset(int offset) async {
_invalidateCaches();
await _analyticsClientGetter.database.updateXPOffset(offset);
@ -490,6 +443,23 @@ class AnalyticsDataService {
events.add(MorphUnlockedEvent(newUnlockedMorphs));
}
for (final entry in newConstructs.entries) {
final prevConstruct = prevConstructs[entry.key];
if (prevConstruct == null) continue;
final prevLevel = prevConstruct.lemmaCategory;
final newLevel = entry.value.lemmaCategory;
if (newLevel.xpNeeded > prevLevel.xpNeeded) {
events.add(
ConstructLevelUpEvent(
entry.key,
newLevel,
update.targetID,
),
);
}
}
if (update.blockedConstruct != null) {
events.add(ConstructBlockedEvent(update.blockedConstruct!));
}

View file

@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
class LevelUpdate {
@ -28,6 +29,18 @@ class AnalyticsUpdate {
});
}
class ConstructLevelUpdate {
final ConstructIdentifier constructId;
final ConstructLevelEnum level;
final String? targetID;
ConstructLevelUpdate({
required this.constructId,
required this.level,
this.targetID,
});
}
class AnalyticsUpdateDispatcher {
final AnalyticsDataService dataService;
@ -46,6 +59,9 @@ class AnalyticsUpdateDispatcher {
final StreamController<Set<ConstructIdentifier>> newConstructsStream =
StreamController<Set<ConstructIdentifier>>.broadcast();
final StreamController<ConstructLevelUpdate> constructLevelUpdateStream =
StreamController<ConstructLevelUpdate>.broadcast();
final StreamController<MapEntry<ConstructIdentifier, UserSetLemmaInfo>>
_lemmaInfoUpdateStream = StreamController<
MapEntry<ConstructIdentifier, UserSetLemmaInfo>>.broadcast();
@ -57,6 +73,7 @@ class AnalyticsUpdateDispatcher {
activityAnalyticsStream.close();
unlockedConstructsStream.close();
levelUpdateStream.close();
constructLevelUpdateStream.close();
_lemmaInfoUpdateStream.close();
}
@ -101,6 +118,9 @@ class AnalyticsUpdateDispatcher {
case final ConstructBlockedEvent e:
_onBlockedConstruct(e.blockedConstruct);
break;
case final ConstructLevelUpEvent e:
_onConstructLevelUp(e.constructId, e.level, e.targetID);
break;
case final NewConstructsEvent e:
_onNewConstruct(e.newConstructs);
break;
@ -137,6 +157,20 @@ class AnalyticsUpdateDispatcher {
constructUpdateStream.add(update);
}
void _onConstructLevelUp(
ConstructIdentifier constructId,
ConstructLevelEnum level,
String? targetID,
) {
constructLevelUpdateStream.add(
ConstructLevelUpdate(
constructId: constructId,
level: level,
targetID: targetID,
),
);
}
void _onBlockedConstruct(ConstructIdentifier constructId) {
final update = AnalyticsStreamUpdate(
blockedConstruct: constructId,

View file

@ -1,4 +1,5 @@
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
sealed class AnalyticsUpdateEvent {}
@ -13,6 +14,17 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent {
MorphUnlockedEvent(this.unlocked);
}
class ConstructLevelUpEvent extends AnalyticsUpdateEvent {
final ConstructIdentifier constructId;
final ConstructLevelEnum level;
final String? targetID;
ConstructLevelUpEvent(
this.constructId,
this.level,
this.targetID,
);
}
class XPGainedEvent extends AnalyticsUpdateEvent {
final int points;
final String? targetID;

View file

@ -3,12 +3,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/widgets/matrix.dart';
mixin AnalyticsUpdater<T extends StatefulWidget> on State<T> {
StreamSubscription? _analyticsSubscription;
StreamSubscription? _constructLevelSubscription;
@override
void initState() {
@ -16,11 +18,14 @@ mixin AnalyticsUpdater<T extends StatefulWidget> on State<T> {
final updater = Matrix.of(context).analyticsDataService.updateDispatcher;
_analyticsSubscription =
updater.constructUpdateStream.stream.listen(_onAnalyticsUpdate);
_constructLevelSubscription =
updater.constructLevelUpdateStream.stream.listen(_onConstructLevelUp);
}
@override
void dispose() {
_analyticsSubscription?.cancel();
_constructLevelSubscription?.cancel();
super.dispose();
}
@ -38,4 +43,10 @@ mixin AnalyticsUpdater<T extends StatefulWidget> on State<T> {
OverlayUtil.showPointsGained(update.targetID!, update.points, context);
}
}
void _onConstructLevelUp(ConstructLevelUpdate update) {
if (update.targetID != null) {
OverlayUtil.showGrowthAnimation(context, update.targetID!, update.level);
}
}
}

View file

@ -37,13 +37,6 @@ class ConstructUses {
);
}
int get uncappedPoints {
return _uses.fold<int>(
0,
(total, use) => total + use.xp,
);
}
DateTime? get lastUsed => _uses.lastOrNull?.timeStamp;
DateTime? get cappedLastUse => cappedUses.lastOrNull?.timeStamp;

View file

@ -1,38 +1,37 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class _GrowthItem {
final ConstructLevelEnum level;
final double horizontalOffset;
final double wiggleAmplitude;
final double wiggleFrequency;
final int delayMs;
/// Tracks active growth animations for offset calculation
class GrowthAnimationTracker {
static int _activeCount = 0;
_GrowthItem({
required this.level,
required this.horizontalOffset,
required this.wiggleAmplitude,
required this.wiggleFrequency,
required this.delayMs,
});
static int get activeCount => _activeCount;
static double? startAnimation() {
if (_activeCount >= 5) return null;
final index = _activeCount;
_activeCount++;
if (index == 0) return 0;
final side = index.isOdd ? 1 : -1;
return side * ((index + 1) ~/ 2) * 20.0;
}
static void endAnimation() {
_activeCount = (_activeCount - 1).clamp(0, 999);
}
}
class GrowthAnimation extends StatefulWidget {
final String targetID;
final Map<ConstructLevelEnum, int> levelCounts;
final int itemDurationMs;
final double riseDistance;
final ConstructLevelEnum level;
const GrowthAnimation({
super.key,
required this.targetID,
required this.levelCounts,
this.itemDurationMs = 1600,
this.riseDistance = 72,
required this.level,
});
@override
@ -42,102 +41,56 @@ class GrowthAnimation extends StatefulWidget {
class _GrowthAnimationState extends State<GrowthAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final List<_GrowthItem> _items;
late final int _totalDurationMs;
late final double? _horizontalOffset;
late final double _wiggleAmplitude;
late final double _wiggleFrequency;
final Random _random = Random();
static const int _staggerDelayMs = 50;
static const _durationMs = 1600;
static const _riseDistance = 72.0;
@override
void initState() {
super.initState();
_items = _buildItems();
final maxDelay = _items.isEmpty ? 0 : _items.last.delayMs;
_totalDurationMs = maxDelay + widget.itemDurationMs;
_horizontalOffset = GrowthAnimationTracker.startAnimation();
_wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0;
_wiggleFrequency = 1.5 + _random.nextDouble() * 1.0;
_controller = AnimationController(
duration: Duration(milliseconds: _totalDurationMs),
duration: const Duration(milliseconds: _durationMs),
vsync: this,
);
_controller.forward().then((_) {
if (!mounted) return;
MatrixState.pAnyState.closeOverlay("${widget.targetID}_growth");
});
}
List<_GrowthItem> _buildItems() {
final items = <_GrowthItem>[];
int index = 0;
for (final level in [
ConstructLevelEnum.seeds,
ConstructLevelEnum.greens,
ConstructLevelEnum.flowers,
]) {
final count = widget.levelCounts[level] ?? 0;
for (int i = 0; i < count; i++) {
final side = index % 2 == 0 ? 1 : -1;
final distance = ((index + 1) ~/ 2) * 30.0;
items.add(
_GrowthItem(
level: level,
horizontalOffset: side * distance,
wiggleAmplitude: 4.0 + _random.nextDouble() * 4.0,
wiggleFrequency: 1.5 + _random.nextDouble() * 1.0,
delayMs: index * _staggerDelayMs,
),
);
index++;
}
}
return items;
)..forward().then((_) {
if (mounted) {
MatrixState.pAnyState.closeOverlay(widget.targetID);
}
});
}
@override
void dispose() {
GrowthAnimationTracker.endAnimation();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_items.isEmpty) return const SizedBox.shrink();
return Material(
type: MaterialType.transparency,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: _items.map(_buildItem).toList(),
);
},
),
);
}
Widget _buildItem(_GrowthItem item) {
final elapsedMs = _controller.value * _totalDurationMs;
final itemElapsedMs =
(elapsedMs - item.delayMs).clamp(0.0, widget.itemDurationMs.toDouble());
final t = (itemElapsedMs / widget.itemDurationMs).clamp(0.0, 1.0);
if (t <= 0) return const SizedBox.shrink();
final curvedT = Curves.easeOut.transform(t);
final dy = -widget.riseDistance * curvedT;
final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2;
final wiggle = sin(t * pi * item.wiggleFrequency) * item.wiggleAmplitude;
return Transform.translate(
offset: Offset(item.horizontalOffset + wiggle, dy),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: item.level.icon(24),
),
if (_horizontalOffset == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final t = _controller.value;
final dy = -_riseDistance * Curves.easeOut.transform(t);
final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2;
final wiggle = sin(t * pi * _wiggleFrequency) * _wiggleAmplitude;
return Transform.translate(
offset: Offset(_horizontalOffset! + wiggle, dy),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: widget.level.icon(24),
),
);
},
);
}
}

View file

@ -1,8 +1,5 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart';
import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
@ -16,6 +13,9 @@ import 'package:fluffychat/pangea/common/widgets/overlay_container.dart';
import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../config/themes.dart';
import '../../../widgets/matrix.dart';
import 'error_handler.dart';
@ -314,19 +314,22 @@ class OverlayUtil {
);
}
static void showGrowthOverlay(
static int _growthAnimationCounter = 0;
static void showGrowthAnimation(
BuildContext context,
String targetId, {
required Map<ConstructLevelEnum, int> levelCounts,
}) {
String targetId,
ConstructLevelEnum level,
) {
final overlayKey = "${targetId}_growth_${_growthAnimationCounter++}";
showOverlay(
overlayKey: "${targetId}_growth",
overlayKey: overlayKey,
followerAnchor: Alignment.topCenter,
targetAnchor: Alignment.topCenter,
context: context,
child: GrowthAnimation(
targetID: targetId,
levelCounts: levelCounts,
targetID: overlayKey,
level: level,
),
transformTargetId: targetId,
closePrevOverlay: false,