@@ -10,6 +10,7 @@ import 'package:agentation_mobile/src/animation_detector.dart';
1010import 'package:agentation_mobile/src/element_collector.dart' ;
1111
1212const _storageKey = 'agentation_mobile_annotations' ;
13+ const _pendingIdsKey = 'agentation_mobile_pending_ids' ;
1314
1415/// Configuration for the agentation-mobile connection.
1516class 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