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, }); /// Deserializes a [CommandEvent] from a JSON-compatible map. factory CommandEvent.fromJson(Map map) { return CommandEvent( eventId: map['eventId'] as String, type: CommandEventType.values.firstWhere((t) => t.name == map['type']), payload: map['payload'] as Map, occurredAt: DateTime.parse(map['occurredAt'] as String), isSynced: map['isSynced'] as bool? ?? 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 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 toJson() => { 'eventId': eventId, 'type': type.name, 'payload': payload, 'occurredAt': occurredAt.toUtc().toIso8601String(), 'isSynced': isSynced, }; @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 attachNode({ required String unitId, required String newParentId, String? previousParentId, }) => { 'unitId': unitId, 'newParentId': newParentId, if (previousParentId != null) 'previousParentId': previousParentId, }; /// Payload for [CommandEventType.detachNode]. static Map detachNode({ required String unitId, required String previousParentId, }) => { 'unitId': unitId, 'previousParentId': previousParentId, }; /// Payload for [CommandEventType.assignPersonnel]. static Map assignPersonnel({ required String soldierId, required String slotId, required String unitId, }) => { 'soldierId': soldierId, 'slotId': slotId, 'unitId': unitId, }; /// Payload for [CommandEventType.unassignPersonnel]. static Map unassignPersonnel({ required String soldierId, required String slotId, required String unitId, }) => { 'soldierId': soldierId, 'slotId': slotId, 'unitId': unitId, }; /// Payload for [CommandEventType.modifySlot]. static Map modifySlot({ required String slotId, required String unitId, required Map 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 _events = []; /// All events recorded since the store was created. List get allEvents => List.unmodifiable(_events); /// Events that have not yet been transmitted to the remote store. List 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. /// Set via [isOnline] setter; read via [isOnline] getter. bool isOnline = true; /// 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 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 _transmit(List events) async { // Simulate async network latency. await Future.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; } }