diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 55730a3..cf0ad73 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -36,13 +36,26 @@ class _ContactsTabState extends State { } Future _openNfcExchange() async { - final cs = Theme.of(context).colorScheme; - await showModalBottomSheet( + await showGeneralDialog( context: context, - isScrollControlled: true, - backgroundColor: cs.surfaceContainerHigh, - shape: kSheetShape, - builder: (_) => const NfcExchangeSheet(), + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => const Align( + alignment: Alignment.topCenter, + child: NfcExchangeSheet(), + ), + transitionBuilder: (_, anim, _, child) { + final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutCubic); + return SlideTransition( + position: Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(curved), + child: child, + ); + }, ); } diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 5ff5d7a..0520876 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/contacts.dart'; @@ -32,9 +33,10 @@ class NfcExchangeSheet extends StatefulWidget { } class _NfcExchangeSheetState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final _nfc = NfcExchangeService.instance; late final AnimationController _pulse; + late final AnimationController _reveal; StreamSubscription? _sub; _Stage _stage = _Stage.checking; @@ -49,6 +51,10 @@ class _NfcExchangeSheetState extends State vsync: this, duration: const Duration(milliseconds: 1800), )..repeat(); + _reveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); _begin(); } @@ -57,6 +63,7 @@ class _NfcExchangeSheetState extends State _sub?.cancel(); _nfc.stop(); _pulse.dispose(); + _reveal.dispose(); super.dispose(); } @@ -107,6 +114,8 @@ class _NfcExchangeSheetState extends State final id = event.id; if (id == null || _peerId != null) return; _peerId = id; + HapticFeedback.mediumImpact(); + _reveal.forward(from: 0); setState(() => _stage = _Stage.found); final info = await ContactInfoFetch.get(id); if (!mounted) return; @@ -181,13 +190,22 @@ class _NfcExchangeSheetState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( + return SizedBox( + width: double.infinity, + child: Material( + color: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(28)), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ Expanded( child: Text( @@ -219,9 +237,11 @@ class _NfcExchangeSheetState extends State child: _buildContent(cs), ), ), - ], + ], + ), ), ), + ), ); } @@ -356,11 +376,28 @@ class _NfcExchangeSheetState extends State padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: [ - KometAvatar( - name: _peerName(), - imageUrl: _peerInfo?['baseUrl'] as String?, - size: 88, - fontSize: 34, + SizedBox( + width: 160, + height: 160, + child: AnimatedBuilder( + animation: _reveal, + builder: (context, child) { + final t = _reveal.value; + final pop = Curves.elasticOut.transform(t.clamp(0.0, 1.0)); + return CustomPaint( + painter: _BurstPainter(t, cs.primary), + child: Center( + child: Transform.scale(scale: pop, child: child), + ), + ); + }, + child: KometAvatar( + name: _peerName(), + imageUrl: _peerInfo?['baseUrl'] as String?, + size: 92, + fontSize: 34, + ), + ), ), const SizedBox(height: 14), Text( @@ -435,3 +472,41 @@ class _RadarPainter extends CustomPainter { bool shouldRepaint(_RadarPainter oldDelegate) => oldDelegate.progress != progress || oldDelegate.color != color; } + +class _BurstPainter extends CustomPainter { + final double progress; + final Color color; + + _BurstPainter(this.progress, this.color); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + final center = Offset(size.width / 2, size.height / 2); + final maxRadius = size.width / 2; + final eased = Curves.easeOut.transform(progress.clamp(0.0, 1.0)); + + final glow = Paint() + ..color = color.withValues(alpha: (1.0 - eased) * 0.18); + canvas.drawCircle(center, maxRadius * (0.45 + 0.55 * eased), glow); + + for (var i = 0; i < 3; i++) { + final delay = i * 0.18; + final t = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0); + if (t <= 0) continue; + final wave = Curves.easeOut.transform(t); + final radius = maxRadius * (0.3 + 0.7 * wave); + final opacity = (1.0 - wave) * 0.5; + if (opacity <= 0) continue; + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 * (1.0 - wave) + 0.5 + ..color = color.withValues(alpha: opacity); + canvas.drawCircle(center, radius, ring); + } + } + + @override + bool shouldRepaint(_BurstPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +}