feat: handle rejected requests

This commit is contained in:
Tien Do Nam
2022-12-23 03:08:23 +01:00
parent d8ad0b04bc
commit 4afafd2c93
19 changed files with 391 additions and 88 deletions
+8 -1
View File
@@ -3,7 +3,9 @@
"general": {
"accept": "Accept",
"advanced": "Advanced",
"cancel": "Cancel",
"decline": "Decline",
"finish": "Finish",
"hide": "Hide",
"offline": "Offline",
"online": "Online",
@@ -27,7 +29,8 @@
"files": "Files",
"filesWithCount": "Files ({count})",
"selectFiles": "Select files",
"nearbyDevices": "Nearby devices"
"nearbyDevices": "Nearby devices",
"thisDevice": "This Device"
},
"settings": {
"title": "Settings",
@@ -57,5 +60,9 @@
"one": "wants to send you a file.",
"other": "wants to send you {n} files."
}
},
"sendPage": {
"waiting": "Waiting for response...",
"rejected": "The recipient has rejected the request."
}
}
+8 -1
View File
@@ -3,7 +3,9 @@
"general": {
"accept": "Akzeptieren",
"advanced": "Erweitert",
"cancel": "Abbrechen",
"decline": "Ablehnen",
"finish": "Abschließen",
"hide": "Verstecken",
"offline": "Offline",
"online": "Online",
@@ -27,7 +29,8 @@
"files": "Dateien",
"filesWithCount": "Dateien ({count})",
"selectFiles": "Dateien auswählen",
"nearbyDevices": "Geräte in der Nähe"
"nearbyDevices": "Geräte in der Nähe",
"thisDevice": "Dieses Gerät"
},
"settings": {
"title": "Einstellungen",
@@ -57,5 +60,9 @@
"one": "möchte dir eine Datei senden.",
"other": "möchte dir {n} Dateien senden."
}
},
"sendPage": {
"waiting": "Warte auf Antwort...",
"rejected": "Der Empfänger hat die Anfrage abgelehnt."
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ Future<void> main() async {
runApp(TranslationProvider(
child: ProviderScope(
overrides: [
deviceInfoProvider.overrideWithValue(deviceInfo),
deviceRawInfoProvider.overrideWithValue(deviceInfo),
settingsProvider.overrideWith((ref) => SettingsNotifier(persistenceService)),
],
child: const LocalSendApp(),
+3 -13
View File
@@ -1,26 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/file_type.dart';
part 'file_dto.freezed.dart';
part 'file_dto.g.dart';
/// Categorization of one file.
/// We use this information for a better UX.
enum FileType {
image,
video,
pdf,
text,
other,
}
@freezed
class FileDto with _$FileDto {
const factory FileDto({
required int id, // unique inside from send batch
required String id, // unique inside send session
required String fileName,
required int size,
required DeviceType deviceType,
required FileType fileType,
}) = _FileDto;
factory FileDto.fromJson(Map<String, Object?> json) => _$FileDtoFromJson(json);
+1 -1
View File
@@ -9,7 +9,7 @@ part 'send_request_dto.g.dart';
class SendRequestDto with _$SendRequestDto {
const factory SendRequestDto({
required InfoDto info,
required List<FileDto> files,
required Map<String, FileDto> files,
}) = _SendRequestDto;
factory SendRequestDto.fromJson(Map<String, Object?> json) => _$SendRequestDtoFromJson(json);
+32
View File
@@ -0,0 +1,32 @@
/// Categorization of one file.
/// We use this information for a better UX.
enum FileType {
image,
video,
pdf,
text,
other,
}
extension FileTypeGuessExt on String {
FileType guessFileType() {
final extension = substring(lastIndexOf('.') + 1).toLowerCase();
switch (extension) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
return FileType.image;
case 'mp4':
case 'mov':
return FileType.video;
case 'pdf':
return FileType.pdf;
case 'txt':
return FileType.text;
default:
return FileType.other;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/send_files/sending_file.dart';
part 'send_state.freezed.dart';
enum SendStatus {
waiting,
declined,
sending,
finished,
}
@freezed
class SendState with _$SendState {
const factory SendState({
required SendStatus status,
required Device target,
required Map<String, SendingFile> files, // file id as key
required CancelToken? cancelToken,
}) = _SendState;
}
+15
View File
@@ -0,0 +1,15 @@
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/dto/file_dto.dart';
part 'sending_file.freezed.dart';
@freezed
class SendingFile with _$SendingFile {
const factory SendingFile({
required FileDto file,
required Future<Uint8List> Function() read,
required String? token,
}) = _SendingFile;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/server/expected_file.dart';
import 'package:localsend_app/model/server/receiving_file.dart';
part 'receive_state.freezed.dart';
@@ -8,6 +8,6 @@ part 'receive_state.freezed.dart';
class ReceiveState with _$ReceiveState {
const factory ReceiveState({
required Device sender,
required Map<int, ExpectedFile> files,
required Map<String, ReceivingFile> files,
}) = _ReceiveState;
}
@@ -1,13 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:localsend_app/model/dto/file_dto.dart';
part 'expected_file.freezed.dart';
part 'receiving_file.freezed.dart';
@freezed
class ExpectedFile with _$ExpectedFile {
const factory ExpectedFile({
required String token,
class ReceivingFile with _$ReceivingFile {
const factory ReceivingFile({
required FileDto file,
required String token,
required String? tempPath, // file is saved to a temporary path first
}) = _ExpectedFile;
}) = _ReceivingFile;
}
+1 -1
View File
@@ -10,7 +10,7 @@ part 'temp_request.freezed.dart';
class TempRequest with _$TempRequest {
const factory TempRequest({
required Device sender,
required List<FileDto> files,
required Map<String, FileDto> files,
required StreamController<bool> responseHandler, // use this to accept / decline the request
}) = _TempRequest;
}
+1 -1
View File
@@ -84,7 +84,7 @@ class ReceivePage extends ConsumerWidget {
foregroundColor: Theme.of(context).buttonTheme.colorScheme!.onPrimary,
),
onPressed: () {
ref.read(serverProvider.notifier).acceptFileRequest();
ref.read(serverProvider.notifier).acceptFileRequest(tempRequest.files.values.map((f) => f.id).toSet());
context.pop();
},
icon: const Icon(Icons.check_circle),
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/send_files/send_state.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/widget/device_list_tile.dart';
class SendPage extends ConsumerStatefulWidget {
const SendPage({Key? key}) : super(key: key);
@override
ConsumerState<SendPage> createState() => _SendPageState();
}
class _SendPageState extends ConsumerState<SendPage> {
Device? _myDevice;
Device? _targetDevice;
@override
Widget build(BuildContext context) {
final sendState = ref.watch(sendProvider);
if (sendState == null && _myDevice == null && _targetDevice == null) {
return Scaffold(
body: Container(),
);
}
final myDevice = ref.watch(deviceInfoProvider);
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 30),
child: Column(
children: [
Expanded(
child: Column(
children: [
Hero(
tag: 'this-device',
child: DeviceListTile(
device: myDevice,
thisDevice: true,
),
),
const SizedBox(height: 20),
const Icon(Icons.arrow_downward),
const SizedBox(height: 20),
Hero(
tag: 'device-${(sendState?.target ?? _targetDevice)?.ip}',
child: DeviceListTile(
device: sendState?.target ?? _targetDevice!,
),
),
],
),
),
if (sendState != null)
...[
if (sendState.status == SendStatus.waiting)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(t.sendPage.waiting, textAlign: TextAlign.center),
)
else if (sendState.status == SendStatus.declined)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(t.sendPage.rejected, style: const TextStyle(color: Colors.orange), textAlign: TextAlign.center),
),
Center(
child: ElevatedButton.icon(
onPressed: () {
setState(() {
_myDevice = myDevice;
_targetDevice = sendState.target;
});
ref.read(sendProvider.notifier).cancel();
context.pop();
},
icon: Icon(sendState.status == SendStatus.declined ? Icons.check_circle : Icons.close),
label: Text(sendState.status == SendStatus.declined ? t.general.finish : t.general.cancel),
),
),
],
],
),
),
),
);
}
}
+21 -31
View File
@@ -1,15 +1,12 @@
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.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/dto/info_dto.dart';
import 'package:localsend_app/model/dto/send_request_dto.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/nearby_devices_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/selected_files_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/widget/device_list_tile.dart';
@@ -28,9 +25,8 @@ class _SendTabState extends ConsumerState<SendTab> {
@override
Widget build(BuildContext context) {
final deviceInfo = ref.watch(deviceInfoProvider);
final settings = ref.watch(settingsProvider);
final selectedFiles = ref.watch(selectedFilesProvider);
final myDevice = ref.watch(deviceInfoProvider);
final devices = ref.watch(nearbyDevicesProvider);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
@@ -90,36 +86,30 @@ class _SendTabState extends ConsumerState<SendTab> {
const SizedBox(height: 20),
Text(t.send.nearbyDevices, style: Theme.of(context).textTheme.subtitle1),
const SizedBox(height: 10),
Hero(
tag: 'this-device',
child: DeviceListTile(
device: myDevice,
thisDevice: true,
),
),
const SizedBox(height: 10),
...devices.when(
data: (data) {
return data.map((device) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DeviceListTile(
device: device,
onTap: () async {
final url = 'http://${device.ip}:${device.port}/localsend/v1/send-request';
final dio = Dio(BaseOptions(
connectTimeout: 30 * 1000,
sendTimeout: 30 * 1000,
));
try {
final response = await dio.post(url,
data: SendRequestDto(
info: InfoDto(
alias: settings.alias,
deviceModel: deviceInfo.deviceModel,
deviceType: deviceInfo.deviceType,
),
files: [],
).toJson());
print('Response: ${response.statusCode}, ${response.data.runtimeType}');
} on DioError catch (e) {
if (e.type != DioErrorType.response) {
print(e);
}
}
},
child: Hero(
tag: 'device-${device.ip}',
child: DeviceListTile(
device: device,
onTap: () {
ref.read(sendProvider.notifier).sendRequest(
target: device,
files: ref.read(selectedFilesProvider),
);
},
),
),
);
});
+17 -1
View File
@@ -1,6 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/provider/network/server_provider.dart';
import 'package:localsend_app/provider/network_info_provider.dart';
import 'package:localsend_app/util/device_info_helper.dart';
final deviceInfoProvider = Provider<DeviceInfoResult>((ref) {
final deviceRawInfoProvider = Provider<DeviceInfoResult>((ref) {
throw Exception('settingsProvider not initialized');
});
final deviceInfoProvider = Provider((ref) {
final networkInfo = ref.watch(networkInfoProvider);
final serverState = ref.watch(serverProvider);
final rawInfo = ref.watch(deviceRawInfoProvider);
return Device(
ip: networkInfo?.localIp ?? '-',
port: serverState?.port ?? -1,
alias: serverState?.alias ?? '-',
deviceModel: rawInfo.deviceModel,
deviceType: rawInfo.deviceType,
);
});
+109
View File
@@ -0,0 +1,109 @@
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/main.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/model/dto/file_dto.dart';
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_type.dart';
import 'package:localsend_app/model/send_files/send_state.dart';
import 'package:localsend_app/model/send_files/sending_file.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/routes.dart';
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
final sendProvider = StateNotifierProvider<SendNotifier, SendState?>((ref) {
return SendNotifier(ref);
});
class SendNotifier extends StateNotifier<SendState?> {
final Ref _ref;
SendNotifier(this._ref) : super(null);
Future<void> sendRequest({
required Device target,
required List<PlatformFile> files,
}) async {
final url = 'http://${target.ip}:${target.port}/localsend/v1/send-request';
final dio = Dio(BaseOptions(
connectTimeout: 30 * 1000,
sendTimeout: 30 * 1000,
));
final cancelToken = CancelToken();
final requestState = SendState(
status: SendStatus.waiting,
target: target,
files: Map.fromEntries(files.map((file) {
final id = _uuid.v4();
return MapEntry(
id,
SendingFile(
file: FileDto(
id: id,
fileName: file.name,
size: file.size,
fileType: file.name.guessFileType(),
),
read: () async => file.bytes!,
token: null,
),
);
})),
cancelToken: cancelToken,
);
final originDevice = _ref.read(deviceInfoProvider);
final requestDto = SendRequestDto(
info: InfoDto(
alias: originDevice.alias,
deviceModel: originDevice.deviceModel,
deviceType: originDevice.deviceType,
),
files: {
for (final file in requestState.files.values)
file.file.id: file.file,
},
);
try {
state = requestState;
// ignore: use_build_context_synchronously
const SendRoute().push(LocalSendApp.routerContext);
final response = await dio.post(
url,
data: requestDto.toJson(),
cancelToken: cancelToken,
);
final responseMap = response.data as Map;
state = requestState.copyWith(
status: SendStatus.sending,
files: {
for (final file in requestState.files.values)
file.file.id: responseMap.containsKey(file.file.id) ? file.copyWith(token: responseMap[file.file.id]) : file,
}
);
print('Response: ${response.statusCode}, ${response.data}, ${response.data.runtimeType}');
} on DioError catch (e) {
if (e.type != DioErrorType.response && e.type != DioErrorType.cancel) {
print(e);
}
state = state?.copyWith(
status: SendStatus.declined,
);
}
}
void cancel() {
state?.cancelToken?.cancel();
state = null;
}
}
+17 -11
View File
@@ -6,8 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/main.dart';
import 'package:localsend_app/model/dto/info_dto.dart';
import 'package:localsend_app/model/dto/send_request_dto.dart';
import 'package:localsend_app/model/server/expected_file.dart';
import 'package:localsend_app/model/server/receive_state.dart';
import 'package:localsend_app/model/server/receiving_file.dart';
import 'package:localsend_app/model/server/server_state.dart';
import 'package:localsend_app/model/server/temp_request.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
@@ -22,7 +22,7 @@ import 'package:uuid/uuid.dart';
/// This provider manages receiving file requests.
final serverProvider = StateNotifierProvider<ServerNotifier, ServerState?>((ref) {
final deviceInfo = ref.watch(deviceInfoProvider);
final deviceInfo = ref.watch(deviceRawInfoProvider);
return ServerNotifier(deviceInfo);
});
@@ -84,7 +84,10 @@ class ServerNotifier extends StateNotifier<ServerState?> {
// Delayed response (waiting for user's decision)
final result = await streamController.stream.first;
if (result) {
return Response.ok('');
return Response.ok(jsonEncode({
for (final file in state!.receiveState!.files.values)
file.file.id: file.token,
}), headers: {'Content-Type': 'application/json'});
} else {
return Response.badRequest();
}
@@ -120,28 +123,29 @@ class ServerNotifier extends StateNotifier<ServerState?> {
return await startServer(alias: alias, port: port);
}
void acceptFileRequest() {
void acceptFileRequest(Set<String> fileIds) {
final tempRequest = state?.tempRequest;
if (tempRequest == null) {
return;
}
tempRequest.responseHandler.add(true);
tempRequest.responseHandler.close();
state = state?.copyWith(
tempRequest: null,
receiveState: ReceiveState(
sender: tempRequest.sender,
files: {
for (final file in tempRequest.files)
file.id: ExpectedFile(
for (final file in tempRequest.files.values)
file.id: ReceivingFile(
token: _uuid.v4(),
file: file,
tempPath: null,
tempPath: fileIds.contains(file.id) ? _uuid.v4() : null,
),
},
),
);
tempRequest.responseHandler.add(true);
tempRequest.responseHandler.close();
}
void declineFileRequest() {
@@ -149,11 +153,13 @@ class ServerNotifier extends StateNotifier<ServerState?> {
if (controller == null) {
return;
}
controller.add(false);
controller.close();
state = state?.copyWith(
tempRequest: null,
receiveState: null,
);
controller.add(false);
controller.close();
}
}
+9
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/pages/send_page.dart';
part 'routes.g.dart';
@@ -20,3 +21,11 @@ class ReceiveRoute extends GoRouteData {
@override
Widget build(BuildContext context, GoRouterState state) => const ReceivePage();
}
@TypedGoRoute<SendRoute>(path: '/send')
class SendRoute extends GoRouteData {
const SendRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const SendPage();
}
+25 -19
View File
@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/util/ip_helper.dart';
import 'package:localsend_app/widget/device_bage.dart';
class DeviceListTile extends StatelessWidget {
final Device device;
final bool thisDevice;
final VoidCallback? onTap;
const DeviceListTile({required this.device, this.onTap});
const DeviceListTile({required this.device, this.thisDevice = false, this.onTap});
@override
Widget build(BuildContext context) {
@@ -24,27 +26,31 @@ class DeviceListTile extends StatelessWidget {
children: [
Icon(device.deviceType.icon, size: 46),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(device.alias, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 5),
Wrap(
runSpacing: 10,
spacing: 10,
children: [
DeviceBadge(
color: Theme.of(context).colorScheme.tertiaryContainer,
label: '#${device.ip.visualId}',
),
if (device.deviceModel != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
child: Text('${device.alias}${thisDevice ? ' (${t.send.thisDevice})' : ''}', style: const TextStyle(fontSize: 20)),
),
const SizedBox(height: 5),
Wrap(
runSpacing: 10,
spacing: 10,
children: [
DeviceBadge(
color: Theme.of(context).colorScheme.tertiaryContainer,
label: device.deviceModel!,
label: '#${device.ip.visualId}',
),
],
),
],
if (device.deviceModel != null)
DeviceBadge(
color: Theme.of(context).colorScheme.tertiaryContainer,
label: device.deviceModel!,
),
],
),
],
),
),
],
),