refactor: merge web receive and web send

This commit is contained in:
Tien Do Nam
2026-08-03 13:00:09 +02:00
parent 73ff2fe6e2
commit 853fe4d85c
5 changed files with 161 additions and 376 deletions
+2 -2
View File
@@ -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<ProgressPage> with Refena {
if (result && mounted) {
if (ref.read(serverProvider)?.webUpload == true) {
context.global.dispatch(NavigateAction.popUntil<WebReceivePage>());
context.global.dispatch(NavigateAction.popUntil<WebSharePage>());
} else {
context.global.dispatch(NavigateAction.popUntilRoot());
}
+2 -2
View File
@@ -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')),
+2 -2
View File
@@ -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;
}
-252
View File
@@ -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<WebReceivePage> createState() => _WebReceivePageState();
}
class _WebReceivePageState extends State<WebReceivePage> 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<void> _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);
},
),
],
),
],
);
},
),
),
);
}
}
@@ -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<CrossFile> 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<CrossFile>? files;
const WebSendPage(this.files);
const WebSharePage({this.files});
@override
State<WebSendPage> createState() => _WebSendPageState();
State<WebSharePage> createState() => _WebSharePageState();
}
class _WebSendPageState extends State<WebSendPage> with Refena {
class _WebSharePageState extends State<WebSharePage> 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<WebSendPage> 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<WebSendPage> 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<void> _revertServerState() async {
await ref.notifier(serverProvider).restartServerFromSettings();
}
@@ -80,7 +104,7 @@ class _WebSendPageState extends State<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<WebSendPage> 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<String>(
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<String>(
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),
),
],
],
);