From 9e4d52a9d8076a2116942637bd57df6a89e8ef89 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 11:43:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=D1=83=20+=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/contacts/contacts_tab.dart | 25 ++++- .../screens/contacts/nfc_exchange_sheet.dart | 103 +++++++++++++++--- 2 files changed, 108 insertions(+), 20 deletions(-) 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; +}