Skip to content

Commit dcd7bb8

Browse files
web-flowclaude
andcommitted
Add local-first sync with server authority to all SDKs
Annotations always create locally first, then sync to server. Pending IDs tracked in separate storage key. On fetch, uploads pending first then merges server data with remaining unsynced locals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7dbb568 commit dcd7bb8

File tree

4 files changed

+461
-154
lines changed

4 files changed

+461
-154
lines changed

sdks/flutter/lib/src/agentation_provider.dart

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:agentation_mobile/src/animation_detector.dart';
1010
import 'package:agentation_mobile/src/element_collector.dart';
1111

1212
const _storageKey = 'agentation_mobile_annotations';
13+
const _pendingIdsKey = 'agentation_mobile_pending_ids';
1314

1415
/// Configuration for the agentation-mobile connection.
1516
class AgentationConfig {
@@ -87,6 +88,7 @@ class AgentationState extends State<AgentationProvider> {
8788
List<DetectedAnimation> _activeAnimations = [];
8889
List<CollectedElement> _collectedElements = [];
8990
VoidCallback? _animationListener;
91+
final Set<String> _pendingIds = {};
9092

9193
List<MobileAnnotation> get annotations => _annotations;
9294
bool get connected => localMode ? true : _connected;
@@ -111,6 +113,7 @@ class AgentationState extends State<AgentationProvider> {
111113
// Load from local storage first
112114
_prefs = await SharedPreferences.getInstance();
113115
_loadFromStorage();
116+
_loadPendingIds();
114117

115118
// Install animation detector
116119
AnimationDetector.instance.install();
@@ -147,11 +150,27 @@ class AgentationState extends State<AgentationProvider> {
147150
}
148151
}
149152

153+
void _loadPendingIds() {
154+
final stored = _prefs?.getString(_pendingIdsKey);
155+
if (stored != null) {
156+
try {
157+
final list = jsonDecode(stored) as List<dynamic>;
158+
_pendingIds.addAll(list.cast<String>());
159+
} catch (_) {
160+
// Ignore corrupted storage
161+
}
162+
}
163+
}
164+
150165
void _saveToStorage() {
151166
final data = jsonEncode(_annotations.map((a) => a.toJson()).toList());
152167
_prefs?.setString(_storageKey, data);
153168
}
154169

170+
void _savePendingIds() {
171+
_prefs?.setString(_pendingIdsKey, jsonEncode(_pendingIds.toList()));
172+
}
173+
155174
@override
156175
void didUpdateWidget(AgentationProvider oldWidget) {
157176
super.didUpdateWidget(oldWidget);
@@ -234,9 +253,45 @@ class AgentationState extends State<AgentationProvider> {
234253
}
235254
}
236255

256+
/// Upload pending local annotations to the server.
257+
Future<void> _uploadPending() async {
258+
if (_httpClient == null || _pendingIds.isEmpty) return;
259+
260+
final pending = _annotations.where((a) => _pendingIds.contains(a.id)).toList();
261+
for (final annotation in pending) {
262+
try {
263+
final uri = Uri.parse('${widget.config.serverUrl}/api/annotations');
264+
final request = await _httpClient!.postUrl(uri);
265+
request.headers.set('Content-Type', 'application/json');
266+
request.write(jsonEncode({
267+
'sessionId': annotation.sessionId,
268+
'x': annotation.x,
269+
'y': annotation.y,
270+
'deviceId': annotation.deviceId,
271+
'platform': annotation.platform,
272+
'screenWidth': annotation.screenWidth,
273+
'screenHeight': annotation.screenHeight,
274+
'comment': annotation.comment,
275+
'intent': annotation.intent.name,
276+
'severity': annotation.severity.name,
277+
}));
278+
final response = await request.close();
279+
if (response.statusCode == 200 || response.statusCode == 201) {
280+
_pendingIds.remove(annotation.id);
281+
}
282+
} catch (_) {
283+
// Will retry on next fetch cycle
284+
}
285+
}
286+
_savePendingIds();
287+
}
288+
237289
Future<void> _fetchAnnotations() async {
238290
if (widget.config.sessionId == null || _httpClient == null) return;
239291
try {
292+
// Upload pending annotations first
293+
await _uploadPending();
294+
240295
final uri = Uri.parse(
241296
'${widget.config.serverUrl}/api/annotations?sessionId=${widget.config.sessionId}',
242297
);
@@ -245,11 +300,20 @@ class AgentationState extends State<AgentationProvider> {
245300
if (response.statusCode == 200) {
246301
final body = await response.transform(utf8.decoder).join();
247302
final list = jsonDecode(body) as List<dynamic>;
303+
final serverAnnotations = list
304+
.map((j) => MobileAnnotation.fromJson(j as Map<String, dynamic>))
305+
.toList();
306+
248307
if (mounted) {
308+
// Merge: server data + remaining unsynced locals
309+
final serverIds = serverAnnotations.map((a) => a.id).toSet();
310+
final stillPending = _annotations
311+
.where((a) => _pendingIds.contains(a.id) && !serverIds.contains(a.id))
312+
.toList();
313+
final merged = [...serverAnnotations, ...stillPending];
314+
249315
setState(() {
250-
_annotations = list
251-
.map((j) => MobileAnnotation.fromJson(j as Map<String, dynamic>))
252-
.toList();
316+
_annotations = merged;
253317
_connected = true;
254318
});
255319
_saveToStorage();
@@ -262,41 +326,45 @@ class AgentationState extends State<AgentationProvider> {
262326
}
263327
}
264328

265-
/// Create a new annotation. Works in both local and server mode.
329+
/// Create a new annotation. Always local-first, then syncs to server.
266330
Future<void> createAnnotation({
267331
required double x,
268332
required double y,
269333
required String comment,
270334
AnnotationIntent intent = AnnotationIntent.fix,
271335
AnnotationSeverity severity = AnnotationSeverity.important,
272336
}) async {
273-
if (localMode) {
274-
// Local-only mode: create annotation locally
275-
final now = DateTime.now().toUtc().toIso8601String();
276-
final annotation = MobileAnnotation(
277-
id: '${DateTime.now().millisecondsSinceEpoch}-${(DateTime.now().microsecond).toRadixString(36)}',
278-
sessionId: widget.config.sessionId ?? 'local',
279-
x: x,
280-
y: y,
281-
deviceId: widget.config.deviceId ?? 'flutter-device',
282-
platform: 'flutter',
283-
screenWidth: 0,
284-
screenHeight: 0,
285-
comment: comment,
286-
intent: intent,
287-
severity: severity,
288-
status: AnnotationStatus.pending,
289-
thread: [],
290-
createdAt: now,
291-
updatedAt: now,
292-
);
293-
if (mounted) {
294-
setState(() => _annotations = [..._annotations, annotation]);
295-
_saveToStorage();
296-
}
297-
} else {
298-
// Server mode: POST to server
299-
if (widget.config.sessionId == null || _httpClient == null) return;
337+
// Always create locally first
338+
final now = DateTime.now().toUtc().toIso8601String();
339+
final id = '${DateTime.now().millisecondsSinceEpoch}-${(DateTime.now().microsecond).toRadixString(36)}';
340+
final annotation = MobileAnnotation(
341+
id: id,
342+
sessionId: widget.config.sessionId ?? 'local',
343+
x: x,
344+
y: y,
345+
deviceId: widget.config.deviceId ?? 'flutter-device',
346+
platform: 'flutter',
347+
screenWidth: 0,
348+
screenHeight: 0,
349+
comment: comment,
350+
intent: intent,
351+
severity: severity,
352+
status: AnnotationStatus.pending,
353+
thread: [],
354+
createdAt: now,
355+
updatedAt: now,
356+
);
357+
358+
_pendingIds.add(id);
359+
_savePendingIds();
360+
361+
if (mounted) {
362+
setState(() => _annotations = [..._annotations, annotation]);
363+
_saveToStorage();
364+
}
365+
366+
// If server mode, try to upload immediately
367+
if (!localMode && _httpClient != null && widget.config.sessionId != null) {
300368
try {
301369
final uri = Uri.parse('${widget.config.serverUrl}/api/annotations');
302370
final request = await _httpClient!.postUrl(uri);
@@ -313,10 +381,14 @@ class AgentationState extends State<AgentationProvider> {
313381
'intent': intent.name,
314382
'severity': severity.name,
315383
}));
316-
await request.close();
317-
await _fetchAnnotations();
384+
final response = await request.close();
385+
if (response.statusCode == 200 || response.statusCode == 201) {
386+
_pendingIds.remove(id);
387+
_savePendingIds();
388+
await _fetchAnnotations();
389+
}
318390
} catch (_) {
319-
// Silently fail — dev tool
391+
// Stays in pending, will be uploaded on next fetch cycle
320392
}
321393
}
322394
}

0 commit comments

Comments
 (0)