278 lines
9.0 KiB
Dart
278 lines
9.0 KiB
Dart
import 'dart:convert';
|
|
|
|
/// The type of a structural command event.
|
|
enum CommandEventType {
|
|
/// A unit node was attached (added or re-parented) under a new parent.
|
|
attachNode,
|
|
|
|
/// A unit node was detached from its parent.
|
|
detachNode,
|
|
|
|
/// A soldier was assigned to a position slot.
|
|
assignPersonnel,
|
|
|
|
/// A soldier was unassigned from a position slot.
|
|
unassignPersonnel,
|
|
|
|
/// A position slot was modified (title, MOS, rank, or key-leader flag).
|
|
modifySlot,
|
|
}
|
|
|
|
/// A single atomic structural change event in the UTO Manager.
|
|
///
|
|
/// [CommandEvent]s are the unit of work for the offline-first architecture.
|
|
/// Every structural mutation (re-parent, assign, unassign, slot edit) is
|
|
/// recorded as a [CommandEvent] before being applied to the local database.
|
|
/// When connectivity is restored, queued events are transmitted to any
|
|
/// remote store via [DeltaSynchronizer].
|
|
class CommandEvent {
|
|
CommandEvent({
|
|
required this.eventId,
|
|
required this.type,
|
|
required this.payload,
|
|
required this.occurredAt,
|
|
this.isSynced = false,
|
|
});
|
|
|
|
/// Unique identifier for this event (UUID v4).
|
|
final String eventId;
|
|
|
|
/// The type of structural change this event represents.
|
|
final CommandEventType type;
|
|
|
|
/// Type-specific payload data (serialized as a JSON-compatible map).
|
|
final Map<String, dynamic> payload;
|
|
|
|
/// When this event was created (UTC).
|
|
final DateTime occurredAt;
|
|
|
|
/// Whether this event has been transmitted to the remote store.
|
|
bool isSynced;
|
|
|
|
/// Serializes this event to a JSON-compatible map.
|
|
Map<String, dynamic> toJson() => {
|
|
'eventId': eventId,
|
|
'type': type.name,
|
|
'payload': payload,
|
|
'occurredAt': occurredAt.toUtc().toIso8601String(),
|
|
'isSynced': isSynced,
|
|
};
|
|
|
|
/// Deserializes a [CommandEvent] from a JSON-compatible map.
|
|
factory CommandEvent.fromJson(Map<String, dynamic> map) {
|
|
return CommandEvent(
|
|
eventId: map['eventId'] as String,
|
|
type: CommandEventType.values.firstWhere((t) => t.name == map['type']),
|
|
payload: (map['payload'] as Map<String, dynamic>),
|
|
occurredAt: DateTime.parse(map['occurredAt'] as String),
|
|
isSynced: map['isSynced'] as bool? ?? false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
String toString() => 'CommandEvent(id: $eventId, type: ${type.name}, synced: $isSynced)';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Payload factory helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Payload factories for each [CommandEventType].
|
|
///
|
|
/// These helpers produce the canonical payload maps consumed by
|
|
/// [CommandEventStore] and [DeltaSynchronizer].
|
|
abstract final class CommandPayloads {
|
|
/// Payload for [CommandEventType.attachNode].
|
|
static Map<String, dynamic> attachNode({
|
|
required String unitId,
|
|
required String newParentId,
|
|
String? previousParentId,
|
|
}) =>
|
|
{
|
|
'unitId': unitId,
|
|
'newParentId': newParentId,
|
|
if (previousParentId != null) 'previousParentId': previousParentId,
|
|
};
|
|
|
|
/// Payload for [CommandEventType.detachNode].
|
|
static Map<String, dynamic> detachNode({
|
|
required String unitId,
|
|
required String previousParentId,
|
|
}) =>
|
|
{
|
|
'unitId': unitId,
|
|
'previousParentId': previousParentId,
|
|
};
|
|
|
|
/// Payload for [CommandEventType.assignPersonnel].
|
|
static Map<String, dynamic> assignPersonnel({
|
|
required String soldierId,
|
|
required String slotId,
|
|
required String unitId,
|
|
}) =>
|
|
{
|
|
'soldierId': soldierId,
|
|
'slotId': slotId,
|
|
'unitId': unitId,
|
|
};
|
|
|
|
/// Payload for [CommandEventType.unassignPersonnel].
|
|
static Map<String, dynamic> unassignPersonnel({
|
|
required String soldierId,
|
|
required String slotId,
|
|
required String unitId,
|
|
}) =>
|
|
{
|
|
'soldierId': soldierId,
|
|
'slotId': slotId,
|
|
'unitId': unitId,
|
|
};
|
|
|
|
/// Payload for [CommandEventType.modifySlot].
|
|
static Map<String, dynamic> modifySlot({
|
|
required String slotId,
|
|
required String unitId,
|
|
required Map<String, dynamic> changes,
|
|
}) =>
|
|
{
|
|
'slotId': slotId,
|
|
'unitId': unitId,
|
|
'changes': changes,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CommandEventStore
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// In-memory command event store for offline-first operation.
|
|
///
|
|
/// All structural mutations are appended here before being applied to the
|
|
/// local Drift database. Unsynced events are queued for transmission by
|
|
/// [DeltaSynchronizer] when connectivity is restored.
|
|
///
|
|
/// This implementation uses an in-memory list as the backing store.
|
|
/// In a production deployment the queue would be persisted to a dedicated
|
|
/// Drift table so events survive app restarts.
|
|
class CommandEventStore {
|
|
CommandEventStore();
|
|
|
|
final List<CommandEvent> _events = [];
|
|
|
|
/// All events recorded since the store was created.
|
|
List<CommandEvent> get allEvents => List.unmodifiable(_events);
|
|
|
|
/// Events that have not yet been transmitted to the remote store.
|
|
List<CommandEvent> get pendingEvents => _events.where((e) => !e.isSynced).toList();
|
|
|
|
/// The total number of events in the store.
|
|
int get eventCount => _events.length;
|
|
|
|
/// The number of unsynced events awaiting transmission.
|
|
int get pendingCount => pendingEvents.length;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Append
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Appends a new [event] to the store.
|
|
void append(CommandEvent event) {
|
|
_events.add(event);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mark synced
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marks the event with [eventId] as synced.
|
|
///
|
|
/// Returns true if the event was found and updated, false otherwise.
|
|
bool markSynced(String eventId) {
|
|
final index = _events.indexWhere((e) => e.eventId == eventId);
|
|
if (index == -1) return false;
|
|
_events[index].isSynced = true;
|
|
return true;
|
|
}
|
|
|
|
/// Marks all pending events as synced (bulk operation after successful
|
|
/// remote transmission).
|
|
void markAllSynced() {
|
|
for (final event in _events) {
|
|
event.isSynced = true;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Serialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Serializes all pending events to a JSON string for transmission.
|
|
String exportPendingToJson() {
|
|
final list = pendingEvents.map((e) => e.toJson()).toList();
|
|
return const JsonEncoder.withIndent(' ').convert(list);
|
|
}
|
|
|
|
/// Clears all events from the store (e.g., after a full sync).
|
|
void clear() => _events.clear();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeltaSynchronizer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Transmits pending [CommandEvent]s from a [CommandEventStore] to a remote
|
|
/// endpoint when connectivity is available.
|
|
///
|
|
/// This implementation provides the interface and a stub transmit method.
|
|
/// In a production deployment the transmit method would POST the JSON payload
|
|
/// to a REST or gRPC endpoint and handle retry/backoff logic.
|
|
class DeltaSynchronizer {
|
|
DeltaSynchronizer({required this.store});
|
|
|
|
final CommandEventStore store;
|
|
|
|
/// Whether the synchronizer believes it has network connectivity.
|
|
bool _isOnline = true;
|
|
|
|
/// Sets the connectivity state. When set to true, [syncIfOnline] will
|
|
/// attempt to transmit pending events.
|
|
// ignore: avoid_setters_without_getters
|
|
set isOnline(bool value) => _isOnline = value;
|
|
|
|
/// Returns true if the synchronizer believes it has network connectivity.
|
|
bool get isOnline => _isOnline;
|
|
|
|
/// Transmits all pending events if online.
|
|
///
|
|
/// Returns the number of events successfully transmitted, or 0 if offline
|
|
/// or if there are no pending events.
|
|
///
|
|
/// In this implementation the transmission is simulated (no real network
|
|
/// call). Replace [_transmit] with a real HTTP/gRPC call in production.
|
|
Future<int> syncIfOnline() async {
|
|
if (!_isOnline) return 0;
|
|
final pending = store.pendingEvents;
|
|
if (pending.isEmpty) return 0;
|
|
|
|
final transmitted = await _transmit(pending);
|
|
if (transmitted) {
|
|
store.markAllSynced();
|
|
return pending.length;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/// Stub transmission method. Replace with real network call in production.
|
|
///
|
|
/// Returns true on success, false on failure.
|
|
Future<bool> _transmit(List<CommandEvent> events) async {
|
|
// Simulate async network latency.
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
// In production: POST events as JSON to remote endpoint.
|
|
// final json = store.exportPendingToJson();
|
|
// final response = await http.post(Uri.parse(remoteUrl), body: json);
|
|
// return response.statusCode == 200;
|
|
return true;
|
|
}
|
|
}
|