@@ -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).
10131057class _HeartPainter extends CustomPainter {
0 commit comments