mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: add verify button to send and receive page
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+138
-118
@@ -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,125 +110,158 @@ class _ReceivePageState extends State<ReceivePage> 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<WebSharePage>());
|
||||
},
|
||||
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<WebSharePage>());
|
||||
},
|
||||
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<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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_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<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),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user