Skip to content

Commit cfbbf0b

Browse files
committed
Implement offline mode functionality: update user preferences, enhance app state provider, and modify UI components to support offline operations
1 parent 6dd785c commit cfbbf0b

File tree

11 files changed

+460
-87
lines changed

11 files changed

+460
-87
lines changed

lib/models/user_preferences.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class UserPreferences {
6464
ignoreRepeaterId: json['ignoreRepeaterId'] as String?,
6565
autoPowerSet: (json['autoPowerSet'] as bool?) ?? false,
6666
powerLevelSet: (json['powerLevelSet'] as bool?) ?? false,
67-
offlineMode: (json['offlineMode'] as bool?) ?? false,
67+
offlineMode: false, // Never persist - always off by default
6868
iataCode: (json['iataCode'] as String?) ?? 'YOW',
6969
backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false,
7070
);
@@ -82,7 +82,7 @@ class UserPreferences {
8282
'ignoreRepeaterId': ignoreRepeaterId,
8383
'autoPowerSet': autoPowerSet,
8484
'powerLevelSet': powerLevelSet,
85-
'offlineMode': offlineMode,
85+
// offlineMode intentionally not persisted - always off on app start
8686
'iataCode': iataCode,
8787
'backgroundModeEnabled': backgroundModeEnabled,
8888
};

lib/providers/app_state_provider.dart

Lines changed: 181 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4963
class 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);

lib/screens/connection_screen.dart

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ class ConnectionScreen extends StatelessWidget {
2727
body: _buildBody(context, appState),
2828
floatingActionButton: !appState.isScanning && !appState.isConnected
2929
? FloatingActionButton.extended(
30-
onPressed: appState.inZone == true ? () => appState.startScan() : null,
30+
// Allow scanning when in zone OR when offline mode enabled
31+
onPressed: (appState.inZone == true || appState.offlineMode)
32+
? () => appState.startScan()
33+
: null,
3134
icon: const Icon(Icons.bluetooth_searching),
32-
label: Text(appState.inZone == true ? 'Scan' : 'Outside Zone'),
33-
backgroundColor: appState.inZone == true ? null : Colors.grey,
35+
label: Text(appState.offlineMode
36+
? 'Scan'
37+
: (appState.inZone == true ? 'Scan' : 'Outside Zone')),
38+
backgroundColor: (appState.inZone == true || appState.offlineMode)
39+
? null
40+
: Colors.grey,
3441
)
3542
: null,
3643
);
@@ -169,9 +176,10 @@ class ConnectionScreen extends StatelessWidget {
169176
const SizedBox(height: 16),
170177
// Regional Configuration card
171178
RegionalConfigCard(
172-
zoneName: appState.zoneName,
173-
zoneCode: appState.zoneCode,
174-
channels: appState.regionalChannels,
179+
zoneName: appState.offlineMode ? null : appState.zoneName,
180+
zoneCode: appState.offlineMode ? null : appState.zoneCode,
181+
channels: appState.offlineMode ? [] : appState.regionalChannels,
182+
isOfflineMode: appState.offlineMode,
175183
),
176184
],
177185
),
@@ -525,9 +533,14 @@ class ConnectionScreen extends StatelessWidget {
525533
String? iataCode;
526534
Color locationColor;
527535

536+
// Offline mode: show greyed out with "-"
537+
if (appState.offlineMode) {
538+
locationIcon = Icons.gps_off;
539+
locationText = '-';
540+
locationColor = Colors.grey;
528541
// Only show "Checking..." on initial load when we don't have zone info yet
529542
// After that, keep showing current state while checking happens in background
530-
if (appState.isCheckingZone && appState.inZone == null) {
543+
} else if (appState.isCheckingZone && appState.inZone == null) {
531544
locationIcon = Icons.location_searching;
532545
locationText = 'Checking...';
533546
locationColor = Colors.blue;
@@ -642,11 +655,11 @@ class ConnectionScreen extends StatelessWidget {
642655
}
643656

644657
Widget _buildDeviceList(BuildContext context, AppStateProvider appState) {
645-
// Check if Connect should be disabled (outside zone)
646-
final canConnect = appState.inZone == true;
658+
// Allow connection when in zone OR when offline mode enabled
659+
final canConnect = appState.inZone == true || appState.offlineMode;
647660

648-
// Show onboarding message when outside zone
649-
if (appState.inZone == false) {
661+
// Show onboarding message when outside zone (but NOT when offline mode is enabled)
662+
if (appState.inZone == false && !appState.offlineMode) {
650663
return Column(
651664
children: [
652665
_buildZoneStatusBar(context, appState),

0 commit comments

Comments
 (0)