feat: implement multi send prototype

This commit is contained in:
Tien Do Nam
2023-03-03 02:49:55 +01:00
parent d18977109c
commit d217bebdd7
7 changed files with 255 additions and 78 deletions

View File

@@ -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<String, SendingFile> files, // file id as key

View File

@@ -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<ProgressPage> createState() => _ProgressPageState();
@@ -74,6 +80,20 @@ class _ProgressPageState extends ConsumerState<ProgressPage> {
} catch (_) {}
}
Future<bool> _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<bool> _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<ProgressPage> {
@override
Widget build(BuildContext context) {
final ProgressNotifier progressNotifier = ref.watch(progressProvider);
final progressNotifier = ref.watch(progressProvider);
final currBytes = _files.fold<int>(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<ProgressPage> {
);
}
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<ProgressPage> {
}
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<ProgressPage> {
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),

View File

@@ -265,7 +265,11 @@ class _ReceivePageState extends ConsumerState<ReceivePage> {
_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),

View File

@@ -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<SendTab> {
@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<SendTab> {
ref.read(sendProvider.notifier).startSession(
target: device,
files: files,
background: false,
);
}
},
@@ -224,21 +231,24 @@ class _SendTabState extends ConsumerState<SendTab> {
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<int>(0, (prev, curr) => prev + ((progressNotifier.getProgress(sessionId: session.sessionId, fileId: curr.file.id) * curr.file.size).round()));
final totalBytes = files.fold<int>(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;
}
}
}

View File

@@ -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<Map<String, SendSessionState>> {
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<void> startSession({
required Device target,
required List<CrossFile> 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<Map<String, SendSessionState>> {
);
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<Map<String, SendSessionState>> {
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<Map<String, SendSessionState>> {
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<Map<String, SendSessionState>> {
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<Map<String, SendSessionState>> {
}
Future<void> _send(String sessionId, Dio dio, Device target, Map<String, SendingFile> files) async {
final sessionState = state[sessionId];
if (sessionState == null) {
return;
}
bool hasError = false;
state = state.updateSession(
@@ -209,10 +227,10 @@ class SendNotifier extends StateNotifier<Map<String, SendSessionState>> {
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<Map<String, SendSessionState>> {
);
}
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<void> cancelSession(String sessionId) async {
void cancelSession(String sessionId) {
final sessionState = state[sessionId];
if (sessionState == null) {
return;
@@ -248,9 +271,20 @@ class SendNotifier extends StateNotifier<Map<String, SendSessionState>> {
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<String, SendSessionState> {
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,
),
),
);
}
}

View File

@@ -239,7 +239,11 @@ class ServerNotifier extends StateNotifier<ServerState?> {
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<ServerState?> {
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<ServerState?> {
}
_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<ServerState?> {
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) {

View File

@@ -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,