From a2073dacc3f12b0fb76cc732f0167f12df36046d Mon Sep 17 00:00:00 2001 From: Kit Plummer Date: Mon, 6 Jul 2026 12:23:32 -0400 Subject: [PATCH] feat: MarkerInfo OR-Set facade + deleteDocument + delete-event visibility (#2, #3) Expose putMarker/getMarkers/MarkerInfo and generic deleteDocument on the PeatFlutterNode facade. Add Markers card to the Peat Water demo (Operations tab) with drop-pin and swipe-to-soft-delete. Fix Activity tab to surface hard-delete events instead of silently discarding them. --- example/lib/main.dart | 183 +++++++++++++++++++++++++++++++----- lib/peat_flutter.dart | 3 +- lib/src/peat_node.dart | 17 +++- test/delete_event_test.dart | 66 +++++++++++++ test/marker_info_test.dart | 142 ++++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 23 deletions(-) create mode 100644 test/delete_event_test.dart create mode 100644 test/marker_info_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index f8dd9bd..50a43cc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -163,6 +163,9 @@ class _PeatExampleHomeState extends State BlobFetchStatus? _blobDownloadStatus; String? _blobDownloadHash; // hash of the blob currently being fetched + // Markers (OR-Set with soft-delete tombstones) + List _markers = []; + // Cell and Command state CellInfo? _activeCell; List _commands = []; @@ -476,6 +479,7 @@ class _PeatExampleHomeState extends State _sessionStartMs = DateTime.now().millisecondsSinceEpoch; // Seed the displayed total from the (re-synced) shared CRDT counter. _refreshCounter(node); + _refreshMarkers(node); _counterTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { if (!mounted || _node == null) return; _refreshCounter(_node!); @@ -816,6 +820,7 @@ class _PeatExampleHomeState extends State } return; } + if (change.collection == 'markers') _refreshMarkers(node); // Internal collections shown elsewhere — skip in the feed. if (change.collection == 'nodes' || change.collection == 'mission') return; final key = '${change.collection}/${change.docId}'; @@ -826,30 +831,38 @@ class _PeatExampleHomeState extends State String? preview; try { final raw = node.getRaw(change.collection, change.docId); - if (raw == null) return; // doc vanished - final newHash = raw.hashCode; - final knownHash = _contentHashes[key]; - if (knownHash == newHash) return; // same content, skip - _contentHashes[key] = newHash; // record new hash - - final map = jsonDecode(raw) as Map?; - if (map != null) { - if (change.docId.startsWith('counter-') || change.docId == 'counter') { - final inc = map['inc'] as int? ?? 0; - final dec = map['dec'] as int? ?? 0; - final by = map['by'] as String? ?? ''; - preview = 'value: ${inc - dec} · by: $by'; + if (raw == null) { + if (change.changeType == ChangeType.delete) { + _contentHashes.remove(key); + preview = '(deleted)'; } else { - preview = map.entries - .take(3) - .map((e) { - final v = e.value?.toString() ?? 'null'; - return '${e.key}: ${v.length > 20 ? '${v.substring(0, 20)}…' : v}'; - }) - .join(' · '); + return; } } else { - preview = raw.length > 60 ? '${raw.substring(0, 60)}…' : raw; + final newHash = raw.hashCode; + final knownHash = _contentHashes[key]; + if (knownHash == newHash) return; // same content, skip + _contentHashes[key] = newHash; // record new hash + + final map = jsonDecode(raw) as Map?; + if (map != null) { + if (change.docId.startsWith('counter-') || change.docId == 'counter') { + final inc = map['inc'] as int? ?? 0; + final dec = map['dec'] as int? ?? 0; + final by = map['by'] as String? ?? ''; + preview = 'value: ${inc - dec} · by: $by'; + } else { + preview = map.entries + .take(3) + .map((e) { + final v = e.value?.toString() ?? 'null'; + return '${e.key}: ${v.length > 20 ? '${v.substring(0, 20)}…' : v}'; + }) + .join(' · '); + } + } else { + preview = raw.length > 60 ? '${raw.substring(0, 60)}…' : raw; + } } } catch (_) { return; @@ -907,6 +920,13 @@ class _PeatExampleHomeState extends State } catch (_) {} } + void _refreshMarkers(PeatFlutterNode node) { + try { + final all = node.markers; + if (mounted) setState(() => _markers = all); + } catch (_) {} + } + void _setMission(PeatFlutterNode node, int days) { final json = jsonEncode({ 'id': _missionDocId, @@ -1286,6 +1306,7 @@ class _PeatExampleHomeState extends State _crdtReasmTs.clear(); _claimedCommandIds.clear(); _commands = []; + _markers = []; _activeCell = null; _missionDays = 0; _missionSetBy = null; @@ -2703,6 +2724,120 @@ class _PeatExampleHomeState extends State ); } + Widget _buildMarkersCard(ThemeData theme) { + final node = _node!; + final live = _markers.where((m) => !m.deleted).toList(); + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.place, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Text('Markers (${live.length})', + style: theme.textTheme.labelMedium + ?.copyWith(fontWeight: FontWeight.bold)), + const Spacer(), + IconButton( + icon: const Icon(Icons.add_location_alt, size: 20), + tooltip: 'Drop pin at random location', + onPressed: () { + final rng = Random(); + final uid = '${DateTime.now().millisecondsSinceEpoch}-${rng.nextInt(0xFFFF).toRadixString(16).padLeft(4, '0')}'; + node.putMarker(MarkerInfo( + uid: uid, + markerType: 'b-m-p-w', + lat: 38.88 + rng.nextDouble() * 0.02 - 0.01, + lon: -77.02 + rng.nextDouble() * 0.02 - 0.01, + hae: null, + ts: DateTime.now().millisecondsSinceEpoch, + callsign: _callsign.isNotEmpty ? _callsign : null, + color: null, + cellId: null, + deleted: false, + )); + _refreshMarkers(node); + }, + ), + ]), + if (live.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('No markers — tap + to drop a pin.', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline)), + ) + else + ...live.map((m) { + final who = m.callsign ?? '?'; + final when = DateTime.fromMillisecondsSinceEpoch(m.ts); + final ago = DateTime.now().difference(when); + final agoStr = ago.inMinutes < 1 + ? 'now' + : ago.inMinutes < 60 + ? '${ago.inMinutes}m ago' + : '${ago.inHours}h ago'; + return Dismissible( + key: ValueKey(m.uid), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + color: Colors.red.shade400, + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (_) { + node.putMarker(MarkerInfo( + uid: m.uid, + markerType: m.markerType, + lat: m.lat, + lon: m.lon, + hae: m.hae, + ts: m.ts, + callsign: m.callsign, + color: m.color, + cellId: m.cellId, + deleted: true, + )); + _refreshMarkers(node); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + Icon(Icons.location_on, size: 16, + color: theme.colorScheme.primary.withOpacity(0.7)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${m.lat.toStringAsFixed(4)}, ${m.lon.toStringAsFixed(4)}', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + Text('$who · $agoStr · ${m.markerType}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline)), + ], + ), + ), + Icon(Icons.chevron_left, size: 16, + color: theme.colorScheme.outline.withOpacity(0.4)), + ]), + ), + ); + }), + ], + ), + ), + ); + } + Widget _buildDownloadProgress(ThemeData theme) { final status = _blobDownloadStatus!; final hashShort = _blobDownloadHash != null @@ -3294,6 +3429,12 @@ class _PeatExampleHomeState extends State _buildAttachmentsCard(theme), ], + // ---- markers (OR-Set map pins) ---- + if (hasNode) ...[ + const SizedBox(height: 10), + _buildMarkersCard(theme), + ], + // ---- node roster (primary situational awareness) ---- if (_roster.isNotEmpty) ...[ const SizedBox(height: 10), diff --git a/lib/peat_flutter.dart b/lib/peat_flutter.dart index 5092e09..f3240e0 100644 --- a/lib/peat_flutter.dart +++ b/lib/peat_flutter.dart @@ -28,7 +28,8 @@ export 'src/peat_node.dart' BlobFetchStatusStarted, BlobFetchStatusDownloading, BlobFetchStatusCompleted, - BlobFetchStatusFailed; + BlobFetchStatusFailed, + MarkerInfo; /// Opens the peat_ffi native library for the current platform. /// diff --git a/lib/src/peat_node.dart b/lib/src/peat_node.dart index 2eabe04..61d66a0 100644 --- a/lib/src/peat_node.dart +++ b/lib/src/peat_node.dart @@ -35,7 +35,8 @@ export 'generated/peat_ffi.dart' BlobFetchStatusStarted, BlobFetchStatusDownloading, BlobFetchStatusCompleted, - BlobFetchStatusFailed; + BlobFetchStatusFailed, + MarkerInfo; final _rng = Random(); @@ -145,6 +146,20 @@ class PeatFlutterNode { void deleteNode(String nodeId) => _node.deleteDocument('nodes', nodeId); + /// Delete a document by collection and id. Propagates across the mesh. + void deleteDocument(String collection, String docId) => + _node.deleteDocument(collection, docId); + + // ── Markers ─────────────────────────────────────────────────────────── + + /// Place or update a map marker (OR-Set entry). To soft-delete, publish a + /// [MarkerInfo] with `deleted: true` — the tombstone propagates via CRDT. + void putMarker(MarkerInfo marker) => _node.putMarker(marker); + + /// All markers known to the mesh, including soft-deleted tombstones. + /// Filter on [MarkerInfo.deleted] to show only live pins. + List get markers => _node.getMarkers(); + // ── Cell ────────────────────────────────────────────────────────────── /// Publish a cell (team) into the mesh. diff --git a/test/delete_event_test.dart b/test/delete_event_test.dart new file mode 100644 index 0000000..5951231 --- /dev/null +++ b/test/delete_event_test.dart @@ -0,0 +1,66 @@ +// Copyright 2026 Defense Unicorns +// SPDX-License-Identifier: Apache-2.0 + +// Tests for delete-event handling: DocumentChange with ChangeType.delete +// round-trips correctly through JSON, and the ChangeType codec maps the +// 'delete' string to the enum variant. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:peat_flutter/peat_flutter.dart'; + +void main() { + group('DocumentChange — delete events', () { + test('ChangeType.delete round-trips through JSON', () { + final change = DocumentChange( + collection: 'markers', + docId: 'pin-001', + changeType: ChangeType.delete, + origin: const ChangeOrigin.local(), + ); + final json = change.toJson(); + expect(json['changeType'], 'delete'); + + final restored = DocumentChange.fromJson(json); + expect(restored.changeType, ChangeType.delete); + expect(restored.collection, 'markers'); + expect(restored.docId, 'pin-001'); + }); + + test('delete and upsert are distinct change types', () { + final del = DocumentChange.fromJson({ + 'collection': 'markers', + 'docId': 'pin-001', + 'changeType': 'delete', + 'origin': null, + }); + final ups = DocumentChange.fromJson({ + 'collection': 'markers', + 'docId': 'pin-001', + 'changeType': 'upsert', + 'origin': null, + }); + expect(del.changeType, isNot(equals(ups.changeType))); + expect(del, isNot(equals(ups))); + }); + + test('ChangeType.delete name is "delete"', () { + expect(ChangeType.delete.name, 'delete'); + }); + + test('ChangeType.upsert name is "upsert"', () { + expect(ChangeType.upsert.name, 'upsert'); + }); + + test('remote delete preserves peer origin', () { + final change = DocumentChange.fromJson({ + 'collection': 'nodes', + 'docId': 'node-abc', + 'changeType': 'delete', + 'origin': 'peer-xyz', + }); + expect(change.changeType, ChangeType.delete); + expect(change.origin.isLocal, isFalse); + expect(change.origin.peerId, 'peer-xyz'); + }); + }); +} diff --git a/test/marker_info_test.dart b/test/marker_info_test.dart new file mode 100644 index 0000000..d9f5c59 --- /dev/null +++ b/test/marker_info_test.dart @@ -0,0 +1,142 @@ +// Copyright 2026 Defense Unicorns +// SPDX-License-Identifier: Apache-2.0 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:peat_flutter/peat_flutter.dart'; + +void main() { + const marker = MarkerInfo( + uid: 'pin-001', + markerType: 'b-m-p-w', + lat: 38.8895, + lon: -77.0352, + hae: 15.0, + ts: 1720000000000, + callsign: 'ALPHA-1', + color: 0xFFFF0000, + cellId: 'cell-a', + deleted: false, + ); + + group('MarkerInfo — JSON round-trip', () { + test('toJson includes all fields', () { + final json = marker.toJson(); + expect(json['uid'], 'pin-001'); + expect(json['markerType'], 'b-m-p-w'); + expect(json['lat'], 38.8895); + expect(json['lon'], -77.0352); + expect(json['hae'], 15.0); + expect(json['ts'], 1720000000000); + expect(json['callsign'], 'ALPHA-1'); + expect(json['color'], 0xFFFF0000); + expect(json['cellId'], 'cell-a'); + expect(json['deleted'], false); + }); + + test('fromJson reconstructs identical marker', () { + final roundTripped = MarkerInfo.fromJson(marker.toJson()); + expect(roundTripped, equals(marker)); + }); + + test('nullable fields round-trip as null', () { + const sparse = MarkerInfo( + uid: 'pin-002', + markerType: 'a-f-G-U-C', + lat: 0, + lon: 0, + hae: null, + ts: 0, + callsign: null, + color: null, + cellId: null, + deleted: false, + ); + final json = sparse.toJson(); + expect(json['hae'], isNull); + expect(json['callsign'], isNull); + expect(json['color'], isNull); + expect(json['cellId'], isNull); + + final restored = MarkerInfo.fromJson(json); + expect(restored, equals(sparse)); + }); + + test('fromJson coerces int lat/lon to double', () { + final json = marker.toJson(); + json['lat'] = 39; + json['lon'] = -77; + final m = MarkerInfo.fromJson(json); + expect(m.lat, 39.0); + expect(m.lon, -77.0); + }); + }); + + group('MarkerInfo — copyWith', () { + test('preserves fields when no overrides', () { + final copy = marker.copyWith(); + expect(copy, equals(marker)); + }); + + test('overrides deleted for soft-delete tombstone', () { + final tombstone = marker.copyWith(deleted: true); + expect(tombstone.deleted, isTrue); + expect(tombstone.uid, marker.uid); + expect(tombstone.lat, marker.lat); + expect(tombstone.lon, marker.lon); + }); + + test('can clear nullable fields to null', () { + final cleared = marker.copyWith( + hae: null, + callsign: null, + color: null, + cellId: null, + ); + expect(cleared.hae, isNull); + expect(cleared.callsign, isNull); + expect(cleared.color, isNull); + expect(cleared.cellId, isNull); + }); + }); + + group('MarkerInfo — equality', () { + test('identical values are equal', () { + final a = MarkerInfo.fromJson(marker.toJson()); + final b = MarkerInfo.fromJson(marker.toJson()); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different uid is not equal', () { + final other = marker.copyWith(uid: 'pin-999'); + expect(other, isNot(equals(marker))); + }); + + test('deleted flag changes equality', () { + final tombstone = marker.copyWith(deleted: true); + expect(tombstone, isNot(equals(marker))); + }); + }); + + group('MarkerInfo — soft-delete filtering', () { + test('live markers exclude tombstones', () { + final markers = [ + marker, + marker.copyWith(uid: 'pin-002', deleted: true), + marker.copyWith(uid: 'pin-003'), + ]; + final live = markers.where((m) => !m.deleted).toList(); + expect(live, hasLength(2)); + expect(live.map((m) => m.uid), containsAll(['pin-001', 'pin-003'])); + }); + + test('tombstone preserves original data for CRDT convergence', () { + final tombstone = marker.copyWith(deleted: true); + expect(tombstone.uid, marker.uid); + expect(tombstone.lat, marker.lat); + expect(tombstone.lon, marker.lon); + expect(tombstone.callsign, marker.callsign); + expect(tombstone.ts, marker.ts); + }); + }); +}