initial commit

This commit is contained in:
2026-07-27 14:42:23 +02:00
commit 833c68a189
257 changed files with 17947 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/routing/app_router.dart';
import 'core/theme/app_theme.dart';
import 'core/theme/theme_mode_provider.dart';
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
final themeMode = ref.watch(themeModeControllerProvider);
return MaterialApp.router(
title: 'NodeMaster Manager',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: themeMode,
routerConfig: router,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'connection.freezed.dart';
part 'connection.g.dart';
/// A saved NodeMaster API endpoint. Client-side only — has no server-side
/// counterpart. `baseUrl` excludes the `/v1` suffix (added by
/// [NodeMasterApiClient]) and any trailing slash, e.g.
/// `https://edge-01.local:8080`.
@freezed
sealed class Connection with _$Connection {
const factory Connection({
required String id,
required String name,
required String baseUrl,
}) = _Connection;
factory Connection.fromJson(Map<String, dynamic> json) => _$ConnectionFromJson(json);
}
@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'connection.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Connection {
String get id; String get name; String get baseUrl;
/// Create a copy of Connection
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ConnectionCopyWith<Connection> get copyWith => _$ConnectionCopyWithImpl<Connection>(this as Connection, _$identity);
/// Serializes this Connection to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Connection&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,baseUrl);
@override
String toString() {
return 'Connection(id: $id, name: $name, baseUrl: $baseUrl)';
}
}
/// @nodoc
abstract mixin class $ConnectionCopyWith<$Res> {
factory $ConnectionCopyWith(Connection value, $Res Function(Connection) _then) = _$ConnectionCopyWithImpl;
@useResult
$Res call({
String id, String name, String baseUrl
});
}
/// @nodoc
class _$ConnectionCopyWithImpl<$Res>
implements $ConnectionCopyWith<$Res> {
_$ConnectionCopyWithImpl(this._self, this._then);
final Connection _self;
final $Res Function(Connection) _then;
/// Create a copy of Connection
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [Connection].
extension ConnectionPatterns on Connection {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Connection value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Connection() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Connection value) $default,){
final _that = this;
switch (_that) {
case _Connection():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Connection value)? $default,){
final _that = this;
switch (_that) {
case _Connection() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String baseUrl)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Connection() when $default != null:
return $default(_that.id,_that.name,_that.baseUrl);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String baseUrl) $default,) {final _that = this;
switch (_that) {
case _Connection():
return $default(_that.id,_that.name,_that.baseUrl);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String baseUrl)? $default,) {final _that = this;
switch (_that) {
case _Connection() when $default != null:
return $default(_that.id,_that.name,_that.baseUrl);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _Connection implements Connection {
const _Connection({required this.id, required this.name, required this.baseUrl});
factory _Connection.fromJson(Map<String, dynamic> json) => _$ConnectionFromJson(json);
@override final String id;
@override final String name;
@override final String baseUrl;
/// Create a copy of Connection
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ConnectionCopyWith<_Connection> get copyWith => __$ConnectionCopyWithImpl<_Connection>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ConnectionToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Connection&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,baseUrl);
@override
String toString() {
return 'Connection(id: $id, name: $name, baseUrl: $baseUrl)';
}
}
/// @nodoc
abstract mixin class _$ConnectionCopyWith<$Res> implements $ConnectionCopyWith<$Res> {
factory _$ConnectionCopyWith(_Connection value, $Res Function(_Connection) _then) = __$ConnectionCopyWithImpl;
@override @useResult
$Res call({
String id, String name, String baseUrl
});
}
/// @nodoc
class __$ConnectionCopyWithImpl<$Res>
implements _$ConnectionCopyWith<$Res> {
__$ConnectionCopyWithImpl(this._self, this._then);
final _Connection _self;
final $Res Function(_Connection) _then;
/// Create a copy of Connection
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,}) {
return _then(_Connection(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
+20
View File
@@ -0,0 +1,20 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connection.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Connection _$ConnectionFromJson(Map<String, dynamic> json) => _Connection(
id: json['id'] as String,
name: json['name'] as String,
baseUrl: json['baseUrl'] as String,
);
Map<String, dynamic> _$ConnectionToJson(_Connection instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'baseUrl': instance.baseUrl,
};
@@ -0,0 +1,75 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../utils/shared_preferences_provider.dart';
import 'connection.dart';
import 'connection_storage.dart';
part 'connection_providers.g.dart';
@Riverpod(keepAlive: true)
ConnectionStorage connectionStorage(Ref ref) {
return ConnectionStorage(ref.watch(sharedPreferencesProvider));
}
/// The full list of saved connections. Mutations persist immediately.
@Riverpod(keepAlive: true)
class SavedConnections extends _$SavedConnections {
@override
List<Connection> build() => ref.watch(connectionStorageProvider).readConnections();
Future<void> add(Connection connection) async {
state = [...state, connection];
await ref.read(connectionStorageProvider).writeConnections(state);
}
Future<void> update(Connection connection) async {
state = [
for (final c in state)
if (c.id == connection.id) connection else c,
];
await ref.read(connectionStorageProvider).writeConnections(state);
}
Future<void> remove(String id) async {
state = state.where((c) => c.id != id).toList(growable: false);
await ref.read(connectionStorageProvider).writeConnections(state);
final activeId = ref.read(activeConnectionIdProvider);
if (activeId == id) {
await ref.read(activeConnectionIdProvider.notifier).setActive(
state.isEmpty ? null : state.first.id,
);
}
}
}
/// The id of the currently active connection, or null if none is selected
/// (e.g. first launch with nothing configured yet).
@Riverpod(keepAlive: true)
class ActiveConnectionId extends _$ActiveConnectionId {
@override
String? build() {
final stored = ref.watch(connectionStorageProvider).readActiveConnectionId();
final connections = ref.watch(savedConnectionsProvider);
if (stored != null && connections.any((c) => c.id == stored)) return stored;
return connections.isEmpty ? null : connections.first.id;
}
Future<void> setActive(String? id) async {
state = id;
await ref.read(connectionStorageProvider).writeActiveConnectionId(id);
}
}
/// The currently active [Connection], or null if none is configured.
/// Everything in `core/network` derives from this — switching it rebuilds
/// the API client and, transitively, every provider that watches it.
@riverpod
Connection? activeConnection(Ref ref) {
final id = ref.watch(activeConnectionIdProvider);
if (id == null) return null;
final connections = ref.watch(savedConnectionsProvider);
for (final c in connections) {
if (c.id == id) return c;
}
return null;
}
@@ -0,0 +1,232 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connection_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(connectionStorage)
const connectionStorageProvider = ConnectionStorageProvider._();
final class ConnectionStorageProvider
extends
$FunctionalProvider<
ConnectionStorage,
ConnectionStorage,
ConnectionStorage
>
with $Provider<ConnectionStorage> {
const ConnectionStorageProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectionStorageProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectionStorageHash();
@$internal
@override
$ProviderElement<ConnectionStorage> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ConnectionStorage create(Ref ref) {
return connectionStorage(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ConnectionStorage value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ConnectionStorage>(value),
);
}
}
String _$connectionStorageHash() => r'0faf909e3978260785f2f8ce4bc41f101e27ad66';
/// The full list of saved connections. Mutations persist immediately.
@ProviderFor(SavedConnections)
const savedConnectionsProvider = SavedConnectionsProvider._();
/// The full list of saved connections. Mutations persist immediately.
final class SavedConnectionsProvider
extends $NotifierProvider<SavedConnections, List<Connection>> {
/// The full list of saved connections. Mutations persist immediately.
const SavedConnectionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'savedConnectionsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$savedConnectionsHash();
@$internal
@override
SavedConnections create() => SavedConnections();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<Connection> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<Connection>>(value),
);
}
}
String _$savedConnectionsHash() => r'2595280b9ef1f5835ecce70b791a901a51aaf8e8';
/// The full list of saved connections. Mutations persist immediately.
abstract class _$SavedConnections extends $Notifier<List<Connection>> {
List<Connection> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<List<Connection>, List<Connection>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<Connection>, List<Connection>>,
List<Connection>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// The id of the currently active connection, or null if none is selected
/// (e.g. first launch with nothing configured yet).
@ProviderFor(ActiveConnectionId)
const activeConnectionIdProvider = ActiveConnectionIdProvider._();
/// The id of the currently active connection, or null if none is selected
/// (e.g. first launch with nothing configured yet).
final class ActiveConnectionIdProvider
extends $NotifierProvider<ActiveConnectionId, String?> {
/// The id of the currently active connection, or null if none is selected
/// (e.g. first launch with nothing configured yet).
const ActiveConnectionIdProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'activeConnectionIdProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$activeConnectionIdHash();
@$internal
@override
ActiveConnectionId create() => ActiveConnectionId();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$activeConnectionIdHash() =>
r'ac8b7d48bc04e6f9245b23c83a15e11a539e1fc8';
/// The id of the currently active connection, or null if none is selected
/// (e.g. first launch with nothing configured yet).
abstract class _$ActiveConnectionId extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// The currently active [Connection], or null if none is configured.
/// Everything in `core/network` derives from this — switching it rebuilds
/// the API client and, transitively, every provider that watches it.
@ProviderFor(activeConnection)
const activeConnectionProvider = ActiveConnectionProvider._();
/// The currently active [Connection], or null if none is configured.
/// Everything in `core/network` derives from this — switching it rebuilds
/// the API client and, transitively, every provider that watches it.
final class ActiveConnectionProvider
extends $FunctionalProvider<Connection?, Connection?, Connection?>
with $Provider<Connection?> {
/// The currently active [Connection], or null if none is configured.
/// Everything in `core/network` derives from this — switching it rebuilds
/// the API client and, transitively, every provider that watches it.
const ActiveConnectionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'activeConnectionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$activeConnectionHash();
@$internal
@override
$ProviderElement<Connection?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Connection? create(Ref ref) {
return activeConnection(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Connection? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Connection?>(value),
);
}
}
String _$activeConnectionHash() => r'544cf6f795b35636cbfc497e6379a84b1e8f5b88';
@@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'connection.dart';
const _connectionsKey = 'connections';
const _activeConnectionIdKey = 'active_connection_id';
/// Reads/writes the saved connection list and active-connection id to
/// [SharedPreferences] as a plain JSON-encoded array. Not a secret store —
/// the API has no auth, so there's nothing sensitive in a saved base URL.
class ConnectionStorage {
const ConnectionStorage(this._prefs);
final SharedPreferences _prefs;
List<Connection> readConnections() {
final raw = _prefs.getString(_connectionsKey);
if (raw == null || raw.isEmpty) return const [];
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded
.map((e) => Connection.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
Future<void> writeConnections(List<Connection> connections) async {
final encoded = jsonEncode(connections.map((c) => c.toJson()).toList());
await _prefs.setString(_connectionsKey, encoded);
}
String? readActiveConnectionId() => _prefs.getString(_activeConnectionIdKey);
Future<void> writeActiveConnectionId(String? id) async {
if (id == null) {
await _prefs.remove(_activeConnectionIdKey);
} else {
await _prefs.setString(_activeConnectionIdKey, id);
}
}
}
@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'remote_node.dart';
import 'service.dart';
part 'aggregated_node_status.freezed.dart';
part 'aggregated_node_status.g.dart';
/// Mirrors the anonymous `nodeServices` struct returned by
/// `GET /nodes/aggregated`: `{node, services?, error?}`. `error` is the
/// *local* API reporting that fetching *this remote* failed (e.g.
/// unreachable) — a per-entry condition, distinct from the outer call
/// itself failing. Render `error` inline per-card; never conflate it with
/// an [ApiException] on the aggregated fetch as a whole.
@freezed
sealed class AggregatedNodeStatus with _$AggregatedNodeStatus {
const factory AggregatedNodeStatus({
required RemoteNode node,
@Default(<Service>[]) List<Service> services,
String? error,
}) = _AggregatedNodeStatus;
factory AggregatedNodeStatus.fromJson(Map<String, dynamic> json) =>
_$AggregatedNodeStatusFromJson(json);
}
@@ -0,0 +1,301 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'aggregated_node_status.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AggregatedNodeStatus {
RemoteNode get node; List<Service> get services; String? get error;
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AggregatedNodeStatusCopyWith<AggregatedNodeStatus> get copyWith => _$AggregatedNodeStatusCopyWithImpl<AggregatedNodeStatus>(this as AggregatedNodeStatus, _$identity);
/// Serializes this AggregatedNodeStatus to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AggregatedNodeStatus&&(identical(other.node, node) || other.node == node)&&const DeepCollectionEquality().equals(other.services, services)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,node,const DeepCollectionEquality().hash(services),error);
@override
String toString() {
return 'AggregatedNodeStatus(node: $node, services: $services, error: $error)';
}
}
/// @nodoc
abstract mixin class $AggregatedNodeStatusCopyWith<$Res> {
factory $AggregatedNodeStatusCopyWith(AggregatedNodeStatus value, $Res Function(AggregatedNodeStatus) _then) = _$AggregatedNodeStatusCopyWithImpl;
@useResult
$Res call({
RemoteNode node, List<Service> services, String? error
});
$RemoteNodeCopyWith<$Res> get node;
}
/// @nodoc
class _$AggregatedNodeStatusCopyWithImpl<$Res>
implements $AggregatedNodeStatusCopyWith<$Res> {
_$AggregatedNodeStatusCopyWithImpl(this._self, this._then);
final AggregatedNodeStatus _self;
final $Res Function(AggregatedNodeStatus) _then;
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? node = null,Object? services = null,Object? error = freezed,}) {
return _then(_self.copyWith(
node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable
as RemoteNode,services: null == services ? _self.services : services // ignore: cast_nullable_to_non_nullable
as List<Service>,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$RemoteNodeCopyWith<$Res> get node {
return $RemoteNodeCopyWith<$Res>(_self.node, (value) {
return _then(_self.copyWith(node: value));
});
}
}
/// Adds pattern-matching-related methods to [AggregatedNodeStatus].
extension AggregatedNodeStatusPatterns on AggregatedNodeStatus {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _AggregatedNodeStatus value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AggregatedNodeStatus() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _AggregatedNodeStatus value) $default,){
final _that = this;
switch (_that) {
case _AggregatedNodeStatus():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _AggregatedNodeStatus value)? $default,){
final _that = this;
switch (_that) {
case _AggregatedNodeStatus() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( RemoteNode node, List<Service> services, String? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AggregatedNodeStatus() when $default != null:
return $default(_that.node,_that.services,_that.error);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( RemoteNode node, List<Service> services, String? error) $default,) {final _that = this;
switch (_that) {
case _AggregatedNodeStatus():
return $default(_that.node,_that.services,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( RemoteNode node, List<Service> services, String? error)? $default,) {final _that = this;
switch (_that) {
case _AggregatedNodeStatus() when $default != null:
return $default(_that.node,_that.services,_that.error);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _AggregatedNodeStatus implements AggregatedNodeStatus {
const _AggregatedNodeStatus({required this.node, final List<Service> services = const <Service>[], this.error}): _services = services;
factory _AggregatedNodeStatus.fromJson(Map<String, dynamic> json) => _$AggregatedNodeStatusFromJson(json);
@override final RemoteNode node;
final List<Service> _services;
@override@JsonKey() List<Service> get services {
if (_services is EqualUnmodifiableListView) return _services;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_services);
}
@override final String? error;
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AggregatedNodeStatusCopyWith<_AggregatedNodeStatus> get copyWith => __$AggregatedNodeStatusCopyWithImpl<_AggregatedNodeStatus>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$AggregatedNodeStatusToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AggregatedNodeStatus&&(identical(other.node, node) || other.node == node)&&const DeepCollectionEquality().equals(other._services, _services)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,node,const DeepCollectionEquality().hash(_services),error);
@override
String toString() {
return 'AggregatedNodeStatus(node: $node, services: $services, error: $error)';
}
}
/// @nodoc
abstract mixin class _$AggregatedNodeStatusCopyWith<$Res> implements $AggregatedNodeStatusCopyWith<$Res> {
factory _$AggregatedNodeStatusCopyWith(_AggregatedNodeStatus value, $Res Function(_AggregatedNodeStatus) _then) = __$AggregatedNodeStatusCopyWithImpl;
@override @useResult
$Res call({
RemoteNode node, List<Service> services, String? error
});
@override $RemoteNodeCopyWith<$Res> get node;
}
/// @nodoc
class __$AggregatedNodeStatusCopyWithImpl<$Res>
implements _$AggregatedNodeStatusCopyWith<$Res> {
__$AggregatedNodeStatusCopyWithImpl(this._self, this._then);
final _AggregatedNodeStatus _self;
final $Res Function(_AggregatedNodeStatus) _then;
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? node = null,Object? services = null,Object? error = freezed,}) {
return _then(_AggregatedNodeStatus(
node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable
as RemoteNode,services: null == services ? _self._services : services // ignore: cast_nullable_to_non_nullable
as List<Service>,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of AggregatedNodeStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$RemoteNodeCopyWith<$Res> get node {
return $RemoteNodeCopyWith<$Res>(_self.node, (value) {
return _then(_self.copyWith(node: value));
});
}
}
// dart format on
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'aggregated_node_status.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_AggregatedNodeStatus _$AggregatedNodeStatusFromJson(
Map<String, dynamic> json,
) => _AggregatedNodeStatus(
node: RemoteNode.fromJson(json['node'] as Map<String, dynamic>),
services:
(json['services'] as List<dynamic>?)
?.map((e) => Service.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Service>[],
error: json['error'] as String?,
);
Map<String, dynamic> _$AggregatedNodeStatusToJson(
_AggregatedNodeStatus instance,
) => <String, dynamic>{
'node': instance.node.toJson(),
'services': instance.services.map((e) => e.toJson()).toList(),
'error': instance.error,
};
+25
View File
@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'backup_execution.dart';
import 'backup_target.dart';
part 'backup_config.freezed.dart';
part 'backup_config.g.dart';
/// Mirrors `models.BackupConfig`. Used both at node level (`GET /node/backup`)
/// and per-service (`Service.backup`). `nextRun`/`lastRun` here are
/// aggregates (earliest/latest) across `targets` — each target now tracks
/// its own `schedule`/`lastRun`/`nextRun` too. There's no `frequency` field
/// on the Go side anymore; scheduling moved to a per-target cron `schedule`.
@freezed
sealed class BackupConfig with _$BackupConfig {
const factory BackupConfig({
@JsonKey(name: 'source_path') String? sourcePath,
@Default(<BackupTarget>[]) List<BackupTarget> targets,
@JsonKey(name: 'next_run') DateTime? nextRun,
@JsonKey(name: 'last_run') DateTime? lastRun,
@Default(<BackupExecution>[]) List<BackupExecution> history,
}) = _BackupConfig;
factory BackupConfig.fromJson(Map<String, dynamic> json) => _$BackupConfigFromJson(json);
}
+295
View File
@@ -0,0 +1,295 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'backup_config.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$BackupConfig {
@JsonKey(name: 'source_path') String? get sourcePath; List<BackupTarget> get targets;@JsonKey(name: 'next_run') DateTime? get nextRun;@JsonKey(name: 'last_run') DateTime? get lastRun; List<BackupExecution> get history;
/// Create a copy of BackupConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BackupConfigCopyWith<BackupConfig> get copyWith => _$BackupConfigCopyWithImpl<BackupConfig>(this as BackupConfig, _$identity);
/// Serializes this BackupConfig to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupConfig&&(identical(other.sourcePath, sourcePath) || other.sourcePath == sourcePath)&&const DeepCollectionEquality().equals(other.targets, targets)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&const DeepCollectionEquality().equals(other.history, history));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,sourcePath,const DeepCollectionEquality().hash(targets),nextRun,lastRun,const DeepCollectionEquality().hash(history));
@override
String toString() {
return 'BackupConfig(sourcePath: $sourcePath, targets: $targets, nextRun: $nextRun, lastRun: $lastRun, history: $history)';
}
}
/// @nodoc
abstract mixin class $BackupConfigCopyWith<$Res> {
factory $BackupConfigCopyWith(BackupConfig value, $Res Function(BackupConfig) _then) = _$BackupConfigCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'source_path') String? sourcePath, List<BackupTarget> targets,@JsonKey(name: 'next_run') DateTime? nextRun,@JsonKey(name: 'last_run') DateTime? lastRun, List<BackupExecution> history
});
}
/// @nodoc
class _$BackupConfigCopyWithImpl<$Res>
implements $BackupConfigCopyWith<$Res> {
_$BackupConfigCopyWithImpl(this._self, this._then);
final BackupConfig _self;
final $Res Function(BackupConfig) _then;
/// Create a copy of BackupConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? sourcePath = freezed,Object? targets = null,Object? nextRun = freezed,Object? lastRun = freezed,Object? history = null,}) {
return _then(_self.copyWith(
sourcePath: freezed == sourcePath ? _self.sourcePath : sourcePath // ignore: cast_nullable_to_non_nullable
as String?,targets: null == targets ? _self.targets : targets // ignore: cast_nullable_to_non_nullable
as List<BackupTarget>,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable
as DateTime?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable
as DateTime?,history: null == history ? _self.history : history // ignore: cast_nullable_to_non_nullable
as List<BackupExecution>,
));
}
}
/// Adds pattern-matching-related methods to [BackupConfig].
extension BackupConfigPatterns on BackupConfig {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupConfig value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _BackupConfig() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupConfig value) $default,){
final _that = this;
switch (_that) {
case _BackupConfig():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupConfig value)? $default,){
final _that = this;
switch (_that) {
case _BackupConfig() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'source_path') String? sourcePath, List<BackupTarget> targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List<BackupExecution> history)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _BackupConfig() when $default != null:
return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'source_path') String? sourcePath, List<BackupTarget> targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List<BackupExecution> history) $default,) {final _that = this;
switch (_that) {
case _BackupConfig():
return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'source_path') String? sourcePath, List<BackupTarget> targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List<BackupExecution> history)? $default,) {final _that = this;
switch (_that) {
case _BackupConfig() when $default != null:
return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _BackupConfig implements BackupConfig {
const _BackupConfig({@JsonKey(name: 'source_path') this.sourcePath, final List<BackupTarget> targets = const <BackupTarget>[], @JsonKey(name: 'next_run') this.nextRun, @JsonKey(name: 'last_run') this.lastRun, final List<BackupExecution> history = const <BackupExecution>[]}): _targets = targets,_history = history;
factory _BackupConfig.fromJson(Map<String, dynamic> json) => _$BackupConfigFromJson(json);
@override@JsonKey(name: 'source_path') final String? sourcePath;
final List<BackupTarget> _targets;
@override@JsonKey() List<BackupTarget> get targets {
if (_targets is EqualUnmodifiableListView) return _targets;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_targets);
}
@override@JsonKey(name: 'next_run') final DateTime? nextRun;
@override@JsonKey(name: 'last_run') final DateTime? lastRun;
final List<BackupExecution> _history;
@override@JsonKey() List<BackupExecution> get history {
if (_history is EqualUnmodifiableListView) return _history;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_history);
}
/// Create a copy of BackupConfig
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BackupConfigCopyWith<_BackupConfig> get copyWith => __$BackupConfigCopyWithImpl<_BackupConfig>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BackupConfigToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupConfig&&(identical(other.sourcePath, sourcePath) || other.sourcePath == sourcePath)&&const DeepCollectionEquality().equals(other._targets, _targets)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&const DeepCollectionEquality().equals(other._history, _history));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,sourcePath,const DeepCollectionEquality().hash(_targets),nextRun,lastRun,const DeepCollectionEquality().hash(_history));
@override
String toString() {
return 'BackupConfig(sourcePath: $sourcePath, targets: $targets, nextRun: $nextRun, lastRun: $lastRun, history: $history)';
}
}
/// @nodoc
abstract mixin class _$BackupConfigCopyWith<$Res> implements $BackupConfigCopyWith<$Res> {
factory _$BackupConfigCopyWith(_BackupConfig value, $Res Function(_BackupConfig) _then) = __$BackupConfigCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'source_path') String? sourcePath, List<BackupTarget> targets,@JsonKey(name: 'next_run') DateTime? nextRun,@JsonKey(name: 'last_run') DateTime? lastRun, List<BackupExecution> history
});
}
/// @nodoc
class __$BackupConfigCopyWithImpl<$Res>
implements _$BackupConfigCopyWith<$Res> {
__$BackupConfigCopyWithImpl(this._self, this._then);
final _BackupConfig _self;
final $Res Function(_BackupConfig) _then;
/// Create a copy of BackupConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? sourcePath = freezed,Object? targets = null,Object? nextRun = freezed,Object? lastRun = freezed,Object? history = null,}) {
return _then(_BackupConfig(
sourcePath: freezed == sourcePath ? _self.sourcePath : sourcePath // ignore: cast_nullable_to_non_nullable
as String?,targets: null == targets ? _self._targets : targets // ignore: cast_nullable_to_non_nullable
as List<BackupTarget>,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable
as DateTime?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable
as DateTime?,history: null == history ? _self._history : history // ignore: cast_nullable_to_non_nullable
as List<BackupExecution>,
));
}
}
// dart format on
+37
View File
@@ -0,0 +1,37 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_BackupConfig _$BackupConfigFromJson(Map<String, dynamic> json) =>
_BackupConfig(
sourcePath: json['source_path'] as String?,
targets:
(json['targets'] as List<dynamic>?)
?.map((e) => BackupTarget.fromJson(e as Map<String, dynamic>))
.toList() ??
const <BackupTarget>[],
nextRun: json['next_run'] == null
? null
: DateTime.parse(json['next_run'] as String),
lastRun: json['last_run'] == null
? null
: DateTime.parse(json['last_run'] as String),
history:
(json['history'] as List<dynamic>?)
?.map((e) => BackupExecution.fromJson(e as Map<String, dynamic>))
.toList() ??
const <BackupExecution>[],
);
Map<String, dynamic> _$BackupConfigToJson(_BackupConfig instance) =>
<String, dynamic>{
'source_path': instance.sourcePath,
'targets': instance.targets.map((e) => e.toJson()).toList(),
'next_run': instance.nextRun?.toIso8601String(),
'last_run': instance.lastRun?.toIso8601String(),
'history': instance.history.map((e) => e.toJson()).toList(),
};
+19
View File
@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'backup_execution.freezed.dart';
part 'backup_execution.g.dart';
/// Mirrors `models.BackupExecution`.
@freezed
sealed class BackupExecution with _$BackupExecution {
const factory BackupExecution({
required String id,
required DateTime timestamp,
required bool success,
@JsonKey(name: 'duration_seconds') required int durationSeconds,
@Default('') String message,
@JsonKey(name: 'target_id') String? targetId,
}) = _BackupExecution;
factory BackupExecution.fromJson(Map<String, dynamic> json) => _$BackupExecutionFromJson(json);
}
@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'backup_execution.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$BackupExecution {
String get id; DateTime get timestamp; bool get success;@JsonKey(name: 'duration_seconds') int get durationSeconds; String get message;@JsonKey(name: 'target_id') String? get targetId;
/// Create a copy of BackupExecution
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BackupExecutionCopyWith<BackupExecution> get copyWith => _$BackupExecutionCopyWithImpl<BackupExecution>(this as BackupExecution, _$identity);
/// Serializes this BackupExecution to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupExecution&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(identical(other.message, message) || other.message == message)&&(identical(other.targetId, targetId) || other.targetId == targetId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,timestamp,success,durationSeconds,message,targetId);
@override
String toString() {
return 'BackupExecution(id: $id, timestamp: $timestamp, success: $success, durationSeconds: $durationSeconds, message: $message, targetId: $targetId)';
}
}
/// @nodoc
abstract mixin class $BackupExecutionCopyWith<$Res> {
factory $BackupExecutionCopyWith(BackupExecution value, $Res Function(BackupExecution) _then) = _$BackupExecutionCopyWithImpl;
@useResult
$Res call({
String id, DateTime timestamp, bool success,@JsonKey(name: 'duration_seconds') int durationSeconds, String message,@JsonKey(name: 'target_id') String? targetId
});
}
/// @nodoc
class _$BackupExecutionCopyWithImpl<$Res>
implements $BackupExecutionCopyWith<$Res> {
_$BackupExecutionCopyWithImpl(this._self, this._then);
final BackupExecution _self;
final $Res Function(BackupExecution) _then;
/// Create a copy of BackupExecution
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? durationSeconds = null,Object? message = null,Object? targetId = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable
as bool,durationSeconds: null == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable
as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,targetId: freezed == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [BackupExecution].
extension BackupExecutionPatterns on BackupExecution {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupExecution value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _BackupExecution() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupExecution value) $default,){
final _that = this;
switch (_that) {
case _BackupExecution():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupExecution value)? $default,){
final _that = this;
switch (_that) {
case _BackupExecution() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _BackupExecution() when $default != null:
return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId) $default,) {final _that = this;
switch (_that) {
case _BackupExecution():
return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId)? $default,) {final _that = this;
switch (_that) {
case _BackupExecution() when $default != null:
return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _BackupExecution implements BackupExecution {
const _BackupExecution({required this.id, required this.timestamp, required this.success, @JsonKey(name: 'duration_seconds') required this.durationSeconds, this.message = '', @JsonKey(name: 'target_id') this.targetId});
factory _BackupExecution.fromJson(Map<String, dynamic> json) => _$BackupExecutionFromJson(json);
@override final String id;
@override final DateTime timestamp;
@override final bool success;
@override@JsonKey(name: 'duration_seconds') final int durationSeconds;
@override@JsonKey() final String message;
@override@JsonKey(name: 'target_id') final String? targetId;
/// Create a copy of BackupExecution
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BackupExecutionCopyWith<_BackupExecution> get copyWith => __$BackupExecutionCopyWithImpl<_BackupExecution>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BackupExecutionToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupExecution&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(identical(other.message, message) || other.message == message)&&(identical(other.targetId, targetId) || other.targetId == targetId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,timestamp,success,durationSeconds,message,targetId);
@override
String toString() {
return 'BackupExecution(id: $id, timestamp: $timestamp, success: $success, durationSeconds: $durationSeconds, message: $message, targetId: $targetId)';
}
}
/// @nodoc
abstract mixin class _$BackupExecutionCopyWith<$Res> implements $BackupExecutionCopyWith<$Res> {
factory _$BackupExecutionCopyWith(_BackupExecution value, $Res Function(_BackupExecution) _then) = __$BackupExecutionCopyWithImpl;
@override @useResult
$Res call({
String id, DateTime timestamp, bool success,@JsonKey(name: 'duration_seconds') int durationSeconds, String message,@JsonKey(name: 'target_id') String? targetId
});
}
/// @nodoc
class __$BackupExecutionCopyWithImpl<$Res>
implements _$BackupExecutionCopyWith<$Res> {
__$BackupExecutionCopyWithImpl(this._self, this._then);
final _BackupExecution _self;
final $Res Function(_BackupExecution) _then;
/// Create a copy of BackupExecution
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? durationSeconds = null,Object? message = null,Object? targetId = freezed,}) {
return _then(_BackupExecution(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable
as bool,durationSeconds: null == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable
as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,targetId: freezed == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on
+27
View File
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_execution.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_BackupExecution _$BackupExecutionFromJson(Map<String, dynamic> json) =>
_BackupExecution(
id: json['id'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
success: json['success'] as bool,
durationSeconds: (json['duration_seconds'] as num).toInt(),
message: json['message'] as String? ?? '',
targetId: json['target_id'] as String?,
);
Map<String, dynamic> _$BackupExecutionToJson(_BackupExecution instance) =>
<String, dynamic>{
'id': instance.id,
'timestamp': instance.timestamp.toIso8601String(),
'success': instance.success,
'duration_seconds': instance.durationSeconds,
'message': instance.message,
'target_id': instance.targetId,
};
+11
View File
@@ -0,0 +1,11 @@
import 'package:json_annotation/json_annotation.dart';
/// Mirrors `models.BackupMethod` in `nodemaster/models/types.go`.
enum BackupMethod {
@JsonValue('rsync')
rsync,
@JsonValue('restic')
restic,
@JsonValue('external')
external,
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'backup_method.dart';
part 'backup_target.freezed.dart';
part 'backup_target.g.dart';
/// Mirrors `models.BackupTarget`. `schedule` is a standard 5-field cron
/// expression; empty means manual-only (no automatic scheduling).
@freezed
sealed class BackupTarget with _$BackupTarget {
const factory BackupTarget({
@Default('') String id,
required String name,
required BackupMethod method,
required String remote,
@Default(<String, String>{}) Map<String, String> params,
String? schedule,
@JsonKey(name: 'last_run') DateTime? lastRun,
@JsonKey(name: 'next_run') DateTime? nextRun,
}) = _BackupTarget;
factory BackupTarget.fromJson(Map<String, dynamic> json) => _$BackupTargetFromJson(json);
}
+298
View File
@@ -0,0 +1,298 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'backup_target.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$BackupTarget {
String get id; String get name; BackupMethod get method; String get remote; Map<String, String> get params; String? get schedule;@JsonKey(name: 'last_run') DateTime? get lastRun;@JsonKey(name: 'next_run') DateTime? get nextRun;
/// Create a copy of BackupTarget
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BackupTargetCopyWith<BackupTarget> get copyWith => _$BackupTargetCopyWithImpl<BackupTarget>(this as BackupTarget, _$identity);
/// Serializes this BackupTarget to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupTarget&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.method, method) || other.method == method)&&(identical(other.remote, remote) || other.remote == remote)&&const DeepCollectionEquality().equals(other.params, params)&&(identical(other.schedule, schedule) || other.schedule == schedule)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,method,remote,const DeepCollectionEquality().hash(params),schedule,lastRun,nextRun);
@override
String toString() {
return 'BackupTarget(id: $id, name: $name, method: $method, remote: $remote, params: $params, schedule: $schedule, lastRun: $lastRun, nextRun: $nextRun)';
}
}
/// @nodoc
abstract mixin class $BackupTargetCopyWith<$Res> {
factory $BackupTargetCopyWith(BackupTarget value, $Res Function(BackupTarget) _then) = _$BackupTargetCopyWithImpl;
@useResult
$Res call({
String id, String name, BackupMethod method, String remote, Map<String, String> params, String? schedule,@JsonKey(name: 'last_run') DateTime? lastRun,@JsonKey(name: 'next_run') DateTime? nextRun
});
}
/// @nodoc
class _$BackupTargetCopyWithImpl<$Res>
implements $BackupTargetCopyWith<$Res> {
_$BackupTargetCopyWithImpl(this._self, this._then);
final BackupTarget _self;
final $Res Function(BackupTarget) _then;
/// Create a copy of BackupTarget
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? method = null,Object? remote = null,Object? params = null,Object? schedule = freezed,Object? lastRun = freezed,Object? nextRun = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable
as BackupMethod,remote: null == remote ? _self.remote : remote // ignore: cast_nullable_to_non_nullable
as String,params: null == params ? _self.params : params // ignore: cast_nullable_to_non_nullable
as Map<String, String>,schedule: freezed == schedule ? _self.schedule : schedule // ignore: cast_nullable_to_non_nullable
as String?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable
as DateTime?,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// Adds pattern-matching-related methods to [BackupTarget].
extension BackupTargetPatterns on BackupTarget {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupTarget value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _BackupTarget() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupTarget value) $default,){
final _that = this;
switch (_that) {
case _BackupTarget():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupTarget value)? $default,){
final _that = this;
switch (_that) {
case _BackupTarget() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, BackupMethod method, String remote, Map<String, String> params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _BackupTarget() when $default != null:
return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, BackupMethod method, String remote, Map<String, String> params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun) $default,) {final _that = this;
switch (_that) {
case _BackupTarget():
return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, BackupMethod method, String remote, Map<String, String> params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun)? $default,) {final _that = this;
switch (_that) {
case _BackupTarget() when $default != null:
return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _BackupTarget implements BackupTarget {
const _BackupTarget({this.id = '', required this.name, required this.method, required this.remote, final Map<String, String> params = const <String, String>{}, this.schedule, @JsonKey(name: 'last_run') this.lastRun, @JsonKey(name: 'next_run') this.nextRun}): _params = params;
factory _BackupTarget.fromJson(Map<String, dynamic> json) => _$BackupTargetFromJson(json);
@override@JsonKey() final String id;
@override final String name;
@override final BackupMethod method;
@override final String remote;
final Map<String, String> _params;
@override@JsonKey() Map<String, String> get params {
if (_params is EqualUnmodifiableMapView) return _params;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_params);
}
@override final String? schedule;
@override@JsonKey(name: 'last_run') final DateTime? lastRun;
@override@JsonKey(name: 'next_run') final DateTime? nextRun;
/// Create a copy of BackupTarget
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BackupTargetCopyWith<_BackupTarget> get copyWith => __$BackupTargetCopyWithImpl<_BackupTarget>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BackupTargetToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupTarget&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.method, method) || other.method == method)&&(identical(other.remote, remote) || other.remote == remote)&&const DeepCollectionEquality().equals(other._params, _params)&&(identical(other.schedule, schedule) || other.schedule == schedule)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,method,remote,const DeepCollectionEquality().hash(_params),schedule,lastRun,nextRun);
@override
String toString() {
return 'BackupTarget(id: $id, name: $name, method: $method, remote: $remote, params: $params, schedule: $schedule, lastRun: $lastRun, nextRun: $nextRun)';
}
}
/// @nodoc
abstract mixin class _$BackupTargetCopyWith<$Res> implements $BackupTargetCopyWith<$Res> {
factory _$BackupTargetCopyWith(_BackupTarget value, $Res Function(_BackupTarget) _then) = __$BackupTargetCopyWithImpl;
@override @useResult
$Res call({
String id, String name, BackupMethod method, String remote, Map<String, String> params, String? schedule,@JsonKey(name: 'last_run') DateTime? lastRun,@JsonKey(name: 'next_run') DateTime? nextRun
});
}
/// @nodoc
class __$BackupTargetCopyWithImpl<$Res>
implements _$BackupTargetCopyWith<$Res> {
__$BackupTargetCopyWithImpl(this._self, this._then);
final _BackupTarget _self;
final $Res Function(_BackupTarget) _then;
/// Create a copy of BackupTarget
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? method = null,Object? remote = null,Object? params = null,Object? schedule = freezed,Object? lastRun = freezed,Object? nextRun = freezed,}) {
return _then(_BackupTarget(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable
as BackupMethod,remote: null == remote ? _self.remote : remote // ignore: cast_nullable_to_non_nullable
as String,params: null == params ? _self._params : params // ignore: cast_nullable_to_non_nullable
as Map<String, String>,schedule: freezed == schedule ? _self.schedule : schedule // ignore: cast_nullable_to_non_nullable
as String?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable
as DateTime?,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
// dart format on
+45
View File
@@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_target.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_BackupTarget _$BackupTargetFromJson(Map<String, dynamic> json) =>
_BackupTarget(
id: json['id'] as String? ?? '',
name: json['name'] as String,
method: $enumDecode(_$BackupMethodEnumMap, json['method']),
remote: json['remote'] as String,
params:
(json['params'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
const <String, String>{},
schedule: json['schedule'] as String?,
lastRun: json['last_run'] == null
? null
: DateTime.parse(json['last_run'] as String),
nextRun: json['next_run'] == null
? null
: DateTime.parse(json['next_run'] as String),
);
Map<String, dynamic> _$BackupTargetToJson(_BackupTarget instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'method': _$BackupMethodEnumMap[instance.method]!,
'remote': instance.remote,
'params': instance.params,
'schedule': instance.schedule,
'last_run': instance.lastRun?.toIso8601String(),
'next_run': instance.nextRun?.toIso8601String(),
};
const _$BackupMethodEnumMap = {
BackupMethod.rsync: 'rsync',
BackupMethod.restic: 'restic',
BackupMethod.external: 'external',
};
+12
View File
@@ -0,0 +1,12 @@
export 'aggregated_node_status.dart';
export 'backup_config.dart';
export 'backup_execution.dart';
export 'backup_method.dart';
export 'backup_target.dart';
export 'node_info.dart';
export 'remote_node.dart';
export 'run_state.dart';
export 'scan_result.dart';
export 'service.dart';
export 'service_scan_info.dart';
export 'update_record.dart';
+30
View File
@@ -0,0 +1,30 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'backup_config.dart';
import 'update_record.dart';
part 'node_info.freezed.dart';
part 'node_info.g.dart';
/// Mirrors `models.NodeInfo` — the local/current host's identity + backup
/// config + update log, as served by `GET /node/`.
///
/// `resticPassword` is write-only: the API never echoes the real password
/// back (`GET /node/` sends `restic_password_set` instead, see
/// `controllers/node.go`), so this stays `null` unless the user is actively
/// setting/changing it in the identity form, and is omitted from the PUT
/// body entirely when `null` so an untouched form never clobbers the
/// existing password (mirrors the Go side's "empty string = don't change").
@freezed
sealed class NodeInfo with _$NodeInfo {
const factory NodeInfo({
required String hostname,
String? description,
@JsonKey(name: 'update_history') @Default(<UpdateRecord>[]) List<UpdateRecord> updateHistory,
required BackupConfig backup,
@JsonKey(name: 'restic_password_set') @Default(false) bool resticPasswordSet,
@JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword,
}) = _NodeInfo;
factory NodeInfo.fromJson(Map<String, dynamic> json) => _$NodeInfoFromJson(json);
}
+310
View File
@@ -0,0 +1,310 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'node_info.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$NodeInfo {
String get hostname; String? get description;@JsonKey(name: 'update_history') List<UpdateRecord> get updateHistory; BackupConfig get backup;@JsonKey(name: 'restic_password_set') bool get resticPasswordSet;@JsonKey(name: 'restic_password', includeIfNull: false) String? get resticPassword;
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$NodeInfoCopyWith<NodeInfo> get copyWith => _$NodeInfoCopyWithImpl<NodeInfo>(this as NodeInfo, _$identity);
/// Serializes this NodeInfo to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NodeInfo&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.updateHistory, updateHistory)&&(identical(other.backup, backup) || other.backup == backup)&&(identical(other.resticPasswordSet, resticPasswordSet) || other.resticPasswordSet == resticPasswordSet)&&(identical(other.resticPassword, resticPassword) || other.resticPassword == resticPassword));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,hostname,description,const DeepCollectionEquality().hash(updateHistory),backup,resticPasswordSet,resticPassword);
@override
String toString() {
return 'NodeInfo(hostname: $hostname, description: $description, updateHistory: $updateHistory, backup: $backup, resticPasswordSet: $resticPasswordSet, resticPassword: $resticPassword)';
}
}
/// @nodoc
abstract mixin class $NodeInfoCopyWith<$Res> {
factory $NodeInfoCopyWith(NodeInfo value, $Res Function(NodeInfo) _then) = _$NodeInfoCopyWithImpl;
@useResult
$Res call({
String hostname, String? description,@JsonKey(name: 'update_history') List<UpdateRecord> updateHistory, BackupConfig backup,@JsonKey(name: 'restic_password_set') bool resticPasswordSet,@JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword
});
$BackupConfigCopyWith<$Res> get backup;
}
/// @nodoc
class _$NodeInfoCopyWithImpl<$Res>
implements $NodeInfoCopyWith<$Res> {
_$NodeInfoCopyWithImpl(this._self, this._then);
final NodeInfo _self;
final $Res Function(NodeInfo) _then;
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? hostname = null,Object? description = freezed,Object? updateHistory = null,Object? backup = null,Object? resticPasswordSet = null,Object? resticPassword = freezed,}) {
return _then(_self.copyWith(
hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,updateHistory: null == updateHistory ? _self.updateHistory : updateHistory // ignore: cast_nullable_to_non_nullable
as List<UpdateRecord>,backup: null == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable
as BackupConfig,resticPasswordSet: null == resticPasswordSet ? _self.resticPasswordSet : resticPasswordSet // ignore: cast_nullable_to_non_nullable
as bool,resticPassword: freezed == resticPassword ? _self.resticPassword : resticPassword // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BackupConfigCopyWith<$Res> get backup {
return $BackupConfigCopyWith<$Res>(_self.backup, (value) {
return _then(_self.copyWith(backup: value));
});
}
}
/// Adds pattern-matching-related methods to [NodeInfo].
extension NodeInfoPatterns on NodeInfo {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _NodeInfo value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _NodeInfo() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _NodeInfo value) $default,){
final _that = this;
switch (_that) {
case _NodeInfo():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _NodeInfo value)? $default,){
final _that = this;
switch (_that) {
case _NodeInfo() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String hostname, String? description, @JsonKey(name: 'update_history') List<UpdateRecord> updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _NodeInfo() when $default != null:
return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String hostname, String? description, @JsonKey(name: 'update_history') List<UpdateRecord> updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword) $default,) {final _that = this;
switch (_that) {
case _NodeInfo():
return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String hostname, String? description, @JsonKey(name: 'update_history') List<UpdateRecord> updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword)? $default,) {final _that = this;
switch (_that) {
case _NodeInfo() when $default != null:
return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _NodeInfo implements NodeInfo {
const _NodeInfo({required this.hostname, this.description, @JsonKey(name: 'update_history') final List<UpdateRecord> updateHistory = const <UpdateRecord>[], required this.backup, @JsonKey(name: 'restic_password_set') this.resticPasswordSet = false, @JsonKey(name: 'restic_password', includeIfNull: false) this.resticPassword}): _updateHistory = updateHistory;
factory _NodeInfo.fromJson(Map<String, dynamic> json) => _$NodeInfoFromJson(json);
@override final String hostname;
@override final String? description;
final List<UpdateRecord> _updateHistory;
@override@JsonKey(name: 'update_history') List<UpdateRecord> get updateHistory {
if (_updateHistory is EqualUnmodifiableListView) return _updateHistory;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_updateHistory);
}
@override final BackupConfig backup;
@override@JsonKey(name: 'restic_password_set') final bool resticPasswordSet;
@override@JsonKey(name: 'restic_password', includeIfNull: false) final String? resticPassword;
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$NodeInfoCopyWith<_NodeInfo> get copyWith => __$NodeInfoCopyWithImpl<_NodeInfo>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$NodeInfoToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NodeInfo&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._updateHistory, _updateHistory)&&(identical(other.backup, backup) || other.backup == backup)&&(identical(other.resticPasswordSet, resticPasswordSet) || other.resticPasswordSet == resticPasswordSet)&&(identical(other.resticPassword, resticPassword) || other.resticPassword == resticPassword));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,hostname,description,const DeepCollectionEquality().hash(_updateHistory),backup,resticPasswordSet,resticPassword);
@override
String toString() {
return 'NodeInfo(hostname: $hostname, description: $description, updateHistory: $updateHistory, backup: $backup, resticPasswordSet: $resticPasswordSet, resticPassword: $resticPassword)';
}
}
/// @nodoc
abstract mixin class _$NodeInfoCopyWith<$Res> implements $NodeInfoCopyWith<$Res> {
factory _$NodeInfoCopyWith(_NodeInfo value, $Res Function(_NodeInfo) _then) = __$NodeInfoCopyWithImpl;
@override @useResult
$Res call({
String hostname, String? description,@JsonKey(name: 'update_history') List<UpdateRecord> updateHistory, BackupConfig backup,@JsonKey(name: 'restic_password_set') bool resticPasswordSet,@JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword
});
@override $BackupConfigCopyWith<$Res> get backup;
}
/// @nodoc
class __$NodeInfoCopyWithImpl<$Res>
implements _$NodeInfoCopyWith<$Res> {
__$NodeInfoCopyWithImpl(this._self, this._then);
final _NodeInfo _self;
final $Res Function(_NodeInfo) _then;
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? hostname = null,Object? description = freezed,Object? updateHistory = null,Object? backup = null,Object? resticPasswordSet = null,Object? resticPassword = freezed,}) {
return _then(_NodeInfo(
hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,updateHistory: null == updateHistory ? _self._updateHistory : updateHistory // ignore: cast_nullable_to_non_nullable
as List<UpdateRecord>,backup: null == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable
as BackupConfig,resticPasswordSet: null == resticPasswordSet ? _self.resticPasswordSet : resticPasswordSet // ignore: cast_nullable_to_non_nullable
as bool,resticPassword: freezed == resticPassword ? _self.resticPassword : resticPassword // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of NodeInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BackupConfigCopyWith<$Res> get backup {
return $BackupConfigCopyWith<$Res>(_self.backup, (value) {
return _then(_self.copyWith(backup: value));
});
}
}
// dart format on
+29
View File
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'node_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_NodeInfo _$NodeInfoFromJson(Map<String, dynamic> json) => _NodeInfo(
hostname: json['hostname'] as String,
description: json['description'] as String?,
updateHistory:
(json['update_history'] as List<dynamic>?)
?.map((e) => UpdateRecord.fromJson(e as Map<String, dynamic>))
.toList() ??
const <UpdateRecord>[],
backup: BackupConfig.fromJson(json['backup'] as Map<String, dynamic>),
resticPasswordSet: json['restic_password_set'] as bool? ?? false,
resticPassword: json['restic_password'] as String?,
);
Map<String, dynamic> _$NodeInfoToJson(_NodeInfo instance) => <String, dynamic>{
'hostname': instance.hostname,
'description': instance.description,
'update_history': instance.updateHistory.map((e) => e.toJson()).toList(),
'backup': instance.backup.toJson(),
'restic_password_set': instance.resticPasswordSet,
'restic_password': ?instance.resticPassword,
};
+21
View File
@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'remote_node.freezed.dart';
part 'remote_node.g.dart';
/// Mirrors `models.RemoteNode` — an entry in this node's registry of
/// sibling NodeMaster hosts (`/nodes/*`).
@freezed
sealed class RemoteNode with _$RemoteNode {
const factory RemoteNode({
@Default('') String id,
required String name,
required String url,
@Default('') String status,
String? description,
@Default(<String>[]) List<String> tags,
@JsonKey(name: 'last_seen') DateTime? lastSeen,
}) = _RemoteNode;
factory RemoteNode.fromJson(Map<String, dynamic> json) => _$RemoteNodeFromJson(json);
}
+295
View File
@@ -0,0 +1,295 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'remote_node.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RemoteNode {
String get id; String get name; String get url; String get status; String? get description; List<String> get tags;@JsonKey(name: 'last_seen') DateTime? get lastSeen;
/// Create a copy of RemoteNode
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RemoteNodeCopyWith<RemoteNode> get copyWith => _$RemoteNodeCopyWithImpl<RemoteNode>(this as RemoteNode, _$identity);
/// Serializes this RemoteNode to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RemoteNode&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)&&(identical(other.status, status) || other.status == status)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.lastSeen, lastSeen) || other.lastSeen == lastSeen));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,url,status,description,const DeepCollectionEquality().hash(tags),lastSeen);
@override
String toString() {
return 'RemoteNode(id: $id, name: $name, url: $url, status: $status, description: $description, tags: $tags, lastSeen: $lastSeen)';
}
}
/// @nodoc
abstract mixin class $RemoteNodeCopyWith<$Res> {
factory $RemoteNodeCopyWith(RemoteNode value, $Res Function(RemoteNode) _then) = _$RemoteNodeCopyWithImpl;
@useResult
$Res call({
String id, String name, String url, String status, String? description, List<String> tags,@JsonKey(name: 'last_seen') DateTime? lastSeen
});
}
/// @nodoc
class _$RemoteNodeCopyWithImpl<$Res>
implements $RemoteNodeCopyWith<$Res> {
_$RemoteNodeCopyWithImpl(this._self, this._then);
final RemoteNode _self;
final $Res Function(RemoteNode) _then;
/// Create a copy of RemoteNode
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? url = null,Object? status = null,Object? description = freezed,Object? tags = null,Object? lastSeen = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as List<String>,lastSeen: freezed == lastSeen ? _self.lastSeen : lastSeen // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// Adds pattern-matching-related methods to [RemoteNode].
extension RemoteNodePatterns on RemoteNode {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RemoteNode value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RemoteNode() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RemoteNode value) $default,){
final _that = this;
switch (_that) {
case _RemoteNode():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RemoteNode value)? $default,){
final _that = this;
switch (_that) {
case _RemoteNode() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String url, String status, String? description, List<String> tags, @JsonKey(name: 'last_seen') DateTime? lastSeen)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RemoteNode() when $default != null:
return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String url, String status, String? description, List<String> tags, @JsonKey(name: 'last_seen') DateTime? lastSeen) $default,) {final _that = this;
switch (_that) {
case _RemoteNode():
return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String url, String status, String? description, List<String> tags, @JsonKey(name: 'last_seen') DateTime? lastSeen)? $default,) {final _that = this;
switch (_that) {
case _RemoteNode() when $default != null:
return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RemoteNode implements RemoteNode {
const _RemoteNode({this.id = '', required this.name, required this.url, this.status = '', this.description, final List<String> tags = const <String>[], @JsonKey(name: 'last_seen') this.lastSeen}): _tags = tags;
factory _RemoteNode.fromJson(Map<String, dynamic> json) => _$RemoteNodeFromJson(json);
@override@JsonKey() final String id;
@override final String name;
@override final String url;
@override@JsonKey() final String status;
@override final String? description;
final List<String> _tags;
@override@JsonKey() List<String> get tags {
if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags);
}
@override@JsonKey(name: 'last_seen') final DateTime? lastSeen;
/// Create a copy of RemoteNode
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RemoteNodeCopyWith<_RemoteNode> get copyWith => __$RemoteNodeCopyWithImpl<_RemoteNode>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RemoteNodeToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RemoteNode&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)&&(identical(other.status, status) || other.status == status)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.lastSeen, lastSeen) || other.lastSeen == lastSeen));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,url,status,description,const DeepCollectionEquality().hash(_tags),lastSeen);
@override
String toString() {
return 'RemoteNode(id: $id, name: $name, url: $url, status: $status, description: $description, tags: $tags, lastSeen: $lastSeen)';
}
}
/// @nodoc
abstract mixin class _$RemoteNodeCopyWith<$Res> implements $RemoteNodeCopyWith<$Res> {
factory _$RemoteNodeCopyWith(_RemoteNode value, $Res Function(_RemoteNode) _then) = __$RemoteNodeCopyWithImpl;
@override @useResult
$Res call({
String id, String name, String url, String status, String? description, List<String> tags,@JsonKey(name: 'last_seen') DateTime? lastSeen
});
}
/// @nodoc
class __$RemoteNodeCopyWithImpl<$Res>
implements _$RemoteNodeCopyWith<$Res> {
__$RemoteNodeCopyWithImpl(this._self, this._then);
final _RemoteNode _self;
final $Res Function(_RemoteNode) _then;
/// Create a copy of RemoteNode
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? url = null,Object? status = null,Object? description = freezed,Object? tags = null,Object? lastSeen = freezed,}) {
return _then(_RemoteNode(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as List<String>,lastSeen: freezed == lastSeen ? _self.lastSeen : lastSeen // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
// dart format on
+32
View File
@@ -0,0 +1,32 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'remote_node.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_RemoteNode _$RemoteNodeFromJson(Map<String, dynamic> json) => _RemoteNode(
id: json['id'] as String? ?? '',
name: json['name'] as String,
url: json['url'] as String,
status: json['status'] as String? ?? '',
description: json['description'] as String?,
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const <String>[],
lastSeen: json['last_seen'] == null
? null
: DateTime.parse(json['last_seen'] as String),
);
Map<String, dynamic> _$RemoteNodeToJson(_RemoteNode instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'url': instance.url,
'status': instance.status,
'description': instance.description,
'tags': instance.tags,
'last_seen': instance.lastSeen?.toIso8601String(),
};
+24
View File
@@ -0,0 +1,24 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'backup_method.dart';
part 'run_state.freezed.dart';
part 'run_state.g.dart';
/// Mirrors `models.RunState` — the live (in-memory, non-persisted) progress
/// of a single backup target's current or most recent run. Polled from
/// `GET /.../backup/targets/:tid/progress` while a run is in flight.
@freezed
sealed class RunState with _$RunState {
const factory RunState({
@Default(false) bool running,
@JsonKey(name: 'target_id') @Default('') String targetId,
BackupMethod? method,
@Default(0) double percent,
String? eta,
String? message,
@JsonKey(name: 'started_at') DateTime? startedAt,
}) = _RunState;
factory RunState.fromJson(Map<String, dynamic> json) => _$RunStateFromJson(json);
}
+289
View File
@@ -0,0 +1,289 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'run_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RunState {
bool get running;@JsonKey(name: 'target_id') String get targetId; BackupMethod? get method; double get percent; String? get eta; String? get message;@JsonKey(name: 'started_at') DateTime? get startedAt;
/// Create a copy of RunState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RunStateCopyWith<RunState> get copyWith => _$RunStateCopyWithImpl<RunState>(this as RunState, _$identity);
/// Serializes this RunState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RunState&&(identical(other.running, running) || other.running == running)&&(identical(other.targetId, targetId) || other.targetId == targetId)&&(identical(other.method, method) || other.method == method)&&(identical(other.percent, percent) || other.percent == percent)&&(identical(other.eta, eta) || other.eta == eta)&&(identical(other.message, message) || other.message == message)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,running,targetId,method,percent,eta,message,startedAt);
@override
String toString() {
return 'RunState(running: $running, targetId: $targetId, method: $method, percent: $percent, eta: $eta, message: $message, startedAt: $startedAt)';
}
}
/// @nodoc
abstract mixin class $RunStateCopyWith<$Res> {
factory $RunStateCopyWith(RunState value, $Res Function(RunState) _then) = _$RunStateCopyWithImpl;
@useResult
$Res call({
bool running,@JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message,@JsonKey(name: 'started_at') DateTime? startedAt
});
}
/// @nodoc
class _$RunStateCopyWithImpl<$Res>
implements $RunStateCopyWith<$Res> {
_$RunStateCopyWithImpl(this._self, this._then);
final RunState _self;
final $Res Function(RunState) _then;
/// Create a copy of RunState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? running = null,Object? targetId = null,Object? method = freezed,Object? percent = null,Object? eta = freezed,Object? message = freezed,Object? startedAt = freezed,}) {
return _then(_self.copyWith(
running: null == running ? _self.running : running // ignore: cast_nullable_to_non_nullable
as bool,targetId: null == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable
as String,method: freezed == method ? _self.method : method // ignore: cast_nullable_to_non_nullable
as BackupMethod?,percent: null == percent ? _self.percent : percent // ignore: cast_nullable_to_non_nullable
as double,eta: freezed == eta ? _self.eta : eta // ignore: cast_nullable_to_non_nullable
as String?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// Adds pattern-matching-related methods to [RunState].
extension RunStatePatterns on RunState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RunState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RunState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RunState value) $default,){
final _that = this;
switch (_that) {
case _RunState():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RunState value)? $default,){
final _that = this;
switch (_that) {
case _RunState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RunState() when $default != null:
return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt) $default,) {final _that = this;
switch (_that) {
case _RunState():
return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt)? $default,) {final _that = this;
switch (_that) {
case _RunState() when $default != null:
return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RunState implements RunState {
const _RunState({this.running = false, @JsonKey(name: 'target_id') this.targetId = '', this.method, this.percent = 0, this.eta, this.message, @JsonKey(name: 'started_at') this.startedAt});
factory _RunState.fromJson(Map<String, dynamic> json) => _$RunStateFromJson(json);
@override@JsonKey() final bool running;
@override@JsonKey(name: 'target_id') final String targetId;
@override final BackupMethod? method;
@override@JsonKey() final double percent;
@override final String? eta;
@override final String? message;
@override@JsonKey(name: 'started_at') final DateTime? startedAt;
/// Create a copy of RunState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RunStateCopyWith<_RunState> get copyWith => __$RunStateCopyWithImpl<_RunState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RunStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RunState&&(identical(other.running, running) || other.running == running)&&(identical(other.targetId, targetId) || other.targetId == targetId)&&(identical(other.method, method) || other.method == method)&&(identical(other.percent, percent) || other.percent == percent)&&(identical(other.eta, eta) || other.eta == eta)&&(identical(other.message, message) || other.message == message)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,running,targetId,method,percent,eta,message,startedAt);
@override
String toString() {
return 'RunState(running: $running, targetId: $targetId, method: $method, percent: $percent, eta: $eta, message: $message, startedAt: $startedAt)';
}
}
/// @nodoc
abstract mixin class _$RunStateCopyWith<$Res> implements $RunStateCopyWith<$Res> {
factory _$RunStateCopyWith(_RunState value, $Res Function(_RunState) _then) = __$RunStateCopyWithImpl;
@override @useResult
$Res call({
bool running,@JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message,@JsonKey(name: 'started_at') DateTime? startedAt
});
}
/// @nodoc
class __$RunStateCopyWithImpl<$Res>
implements _$RunStateCopyWith<$Res> {
__$RunStateCopyWithImpl(this._self, this._then);
final _RunState _self;
final $Res Function(_RunState) _then;
/// Create a copy of RunState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? running = null,Object? targetId = null,Object? method = freezed,Object? percent = null,Object? eta = freezed,Object? message = freezed,Object? startedAt = freezed,}) {
return _then(_RunState(
running: null == running ? _self.running : running // ignore: cast_nullable_to_non_nullable
as bool,targetId: null == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable
as String,method: freezed == method ? _self.method : method // ignore: cast_nullable_to_non_nullable
as BackupMethod?,percent: null == percent ? _self.percent : percent // ignore: cast_nullable_to_non_nullable
as double,eta: freezed == eta ? _self.eta : eta // ignore: cast_nullable_to_non_nullable
as String?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
// dart format on
+35
View File
@@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'run_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_RunState _$RunStateFromJson(Map<String, dynamic> json) => _RunState(
running: json['running'] as bool? ?? false,
targetId: json['target_id'] as String? ?? '',
method: $enumDecodeNullable(_$BackupMethodEnumMap, json['method']),
percent: (json['percent'] as num?)?.toDouble() ?? 0,
eta: json['eta'] as String?,
message: json['message'] as String?,
startedAt: json['started_at'] == null
? null
: DateTime.parse(json['started_at'] as String),
);
Map<String, dynamic> _$RunStateToJson(_RunState instance) => <String, dynamic>{
'running': instance.running,
'target_id': instance.targetId,
'method': _$BackupMethodEnumMap[instance.method],
'percent': instance.percent,
'eta': instance.eta,
'message': instance.message,
'started_at': instance.startedAt?.toIso8601String(),
};
const _$BackupMethodEnumMap = {
BackupMethod.rsync: 'rsync',
BackupMethod.restic: 'restic',
BackupMethod.external: 'external',
};
+20
View File
@@ -0,0 +1,20 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'service_scan_info.dart';
part 'scan_result.freezed.dart';
part 'scan_result.g.dart';
/// Mirrors `models.ScanResult`, returned by `POST /node/scan`.
@freezed
sealed class ScanResult with _$ScanResult {
const factory ScanResult({
@JsonKey(name: 'scanned_paths') @Default(<String>[]) List<String> scannedPaths,
@Default(0) int found,
@Default(<ServiceScanInfo>[]) List<ServiceScanInfo> created,
@Default(<ServiceScanInfo>[]) List<ServiceScanInfo> updated,
@Default(<String>[]) List<String> errors,
}) = _ScanResult;
factory ScanResult.fromJson(Map<String, dynamic> json) => _$ScanResultFromJson(json);
}
+307
View File
@@ -0,0 +1,307 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'scan_result.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ScanResult {
@JsonKey(name: 'scanned_paths') List<String> get scannedPaths; int get found; List<ServiceScanInfo> get created; List<ServiceScanInfo> get updated; List<String> get errors;
/// Create a copy of ScanResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ScanResultCopyWith<ScanResult> get copyWith => _$ScanResultCopyWithImpl<ScanResult>(this as ScanResult, _$identity);
/// Serializes this ScanResult to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ScanResult&&const DeepCollectionEquality().equals(other.scannedPaths, scannedPaths)&&(identical(other.found, found) || other.found == found)&&const DeepCollectionEquality().equals(other.created, created)&&const DeepCollectionEquality().equals(other.updated, updated)&&const DeepCollectionEquality().equals(other.errors, errors));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(scannedPaths),found,const DeepCollectionEquality().hash(created),const DeepCollectionEquality().hash(updated),const DeepCollectionEquality().hash(errors));
@override
String toString() {
return 'ScanResult(scannedPaths: $scannedPaths, found: $found, created: $created, updated: $updated, errors: $errors)';
}
}
/// @nodoc
abstract mixin class $ScanResultCopyWith<$Res> {
factory $ScanResultCopyWith(ScanResult value, $Res Function(ScanResult) _then) = _$ScanResultCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'scanned_paths') List<String> scannedPaths, int found, List<ServiceScanInfo> created, List<ServiceScanInfo> updated, List<String> errors
});
}
/// @nodoc
class _$ScanResultCopyWithImpl<$Res>
implements $ScanResultCopyWith<$Res> {
_$ScanResultCopyWithImpl(this._self, this._then);
final ScanResult _self;
final $Res Function(ScanResult) _then;
/// Create a copy of ScanResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? scannedPaths = null,Object? found = null,Object? created = null,Object? updated = null,Object? errors = null,}) {
return _then(_self.copyWith(
scannedPaths: null == scannedPaths ? _self.scannedPaths : scannedPaths // ignore: cast_nullable_to_non_nullable
as List<String>,found: null == found ? _self.found : found // ignore: cast_nullable_to_non_nullable
as int,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable
as List<ServiceScanInfo>,updated: null == updated ? _self.updated : updated // ignore: cast_nullable_to_non_nullable
as List<ServiceScanInfo>,errors: null == errors ? _self.errors : errors // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// Adds pattern-matching-related methods to [ScanResult].
extension ScanResultPatterns on ScanResult {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ScanResult value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ScanResult() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ScanResult value) $default,){
final _that = this;
switch (_that) {
case _ScanResult():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ScanResult value)? $default,){
final _that = this;
switch (_that) {
case _ScanResult() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'scanned_paths') List<String> scannedPaths, int found, List<ServiceScanInfo> created, List<ServiceScanInfo> updated, List<String> errors)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ScanResult() when $default != null:
return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'scanned_paths') List<String> scannedPaths, int found, List<ServiceScanInfo> created, List<ServiceScanInfo> updated, List<String> errors) $default,) {final _that = this;
switch (_that) {
case _ScanResult():
return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'scanned_paths') List<String> scannedPaths, int found, List<ServiceScanInfo> created, List<ServiceScanInfo> updated, List<String> errors)? $default,) {final _that = this;
switch (_that) {
case _ScanResult() when $default != null:
return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ScanResult implements ScanResult {
const _ScanResult({@JsonKey(name: 'scanned_paths') final List<String> scannedPaths = const <String>[], this.found = 0, final List<ServiceScanInfo> created = const <ServiceScanInfo>[], final List<ServiceScanInfo> updated = const <ServiceScanInfo>[], final List<String> errors = const <String>[]}): _scannedPaths = scannedPaths,_created = created,_updated = updated,_errors = errors;
factory _ScanResult.fromJson(Map<String, dynamic> json) => _$ScanResultFromJson(json);
final List<String> _scannedPaths;
@override@JsonKey(name: 'scanned_paths') List<String> get scannedPaths {
if (_scannedPaths is EqualUnmodifiableListView) return _scannedPaths;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_scannedPaths);
}
@override@JsonKey() final int found;
final List<ServiceScanInfo> _created;
@override@JsonKey() List<ServiceScanInfo> get created {
if (_created is EqualUnmodifiableListView) return _created;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_created);
}
final List<ServiceScanInfo> _updated;
@override@JsonKey() List<ServiceScanInfo> get updated {
if (_updated is EqualUnmodifiableListView) return _updated;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_updated);
}
final List<String> _errors;
@override@JsonKey() List<String> get errors {
if (_errors is EqualUnmodifiableListView) return _errors;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_errors);
}
/// Create a copy of ScanResult
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ScanResultCopyWith<_ScanResult> get copyWith => __$ScanResultCopyWithImpl<_ScanResult>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ScanResultToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScanResult&&const DeepCollectionEquality().equals(other._scannedPaths, _scannedPaths)&&(identical(other.found, found) || other.found == found)&&const DeepCollectionEquality().equals(other._created, _created)&&const DeepCollectionEquality().equals(other._updated, _updated)&&const DeepCollectionEquality().equals(other._errors, _errors));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_scannedPaths),found,const DeepCollectionEquality().hash(_created),const DeepCollectionEquality().hash(_updated),const DeepCollectionEquality().hash(_errors));
@override
String toString() {
return 'ScanResult(scannedPaths: $scannedPaths, found: $found, created: $created, updated: $updated, errors: $errors)';
}
}
/// @nodoc
abstract mixin class _$ScanResultCopyWith<$Res> implements $ScanResultCopyWith<$Res> {
factory _$ScanResultCopyWith(_ScanResult value, $Res Function(_ScanResult) _then) = __$ScanResultCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'scanned_paths') List<String> scannedPaths, int found, List<ServiceScanInfo> created, List<ServiceScanInfo> updated, List<String> errors
});
}
/// @nodoc
class __$ScanResultCopyWithImpl<$Res>
implements _$ScanResultCopyWith<$Res> {
__$ScanResultCopyWithImpl(this._self, this._then);
final _ScanResult _self;
final $Res Function(_ScanResult) _then;
/// Create a copy of ScanResult
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? scannedPaths = null,Object? found = null,Object? created = null,Object? updated = null,Object? errors = null,}) {
return _then(_ScanResult(
scannedPaths: null == scannedPaths ? _self._scannedPaths : scannedPaths // ignore: cast_nullable_to_non_nullable
as List<String>,found: null == found ? _self.found : found // ignore: cast_nullable_to_non_nullable
as int,created: null == created ? _self._created : created // ignore: cast_nullable_to_non_nullable
as List<ServiceScanInfo>,updated: null == updated ? _self._updated : updated // ignore: cast_nullable_to_non_nullable
as List<ServiceScanInfo>,errors: null == errors ? _self._errors : errors // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
// dart format on
+38
View File
@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scan_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ScanResult _$ScanResultFromJson(Map<String, dynamic> json) => _ScanResult(
scannedPaths:
(json['scanned_paths'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const <String>[],
found: (json['found'] as num?)?.toInt() ?? 0,
created:
(json['created'] as List<dynamic>?)
?.map((e) => ServiceScanInfo.fromJson(e as Map<String, dynamic>))
.toList() ??
const <ServiceScanInfo>[],
updated:
(json['updated'] as List<dynamic>?)
?.map((e) => ServiceScanInfo.fromJson(e as Map<String, dynamic>))
.toList() ??
const <ServiceScanInfo>[],
errors:
(json['errors'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const <String>[],
);
Map<String, dynamic> _$ScanResultToJson(_ScanResult instance) =>
<String, dynamic>{
'scanned_paths': instance.scannedPaths,
'found': instance.found,
'created': instance.created.map((e) => e.toJson()).toList(),
'updated': instance.updated.map((e) => e.toJson()).toList(),
'errors': instance.errors,
};
+29
View File
@@ -0,0 +1,29 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'backup_config.dart';
part 'service.freezed.dart';
part 'service.g.dart';
/// Mirrors `models.Service` — a compose-based service on this host.
/// `status` is one of "up" / "down" / "unknown" (untyped string on the Go
/// side); kept as a raw string here too and mapped to [Severity] at the
/// widget boundary rather than modeled as a closed enum, since the server
/// can emit values this client doesn't yet know about.
@freezed
sealed class Service with _$Service {
const factory Service({
@Default('') String id,
required String name,
@JsonKey(name: 'compose_file') required String composeFile,
@JsonKey(name: 'compose_type') String? composeType,
@JsonKey(name: 'backup_folder') String? backupFolder,
@Default('unknown') String status,
@JsonKey(name: 'stop_on_backup') @Default(false) bool stopOnBackup,
@JsonKey(name: 'external_backup_stop_minutes') @Default(0) int externalBackupStopMinutes,
@JsonKey(name: 'external_backup_start_minutes') @Default(0) int externalBackupStartMinutes,
BackupConfig? backup,
}) = _Service;
factory Service.fromJson(Map<String, dynamic> json) => _$ServiceFromJson(json);
}
+322
View File
@@ -0,0 +1,322 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'service.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Service {
String get id; String get name;@JsonKey(name: 'compose_file') String get composeFile;@JsonKey(name: 'compose_type') String? get composeType;@JsonKey(name: 'backup_folder') String? get backupFolder; String get status;@JsonKey(name: 'stop_on_backup') bool get stopOnBackup;@JsonKey(name: 'external_backup_stop_minutes') int get externalBackupStopMinutes;@JsonKey(name: 'external_backup_start_minutes') int get externalBackupStartMinutes; BackupConfig? get backup;
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServiceCopyWith<Service> get copyWith => _$ServiceCopyWithImpl<Service>(this as Service, _$identity);
/// Serializes this Service to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Service&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.composeType, composeType) || other.composeType == composeType)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)&&(identical(other.stopOnBackup, stopOnBackup) || other.stopOnBackup == stopOnBackup)&&(identical(other.externalBackupStopMinutes, externalBackupStopMinutes) || other.externalBackupStopMinutes == externalBackupStopMinutes)&&(identical(other.externalBackupStartMinutes, externalBackupStartMinutes) || other.externalBackupStartMinutes == externalBackupStartMinutes)&&(identical(other.backup, backup) || other.backup == backup));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,composeFile,composeType,backupFolder,status,stopOnBackup,externalBackupStopMinutes,externalBackupStartMinutes,backup);
@override
String toString() {
return 'Service(id: $id, name: $name, composeFile: $composeFile, composeType: $composeType, backupFolder: $backupFolder, status: $status, stopOnBackup: $stopOnBackup, externalBackupStopMinutes: $externalBackupStopMinutes, externalBackupStartMinutes: $externalBackupStartMinutes, backup: $backup)';
}
}
/// @nodoc
abstract mixin class $ServiceCopyWith<$Res> {
factory $ServiceCopyWith(Service value, $Res Function(Service) _then) = _$ServiceCopyWithImpl;
@useResult
$Res call({
String id, String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'compose_type') String? composeType,@JsonKey(name: 'backup_folder') String? backupFolder, String status,@JsonKey(name: 'stop_on_backup') bool stopOnBackup,@JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes,@JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup
});
$BackupConfigCopyWith<$Res>? get backup;
}
/// @nodoc
class _$ServiceCopyWithImpl<$Res>
implements $ServiceCopyWith<$Res> {
_$ServiceCopyWithImpl(this._self, this._then);
final Service _self;
final $Res Function(Service) _then;
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? composeFile = null,Object? composeType = freezed,Object? backupFolder = freezed,Object? status = null,Object? stopOnBackup = null,Object? externalBackupStopMinutes = null,Object? externalBackupStartMinutes = null,Object? backup = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable
as String,composeType: freezed == composeType ? _self.composeType : composeType // ignore: cast_nullable_to_non_nullable
as String?,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,stopOnBackup: null == stopOnBackup ? _self.stopOnBackup : stopOnBackup // ignore: cast_nullable_to_non_nullable
as bool,externalBackupStopMinutes: null == externalBackupStopMinutes ? _self.externalBackupStopMinutes : externalBackupStopMinutes // ignore: cast_nullable_to_non_nullable
as int,externalBackupStartMinutes: null == externalBackupStartMinutes ? _self.externalBackupStartMinutes : externalBackupStartMinutes // ignore: cast_nullable_to_non_nullable
as int,backup: freezed == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable
as BackupConfig?,
));
}
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BackupConfigCopyWith<$Res>? get backup {
if (_self.backup == null) {
return null;
}
return $BackupConfigCopyWith<$Res>(_self.backup!, (value) {
return _then(_self.copyWith(backup: value));
});
}
}
/// Adds pattern-matching-related methods to [Service].
extension ServicePatterns on Service {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Service value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Service() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Service value) $default,){
final _that = this;
switch (_that) {
case _Service():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Service value)? $default,){
final _that = this;
switch (_that) {
case _Service() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Service() when $default != null:
return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup) $default,) {final _that = this;
switch (_that) {
case _Service():
return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup)? $default,) {final _that = this;
switch (_that) {
case _Service() when $default != null:
return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _Service implements Service {
const _Service({this.id = '', required this.name, @JsonKey(name: 'compose_file') required this.composeFile, @JsonKey(name: 'compose_type') this.composeType, @JsonKey(name: 'backup_folder') this.backupFolder, this.status = 'unknown', @JsonKey(name: 'stop_on_backup') this.stopOnBackup = false, @JsonKey(name: 'external_backup_stop_minutes') this.externalBackupStopMinutes = 0, @JsonKey(name: 'external_backup_start_minutes') this.externalBackupStartMinutes = 0, this.backup});
factory _Service.fromJson(Map<String, dynamic> json) => _$ServiceFromJson(json);
@override@JsonKey() final String id;
@override final String name;
@override@JsonKey(name: 'compose_file') final String composeFile;
@override@JsonKey(name: 'compose_type') final String? composeType;
@override@JsonKey(name: 'backup_folder') final String? backupFolder;
@override@JsonKey() final String status;
@override@JsonKey(name: 'stop_on_backup') final bool stopOnBackup;
@override@JsonKey(name: 'external_backup_stop_minutes') final int externalBackupStopMinutes;
@override@JsonKey(name: 'external_backup_start_minutes') final int externalBackupStartMinutes;
@override final BackupConfig? backup;
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServiceCopyWith<_Service> get copyWith => __$ServiceCopyWithImpl<_Service>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ServiceToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Service&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.composeType, composeType) || other.composeType == composeType)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)&&(identical(other.stopOnBackup, stopOnBackup) || other.stopOnBackup == stopOnBackup)&&(identical(other.externalBackupStopMinutes, externalBackupStopMinutes) || other.externalBackupStopMinutes == externalBackupStopMinutes)&&(identical(other.externalBackupStartMinutes, externalBackupStartMinutes) || other.externalBackupStartMinutes == externalBackupStartMinutes)&&(identical(other.backup, backup) || other.backup == backup));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,composeFile,composeType,backupFolder,status,stopOnBackup,externalBackupStopMinutes,externalBackupStartMinutes,backup);
@override
String toString() {
return 'Service(id: $id, name: $name, composeFile: $composeFile, composeType: $composeType, backupFolder: $backupFolder, status: $status, stopOnBackup: $stopOnBackup, externalBackupStopMinutes: $externalBackupStopMinutes, externalBackupStartMinutes: $externalBackupStartMinutes, backup: $backup)';
}
}
/// @nodoc
abstract mixin class _$ServiceCopyWith<$Res> implements $ServiceCopyWith<$Res> {
factory _$ServiceCopyWith(_Service value, $Res Function(_Service) _then) = __$ServiceCopyWithImpl;
@override @useResult
$Res call({
String id, String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'compose_type') String? composeType,@JsonKey(name: 'backup_folder') String? backupFolder, String status,@JsonKey(name: 'stop_on_backup') bool stopOnBackup,@JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes,@JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup
});
@override $BackupConfigCopyWith<$Res>? get backup;
}
/// @nodoc
class __$ServiceCopyWithImpl<$Res>
implements _$ServiceCopyWith<$Res> {
__$ServiceCopyWithImpl(this._self, this._then);
final _Service _self;
final $Res Function(_Service) _then;
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? composeFile = null,Object? composeType = freezed,Object? backupFolder = freezed,Object? status = null,Object? stopOnBackup = null,Object? externalBackupStopMinutes = null,Object? externalBackupStartMinutes = null,Object? backup = freezed,}) {
return _then(_Service(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable
as String,composeType: freezed == composeType ? _self.composeType : composeType // ignore: cast_nullable_to_non_nullable
as String?,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,stopOnBackup: null == stopOnBackup ? _self.stopOnBackup : stopOnBackup // ignore: cast_nullable_to_non_nullable
as bool,externalBackupStopMinutes: null == externalBackupStopMinutes ? _self.externalBackupStopMinutes : externalBackupStopMinutes // ignore: cast_nullable_to_non_nullable
as int,externalBackupStartMinutes: null == externalBackupStartMinutes ? _self.externalBackupStartMinutes : externalBackupStartMinutes // ignore: cast_nullable_to_non_nullable
as int,backup: freezed == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable
as BackupConfig?,
));
}
/// Create a copy of Service
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BackupConfigCopyWith<$Res>? get backup {
if (_self.backup == null) {
return null;
}
return $BackupConfigCopyWith<$Res>(_self.backup!, (value) {
return _then(_self.copyWith(backup: value));
});
}
}
// dart format on
+37
View File
@@ -0,0 +1,37 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'service.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Service _$ServiceFromJson(Map<String, dynamic> json) => _Service(
id: json['id'] as String? ?? '',
name: json['name'] as String,
composeFile: json['compose_file'] as String,
composeType: json['compose_type'] as String?,
backupFolder: json['backup_folder'] as String?,
status: json['status'] as String? ?? 'unknown',
stopOnBackup: json['stop_on_backup'] as bool? ?? false,
externalBackupStopMinutes:
(json['external_backup_stop_minutes'] as num?)?.toInt() ?? 0,
externalBackupStartMinutes:
(json['external_backup_start_minutes'] as num?)?.toInt() ?? 0,
backup: json['backup'] == null
? null
: BackupConfig.fromJson(json['backup'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ServiceToJson(_Service instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'compose_file': instance.composeFile,
'compose_type': instance.composeType,
'backup_folder': instance.backupFolder,
'status': instance.status,
'stop_on_backup': instance.stopOnBackup,
'external_backup_stop_minutes': instance.externalBackupStopMinutes,
'external_backup_start_minutes': instance.externalBackupStartMinutes,
'backup': instance.backup?.toJson(),
};
+18
View File
@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'service_scan_info.freezed.dart';
part 'service_scan_info.g.dart';
/// Mirrors `models.ServiceScanInfo` — one entry in a [ScanResult]'s
/// `created`/`updated` lists.
@freezed
sealed class ServiceScanInfo with _$ServiceScanInfo {
const factory ServiceScanInfo({
required String name,
@JsonKey(name: 'compose_file') required String composeFile,
@JsonKey(name: 'backup_folder') String? backupFolder,
@Default('unknown') String status,
}) = _ServiceScanInfo;
factory ServiceScanInfo.fromJson(Map<String, dynamic> json) => _$ServiceScanInfoFromJson(json);
}
@@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'service_scan_info.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServiceScanInfo {
String get name;@JsonKey(name: 'compose_file') String get composeFile;@JsonKey(name: 'backup_folder') String? get backupFolder; String get status;
/// Create a copy of ServiceScanInfo
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServiceScanInfoCopyWith<ServiceScanInfo> get copyWith => _$ServiceScanInfoCopyWithImpl<ServiceScanInfo>(this as ServiceScanInfo, _$identity);
/// Serializes this ServiceScanInfo to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServiceScanInfo&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,composeFile,backupFolder,status);
@override
String toString() {
return 'ServiceScanInfo(name: $name, composeFile: $composeFile, backupFolder: $backupFolder, status: $status)';
}
}
/// @nodoc
abstract mixin class $ServiceScanInfoCopyWith<$Res> {
factory $ServiceScanInfoCopyWith(ServiceScanInfo value, $Res Function(ServiceScanInfo) _then) = _$ServiceScanInfoCopyWithImpl;
@useResult
$Res call({
String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'backup_folder') String? backupFolder, String status
});
}
/// @nodoc
class _$ServiceScanInfoCopyWithImpl<$Res>
implements $ServiceScanInfoCopyWith<$Res> {
_$ServiceScanInfoCopyWithImpl(this._self, this._then);
final ServiceScanInfo _self;
final $Res Function(ServiceScanInfo) _then;
/// Create a copy of ServiceScanInfo
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? composeFile = null,Object? backupFolder = freezed,Object? status = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable
as String,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [ServiceScanInfo].
extension ServiceScanInfoPatterns on ServiceScanInfo {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ServiceScanInfo value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServiceScanInfo() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ServiceScanInfo value) $default,){
final _that = this;
switch (_that) {
case _ServiceScanInfo():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ServiceScanInfo value)? $default,){
final _that = this;
switch (_that) {
case _ServiceScanInfo() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServiceScanInfo() when $default != null:
return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status) $default,) {final _that = this;
switch (_that) {
case _ServiceScanInfo():
return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status)? $default,) {final _that = this;
switch (_that) {
case _ServiceScanInfo() when $default != null:
return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ServiceScanInfo implements ServiceScanInfo {
const _ServiceScanInfo({required this.name, @JsonKey(name: 'compose_file') required this.composeFile, @JsonKey(name: 'backup_folder') this.backupFolder, this.status = 'unknown'});
factory _ServiceScanInfo.fromJson(Map<String, dynamic> json) => _$ServiceScanInfoFromJson(json);
@override final String name;
@override@JsonKey(name: 'compose_file') final String composeFile;
@override@JsonKey(name: 'backup_folder') final String? backupFolder;
@override@JsonKey() final String status;
/// Create a copy of ServiceScanInfo
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServiceScanInfoCopyWith<_ServiceScanInfo> get copyWith => __$ServiceScanInfoCopyWithImpl<_ServiceScanInfo>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ServiceScanInfoToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServiceScanInfo&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,composeFile,backupFolder,status);
@override
String toString() {
return 'ServiceScanInfo(name: $name, composeFile: $composeFile, backupFolder: $backupFolder, status: $status)';
}
}
/// @nodoc
abstract mixin class _$ServiceScanInfoCopyWith<$Res> implements $ServiceScanInfoCopyWith<$Res> {
factory _$ServiceScanInfoCopyWith(_ServiceScanInfo value, $Res Function(_ServiceScanInfo) _then) = __$ServiceScanInfoCopyWithImpl;
@override @useResult
$Res call({
String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'backup_folder') String? backupFolder, String status
});
}
/// @nodoc
class __$ServiceScanInfoCopyWithImpl<$Res>
implements _$ServiceScanInfoCopyWith<$Res> {
__$ServiceScanInfoCopyWithImpl(this._self, this._then);
final _ServiceScanInfo _self;
final $Res Function(_ServiceScanInfo) _then;
/// Create a copy of ServiceScanInfo
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? composeFile = null,Object? backupFolder = freezed,Object? status = null,}) {
return _then(_ServiceScanInfo(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable
as String,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
+23
View File
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'service_scan_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ServiceScanInfo _$ServiceScanInfoFromJson(Map<String, dynamic> json) =>
_ServiceScanInfo(
name: json['name'] as String,
composeFile: json['compose_file'] as String,
backupFolder: json['backup_folder'] as String?,
status: json['status'] as String? ?? 'unknown',
);
Map<String, dynamic> _$ServiceScanInfoToJson(_ServiceScanInfo instance) =>
<String, dynamic>{
'name': instance.name,
'compose_file': instance.composeFile,
'backup_folder': instance.backupFolder,
'status': instance.status,
};
+16
View File
@@ -0,0 +1,16 @@
import '../theme/status_colors.dart';
/// Maps the API's untyped status strings to a [Severity] for [StatusPill]
/// rendering. Centralized here (not per-screen) since `Service.status` is
/// rendered on both the Services table and Fleet's aggregated card grid.
Severity severityForServiceStatus(String status) => switch (status) {
'up' => Severity.good,
'down' => Severity.critical,
_ => Severity.warning, // "unknown" or anything this client doesn't recognize yet
};
/// Maps a backup/update execution's `success` flag to a [Severity].
Severity severityForSuccess(bool success) => success ? Severity.good : Severity.critical;
/// Maps a [RemoteNode.status]/reachability signal to a [Severity].
Severity severityForNodeReachable(bool reachable) => reachable ? Severity.good : Severity.critical;
+18
View File
@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'update_record.freezed.dart';
part 'update_record.g.dart';
/// Mirrors `models.UpdateRecord` — an OS package update log entry.
@freezed
sealed class UpdateRecord with _$UpdateRecord {
const factory UpdateRecord({
@Default('') String id,
required DateTime timestamp,
required bool success,
@Default(<String>[]) List<String> packages,
@Default('') String message,
}) = _UpdateRecord;
factory UpdateRecord.fromJson(Map<String, dynamic> json) => _$UpdateRecordFromJson(json);
}
+289
View File
@@ -0,0 +1,289 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'update_record.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UpdateRecord {
String get id; DateTime get timestamp; bool get success; List<String> get packages; String get message;
/// Create a copy of UpdateRecord
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UpdateRecordCopyWith<UpdateRecord> get copyWith => _$UpdateRecordCopyWithImpl<UpdateRecord>(this as UpdateRecord, _$identity);
/// Serializes this UpdateRecord to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.message, message) || other.message == message));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,timestamp,success,const DeepCollectionEquality().hash(packages),message);
@override
String toString() {
return 'UpdateRecord(id: $id, timestamp: $timestamp, success: $success, packages: $packages, message: $message)';
}
}
/// @nodoc
abstract mixin class $UpdateRecordCopyWith<$Res> {
factory $UpdateRecordCopyWith(UpdateRecord value, $Res Function(UpdateRecord) _then) = _$UpdateRecordCopyWithImpl;
@useResult
$Res call({
String id, DateTime timestamp, bool success, List<String> packages, String message
});
}
/// @nodoc
class _$UpdateRecordCopyWithImpl<$Res>
implements $UpdateRecordCopyWith<$Res> {
_$UpdateRecordCopyWithImpl(this._self, this._then);
final UpdateRecord _self;
final $Res Function(UpdateRecord) _then;
/// Create a copy of UpdateRecord
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? packages = null,Object? message = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable
as bool,packages: null == packages ? _self.packages : packages // ignore: cast_nullable_to_non_nullable
as List<String>,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [UpdateRecord].
extension UpdateRecordPatterns on UpdateRecord {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _UpdateRecord value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _UpdateRecord() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _UpdateRecord value) $default,){
final _that = this;
switch (_that) {
case _UpdateRecord():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _UpdateRecord value)? $default,){
final _that = this;
switch (_that) {
case _UpdateRecord() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, DateTime timestamp, bool success, List<String> packages, String message)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _UpdateRecord() when $default != null:
return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, DateTime timestamp, bool success, List<String> packages, String message) $default,) {final _that = this;
switch (_that) {
case _UpdateRecord():
return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, DateTime timestamp, bool success, List<String> packages, String message)? $default,) {final _that = this;
switch (_that) {
case _UpdateRecord() when $default != null:
return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _UpdateRecord implements UpdateRecord {
const _UpdateRecord({this.id = '', required this.timestamp, required this.success, final List<String> packages = const <String>[], this.message = ''}): _packages = packages;
factory _UpdateRecord.fromJson(Map<String, dynamic> json) => _$UpdateRecordFromJson(json);
@override@JsonKey() final String id;
@override final DateTime timestamp;
@override final bool success;
final List<String> _packages;
@override@JsonKey() List<String> get packages {
if (_packages is EqualUnmodifiableListView) return _packages;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_packages);
}
@override@JsonKey() final String message;
/// Create a copy of UpdateRecord
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$UpdateRecordCopyWith<_UpdateRecord> get copyWith => __$UpdateRecordCopyWithImpl<_UpdateRecord>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$UpdateRecordToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.message, message) || other.message == message));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,timestamp,success,const DeepCollectionEquality().hash(_packages),message);
@override
String toString() {
return 'UpdateRecord(id: $id, timestamp: $timestamp, success: $success, packages: $packages, message: $message)';
}
}
/// @nodoc
abstract mixin class _$UpdateRecordCopyWith<$Res> implements $UpdateRecordCopyWith<$Res> {
factory _$UpdateRecordCopyWith(_UpdateRecord value, $Res Function(_UpdateRecord) _then) = __$UpdateRecordCopyWithImpl;
@override @useResult
$Res call({
String id, DateTime timestamp, bool success, List<String> packages, String message
});
}
/// @nodoc
class __$UpdateRecordCopyWithImpl<$Res>
implements _$UpdateRecordCopyWith<$Res> {
__$UpdateRecordCopyWithImpl(this._self, this._then);
final _UpdateRecord _self;
final $Res Function(_UpdateRecord) _then;
/// Create a copy of UpdateRecord
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? packages = null,Object? message = null,}) {
return _then(_UpdateRecord(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable
as bool,packages: null == packages ? _self._packages : packages // ignore: cast_nullable_to_non_nullable
as List<String>,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
+29
View File
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_record.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_UpdateRecord _$UpdateRecordFromJson(Map<String, dynamic> json) =>
_UpdateRecord(
id: json['id'] as String? ?? '',
timestamp: DateTime.parse(json['timestamp'] as String),
success: json['success'] as bool,
packages:
(json['packages'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const <String>[],
message: json['message'] as String? ?? '',
);
Map<String, dynamic> _$UpdateRecordToJson(_UpdateRecord instance) =>
<String, dynamic>{
'id': instance.id,
'timestamp': instance.timestamp.toIso8601String(),
'success': instance.success,
'packages': instance.packages,
'message': instance.message,
};
+41
View File
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../connections/connection_providers.dart';
import 'node_master_api_client.dart';
part 'api_client_provider.g.dart';
/// Rebuilt (not mutated) whenever the active connection changes, so every
/// watcher automatically rebuilds against the new host with no manual
/// invalidation elsewhere. Returns null when no connection is configured —
/// `app_router.dart` redirects to Settings in that case rather than every
/// screen needing its own "no connection" branch.
@riverpod
NodeMasterApiClient? apiClient(Ref ref) {
final connection = ref.watch(activeConnectionProvider);
if (connection == null) return null;
final cancelToken = CancelToken();
ref.onDispose(() => cancelToken.cancel('Active connection changed.'));
final baseUrl = connection.baseUrl.endsWith('/')
? connection.baseUrl.substring(0, connection.baseUrl.length - 1)
: connection.baseUrl;
final dio = Dio(
BaseOptions(
baseUrl: '$baseUrl/v1',
connectTimeout: const Duration(seconds: 8),
sendTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
contentType: 'application/json',
),
);
if (kDebugMode) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}
return NodeMasterApiClient(dio, cancelToken);
}
@@ -0,0 +1,73 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Rebuilt (not mutated) whenever the active connection changes, so every
/// watcher automatically rebuilds against the new host with no manual
/// invalidation elsewhere. Returns null when no connection is configured —
/// `app_router.dart` redirects to Settings in that case rather than every
/// screen needing its own "no connection" branch.
@ProviderFor(apiClient)
const apiClientProvider = ApiClientProvider._();
/// Rebuilt (not mutated) whenever the active connection changes, so every
/// watcher automatically rebuilds against the new host with no manual
/// invalidation elsewhere. Returns null when no connection is configured —
/// `app_router.dart` redirects to Settings in that case rather than every
/// screen needing its own "no connection" branch.
final class ApiClientProvider
extends
$FunctionalProvider<
NodeMasterApiClient?,
NodeMasterApiClient?,
NodeMasterApiClient?
>
with $Provider<NodeMasterApiClient?> {
/// Rebuilt (not mutated) whenever the active connection changes, so every
/// watcher automatically rebuilds against the new host with no manual
/// invalidation elsewhere. Returns null when no connection is configured —
/// `app_router.dart` redirects to Settings in that case rather than every
/// screen needing its own "no connection" branch.
const ApiClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'apiClientProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$apiClientHash();
@$internal
@override
$ProviderElement<NodeMasterApiClient?> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
NodeMasterApiClient? create(Ref ref) {
return apiClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NodeMasterApiClient? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NodeMasterApiClient?>(value),
);
}
}
String _$apiClientHash() => r'0a33d0449a51fd825f43bdcddd686bd413ff74ea';
+44
View File
@@ -0,0 +1,44 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'api_exception.freezed.dart';
/// Typed errors thrown by [NodeMasterApiClient]/repositories. Riverpod's
/// `AsyncValue` (via `FutureProvider`/`AsyncNotifier`) already catches
/// thrown exceptions into `AsyncValue.error`, so this is the result-type
/// boundary — no separate `Result<T, E>` wrapper is layered on top.
@freezed
sealed class ApiException with _$ApiException implements Exception {
/// DNS/connection failure — couldn't reach the host at all.
const factory ApiException.network(String message) = ApiNetworkException;
/// Connect/send/receive timeout.
const factory ApiException.timeout() = ApiTimeoutException;
/// The server responded with a 4xx/5xx and (usually) a `{"error": msg}`
/// body.
const factory ApiException.server(int statusCode, String message) = ApiServerException;
/// The response body wasn't the JSON shape a model expected.
const factory ApiException.parse(String message) = ApiParseException;
/// Request was cancelled (e.g. the active connection changed mid-flight).
const factory ApiException.cancelled() = ApiCancelledException;
/// Anything else.
const factory ApiException.unknown(String message) = ApiUnknownException;
}
extension ApiExceptionUserMessage on ApiException {
/// Short, user-facing copy — screens can use this directly or switch on
/// the exception type themselves for more specific handling.
String get userMessage => switch (this) {
ApiNetworkException() => "Can't reach this node — check the URL and that it's reachable.",
ApiTimeoutException() => 'The node took too long to respond.',
ApiServerException(:final statusCode, :final message) => statusCode == 404
? 'Not found.'
: 'Server error ($statusCode): $message',
ApiParseException() => 'Received an unexpected response from the node.',
ApiCancelledException() => 'Cancelled.',
ApiUnknownException(:final message) => message,
};
}
+528
View File
@@ -0,0 +1,528 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'api_exception.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ApiException {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiException);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'ApiException()';
}
}
/// @nodoc
class $ApiExceptionCopyWith<$Res> {
$ApiExceptionCopyWith(ApiException _, $Res Function(ApiException) __);
}
/// Adds pattern-matching-related methods to [ApiException].
extension ApiExceptionPatterns on ApiException {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( ApiNetworkException value)? network,TResult Function( ApiTimeoutException value)? timeout,TResult Function( ApiServerException value)? server,TResult Function( ApiParseException value)? parse,TResult Function( ApiCancelledException value)? cancelled,TResult Function( ApiUnknownException value)? unknown,required TResult orElse(),}){
final _that = this;
switch (_that) {
case ApiNetworkException() when network != null:
return network(_that);case ApiTimeoutException() when timeout != null:
return timeout(_that);case ApiServerException() when server != null:
return server(_that);case ApiParseException() when parse != null:
return parse(_that);case ApiCancelledException() when cancelled != null:
return cancelled(_that);case ApiUnknownException() when unknown != null:
return unknown(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( ApiNetworkException value) network,required TResult Function( ApiTimeoutException value) timeout,required TResult Function( ApiServerException value) server,required TResult Function( ApiParseException value) parse,required TResult Function( ApiCancelledException value) cancelled,required TResult Function( ApiUnknownException value) unknown,}){
final _that = this;
switch (_that) {
case ApiNetworkException():
return network(_that);case ApiTimeoutException():
return timeout(_that);case ApiServerException():
return server(_that);case ApiParseException():
return parse(_that);case ApiCancelledException():
return cancelled(_that);case ApiUnknownException():
return unknown(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( ApiNetworkException value)? network,TResult? Function( ApiTimeoutException value)? timeout,TResult? Function( ApiServerException value)? server,TResult? Function( ApiParseException value)? parse,TResult? Function( ApiCancelledException value)? cancelled,TResult? Function( ApiUnknownException value)? unknown,}){
final _that = this;
switch (_that) {
case ApiNetworkException() when network != null:
return network(_that);case ApiTimeoutException() when timeout != null:
return timeout(_that);case ApiServerException() when server != null:
return server(_that);case ApiParseException() when parse != null:
return parse(_that);case ApiCancelledException() when cancelled != null:
return cancelled(_that);case ApiUnknownException() when unknown != null:
return unknown(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String message)? network,TResult Function()? timeout,TResult Function( int statusCode, String message)? server,TResult Function( String message)? parse,TResult Function()? cancelled,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this;
switch (_that) {
case ApiNetworkException() when network != null:
return network(_that.message);case ApiTimeoutException() when timeout != null:
return timeout();case ApiServerException() when server != null:
return server(_that.statusCode,_that.message);case ApiParseException() when parse != null:
return parse(_that.message);case ApiCancelledException() when cancelled != null:
return cancelled();case ApiUnknownException() when unknown != null:
return unknown(_that.message);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String message) network,required TResult Function() timeout,required TResult Function( int statusCode, String message) server,required TResult Function( String message) parse,required TResult Function() cancelled,required TResult Function( String message) unknown,}) {final _that = this;
switch (_that) {
case ApiNetworkException():
return network(_that.message);case ApiTimeoutException():
return timeout();case ApiServerException():
return server(_that.statusCode,_that.message);case ApiParseException():
return parse(_that.message);case ApiCancelledException():
return cancelled();case ApiUnknownException():
return unknown(_that.message);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String message)? network,TResult? Function()? timeout,TResult? Function( int statusCode, String message)? server,TResult? Function( String message)? parse,TResult? Function()? cancelled,TResult? Function( String message)? unknown,}) {final _that = this;
switch (_that) {
case ApiNetworkException() when network != null:
return network(_that.message);case ApiTimeoutException() when timeout != null:
return timeout();case ApiServerException() when server != null:
return server(_that.statusCode,_that.message);case ApiParseException() when parse != null:
return parse(_that.message);case ApiCancelledException() when cancelled != null:
return cancelled();case ApiUnknownException() when unknown != null:
return unknown(_that.message);case _:
return null;
}
}
}
/// @nodoc
class ApiNetworkException implements ApiException {
const ApiNetworkException(this.message);
final String message;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiNetworkExceptionCopyWith<ApiNetworkException> get copyWith => _$ApiNetworkExceptionCopyWithImpl<ApiNetworkException>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiNetworkException&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,message);
@override
String toString() {
return 'ApiException.network(message: $message)';
}
}
/// @nodoc
abstract mixin class $ApiNetworkExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> {
factory $ApiNetworkExceptionCopyWith(ApiNetworkException value, $Res Function(ApiNetworkException) _then) = _$ApiNetworkExceptionCopyWithImpl;
@useResult
$Res call({
String message
});
}
/// @nodoc
class _$ApiNetworkExceptionCopyWithImpl<$Res>
implements $ApiNetworkExceptionCopyWith<$Res> {
_$ApiNetworkExceptionCopyWithImpl(this._self, this._then);
final ApiNetworkException _self;
final $Res Function(ApiNetworkException) _then;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
return _then(ApiNetworkException(
null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class ApiTimeoutException implements ApiException {
const ApiTimeoutException();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiTimeoutException);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'ApiException.timeout()';
}
}
/// @nodoc
class ApiServerException implements ApiException {
const ApiServerException(this.statusCode, this.message);
final int statusCode;
final String message;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiServerExceptionCopyWith<ApiServerException> get copyWith => _$ApiServerExceptionCopyWithImpl<ApiServerException>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiServerException&&(identical(other.statusCode, statusCode) || other.statusCode == statusCode)&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,statusCode,message);
@override
String toString() {
return 'ApiException.server(statusCode: $statusCode, message: $message)';
}
}
/// @nodoc
abstract mixin class $ApiServerExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> {
factory $ApiServerExceptionCopyWith(ApiServerException value, $Res Function(ApiServerException) _then) = _$ApiServerExceptionCopyWithImpl;
@useResult
$Res call({
int statusCode, String message
});
}
/// @nodoc
class _$ApiServerExceptionCopyWithImpl<$Res>
implements $ApiServerExceptionCopyWith<$Res> {
_$ApiServerExceptionCopyWithImpl(this._self, this._then);
final ApiServerException _self;
final $Res Function(ApiServerException) _then;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? statusCode = null,Object? message = null,}) {
return _then(ApiServerException(
null == statusCode ? _self.statusCode : statusCode // ignore: cast_nullable_to_non_nullable
as int,null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class ApiParseException implements ApiException {
const ApiParseException(this.message);
final String message;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiParseExceptionCopyWith<ApiParseException> get copyWith => _$ApiParseExceptionCopyWithImpl<ApiParseException>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiParseException&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,message);
@override
String toString() {
return 'ApiException.parse(message: $message)';
}
}
/// @nodoc
abstract mixin class $ApiParseExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> {
factory $ApiParseExceptionCopyWith(ApiParseException value, $Res Function(ApiParseException) _then) = _$ApiParseExceptionCopyWithImpl;
@useResult
$Res call({
String message
});
}
/// @nodoc
class _$ApiParseExceptionCopyWithImpl<$Res>
implements $ApiParseExceptionCopyWith<$Res> {
_$ApiParseExceptionCopyWithImpl(this._self, this._then);
final ApiParseException _self;
final $Res Function(ApiParseException) _then;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
return _then(ApiParseException(
null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class ApiCancelledException implements ApiException {
const ApiCancelledException();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiCancelledException);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'ApiException.cancelled()';
}
}
/// @nodoc
class ApiUnknownException implements ApiException {
const ApiUnknownException(this.message);
final String message;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiUnknownExceptionCopyWith<ApiUnknownException> get copyWith => _$ApiUnknownExceptionCopyWithImpl<ApiUnknownException>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiUnknownException&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,message);
@override
String toString() {
return 'ApiException.unknown(message: $message)';
}
}
/// @nodoc
abstract mixin class $ApiUnknownExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> {
factory $ApiUnknownExceptionCopyWith(ApiUnknownException value, $Res Function(ApiUnknownException) _then) = _$ApiUnknownExceptionCopyWithImpl;
@useResult
$Res call({
String message
});
}
/// @nodoc
class _$ApiUnknownExceptionCopyWithImpl<$Res>
implements $ApiUnknownExceptionCopyWith<$Res> {
_$ApiUnknownExceptionCopyWithImpl(this._self, this._then);
final ApiUnknownException _self;
final $Res Function(ApiUnknownException) _then;
/// Create a copy of ApiException
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
return _then(ApiUnknownException(
null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
+39
View File
@@ -0,0 +1,39 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
/// Maps a caught [DioException] (or any other error) to a typed
/// [ApiException]. The one place this translation happens — repositories
/// and the API client both funnel through this rather than inspecting
/// `DioException` directly.
ApiException mapDioException(Object error) {
if (error is ApiException) return error;
if (error is! DioException) {
return ApiException.unknown(error.toString());
}
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
return const ApiException.timeout();
case DioExceptionType.connectionError:
return ApiException.network(error.message ?? 'Connection failed.');
case DioExceptionType.cancel:
return const ApiException.cancelled();
case DioExceptionType.badResponse:
final response = error.response;
final statusCode = response?.statusCode ?? 0;
final data = response?.data;
final message = data is Map && data['error'] is String
? data['error'] as String
: (response?.statusMessage ?? 'Request failed.');
return ApiException.server(statusCode, message);
case DioExceptionType.badCertificate:
return ApiException.network('Bad certificate: ${error.message ?? ''}');
case DioExceptionType.unknown:
return ApiException.unknown(error.message ?? error.toString());
}
}
@@ -0,0 +1,261 @@
import 'dart:async';
import 'package:dio/dio.dart';
import '../models/models.dart';
import 'api_exception.dart';
import 'dio_error_mapper.dart';
/// Thin typed wrapper over [Dio], one method per NodeMaster REST endpoint.
/// Every method funnels errors through [mapDioException] so callers only
/// ever see [ApiException]. Holds a [CancelToken] tied to the client's own
/// lifetime (cancelled by [apiClientProvider] when the active connection
/// changes), so a request from an abandoned connection can't resolve into
/// the next connection's UI state.
class NodeMasterApiClient {
NodeMasterApiClient(this._dio, this._cancelToken);
final Dio _dio;
final CancelToken _cancelToken;
/// The overall budget for one request, enforced by Dart's own
/// `Future.timeout` rather than relying solely on Dio's
/// `connectTimeout`/`receiveTimeout` `BaseOptions`. Dio's browser HTTP
/// adapter has no socket-level hooks to honor those (the underlying
/// `fetch`/`XMLHttpRequest` APIs don't expose a connect-phase timeout),
/// so on web a request to an unreachable host would otherwise hang
/// until the browser's own (much longer, sometimes unbounded) default —
/// this timeout is what actually bounds it on every platform.
static const _requestBudget = Duration(seconds: 20);
Future<T> _guard<T>(Future<T> Function() body) async {
try {
return await body().timeout(_requestBudget);
} on ApiException {
rethrow;
} on DioException catch (e) {
throw mapDioException(e);
} on TimeoutException {
throw const ApiException.timeout();
} on TypeError catch (e) {
throw ApiException.parse(e.toString());
} on FormatException catch (e) {
throw ApiException.parse(e.toString());
}
}
Future<Response<dynamic>> _get(String path, {Map<String, dynamic>? query}) => _dio.get(
path,
queryParameters: query,
cancelToken: _cancelToken,
);
Future<Response<dynamic>> _post(String path, {Object? data}) => _dio.post(
path,
data: data,
cancelToken: _cancelToken,
);
Future<Response<dynamic>> _put(String path, {Object? data}) => _dio.put(
path,
data: data,
cancelToken: _cancelToken,
);
Future<Response<dynamic>> _delete(String path) => _dio.delete(path, cancelToken: _cancelToken);
// ---- node ----
Future<NodeInfo> getNode() => _guard(() async {
final res = await _get('/node/');
return NodeInfo.fromJson(res.data as Map<String, dynamic>);
});
Future<void> updateNode(NodeInfo node) => _guard(() async {
await _put('/node/', data: node.toJson());
});
Future<BackupConfig> getNodeBackup() => _guard(() async {
final res = await _get('/node/backup');
return BackupConfig.fromJson(res.data as Map<String, dynamic>);
});
Future<void> updateNodeBackup(BackupConfig config) => _guard(() async {
await _put('/node/backup', data: config.toJson());
});
Future<void> runNodeBackup() => _guard(() async {
await _post('/node/backup/run');
});
Future<List<BackupTarget>> getNodeBackupTargets() => _guard(() async {
final res = await _get('/node/backup/targets');
return (res.data as List<dynamic>)
.map((e) => BackupTarget.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<BackupTarget> addNodeBackupTarget(BackupTarget target) => _guard(() async {
final res = await _post('/node/backup/targets', data: target.toJson());
return BackupTarget.fromJson(res.data as Map<String, dynamic>);
});
Future<BackupTarget> updateNodeBackupTarget(String targetId, BackupTarget target) => _guard(() async {
final res = await _put('/node/backup/targets/$targetId', data: target.toJson());
return BackupTarget.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deleteNodeBackupTarget(String targetId) => _guard(() async {
await _delete('/node/backup/targets/$targetId');
});
Future<void> runNodeBackupTarget(String targetId) => _guard(() async {
await _post('/node/backup/targets/$targetId/run');
});
Future<RunState> getNodeBackupTargetProgress(String targetId) => _guard(() async {
final res = await _get('/node/backup/targets/$targetId/progress');
return RunState.fromJson(res.data as Map<String, dynamic>);
});
Future<List<RunState>> getNodeBackupProgress() => _guard(() async {
final res = await _get('/node/backup/progress');
return (res.data as List<dynamic>)
.map((e) => RunState.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<ScanResult> scan({String? folder}) => _guard(() async {
final res = await _post('/node/scan', data: folder == null ? {} : {'folder': folder});
return ScanResult.fromJson(res.data as Map<String, dynamic>);
});
Future<List<UpdateRecord>> getUpdates() => _guard(() async {
final res = await _get('/node/updates');
return (res.data as List<dynamic>)
.map((e) => UpdateRecord.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<UpdateRecord> addUpdate(UpdateRecord record) => _guard(() async {
final res = await _post('/node/updates', data: record.toJson());
return UpdateRecord.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deleteUpdate(String id) => _guard(() async {
await _delete('/node/updates/$id');
});
// ---- nodes (fleet registry) ----
Future<List<RemoteNode>> getAllRemoteNodes() => _guard(() async {
final res = await _get('/nodes/');
return (res.data as List<dynamic>)
.map((e) => RemoteNode.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<RemoteNode> addRemoteNode(RemoteNode node) => _guard(() async {
final res = await _post('/nodes/', data: node.toJson());
return RemoteNode.fromJson(res.data as Map<String, dynamic>);
});
Future<RemoteNode> getRemoteNode(String id) => _guard(() async {
final res = await _get('/nodes/$id');
return RemoteNode.fromJson(res.data as Map<String, dynamic>);
});
Future<RemoteNode> updateRemoteNode(String id, RemoteNode node) => _guard(() async {
final res = await _put('/nodes/$id', data: node.toJson());
return RemoteNode.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deleteRemoteNode(String id) => _guard(() async {
await _delete('/nodes/$id');
});
Future<List<AggregatedNodeStatus>> getAggregated() => _guard(() async {
final res = await _get('/nodes/aggregated');
return (res.data as List<dynamic>)
.map((e) => AggregatedNodeStatus.fromJson(e as Map<String, dynamic>))
.toList();
});
// ---- services ----
Future<List<Service>> getAllServices() => _guard(() async {
final res = await _get('/services/');
return (res.data as List<dynamic>)
.map((e) => Service.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<Service> addService(Service service) => _guard(() async {
final res = await _post('/services/', data: service.toJson());
return Service.fromJson(res.data as Map<String, dynamic>);
});
Future<Service> getService(String id) => _guard(() async {
final res = await _get('/services/$id');
return Service.fromJson(res.data as Map<String, dynamic>);
});
Future<Service> updateService(String id, Service service) => _guard(() async {
final res = await _put('/services/$id', data: service.toJson());
return Service.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deleteService(String id) => _guard(() async {
await _delete('/services/$id');
});
Future<void> startService(String id) => _guard(() async {
await _post('/services/$id/start');
});
Future<void> stopService(String id) => _guard(() async {
await _post('/services/$id/stop');
});
Future<void> backupService(String id) => _guard(() async {
await _post('/services/$id/backup');
});
Future<List<BackupTarget>> getServiceBackupTargets(String id) => _guard(() async {
final res = await _get('/services/$id/backup/targets');
return (res.data as List<dynamic>)
.map((e) => BackupTarget.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<BackupTarget> addServiceBackupTarget(String id, BackupTarget target) => _guard(() async {
final res = await _post('/services/$id/backup/targets', data: target.toJson());
return BackupTarget.fromJson(res.data as Map<String, dynamic>);
});
Future<BackupTarget> updateServiceBackupTarget(String id, String targetId, BackupTarget target) =>
_guard(() async {
final res = await _put('/services/$id/backup/targets/$targetId', data: target.toJson());
return BackupTarget.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deleteServiceBackupTarget(String id, String targetId) => _guard(() async {
await _delete('/services/$id/backup/targets/$targetId');
});
Future<void> runServiceBackupTarget(String id, String targetId) => _guard(() async {
await _post('/services/$id/backup/targets/$targetId/run');
});
Future<RunState> getServiceBackupTargetProgress(String id, String targetId) => _guard(() async {
final res = await _get('/services/$id/backup/targets/$targetId/progress');
return RunState.fromJson(res.data as Map<String, dynamic>);
});
Future<List<RunState>> getServiceBackupProgress(String id) => _guard(() async {
final res = await _get('/services/$id/backup/progress');
return (res.data as List<dynamic>)
.map((e) => RunState.fromJson(e as Map<String, dynamic>))
.toList();
});
}
@@ -0,0 +1,37 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
/// Mixed into a `@riverpod` `AutoDisposeAsyncNotifier` to auto-refresh it on
/// a timer while it has at least one listener — Dashboard's summary and
/// Fleet's aggregated view, specifically. The API has no push/websocket, so
/// this is the honest refresh model: a manual pull plus a background
/// timer, not a live stream.
///
/// Usage: `class FooNotifier extends _$FooNotifier with PollingMixin<Foo>`,
/// implement [interval] and [fetch], and have `build()` return
/// `startPolling()`.
mixin PollingMixin<T> on $AsyncNotifier<T> {
/// How often to refetch while this provider has at least one listener.
Duration get interval;
/// Performs the actual fetch. Thrown exceptions surface as
/// `AsyncValue.error` exactly like any other async provider.
Future<T> fetch();
/// Call from `build()`: starts the periodic timer (cancelled on dispose)
/// and returns the initial fetch.
Future<T> startPolling() {
final timer = Timer.periodic(interval, (_) => refresh());
ref.onDispose(timer.cancel);
return fetch();
}
/// Re-fetches and updates state. Deliberately never emits an
/// intermediate `AsyncLoading` — the previous data/error stays on screen
/// until the new result lands, so a background tick never flashes the
/// UI back to a loading spinner.
Future<void> refresh() async {
state = await AsyncValue.guard(fetch);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/backups/backups_screen.dart';
import '../../features/dashboard/dashboard_screen.dart';
import '../../features/fleet/fleet_screen.dart';
import '../../features/services/services_screen.dart';
import '../../features/settings/settings_screen.dart';
import '../../features/updates/updates_screen.dart';
import '../connections/connection_providers.dart';
import '../widgets/nav_rail_shell.dart';
import 'route_paths.dart';
part 'app_router.g.dart';
/// Bridges Riverpod's [activeConnectionProvider] to go_router's
/// [Listenable]-based `refreshListenable` — so removing/adding the active
/// connection re-evaluates the top-level redirect immediately, not just on
/// the next navigation.
class _ConnectionRefreshListenable extends ChangeNotifier {
_ConnectionRefreshListenable(Ref ref) {
ref.listen(activeConnectionProvider, (previous, next) => notifyListeners());
}
}
@Riverpod(keepAlive: true)
GoRouter appRouter(Ref ref) {
return GoRouter(
initialLocation: RoutePaths.dashboard,
refreshListenable: _ConnectionRefreshListenable(ref),
redirect: (context, state) {
final hasConnection = ref.read(activeConnectionProvider) != null;
final onSettings = state.matchedLocation == RoutePaths.settings;
if (!hasConnection && !onSettings) return RoutePaths.settings;
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) {
return NavRailShell(currentPath: state.uri.path, child: child);
},
routes: [
GoRoute(
path: RoutePaths.dashboard,
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: RoutePaths.services,
builder: (context, state) => ServicesScreen(serviceId: state.uri.queryParameters['id']),
),
GoRoute(
path: RoutePaths.backups,
builder: (context, state) => const BackupsScreen(),
),
GoRoute(
path: RoutePaths.fleet,
builder: (context, state) => const FleetScreen(),
),
GoRoute(
path: RoutePaths.updates,
builder: (context, state) => const UpdatesScreen(),
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(),
),
],
),
],
);
}
+51
View File
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_router.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(appRouter)
const appRouterProvider = AppRouterProvider._();
final class AppRouterProvider
extends $FunctionalProvider<GoRouter, GoRouter, GoRouter>
with $Provider<GoRouter> {
const AppRouterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appRouterProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appRouterHash();
@$internal
@override
$ProviderElement<GoRouter> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
GoRouter create(Ref ref) {
return appRouter(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GoRouter value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GoRouter>(value),
);
}
}
String _$appRouterHash() => r'564cdc8b6bb47dea31d32b10bbfa1253170018af';
+17
View File
@@ -0,0 +1,17 @@
/// Route path constants — the single source of truth so screens and the
/// nav rail never hand-type a path string more than once.
abstract final class RoutePaths {
static const dashboard = '/dashboard';
static const services = '/services';
static const backups = '/backups';
static const fleet = '/fleet';
static const updates = '/updates';
static const settings = '/settings';
/// The service id is a query parameter (`?id=`), not a path segment.
/// `/services` and `/services?id=x` are the *same* go_router route, so
/// switching between them updates the existing page in place — no page
/// transition — which is what makes the slide-over panel read as an
/// overlay on the list rather than a navigation to a new screen.
static String serviceDetail(String id) => '/services?id=$id';
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Exact hex values from the approved design mockup. Hand-authored rather
/// than derived from [ColorScheme.fromSeed] so these values can't drift.
abstract final class AppColors {
// Light theme
static const lightBg = Color(0xFFF2F6F6);
static const lightSurface = Color(0xFFFFFFFF);
static const lightSurfaceAlt = Color(0xFFE9EFEF);
static const lightBorder = Color(0xFFDAE3E4);
static const lightText = Color(0xFF0E1B1D);
static const lightTextMuted = Color(0xFF47585B);
static const lightTextFaint = Color(0xFF7C9195);
static const lightAccent = Color(0xFF0E8E92);
static const lightAccentStrong = Color(0xFF0A7377);
static const lightAccentInk = Color(0xFFFFFFFF);
// Dark theme
static const darkBg = Color(0xFF0C1315);
static const darkSurface = Color(0xFF121B1D);
static const darkSurfaceAlt = Color(0xFF182224);
static const darkBorder = Color(0xFF223032);
static const darkText = Color(0xFFE8F0F0);
static const darkTextMuted = Color(0xFFA9BFC2);
static const darkTextFaint = Color(0xFF6C8386);
static const darkAccent = Color(0xFF2FC6CA);
static const darkAccentStrong = Color(0xFF55D8DB);
static const darkAccentInk = Color(0xFF06282A);
// Status — semantic, deliberately distinct from the accent hue, same
// across themes except `warning`, which is re-tuned per theme below to
// hold contrast against each surface.
static const good = Color(0xFF0CA30C);
static const warningLight = Color(0xFFC8890A);
static const warningDark = Color(0xFFE3A82B);
static const serious = Color(0xFFEC835A);
static const critical = Color(0xFFD03B3B);
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// Typography for "technical values" (compose paths, hostnames, ids,
/// timestamps) and for tabular numerals. These are two distinct rules from
/// the design spec, not one:
/// - `dataMono*` swaps the font *family* to JetBrains Mono.
/// - `numericTabular` keeps the UI sans (Inter) but turns on the
/// `tnum` font feature so digits line up in columns (stat tiles,
/// numeric table cells) without looking like a code snippet.
@immutable
class AppDataStyles extends ThemeExtension<AppDataStyles> {
const AppDataStyles({
required this.dataMono,
required this.dataMonoSmall,
required this.numericTabular,
});
final TextStyle dataMono;
final TextStyle dataMonoSmall;
final TextStyle numericTabular;
static AppDataStyles resolve({required Color textColor, required Color mutedColor}) {
return AppDataStyles(
dataMono: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
color: mutedColor,
height: 1.3,
),
dataMonoSmall: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11.5,
color: mutedColor,
height: 1.3,
),
numericTabular: TextStyle(
fontFamily: 'Inter',
color: textColor,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
@override
AppDataStyles copyWith({
TextStyle? dataMono,
TextStyle? dataMonoSmall,
TextStyle? numericTabular,
}) {
return AppDataStyles(
dataMono: dataMono ?? this.dataMono,
dataMonoSmall: dataMonoSmall ?? this.dataMonoSmall,
numericTabular: numericTabular ?? this.numericTabular,
);
}
@override
AppDataStyles lerp(ThemeExtension<AppDataStyles>? other, double t) {
if (other is! AppDataStyles) return this;
return AppDataStyles(
dataMono: TextStyle.lerp(dataMono, other.dataMono, t)!,
dataMonoSmall: TextStyle.lerp(dataMonoSmall, other.dataMonoSmall, t)!,
numericTabular: TextStyle.lerp(numericTabular, other.numericTabular, t)!,
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_data_styles.dart';
import 'status_colors.dart';
/// Builds the app's light/dark [ThemeData]. Colors are constructed
/// explicitly from [AppColors] rather than via [ColorScheme.fromSeed] so the
/// approved mockup's exact hex values can't drift through algorithmic tone
/// generation.
abstract final class AppTheme {
static ThemeData light() => _build(brightness: Brightness.light);
static ThemeData dark() => _build(brightness: Brightness.dark);
static ThemeData _build({required Brightness brightness}) {
final isDark = brightness == Brightness.dark;
final colorScheme = isDark
? const ColorScheme.dark(
surface: AppColors.darkBg,
onSurface: AppColors.darkText,
surfaceContainerHighest: AppColors.darkSurfaceAlt,
primary: AppColors.darkAccent,
onPrimary: AppColors.darkAccentInk,
secondary: AppColors.darkAccentStrong,
onSecondary: AppColors.darkAccentInk,
error: AppColors.critical,
onError: Colors.white,
outline: AppColors.darkBorder,
outlineVariant: AppColors.darkBorder,
)
: const ColorScheme.light(
surface: AppColors.lightBg,
onSurface: AppColors.lightText,
surfaceContainerHighest: AppColors.lightSurfaceAlt,
primary: AppColors.lightAccent,
onPrimary: AppColors.lightAccentInk,
secondary: AppColors.lightAccentStrong,
onSecondary: AppColors.lightAccentInk,
error: AppColors.critical,
onError: Colors.white,
outline: AppColors.lightBorder,
outlineVariant: AppColors.lightBorder,
);
final surfaceCard = isDark ? AppColors.darkSurface : AppColors.lightSurface;
final textColor = isDark ? AppColors.darkText : AppColors.lightText;
final textMuted = isDark ? AppColors.darkTextMuted : AppColors.lightTextMuted;
final textFaint = isDark ? AppColors.darkTextFaint : AppColors.lightTextFaint;
final border = isDark ? AppColors.darkBorder : AppColors.lightBorder;
final textTheme = TextTheme(
displaySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 30, color: textColor, letterSpacing: -0.2),
headlineSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 22, color: textColor, letterSpacing: -0.1),
titleLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 17, color: textColor, letterSpacing: -0.1),
titleMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 14, color: textColor),
titleSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.6),
bodyLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 15, color: textColor, height: 1.5),
bodyMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 13.5, color: textColor, height: 1.45),
bodySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500, fontSize: 12, color: textMuted),
labelLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13, color: textColor),
labelMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 12, color: textMuted),
labelSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5),
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
canvasColor: colorScheme.surface,
dividerColor: border,
fontFamily: 'Inter',
textTheme: textTheme,
cardTheme: CardThemeData(
color: surfaceCard,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: border),
),
),
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: textColor,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: isDark ? AppColors.darkSurfaceAlt : AppColors.lightSurfaceAlt,
indicatorColor: colorScheme.primary.withValues(alpha: 0.16),
selectedIconTheme: IconThemeData(color: colorScheme.secondary),
unselectedIconTheme: IconThemeData(color: textMuted),
selectedLabelTextStyle: TextStyle(color: colorScheme.secondary, fontWeight: FontWeight.w700, fontSize: 12.5),
unselectedLabelTextStyle: TextStyle(color: textMuted, fontWeight: FontWeight.w500, fontSize: 12.5),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: textColor,
side: BorderSide(color: border),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: colorScheme.primary, width: 1.5),
),
labelStyle: TextStyle(color: textMuted, fontSize: 12.5, fontWeight: FontWeight.w600),
),
dataTableTheme: DataTableThemeData(
headingTextStyle: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5),
dataTextStyle: TextStyle(fontFamily: 'Inter', fontSize: 13, color: textColor),
dividerThickness: 1,
),
extensions: [
isDark ? StatusColors.dark : StatusColors.light,
AppDataStyles.resolve(textColor: textColor, mutedColor: textMuted),
],
);
}
}
+16
View File
@@ -0,0 +1,16 @@
/// Spacing scale used across the app. Plain constants rather than a
/// [ThemeExtension] — spacing doesn't change between light/dark, so there's
/// no lerp behavior to gain from routing it through the theme mechanism.
abstract final class Spacing {
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
static const xl = 20.0;
static const xxl = 28.0;
static const xxxl = 40.0;
static const radiusSm = 6.0;
static const radiusMd = 8.0;
static const radiusLg = 10.0;
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Semantic status colors (good/warning/serious/critical) used for service
/// up/down/unknown pills, backup/update success/fail pills, and stat-tile
/// severity stripes. Kept out of [ColorScheme] because Material only has a
/// single `error` role and these four are a distinct, reserved set that
/// must never be reused for anything else (e.g. a 4th categorical series).
@immutable
class StatusColors extends ThemeExtension<StatusColors> {
const StatusColors({
required this.good,
required this.warning,
required this.serious,
required this.critical,
});
final Color good;
final Color warning;
final Color serious;
final Color critical;
static const light = StatusColors(
good: AppColors.good,
warning: AppColors.warningLight,
serious: AppColors.serious,
critical: AppColors.critical,
);
static const dark = StatusColors(
good: AppColors.good,
warning: AppColors.warningDark,
serious: AppColors.serious,
critical: AppColors.critical,
);
@override
StatusColors copyWith({
Color? good,
Color? warning,
Color? serious,
Color? critical,
}) {
return StatusColors(
good: good ?? this.good,
warning: warning ?? this.warning,
serious: serious ?? this.serious,
critical: critical ?? this.critical,
);
}
@override
StatusColors lerp(ThemeExtension<StatusColors>? other, double t) {
if (other is! StatusColors) return this;
return StatusColors(
good: Color.lerp(good, other.good, t)!,
warning: Color.lerp(warning, other.warning, t)!,
serious: Color.lerp(serious, other.serious, t)!,
critical: Color.lerp(critical, other.critical, t)!,
);
}
}
/// The four states a service / backup execution / update record / remote
/// node can render as a [StatusPill]. Kept as one enum shared across
/// features rather than per-feature status strings, so pill color mapping
/// lives in exactly one place.
enum Severity { good, warning, serious, critical, neutral }
extension SeverityColor on Severity {
/// Resolves to a status color, or [muted] for [Severity.neutral] (which
/// has no reserved status hue — callers pass their theme's muted/outline
/// color for the "no data" / "not configured" case).
Color resolve(StatusColors colors, {required Color muted}) => switch (this) {
Severity.good => colors.good,
Severity.warning => colors.warning,
Severity.serious => colors.serious,
Severity.critical => colors.critical,
Severity.neutral => muted,
};
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../utils/shared_preferences_provider.dart';
part 'theme_mode_provider.g.dart';
const _themeModeKey = 'theme_mode';
@Riverpod(keepAlive: true)
class ThemeModeController extends _$ThemeModeController {
@override
ThemeMode build() {
final stored = ref.watch(sharedPreferencesProvider).getString(_themeModeKey);
return switch (stored) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
Future<void> setThemeMode(ThemeMode mode) async {
state = mode;
await ref.read(sharedPreferencesProvider).setString(_themeModeKey, mode.name);
}
}
+64
View File
@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_mode_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ThemeModeController)
const themeModeControllerProvider = ThemeModeControllerProvider._();
final class ThemeModeControllerProvider
extends $NotifierProvider<ThemeModeController, ThemeMode> {
const ThemeModeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeModeControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeModeControllerHash();
@$internal
@override
ThemeModeController create() => ThemeModeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeMode>(value),
);
}
}
String _$themeModeControllerHash() =>
r'521a0916c167062954c4b944c327fbeca64c294c';
abstract class _$ThemeModeController extends $Notifier<ThemeMode> {
ThemeMode build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ThemeMode, ThemeMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ThemeMode, ThemeMode>,
ThemeMode,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'app_data_styles.dart';
import 'status_colors.dart';
/// Shorthand accessors so screens read `context.status.good` /
/// `context.dataStyles.dataMono` instead of the verbose
/// `Theme.of(context).extension<...>()!` at every call site.
extension ThemeX on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get text => Theme.of(this).textTheme;
StatusColors get status => Theme.of(this).extension<StatusColors>()!;
AppDataStyles get dataStyles => Theme.of(this).extension<AppDataStyles>()!;
}
+12
View File
@@ -0,0 +1,12 @@
/// Formats a past [DateTime] as a short relative string ("2h ago",
/// "5d ago") for activity feeds and stat-tile captions — full timestamps
/// belong in tables (as [MonoText]), not here.
String formatRelative(DateTime dateTime) {
final diff = DateTime.now().difference(dateTime);
if (diff.inSeconds < 45) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w ago';
return '${(diff.inDays / 30).floor()}mo ago';
}
@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Overridden in `main()` with an instance obtained via
/// `SharedPreferences.getInstance()` before `runApp`. Left unimplemented by
/// default so a missing override fails loudly instead of silently using a
/// throwaway in-memory instance.
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError(
'sharedPreferencesProvider must be overridden in main() with a loaded SharedPreferences instance.',
);
});
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../network/api_exception.dart';
const _methods = BackupMethod.values;
/// Add/edit dialog for a single [BackupTarget] — shared by the node Backups
/// screen and the Services detail panel, which otherwise only differ in
/// which repository [onSave] calls. Per-target `params` editing stays out
/// of scope here, same reasoning as the rest of the app: a power-user
/// escape hatch, not worth a nested key/value editor in v1.
Future<void> showBackupTargetFormDialog(
BuildContext context, {
BackupTarget? existing,
required Future<void> Function(BackupTarget draft) onSave,
}) {
return showDialog<void>(
context: context,
builder: (context) => _BackupTargetFormDialog(existing: existing, onSave: onSave),
);
}
class _BackupTargetFormDialog extends StatefulWidget {
const _BackupTargetFormDialog({this.existing, required this.onSave});
final BackupTarget? existing;
final Future<void> Function(BackupTarget draft) onSave;
@override
State<_BackupTargetFormDialog> createState() => _BackupTargetFormDialogState();
}
class _BackupTargetFormDialogState extends State<_BackupTargetFormDialog> {
final _formKey = GlobalKey<FormState>();
late final _nameController = TextEditingController(text: widget.existing?.name);
late final _remoteController = TextEditingController(text: widget.existing?.remote);
late final _scheduleController = TextEditingController(text: widget.existing?.schedule);
late BackupMethod _method = widget.existing?.method ?? BackupMethod.restic;
bool _saving = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_remoteController.dispose();
_scheduleController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final schedule = _scheduleController.text.trim();
final draft = (widget.existing ?? const BackupTarget(name: '', method: BackupMethod.restic, remote: '')).copyWith(
name: _nameController.text.trim(),
method: _method,
remote: _remoteController.text.trim(),
schedule: schedule.isEmpty ? null : schedule,
);
try {
await widget.onSave(draft);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Add backup target' : 'Edit backup target'),
content: Form(
key: _formKey,
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
DropdownButtonFormField<BackupMethod>(
initialValue: _method,
decoration: const InputDecoration(labelText: 'Method'),
items: [for (final m in _methods) DropdownMenuItem(value: m, child: Text(m.name))],
onChanged: (v) => setState(() => _method = v ?? _method),
),
const SizedBox(height: 14),
TextFormField(
controller: _remoteController,
decoration: InputDecoration(
labelText: 'Remote',
hintText: switch (_method) {
BackupMethod.restic => '/mnt/backup/repo or s3:s3.amazonaws.com/bucket/path or sftp:user@host:/path',
BackupMethod.rsync => 'user@host:/path',
BackupMethod.external => 'informational only — backup is managed outside the API',
},
helperText: _method == BackupMethod.restic
? 'Restic repository — encrypted with this node\'s restic password (Settings).'
: null,
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _scheduleController,
decoration: const InputDecoration(
labelText: 'Schedule (optional)',
hintText: '0 2 * * * — standard 5-field cron; empty = manual only',
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
),
],
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/models.dart';
import '../network/api_exception.dart';
import '../theme/theme_x.dart';
import 'mono_text.dart';
/// One backup target: identity + schedule/last-run caption, and either
/// Run/Edit/Delete actions or — while a run it triggered is in flight — a
/// live progress bar polled via [fetchProgress]. Polling is a plain local
/// [Timer] scoped to this row's lifetime, not a Riverpod provider: progress
/// only matters while this exact row is on screen and something is
/// actively running, so there's nothing to share or keep alive beyond that.
class BackupTargetRow extends StatefulWidget {
const BackupTargetRow({
super.key,
required this.target,
required this.onRun,
required this.fetchProgress,
required this.onSettled,
required this.onEdit,
required this.onDelete,
});
final BackupTarget target;
final Future<void> Function() onRun;
final Future<RunState> Function() fetchProgress;
final void Function(RunState finalState) onSettled;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
State<BackupTargetRow> createState() => _BackupTargetRowState();
}
class _BackupTargetRowState extends State<BackupTargetRow> {
Timer? _timer;
RunState? _runState;
bool _starting = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _handleRun() async {
setState(() => _starting = true);
try {
await widget.onRun();
setState(() {
_starting = false;
_runState = const RunState(running: true);
});
_poll();
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
} catch (e) {
if (!mounted) return;
setState(() => _starting = false);
final message = e is ApiException ? e.userMessage : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
Future<void> _poll() async {
final state = await widget.fetchProgress();
if (!mounted) return;
setState(() => _runState = state);
if (!state.running) {
_timer?.cancel();
_timer = null;
widget.onSettled(state);
}
}
@override
Widget build(BuildContext context) {
final target = widget.target;
final running = _runState?.running ?? false;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(target.name, style: context.text.labelLarge),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(target.method.name, style: context.dataStyles.dataMonoSmall),
),
],
),
const SizedBox(height: 3),
MonoText(target.remote, small: true),
const SizedBox(height: 3),
Text(_caption(target), style: context.text.bodySmall),
],
),
),
const SizedBox(width: 8),
if (running)
const SizedBox.shrink()
else ...[
_starting
? const Padding(
padding: EdgeInsets.all(8),
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
)
: IconButton(
tooltip: 'Run now',
icon: const Icon(Icons.play_arrow_rounded, size: 20),
onPressed: _handleRun,
),
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: widget.onEdit,
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: widget.onDelete,
),
],
],
),
if (running) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (_runState!.percent > 0 && _runState!.percent <= 100)
? _runState!.percent / 100
: null,
minHeight: 5,
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${_runState!.percent.toStringAsFixed(0)}%',
style: context.dataStyles.numericTabular.copyWith(fontSize: 12),
),
if (_runState!.eta != null) ...[
const SizedBox(width: 8),
Text('ETA ${_runState!.eta}', style: context.text.bodySmall),
],
if (_runState!.message?.isNotEmpty == true) ...[
const SizedBox(width: 8),
Expanded(
child: MonoText(_runState!.message!, small: true, maxLines: 1),
),
],
],
),
],
],
),
);
}
String _caption(BackupTarget target) {
final dateFormat = DateFormat('MMM d, HH:mm');
final parts = <String>[
target.schedule?.isNotEmpty == true ? 'Schedule: ${target.schedule}' : 'Manual only',
if (target.lastRun != null) 'last ${dateFormat.format(target.lastRun!.toLocal())}',
if (target.nextRun != null) 'next ${dateFormat.format(target.nextRun!.toLocal())}',
];
return parts.join(' · ');
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../theme/theme_x.dart';
import 'backup_target_row.dart';
/// A list of [BackupTargetRow]s plus an "Add target" action — shared by the
/// node Backups screen and the Services detail panel.
class BackupTargetsList extends StatelessWidget {
const BackupTargetsList({
super.key,
required this.targets,
required this.onAdd,
required this.onEdit,
required this.onDelete,
required this.onRun,
required this.fetchProgress,
required this.onSettled,
});
final List<BackupTarget> targets;
final VoidCallback onAdd;
final void Function(BackupTarget target) onEdit;
final void Function(BackupTarget target) onDelete;
final Future<void> Function(BackupTarget target) onRun;
final Future<RunState> Function(BackupTarget target) fetchProgress;
final void Function(BackupTarget target, RunState finalState) onSettled;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (targets.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('No backup targets configured.', style: context.text.bodySmall),
)
else
for (var i = 0; i < targets.length; i++) ...[
if (i > 0) const Divider(height: 1),
BackupTargetRow(
key: ValueKey(targets[i].id),
target: targets[i],
onRun: () => onRun(targets[i]),
fetchProgress: () => fetchProgress(targets[i]),
onSettled: (state) => onSettled(targets[i], state),
onEdit: () => onEdit(targets[i]),
onDelete: () => onDelete(targets[i]),
),
],
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add target'),
),
],
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// The bordered, horizontally-scrollable card wrapper used by every dense
/// table in the app (Services, Fleet, Updates, Backup history). A thin
/// shell around [DataTable] — column/row content stays screen-specific,
/// this just guarantees the same card chrome, spacing, and empty-state
/// fallback everywhere.
class DataTableScaffold extends StatelessWidget {
const DataTableScaffold({
super.key,
required this.columns,
required this.rows,
this.empty,
this.columnSpacing = 28,
this.horizontalMargin = 16,
});
final List<DataColumn> columns;
final List<DataRow> rows;
final Widget? empty;
final double columnSpacing;
final double horizontalMargin;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.colors.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: rows.isEmpty && empty != null
? empty
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: MediaQuery.sizeOf(context).width),
child: DataTable(
showCheckboxColumn: false,
columns: columns,
rows: rows,
columnSpacing: columnSpacing,
horizontalMargin: horizontalMargin,
headingRowHeight: 38,
dataRowMinHeight: 46,
dataRowMaxHeight: 58,
dividerThickness: 1,
),
),
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// Centered "nothing here yet" placeholder — no services discovered, no
/// connections saved, no update history. An optional action gives the user
/// the next step rather than a dead end.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 30, color: context.colors.outline),
const SizedBox(height: 12),
Text(title, style: context.text.titleMedium, textAlign: TextAlign.center),
if (message != null) ...[
const SizedBox(height: 4),
Text(
message!,
style: context.text.bodySmall,
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
OutlinedButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// Inline error state for a failed fetch — an `AsyncValue.error` case in a
/// screen body. Deliberately takes a plain [message] string rather than an
/// `ApiException` so `core/widgets` has no dependency on `core/network`;
/// callers resolve `exception.userMessage` before passing it in.
class ErrorBanner extends StatelessWidget {
const ErrorBanner({super.key, required this.message, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.status.critical.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: context.status.critical),
const SizedBox(width: 12),
Expanded(child: Text(message, style: context.text.bodyMedium)),
if (onRetry != null) ...[
const SizedBox(width: 12),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
],
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A technical value — compose file path, hostname, id, URL — rendered in
/// the monospace data face so it reads as "an exact string", distinct from
/// prose. Use for anything a user might copy-paste or match against a log.
class MonoText extends StatelessWidget {
const MonoText(
this.value, {
super.key,
this.small = false,
this.color,
this.maxLines = 1,
this.overflow = TextOverflow.ellipsis,
});
final String value;
final bool small;
final Color? color;
final int? maxLines;
final TextOverflow overflow;
@override
Widget build(BuildContext context) {
final base = small ? context.dataStyles.dataMonoSmall : context.dataStyles.dataMono;
return Text(
value,
maxLines: maxLines,
overflow: overflow,
style: color == null ? base : base.copyWith(color: color),
);
}
}
+295
View File
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../connections/connection_providers.dart';
import '../routing/route_paths.dart';
import '../theme/theme_mode_provider.dart';
import '../theme/theme_x.dart';
class _NavItem {
const _NavItem(this.path, this.label, this.icon, this.selectedIcon);
final String path;
final String label;
final IconData icon;
final IconData selectedIcon;
}
const _navItems = [
_NavItem(RoutePaths.dashboard, 'Dashboard', Icons.grid_view_outlined, Icons.grid_view_rounded),
_NavItem(RoutePaths.services, 'Services', Icons.layers_outlined, Icons.layers_rounded),
_NavItem(RoutePaths.backups, 'Backups', Icons.cloud_outlined, Icons.cloud_rounded),
_NavItem(RoutePaths.fleet, 'Fleet', Icons.hub_outlined, Icons.hub_rounded),
_NavItem(RoutePaths.updates, 'Updates', Icons.history_outlined, Icons.history_rounded),
_NavItem(RoutePaths.settings, 'Settings', Icons.tune_outlined, Icons.tune_rounded),
];
/// The persistent left nav rail + top bar frame wrapping every screen, per
/// the approved mockup. Wraps go_router's `ShellRoute` child.
class NavRailShell extends ConsumerWidget {
const NavRailShell({super.key, required this.currentPath, required this.child});
final String currentPath;
final Widget child;
int get _selectedIndex {
final index = _navItems.indexWhere((i) => currentPath.startsWith(i.path));
return index == -1 ? 0 : index;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final narrow = MediaQuery.sizeOf(context).width < 860;
return Scaffold(
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Rail(selectedIndex: _selectedIndex, narrow: narrow),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
child: Column(
children: [
_TopBar(currentPath: currentPath),
const Divider(height: 1, thickness: 1),
Expanded(child: child),
],
),
),
],
),
);
}
}
class _Rail extends ConsumerWidget {
const _Rail({required this.selectedIndex, required this.narrow});
final int selectedIndex;
final bool narrow;
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeConnection = ref.watch(activeConnectionProvider);
return SizedBox(
width: narrow ? 72 : 226,
child: Container(
color: context.theme.navigationRailTheme.backgroundColor,
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 14, 20),
child: Row(
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.hub_rounded, size: 16, color: context.colors.onPrimary),
),
if (!narrow) ...[
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('NodeMaster', style: context.text.titleMedium?.copyWith(fontSize: 14.5)),
Text('Manager', style: context.text.bodySmall),
],
),
),
],
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var i = 0; i < _navItems.length; i++)
_RailButton(item: _navItems[i], selected: i == selectedIndex, narrow: narrow),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: _ConnectionChip(name: activeConnection?.name, url: activeConnection?.baseUrl, narrow: narrow),
),
],
),
),
),
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({required this.item, required this.selected, required this.narrow});
final _NavItem item;
final bool selected;
final bool narrow;
@override
Widget build(BuildContext context) {
final fg = selected ? context.colors.secondary : context.text.bodyMedium?.color;
final bg = selected ? context.colors.primary.withValues(alpha: 0.14) : Colors.transparent;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Material(
color: bg,
borderRadius: BorderRadius.circular(7),
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: () => context.go(item.path),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: narrow ? 0 : 10, vertical: 9),
child: Row(
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Icon(selected ? item.selectedIcon : item.icon, size: 18, color: fg),
if (!narrow) ...[
const SizedBox(width: 10),
Text(
item.label,
style: context.text.bodyMedium?.copyWith(
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
],
),
),
),
),
);
}
}
class _ConnectionChip extends StatelessWidget {
const _ConnectionChip({required this.name, required this.url, required this.narrow});
final String? name;
final String? url;
final bool narrow;
@override
Widget build(BuildContext context) {
final dot = Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: name == null ? context.colors.outline : context.status.good,
shape: BoxShape.circle,
),
);
if (narrow) return Center(child: dot);
return Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
dot,
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name ?? 'No connection',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.labelLarge?.copyWith(fontSize: 12),
),
if (url != null)
Text(
url!.replaceFirst(RegExp(r'^https?://'), ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.dataStyles.dataMonoSmall,
),
],
),
),
],
),
);
}
}
const _titles = <String, (String, String Function(WidgetRef))>{
RoutePaths.dashboard: ('Dashboard', _connSubtitle),
RoutePaths.services: ('Services', _connSubtitle),
RoutePaths.backups: ('Backups', _connSubtitle),
RoutePaths.fleet: ('Fleet', _connSubtitle),
RoutePaths.updates: ('Updates', _connSubtitle),
RoutePaths.settings: ('Settings', _connSubtitle),
};
String _connSubtitle(WidgetRef ref) {
final connection = ref.watch(activeConnectionProvider);
return connection == null ? 'No connection configured' : 'Connected to ${connection.name}';
}
class _TopBar extends ConsumerWidget {
const _TopBar({required this.currentPath});
final String currentPath;
@override
Widget build(BuildContext context, WidgetRef ref) {
final entry = _titles.entries.firstWhere(
(e) => currentPath.startsWith(e.key),
orElse: () => _titles.entries.first,
);
final title = entry.value.$1;
final subtitle = entry.value.$2(ref);
final mode = ref.watch(themeModeControllerProvider);
final effectiveDark = switch (mode) {
ThemeMode.dark => true,
ThemeMode.light => false,
ThemeMode.system => MediaQuery.platformBrightnessOf(context) == Brightness.dark,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: context.text.titleLarge),
Text(subtitle, style: context.text.bodySmall),
],
),
),
IconButton(
tooltip: effectiveDark ? 'Switch to light theme' : 'Switch to dark theme',
icon: Icon(effectiveDark ? Icons.dark_mode_outlined : Icons.light_mode_outlined),
onPressed: () => ref
.read(themeModeControllerProvider.notifier)
.setThemeMode(effectiveDark ? ThemeMode.light : ThemeMode.dark),
),
],
),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A right-anchored detail panel over the still-visible list behind it —
/// the "inspect a record without losing list context" pattern used by
/// Services (and reused by Fleet's per-node cards). Place as the last child
/// of a [Stack] alongside the screen's main content; it fills the stack
/// itself via [Positioned.fill] and no-ops when [open] is false.
class SlideOverPanel extends StatelessWidget {
const SlideOverPanel({
super.key,
required this.open,
required this.onClose,
required this.title,
this.subtitle,
required this.body,
this.actions = const [],
this.headerActions = const [],
this.width = 420,
});
final bool open;
final VoidCallback onClose;
final String title;
final Widget? subtitle;
final Widget body;
final List<Widget> actions;
/// Icon buttons shown before the close button — for actions that belong
/// with the record identity (e.g. Edit) rather than the primary action
/// row at the bottom, which stays reserved for the few actions that need
/// full label width.
final List<Widget> headerActions;
final double width;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion ? Duration.zero : const Duration(milliseconds: 200);
return Positioned.fill(
child: IgnorePointer(
ignoring: !open,
child: Stack(
children: [
AnimatedOpacity(
opacity: open ? 1 : 0,
duration: duration,
child: GestureDetector(
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.35)),
),
),
Align(
alignment: Alignment.centerRight,
child: ClipRect(
child: AnimatedSlide(
offset: open ? Offset.zero : const Offset(1, 0),
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: width,
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.92),
height: double.infinity,
decoration: BoxDecoration(
color: context.colors.surface,
border: Border(left: BorderSide(color: context.colors.outlineVariant)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.18), blurRadius: 48, offset: const Offset(-8, 0)),
],
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: context.text.titleLarge),
if (subtitle != null) ...[
const SizedBox(height: 6),
subtitle!,
],
],
),
),
...headerActions,
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close, size: 19),
tooltip: 'Close',
),
],
),
),
const Divider(height: 1),
Expanded(child: body),
if (actions.isNotEmpty) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: actions[i]),
],
],
),
),
],
],
),
),
),
),
),
),
],
),
),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/theme_x.dart';
/// A single-series trend line — currently just backup-duration history.
/// Hand-rolled `CustomPainter` rather than a charting dependency: one chart
/// in the whole app, a specific visual spec (faint grid, thin line,
/// emphasized endpoint), full control for near-zero cost.
class Sparkline extends StatelessWidget {
const Sparkline({super.key, required this.values, this.width = 240, this.height = 52});
final List<double> values;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: CustomPaint(
painter: _SparklinePainter(
values: values,
lineColor: context.colors.primary,
gridColor: context.colors.outlineVariant,
),
),
);
}
}
class _SparklinePainter extends CustomPainter {
_SparklinePainter({required this.values, required this.lineColor, required this.gridColor});
final List<double> values;
final Color lineColor;
final Color gridColor;
static const _topPad = 6.0;
static const _bottomPad = 6.0;
@override
void paint(Canvas canvas, Size size) {
if (values.isEmpty) return;
final minV = values.reduce(math.min);
final maxV = values.reduce(math.max);
final range = (maxV - minV).abs() < 1e-9 ? 1.0 : maxV - minV;
final plotHeight = size.height - _topPad - _bottomPad;
final gridPaint = Paint()
..color = gridColor
..strokeWidth = 1;
canvas.drawLine(Offset(0, _topPad), Offset(size.width, _topPad), gridPaint);
canvas.drawLine(
Offset(0, size.height - _bottomPad),
Offset(size.width, size.height - _bottomPad),
gridPaint,
);
if (values.length == 1) {
final y = _topPad + plotHeight / 2;
canvas.drawCircle(Offset(size.width, y), 3.4, Paint()..color = lineColor);
return;
}
final dx = size.width / (values.length - 1);
final points = [
for (var i = 0; i < values.length; i++)
Offset(i * dx, _topPad + plotHeight - ((values[i] - minV) / range) * plotHeight),
];
final fillPath = Path()..moveTo(points.first.dx, size.height - _bottomPad);
for (final p in points) {
fillPath.lineTo(p.dx, p.dy);
}
fillPath
..lineTo(points.last.dx, size.height - _bottomPad)
..close();
canvas.drawPath(fillPath, Paint()..color = lineColor.withValues(alpha: 0.14));
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (final p in points.skip(1)) {
linePath.lineTo(p.dx, p.dy);
}
canvas.drawPath(
linePath,
Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round,
);
canvas.drawCircle(points.last, 3.4, Paint()..color = lineColor);
}
@override
bool shouldRepaint(covariant _SparklinePainter oldDelegate) =>
oldDelegate.values != values ||
oldDelegate.lineColor != lineColor ||
oldDelegate.gridColor != gridColor;
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../theme/status_colors.dart';
import '../theme/theme_x.dart';
/// A dashboard summary tile: an uppercase label, a large tabular-nums value
/// (with an optional muted trailing qualifier, e.g. "7 / 9"), a caption, and
/// a left severity stripe communicating worst-case state at a glance —
/// severity is a first-class visual channel here, not just the number.
class StatTile extends StatelessWidget {
const StatTile({
super.key,
required this.label,
required this.value,
this.valueQualifier,
this.caption,
this.severity = Severity.good,
});
final String label;
final String value;
final String? valueQualifier;
final String? caption;
final Severity severity;
@override
Widget build(BuildContext context) {
final stripeColor = severity.resolve(context.status, muted: context.colors.outline);
return Container(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: context.colors.outlineVariant),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 2, offset: const Offset(0, 1)),
],
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
children: [
Container(width: 3, color: stripeColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(13, 14, 13, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: context.text.titleSmall),
const SizedBox(height: 8),
Text.rich(
TextSpan(
style: context.dataStyles.numericTabular.copyWith(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
children: [
TextSpan(text: value),
if (valueQualifier != null)
TextSpan(
text: ' $valueQualifier',
style: context.text.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
],
),
),
if (caption != null) ...[
const SizedBox(height: 4),
Text(
caption!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
],
),
),
),
],
),
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../theme/status_colors.dart';
import '../theme/theme_x.dart';
/// A dot + label badge encoding state — service up/down/unknown, backup or
/// update success/fail, node reachable/unreachable. The same component
/// everywhere a status appears so a given [Severity] always reads the same
/// color, matching the design rule that status color is reserved and never
/// doubles as the brand accent.
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, required this.severity});
final String label;
final Severity severity;
@override
Widget build(BuildContext context) {
final color = severity.resolve(context.status, muted: context.colors.outline);
return Container(
padding: const EdgeInsets.fromLTRB(7, 3, 9, 3),
decoration: BoxDecoration(
// 8% tint, not 14% — measured against the dark surface, a same-hue
// tint background caps critical's text contrast around ~3.5:1 no
// matter the blend ratio (text and background converge toward the
// same hue as alpha rises, and approach the plain-surface ceiling
// as it falls); 8% is close to that ceiling while keeping
// good/warning/serious comfortably at 4.5:1+. Below AA-for-text on
// its own, mitigated the same way the design system's status
// palette always mitigates this: label text, never color alone.
color: severity == Severity.neutral
? context.colors.surfaceContainerHighest
: Color.alphaBlend(color.withValues(alpha: 0.08), context.colors.surface),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: context.text.labelMedium?.copyWith(
color: severity == Severity.neutral ? context.colors.onSurfaceVariant : color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
@@ -0,0 +1,79 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/models/models.dart';
import '../../core/network/api_client_provider.dart';
import '../../core/network/node_master_api_client.dart';
part 'node_repository.g.dart';
/// Wraps the `/node/*` endpoints — this host's own identity, backup config,
/// scan trigger, and update log.
class NodeRepository {
const NodeRepository(this._client);
final NodeMasterApiClient _client;
Future<NodeInfo> getNode() => _client.getNode();
Future<void> updateNode(NodeInfo node) => _client.updateNode(node);
Future<BackupConfig> getBackup() => _client.getNodeBackup();
Future<void> updateBackup(BackupConfig config) => _client.updateNodeBackup(config);
Future<void> runBackup() => _client.runNodeBackup();
Future<List<BackupTarget>> getBackupTargets() => _client.getNodeBackupTargets();
Future<BackupTarget> addBackupTarget(BackupTarget target) => _client.addNodeBackupTarget(target);
Future<BackupTarget> updateBackupTarget(String targetId, BackupTarget target) =>
_client.updateNodeBackupTarget(targetId, target);
Future<void> deleteBackupTarget(String targetId) => _client.deleteNodeBackupTarget(targetId);
Future<void> runBackupTarget(String targetId) => _client.runNodeBackupTarget(targetId);
Future<RunState> getBackupTargetProgress(String targetId) =>
_client.getNodeBackupTargetProgress(targetId);
Future<List<RunState>> getBackupProgress() => _client.getNodeBackupProgress();
Future<ScanResult> scan({String? folder}) => _client.scan(folder: folder);
Future<List<UpdateRecord>> getUpdates() => _client.getUpdates();
Future<UpdateRecord> addUpdate(UpdateRecord record) => _client.addUpdate(record);
Future<void> deleteUpdate(String id) => _client.deleteUpdate(id);
}
/// Null when no connection is active — mirrors [apiClientProvider] so every
/// downstream provider that watches this gets the same "no connection"
/// signal without re-deriving it from the connection state itself.
@riverpod
NodeRepository? nodeRepository(Ref ref) {
final client = ref.watch(apiClientProvider);
if (client == null) return null;
return NodeRepository(client);
}
/// The active connection's node info, or null if no connection is
/// configured. Exceptions from an unreachable/misbehaving node surface as
/// [AsyncError] — this is also the smoke-test path proving the client →
/// repository → provider chain works end to end against a live server.
@riverpod
Future<NodeInfo?> currentNode(Ref ref) async {
final repo = ref.watch(nodeRepositoryProvider);
if (repo == null) return null;
return repo.getNode();
}
/// The node-level backup config — manual-refresh only, same reasoning as
/// `servicesListProvider`: this backs a detail-heavy screen with its own
/// edit dialog, where a silent background refresh could yank state out
/// from under an in-progress edit.
@riverpod
Future<BackupConfig?> backupConfig(Ref ref) async {
final repo = ref.watch(nodeRepositoryProvider);
if (repo == null) return null;
return repo.getBackup();
}
/// The OS update log — manual-refresh only, same reasoning as the other
/// list providers in this app.
@riverpod
Future<List<UpdateRecord>> updatesList(Ref ref) async {
final repo = ref.watch(nodeRepositoryProvider);
if (repo == null) return [];
return repo.getUpdates();
}
@@ -0,0 +1,216 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'node_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Null when no connection is active — mirrors [apiClientProvider] so every
/// downstream provider that watches this gets the same "no connection"
/// signal without re-deriving it from the connection state itself.
@ProviderFor(nodeRepository)
const nodeRepositoryProvider = NodeRepositoryProvider._();
/// Null when no connection is active — mirrors [apiClientProvider] so every
/// downstream provider that watches this gets the same "no connection"
/// signal without re-deriving it from the connection state itself.
final class NodeRepositoryProvider
extends
$FunctionalProvider<NodeRepository?, NodeRepository?, NodeRepository?>
with $Provider<NodeRepository?> {
/// Null when no connection is active — mirrors [apiClientProvider] so every
/// downstream provider that watches this gets the same "no connection"
/// signal without re-deriving it from the connection state itself.
const NodeRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'nodeRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$nodeRepositoryHash();
@$internal
@override
$ProviderElement<NodeRepository?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
NodeRepository? create(Ref ref) {
return nodeRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NodeRepository? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NodeRepository?>(value),
);
}
}
String _$nodeRepositoryHash() => r'c7cf7aac4b407fd4a21a56f5c3d34c81fa9abe5e';
/// The active connection's node info, or null if no connection is
/// configured. Exceptions from an unreachable/misbehaving node surface as
/// [AsyncError] — this is also the smoke-test path proving the client →
/// repository → provider chain works end to end against a live server.
@ProviderFor(currentNode)
const currentNodeProvider = CurrentNodeProvider._();
/// The active connection's node info, or null if no connection is
/// configured. Exceptions from an unreachable/misbehaving node surface as
/// [AsyncError] — this is also the smoke-test path proving the client →
/// repository → provider chain works end to end against a live server.
final class CurrentNodeProvider
extends
$FunctionalProvider<
AsyncValue<NodeInfo?>,
NodeInfo?,
FutureOr<NodeInfo?>
>
with $FutureModifier<NodeInfo?>, $FutureProvider<NodeInfo?> {
/// The active connection's node info, or null if no connection is
/// configured. Exceptions from an unreachable/misbehaving node surface as
/// [AsyncError] — this is also the smoke-test path proving the client →
/// repository → provider chain works end to end against a live server.
const CurrentNodeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentNodeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentNodeHash();
@$internal
@override
$FutureProviderElement<NodeInfo?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<NodeInfo?> create(Ref ref) {
return currentNode(ref);
}
}
String _$currentNodeHash() => r'e1742b62ba610695d0e8a560d554902fa29d128c';
/// The node-level backup config — manual-refresh only, same reasoning as
/// `servicesListProvider`: this backs a detail-heavy screen with its own
/// edit dialog, where a silent background refresh could yank state out
/// from under an in-progress edit.
@ProviderFor(backupConfig)
const backupConfigProvider = BackupConfigProvider._();
/// The node-level backup config — manual-refresh only, same reasoning as
/// `servicesListProvider`: this backs a detail-heavy screen with its own
/// edit dialog, where a silent background refresh could yank state out
/// from under an in-progress edit.
final class BackupConfigProvider
extends
$FunctionalProvider<
AsyncValue<BackupConfig?>,
BackupConfig?,
FutureOr<BackupConfig?>
>
with $FutureModifier<BackupConfig?>, $FutureProvider<BackupConfig?> {
/// The node-level backup config — manual-refresh only, same reasoning as
/// `servicesListProvider`: this backs a detail-heavy screen with its own
/// edit dialog, where a silent background refresh could yank state out
/// from under an in-progress edit.
const BackupConfigProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'backupConfigProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$backupConfigHash();
@$internal
@override
$FutureProviderElement<BackupConfig?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<BackupConfig?> create(Ref ref) {
return backupConfig(ref);
}
}
String _$backupConfigHash() => r'd3eeedd7536dd8610b34b922bd5658c507c91efe';
/// The OS update log — manual-refresh only, same reasoning as the other
/// list providers in this app.
@ProviderFor(updatesList)
const updatesListProvider = UpdatesListProvider._();
/// The OS update log — manual-refresh only, same reasoning as the other
/// list providers in this app.
final class UpdatesListProvider
extends
$FunctionalProvider<
AsyncValue<List<UpdateRecord>>,
List<UpdateRecord>,
FutureOr<List<UpdateRecord>>
>
with
$FutureModifier<List<UpdateRecord>>,
$FutureProvider<List<UpdateRecord>> {
/// The OS update log — manual-refresh only, same reasoning as the other
/// list providers in this app.
const UpdatesListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'updatesListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$updatesListHash();
@$internal
@override
$FutureProviderElement<List<UpdateRecord>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<UpdateRecord>> create(Ref ref) {
return updatesList(ref);
}
}
String _$updatesListHash() => r'01c6dd3b69dfd8f98e57f29e43e3fa60d014a6e4';
@@ -0,0 +1,60 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/models/models.dart';
import '../../core/network/api_client_provider.dart';
import '../../core/network/node_master_api_client.dart';
import '../../core/polling/polling_async_notifier.dart';
part 'nodes_repository.g.dart';
/// Wraps the `/nodes/*` endpoints — the registry of sibling NodeMaster
/// hosts and the server-side fan-out status view.
class NodesRepository {
const NodesRepository(this._client);
final NodeMasterApiClient _client;
Future<List<RemoteNode>> getAll() => _client.getAllRemoteNodes();
Future<RemoteNode> get(String id) => _client.getRemoteNode(id);
Future<RemoteNode> add(RemoteNode node) => _client.addRemoteNode(node);
Future<RemoteNode> update(String id, RemoteNode node) => _client.updateRemoteNode(id, node);
Future<void> delete(String id) => _client.deleteRemoteNode(id);
Future<List<AggregatedNodeStatus>> getAggregated() => _client.getAggregated();
}
/// Null when no connection is active, mirroring [apiClientProvider].
@riverpod
NodesRepository? nodesRepository(Ref ref) {
final client = ref.watch(apiClientProvider);
if (client == null) return null;
return NodesRepository(client);
}
/// The registered-nodes list. Manual-refresh only, same reasoning as
/// `servicesListProvider` — this backs Fleet's registry table (M6).
@riverpod
Future<List<RemoteNode>> remoteNodesList(Ref ref) async {
final repo = ref.watch(nodesRepositoryProvider);
if (repo == null) return [];
return repo.getAll();
}
/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared
/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid
/// (M6) — one poll, cached by Riverpod, rather than each screen running
/// its own duplicate timer against the same endpoint.
@riverpod
class AggregatedNodes extends _$AggregatedNodes with PollingMixin<List<AggregatedNodeStatus>> {
@override
Duration get interval => const Duration(seconds: 25);
@override
Future<List<AggregatedNodeStatus>> fetch() async {
final repo = ref.watch(nodesRepositoryProvider);
if (repo == null) return [];
return repo.getAggregated();
}
@override
Future<List<AggregatedNodeStatus>> build() => startPolling();
}
@@ -0,0 +1,180 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nodes_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Null when no connection is active, mirroring [apiClientProvider].
@ProviderFor(nodesRepository)
const nodesRepositoryProvider = NodesRepositoryProvider._();
/// Null when no connection is active, mirroring [apiClientProvider].
final class NodesRepositoryProvider
extends
$FunctionalProvider<
NodesRepository?,
NodesRepository?,
NodesRepository?
>
with $Provider<NodesRepository?> {
/// Null when no connection is active, mirroring [apiClientProvider].
const NodesRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'nodesRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$nodesRepositoryHash();
@$internal
@override
$ProviderElement<NodesRepository?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
NodesRepository? create(Ref ref) {
return nodesRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NodesRepository? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NodesRepository?>(value),
);
}
}
String _$nodesRepositoryHash() => r'7dd03a46ea79ae87bb251ff273ff77a634a53f5f';
/// The registered-nodes list. Manual-refresh only, same reasoning as
/// `servicesListProvider` — this backs Fleet's registry table (M6).
@ProviderFor(remoteNodesList)
const remoteNodesListProvider = RemoteNodesListProvider._();
/// The registered-nodes list. Manual-refresh only, same reasoning as
/// `servicesListProvider` — this backs Fleet's registry table (M6).
final class RemoteNodesListProvider
extends
$FunctionalProvider<
AsyncValue<List<RemoteNode>>,
List<RemoteNode>,
FutureOr<List<RemoteNode>>
>
with $FutureModifier<List<RemoteNode>>, $FutureProvider<List<RemoteNode>> {
/// The registered-nodes list. Manual-refresh only, same reasoning as
/// `servicesListProvider` — this backs Fleet's registry table (M6).
const RemoteNodesListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'remoteNodesListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$remoteNodesListHash();
@$internal
@override
$FutureProviderElement<List<RemoteNode>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<RemoteNode>> create(Ref ref) {
return remoteNodesList(ref);
}
}
String _$remoteNodesListHash() => r'a2d3bd0b2e2f2cd54a7590bb5ace8a0c63e3d2aa';
/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared
/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid
/// (M6) — one poll, cached by Riverpod, rather than each screen running
/// its own duplicate timer against the same endpoint.
@ProviderFor(AggregatedNodes)
const aggregatedNodesProvider = AggregatedNodesProvider._();
/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared
/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid
/// (M6) — one poll, cached by Riverpod, rather than each screen running
/// its own duplicate timer against the same endpoint.
final class AggregatedNodesProvider
extends
$AsyncNotifierProvider<AggregatedNodes, List<AggregatedNodeStatus>> {
/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared
/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid
/// (M6) — one poll, cached by Riverpod, rather than each screen running
/// its own duplicate timer against the same endpoint.
const AggregatedNodesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'aggregatedNodesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$aggregatedNodesHash();
@$internal
@override
AggregatedNodes create() => AggregatedNodes();
}
String _$aggregatedNodesHash() => r'14dd7a1e7d4b9caf2d7c975613555977c037dceb';
/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared
/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid
/// (M6) — one poll, cached by Riverpod, rather than each screen running
/// its own duplicate timer against the same endpoint.
abstract class _$AggregatedNodes
extends $AsyncNotifier<List<AggregatedNodeStatus>> {
FutureOr<List<AggregatedNodeStatus>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref
as $Ref<
AsyncValue<List<AggregatedNodeStatus>>,
List<AggregatedNodeStatus>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<AggregatedNodeStatus>>,
List<AggregatedNodeStatus>
>,
AsyncValue<List<AggregatedNodeStatus>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@@ -0,0 +1,57 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/models/models.dart';
import '../../core/network/api_client_provider.dart';
import '../../core/network/node_master_api_client.dart';
part 'services_repository.g.dart';
/// Wraps the `/services/*` endpoints — the compose-based services on this
/// host and their start/stop/backup actions.
class ServicesRepository {
const ServicesRepository(this._client);
final NodeMasterApiClient _client;
Future<List<Service>> getAll() => _client.getAllServices();
Future<Service> get(String id) => _client.getService(id);
Future<Service> add(Service service) => _client.addService(service);
Future<Service> update(String id, Service service) => _client.updateService(id, service);
Future<void> delete(String id) => _client.deleteService(id);
Future<void> start(String id) => _client.startService(id);
Future<void> stop(String id) => _client.stopService(id);
Future<void> backupNow(String id) => _client.backupService(id);
Future<List<BackupTarget>> getBackupTargets(String id) => _client.getServiceBackupTargets(id);
Future<BackupTarget> addBackupTarget(String id, BackupTarget target) =>
_client.addServiceBackupTarget(id, target);
Future<BackupTarget> updateBackupTarget(String id, String targetId, BackupTarget target) =>
_client.updateServiceBackupTarget(id, targetId, target);
Future<void> deleteBackupTarget(String id, String targetId) =>
_client.deleteServiceBackupTarget(id, targetId);
Future<void> runBackupTarget(String id, String targetId) =>
_client.runServiceBackupTarget(id, targetId);
Future<RunState> getBackupTargetProgress(String id, String targetId) =>
_client.getServiceBackupTargetProgress(id, targetId);
Future<List<RunState>> getBackupProgress(String id) => _client.getServiceBackupProgress(id);
}
/// Null when no connection is active, mirroring [apiClientProvider].
@riverpod
ServicesRepository? servicesRepository(Ref ref) {
final client = ref.watch(apiClientProvider);
if (client == null) return null;
return ServicesRepository(client);
}
/// The full services list for the active connection. Manual-refresh only
/// (not polled) — this is a detail-heavy table screen where a mid-edit
/// slide-over silently refreshing underneath the user would be worse than
/// slightly stale data; Dashboard's polled summary is where "did something
/// change" gets noticed at a glance.
@riverpod
Future<List<Service>> servicesList(Ref ref) async {
final repo = ref.watch(servicesRepositoryProvider);
if (repo == null) return [];
return repo.getAll();
}
@@ -0,0 +1,118 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'services_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Null when no connection is active, mirroring [apiClientProvider].
@ProviderFor(servicesRepository)
const servicesRepositoryProvider = ServicesRepositoryProvider._();
/// Null when no connection is active, mirroring [apiClientProvider].
final class ServicesRepositoryProvider
extends
$FunctionalProvider<
ServicesRepository?,
ServicesRepository?,
ServicesRepository?
>
with $Provider<ServicesRepository?> {
/// Null when no connection is active, mirroring [apiClientProvider].
const ServicesRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'servicesRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$servicesRepositoryHash();
@$internal
@override
$ProviderElement<ServicesRepository?> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ServicesRepository? create(Ref ref) {
return servicesRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ServicesRepository? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ServicesRepository?>(value),
);
}
}
String _$servicesRepositoryHash() =>
r'6eab348f1911803394716fd8f90a079bfe5cbda1';
/// The full services list for the active connection. Manual-refresh only
/// (not polled) — this is a detail-heavy table screen where a mid-edit
/// slide-over silently refreshing underneath the user would be worse than
/// slightly stale data; Dashboard's polled summary is where "did something
/// change" gets noticed at a glance.
@ProviderFor(servicesList)
const servicesListProvider = ServicesListProvider._();
/// The full services list for the active connection. Manual-refresh only
/// (not polled) — this is a detail-heavy table screen where a mid-edit
/// slide-over silently refreshing underneath the user would be worse than
/// slightly stale data; Dashboard's polled summary is where "did something
/// change" gets noticed at a glance.
final class ServicesListProvider
extends
$FunctionalProvider<
AsyncValue<List<Service>>,
List<Service>,
FutureOr<List<Service>>
>
with $FutureModifier<List<Service>>, $FutureProvider<List<Service>> {
/// The full services list for the active connection. Manual-refresh only
/// (not polled) — this is a detail-heavy table screen where a mid-edit
/// slide-over silently refreshing underneath the user would be worse than
/// slightly stale data; Dashboard's polled summary is where "did something
/// change" gets noticed at a glance.
const ServicesListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'servicesListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$servicesListHash();
@$internal
@override
$FutureProviderElement<List<Service>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Service>> create(Ref ref) {
return servicesList(ref);
}
}
String _$servicesListHash() => r'e9d080c9ce0c0796d39c663ba79d7b67a2cf3adc';
+267
View File
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/models/models.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/theme_x.dart';
import '../../core/widgets/backup_target_form_dialog.dart';
import '../../core/widgets/backup_targets_list.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/error_banner.dart';
import '../../core/widgets/sparkline.dart';
import '../../data/repositories/node_repository.dart';
import 'widgets/backup_config_form_dialog.dart';
import 'widgets/backup_history_table.dart';
class BackupsScreen extends ConsumerWidget {
const BackupsScreen({super.key});
Future<void> _runNow(BuildContext context, WidgetRef ref) async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) return;
try {
await repo.runBackup();
ref.invalidate(backupConfigProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Backup started')));
}
} catch (e) {
final message = e is ApiException ? e.userMessage : e.toString();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
}
Future<void> _addTarget(BuildContext context, WidgetRef ref) {
return showBackupTargetFormDialog(
context,
onSave: (draft) async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.addBackupTarget(draft);
ref.invalidate(backupConfigProvider);
},
);
}
Future<void> _editTarget(BuildContext context, WidgetRef ref, BackupTarget target) {
return showBackupTargetFormDialog(
context,
existing: target,
onSave: (draft) async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.updateBackupTarget(target.id, draft);
ref.invalidate(backupConfigProvider);
},
);
}
Future<void> _deleteTarget(BuildContext context, WidgetRef ref, BackupTarget target) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove backup target?'),
content: Text('This removes "${target.name}" from the node\'s backup targets.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: context.status.critical),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true) return;
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) return;
try {
await repo.deleteBackupTarget(target.id);
ref.invalidate(backupConfigProvider);
} catch (e) {
final message = e is ApiException ? e.userMessage : e.toString();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
}
Future<void> _runTarget(WidgetRef ref, BackupTarget target) async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.runBackupTarget(target.id);
}
Future<RunState> _fetchProgress(WidgetRef ref, BackupTarget target) async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) return const RunState();
return repo.getBackupTargetProgress(target.id);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final configAsync = ref.watch(backupConfigProvider);
return configAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: ErrorBanner(
message: error is ApiException ? error.userMessage : error.toString(),
onRetry: () => ref.invalidate(backupConfigProvider),
),
),
data: (config) {
if (config == null) {
return const Center(
child: EmptyState(icon: Icons.cloud_outlined, title: 'No connection configured'),
);
}
return ListView(
padding: const EdgeInsets.all(24),
children: [
_ConfigCard(config: config, onRunNow: () => _runNow(context, ref)),
const SizedBox(height: 22),
Text('BACKUP TARGETS', style: context.text.titleSmall),
const SizedBox(height: 10),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: BackupTargetsList(
targets: config.targets,
onAdd: () => _addTarget(context, ref),
onEdit: (target) => _editTarget(context, ref, target),
onDelete: (target) => _deleteTarget(context, ref, target),
onRun: (target) => _runTarget(ref, target),
fetchProgress: (target) => _fetchProgress(ref, target),
onSettled: (target, state) => ref.invalidate(backupConfigProvider),
),
),
),
const SizedBox(height: 22),
_DurationTrendCard(history: config.history),
const SizedBox(height: 22),
Text('EXECUTION HISTORY', style: context.text.titleSmall),
const SizedBox(height: 10),
BackupHistoryTable(history: config.history, targets: config.targets),
],
);
},
);
}
}
class _ConfigCard extends StatelessWidget {
const _ConfigCard({required this.config, required this.onRunNow});
final BackupConfig config;
final VoidCallback onRunNow;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('EEE, MMM d · HH:mm');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Node backup configuration', style: context.text.titleMedium),
Row(
children: [
OutlinedButton.icon(
onPressed: () => showBackupConfigFormDialog(context, existing: config),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('Edit'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: onRunNow,
icon: const Icon(Icons.cloud_outlined, size: 16),
label: const Text('Run all now'),
),
],
),
],
),
const Divider(height: 24),
_kv(context, 'Source path', config.sourcePath?.isNotEmpty == true ? config.sourcePath! : 'Not set'),
_kv(
context,
'Next run',
config.nextRun == null ? 'Not scheduled' : dateFormat.format(config.nextRun!.toLocal()),
),
_kv(
context,
'Last run',
config.lastRun == null ? 'Never' : dateFormat.format(config.lastRun!.toLocal()),
),
],
),
),
);
}
Widget _kv(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
SizedBox(width: 120, child: Text(label, style: context.text.bodySmall)),
Expanded(child: Text(value, style: context.text.bodyMedium)),
],
),
);
}
}
class _DurationTrendCard extends StatelessWidget {
const _DurationTrendCard({required this.history});
final List<BackupExecution> history;
@override
Widget build(BuildContext context) {
final sorted = [...history]..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final recent = sorted.length > 10 ? sorted.sublist(sorted.length - 10) : sorted;
if (recent.isEmpty) {
return const SizedBox.shrink();
}
final values = [for (final e in recent) e.durationSeconds.toDouble()];
final latest = recent.last.durationSeconds;
final avg = values.reduce((a, b) => a + b) / values.length;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Sparkline(values: values),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Duration trend', style: context.text.titleMedium),
const SizedBox(height: 4),
Text(
'Latest ${latest}s · last ${recent.length}-run avg ${avg.toStringAsFixed(1)}s',
style: context.text.bodySmall,
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/models.dart';
import '../../../core/network/api_exception.dart';
import '../../../data/repositories/node_repository.dart';
/// Edit dialog for the node-level [BackupConfig]'s source path. Target
/// management (add/edit/remove/run/schedule) lives in the dedicated
/// [BackupTargetsList] on the Backups screen, not in this dialog.
Future<void> showBackupConfigFormDialog(BuildContext context, {required BackupConfig existing}) {
return showDialog<void>(
context: context,
builder: (context) => _BackupConfigFormDialog(existing: existing),
);
}
class _BackupConfigFormDialog extends ConsumerStatefulWidget {
const _BackupConfigFormDialog({required this.existing});
final BackupConfig existing;
@override
ConsumerState<_BackupConfigFormDialog> createState() => _BackupConfigFormDialogState();
}
class _BackupConfigFormDialogState extends ConsumerState<_BackupConfigFormDialog> {
late final _sourcePathController = TextEditingController(text: widget.existing.sourcePath);
bool _saving = false;
String? _error;
@override
void dispose() {
_sourcePathController.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) {
setState(() {
_saving = false;
_error = 'No active connection.';
});
return;
}
final draft = widget.existing.copyWith(sourcePath: _sourcePathController.text.trim());
try {
await repo.updateBackup(draft);
ref.invalidate(backupConfigProvider);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Edit backup configuration'),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _sourcePathController,
decoration: const InputDecoration(labelText: 'Source path', hintText: '/opt'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
),
],
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/models/models.dart';
import '../../../core/models/severity_mapping.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/widgets/data_table_scaffold.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/mono_text.dart';
import '../../../core/widgets/status_pill.dart';
class BackupHistoryTable extends StatelessWidget {
const BackupHistoryTable({super.key, required this.history, required this.targets});
final List<BackupExecution> history;
final List<BackupTarget> targets;
String _targetName(String? targetId) {
if (targetId == null) return '';
for (final t in targets) {
if (t.id == targetId) return t.name;
}
return targetId;
}
@override
Widget build(BuildContext context) {
final sorted = [...history]..sort((a, b) => b.timestamp.compareTo(a.timestamp));
final timeFormat = DateFormat('yyyy-MM-dd HH:mm');
return DataTableScaffold(
empty: const EmptyState(
icon: Icons.cloud_outlined,
title: 'No backup history yet',
message: 'Run a backup to see execution results here.',
),
columns: const [
DataColumn(label: Text('Timestamp')),
DataColumn(label: Text('Target')),
DataColumn(label: Text('Result')),
DataColumn(label: Text('Duration')),
DataColumn(label: Text('Message')),
],
rows: [
for (final execution in sorted)
DataRow(
cells: [
DataCell(MonoText(timeFormat.format(execution.timestamp.toLocal()))),
DataCell(Text(_targetName(execution.targetId))),
DataCell(
StatusPill(
label: execution.success ? 'success' : 'failed',
severity: severityForSuccess(execution.success),
),
),
DataCell(
Text(
'${execution.durationSeconds}s',
style: context.dataStyles.numericTabular,
),
),
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: MonoText(execution.message, small: true, maxLines: 1),
),
),
],
),
],
);
}
}
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/models.dart';
import '../../core/models/severity_mapping.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/status_colors.dart';
import '../../core/theme/theme_x.dart';
import '../../core/utils/relative_time.dart';
import '../../core/widgets/error_banner.dart';
import '../../core/widgets/stat_tile.dart';
import '../../data/repositories/nodes_repository.dart';
import 'providers/dashboard_summary.dart';
import 'providers/dashboard_summary_provider.dart';
import 'widgets/activity_feed.dart';
import 'widgets/quick_actions_row.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summaryAsync = ref.watch(dashboardSummaryProvider);
final fleetAsync = ref.watch(aggregatedNodesProvider);
return summaryAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: ErrorBanner(
message: error is ApiException ? error.userMessage : error.toString(),
onRetry: () => ref.invalidate(dashboardSummaryProvider),
),
),
data: (summary) => ListView(
padding: const EdgeInsets.all(24),
children: [
_StatRow(summary: summary, fleetAsync: fleetAsync),
const SizedBox(height: 22),
Text('QUICK ACTIONS', style: context.text.titleSmall),
const SizedBox(height: 10),
const QuickActionsRow(),
const SizedBox(height: 22),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('RECENT ACTIVITY', style: context.text.titleSmall),
],
),
const SizedBox(height: 10),
Card(child: ActivityFeed(node: summary.node)),
],
),
);
}
}
class _StatRow extends StatelessWidget {
const _StatRow({required this.summary, required this.fleetAsync});
final DashboardSummary summary;
final AsyncValue<List<AggregatedNodeStatus>> fleetAsync;
@override
Widget build(BuildContext context) {
final services = summary.services;
final up = services.where((s) => s.status == 'up').length;
final down = services.where((s) => s.status == 'down').length;
final unknown = services.length - up - down;
final servicesSeverity = down > 0
? Severity.critical
: unknown > 0
? Severity.warning
: Severity.good;
final servicesCaption = services.isEmpty
? 'No services discovered yet'
: [
if (down > 0) '$down down',
if (unknown > 0) '$unknown unknown',
].isEmpty
? 'All running'
: [if (down > 0) '$down down', if (unknown > 0) '$unknown unknown'].join(' · ');
final history = [...summary.node.backup.history]
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
final lastBackup = history.isEmpty ? null : history.first;
final updates = [...summary.node.updateHistory]..sort((a, b) => b.timestamp.compareTo(a.timestamp));
final lastUpdate = updates.isEmpty ? null : updates.first;
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 260,
child: StatTile(
label: 'Services',
value: '$up',
valueQualifier: '/ ${services.length} up',
caption: servicesCaption,
severity: services.isEmpty ? Severity.neutral : servicesSeverity,
),
),
SizedBox(
width: 260,
child: StatTile(
label: 'Last backup',
value: lastBackup == null ? 'Never' : formatRelative(lastBackup.timestamp),
caption: lastBackup == null
? 'No backups run yet'
: (lastBackup.success ? '${lastBackup.durationSeconds}s' : lastBackup.message),
severity: lastBackup == null ? Severity.neutral : severityForSuccess(lastBackup.success),
),
),
SizedBox(
width: 260,
child: StatTile(
label: 'Last system update',
value: lastUpdate == null ? 'Never' : formatRelative(lastUpdate.timestamp),
caption: lastUpdate == null
? 'No updates recorded yet'
: '${lastUpdate.packages.length} package(s)',
severity: lastUpdate == null ? Severity.neutral : severityForSuccess(lastUpdate.success),
),
),
SizedBox(width: 260, child: _FleetTile(fleetAsync: fleetAsync)),
],
);
}
}
class _FleetTile extends StatelessWidget {
const _FleetTile({required this.fleetAsync});
final AsyncValue<List<AggregatedNodeStatus>> fleetAsync;
@override
Widget build(BuildContext context) {
return fleetAsync.when(
loading: () => const StatTile(
label: 'Fleet',
value: '',
severity: Severity.neutral,
),
error: (error, stackTrace) => StatTile(
label: 'Fleet',
value: '',
caption: error is ApiException ? error.userMessage : error.toString(),
severity: Severity.critical,
),
data: (nodes) {
if (nodes.isEmpty) {
return const StatTile(
label: 'Fleet',
value: '0',
caption: 'No nodes registered',
severity: Severity.neutral,
);
}
final online = nodes.where((n) => n.error == null).length;
final unreachable = nodes.where((n) => n.error != null).toList();
return StatTile(
label: 'Fleet',
value: '$online',
valueQualifier: '/ ${nodes.length} online',
caption: unreachable.isEmpty
? 'All nodes reachable'
: 'unreachable: ${unreachable.map((n) => n.node.name).join(', ')}',
severity: unreachable.isEmpty ? Severity.good : Severity.critical,
);
},
);
}
}
@@ -0,0 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../core/models/models.dart';
part 'dashboard_summary.freezed.dart';
/// Client-side composition of the two data sources the Dashboard needs —
/// no server endpoint returns this shape directly. Not JSON-serializable
/// (nothing here is ever sent/stored as JSON), so no `fromJson`/`toJson`.
@freezed
sealed class DashboardSummary with _$DashboardSummary {
const factory DashboardSummary({required List<Service> services, required NodeInfo node}) =
_DashboardSummary;
}
@@ -0,0 +1,292 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'dashboard_summary.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$DashboardSummary {
List<Service> get services; NodeInfo get node;
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$DashboardSummaryCopyWith<DashboardSummary> get copyWith => _$DashboardSummaryCopyWithImpl<DashboardSummary>(this as DashboardSummary, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is DashboardSummary&&const DeepCollectionEquality().equals(other.services, services)&&(identical(other.node, node) || other.node == node));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(services),node);
@override
String toString() {
return 'DashboardSummary(services: $services, node: $node)';
}
}
/// @nodoc
abstract mixin class $DashboardSummaryCopyWith<$Res> {
factory $DashboardSummaryCopyWith(DashboardSummary value, $Res Function(DashboardSummary) _then) = _$DashboardSummaryCopyWithImpl;
@useResult
$Res call({
List<Service> services, NodeInfo node
});
$NodeInfoCopyWith<$Res> get node;
}
/// @nodoc
class _$DashboardSummaryCopyWithImpl<$Res>
implements $DashboardSummaryCopyWith<$Res> {
_$DashboardSummaryCopyWithImpl(this._self, this._then);
final DashboardSummary _self;
final $Res Function(DashboardSummary) _then;
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? services = null,Object? node = null,}) {
return _then(_self.copyWith(
services: null == services ? _self.services : services // ignore: cast_nullable_to_non_nullable
as List<Service>,node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable
as NodeInfo,
));
}
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$NodeInfoCopyWith<$Res> get node {
return $NodeInfoCopyWith<$Res>(_self.node, (value) {
return _then(_self.copyWith(node: value));
});
}
}
/// Adds pattern-matching-related methods to [DashboardSummary].
extension DashboardSummaryPatterns on DashboardSummary {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DashboardSummary value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _DashboardSummary() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DashboardSummary value) $default,){
final _that = this;
switch (_that) {
case _DashboardSummary():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DashboardSummary value)? $default,){
final _that = this;
switch (_that) {
case _DashboardSummary() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<Service> services, NodeInfo node)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _DashboardSummary() when $default != null:
return $default(_that.services,_that.node);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<Service> services, NodeInfo node) $default,) {final _that = this;
switch (_that) {
case _DashboardSummary():
return $default(_that.services,_that.node);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<Service> services, NodeInfo node)? $default,) {final _that = this;
switch (_that) {
case _DashboardSummary() when $default != null:
return $default(_that.services,_that.node);case _:
return null;
}
}
}
/// @nodoc
class _DashboardSummary implements DashboardSummary {
const _DashboardSummary({required final List<Service> services, required this.node}): _services = services;
final List<Service> _services;
@override List<Service> get services {
if (_services is EqualUnmodifiableListView) return _services;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_services);
}
@override final NodeInfo node;
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$DashboardSummaryCopyWith<_DashboardSummary> get copyWith => __$DashboardSummaryCopyWithImpl<_DashboardSummary>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DashboardSummary&&const DeepCollectionEquality().equals(other._services, _services)&&(identical(other.node, node) || other.node == node));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_services),node);
@override
String toString() {
return 'DashboardSummary(services: $services, node: $node)';
}
}
/// @nodoc
abstract mixin class _$DashboardSummaryCopyWith<$Res> implements $DashboardSummaryCopyWith<$Res> {
factory _$DashboardSummaryCopyWith(_DashboardSummary value, $Res Function(_DashboardSummary) _then) = __$DashboardSummaryCopyWithImpl;
@override @useResult
$Res call({
List<Service> services, NodeInfo node
});
@override $NodeInfoCopyWith<$Res> get node;
}
/// @nodoc
class __$DashboardSummaryCopyWithImpl<$Res>
implements _$DashboardSummaryCopyWith<$Res> {
__$DashboardSummaryCopyWithImpl(this._self, this._then);
final _DashboardSummary _self;
final $Res Function(_DashboardSummary) _then;
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? services = null,Object? node = null,}) {
return _then(_DashboardSummary(
services: null == services ? _self._services : services // ignore: cast_nullable_to_non_nullable
as List<Service>,node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable
as NodeInfo,
));
}
/// Create a copy of DashboardSummary
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$NodeInfoCopyWith<$Res> get node {
return $NodeInfoCopyWith<$Res>(_self.node, (value) {
return _then(_self.copyWith(node: value));
});
}
}
// dart format on
@@ -0,0 +1,39 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/polling/polling_async_notifier.dart';
import '../../../data/repositories/node_repository.dart';
import '../../../data/repositories/services_repository.dart';
import 'dashboard_summary.dart';
part 'dashboard_summary_provider.g.dart';
/// Polled every 20s while the Dashboard is mounted — deliberately its own
/// notifier rather than watching `servicesListProvider`/`currentNodeProvider`
/// directly, so Dashboard auto-refreshes independent of whether the
/// Services screen (manual-refresh only) happens to be open too.
@riverpod
class DashboardSummaryNotifier extends _$DashboardSummaryNotifier
with PollingMixin<DashboardSummary> {
@override
Duration get interval => const Duration(seconds: 20);
@override
Future<DashboardSummary> fetch() async {
final servicesRepo = ref.watch(servicesRepositoryProvider);
final nodeRepo = ref.watch(nodeRepositoryProvider);
if (servicesRepo == null || nodeRepo == null) {
// The router redirects to Settings whenever there's no active
// connection, so reaching this in the UI would mean that redirect
// hasn't fired yet — an ApiException here (vs. a raw StateError)
// keeps error handling on the same path as every other screen.
throw const ApiException.unknown('No active connection.');
}
final services = await servicesRepo.getAll();
final node = await nodeRepo.getNode();
return DashboardSummary(services: services, node: node);
}
@override
Future<DashboardSummary> build() => startPolling();
}
@@ -0,0 +1,75 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dashboard_summary_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Polled every 20s while the Dashboard is mounted — deliberately its own
/// notifier rather than watching `servicesListProvider`/`currentNodeProvider`
/// directly, so Dashboard auto-refreshes independent of whether the
/// Services screen (manual-refresh only) happens to be open too.
@ProviderFor(DashboardSummaryNotifier)
const dashboardSummaryProvider = DashboardSummaryNotifierProvider._();
/// Polled every 20s while the Dashboard is mounted — deliberately its own
/// notifier rather than watching `servicesListProvider`/`currentNodeProvider`
/// directly, so Dashboard auto-refreshes independent of whether the
/// Services screen (manual-refresh only) happens to be open too.
final class DashboardSummaryNotifierProvider
extends $AsyncNotifierProvider<DashboardSummaryNotifier, DashboardSummary> {
/// Polled every 20s while the Dashboard is mounted — deliberately its own
/// notifier rather than watching `servicesListProvider`/`currentNodeProvider`
/// directly, so Dashboard auto-refreshes independent of whether the
/// Services screen (manual-refresh only) happens to be open too.
const DashboardSummaryNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dashboardSummaryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dashboardSummaryNotifierHash();
@$internal
@override
DashboardSummaryNotifier create() => DashboardSummaryNotifier();
}
String _$dashboardSummaryNotifierHash() =>
r'807d0df3c8b7ce9652ae56fdba542b108aa99456';
/// Polled every 20s while the Dashboard is mounted — deliberately its own
/// notifier rather than watching `servicesListProvider`/`currentNodeProvider`
/// directly, so Dashboard auto-refreshes independent of whether the
/// Services screen (manual-refresh only) happens to be open too.
abstract class _$DashboardSummaryNotifier
extends $AsyncNotifier<DashboardSummary> {
FutureOr<DashboardSummary> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<DashboardSummary>, DashboardSummary>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<DashboardSummary>, DashboardSummary>,
AsyncValue<DashboardSummary>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../../core/models/models.dart';
import '../../../core/models/severity_mapping.dart';
import '../../../core/theme/status_colors.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/utils/relative_time.dart';
import '../../../core/widgets/empty_state.dart';
class _FeedEntry {
const _FeedEntry({
required this.timestamp,
required this.title,
required this.subtitle,
required this.severity,
required this.icon,
});
final DateTime timestamp;
final String title;
final String subtitle;
final Severity severity;
final IconData icon;
}
/// Merges backup executions and OS update records from [NodeInfo] into one
/// reverse-chronological feed. There's no persisted scan-result log on the
/// server to fold in here — only these two histories actually exist.
class ActivityFeed extends StatelessWidget {
const ActivityFeed({super.key, required this.node, this.maxEntries = 6});
final NodeInfo node;
final int maxEntries;
List<_FeedEntry> _buildEntries() {
final entries = <_FeedEntry>[
for (final execution in node.backup.history)
_FeedEntry(
timestamp: execution.timestamp,
title: execution.success ? 'Backup completed' : 'Backup failed',
subtitle: execution.message.isEmpty
? '${execution.durationSeconds}s'
: execution.message,
severity: severityForSuccess(execution.success),
icon: Icons.cloud_outlined,
),
for (final update in node.updateHistory)
_FeedEntry(
timestamp: update.timestamp,
title: update.success ? 'System update applied' : 'System update failed',
subtitle: update.packages.isEmpty
? update.message
: '${update.packages.length} package(s)${update.message.isEmpty ? '' : ' · ${update.message}'}',
severity: severityForSuccess(update.success),
icon: Icons.history_rounded,
),
]..sort((a, b) => b.timestamp.compareTo(a.timestamp));
return entries.take(maxEntries).toList();
}
@override
Widget build(BuildContext context) {
final entries = _buildEntries();
if (entries.isEmpty) {
return const EmptyState(
icon: Icons.history_rounded,
title: 'No activity yet',
message: 'Backup runs and system updates will show up here.',
);
}
return Column(
children: [
for (var i = 0; i < entries.length; i++) ...[
if (i > 0) const Divider(height: 1),
_FeedRow(entry: entries[i]),
],
],
);
}
}
class _FeedRow extends StatelessWidget {
const _FeedRow({required this.entry});
final _FeedEntry entry;
@override
Widget build(BuildContext context) {
final color = entry.severity.resolve(context.status, muted: context.colors.outline);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: Color.alphaBlend(color.withValues(alpha: 0.14), context.colors.surface),
borderRadius: BorderRadius.circular(7),
),
child: Icon(entry.icon, size: 14, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(entry.title, style: context.text.labelLarge),
Text(
entry.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
),
),
const SizedBox(width: 12),
Text(
formatRelative(entry.timestamp),
style: context.dataStyles.numericTabular.copyWith(fontSize: 12, color: context.colors.outline),
),
],
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../data/repositories/node_repository.dart';
import '../../../data/repositories/nodes_repository.dart';
import '../providers/dashboard_summary_provider.dart';
class QuickActionsRow extends ConsumerStatefulWidget {
const QuickActionsRow({super.key});
@override
ConsumerState<QuickActionsRow> createState() => _QuickActionsRowState();
}
class _QuickActionsRowState extends ConsumerState<QuickActionsRow> {
bool _busy = false;
Future<void> _guarded(Future<String> Function() action) async {
setState(() => _busy = true);
try {
final message = await action();
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} catch (e) {
final message = e is ApiException ? e.userMessage : e.toString();
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _scan() => _guarded(() async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) throw StateError('No active connection.');
final result = await repo.scan();
await ref.read(dashboardSummaryProvider.notifier).refresh();
final changed = result.created.length + result.updated.length;
return changed == 0
? 'Scan complete — no changes (${result.found} compose file(s) found)'
: 'Scan complete — ${result.created.length} new, ${result.updated.length} updated';
});
Future<void> _runBackup() => _guarded(() async {
final repo = ref.read(nodeRepositoryProvider);
if (repo == null) throw StateError('No active connection.');
await repo.runBackup();
await ref.read(dashboardSummaryProvider.notifier).refresh();
return 'Backup started';
});
Future<void> _recheckFleet() => _guarded(() async {
await ref.read(aggregatedNodesProvider.notifier).refresh();
return 'Fleet status refreshed';
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: _busy ? null : _scan,
icon: const Icon(Icons.search_rounded, size: 17),
label: const Text('Scan for services'),
),
OutlinedButton.icon(
onPressed: _busy ? null : _runBackup,
icon: const Icon(Icons.cloud_outlined, size: 17),
label: const Text('Run backup now'),
),
OutlinedButton.icon(
onPressed: _busy ? null : _recheckFleet,
icon: const Icon(Icons.refresh_rounded, size: 17),
label: const Text('Re-check fleet'),
),
],
),
),
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/models.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/theme_x.dart';
import '../../core/widgets/error_banner.dart';
import '../../data/repositories/nodes_repository.dart';
import 'widgets/fleet_card_grid.dart';
import 'widgets/fleet_table.dart';
import 'widgets/node_form_dialog.dart';
class FleetScreen extends ConsumerWidget {
const FleetScreen({super.key});
Future<void> _delete(BuildContext context, WidgetRef ref, RemoteNode node) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove node?'),
content: Text('This removes "${node.name}" from the registry. It does not affect that host.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: context.status.critical),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true) return;
final repo = ref.read(nodesRepositoryProvider);
if (repo == null) return;
try {
await repo.delete(node.id);
ref.invalidate(remoteNodesListProvider);
ref.invalidate(aggregatedNodesProvider);
} catch (e) {
final message = e is ApiException ? e.userMessage : e.toString();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final nodesAsync = ref.watch(remoteNodesListProvider);
final aggregatedAsync = ref.watch(aggregatedNodesProvider);
return ListView(
padding: const EdgeInsets.all(24),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('REGISTERED NODES', style: context.text.titleSmall),
FilledButton.icon(
onPressed: () => showNodeFormDialog(context),
icon: const Icon(Icons.add, size: 17),
label: const Text('Register node'),
),
],
),
const SizedBox(height: 10),
nodesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
),
error: (error, stackTrace) => ErrorBanner(
message: error is ApiException ? error.userMessage : error.toString(),
onRetry: () => ref.invalidate(remoteNodesListProvider),
),
data: (nodes) => FleetTable(
nodes: nodes,
aggregated: aggregatedAsync.value,
onEdit: (node) => showNodeFormDialog(context, existing: node),
onDelete: (node) => _delete(context, ref, node),
),
),
const SizedBox(height: 22),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('AGGREGATED SERVICES', style: context.text.titleSmall),
Text(
'live fan-out via /nodes/aggregated',
style: context.dataStyles.dataMonoSmall,
),
],
),
const SizedBox(height: 10),
aggregatedAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
),
error: (error, stackTrace) => ErrorBanner(
message: error is ApiException ? error.userMessage : error.toString(),
onRetry: () => ref.invalidate(aggregatedNodesProvider),
),
data: (entries) => FleetCardGrid(entries: entries),
),
],
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../../core/models/models.dart';
import '../../../core/models/severity_mapping.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/status_pill.dart';
/// One card per registered node showing its live services (via
/// `/nodes/aggregated`), or an inline error card if this particular node
/// was unreachable — a per-entry condition the local API itself reports,
/// distinct from the aggregated fetch as a whole failing.
class FleetCardGrid extends StatelessWidget {
const FleetCardGrid({super.key, required this.entries});
final List<AggregatedNodeStatus> entries;
@override
Widget build(BuildContext context) {
if (entries.isEmpty) {
return const EmptyState(
icon: Icons.hub_outlined,
title: 'Nothing to show yet',
message: 'Register a node to see its live services here.',
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 180,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: entries.length,
itemBuilder: (context, index) => _NodeCard(entry: entries[index]),
);
}
}
class _NodeCard extends StatelessWidget {
const _NodeCard({required this.entry});
final AggregatedNodeStatus entry;
@override
Widget build(BuildContext context) {
final hasError = entry.error != null;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: hasError ? context.status.critical.withValues(alpha: 0.4) : context.colors.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.node.name, style: context.text.labelLarge),
const SizedBox(height: 10),
Expanded(
child: hasError
? _ErrorCallout(message: entry.error!)
: entry.services.isEmpty
? Text('No services reported.', style: context.text.bodySmall)
: ListView(
padding: EdgeInsets.zero,
children: [
for (final service in entry.services)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
service.name,
overflow: TextOverflow.ellipsis,
style: context.text.bodyMedium,
),
),
StatusPill(
label: service.status,
severity: severityForServiceStatus(service.status),
),
],
),
),
],
),
),
],
),
);
}
}
class _ErrorCallout extends StatelessWidget {
const _ErrorCallout({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.status.critical.withValues(alpha: 0.35)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 15, color: context.status.critical),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: context.dataStyles.dataMonoSmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../../../core/models/models.dart';
import '../../../core/theme/status_colors.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/utils/relative_time.dart';
import '../../../core/widgets/data_table_scaffold.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/mono_text.dart';
import '../../../core/widgets/status_pill.dart';
class FleetTable extends StatelessWidget {
const FleetTable({
super.key,
required this.nodes,
required this.aggregated,
required this.onEdit,
required this.onDelete,
});
final List<RemoteNode> nodes;
/// Null while the aggregated fetch is still loading/erroring — reachability
/// then shows as "unknown" rather than guessing.
final List<AggregatedNodeStatus>? aggregated;
final void Function(RemoteNode node) onEdit;
final void Function(RemoteNode node) onDelete;
({String label, Severity severity}) _reachability(RemoteNode node) {
final entry = aggregated?.where((a) => a.node.id == node.id).firstOrNull;
if (entry == null) return (label: 'unknown', severity: Severity.neutral);
return entry.error == null
? (label: 'online', severity: Severity.good)
: (label: 'unreachable', severity: Severity.critical);
}
@override
Widget build(BuildContext context) {
return DataTableScaffold(
empty: const EmptyState(
icon: Icons.hub_outlined,
title: 'No nodes registered',
message: 'Register a sibling NodeMaster host to see it here.',
),
columns: const [
DataColumn(label: Text('Node')),
DataColumn(label: Text('URL')),
DataColumn(label: Text('Tags')),
DataColumn(label: Text('Status')),
DataColumn(label: Text('Last seen')),
DataColumn(label: Text('')),
],
rows: [
for (final node in nodes)
DataRow(
cells: [
DataCell(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(node.name, style: context.text.labelLarge),
if (node.description?.isNotEmpty == true)
Text(node.description!, style: context.text.bodySmall),
],
),
),
DataCell(MonoText(node.url)),
DataCell(
Wrap(
spacing: 4,
children: [
for (final tag in node.tags)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(tag, style: context.dataStyles.dataMonoSmall),
),
],
),
),
DataCell(Builder(
builder: (context) {
final r = _reachability(node);
return StatusPill(label: r.label, severity: r.severity);
},
)),
DataCell(
Text(
node.lastSeen == null ? '' : formatRelative(node.lastSeen!.toLocal()),
style: context.text.bodySmall,
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => onEdit(node),
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () => onDelete(node),
),
],
),
),
],
),
],
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/models.dart';
import '../../../core/network/api_exception.dart';
import '../../../data/repositories/nodes_repository.dart';
/// Add/edit dialog for a registered [RemoteNode]. This only edits the
/// registry entry (name/url/description/tags) — reachability is never
/// user-editable, it's always computed live via `/nodes/aggregated`.
Future<void> showNodeFormDialog(BuildContext context, {RemoteNode? existing}) {
return showDialog<void>(
context: context,
builder: (context) => _NodeFormDialog(existing: existing),
);
}
class _NodeFormDialog extends ConsumerStatefulWidget {
const _NodeFormDialog({this.existing});
final RemoteNode? existing;
@override
ConsumerState<_NodeFormDialog> createState() => _NodeFormDialogState();
}
class _NodeFormDialogState extends ConsumerState<_NodeFormDialog> {
final _formKey = GlobalKey<FormState>();
late final _nameController = TextEditingController(text: widget.existing?.name);
late final _urlController = TextEditingController(text: widget.existing?.url);
late final _descriptionController = TextEditingController(text: widget.existing?.description);
late final _tagsController = TextEditingController(text: widget.existing?.tags.join(', '));
bool _saving = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_descriptionController.dispose();
_tagsController.dispose();
super.dispose();
}
String? _validateUrl(String? value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) return 'Required.';
final uri = Uri.tryParse(trimmed);
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
return 'Enter a full URL, e.g. https://edge-02.local:8080';
}
return null;
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final repo = ref.read(nodesRepositoryProvider);
if (repo == null) {
setState(() {
_saving = false;
_error = 'No active connection.';
});
return;
}
var url = _urlController.text.trim();
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
final tags = _tagsController.text
.split(',')
.map((t) => t.trim())
.where((t) => t.isNotEmpty)
.toList();
final description = _descriptionController.text.trim();
final draft = (widget.existing ?? const RemoteNode(name: '', url: '')).copyWith(
name: _nameController.text.trim(),
url: url,
description: description.isEmpty ? null : description,
tags: tags,
);
try {
if (widget.existing == null) {
await repo.add(draft);
} else {
await repo.update(widget.existing!.id, draft);
}
ref.invalidate(remoteNodesListProvider);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Register node' : 'Edit node'),
content: Form(
key: _formKey,
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Base URL',
hintText: 'https://edge-02.local:8080',
),
validator: _validateUrl,
keyboardType: TextInputType.url,
),
const SizedBox(height: 14),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description (optional)'),
),
const SizedBox(height: 14),
TextFormField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (optional, comma-separated)',
hintText: 'prod, db',
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
),
],
);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/models.dart';
import '../../core/models/severity_mapping.dart';
import '../../core/network/api_exception.dart';
import '../../core/routing/route_paths.dart';
import '../../core/theme/theme_x.dart';
import '../../core/widgets/error_banner.dart';
import '../../core/widgets/slide_over_panel.dart';
import '../../core/widgets/status_pill.dart';
import '../../data/repositories/services_repository.dart';
import 'widgets/service_detail_body.dart';
import 'widgets/service_form_dialog.dart';
import 'widgets/services_table.dart';
class ServicesScreen extends ConsumerStatefulWidget {
const ServicesScreen({super.key, this.serviceId});
/// Present when routed via `/services?id=...` — renders the slide-over
/// detail panel for this service alongside the still-visible table.
final String? serviceId;
@override
ConsumerState<ServicesScreen> createState() => _ServicesScreenState();
}
class _ServicesScreenState extends ConsumerState<ServicesScreen> {
final _busyIds = <String>{};
Future<void> _run(String id, Future<void> Function() action, {String? successMessage}) async {
setState(() => _busyIds.add(id));
try {
await action();
ref.invalidate(servicesListProvider);
if (mounted && successMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (e) {
if (mounted) {
final message = e is ApiException ? e.userMessage : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
} finally {
if (mounted) setState(() => _busyIds.remove(id));
}
}
void _toggleRunning(Service service) {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) return;
_run(service.id, () => service.status == 'up' ? repo.stop(service.id) : repo.start(service.id));
}
void _backupNow(Service service) {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) return;
_run(service.id, () => repo.backupNow(service.id), successMessage: 'Backup started');
}
Future<void> _delete(Service service) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete service?'),
content: Text('This removes "${service.name}" from NodeMaster. It does not stop or delete the container.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: context.status.critical),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) return;
await _run(service.id, () => repo.delete(service.id));
if (mounted) context.go(RoutePaths.services);
}
@override
Widget build(BuildContext context) {
final servicesAsync = ref.watch(servicesListProvider);
return Stack(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
servicesAsync.maybeWhen(
data: (services) => '${services.length} SERVICES',
orElse: () => 'SERVICES',
),
style: context.text.titleSmall,
),
FilledButton.icon(
onPressed: () => showServiceFormDialog(context),
icon: const Icon(Icons.add, size: 17),
label: const Text('Add service'),
),
],
),
const SizedBox(height: 12),
Expanded(
child: servicesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Align(
alignment: Alignment.topCenter,
child: ErrorBanner(
message: error is ApiException ? error.userMessage : error.toString(),
onRetry: () => ref.invalidate(servicesListProvider),
),
),
data: (services) => SingleChildScrollView(
child: ServicesTable(
services: services,
busyServiceIds: _busyIds,
onRowTap: (service) => context.go(RoutePaths.serviceDetail(service.id)),
onToggleRunning: _toggleRunning,
),
),
),
),
],
),
),
_buildDetailPanel(servicesAsync),
],
);
}
Widget _buildDetailPanel(AsyncValue<List<Service>> servicesAsync) {
final id = widget.serviceId;
final service = servicesAsync.maybeWhen(
data: (services) {
for (final s in services) {
if (s.id == id) return s;
}
return null;
},
orElse: () => null,
);
return SlideOverPanel(
open: id != null,
onClose: () => context.go(RoutePaths.services),
title: service?.name ?? '',
subtitle: service == null
? null
: StatusPill(label: service.status, severity: severityForServiceStatus(service.status)),
body: service == null
? const SizedBox.shrink()
: ServiceDetailBody(service: service),
headerActions: service == null
? const []
: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => showServiceFormDialog(context, existing: service),
),
],
actions: service == null
? const []
: [
OutlinedButton.icon(
onPressed: () => _toggleRunning(service),
icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 17),
label: Text(service.status == 'up' ? 'Stop' : 'Start'),
),
OutlinedButton.icon(
onPressed: service.backup == null ? null : () => _backupNow(service),
icon: const Icon(Icons.cloud_outlined, size: 17),
label: const Text('Backup'),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: context.status.critical),
onPressed: () => _delete(service),
icon: const Icon(Icons.delete_outline, size: 17),
label: const Text('Delete'),
),
],
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/models/models.dart';
import '../../../core/models/severity_mapping.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/widgets/backup_target_form_dialog.dart';
import '../../../core/widgets/backup_targets_list.dart';
import '../../../core/widgets/mono_text.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../data/repositories/services_repository.dart';
/// The scrollable body of the Services slide-over panel — everything below
/// the title/close row that [SlideOverPanel] already renders.
class ServiceDetailBody extends ConsumerWidget {
const ServiceDetailBody({super.key, required this.service});
final Service service;
Future<void> _addTarget(BuildContext context, WidgetRef ref) {
return showBackupTargetFormDialog(
context,
onSave: (draft) async {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.addBackupTarget(service.id, draft);
ref.invalidate(servicesListProvider);
},
);
}
Future<void> _editTarget(BuildContext context, WidgetRef ref, BackupTarget target) {
return showBackupTargetFormDialog(
context,
existing: target,
onSave: (draft) async {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.updateBackupTarget(service.id, target.id, draft);
ref.invalidate(servicesListProvider);
},
);
}
Future<void> _deleteTarget(BuildContext context, WidgetRef ref, BackupTarget target) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove backup target?'),
content: Text('This removes "${target.name}" from this service\'s backup targets.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: context.status.critical),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true) return;
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) return;
try {
await repo.deleteBackupTarget(service.id, target.id);
ref.invalidate(servicesListProvider);
} catch (e) {
final message = e is ApiException ? e.userMessage : e.toString();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
}
}
Future<void> _runTarget(WidgetRef ref, BackupTarget target) async {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) throw const ApiException.unknown('No active connection.');
await repo.runBackupTarget(service.id, target.id);
}
Future<RunState> _fetchProgress(WidgetRef ref, BackupTarget target) async {
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) return const RunState();
return repo.getBackupTargetProgress(service.id, target.id);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final backup = service.backup;
final targets = backup?.targets ?? const [];
final history = (backup?.history ?? const []).reversed.take(5).toList();
final timeFormat = DateFormat('MM-dd HH:mm');
return ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
children: [
_KvRow(label: 'Compose file', value: MonoText(service.composeFile)),
_KvRow(
label: 'Compose type',
value: Text(service.composeType?.isNotEmpty == true ? service.composeType! : 'docker compose'),
),
_KvRow(
label: 'Backup folder',
value: service.backupFolder?.isNotEmpty == true
? MonoText(service.backupFolder!)
: Text('Not set', style: context.text.bodySmall),
),
_KvRow(label: 'Stop on backup', value: Text(service.stopOnBackup ? 'Yes' : 'No')),
const SizedBox(height: 18),
Text('BACKUP TARGETS', style: context.text.titleSmall),
const SizedBox(height: 4),
BackupTargetsList(
targets: targets,
onAdd: () => _addTarget(context, ref),
onEdit: (target) => _editTarget(context, ref, target),
onDelete: (target) => _deleteTarget(context, ref, target),
onRun: (target) => _runTarget(ref, target),
fetchProgress: (target) => _fetchProgress(ref, target),
onSettled: (target, state) => ref.invalidate(servicesListProvider),
),
const SizedBox(height: 18),
Text('RECENT BACKUPS', style: context.text.titleSmall),
const SizedBox(height: 4),
if (history.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text('No backup history yet.', style: context.text.bodySmall),
)
else
for (final execution in history)
Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
MonoText(timeFormat.format(execution.timestamp.toLocal()), small: true),
const Spacer(),
StatusPill(
label: execution.success ? '${execution.durationSeconds}s' : 'failed',
severity: severityForSuccess(execution.success),
),
],
),
),
],
);
}
}
class _KvRow extends StatelessWidget {
const _KvRow({required this.label, required this.value});
final String label;
final Widget value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: context.colors.outlineVariant))),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 128, child: Text(label, style: context.text.bodySmall)),
Expanded(child: value),
],
),
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/models.dart';
import '../../../core/network/api_exception.dart';
import '../../../data/repositories/services_repository.dart';
const _composeTypes = ['docker compose', 'docker-compose', 'podman-compose'];
/// Add/edit dialog for a [Service]'s core identity fields. Backup target
/// configuration is deliberately out of scope here — that's a node/service
/// backup-config concern owned by the Backups screen, not service CRUD.
Future<void> showServiceFormDialog(BuildContext context, {Service? existing}) {
return showDialog<void>(
context: context,
builder: (context) => _ServiceFormDialog(existing: existing),
);
}
class _ServiceFormDialog extends ConsumerStatefulWidget {
const _ServiceFormDialog({this.existing});
final Service? existing;
@override
ConsumerState<_ServiceFormDialog> createState() => _ServiceFormDialogState();
}
class _ServiceFormDialogState extends ConsumerState<_ServiceFormDialog> {
final _formKey = GlobalKey<FormState>();
late final _nameController = TextEditingController(text: widget.existing?.name);
late final _composeFileController = TextEditingController(text: widget.existing?.composeFile);
late final _backupFolderController = TextEditingController(text: widget.existing?.backupFolder);
late String _composeType = widget.existing?.composeType?.isNotEmpty == true
? widget.existing!.composeType!
: _composeTypes.first;
late bool _stopOnBackup = widget.existing?.stopOnBackup ?? false;
bool _saving = false;
String? _error;
@override
void dispose() {
_nameController.dispose();
_composeFileController.dispose();
_backupFolderController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final existing = widget.existing;
final draft = (existing ?? const Service(name: '', composeFile: '')).copyWith(
name: _nameController.text.trim(),
composeFile: _composeFileController.text.trim(),
composeType: _composeType,
backupFolder: _backupFolderController.text.trim(),
stopOnBackup: _stopOnBackup,
);
final repo = ref.read(servicesRepositoryProvider);
if (repo == null) {
setState(() {
_saving = false;
_error = 'No active connection.';
});
return;
}
try {
if (existing == null) {
await repo.add(draft);
} else {
await repo.update(existing.id, draft);
}
ref.invalidate(servicesListProvider);
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = e is ApiException ? e.userMessage : e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Add service' : 'Edit service'),
content: Form(
key: _formKey,
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _composeFileController,
decoration: const InputDecoration(
labelText: 'Compose file',
hintText: '/opt/compose/gitea/docker-compose.yml',
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null,
),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: _composeType,
decoration: const InputDecoration(labelText: 'Compose type'),
items: [
for (final type in _composeTypes) DropdownMenuItem(value: type, child: Text(type)),
],
onChanged: (value) => setState(() => _composeType = value ?? _composeTypes.first),
),
const SizedBox(height: 14),
TextFormField(
controller: _backupFolderController,
decoration: const InputDecoration(
labelText: 'Backup folder (optional)',
hintText: '/opt/data/containers/gitea',
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Stop container while backing up'),
value: _stopOnBackup,
onChanged: (value) => setState(() => _stopOnBackup = value),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
),
],
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../../../core/models/models.dart';
import '../../../core/models/severity_mapping.dart';
import '../../../core/theme/theme_x.dart';
import '../../../core/widgets/data_table_scaffold.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/mono_text.dart';
import '../../../core/widgets/status_pill.dart';
class ServicesTable extends StatelessWidget {
const ServicesTable({
super.key,
required this.services,
required this.onRowTap,
required this.onToggleRunning,
required this.busyServiceIds,
});
final List<Service> services;
final void Function(Service service) onRowTap;
final void Function(Service service) onToggleRunning;
final Set<String> busyServiceIds;
String _backupSummary(Service service) {
final targets = service.backup?.targets ?? const [];
if (targets.isEmpty) return 'none';
if (targets.length == 1) return targets.first.method.name;
return '${targets.length} targets';
}
@override
Widget build(BuildContext context) {
return DataTableScaffold(
empty: const EmptyState(
icon: Icons.layers_outlined,
title: 'No services yet',
message: 'Scan a compose folder from the Dashboard, or add one manually.',
),
columns: const [
DataColumn(label: Text('Service')),
DataColumn(label: Text('Status')),
DataColumn(label: Text('Compose file')),
DataColumn(label: Text('Backup')),
DataColumn(label: Text('')),
],
rows: [
for (final service in services)
DataRow(
onSelectChanged: (_) => onRowTap(service),
cells: [
DataCell(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(service.name, style: context.text.labelLarge),
Text(
service.composeType?.isNotEmpty == true ? service.composeType! : 'docker compose',
style: context.text.bodySmall,
),
],
),
),
DataCell(
StatusPill(label: service.status, severity: severityForServiceStatus(service.status)),
),
DataCell(MonoText(service.composeFile)),
DataCell(
_backupSummary(service) == 'none'
? Text('none', style: context.text.bodySmall)
: Text(_backupSummary(service), style: context.text.bodyMedium),
),
DataCell(
busyServiceIds.contains(service.id)
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: IconButton(
tooltip: service.status == 'up' ? 'Stop' : 'Start',
icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 20),
onPressed: () => onToggleRunning(service),
),
),
],
),
],
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/theme_x.dart';
import '../../data/repositories/node_repository.dart';
import 'widgets/connection_form_dialog.dart';
import 'widgets/connections_list.dart';
import 'widgets/no_auth_callout.dart';
import 'widgets/node_identity_form.dart';
import 'widgets/theme_toggle.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
padding: const EdgeInsets.all(24),
children: [
_Block(
title: 'Connections',
trailing: TextButton.icon(
onPressed: () => showConnectionFormDialog(context),
icon: const Icon(Icons.add, size: 17),
label: const Text('Add connection'),
),
child: const ConnectionsList(),
),
const SizedBox(height: 22),
_Block(title: 'Node identity', child: const _NodeIdentitySection()),
const SizedBox(height: 22),
_Block(
title: 'Appearance',
child: const Padding(
padding: EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Theme'),
ThemeToggle(),
],
),
),
),
const SizedBox(height: 22),
const NoAuthCallout(),
],
);
}
}
class _NodeIdentitySection extends ConsumerWidget {
const _NodeIdentitySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final nodeAsync = ref.watch(currentNodeProvider);
return nodeAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 10),
Text('Contacting node…'),
],
),
),
error: (error, stackTrace) {
final message = error is ApiException ? error.userMessage : error.toString();
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: context.status.critical),
const SizedBox(width: 10),
Expanded(
child: Text(message, style: context.text.bodyMedium?.copyWith(color: context.status.critical)),
),
],
),
);
},
data: (node) {
if (node == null) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text('No connection selected.'),
);
}
return NodeIdentityForm(node: node);
},
);
}
}
class _Block extends StatelessWidget {
const _Block({required this.title, required this.child, this.trailing});
final String title;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: context.text.titleMedium),
trailing ?? const SizedBox.shrink(),
],
),
const SizedBox(height: 10),
Card(child: child),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More