diff --git a/lib/model/state/send/send_session_state.dart b/lib/model/state/send/send_session_state.dart index 8a096cb2..b1ab6fc1 100644 --- a/lib/model/state/send/send_session_state.dart +++ b/lib/model/state/send/send_session_state.dart @@ -9,6 +9,8 @@ part 'send_session_state.freezed.dart'; @freezed class SendSessionState with _$SendSessionState { const factory SendSessionState({ + required String sessionId, + required bool background, required SessionStatus status, required Device target, required Map files, // file id as key diff --git a/lib/pages/progress_page.dart b/lib/pages/progress_page.dart index 6d0d5ba2..4e495c3c 100644 --- a/lib/pages/progress_page.dart +++ b/lib/pages/progress_page.dart @@ -23,9 +23,15 @@ import 'package:routerino/routerino.dart'; import 'package:wakelock/wakelock.dart'; class ProgressPage extends ConsumerStatefulWidget { + final bool showAppBar; + final bool closeSessionOnClose; final String sessionId; - const ProgressPage({required this.sessionId}); + const ProgressPage({ + required this.showAppBar, + required this.closeSessionOnClose, + required this.sessionId, + }); @override ConsumerState createState() => _ProgressPageState(); @@ -74,6 +80,20 @@ class _ProgressPageState extends ConsumerState { } catch (_) {} } + Future _onWillPop() async { + final receiveSession = ref.watch(serverProvider.select((s) => s?.session)); + final sendSession = ref.watch(sendProvider)[widget.sessionId]; + final SessionStatus? status = receiveSession?.status ?? sendSession?.status; + if (status == null) { + return true; + } + if (!widget.closeSessionOnClose && status == SessionStatus.sending) { + // keep session except [closeSessionOnClose] is true and the session is active + return true; + } + return _askCancelConfirmation(status); + } + Future _askCancelConfirmation(SessionStatus status) async { final bool result = status == SessionStatus.sending ? await context.pushBottomSheet(() => const CancelSessionDialog()) : true; if (result) { @@ -91,7 +111,7 @@ class _ProgressPageState extends ConsumerState { @override Widget build(BuildContext context) { - final ProgressNotifier progressNotifier = ref.watch(progressProvider); + final progressNotifier = ref.watch(progressProvider); final currBytes = _files.fold(0, (prev, curr) => prev + ((progressNotifier.getProgress(sessionId: widget.sessionId, fileId: curr.id) * curr.size).round())); final receiveSession = ref.watch(serverProvider.select((s) => s?.session)); @@ -104,6 +124,7 @@ class _ProgressPageState extends ConsumerState { ); } + final title = receiveSession != null ? t.progressPage.titleReceiving : t.progressPage.titleSending; final startTime = receiveSession?.startTime ?? sendSession?.startTime; final endTime = receiveSession?.endTime ?? sendSession?.endTime; final int? speedInBytes; @@ -120,8 +141,11 @@ class _ProgressPageState extends ConsumerState { } return WillPopScope( - onWillPop: () => _askCancelConfirmation(status), + onWillPop: _onWillPop, child: Scaffold( + appBar: widget.showAppBar ? AppBar( + title: Text(title), + ) : null, body: Stack( children: [ ListView.builder( @@ -140,10 +164,7 @@ class _ProgressPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - receiveSession != null ? t.progressPage.titleReceiving : t.progressPage.titleSending, - style: Theme.of(context).textTheme.titleLarge, - ), + Text(title, style: Theme.of(context).textTheme.titleLarge), if (checkPlatformWithFileSystem() && receiveSession != null) Padding( padding: const EdgeInsets.only(bottom: 10), diff --git a/lib/pages/receive_page.dart b/lib/pages/receive_page.dart index d5fd5902..0d096567 100644 --- a/lib/pages/receive_page.dart +++ b/lib/pages/receive_page.dart @@ -265,7 +265,11 @@ class _ReceivePageState extends ConsumerState { _accept(ref, receiveSession); context.pushAndRemoveUntilImmediately( removeUntil: ReceivePage, - builder: () => ProgressPage(sessionId: sessionId), + builder: () => ProgressPage( + showAppBar: false, + closeSessionOnClose: true, + sessionId: sessionId, + ), ); }, icon: const Icon(Icons.check_circle), diff --git a/lib/pages/tabs/send_tab.dart b/lib/pages/tabs/send_tab.dart index d4ed900a..213766a9 100644 --- a/lib/pages/tabs/send_tab.dart +++ b/lib/pages/tabs/send_tab.dart @@ -1,13 +1,18 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/model/device.dart'; +import 'package:localsend_app/model/session_status.dart'; +import 'package:localsend_app/pages/progress_page.dart'; import 'package:localsend_app/pages/selected_files_page.dart'; +import 'package:localsend_app/pages/send_page.dart'; import 'package:localsend_app/pages/troubleshoot_page.dart'; import 'package:localsend_app/provider/network/nearby_devices_provider.dart'; import 'package:localsend_app/provider/network/scan_provider.dart'; import 'package:localsend_app/provider/network/send_provider.dart'; import 'package:localsend_app/provider/network_info_provider.dart'; +import 'package:localsend_app/provider/progress_provider.dart'; import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/file_picker.dart'; @@ -55,6 +60,7 @@ class _SendTabState extends ConsumerState { @override Widget build(BuildContext context) { + final sendMode = ref.watch(settingsProvider.select((s) => s.sendMode)); final selectedFiles = ref.watch(selectedSendingFilesProvider); final networkInfo = ref.watch(networkStateProvider); final nearbyDevicesState = ref.watch(nearbyDevicesProvider); @@ -200,6 +206,7 @@ class _SendTabState extends ConsumerState { ref.read(sendProvider.notifier).startSession( target: device, files: files, + background: false, ); } }, @@ -224,21 +231,24 @@ class _SendTabState extends ConsumerState { padding: const EdgeInsets.only(bottom: 10, left: _horizontalPadding, right: _horizontalPadding), child: Hero( tag: 'device-${device.ip}', - child: DeviceListTile( - device: device, - onTap: () { - final files = ref.read(selectedSendingFilesProvider); - if (files.isEmpty) { - context.pushBottomSheet(() => const NoFilesDialog()); - return; - } + child: sendMode == SendMode.multiple + ? _MultiSendDeviceListTile(device: device) + : DeviceListTile( + device: device, + onTap: () { + final files = ref.read(selectedSendingFilesProvider); + if (files.isEmpty) { + context.pushBottomSheet(() => const NoFilesDialog()); + return; + } - ref.read(sendProvider.notifier).startSession( - target: device, - files: files, - ); - }, - ), + ref.read(sendProvider.notifier).startSession( + target: device, + files: files, + background: false, + ); + }, + ), ), ); }), @@ -478,3 +488,88 @@ class _SendModeButton extends StatelessWidget { ); } } + +/// An advanced list tile which shows the progress of the file transfer. +class _MultiSendDeviceListTile extends ConsumerWidget { + final Device device; + + const _MultiSendDeviceListTile({ + required this.device, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(sendProvider).values.firstWhereOrNull((s) => s.target.ip == device.ip); + final double? progress; + if (session != null) { + final files = session.files.values.where((f) => f.token != null); + final progressNotifier = ref.watch(progressProvider); + final currBytes = files.fold(0, (prev, curr) => prev + ((progressNotifier.getProgress(sessionId: session.sessionId, fileId: curr.file.id) * curr.file.size).round())); + final totalBytes = files.fold(0, (prev, curr) => prev + curr.file.size); + progress = totalBytes == 0 ? 0 : currBytes / totalBytes; + } else { + progress = null; + } + return DeviceListTile( + device: device, + info: session?.status.humanString, + progress: progress, + onTap: () async { + if (session != null) { + if (session.status == SessionStatus.waiting) { + ref.read(sendProvider.notifier).setBackground(session.sessionId, false); + await context.push(() => SendPage(sessionId: session.sessionId), transition: RouterinoTransition.fade); + ref.read(sendProvider.notifier).setBackground(session.sessionId, true); + return; + } else if (session.status == SessionStatus.sending) { + ref.read(sendProvider.notifier).setBackground(session.sessionId, false); + await context.push(() => ProgressPage(showAppBar: true, closeSessionOnClose: false, sessionId: session.sessionId)); + ref.read(sendProvider.notifier).setBackground(session.sessionId, true); + return; + } + } + + final files = ref.read(selectedSendingFilesProvider); + if (files.isEmpty) { + // ignore: use_build_context_synchronously + context.pushBottomSheet(() => const NoFilesDialog()); + return; + } + + if (session != null) { + // close old session + ref.read(sendProvider.notifier).cancelSession(session.sessionId); + } + + ref.read(sendProvider.notifier).startSession( + target: device, + files: files, + background: true, + ); + }, + ); + } +} + +extension on SessionStatus { + String? get humanString { + switch (this) { + case SessionStatus.waiting: + return t.sendPage.waiting; + case SessionStatus.recipientBusy: + return t.sendPage.busy; + case SessionStatus.declined: + return t.sendPage.rejected; + case SessionStatus.sending: + return null; + case SessionStatus.finished: + return t.general.finished; + case SessionStatus.finishedWithErrors: + return t.progressPage.total.title.finishedError; + case SessionStatus.canceledBySender: + return t.progressPage.total.title.canceledSender; + case SessionStatus.canceledByReceiver: + return t.progressPage.total.title.canceledReceiver; + } + } +} diff --git a/lib/provider/network/send_provider.dart b/lib/provider/network/send_provider.dart index ab8c234f..4f0f93dd 100644 --- a/lib/provider/network/send_provider.dart +++ b/lib/provider/network/send_provider.dart @@ -10,6 +10,7 @@ import 'package:localsend_app/model/dto/info_dto.dart'; import 'package:localsend_app/model/dto/send_request_dto.dart'; import 'package:localsend_app/model/file_status.dart'; import 'package:localsend_app/model/file_type.dart'; +import 'package:localsend_app/model/send_mode.dart'; import 'package:localsend_app/model/state/send/send_session_state.dart'; import 'package:localsend_app/model/state/send/sending_file.dart'; import 'package:localsend_app/model/session_status.dart'; @@ -19,7 +20,10 @@ import 'package:localsend_app/pages/send_page.dart'; import 'package:localsend_app/provider/device_info_provider.dart'; import 'package:localsend_app/provider/dio_provider.dart'; import 'package:localsend_app/provider/progress_provider.dart'; +import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart'; +import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/api_route_builder.dart'; +import 'package:localsend_app/util/cache_helper.dart'; import 'package:routerino/routerino.dart'; import 'package:uuid/uuid.dart'; @@ -36,15 +40,22 @@ class SendNotifier extends StateNotifier> { SendNotifier(this._ref) : super({}); + /// Starts a session. + /// If [background] is true, then the session closes itself on success and no pages will be open + /// If [background] is false, then this method will open pages by itself and waits for user input to close the session. Future startSession({ required Device target, required List files, + required bool background, }) async { final requestDio = _ref.read(dioProvider(DioType.longLiving)); final uploadDio = _ref.read(dioProvider(DioType.longLiving)); final cancelToken = CancelToken(); + final sessionId = _uuid.v4(); final requestState = SendSessionState( + sessionId: sessionId, + background: background, status: SessionStatus.waiting, target: target, files: Map.fromEntries(await Future.wait(files.map((file) async { @@ -77,7 +88,6 @@ class SendNotifier extends StateNotifier> { ); final originDevice = _ref.read(deviceInfoProvider); - final sessionId = _uuid.v4(); final requestDto = SendRequestDto( info: InfoDto( alias: originDevice.alias, @@ -94,8 +104,10 @@ class SendNotifier extends StateNotifier> { state: (_) => requestState, ); - // ignore: use_build_context_synchronously - Routerino.context.push(() => SendPage(sessionId: sessionId), transition: RouterinoTransition.fade); + if (!background) { + // ignore: use_build_context_synchronously + Routerino.context.push(() => SendPage(sessionId: sessionId), transition: RouterinoTransition.fade); + } final Response response; try { @@ -135,8 +147,11 @@ class SendNotifier extends StateNotifier> { if (responseMap.isEmpty) { // receiver has nothing selected - // ignore: use_build_context_synchronously - Routerino.context.pushRootImmediately(() => const HomePage(appStart: false)); + if (state[sessionId]?.background == false) { + // ignore: use_build_context_synchronously + Routerino.context.pushRootImmediately(() => const HomePage(appStart: false)); + } + state = state.removeSession(_ref, sessionId); return; } @@ -147,11 +162,19 @@ class SendNotifier extends StateNotifier> { responseMap.containsKey(file.file.id) ? file.copyWith(token: responseMap[file.file.id]) : file.copyWith(status: FileStatus.skipped), }; - // ignore: use_build_context_synchronously - Routerino.context.pushAndRemoveUntilImmediately( - removeUntil: SendPage, - builder: () => ProgressPage(sessionId: sessionId), - ); + if (state[sessionId]?.background == false) { + final background = _ref.read(settingsProvider.select((s) => s.sendMode == SendMode.multiple)); + + // ignore: use_build_context_synchronously + Routerino.context.pushAndRemoveUntilImmediately( + removeUntil: SendPage, + builder: () => ProgressPage( + showAppBar: background, + closeSessionOnClose: !background, + sessionId: sessionId, + ), + ); + } state = state.updateSession( sessionId: sessionId, @@ -165,11 +188,6 @@ class SendNotifier extends StateNotifier> { } Future _send(String sessionId, Dio dio, Device target, Map files) async { - final sessionState = state[sessionId]; - if (sessionState == null) { - return; - } - bool hasError = false; state = state.updateSession( @@ -209,10 +227,10 @@ class SendNotifier extends StateNotifier> { data: file.path != null ? File(file.path!).openRead() : Stream.fromIterable([file.bytes!]), onSendProgress: (curr, total) { _ref.read(progressProvider.notifier).setProgress( - sessionId: sessionId, - fileId: file.file.id, - progress: curr / total, - ); + sessionId: sessionId, + fileId: file.file.id, + progress: curr / total, + ); }, cancelToken: cancelToken, ); @@ -229,18 +247,23 @@ class SendNotifier extends StateNotifier> { ); } - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: hasError ? SessionStatus.finishedWithErrors : SessionStatus.finished, - endTime: DateTime.now().millisecondsSinceEpoch, - ), - ); + if (state[sessionId]?.background == true) { + state = state.removeSession(_ref, sessionId); + } else { + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: hasError ? SessionStatus.finishedWithErrors : SessionStatus.finished, + endTime: DateTime.now().millisecondsSinceEpoch, + ), + ); + } + print('Files sent successfully.'); } /// Closes the send-session and sends a cancel event to the receiver. - Future cancelSession(String sessionId) async { + void cancelSession(String sessionId) { final sessionState = state[sessionId]; if (sessionState == null) { return; @@ -248,9 +271,20 @@ class SendNotifier extends StateNotifier> { final target = sessionState.target; sessionState.cancelToken?.cancel(); // cancel current request state = state.removeSession(_ref, sessionId); - try { - await _ref.read(dioProvider(DioType.discovery)).post(ApiRoute.cancel.target(target)); - } catch (_) {} + if (_ref.read(settingsProvider.select((s) => s.sendMode == SendMode.single))) { + // clear selected files + _ref.read(selectedSendingFilesProvider.notifier).reset(); + clearCache(); + } + + // notify the receiver + _ref.read(dioProvider(DioType.discovery)).post(ApiRoute.cancel.target(target)).then((_) {}).catchError((e) { + print(e); + }); + } + + void setBackground(String sessionId, bool background) { + state = state.updateSession(sessionId: sessionId, state: (s) => s?.copyWith(background: background)); } } @@ -279,10 +313,13 @@ extension on Map { extension on SendSessionState { SendSessionState withFileStatus(String fileId, FileStatus status, String? errorMessage) { return copyWith( - files: {...files}..update(fileId, (file) => file.copyWith( - status: status, - errorMessage: errorMessage, - )), + files: {...files}..update( + fileId, + (file) => file.copyWith( + status: status, + errorMessage: errorMessage, + ), + ), ); } } diff --git a/lib/provider/network/server_provider.dart b/lib/provider/network/server_provider.dart index 19da8226..a5db03f9 100644 --- a/lib/provider/network/server_provider.dart +++ b/lib/provider/network/server_provider.dart @@ -239,7 +239,11 @@ class ServerNotifier extends StateNotifier { if (quickSave) { // ignore: use_build_context_synchronously - Routerino.context.pushImmediately(() => ProgressPage(sessionId: sessionId)); + Routerino.context.pushImmediately(() => ProgressPage( + showAppBar: false, + closeSessionOnClose: true, + sessionId: sessionId, + )); } return _response(200, body: { @@ -315,10 +319,10 @@ class ServerNotifier extends StateNotifier { onProgress: (savedBytes) { if (receivingFile.file.size != 0) { _ref.read(progressProvider.notifier).setProgress( - sessionId: receiveState.sessionId, - fileId: fileId, - progress: savedBytes / receivingFile.file.size, - ); + sessionId: receiveState.sessionId, + fileId: fileId, + progress: savedBytes / receivingFile.file.size, + ); } }, ); @@ -363,10 +367,10 @@ class ServerNotifier extends StateNotifier { } _ref.read(progressProvider.notifier).setProgress( - sessionId: receiveState.sessionId, - fileId: fileId, - progress: 1, - ); + sessionId: receiveState.sessionId, + fileId: fileId, + progress: 1, + ); if (state!.session!.files.values .every((f) => f.status == FileStatus.finished || f.status == FileStatus.skipped || f.status == FileStatus.failed)) { @@ -388,9 +392,7 @@ class ServerNotifier extends StateNotifier { print('Received all files.'); } - return state?.session?.files[fileId]?.status == FileStatus.finished - ? _response(200) - : _response(500, message: 'Could not save file'); + return state?.session?.files[fileId]?.status == FileStatus.finished ? _response(200) : _response(500, message: 'Could not save file'); }); router.post(ApiRoute.cancel.path, (Request request) { diff --git a/lib/widget/list_tile/device_list_tile.dart b/lib/widget/list_tile/device_list_tile.dart index fb1b559a..197af39b 100644 --- a/lib/widget/list_tile/device_list_tile.dart +++ b/lib/widget/list_tile/device_list_tile.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:localsend_app/model/device.dart'; import 'package:localsend_app/util/ip_helper.dart'; +import 'package:localsend_app/widget/custom_progress_bar.dart'; import 'package:localsend_app/widget/device_bage.dart'; import 'package:localsend_app/widget/list_tile/custom_list_tile.dart'; class DeviceListTile extends StatelessWidget { final Device device; + final String? info; + final double? progress; final VoidCallback? onTap; - const DeviceListTile({required this.device, this.onTap}); + const DeviceListTile({required this.device, this.info, this.progress, this.onTap}); @override Widget build(BuildContext context) { @@ -19,15 +22,28 @@ class DeviceListTile extends StatelessWidget { runSpacing: 10, spacing: 10, children: [ - DeviceBadge( - color: Theme.of(context).colorScheme.tertiaryContainer, - label: '#${device.ip.visualId}', - ), - if (device.deviceModel != null) - DeviceBadge( - color: Theme.of(context).colorScheme.tertiaryContainer, - label: device.deviceModel!, - ), + if (info != null) + Text(info!, style: const TextStyle(color: Colors.grey)) + else if (progress != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: CustomProgressBar( + progress: progress!, + color: Theme.of(context).colorScheme.tertiaryContainer, + ), + ) + else + ...[ + DeviceBadge( + color: Theme.of(context).colorScheme.tertiaryContainer, + label: '#${device.ip.visualId}', + ), + if (device.deviceModel != null) + DeviceBadge( + color: Theme.of(context).colorScheme.tertiaryContainer, + label: device.deviceModel!, + ), + ], ], ), onTap: onTap,