From 33532c9f0fda2b924c8fb1b6afb5e8b823fdef67 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Wed, 5 Aug 2026 14:28:56 +0200 Subject: [PATCH] feat: add verify button to send and receive page --- app/lib/pages/device_details_page.dart | 6 +- app/lib/pages/receive_page.dart | 256 +++++++++++++------------ app/lib/pages/send_page.dart | 23 ++- app/lib/pages/verify_page.dart | 97 +++++++--- 4 files changed, 233 insertions(+), 149 deletions(-) diff --git a/app/lib/pages/device_details_page.dart b/app/lib/pages/device_details_page.dart index ba59c262..c103d24d 100644 --- a/app/lib/pages/device_details_page.dart +++ b/app/lib/pages/device_details_page.dart @@ -88,7 +88,11 @@ class _DeviceDetailsPageState extends State with Refena { label: t.deviceDetailsPage.verify, filled: false, width: 120, - onTap: () async => await context.push(() => VerifyPage(device: device)), + onTap: () async => await context.push( + () => VerifyPage( + fingerprint: CombinedFingerprint.load(context, device.fingerprint), + ), + ), ), ], ), diff --git a/app/lib/pages/receive_page.dart b/app/lib/pages/receive_page.dart index a816fd38..a503c262 100644 --- a/app/lib/pages/receive_page.dart +++ b/app/lib/pages/receive_page.dart @@ -6,12 +6,13 @@ import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/model/persistence/color_mode.dart'; import 'package:localsend_app/pages/receive_options_page.dart'; +import 'package:localsend_app/pages/verify_page.dart'; +import 'package:localsend_app/pages/web_share_page.dart'; import 'package:localsend_app/provider/favorites_provider.dart'; import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/device_type_ext.dart'; import 'package:localsend_app/util/favorites.dart'; -import 'package:localsend_app/util/ip_helper.dart'; import 'package:localsend_app/util/native/platform_check.dart'; import 'package:localsend_app/util/native/taskbar_helper.dart'; import 'package:localsend_app/util/ui/snackbar.dart'; @@ -20,6 +21,7 @@ import 'package:localsend_app/widget/responsive_list_view.dart'; import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/model/dto/file_dto.dart'; import 'package:localsend_isolates/model/session_status.dart'; +import 'package:refena_flutter/addons.dart'; import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -28,7 +30,7 @@ class ReceivePageVm { final SessionStatus? status; final Device sender; - /// Show hashtag and device model. + /// Show verify button and device model. final bool showSenderInfo; final List files; final String? message; @@ -59,8 +61,6 @@ class ReceivePage extends StatefulWidget { } class _ReceivePageState extends State with Refena { - bool _showFullIp = false; - @override Widget build(BuildContext context) { final vm = context.watch( @@ -110,125 +110,158 @@ class _ReceivePageState extends State with Refena { children: [ Expanded( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - if (vm.showSenderInfo && !smallUi) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Icon(vm.sender.deviceType.icon, size: 64), - ), - Builder( - builder: (context) { - final alias = senderFavoriteEntry?.alias ?? vm.sender.alias; - if (alias.isEmpty) { - return Text('', style: TextStyle(fontSize: smallUi ? 32 : 48)); - } - return FittedBox( - child: Text( - alias, - style: TextStyle(fontSize: smallUi ? 32 : 48), - textAlign: TextAlign.center, + Column( + children: [ + if (vm.showSenderInfo && !smallUi) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Icon(vm.sender.deviceType.icon, size: 64), ), - ); - }, - ), - if (vm.showSenderInfo) ...[ - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - InkWell( - onTap: () { - setState(() { - _showFullIp = !_showFullIp; - }); - }, + Builder( + builder: (context) { + final alias = senderFavoriteEntry?.alias ?? vm.sender.alias; + if (alias.isEmpty) { + return Text('', style: TextStyle(fontSize: smallUi ? 32 : 48)); + } + return FittedBox( + child: Text( + alias, + style: TextStyle(fontSize: smallUi ? 32 : 48), + textAlign: TextAlign.center, + ), + ); + }, + ), + if (vm.showSenderInfo && vm.sender.deviceModel != null) ...[ + const SizedBox(height: 10), + Center( child: DeviceBadge( - backgroundColor: Theme.of(context).colorScheme.onSecondaryContainer, - foregroundColor: Theme.of(context).colorScheme.onInverseSurface, - label: switch (vm.sender.ip) { - String ip => _showFullIp ? ip : '#${ip.visualId}', - null => 'WebRTC', - }, - ), - ), - if (vm.sender.deviceModel != null) ...[ - const SizedBox(width: 10), - DeviceBadge( backgroundColor: Theme.of(context).colorScheme.onSecondaryContainer, foregroundColor: Theme.of(context).colorScheme.onInverseSurface, label: vm.sender.deviceModel!, ), - ], + ), ], - ), - ], - const SizedBox(height: 40), - Text( - vm.message != null - ? (vm.isLink ? t.receivePage.subTitleLink : t.receivePage.subTitleMessage) - : t.receivePage.subTitle(n: vm.files.length), - style: smallUi ? null : Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, + ], ), - if (vm.message != null) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + + Column( + children: [ + Text( + vm.message != null + ? (vm.isLink ? t.receivePage.subTitleLink : t.receivePage.subTitleMessage) + : t.receivePage.subTitle(n: vm.files.length), + style: smallUi ? null : Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + if (vm.showSenderInfo && vm.message == null) Padding( - padding: const EdgeInsets.only(top: 20), - child: SizedBox( - height: 100, - child: Card( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(10), - child: SelectableText( - vm.message!, + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: () async => await context.push( + () => VerifyPage( + fingerprint: CombinedFingerprint.load(context, vm.sender.fingerprint), ), ), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + icon: Icon(Icons.verified_user), + label: Text(t.verifyPage.title), ), - ), + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + onPressed: () async { + await context.push(() => ReceiveOptionsPage(vm)); + }, + icon: const Icon(Icons.settings), + label: Text(t.receiveOptionsPage.title), + ), + ], ), ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, + if (vm.message != null) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ElevatedButton( - onPressed: () { - unawaited( - Clipboard.setData(ClipboardData(text: vm.message!)), - ); - if (checkPlatformIsDesktop()) { - context.showSnackBar(t.general.copiedToClipboard); - } - vm.onAccept(); - context.pop(); - }, - child: Text(t.general.copy), - ), - if (vm.isLink) - Padding( - padding: const EdgeInsetsDirectional.only(start: 20), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, + Padding( + padding: const EdgeInsets.only(top: 20), + child: SizedBox( + height: 100, + child: Card( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(10), + child: SelectableText( + vm.message!, + ), + ), ), - onPressed: () { - // ignore: discarded_futures - launchUrl(Uri.parse(vm.message!), mode: LaunchMode.externalApplication); - vm.onAccept(); - context.pop(); - }, - child: Text(t.general.open), ), ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (vm.showSenderInfo) + Padding( + padding: const EdgeInsetsDirectional.only(end: 20), + child: ElevatedButton.icon( + onPressed: () async => await context.push( + () => VerifyPage( + fingerprint: CombinedFingerprint.load(context, vm.sender.fingerprint), + ), + ), + icon: Icon(Icons.verified_user), + label: Text(t.verifyPage.title), + ), + ), + ElevatedButton.icon( + onPressed: () { + unawaited( + Clipboard.setData(ClipboardData(text: vm.message!)), + ); + if (checkPlatformIsDesktop()) { + context.showSnackBar(t.general.copiedToClipboard); + } + vm.onAccept(); + context.global.dispatch(NavigateAction.popUntil()); + }, + icon: Icon(Icons.copy), + label: Text(t.general.copy), + ), + if (vm.isLink) + Padding( + padding: const EdgeInsetsDirectional.only(start: 20), + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + onPressed: () { + // ignore: discarded_futures + launchUrl(Uri.parse(vm.message!), mode: LaunchMode.externalApplication); + vm.onAccept(); + context.global.dispatch(NavigateAction.popUntil()); + }, + icon: Icon(Icons.open_in_new), + label: Text(t.general.open), + ), + ), + ], + ), ], ), - ], - ), + ], + ), ], ), ), @@ -266,7 +299,7 @@ class _Actions extends StatelessWidget { ), onPressed: () { vm.onAccept(); - context.pop(); + context.global.dispatch(NavigateAction.popUntil()); }, icon: const Icon(Icons.close), label: Text(t.general.close), @@ -289,7 +322,7 @@ class _Actions extends StatelessWidget { child: ElevatedButton.icon( onPressed: () { vm.onClose(); - context.pop(); + context.global.dispatch(NavigateAction.popUntil()); }, icon: const Icon(Icons.check_circle), label: Text(t.general.close), @@ -301,19 +334,6 @@ class _Actions extends StatelessWidget { return Column( children: [ - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - await context.push(() => ReceiveOptionsPage(vm)); - }, - icon: const Icon(Icons.settings), - label: Text(t.receiveOptionsPage.title), - ), - ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -325,7 +345,7 @@ class _Actions extends StatelessWidget { ), onPressed: () { vm.onDecline(); - context.pop(); + context.global.dispatch(NavigateAction.popUntil()); }, icon: const Icon(Icons.close), label: Text(t.general.decline), diff --git a/app/lib/pages/send_page.dart b/app/lib/pages/send_page.dart index f4474b67..2ce5622f 100644 --- a/app/lib/pages/send_page.dart +++ b/app/lib/pages/send_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/model/state/send/send_session_state.dart'; +import 'package:localsend_app/pages/verify_page.dart'; import 'package:localsend_app/provider/device_info_provider.dart'; import 'package:localsend_app/provider/favorites_provider.dart'; import 'package:localsend_app/provider/file_transfer_provider.dart'; @@ -17,6 +18,7 @@ import 'package:localsend_app/widget/list_tile/device_list_tile.dart'; import 'package:localsend_app/widget/responsive_list_view.dart'; import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/model/session_status.dart'; +import 'package:refena_flutter/addons.dart'; import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; @@ -137,6 +139,25 @@ class _SendPageState extends State with Refena { nameOverride: targetFavoriteEntry?.alias, ), ), + InitialFadeTransition( + duration: const Duration(milliseconds: 300), + delay: const Duration(milliseconds: 400), + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: TextButton.icon( + onPressed: () async => await context.push( + () => VerifyPage( + fingerprint: CombinedFingerprint.load(context, targetDevice.fingerprint), + ), + ), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + icon: Icon(Icons.verified_user), + label: Text(t.verifyPage.title), + ), + ), + ), ], ), ), @@ -217,7 +238,7 @@ class _SendPageState extends State with Refena { child: FilledButton.icon( onPressed: () { _cancel(); - context.pop(); + context.global.dispatch(NavigateAction.popUntilRoot()); }, icon: Icon(waiting ? Icons.close : Icons.check_circle), label: Text(waiting ? t.general.cancel : t.general.close), diff --git a/app/lib/pages/verify_page.dart b/app/lib/pages/verify_page.dart index f7300fc3..5b0ec1d7 100644 --- a/app/lib/pages/verify_page.dart +++ b/app/lib/pages/verify_page.dart @@ -3,10 +3,30 @@ import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/provider/security_provider.dart'; import 'package:localsend_app/util/fingerprint_alphabet.dart'; import 'package:localsend_app/widget/responsive_list_view.dart'; -import 'package:localsend_isolates/model/device.dart'; import 'package:refena_flutter/refena_flutter.dart'; -enum _VerifyMode { +class CombinedFingerprint { + final String combined; + final List icons; + + CombinedFingerprint({ + required this.combined, + required this.icons, + }); + + factory CombinedFingerprint.load(BuildContext context, String fingerprint) { + final myFingerprint = context.ref.watch(securityProvider.select((s) => s.certificateHash)); + final fingerprints = [myFingerprint, fingerprint]..sort(); + final combined = fingerprints.join(); + final icons = fingerprintToIcons(combined); + return CombinedFingerprint( + combined: combined, + icons: icons, + ); + } +} + +enum VerifyMode { icons, raw, } @@ -14,22 +34,19 @@ enum _VerifyMode { /// Verifies the identity of a discovered device by comparing the fingerprints /// of both sides. class VerifyPage extends StatefulWidget { - final Device device; + final CombinedFingerprint fingerprint; - const VerifyPage({required this.device}); + const VerifyPage({required this.fingerprint}); @override State createState() => _VerifyPageState(); } class _VerifyPageState extends State { - _VerifyMode _mode = _VerifyMode.icons; + VerifyMode _mode = VerifyMode.icons; @override Widget build(BuildContext context) { - final myFingerprint = context.ref.watch(securityProvider.select((s) => s.certificateHash)); - final fingerprints = [myFingerprint, widget.device.fingerprint]..sort(); - final combined = fingerprints.join(); return Scaffold( appBar: AppBar( title: Text(t.verifyPage.title), @@ -38,34 +55,20 @@ class _VerifyPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), children: [ Center( - child: SegmentedButton<_VerifyMode>( + child: SegmentedButton( segments: [ - ButtonSegment(value: _VerifyMode.icons, label: Text(t.verifyPage.icons)), - ButtonSegment(value: _VerifyMode.raw, label: Text(t.verifyPage.raw)), + ButtonSegment(value: VerifyMode.icons, label: Text(t.verifyPage.icons)), + ButtonSegment(value: VerifyMode.raw, label: Text(t.verifyPage.raw)), ], selected: {_mode}, onSelectionChanged: (selection) => setState(() => _mode = selection.first), ), ), const SizedBox(height: 20), - switch (_mode) { - _VerifyMode.icons => Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 240), - child: GridView.count( - crossAxisCount: 4, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - for (final icon in fingerprintToIcons(combined)) Icon(icon, size: 32), - ], - ), - ), - ), - _VerifyMode.raw => SelectableText(combined), - }, + VerifyWidget( + mode: _mode, + fingerprint: widget.fingerprint, + ), const SizedBox(height: 20), Center( child: Text( @@ -79,3 +82,39 @@ class _VerifyPageState extends State { ); } } + +class VerifyWidget extends StatelessWidget { + final VerifyMode mode; + final CombinedFingerprint fingerprint; + + const VerifyWidget({ + required this.mode, + required this.fingerprint, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + switch (mode) { + VerifyMode.icons => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: GridView.count( + crossAxisCount: 4, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (final icon in fingerprint.icons) Icon(icon, size: 32), + ], + ), + ), + ), + VerifyMode.raw => SelectableText(fingerprint.combined), + }, + ], + ); + } +}