Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 162 additions & 21 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
BlobFetchStatus? _blobDownloadStatus;
String? _blobDownloadHash; // hash of the blob currently being fetched

// Markers (OR-Set with soft-delete tombstones)
List<MarkerInfo> _markers = [];

// Cell and Command state
CellInfo? _activeCell;
List<CommandInfo> _commands = [];
Expand Down Expand Up @@ -476,6 +479,7 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
_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!);
Expand Down Expand Up @@ -816,6 +820,7 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
}
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}';
Expand All @@ -826,30 +831,38 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
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<String, dynamic>?;
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<String, dynamic>?;
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;
Expand Down Expand Up @@ -907,6 +920,13 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
} 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,
Expand Down Expand Up @@ -1286,6 +1306,7 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
_crdtReasmTs.clear();
_claimedCommandIds.clear();
_commands = [];
_markers = [];
_activeCell = null;
_missionDays = 0;
_missionSetBy = null;
Expand Down Expand Up @@ -2703,6 +2724,120 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
);
}

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
Expand Down Expand Up @@ -3294,6 +3429,12 @@ class _PeatExampleHomeState extends State<PeatExampleHome>
_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),
Expand Down
3 changes: 2 additions & 1 deletion lib/peat_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
17 changes: 16 additions & 1 deletion lib/src/peat_node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export 'generated/peat_ffi.dart'
BlobFetchStatusStarted,
BlobFetchStatusDownloading,
BlobFetchStatusCompleted,
BlobFetchStatusFailed;
BlobFetchStatusFailed,
MarkerInfo;

final _rng = Random();

Expand Down Expand Up @@ -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<MarkerInfo> get markers => _node.getMarkers();

// ── Cell ──────────────────────────────────────────────────────────────

/// Publish a cell (team) into the mesh.
Expand Down
66 changes: 66 additions & 0 deletions test/delete_event_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
Loading
Loading