From 853fe4d85c7c5a7cc81159d539561113c6a18786 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Mon, 3 Aug 2026 13:00:09 +0200 Subject: [PATCH] refactor: merge web receive and web send --- app/lib/pages/progress_page.dart | 4 +- app/lib/pages/tabs/receive_tab.dart | 4 +- app/lib/pages/tabs/send_tab_vm.dart | 4 +- app/lib/pages/web_receive_page.dart | 252 ---------------- ...web_send_page.dart => web_share_page.dart} | 273 ++++++++++-------- 5 files changed, 161 insertions(+), 376 deletions(-) delete mode 100644 app/lib/pages/web_receive_page.dart rename app/lib/pages/{web_send_page.dart => web_share_page.dart} (58%) diff --git a/app/lib/pages/progress_page.dart b/app/lib/pages/progress_page.dart index 2a0e33ee..98cac548 100644 --- a/app/lib/pages/progress_page.dart +++ b/app/lib/pages/progress_page.dart @@ -6,7 +6,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/server/receive_session_state.dart'; -import 'package:localsend_app/pages/web_receive_page.dart'; +import 'package:localsend_app/pages/web_share_page.dart'; import 'package:localsend_app/provider/network/send_provider.dart'; import 'package:localsend_app/provider/network/server/server_provider.dart'; import 'package:localsend_app/provider/progress_provider.dart'; @@ -143,7 +143,7 @@ class _ProgressPageState extends State with Refena { if (result && mounted) { if (ref.read(serverProvider)?.webUpload == true) { - context.global.dispatch(NavigateAction.popUntil()); + context.global.dispatch(NavigateAction.popUntil()); } else { context.global.dispatch(NavigateAction.popUntilRoot()); } diff --git a/app/lib/pages/tabs/receive_tab.dart b/app/lib/pages/tabs/receive_tab.dart index 21f1ba5d..d2ebb129 100644 --- a/app/lib/pages/tabs/receive_tab.dart +++ b/app/lib/pages/tabs/receive_tab.dart @@ -4,7 +4,7 @@ import 'package:localsend_app/pages/home_page.dart'; import 'package:localsend_app/pages/home_page_controller.dart'; import 'package:localsend_app/pages/receive_history_page.dart'; import 'package:localsend_app/pages/tabs/receive_tab_vm.dart'; -import 'package:localsend_app/pages/web_receive_page.dart'; +import 'package:localsend_app/pages/web_share_page.dart'; import 'package:localsend_app/provider/animation_provider.dart'; import 'package:localsend_app/util/ip_helper.dart'; import 'package:localsend_app/widget/animations/initial_fade_transition.dart'; @@ -74,7 +74,7 @@ class ReceiveTab extends StatelessWidget { child: Center( child: OutlinedButton.icon( onPressed: () async { - await context.global.dispatchAsync(NavigateAction.push(WebReceivePage())); + await context.global.dispatchAsync(NavigateAction.push(const WebSharePage())); }, icon: Icon(Icons.language), label: Text(t.$wip.receiveTab.link('Receive via link')), diff --git a/app/lib/pages/tabs/send_tab_vm.dart b/app/lib/pages/tabs/send_tab_vm.dart index d5e12268..177cbdc5 100644 --- a/app/lib/pages/tabs/send_tab_vm.dart +++ b/app/lib/pages/tabs/send_tab_vm.dart @@ -5,7 +5,7 @@ import 'package:localsend_app/model/persistence/favorite_device.dart'; import 'package:localsend_app/model/send_mode.dart'; import 'package:localsend_app/pages/progress_page.dart'; import 'package:localsend_app/pages/send_page.dart'; -import 'package:localsend_app/pages/web_send_page.dart'; +import 'package:localsend_app/pages/web_share_page.dart'; import 'package:localsend_app/provider/favorites_provider.dart'; import 'package:localsend_app/provider/local_ip_provider.dart'; import 'package:localsend_app/provider/network/nearby_devices_provider.dart'; @@ -113,7 +113,7 @@ final sendTabVmProvider = ViewProvider((ref) { await context.pushBottomSheet(() => const NoFilesDialog()); return; } - await context.push(() => WebSendPage(files)); + await context.push(() => WebSharePage(files: files)); return; } diff --git a/app/lib/pages/web_receive_page.dart b/app/lib/pages/web_receive_page.dart deleted file mode 100644 index 83549558..00000000 --- a/app/lib/pages/web_receive_page.dart +++ /dev/null @@ -1,252 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:localsend_app/config/theme.dart'; -import 'package:localsend_app/gen/strings.g.dart'; -import 'package:localsend_app/provider/local_ip_provider.dart'; -import 'package:localsend_app/provider/network/server/server_provider.dart'; -import 'package:localsend_app/provider/settings_provider.dart'; -import 'package:localsend_app/util/native/platform_check.dart'; -import 'package:localsend_app/util/ui/snackbar.dart'; -import 'package:localsend_app/widget/dialogs/qr_dialog.dart'; -import 'package:localsend_app/widget/dialogs/zoom_dialog.dart'; -import 'package:localsend_app/widget/responsive_list_view.dart'; -import 'package:localsend_isolates/util/sleep.dart'; -import 'package:refena_flutter/refena_flutter.dart'; -import 'package:routerino/routerino.dart'; - -enum _ServerState { initializing, running, error, stopping } - -/// Lets web browsers upload files to this device. -/// Incoming requests are not listed here because they open the receive page -/// like any other incoming request. -class WebReceivePage extends StatefulWidget { - const WebReceivePage(); - - @override - State createState() => _WebReceivePageState(); -} - -class _WebReceivePageState extends State with Refena { - _ServerState _stateEnum = _ServerState.initializing; - bool _encrypted = false; - String? _initializedError; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _init(encrypted: false); - }); - } - - void _init({required bool encrypted}) async { - final settings = ref.read(settingsProvider); - setState(() { - _stateEnum = _ServerState.initializing; - _encrypted = encrypted; - _initializedError = null; - }); - await sleepAsync(500); - try { - await ref - .notifier(serverProvider) - .restartServer( - alias: settings.alias, - port: settings.port, - https: _encrypted, - webUpload: true, - ); - setState(() { - _stateEnum = _ServerState.running; - }); - } catch (e) { - if (context.mounted) { - setState(() { - _stateEnum = _ServerState.error; - _initializedError = e.toString(); - }); - } - } - } - - /// Web receive uses unencrypted http by default, so we need to revert to the previous state. - Future _revertServerState() async { - await ref.notifier(serverProvider).restartServerFromSettings(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - onPopInvokedWithResult: (_, _) async { - if (_stateEnum != _ServerState.running) { - return; - } - - setState(() { - _stateEnum = _ServerState.stopping; - }); - await sleepAsync(250); - await _revertServerState(); - await sleepAsync(250); - - if (context.mounted) { - context.pop(); - } - }, - canPop: false, - child: Scaffold( - appBar: AppBar( - title: Text(t.webReceivePage.title), - ), - body: Builder( - builder: (context) { - if (_stateEnum != _ServerState.running) { - return Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (_stateEnum == _ServerState.initializing || _stateEnum == _ServerState.stopping) ...[ - const CircularProgressIndicator(), - const SizedBox(height: 20), - Center( - child: Text( - _stateEnum == _ServerState.initializing ? t.webSharePage.loading : t.webSharePage.stopping, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ] else if (_initializedError != null) ...[ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 10), - Center( - child: Text(t.webSharePage.error, style: Theme.of(context).textTheme.titleLarge), - ), - const SizedBox(height: 10), - Center( - child: SelectableText(_initializedError!, style: Theme.of(context).textTheme.bodyMedium), - ), - ], - ], - ); - } - - final serverState = context.watch(serverProvider); - if (serverState == null) { - // the server is restarting - return const Center(child: CircularProgressIndicator()); - } - final networkState = context.watch(localIpProvider); - final settings = context.watch(settingsProvider); - - return ResponsiveListView( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), - children: [ - Text(t.webSharePage.openLink(n: networkState.localIps.length), style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 10), - Card( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...networkState.localIps.map((ip) { - final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}'; - return Padding( - padding: const EdgeInsets.all(5), - child: Row( - children: [ - SelectableText( - url, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(width: 5), - InkWell( - onTap: () async { - await Clipboard.setData(ClipboardData(text: url)); - if (context.mounted && checkPlatformIsDesktop()) { - context.showSnackBar(t.general.copiedToClipboard); - } - }, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Icon(Icons.content_copy, size: 16), - ), - ), - InkWell( - onTap: () async { - await showDialog( - context: context, - builder: (_) => QrDialog( - data: url, - label: url, - ), - ); - }, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Icon(Icons.qr_code, size: 16), - ), - ), - InkWell( - onTap: () async { - await showDialog( - context: context, - builder: (_) => ZoomDialog( - label: url, - ), - ); - }, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Icon(Icons.tv, size: 16), - ), - ), - ], - ), - ); - }), - ], - ), - ), - ), - const SizedBox(height: 20), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(t.webSharePage.encryption, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(width: 10), - Checkbox( - value: _encrypted, - onChanged: (value) { - _init(encrypted: value == true); - }, - ), - ], - ), - if (_encrypted) - Text( - t.webSharePage.encryptionHint, - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(t.webSharePage.autoAccept, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(width: 10), - Checkbox( - value: settings.receiveViaLinkAutoAccept, - onChanged: (value) async { - await ref.notifier(settingsProvider).setReceiveViaLinkAutoAccept(value == true); - }, - ), - ], - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/app/lib/pages/web_send_page.dart b/app/lib/pages/web_share_page.dart similarity index 58% rename from app/lib/pages/web_send_page.dart rename to app/lib/pages/web_share_page.dart index f44da98d..b9228953 100644 --- a/app/lib/pages/web_send_page.dart +++ b/app/lib/pages/web_share_page.dart @@ -13,25 +13,37 @@ import 'package:localsend_app/widget/dialogs/qr_dialog.dart'; import 'package:localsend_app/widget/dialogs/zoom_dialog.dart'; import 'package:localsend_app/widget/responsive_list_view.dart'; import 'package:localsend_isolates/util/sleep.dart'; +import 'package:logging/logging.dart'; import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; +final _logger = Logger('WebSharePage'); + enum _ServerState { initializing, running, error, stopping } -class WebSendPage extends StatefulWidget { - final List files; +/// Shares a link with web browsers, in one of two modes: +/// - send: offers [WebSharePage.files] for download. +/// - receive: serves the upload page so browsers can upload files to this device. +/// Incoming requests are not listed here because they open the receive page +/// like any other incoming request. +class WebSharePage extends StatefulWidget { + /// The files offered for download (share via link). + /// `null` serves the upload page instead (receive via link). + final List? files; - const WebSendPage(this.files); + const WebSharePage({this.files}); @override - State createState() => _WebSendPageState(); + State createState() => _WebSharePageState(); } -class _WebSendPageState extends State with Refena { +class _WebSharePageState extends State with Refena { _ServerState _stateEnum = _ServerState.initializing; bool _encrypted = false; String? _initializedError; + bool get _sendMode => widget.files != null; + @override void initState() { super.initState(); @@ -49,15 +61,27 @@ class _WebSendPageState extends State with Refena { }); await sleepAsync(500); try { - // The auto accept setting and the pin of a previous web send state are kept. - await ref - .notifier(serverProvider) - .restartServerWithWebSend( - alias: settings.alias, - port: settings.port, - https: _encrypted, - files: widget.files, - ); + final files = widget.files; + if (files != null) { + // The auto accept setting and the pin of a previous web send state are kept. + await ref + .notifier(serverProvider) + .restartServerWithWebSend( + alias: settings.alias, + port: settings.port, + https: _encrypted, + files: files, + ); + } else { + await ref + .notifier(serverProvider) + .restartServer( + alias: settings.alias, + port: settings.port, + https: _encrypted, + webUpload: true, + ); + } setState(() { _stateEnum = _ServerState.running; }); @@ -71,7 +95,7 @@ class _WebSendPageState extends State with Refena { } } - /// Web share uses unencrypted http, so we need to revert to the previous state. + /// Web share uses unencrypted http by default, so we need to revert to the previous state. Future _revertServerState() async { await ref.notifier(serverProvider).restartServerFromSettings(); } @@ -80,7 +104,7 @@ class _WebSendPageState extends State with Refena { Widget build(BuildContext context) { return PopScope( onPopInvokedWithResult: (_, _) async { - if (_stateEnum != _ServerState.running) { + if (_stateEnum == _ServerState.initializing || _stateEnum == _ServerState.stopping) { return; } @@ -88,7 +112,12 @@ class _WebSendPageState extends State with Refena { _stateEnum = _ServerState.stopping; }); await sleepAsync(250); - await _revertServerState(); + try { + // Also needed in the error state: the failed restart already stopped the old server. + await _revertServerState(); + } catch (e) { + _logger.warning('Failed to restore the server', e); + } await sleepAsync(250); if (context.mounted) { @@ -98,7 +127,7 @@ class _WebSendPageState extends State with Refena { canPop: false, child: Scaffold( appBar: AppBar( - title: Text(t.webSharePage.title), + title: Text(_sendMode ? t.webSharePage.title : t.webReceivePage.title), ), body: Builder( builder: (context) { @@ -134,11 +163,12 @@ class _WebSendPageState extends State with Refena { final serverState = context.watch(serverProvider); final webSendState = serverState?.webSendState; - if (serverState == null || webSendState == null) { + if (serverState == null || (_sendMode && webSendState == null)) { // the server is restarting (e.g. because the pin changed) return const Center(child: CircularProgressIndicator()); } final networkState = context.watch(localIpProvider); + final settings = context.watch(settingsProvider); return ResponsiveListView( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), @@ -154,8 +184,8 @@ class _WebSendPageState extends State with Refena { children: [ ...networkState.localIps.map((ip) { final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}'; - final urlWithPin = switch (webSendState.pin) { - String() => '$url/?pin=${Uri.encodeQueryComponent(webSendState.pin!)}', + final urlWithPin = switch (webSendState?.pin) { + String() => '$url/?pin=${Uri.encodeQueryComponent(webSendState!.pin!)}', null => url, }; return Padding( @@ -186,8 +216,8 @@ class _WebSendPageState extends State with Refena { builder: (_) => QrDialog( data: urlWithPin, label: url, - listenIncomingWebSendRequests: true, - pin: webSendState.pin, + listenIncomingWebSendRequests: _sendMode, + pin: webSendState?.pin, ), ); }, @@ -202,8 +232,8 @@ class _WebSendPageState extends State with Refena { context: context, builder: (_) => ZoomDialog( label: url, - pin: webSendState.pin, - listenIncomingWebSendRequests: true, + listenIncomingWebSendRequests: _sendMode, + pin: webSendState?.pin, ), ); }, @@ -221,72 +251,74 @@ class _WebSendPageState extends State with Refena { ), ), const SizedBox(height: 20), - Text(t.webSharePage.requests, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 10), - if (webSendState.sessions.isEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 30), - child: Text(t.webSharePage.noRequests), - ), - ...webSendState.sessions.entries.map((entry) { - final session = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Card( - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.deviceInfo, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: session.pending ? Theme.of(context).colorScheme.warning : null, + if (webSendState != null) ...[ + Text(t.webSharePage.requests, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + if (webSendState.sessions.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 30), + child: Text(t.webSharePage.noRequests), + ), + ...webSendState.sessions.entries.map((entry) { + final session = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Card( + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.deviceInfo, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: session.pending ? Theme.of(context).colorScheme.warning : null, + ), + ), + const SizedBox(height: 5), + Text(session.ip, style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.grey)), + ], + ), + ), + if (session.pending) ...[ + TextButton( + onPressed: () { + ref.notifier(serverProvider).declineWebSendRequest(session.sessionId); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + child: const Icon(Icons.close), + ), + TextButton( + onPressed: () { + ref.notifier(serverProvider).acceptWebSendRequest(session.sessionId); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + child: const Icon(Icons.check_circle), + ), + ] else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + t.general.accepted, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, ), ), - const SizedBox(height: 5), - Text(session.ip, style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.grey)), - ], - ), - ), - if (session.pending) ...[ - TextButton( - onPressed: () { - ref.notifier(serverProvider).declineWebSendRequest(session.sessionId); - }, - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurface, ), - child: const Icon(Icons.close), - ), - TextButton( - onPressed: () { - ref.notifier(serverProvider).acceptWebSendRequest(session.sessionId); - }, - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - child: const Icon(Icons.check_circle), - ), - ] else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - t.general.accepted, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - ), - ], + ], + ), ), ), - ), - ); - }), + ); + }), + ], Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -311,46 +343,51 @@ class _WebSendPageState extends State with Refena { Text(t.webSharePage.autoAccept, style: Theme.of(context).textTheme.titleMedium), const SizedBox(width: 10), Checkbox( - value: webSendState.autoAccept, - onChanged: (value) { - ref.notifier(serverProvider).setWebSendAutoAccept(value == true); - }, - ), - ], - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(width: 10), - Checkbox( - value: webSendState.pin != null, + value: webSendState != null ? webSendState.autoAccept : settings.receiveViaLinkAutoAccept, onChanged: (value) async { - final currentPIN = webSendState.pin; - if (currentPIN != null) { - await ref.notifier(serverProvider).setWebSendPin(null); + if (webSendState != null) { + ref.notifier(serverProvider).setWebSendAutoAccept(value == true); } else { - final String? newPin = await showDialog( - context: context, - builder: (_) => const PinDialog( - obscureText: false, - generateRandom: true, - ), - ); - - if (newPin != null && newPin.isNotEmpty) { - await ref.notifier(serverProvider).setWebSendPin(newPin); - } + await ref.notifier(settingsProvider).setReceiveViaLinkAutoAccept(value == true); } }, ), ], ), - if (webSendState.pin != null) ...[ - Text( - t.webSharePage.pinHint(pin: webSendState.pin!), - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), + if (webSendState != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(width: 10), + Checkbox( + value: webSendState.pin != null, + onChanged: (value) async { + final currentPIN = webSendState.pin; + if (currentPIN != null) { + await ref.notifier(serverProvider).setWebSendPin(null); + } else { + final String? newPin = await showDialog( + context: context, + builder: (_) => const PinDialog( + obscureText: false, + generateRandom: true, + ), + ); + + if (newPin != null && newPin.isNotEmpty) { + await ref.notifier(serverProvider).setWebSendPin(newPin); + } + } + }, + ), + ], ), + if (webSendState.pin != null) + Text( + t.webSharePage.pinHint(pin: webSendState.pin!), + style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), + ), ], ], );