@@ -45,6 +45,20 @@ enum AutoMode {
4545 rxOnly,
4646}
4747
48+ /// Result of uploading an offline session
49+ enum OfflineUploadResult {
50+ /// Upload completed successfully
51+ success,
52+ /// Session file not found
53+ notFound,
54+ /// Session data is invalid or empty
55+ invalidSession,
56+ /// API authentication failed
57+ authFailed,
58+ /// Some pings failed to upload
59+ partialFailure,
60+ }
61+
4862/// Main application state provider
4963class AppStateProvider extends ChangeNotifier {
5064 final BluetoothService _bluetoothService;
@@ -337,14 +351,16 @@ class AppStateProvider extends ChangeNotifier {
337351 notifyListeners ();
338352
339353 // Check zone on first GPS lock (when _inZone is null)
340- if (_inZone == null ) {
354+ // Skip zone checks when offline mode is enabled
355+ if (_inZone == null && ! _preferences.offlineMode) {
341356 debugLog ('[GEOFENCE] First GPS lock, checking zone status' );
342357 await checkZoneStatus ();
343358 }
344359
345360 // Check zone every 100m movement (while disconnected)
346361 // This allows users to know if they've entered/exited a zone while moving
347- if (! isConnected && _shouldRecheckZone (position)) {
362+ // Skip zone checks when offline mode is enabled
363+ if (! isConnected && ! _preferences.offlineMode && _shouldRecheckZone (position)) {
348364 debugLog ('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone' );
349365 await checkZoneStatus ();
350366 }
@@ -469,29 +485,36 @@ class AppStateProvider extends ChangeNotifier {
469485 _meshCoreConnection = MeshCoreConnection (bluetooth: _bluetoothService);
470486
471487 // Set auth callback for Step 6 (called during connect, after public key is acquired)
472- _meshCoreConnection! .onRequestAuth = () async {
473- final publicKey = _meshCoreConnection! .devicePublicKey;
474- if (publicKey == null ) {
475- debugError ('[APP] Cannot request auth: no public key' );
476- return {'success' : false , 'reason' : 'no_public_key' , 'message' : 'Device public key not available' };
477- }
488+ // Skip auth when offline mode is enabled
489+ if (! _preferences.offlineMode) {
490+ _meshCoreConnection! .onRequestAuth = () async {
491+ final publicKey = _meshCoreConnection! .devicePublicKey;
492+ if (publicKey == null ) {
493+ debugError ('[APP] Cannot request auth: no public key' );
494+ return {'success' : false , 'reason' : 'no_public_key' , 'message' : 'Device public key not available' };
495+ }
478496
479- debugLog ('[APP] Requesting API auth with public key: ${publicKey .substring (0 , 16 )}...' );
480- // Strip "MeshCore-" prefix from device name for API
481- final deviceName = connectedDeviceName? .replaceFirst ('MeshCore-' , '' ) ?? 'GOME-WarDriver' ;
482- return await _apiService.requestAuth (
483- reason: 'connect' ,
484- publicKey: publicKey,
485- who: deviceName,
486- appVersion: _appVersion,
487- power: _preferences.powerLevel, // Send wattage (0.3, 1.0, 2.0) to match WebClient
488- iataCode: _preferences.iataCode,
489- model: _meshCoreConnection! .deviceModel? .manufacturer ?? _meshCoreConnection! .deviceInfo? .manufacturer ?? 'Unknown' ,
490- lat: _currentPosition? .latitude,
491- lon: _currentPosition? .longitude,
492- accuracyMeters: _currentPosition? .accuracy,
493- );
494- };
497+ debugLog ('[APP] Requesting API auth with public key: ${publicKey .substring (0 , 16 )}...' );
498+ // Strip "MeshCore-" prefix from device name for API
499+ final deviceName = connectedDeviceName? .replaceFirst ('MeshCore-' , '' ) ?? 'GOME-WarDriver' ;
500+ return await _apiService.requestAuth (
501+ reason: 'connect' ,
502+ publicKey: publicKey,
503+ who: deviceName,
504+ appVersion: _appVersion,
505+ power: _preferences.powerLevel, // Send wattage (0.3, 1.0, 2.0) to match WebClient
506+ iataCode: _preferences.iataCode,
507+ model: _meshCoreConnection! .deviceModel? .manufacturer ?? _meshCoreConnection! .deviceInfo? .manufacturer ?? 'Unknown' ,
508+ lat: _currentPosition? .latitude,
509+ lon: _currentPosition? .longitude,
510+ accuracyMeters: _currentPosition? .accuracy,
511+ );
512+ };
513+ } else {
514+ // Offline mode: skip API auth
515+ _meshCoreConnection! .onRequestAuth = null ;
516+ debugLog ('[APP] Offline mode: skipping API auth' );
517+ }
495518
496519 // Listen for step changes
497520 _meshCoreConnection! .stepStream.listen ((step) {
@@ -1237,11 +1260,36 @@ class AppStateProvider extends ChangeNotifier {
12371260 // ============================================
12381261
12391262 /// Toggle offline mode
1240- void setOfflineMode (bool enabled) {
1263+ /// Returns false if offline mode cannot be changed (e.g., while connected)
1264+ bool setOfflineMode (bool enabled) {
1265+ // Cannot change offline mode while connected
1266+ if (isConnected) {
1267+ debugLog ('[APP] Cannot change offline mode while connected' );
1268+ return false ;
1269+ }
1270+
12411271 _preferences = _preferences.copyWith (offlineMode: enabled);
12421272 _apiQueueService.offlineMode = enabled;
12431273 debugLog ('[APP] Offline mode ${enabled ? 'enabled' : 'disabled' }' );
1274+
1275+ if (enabled) {
1276+ // Clear zone data when entering offline mode
1277+ _inZone = null ;
1278+ _currentZone = null ;
1279+ _nearestZone = null ;
1280+ _lastZoneCheck = null ;
1281+ _lastZoneCheckPosition = null ;
1282+ debugLog ('[GEOFENCE] Cleared zone data for offline mode' );
1283+ } else {
1284+ // Re-check zone status when exiting offline mode
1285+ if (_currentPosition != null ) {
1286+ debugLog ('[GEOFENCE] Re-checking zone status after offline mode disabled' );
1287+ checkZoneStatus ();
1288+ }
1289+ }
1290+
12441291 notifyListeners ();
1292+ return true ;
12451293 }
12461294
12471295 /// Save accumulated offline pings to a session file
@@ -1251,7 +1299,13 @@ class AppStateProvider extends ChangeNotifier {
12511299 debugLog ('[APP] No offline pings to save' );
12521300 return ;
12531301 }
1254- await _offlineSessionService.saveSession (pings);
1302+
1303+ // Include device info for auth during upload
1304+ await _offlineSessionService.saveSession (
1305+ pings,
1306+ devicePublicKey: _devicePublicKey,
1307+ deviceName: connectedDeviceName? .replaceFirst ('MeshCore-' , '' ),
1308+ );
12551309 debugLog ('[APP] Saved offline session with ${pings .length } pings' );
12561310 notifyListeners ();
12571311 }
@@ -1303,6 +1357,107 @@ class AppStateProvider extends ChangeNotifier {
13031357 }
13041358 }
13051359
1360+ /// Upload an offline session with authenticated API session
1361+ /// Uses stored device credentials to authenticate before uploading
1362+ ///
1363+ /// Returns the result of the upload operation
1364+ Future <OfflineUploadResult > uploadOfflineSessionWithAuth (String filename) async {
1365+ // 1. Get session with stored device credentials
1366+ final session = _offlineSessionService.getSession (filename);
1367+ if (session == null ) {
1368+ debugLog ('[APP] Offline session not found: $filename ' );
1369+ return OfflineUploadResult .notFound;
1370+ }
1371+
1372+ // Check if session has pings
1373+ final sessionData = session.data;
1374+ final pings = (sessionData['pings' ] as List <dynamic >? )
1375+ ? .map ((p) => Map <String , dynamic >.from (p as Map ))
1376+ .toList ();
1377+
1378+ if (pings == null || pings.isEmpty) {
1379+ debugLog ('[APP] Offline session has no pings: $filename ' );
1380+ return OfflineUploadResult .invalidSession;
1381+ }
1382+
1383+ // 2. Get device credentials from session
1384+ final publicKey = session.devicePublicKey;
1385+ if (publicKey == null ) {
1386+ debugLog ('[APP] Offline session missing device public key: $filename ' );
1387+ return OfflineUploadResult .invalidSession;
1388+ }
1389+
1390+ // 3. Authenticate with offline_mode: true
1391+ debugLog ('[APP] Authenticating for offline upload with device: ${session .deviceName ?? "unknown" }' );
1392+ final authResult = await _apiService.requestAuth (
1393+ reason: 'connect' ,
1394+ publicKey: publicKey,
1395+ who: session.deviceName ?? 'GOME-WarDriver' ,
1396+ appVersion: _appVersion,
1397+ power: _preferences.powerLevel,
1398+ iataCode: _preferences.iataCode,
1399+ model: 'Offline Upload' ,
1400+ lat: _currentPosition? .latitude,
1401+ lon: _currentPosition? .longitude,
1402+ accuracyMeters: _currentPosition? .accuracy,
1403+ offlineMode: true ,
1404+ );
1405+
1406+ if (authResult == null || authResult['success' ] != true ) {
1407+ final reason = authResult? ['reason' ] as String ? ?? 'unknown' ;
1408+ debugLog ('[APP] Offline upload auth failed: $reason ' );
1409+ _statusMessageService.setDynamicStatus (
1410+ 'Auth failed: $reason ' ,
1411+ StatusColor .error,
1412+ );
1413+ return OfflineUploadResult .authFailed;
1414+ }
1415+
1416+ debugLog ('[APP] Offline upload authenticated, session: ${authResult ['session_id' ]}' );
1417+
1418+ // 4. Upload pings in batches of 50
1419+ const batchSize = 50 ;
1420+ var uploadedCount = 0 ;
1421+ var failedBatches = 0 ;
1422+
1423+ for (var i = 0 ; i < pings.length; i += batchSize) {
1424+ final batch = pings.skip (i).take (batchSize).toList ();
1425+ final success = await _apiService.uploadBatch (batch);
1426+ if (success) {
1427+ uploadedCount += batch.length;
1428+ debugLog ('[APP] Uploaded batch ${(i ~/ batchSize ) + 1 }: ${batch .length } pings' );
1429+ } else {
1430+ failedBatches++ ;
1431+ debugError ('[APP] Failed to upload batch ${(i ~/ batchSize ) + 1 }' );
1432+ }
1433+ }
1434+
1435+ // 5. Release API session
1436+ await _apiService.requestAuth (
1437+ reason: 'disconnect' ,
1438+ publicKey: publicKey,
1439+ );
1440+ debugLog ('[APP] Offline upload session released' );
1441+
1442+ // 6. Mark session as uploaded (don't delete) if all batches succeeded
1443+ if (failedBatches == 0 ) {
1444+ await _offlineSessionService.markAsUploaded (filename);
1445+ _statusMessageService.setDynamicStatus (
1446+ 'Uploaded ${pings .length } pings from $filename ' ,
1447+ StatusColor .success,
1448+ );
1449+ notifyListeners ();
1450+ return OfflineUploadResult .success;
1451+ } else {
1452+ _statusMessageService.setDynamicStatus (
1453+ 'Partial upload: $uploadedCount /${pings .length } pings' ,
1454+ StatusColor .warning,
1455+ );
1456+ notifyListeners ();
1457+ return OfflineUploadResult .partialFailure;
1458+ }
1459+ }
1460+
13061461 /// Delete an offline session without uploading
13071462 Future <void > deleteOfflineSession (String filename) async {
13081463 await _offlineSessionService.deleteSession (filename);
0 commit comments