feat: add verify button to send and receive page
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-08-05 14:28:56 +02:00
parent 5e8867e0e9
commit 33532c9f0f
4 changed files with 233 additions and 149 deletions
+5 -1
View File
@@ -88,7 +88,11 @@ class _DeviceDetailsPageState extends State<DeviceDetailsPage> 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),
),
),
),
],
),
+70 -50
View File
@@ -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<FileDto> files;
final String? message;
@@ -59,8 +61,6 @@ class ReceivePage extends StatefulWidget {
}
class _ReceivePageState extends State<ReceivePage> with Refena {
bool _showFullIp = false;
@override
Widget build(BuildContext context) {
final vm = context.watch(
@@ -110,7 +110,9 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
if (vm.showSenderInfo && !smallUi)
Padding(
@@ -132,38 +134,21 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
);
},
),
if (vm.showSenderInfo) ...[
if (vm.showSenderInfo && vm.sender.deviceModel != null) ...[
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InkWell(
onTap: () {
setState(() {
_showFullIp = !_showFullIp;
});
},
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),
Column(
children: [
Text(
vm.message != null
? (vm.isLink ? t.receivePage.subTitleLink : t.receivePage.subTitleMessage)
@@ -171,6 +156,37 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
style: smallUi ? null : Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (vm.showSenderInfo && vm.message == null)
Padding(
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),
),
],
),
),
if (vm.message != null)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -195,7 +211,20 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
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!)),
@@ -204,14 +233,15 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
context.showSnackBar(t.general.copiedToClipboard);
}
vm.onAccept();
context.pop();
context.global.dispatch(NavigateAction.popUntil<WebSharePage>());
},
child: Text(t.general.copy),
icon: Icon(Icons.copy),
label: Text(t.general.copy),
),
if (vm.isLink)
Padding(
padding: const EdgeInsetsDirectional.only(start: 20),
child: ElevatedButton(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
@@ -220,9 +250,10 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
// ignore: discarded_futures
launchUrl(Uri.parse(vm.message!), mode: LaunchMode.externalApplication);
vm.onAccept();
context.pop();
context.global.dispatch(NavigateAction.popUntil<WebSharePage>());
},
child: Text(t.general.open),
icon: Icon(Icons.open_in_new),
label: Text(t.general.open),
),
),
],
@@ -231,6 +262,8 @@ class _ReceivePageState extends State<ReceivePage> with Refena {
),
],
),
],
),
),
_Actions(vm),
],
@@ -266,7 +299,7 @@ class _Actions extends StatelessWidget {
),
onPressed: () {
vm.onAccept();
context.pop();
context.global.dispatch(NavigateAction.popUntil<WebSharePage>());
},
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<WebSharePage>());
},
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<WebSharePage>());
},
icon: const Icon(Icons.close),
label: Text(t.general.decline),
+22 -1
View File
@@ -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<SendPage> 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<SendPage> 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),
+67 -28
View File
@@ -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<IconData> 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<VerifyPage> createState() => _VerifyPageState();
}
class _VerifyPageState extends State<VerifyPage> {
_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<VerifyPage> {
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
children: [
Center(
child: SegmentedButton<_VerifyMode>(
child: SegmentedButton<VerifyMode>(
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),
],
VerifyWidget(
mode: _mode,
fingerprint: widget.fingerprint,
),
),
),
_VerifyMode.raw => SelectableText(combined),
},
const SizedBox(height: 20),
Center(
child: Text(
@@ -79,3 +82,39 @@ class _VerifyPageState extends State<VerifyPage> {
);
}
}
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),
},
],
);
}
}