mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat: show exact error when typing IP address manually
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
## 1.16.1 (unreleased)
|
||||
|
||||
- feat: show exact error message when using IP address dialog or favorite dialog (@Tienisto)
|
||||
- feat(desktop): highlight file when tapping "Show in folder" (@Tienisto)
|
||||
- fix(android): properly close app on back gesture (@Tienisto)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:common/isolate.dart';
|
||||
import 'package:common/model/device.dart';
|
||||
import 'package:common/util/task_runner.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:localsend_app/config/theme.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/provider/last_devices.provider.dart';
|
||||
import 'package:localsend_app/provider/local_ip_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:routerino/routerino.dart';
|
||||
|
||||
@@ -38,7 +40,7 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
|
||||
_InputMode _mode = _InputMode.hashtag;
|
||||
String _input = '';
|
||||
bool _fetching = false;
|
||||
bool _failed = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _submit(List<String> localIps, int port, [String? candidate]) async {
|
||||
final List<String> candidates;
|
||||
@@ -56,35 +58,51 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
|
||||
});
|
||||
|
||||
final https = ref.read(settingsProvider).https;
|
||||
final results = TaskRunner<Device?>(
|
||||
concurrency: 10,
|
||||
initialTasks: [
|
||||
for (final ip in candidates)
|
||||
() => ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: ip,
|
||||
port: port,
|
||||
https: https,
|
||||
)),
|
||||
],
|
||||
).stream;
|
||||
|
||||
bool found = false;
|
||||
final deviceCompleter = Completer<void>();
|
||||
Device? foundDevice;
|
||||
String? error;
|
||||
|
||||
await for (final device in results) {
|
||||
if (device != null) {
|
||||
found = true;
|
||||
if (mounted) {
|
||||
ref.redux(lastDevicesProvider).dispatch(AddLastDeviceAction(device));
|
||||
context.pop(device);
|
||||
}
|
||||
break;
|
||||
}
|
||||
final List<Future<Device>> futures = [
|
||||
for (final ip in candidates)
|
||||
() async {
|
||||
try {
|
||||
final device = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: ip,
|
||||
port: port,
|
||||
https: https,
|
||||
));
|
||||
foundDevice = device;
|
||||
deviceCompleter.complete();
|
||||
return device;
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
rethrow;
|
||||
}
|
||||
}(),
|
||||
];
|
||||
|
||||
// Wait until,
|
||||
// - a device is found
|
||||
// - all candidates are checked
|
||||
try {
|
||||
await Future.any([
|
||||
deviceCompleter.future,
|
||||
Future.wait(futures),
|
||||
]);
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!found && mounted) {
|
||||
if (foundDevice != null) {
|
||||
ref.redux(lastDevicesProvider).dispatch(AddLastDeviceAction(foundDevice!));
|
||||
context.pop(foundDevice);
|
||||
} else {
|
||||
setState(() {
|
||||
_fetching = false;
|
||||
_failed = true;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -182,10 +200,29 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_failed)
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(width: 5),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => ErrorDialog(error: _error!),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/model/persistence/favorite_device.dart';
|
||||
import 'package:localsend_app/provider/favorites_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/favorite_edit_dialog.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:routerino/routerino.dart';
|
||||
@@ -19,7 +20,7 @@ class FavoritesDialog extends StatefulWidget {
|
||||
|
||||
class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
|
||||
bool _fetching = false;
|
||||
bool _failed = false;
|
||||
String? _error;
|
||||
|
||||
/// Checks if the device is reachable and pops the dialog with the result if it is.
|
||||
Future<void> _checkConnectionToDevice(FavoriteDevice favorite) async {
|
||||
@@ -29,24 +30,22 @@ class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
|
||||
|
||||
final https = ref.read(settingsProvider).https;
|
||||
|
||||
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: favorite.ip,
|
||||
port: favorite.port,
|
||||
https: https,
|
||||
));
|
||||
if (result == null) {
|
||||
try {
|
||||
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: favorite.ip,
|
||||
port: favorite.port,
|
||||
https: https,
|
||||
));
|
||||
|
||||
if (mounted) {
|
||||
context.pop(result);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_fetching = false;
|
||||
_failed = true;
|
||||
_error = e.toString();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pop(result);
|
||||
}
|
||||
|
||||
Future<void> _showDeviceDialog([FavoriteDevice? favorite]) async {
|
||||
@@ -88,10 +87,29 @@ class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_failed)
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(width: 5),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => ErrorDialog(error: _error!),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/model/persistence/favorite_device.dart';
|
||||
import 'package:localsend_app/provider/favorites_provider.dart';
|
||||
import 'package:localsend_app/provider/settings_provider.dart';
|
||||
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/favorite_delete_dialog.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:routerino/routerino.dart';
|
||||
@@ -29,7 +30,7 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
|
||||
final _portController = TextEditingController();
|
||||
final _aliasController = TextEditingController();
|
||||
bool _fetching = false;
|
||||
bool _failed = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -110,10 +111,29 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
|
||||
label: Text(t.general.delete),
|
||||
),
|
||||
],
|
||||
if (_failed)
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(width: 5),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => ErrorDialog(error: _error!),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -157,31 +177,32 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
|
||||
setState(() {
|
||||
_fetching = true;
|
||||
});
|
||||
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: ip,
|
||||
port: port,
|
||||
https: https,
|
||||
));
|
||||
if (result == null) {
|
||||
|
||||
try {
|
||||
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
|
||||
ip: ip,
|
||||
port: port,
|
||||
https: https,
|
||||
));
|
||||
|
||||
final name = _aliasController.text.trim();
|
||||
|
||||
await ref.redux(favoritesProvider).dispatchAsync(AddFavoriteAction(FavoriteDevice.fromValues(
|
||||
fingerprint: result.fingerprint,
|
||||
ip: _ipController.text,
|
||||
port: int.parse(_portController.text),
|
||||
alias: name.isEmpty ? result.alias : name,
|
||||
)));
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_fetching = false;
|
||||
_failed = true;
|
||||
_error = e.toString();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final name = _aliasController.text.trim();
|
||||
|
||||
await ref.redux(favoritesProvider).dispatchAsync(AddFavoriteAction(FavoriteDevice.fromValues(
|
||||
fingerprint: result.fingerprint,
|
||||
ip: _ipController.text,
|
||||
port: int.parse(_portController.text),
|
||||
alias: name.isEmpty ? result.alias : name,
|
||||
)));
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
child: Text(t.general.confirm),
|
||||
|
||||
@@ -21,7 +21,7 @@ class HttpTargetTask {
|
||||
@internal
|
||||
Future<void> setupHttpTargetDiscoveryIsolate(
|
||||
Stream<SendToIsolateData<IsolateTask<HttpTargetTask>>> receiveFromMain,
|
||||
void Function(IsolateTaskResult<Device?>) sendToMain,
|
||||
void Function(IsolateTaskResult<Device>) sendToMain,
|
||||
InitialData initialData,
|
||||
) async {
|
||||
await setupChildIsolateHelper(
|
||||
@@ -30,12 +30,22 @@ Future<void> setupHttpTargetDiscoveryIsolate(
|
||||
sendToMain: sendToMain,
|
||||
initialData: initialData,
|
||||
handler: (ref, task) async {
|
||||
Object? error;
|
||||
final device = await ref.read(httpTargetDiscoveryProvider).discover(
|
||||
ip: task.data.ip,
|
||||
port: task.data.port,
|
||||
https: task.data.https,
|
||||
onError: (url, e) => error = e,
|
||||
);
|
||||
sendToMain(IsolateTaskResult(
|
||||
|
||||
if (error != null || device == null) {
|
||||
return sendToMain(IsolateTaskErrorResult(
|
||||
id: task.id,
|
||||
error: error?.toString() ?? 'Unknown error',
|
||||
));
|
||||
}
|
||||
|
||||
sendToMain(IsolateTaskSuccessResult(
|
||||
id: task.id,
|
||||
data: device,
|
||||
));
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import 'package:common/src/isolate/dto/isolate_task.dart';
|
||||
|
||||
/// The response data structure from an [IsolateTask].
|
||||
class IsolateTaskResult<T> {
|
||||
sealed class IsolateTaskResult<T> {
|
||||
/// The id of the task to be matched with [IsolateTask.id].
|
||||
final int id;
|
||||
|
||||
IsolateTaskResult._({
|
||||
required this.id,
|
||||
});
|
||||
}
|
||||
|
||||
class IsolateTaskSuccessResult<T> extends IsolateTaskResult<T> {
|
||||
/// The payload of the response.
|
||||
final T data;
|
||||
|
||||
IsolateTaskResult({
|
||||
required this.id,
|
||||
IsolateTaskSuccessResult({
|
||||
required int id,
|
||||
required this.data,
|
||||
});
|
||||
}) : super._(id: id);
|
||||
}
|
||||
|
||||
class IsolateTaskErrorResult<T> extends IsolateTaskResult<T> {
|
||||
/// The error.
|
||||
final String error;
|
||||
|
||||
IsolateTaskErrorResult({
|
||||
required int id,
|
||||
required this.error,
|
||||
}) : super._(id: id);
|
||||
}
|
||||
|
||||
/// Stream version of [IsolateTaskResult].
|
||||
|
||||
@@ -15,7 +15,7 @@ import 'package:refena/refena.dart';
|
||||
|
||||
final _idProvider = IdProvider();
|
||||
|
||||
class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, Device?> {
|
||||
class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, Device> {
|
||||
final String ip;
|
||||
final int port;
|
||||
final bool https;
|
||||
@@ -27,7 +27,7 @@ class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<Isolat
|
||||
});
|
||||
|
||||
@override
|
||||
Future<(ParentIsolateState, Device?)> reduce() async {
|
||||
Future<(ParentIsolateState, Device)> reduce() async {
|
||||
final connection = state.httpTargetDiscovery;
|
||||
if (connection == null) {
|
||||
throw StateError('httpTargetDiscovery is not initialized');
|
||||
@@ -52,11 +52,16 @@ class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<Isolat
|
||||
|
||||
await for (final result in connection.receiveFromIsolate) {
|
||||
if (result.id == task.id) {
|
||||
return (state, result.data);
|
||||
switch (result) {
|
||||
case IsolateTaskSuccessResult<Device>():
|
||||
return (state, result.data);
|
||||
case IsolateTaskErrorResult<Device>():
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (state, null);
|
||||
throw StateError('Unexpected end of stream');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const _uploadIsolateCount = 2;
|
||||
class ParentIsolateState with ParentIsolateStateMappable {
|
||||
final SyncState syncState;
|
||||
final IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery;
|
||||
final IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery;
|
||||
final IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery;
|
||||
final IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery;
|
||||
final List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>> httpUpload;
|
||||
int get uploadIsolateCount => httpUpload.length;
|
||||
@@ -86,7 +86,7 @@ class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsola
|
||||
),
|
||||
);
|
||||
|
||||
final httpTargetDiscovery = await startIsolate<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>, InitialData>(
|
||||
final httpTargetDiscovery = await startIsolate<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>, InitialData>(
|
||||
task: setupHttpTargetDiscoveryIsolate,
|
||||
param: InitialData(
|
||||
syncState: state.syncState,
|
||||
|
||||
@@ -28,9 +28,9 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
||||
v.httpScanDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>>
|
||||
_f$httpScanDiscovery = Field('httpScanDiscovery', _$httpScanDiscovery);
|
||||
static IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? _$httpTargetDiscovery(ParentIsolateState v) =>
|
||||
static IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? _$httpTargetDiscovery(ParentIsolateState v) =>
|
||||
v.httpTargetDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>>
|
||||
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>>
|
||||
_f$httpTargetDiscovery = Field('httpTargetDiscovery', _$httpTargetDiscovery);
|
||||
static IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>> _f$multicastDiscovery =
|
||||
@@ -113,7 +113,7 @@ abstract class ParentIsolateStateCopyWith<$R, $In extends ParentIsolateState, $O
|
||||
$R call(
|
||||
{SyncState? syncState,
|
||||
IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery,
|
||||
IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery,
|
||||
IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery,
|
||||
IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery,
|
||||
List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>>? httpUpload});
|
||||
ParentIsolateStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
|
||||
|
||||
@@ -38,7 +38,7 @@ class HttpTargetDiscoveryService {
|
||||
final dto = InfoDto.fromJson(response.data);
|
||||
return dto.toDevice(ip, port, https);
|
||||
} on DioException catch (e) {
|
||||
onError?.call(url, e.error);
|
||||
onError?.call(url, e);
|
||||
return null;
|
||||
} catch (e) {
|
||||
onError?.call(url, e);
|
||||
|
||||
Reference in New Issue
Block a user