Skip to content

Commit a190859

Browse files
Adam Fiskclaude
authored andcommitted
SmC: lift heart-burst off the globe into a floating toast
Anchoring the burst to projected globe coords (via Point.labelBuilder) forced the widget to repaint every rotation frame, which made the globe rotation jittery. The burst is now a separate floating pill overlaid at the bottom of the globe area: - _ArrivalToast subscribes to ShareNotifier.connectionEvents, ignores replays, surfaces the current arrival in a slide-up + fade-in card. ValueKey on workerIdx forces AnimatedSwitcher to swap the widget when overlapping arrivals land so the Lottie restarts cleanly. - _HeartBurst is now just heart + Lottie, no country label, no globe anchor. The label moved into _ArrivalCard alongside the burst. - Removed _announceArrival (Point/labelBuilder pattern) and the burst anchor lifecycle. Globe rotation is smooth again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7dcf3d1 commit a190859

1 file changed

Lines changed: 176 additions & 132 deletions

File tree

lib/features/share_my_connection/share_my_connection.dart

Lines changed: 176 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,22 @@ class ShareMyConnectionScreen extends HookConsumerWidget {
490490
const SizedBox(height: 16),
491491
Expanded(
492492
flex: 3,
493-
child: _GlobeView(),
493+
child: Stack(
494+
children: [
495+
Positioned.fill(child: _GlobeView()),
496+
// Floating "new connection from X" toast — overlays the
497+
// bottom of the globe area rather than the peer's exact
498+
// location on the sphere. Anchoring to projected coords
499+
// forced the burst to repaint every globe rotation
500+
// frame, which made the rotation jittery.
501+
const Positioned(
502+
left: 0,
503+
right: 0,
504+
bottom: 8,
505+
child: Center(child: _ArrivalToast()),
506+
),
507+
],
508+
),
494509
),
495510
const SizedBox(height: 8),
496511
_StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)),
@@ -700,9 +715,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> {
700715
// workerIdx +1's again before it fires.
701716
final Map<int, Timer> _pendingRemovals = {};
702717
static const _arcLinger = Duration(seconds: 5);
703-
// Matches the explosion.json timeline (4.55s at 30fps) so the Lottie
704-
// plays to completion before the anchor point is removed.
705-
static const _burstDuration = Duration(milliseconds: 4600);
706718

707719
@override
708720
void initState() {
@@ -761,7 +773,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> {
761773
// Cancel any lingering removal — same workerIdx is back.
762774
_pendingRemovals.remove(event.workerIdx)?.cancel();
763775
_addPeer(event);
764-
if (!event.isReplay) _announceArrival(event);
765776
} else if (event.state == -1) {
766777
// Linger the arc so brief connections still register visually.
767778
_pendingRemovals[event.workerIdx]?.cancel();
@@ -813,33 +824,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> {
813824
));
814825
}
815826

816-
void _announceArrival(UnboundedConnectionEvent event) {
817-
if (!mounted) return;
818-
final coords = _jittered(event.coordinates!, event.workerIdx);
819-
final burstId = 'burst_${event.workerIdx}_${DateTime.now().microsecondsSinceEpoch}';
820-
// Anchor a zero-size point at the peer's location. flutter_earth_globe
821-
// calls labelBuilder with the projected on-screen position, so the
822-
// burst widget renders directly on top of the peer's spot on the map
823-
// (rotating + projecting along with the globe).
824-
_globeController.addPoint(Point(
825-
id: burstId,
826-
coordinates: coords,
827-
style: const PointStyle(color: Colors.transparent, size: 0.1),
828-
isLabelVisible: true,
829-
labelBuilder: (ctx, _, isHovering, isVisible) {
830-
if (!isVisible) return const SizedBox.shrink();
831-
return _HeartBurst(
832-
countryName: event.countryName,
833-
flagEmoji: event.flagEmoji,
834-
);
835-
},
836-
));
837-
Future.delayed(_burstDuration, () {
838-
if (!mounted) return;
839-
_globeController.removePoint(burstId);
840-
});
841-
}
842-
843827
void _removePeer(int workerIdx) {
844828
_globeController.removePointConnection('conn_$workerIdx');
845829
_globeController.removePoint('peer_$workerIdx');
@@ -879,135 +863,195 @@ class _GlobeViewState extends ConsumerState<_GlobeView> {
879863
}
880864
}
881865

882-
// ─── Heart burst ─────────────────────────────────────────────────────────────
883-
884-
/// Heart-burst anchored to a peer point on the globe — same visual as
885-
/// unbounded.lantern.io. The pink heart is the inline SVG path from
886-
/// `unbounded/ui/.../notification/explosion.tsx` (FF5A79 fill, 32×27
887-
/// viewBox); the burst is `unbounded/.../explosion.json` played once
888-
/// via the `lottie` Flutter package. A small `flag country` label sits
889-
/// just below and fades alongside. Self-disposes when the anchor point
890-
/// is removed (~1.2s after creation by the caller).
891-
class _HeartBurst extends StatefulWidget {
892-
const _HeartBurst({this.countryName = '', this.flagEmoji = ''});
866+
// ─── Arrival toast ───────────────────────────────────────────────────────────
893867

894-
final String countryName;
895-
final String flagEmoji;
868+
/// Floating notification overlay shown under the globe when a new peer
869+
/// arrives. Mirrors the unbounded.lantern.io notification pattern:
870+
/// heart-burst on the left, `New connection from <country>` text on
871+
/// the right. Slides up + fades in, auto-hides after ~3.5s. Listens
872+
/// directly to ShareNotifier.connectionEvents so we don't depend on
873+
/// the globe widget for triggering.
874+
class _ArrivalToast extends ConsumerStatefulWidget {
875+
const _ArrivalToast();
896876

897877
@override
898-
State<_HeartBurst> createState() => _HeartBurstState();
878+
ConsumerState<_ArrivalToast> createState() => _ArrivalToastState();
899879
}
900880

901-
class _HeartBurstState extends State<_HeartBurst>
902-
with TickerProviderStateMixin {
903-
// Drives only the label's fade in/out — the Lottie file owns its own
904-
// animation timeline.
905-
late final AnimationController _labelCtrl;
906-
late final Animation<double> _labelOpacity;
907-
908-
// The lottie controller is driven by Lottie's own composition once
909-
// it loads; we kick it off with goToAndPlay equivalent (forward).
910-
AnimationController? _lottieCtrl;
881+
class _ArrivalToastState extends ConsumerState<_ArrivalToast> {
882+
StreamSubscription<UnboundedConnectionEvent>? _sub;
883+
Timer? _hideTimer;
884+
UnboundedConnectionEvent? _current;
911885

912886
@override
913887
void initState() {
914888
super.initState();
915-
// Matches unbounded's notification auto-hide (3.5s visible + 0.7s
916-
// fade-out) so the country label stays readable through the bulk
917-
// of the explosion animation.
918-
_labelCtrl = AnimationController(
919-
vsync: this,
920-
duration: const Duration(milliseconds: 4200),
921-
);
922-
_labelOpacity = TweenSequence<double>([
923-
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15),
924-
TweenSequenceItem(tween: ConstantTween(1.0), weight: 55),
925-
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
926-
]).animate(_labelCtrl);
927-
_labelCtrl.forward();
889+
_sub = ref
890+
.read(shareProvider.notifier)
891+
.connectionEvents
892+
.listen(_onEvent);
893+
}
894+
895+
void _onEvent(UnboundedConnectionEvent event) {
896+
if (event.state != 1 || event.isReplay) return;
897+
if (event.countryName.isEmpty) return;
898+
if (!mounted) return;
899+
_hideTimer?.cancel();
900+
setState(() => _current = event);
901+
_hideTimer = Timer(const Duration(milliseconds: 3500), () {
902+
if (!mounted) return;
903+
setState(() => _current = null);
904+
});
928905
}
929906

930907
@override
931908
void dispose() {
932-
_labelCtrl.dispose();
933-
_lottieCtrl?.dispose();
909+
_sub?.cancel();
910+
_hideTimer?.cancel();
934911
super.dispose();
935912
}
936913

937914
@override
938915
Widget build(BuildContext context) {
939-
final label = widget.countryName.isEmpty
940-
? null
941-
: '${widget.flagEmoji} ${widget.countryName}'.trim();
916+
final event = _current;
917+
return AnimatedSwitcher(
918+
duration: const Duration(milliseconds: 280),
919+
transitionBuilder: (child, anim) => FadeTransition(
920+
opacity: anim,
921+
child: SlideTransition(
922+
position: Tween<Offset>(
923+
begin: const Offset(0, 0.4), end: Offset.zero)
924+
.animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)),
925+
child: child,
926+
),
927+
),
928+
child: event == null
929+
? const SizedBox.shrink(key: ValueKey('arrival-hidden'))
930+
: _ArrivalCard(
931+
// ValueKey forces AnimatedSwitcher to swap children when a
932+
// new arrival lands while the previous toast is still up,
933+
// so the Lottie restarts cleanly.
934+
key: ValueKey('arrival-${event.workerIdx}'),
935+
countryName: event.countryName,
936+
flagEmoji: event.flagEmoji,
937+
),
938+
);
939+
}
940+
}
941+
942+
class _ArrivalCard extends StatelessWidget {
943+
const _ArrivalCard({
944+
super.key,
945+
required this.countryName,
946+
required this.flagEmoji,
947+
});
948+
949+
final String countryName;
950+
final String flagEmoji;
951+
952+
@override
953+
Widget build(BuildContext context) {
942954
return IgnorePointer(
943-
child: SizedBox(
944-
width: 120,
945-
height: 120,
946-
child: Stack(
947-
alignment: Alignment.center,
948-
clipBehavior: Clip.none,
955+
child: Container(
956+
padding: const EdgeInsets.fromLTRB(10, 8, 16, 8),
957+
decoration: BoxDecoration(
958+
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92),
959+
borderRadius: BorderRadius.circular(100),
960+
border: Border.all(color: Colors.black12),
961+
boxShadow: [
962+
BoxShadow(
963+
color: Colors.black.withValues(alpha: 0.12),
964+
blurRadius: 12,
965+
offset: const Offset(0, 4),
966+
),
967+
],
968+
),
969+
child: Row(
970+
mainAxisSize: MainAxisSize.min,
949971
children: [
950-
// Lottie explosion sits behind the heart, sized larger so
951-
// particle spray extends past the heart bounds. Matches
952-
// unbounded's LottieWrapper sizing (420px wide canvas
953-
// around a 32×27 heart) scaled for our anchor.
954-
SizedBox(
955-
width: 120,
956-
height: 120,
957-
child: Lottie.asset(
958-
'assets/unbounded/explosion.json',
959-
repeat: false,
960-
fit: BoxFit.contain,
961-
onLoaded: (composition) {
962-
_lottieCtrl = AnimationController(
963-
vsync: this,
964-
duration: composition.duration,
965-
)..forward();
966-
setState(() {});
967-
},
968-
controller: _lottieCtrl,
972+
const SizedBox(width: 40, height: 40, child: _HeartBurst()),
973+
const SizedBox(width: 12),
974+
Text(
975+
flagEmoji.isEmpty
976+
? 'New connection from $countryName'
977+
: '$flagEmoji New connection from $countryName',
978+
style: const TextStyle(
979+
fontSize: 13,
980+
fontWeight: FontWeight.w500,
969981
),
970982
),
971-
// Heart SVG path — same coords as unbounded's inline SVG
972-
// (FF5A79, 32x27 viewBox).
973-
const SizedBox(
974-
width: 32,
975-
height: 27,
976-
child: CustomPaint(painter: _HeartPainter()),
977-
),
978-
if (label != null)
979-
Positioned(
980-
top: 78,
981-
child: AnimatedBuilder(
982-
animation: _labelCtrl,
983-
builder: (context, _) => Opacity(
984-
opacity: _labelOpacity.value,
985-
child: Container(
986-
padding: const EdgeInsets.symmetric(
987-
horizontal: 8, vertical: 3),
988-
decoration: BoxDecoration(
989-
color: Colors.black.withValues(alpha: 0.6),
990-
borderRadius: BorderRadius.circular(10),
991-
),
992-
child: Text(
993-
label,
994-
style: const TextStyle(
995-
color: Colors.white,
996-
fontSize: 12,
997-
fontWeight: FontWeight.w500,
998-
),
999-
),
1000-
),
1001-
),
1002-
),
1003-
),
1004983
],
1005984
),
1006985
),
1007986
);
1008987
}
1009988
}
1010989

990+
// ─── Heart burst ─────────────────────────────────────────────────────────────
991+
992+
/// Heart + Lottie explosion lifted from getlantern/unbounded. The pink
993+
/// heart is the inline SVG path from `notification/explosion.tsx`
994+
/// (FF5A79 fill, 32×27 viewBox); the burst is `explosion.json` played
995+
/// once via the `lottie` Flutter package. Rendered inside _ArrivalCard
996+
/// (under the globe), NOT anchored to globe coords — anchoring forced
997+
/// a repaint per globe rotation frame and made rotation jittery.
998+
class _HeartBurst extends StatefulWidget {
999+
const _HeartBurst();
1000+
1001+
@override
1002+
State<_HeartBurst> createState() => _HeartBurstState();
1003+
}
1004+
1005+
class _HeartBurstState extends State<_HeartBurst>
1006+
with TickerProviderStateMixin {
1007+
AnimationController? _lottieCtrl;
1008+
1009+
@override
1010+
void dispose() {
1011+
_lottieCtrl?.dispose();
1012+
super.dispose();
1013+
}
1014+
1015+
@override
1016+
Widget build(BuildContext context) {
1017+
return IgnorePointer(
1018+
child: Stack(
1019+
alignment: Alignment.center,
1020+
clipBehavior: Clip.none,
1021+
children: [
1022+
// Lottie explosion sized so particle spray extends slightly
1023+
// past the card bounds (Clip.none on parent lets it overflow).
1024+
// Mirrors unbounded's LottieWrapper sizing, scaled down for an
1025+
// inline card slot.
1026+
Positioned(
1027+
width: 110,
1028+
height: 110,
1029+
child: Lottie.asset(
1030+
'assets/unbounded/explosion.json',
1031+
repeat: false,
1032+
fit: BoxFit.contain,
1033+
onLoaded: (composition) {
1034+
_lottieCtrl = AnimationController(
1035+
vsync: this,
1036+
duration: composition.duration,
1037+
)..forward();
1038+
setState(() {});
1039+
},
1040+
controller: _lottieCtrl,
1041+
),
1042+
),
1043+
// Heart SVG path — exact coords from unbounded's inline SVG.
1044+
const SizedBox(
1045+
width: 22,
1046+
height: 19,
1047+
child: CustomPaint(painter: _HeartPainter()),
1048+
),
1049+
],
1050+
),
1051+
);
1052+
}
1053+
}
1054+
10111055
/// Pink heart from `getlantern/unbounded` — exact SVG path coords
10121056
/// (viewBox 0 0 32 27, fill #FF5A79).
10131057
class _HeartPainter extends CustomPainter {

0 commit comments

Comments
 (0)