feat: add device detail page

This commit is contained in:
Tien Do Nam
2026-08-04 16:05:21 +02:00
parent 966543da6a
commit 56af4ef616
19 changed files with 877 additions and 69 deletions
@@ -41,6 +41,29 @@ class SignalingChannel extends DeviceChannel with SignalingChannelMappable {
const SignalingChannel({required this.signalingServer});
}
/// Whether a [DeviceLog] discovered the device or re-confirmed it.
enum DeviceLogKind {
discovered,
updated,
}
/// One retained confirmation of a stored device, for the device details UI.
class DeviceLog {
/// When the confirmation happened.
final DateTime timestamp;
final DeviceLogKind kind;
/// The channel the confirmation happened on.
final HttpChannel channel;
const DeviceLog({
required this.timestamp,
required this.kind,
required this.channel,
});
}
enum TransmissionMethod {
http('HTTP'),
webrtc('WebRTC')
@@ -6,6 +6,29 @@ import 'package:typed_isolates/typed_isolates.dart';
sealed class DiscoveryTask {}
/// A result sent from the discovery isolate to the main isolate,
/// routed to the task that requested it.
sealed class DiscoveryResult {}
/// A confirmed device, emitted on the [DiscoveryListenTask] stream.
class DiscoveryDeviceResult implements DiscoveryResult {
final Device device;
DiscoveryDeviceResult({
required this.device,
});
}
/// The retained confirmations of one stored device, answering a
/// [DiscoveryDeviceLogsTask].
class DiscoveryDeviceLogsResult implements DiscoveryResult {
final List<DeviceLog> logs;
DiscoveryDeviceLogsResult({
required this.logs,
});
}
/// Starts the discovery and streams every confirmed device.
/// The stream never completes; it survives [DiscoveryRestartTask]s.
class DiscoveryListenTask implements DiscoveryTask {}
@@ -56,9 +79,20 @@ class DiscoveryAddDeviceTask implements DiscoveryTask {
});
}
/// Fetches the retained confirmations of a stored device, oldest first.
/// Answered with one [DiscoveryDeviceLogsResult]; the logs are empty when
/// the fingerprint is unknown.
class DiscoveryDeviceLogsTask implements DiscoveryTask {
final String fingerprint;
DiscoveryDeviceLogsTask({
required this.fingerprint,
});
}
Future<void> setupDiscoveryIsolate(
Stream<SendToIsolateData<IsolateTask<DiscoveryTask>>> receiveFromMain,
void Function(IsolateTaskStreamResult<Device>) sendToMain,
void Function(IsolateTaskStreamResult<DiscoveryResult>) sendToMain,
InitialData initialData,
) async {
await setupChildIsolateHelper(
@@ -73,7 +107,7 @@ Future<void> setupDiscoveryIsolate(
sendToMain(
IsolateTaskStreamResult.event(
id: task.id,
data: device,
data: DiscoveryDeviceResult(device: device),
),
);
}
@@ -104,6 +138,15 @@ Future<void> setupDiscoveryIsolate(
case DiscoveryAddDeviceTask data:
await ref.read(discoveryProvider).addDevice(data.device);
break;
case DiscoveryDeviceLogsTask data:
final logs = await ref.read(discoveryProvider).deviceLogs(data.fingerprint);
sendToMain(
IsolateTaskStreamResult.event(
id: task.id,
data: DiscoveryDeviceLogsResult(logs: logs),
),
);
break;
}
sendToMain(
IsolateTaskStreamResult.done(
@@ -25,9 +25,11 @@ class IsolateDiscoveryListenAction extends ReduxActionWithResult<IsolateControll
return (
state,
connection.sendWrappedTaskAndListenStream(
task: DiscoveryListenTask(),
),
connection
.sendWrappedTaskAndListenStream(
task: DiscoveryListenTask(),
)
.toDeviceStream(),
);
}
}
@@ -56,13 +58,15 @@ class IsolateDiscoverySubnetScanAction extends ReduxActionWithResult<IsolateCont
return (
state,
connection.sendWrappedTaskAndListenStream(
task: DiscoverySubnetScanTask(
networkInterface: networkInterface,
port: port,
https: https,
),
),
connection
.sendWrappedTaskAndListenStream(
task: DiscoverySubnetScanTask(
networkInterface: networkInterface,
port: port,
https: https,
),
)
.toDeviceStream(),
);
}
}
@@ -88,16 +92,45 @@ class IsolateDiscoveryFavoriteScanAction extends ReduxActionWithResult<IsolateCo
return (
state,
connection.sendWrappedTaskAndListenStream(
task: DiscoveryFavoriteScanTask(
favorites: favorites,
https: https,
),
),
connection
.sendWrappedTaskAndListenStream(
task: DiscoveryFavoriteScanTask(
favorites: favorites,
https: https,
),
)
.toDeviceStream(),
);
}
}
/// Fetches the retained confirmations of a stored device, oldest first.
/// The logs are empty when the fingerprint is unknown or the discovery is
/// not running.
class IsolateDiscoveryDeviceLogsAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, List<DeviceLog>> {
final String fingerprint;
IsolateDiscoveryDeviceLogsAction({
required this.fingerprint,
});
@override
Future<(ParentIsolateState, List<DeviceLog>)> reduce() async {
final connection = state.discovery;
if (connection == null) {
throw StateError('discovery is not initialized');
}
final result = await connection
.sendWrappedTaskAndListenStream(
task: DiscoveryDeviceLogsTask(fingerprint: fingerprint),
)
.first;
return (state, (result as DiscoveryDeviceLogsResult).logs);
}
}
/// Sends an announcement which makes every other LocalSend device on the
/// network register with this device's HTTP server.
class IsolateDiscoveryAnnouncementAction extends ReduxAction<IsolateController, ParentIsolateState> {
@@ -496,6 +529,13 @@ class IsolateHttpServerFailFileDownloadAction extends ReduxAction<IsolateControl
}
}
extension _DeviceStreamExt on Stream<DiscoveryResult> {
/// Unwraps the [DiscoveryDeviceResult]s of a device stream.
Stream<Device> toDeviceStream() {
return map((result) => (result as DiscoveryDeviceResult).device);
}
}
/// Adds the [SendToIsolateData] envelope on top of the generic
/// [IsolateTaskConnector.sendTaskAndListenStream] from `typed_isolates`.
extension _WrappedTaskConnector<R, T> on IsolateConnector<IsolateTaskStreamResult<R>, SendToIsolateData<IsolateTask<T>>> {
@@ -1,5 +1,4 @@
import 'package:dart_mappable/dart_mappable.dart';
import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/src/isolate/child/discovery_isolate.dart';
import 'package:localsend_isolates/src/isolate/child/main.dart';
import 'package:localsend_isolates/src/isolate/child/server_isolate.dart';
@@ -18,7 +17,7 @@ part 'parent_isolate_provider.mapper.dart';
@MappableClass()
class ParentIsolateState with ParentIsolateStateMappable {
final SyncState syncState;
final IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<DiscoveryTask>>>? discovery;
final IsolateConnector<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>>? discovery;
final IsolateConnector<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>? httpUpload;
final IsolateConnector<IsolateTaskStreamResult<HttpServerEvent>, SendToIsolateData<IsolateTask<BaseHttpServerTask>>>? httpServer;
@@ -62,13 +61,14 @@ class IsolateController extends ReduxNotifier<ParentIsolateState> {
class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsolateState> {
@override
Future<ParentIsolateState> reduce() async {
final discovery = await TypedIsolates.startIsolate<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
task: setupDiscoveryIsolate,
param: InitialData(
syncState: state.syncState,
logLevel: Logger.root.level,
),
);
final discovery =
await TypedIsolates.startIsolate<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
task: setupDiscoveryIsolate,
param: InitialData(
syncState: state.syncState,
logLevel: Logger.root.level,
),
);
final httpUpload =
await TypedIsolates.startIsolate<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>, InitialData>(
@@ -16,7 +16,6 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
if (_instance == null) {
MapperContainer.globals.use(_instance = ParentIsolateStateMapper._());
SyncStateMapper.ensureInitialized();
DeviceMapper.ensureInitialized();
}
return _instance!;
}
@@ -30,14 +29,14 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
_$syncState,
);
static IsolateConnector<
IsolateTaskStreamResult<Device>,
IsolateTaskStreamResult<DiscoveryResult>,
SendToIsolateData<IsolateTask<DiscoveryTask>>
>?
_$discovery(ParentIsolateState v) => v.discovery;
static const Field<
ParentIsolateState,
IsolateConnector<
IsolateTaskStreamResult<Device>,
IsolateTaskStreamResult<DiscoveryResult>,
SendToIsolateData<IsolateTask<DiscoveryTask>>
>
>
@@ -161,7 +160,7 @@ abstract class ParentIsolateStateCopyWith<
$R call({
SyncState? syncState,
IsolateConnector<
IsolateTaskStreamResult<Device>,
IsolateTaskStreamResult<DiscoveryResult>,
SendToIsolateData<IsolateTask<DiscoveryTask>>
>?
discovery,
@@ -164,6 +164,19 @@ class DiscoveryService {
]);
}
/// The retained confirmations of a stored device, oldest first.
/// Empty when the fingerprint is unknown or the discovery is not running.
Future<List<DeviceLog>> deviceLogs(String fingerprint) async {
final discovery = _discovery;
if (discovery == null) {
_logger.info('Discovery is not running, skipping device logs');
return const [];
}
final logs = await discovery.deviceLogs(fingerprint: fingerprint);
return logs.map((log) => log.toDeviceLog()).toList();
}
/// Feeds a device confirmed outside of the discovery into the store, e.g.
/// one that registered with this device's HTTP server.
/// The device comes back on the [startListener] stream.
@@ -159,6 +159,23 @@ extension RsStoredDeviceExt on rust_discovery.RsStoredDevice {
}
}
extension RsDeviceLogExt on rust_discovery.RsDeviceLog {
DeviceLog toDeviceLog() {
return DeviceLog(
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampMillis.toInt()),
kind: switch (kind) {
rust_discovery.DeviceLogKind.discovered => DeviceLogKind.discovered,
rust_discovery.DeviceLogKind.updated => DeviceLogKind.updated,
},
channel: HttpChannel(
host: channel.host,
port: channel.port,
https: channel.protocol == rust_model.ProtocolType.https,
),
);
}
}
extension DeviceToRsDiscoveredDeviceExt on Device {
rust_discovery.RsDiscoveredDevice toRsDiscoveredDevice(String ip) {
return rust_discovery.RsDiscoveredDevice(