mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
400 lines
17 KiB
Dart
400 lines
17 KiB
Dart
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/model/cross_file.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/pin_dialog.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: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 }
|
|
|
|
/// 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 WebSharePage({this.files});
|
|
|
|
@override
|
|
State<WebSharePage> createState() => _WebSharePageState();
|
|
}
|
|
|
|
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();
|
|
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 {
|
|
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;
|
|
});
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
setState(() {
|
|
_stateEnum = _ServerState.error;
|
|
_initializedError = e.toString();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
onPopInvokedWithResult: (_, _) async {
|
|
if (_stateEnum == _ServerState.initializing || _stateEnum == _ServerState.stopping) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_stateEnum = _ServerState.stopping;
|
|
});
|
|
await sleepAsync(250);
|
|
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) {
|
|
context.pop();
|
|
}
|
|
},
|
|
canPop: false,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_sendMode ? t.webSharePage.title : 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);
|
|
final webSendState = serverState?.webSendState;
|
|
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),
|
|
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}';
|
|
final urlWithPin = switch (webSendState?.pin) {
|
|
String() => '$url/?pin=${Uri.encodeQueryComponent(webSendState!.pin!)}',
|
|
null => url,
|
|
};
|
|
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: urlWithPin,
|
|
label: url,
|
|
listenIncomingWebSendRequests: _sendMode,
|
|
pin: webSendState?.pin,
|
|
),
|
|
);
|
|
},
|
|
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,
|
|
listenIncomingWebSendRequests: _sendMode,
|
|
pin: webSendState?.pin,
|
|
),
|
|
);
|
|
},
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Icon(Icons.tv, size: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
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: webSendState != null ? webSendState.autoAccept : settings.receiveViaLinkAutoAccept,
|
|
onChanged: (value) async {
|
|
if (webSendState != null) {
|
|
ref.notifier(serverProvider).setWebSendAutoAccept(value == true);
|
|
} else {
|
|
await ref.notifier(settingsProvider).setReceiveViaLinkAutoAccept(value == true);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|