offline options globally toggleable

This commit is contained in:
stopflock
2026-07-25 20:40:15 -05:00
parent 6f017fd6c1
commit dfcc64e96e
24 changed files with 489 additions and 92 deletions
+3 -1
View File
@@ -2,9 +2,11 @@
"2.10.5": {
"content": [
"• Make node tags selectable / copyable",
"• Add status indicator to details sheet for pending nodes"
"• Add status indicator to details sheet for pending nodes",
"• Offline areas now live behind a new 'Enable Offline Features' toggle in Offline Settings (auto-enabled for existing users with downloaded areas)"
]
},
"2.10.4": {
"content": [
"• Catch and correct a couple rare map bounds issues",
+18
View File
@@ -158,6 +158,8 @@ class AppState extends ChangeNotifier {
FollowMeMode get followMeMode => _settingsState.followMeMode;
bool get keepScreenAwake => _settingsState.keepScreenAwake;
bool get hideZoomControls => _settingsState.hideZoomControls;
bool get offlineFeaturesEnabled => _settingsState.offlineFeaturesEnabled;
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
@@ -691,6 +693,22 @@ class AppState extends ChangeNotifier {
await _settingsState.setHideZoomControls(enabled);
}
/// Set the master "offline features enabled" toggle. When disabling,
/// also forces offlineMode off (if it was on) and cancels any active
/// area downloads, so we never end up "in offline mode" with the
/// feature disabled. Does NOT delete existing offline area data — that
/// is a separate, explicit user choice handled by the UI.
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
await _settingsState.setOfflineFeaturesEnabled(enabled);
if (!enabled) {
if (offlineMode) {
await setOfflineMode(false);
}
await OfflineAreaService().cancelActiveDownloads();
}
}
Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled);
+11 -2
View File
@@ -76,8 +76,12 @@
"proximityAlerts": "Näherungswarnungen",
"keepScreenAwake": "Bildschirm Aktiv Lassen",
"networkStatusIndicator": "Netzwerkstatus-Anzeige",
"hideZoomControls": "Zoom-Steuerung Ausblenden"
"hideZoomControls": "Zoom-Steuerung Ausblenden",
"enableOfflineFeatures": "Offline-Funktionen Aktivieren",
"enableOfflineFeaturesTitle": "Offline-Funktionen aktivieren",
"enableOfflineFeaturesSubtitle": "Herunterladen und Nutzen von Offline-Kartenbereichen erlauben. Wenn deaktiviert, sind Offline-Modus und heruntergeladene Bereiche nicht verfügbar."
},
"keepScreenAwake": {
"title": "Bildschirm während der App-Nutzung aktiv lassen",
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
@@ -411,8 +415,13 @@
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
"startRefresh": "Aktualisierung starten",
"refreshStarted": "Aktualisierung gestartet!",
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
"refreshFailed": "Aktualisierung fehlgeschlagen: {}",
"disableConfirmTitle": "Vorhandene Offline-Bereichsdaten löschen?",
"disableConfirmMessage": "Sie haben bereits Offline-Bereichsdaten. Das Deaktivieren der Offline-Funktionen verhindert, dass die App diese verwendet. Möchten Sie diese Daten für den Fall behalten, dass Sie die Offline-Funktionen später wieder aktivieren, oder sie jetzt löschen, um Speicherplatz freizugeben?",
"keepData": "Daten Behalten",
"deleteData": "Daten Löschen"
},
"refineTagsSheet": {
"title": "Tags Verfeinern",
"operatorProfile": "Betreiber-Profil",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Proximity Alerts",
"networkStatusIndicator": "Network Status Indicator",
"keepScreenAwake": "Keep Screen Awake",
"hideZoomControls": "Hide Zoom Controls"
"hideZoomControls": "Hide Zoom Controls",
"enableOfflineFeatures": "Enable Offline Features",
"enableOfflineFeaturesTitle": "Enable offline features",
"enableOfflineFeaturesSubtitle": "Allow downloading and using offline map areas. When disabled, offline mode and downloaded areas are unavailable."
},
"keepScreenAwake": {
"title": "Keep screen awake while using app",
"subtitle": "Prevents the screen from turning off automatically while using the app"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Re-fetch node data for this area",
"startRefresh": "Start Refresh",
"refreshStarted": "Refresh started!",
"refreshFailed": "Refresh failed: {}"
"refreshFailed": "Refresh failed: {}",
"disableConfirmTitle": "Delete Existing Offline Area Data?",
"disableConfirmMessage": "You have existing offline area data. Disabling offline features will stop the app from using it. Would you like to keep this data on disk in case you re-enable offline features later, or delete it now to free up storage space?",
"keepData": "Keep Data",
"deleteData": "Delete Data"
},
"refineTagsSheet": {
"title": "Refine Tags",
"operatorProfile": "Operator Profile",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Alertas de Proximidad",
"keepScreenAwake": "Mantener la Pantalla Encendida",
"networkStatusIndicator": "Indicador de Estado de Red",
"hideZoomControls": "Ocultar Controles de Zoom"
"hideZoomControls": "Ocultar Controles de Zoom",
"enableOfflineFeatures": "Activar Funciones sin Conexión",
"enableOfflineFeaturesTitle": "Activar funciones sin conexión",
"enableOfflineFeaturesSubtitle": "Permitir descargar y usar áreas de mapa sin conexión. Si se desactiva, el modo sin conexión y las áreas descargadas no estarán disponibles."
},
"keepScreenAwake": {
"title": "Mantener la pantalla encendida mientras se usa la app",
"subtitle": "Evita que la pantalla se apague automáticamente mientras se usa la app"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
"startRefresh": "Iniciar Actualización",
"refreshStarted": "¡Actualización iniciada!",
"refreshFailed": "Actualización falló: {}"
"refreshFailed": "Actualización falló: {}",
"disableConfirmTitle": "¿Eliminar Datos de Áreas sin Conexión Existentes?",
"disableConfirmMessage": "Ya tiene datos de áreas sin conexión existentes. Desactivar las funciones sin conexión evitará que la app los use. ¿Desea conservar estos datos en el dispositivo por si reactiva las funciones sin conexión más tarde, o eliminarlos ahora para liberar espacio de almacenamiento?",
"keepData": "Conservar Datos",
"deleteData": "Eliminar Datos"
},
"refineTagsSheet": {
"title": "Refinar Etiquetas",
"operatorProfile": "Perfil de Operador",
+11 -2
View File
@@ -113,9 +113,13 @@
"proximityAlerts": "Alertes de Proximité",
"keepScreenAwake": "Maintenir l'écran Allumé",
"networkStatusIndicator": "Indicateur de Statut Réseau",
"hideZoomControls": "Masquer les Contrôles de Zoom"
"hideZoomControls": "Masquer les Contrôles de Zoom",
"enableOfflineFeatures": "Activer les Fonctionnalités Hors Ligne",
"enableOfflineFeaturesTitle": "Activer les fonctionnalités hors ligne",
"enableOfflineFeaturesSubtitle": "Autoriser le téléchargement et l'utilisation de zones de carte hors ligne. Si désactivé, le mode hors ligne et les zones téléchargées ne seront pas disponibles."
},
"keepScreenAwake": {
"title": "Maintenir l'écran allumé lors de l'utilisation de l'application",
"subtitle": "Empêche l'écran de s'éteindre automatiquement pendant l'utilisation de l'application"
},
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
"startRefresh": "Démarrer l'Actualisation",
"refreshStarted": "Actualisation démarrée !",
"refreshFailed": "Actualisation échouée : {}"
"refreshFailed": "Actualisation échouée : {}",
"disableConfirmTitle": "Supprimer les Données de Zones Hors Ligne Existantes ?",
"disableConfirmMessage": "Vous avez déjà des données de zones hors ligne. Désactiver les fonctionnalités hors ligne empêchera l'application de les utiliser. Souhaitez-vous conserver ces données sur l'appareil au cas où vous réactiveriez les fonctionnalités hors ligne plus tard, ou les supprimer maintenant pour libérer de l'espace de stockage ?",
"keepData": "Conserver les Données",
"deleteData": "Supprimer les Données"
},
"refineTagsSheet": {
"title": "Affiner les Étiquettes",
"operatorProfile": "Profil d'Opérateur",
+11 -2
View File
@@ -113,9 +113,13 @@
"proximityAlerts": "Avvisi di Prossimità",
"keepScreenAwake": "Mantieni lo Schermo Attivo",
"networkStatusIndicator": "Indicatore di Stato di Rete",
"hideZoomControls": "Nascondi Controlli Zoom"
"hideZoomControls": "Nascondi Controlli Zoom",
"enableOfflineFeatures": "Abilita Funzionalità Offline",
"enableOfflineFeaturesTitle": "Abilita funzionalità offline",
"enableOfflineFeaturesSubtitle": "Consenti il download e l'uso di aree mappa offline. Se disabilitato, la modalità offline e le aree scaricate non saranno disponibili."
},
"keepScreenAwake": {
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
},
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
"startRefresh": "Avvia Aggiornamento",
"refreshStarted": "Aggiornamento avviato!",
"refreshFailed": "Aggiornamento fallito: {}"
"refreshFailed": "Aggiornamento fallito: {}",
"disableConfirmTitle": "Eliminare i Dati delle Aree Offline Esistenti?",
"disableConfirmMessage": "Hai già dati di aree offline esistenti. Disabilitare le funzionalità offline impedirà all'app di usarli. Vuoi conservare questi dati sul dispositivo nel caso in cui riattivi le funzionalità offline in seguito, oppure eliminarli ora per liberare spazio di archiviazione?",
"keepData": "Conserva Dati",
"deleteData": "Elimina Dati"
},
"refineTagsSheet": {
"title": "Affina Tag",
"operatorProfile": "Profilo Operatore",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Nabijheids Waarschuwingen",
"keepScreenAwake": "Scherm Actief Houden",
"networkStatusIndicator": "Netwerk Status Indicator",
"hideZoomControls": "Zoomknoppen Verbergen"
"hideZoomControls": "Zoomknoppen Verbergen",
"enableOfflineFeatures": "Offline Functies Inschakelen",
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
},
"keepScreenAwake": {
"title": "Houd het scherm actief tijdens het gebruik van de app",
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
"startRefresh": "Start Verversen",
"refreshStarted": "Verversen gestart!",
"refreshFailed": "Verversen mislukt: {}"
"refreshFailed": "Verversen mislukt: {}",
"disableConfirmTitle": "Bestaande Offline Gebiedsgegevens Verwijderen?",
"disableConfirmMessage": "U heeft al bestaande offline gebiedsgegevens. Het uitschakelen van offline functies zal voorkomen dat de app deze gebruikt. Wilt u deze gegevens op het apparaat bewaren voor het geval u offline functies later weer inschakelt, of ze nu verwijderen om opslagruimte vrij te maken?",
"keepData": "Gegevens Behouden",
"deleteData": "Gegevens Verwijderen"
},
"refineTagsSheet": {
"title": "Verfijn Tags",
"operatorProfile": "Operator Profiel",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Alerty Bliskości",
"keepScreenAwake": "Nie Wygaszaj Ekranu",
"networkStatusIndicator": "Wskaźnik Stanu Sieci",
"hideZoomControls": "Ukryj Kontrolki Zoomu"
"hideZoomControls": "Ukryj Kontrolki Zoomu",
"enableOfflineFeatures": "Włącz Funkcje Offline",
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
},
"keepScreenAwake": {
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
"startRefresh": "Rozpocznij Odświeżanie",
"refreshStarted": "Odświeżanie rozpoczęte!",
"refreshFailed": "Odświeżanie nie powiodło się: {}"
"refreshFailed": "Odświeżanie nie powiodło się: {}",
"disableConfirmTitle": "Usunąć Istniejące Dane Obszarów Offline?",
"disableConfirmMessage": "Masz już istniejące dane obszarów offline. Wyłączenie funkcji offline uniemożliwi aplikacji ich używanie. Czy chcesz zachować te dane na urządzeniu na wypadek ponownego włączenia funkcji offline później, czy usunąć je teraz, aby zwolnić miejsce na dysku?",
"keepData": "Zachowaj Dane",
"deleteData": "Usuń Dane"
},
"refineTagsSheet": {
"title": "Doprecyzuj Tagi",
"operatorProfile": "Profil Operatora",
+11 -2
View File
@@ -113,9 +113,13 @@
"proximityAlerts": "Alertas de Proximidade",
"keepScreenAwake": "Manter Ecrã Ativa",
"networkStatusIndicator": "Indicador de Status de Rede",
"hideZoomControls": "Ocultar Controlos de Zoom"
"hideZoomControls": "Ocultar Controlos de Zoom",
"enableOfflineFeatures": "Ativar Funcionalidades Offline",
"enableOfflineFeaturesTitle": "Ativar funcionalidades offline",
"enableOfflineFeaturesSubtitle": "Permitir descarregar e usar áreas de mapa offline. Se desativado, o modo offline e as áreas descarregadas não estarão disponíveis."
},
"keepScreenAwake": {
"title": "Manter o ecrã ativo durante o uso do app",
"subtitle": "Impede que o ecrã se desligue automaticamente enquanto a app estiver em uso"
},
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
"startRefresh": "Iniciar Atualização",
"refreshStarted": "Atualização iniciada!",
"refreshFailed": "Atualização falhou: {}"
"refreshFailed": "Atualização falhou: {}",
"disableConfirmTitle": "Eliminar Dados de Áreas Offline Existentes?",
"disableConfirmMessage": "Já tem dados de áreas offline existentes. Desativar as funcionalidades offline impedirá que a app os utilize. Deseja manter estes dados no dispositivo caso reative as funcionalidades offline mais tarde, ou eliminá-los agora para libertar espaço de armazenamento?",
"keepData": "Manter Dados",
"deleteData": "Eliminar Dados"
},
"refineTagsSheet": {
"title": "Refinar Tags",
"operatorProfile": "Perfil de Operador",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Yakınlık Uyarıları",
"keepScreenAwake": "Ekranı Açık Tut",
"networkStatusIndicator": "Ağ Durumu Göstergesi",
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle"
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle",
"enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir",
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
},
"keepScreenAwake": {
"title": "Uygulamayı kullanırken ekranı açık tut",
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
"startRefresh": "Yenilemeyi Başlat",
"refreshStarted": "Yenileme başladı!",
"refreshFailed": "Yenileme başarısız: {}"
"refreshFailed": "Yenileme başarısız: {}",
"disableConfirmTitle": "Mevcut Çevrimdışı Alan Verileri Silinsin mi?",
"disableConfirmMessage": "Zaten mevcut çevrimdışı alan verileriniz var. Çevrimdışı özellikleri devre dışı bırakmak, uygulamanın bunları kullanmasını engelleyecektir. Daha sonra çevrimdışı özellikleri tekrar etkinleştirme ihtimaline karşı bu verileri cihazda saklamak mı istersiniz, yoksa depolama alanını boşaltmak için şimdi silmek mi istersiniz?",
"keepData": "Verileri Sakla",
"deleteData": "Verileri Sil"
},
"refineTagsSheet": {
"title": "Etiketleri Düzenle",
"operatorProfile": "Operatör Profili",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "Сповіщення Про Близькість",
"keepScreenAwake": "Тримати екран активним",
"networkStatusIndicator": "Індикатор Стану Мережі",
"hideZoomControls": "Приховати Елементи Керування Масштабом"
"hideZoomControls": "Приховати Елементи Керування Масштабом",
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
},
"keepScreenAwake": {
"title": "Тримати екран активним під час використання програми",
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
"startRefresh": "Почати Оновлення",
"refreshStarted": "Оновлення розпочато!",
"refreshFailed": "Оновлення не вдалося: {}"
"refreshFailed": "Оновлення не вдалося: {}",
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
"keepData": "Зберегти Дані",
"deleteData": "Видалити Дані"
},
"refineTagsSheet": {
"title": "Уточнити Теги",
"operatorProfile": "Профіль Оператора",
+11 -2
View File
@@ -113,8 +113,12 @@
"proximityAlerts": "邻近警报",
"keepScreenAwake": "保持屏幕常亮",
"networkStatusIndicator": "网络状态指示器",
"hideZoomControls": "隐藏缩放控件"
"hideZoomControls": "隐藏缩放控件",
"enableOfflineFeatures": "启用离线功能",
"enableOfflineFeaturesTitle": "启用离线功能",
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
},
"keepScreenAwake": {
"title": "使用应用时保持屏幕常亮",
"subtitle": "防止在使用应用期间屏幕自动熄灭"
@@ -448,8 +452,13 @@
"refreshNodesSubtitle": "重新获取此区域的节点数据",
"startRefresh": "开始刷新",
"refreshStarted": "刷新已开始!",
"refreshFailed": "刷新失败:{}"
"refreshFailed": "刷新失败:{}",
"disableConfirmTitle": "删除现有的离线区域数据?",
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
"keepData": "保留数据",
"deleteData": "删除数据"
},
"refineTagsSheet": {
"title": "细化标签",
"operatorProfile": "运营商配置文件",
+33 -1
View File
@@ -17,12 +17,44 @@ import 'services/localization_service.dart';
import 'services/provider_tile_cache_manager.dart';
import 'services/version_service.dart';
import 'services/deep_link_service.dart';
import 'services/deflock_tile_provider.dart' show TileLoadCancelledException, TileNotAvailableOfflineException;
/// Suppress console noise for expected, already-handled tile load errors.
///
/// [TileLoadCancelledException] (tile scrolled off screen) and
/// [TileNotAvailableOfflineException] (no cached data available) are thrown
/// deliberately by DeflockOfflineTileImageProvider and are already handled —
/// TileLayerManager.onTileLoadError() specifically skips retry logic for
/// both. Flutter's image pipeline reports them via
/// MultiFrameImageStreamCompleter with `silent: true`, but
/// FlutterError.dumpErrorToConsole has a debug-mode assert() that ignores
/// the silent flag and always prints to console anyway. This filters those
/// two specific, expected exception types back out while leaving all other
/// errors (including genuinely silent-but-unexpected ones) reported as
/// normal.
void _installTileErrorFilter() {
final defaultOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
final exception = details.exception;
if (details.silent &&
(exception is TileLoadCancelledException ||
exception is TileNotAvailableOfflineException)) {
return; // Already handled — don't spam the console.
}
if (defaultOnError != null) {
defaultOnError(details);
} else {
FlutterError.presentError(details);
}
};
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_installTileErrorFilter();
// Initialize version service
await VersionService().init();
+29
View File
@@ -3,11 +3,13 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_state.dart';
import 'services/offline_area_service.dart';
import 'services/profile_service.dart';
import 'services/suspected_location_cache.dart';
import 'widgets/nuclear_reset_dialog.dart';
import 'dev_config.dart';
/// One-time migrations that run when users upgrade to specific versions.
/// Each migration function is named after the version where it should run.
class OneTimeMigrations {
@@ -189,6 +191,30 @@ class OneTimeMigrations {
}
}
/// Enable offline features for existing users who already have offline
/// area data (v2.10.5). New installs and existing users who never used
/// offline areas will keep the new "offline features enabled" setting at
/// its default of false.
static Future<void> migrate_2_10_5(AppState appState) async {
try {
final offlineAreaService = OfflineAreaService();
await offlineAreaService.ensureInitialized();
if (offlineAreaService.offlineAreas.isNotEmpty) {
await appState.setOfflineFeaturesEnabled(true);
debugPrint('[Migration] 2.10.5: Existing offline area data found - enabled offline features');
} else {
debugPrint('[Migration] 2.10.5: No existing offline area data - leaving offline features disabled');
}
debugPrint('[Migration] 2.10.5 completed: offline features migration done');
} catch (e) {
debugPrint('[Migration] 2.10.5 ERROR: Failed to migrate offline features setting: $e');
// Don't rethrow - this is non-critical, worst case is the user needs
// to manually re-enable the toggle to see their existing offline areas.
}
}
/// Get the migration function for a specific version
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
switch (version) {
@@ -206,11 +232,14 @@ class OneTimeMigrations {
return migrate_2_7_3;
case '2.10.0':
return migrate_2_10_0;
case '2.10.5':
return migrate_2_10_5;
default:
return null;
}
}
/// Run migration for a specific version with nuclear reset on failure
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
try {
+57 -54
View File
@@ -595,67 +595,70 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final showDownloadButton = appState.offlineFeaturesEnabled;
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
return Row(
children: [
Expanded(
// Take full width when the download button is hidden
flex: showDownloadButton ? 7 : 10,
child: ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
return FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: canDownload ? () {
// Check minimum zoom level before opening download dialog
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForOfflineDownload) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('download.areaTooBigMessage',
params: [kMinZoomForOfflineDownload.toString()])
if (showDownloadButton) ...[
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: canDownload ? () {
// Check minimum zoom level before opening download dialog
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForOfflineDownload) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('download.areaTooBigMessage',
params: [kMinZoomForOfflineDownload.toString()])
),
),
),
);
return;
}
);
return;
}
showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
);
} : null,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
);
} : null,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
);
},
),
),
],
),
],
],
);
},
),
),
),
);
+38 -13
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import 'settings/sections/enable_offline_features_section.dart';
import 'settings/sections/offline_mode_section.dart';
import 'settings/sections/offline_areas_section.dart';
import '../services/localization_service.dart';
@@ -16,20 +19,42 @@ class OfflineSettingsScreen extends StatelessWidget {
appBar: AppBar(
title: Text(locService.t('settings.offlineSettings')),
),
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
body: Consumer<AppState>(
builder: (context, appState, child) {
final offlineFeaturesEnabled = appState.offlineFeaturesEnabled;
return ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
const EnableOfflineFeaturesSection(),
const Divider(),
// Grey out and disable interaction with the rest of the
// offline settings when the master toggle is off, while
// keeping them visible/discoverable.
Opacity(
opacity: offlineFeaturesEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !offlineFeaturesEnabled,
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
),
),
),
],
);
},
),
),
);
}
}
}
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
/// Master toggle for all offline-area functionality (downloading, browsing,
/// and using cached tiles/nodes offline). When disabled, the rest of the
/// Offline Settings screen (offline mode + offline areas list) is greyed out
/// and the download button on the map screen is hidden. Functionally, the
/// app will behave as if no offline area data exists at all while this is
/// off, regardless of whether area files still exist on disk.
class EnableOfflineFeaturesSection extends StatelessWidget {
const EnableOfflineFeaturesSection({super.key});
Future<void> _handleToggle(BuildContext context, AppState appState, bool value) async {
if (value) {
// Enabling requires no confirmation.
await appState.setOfflineFeaturesEnabled(true);
return;
}
// Disabling: if offline area data already exists, ask whether to keep
// or delete it. Either choice proceeds to disable the toggle - there is
// no "cancel" path once this dialog is shown.
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
if (!hasExistingAreas) {
await appState.setOfflineFeaturesEnabled(false);
return;
}
final locService = LocalizationService.instance;
final shouldDelete = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(locService.t('offlineAreas.disableConfirmTitle')),
content: Text(locService.t('offlineAreas.disableConfirmMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locService.t('offlineAreas.keepData')),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text(locService.t('offlineAreas.deleteData')),
),
],
),
);
if (shouldDelete == true) {
for (final area in offlineService.offlineAreas.toList()) {
offlineService.deleteArea(area.id);
}
}
// Disable regardless of the user's data choice.
await appState.setOfflineFeaturesEnabled(false);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.enableOfflineFeatures'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(locService.t('settings.enableOfflineFeaturesTitle')),
subtitle: Text(
locService.t('settings.enableOfflineFeaturesSubtitle'),
style: const TextStyle(fontSize: 12),
),
value: appState.offlineFeaturesEnabled,
onChanged: (value) => _handleToggle(context, appState, value),
),
],
);
},
);
}
}
+5
View File
@@ -241,7 +241,12 @@ class ChangelogService {
versionsNeedingMigration.add('2.10.0');
}
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
versionsNeedingMigration.add('2.10.5');
}
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0');
// }
+5
View File
@@ -390,8 +390,13 @@ class NodeDataManager extends ChangeNotifier {
/// Load all offline nodes into cache (call at app startup)
Future<void> preloadOfflineNodes() async {
// Skip entirely when the user has disabled offline features — this call
// isn't gated by OfflineAreaService.hasOfflineAreasForProvider*() since
// it fetches directly via fetchLocalNodes, so we guard it explicitly.
if (!AppState.instance.offlineFeaturesEnabled) return;
try {
final offlineAreaService = OfflineAreaService();
for (final area in offlineAreaService.offlineAreas) {
if (area.status != OfflineAreaStatus.complete) continue;
+11
View File
@@ -29,7 +29,13 @@ class OfflineAreaService {
/// Fast check: do we have any completed offline areas for a specific provider/type?
/// This allows smart cache routing without expensive filesystem searches.
/// Safe to call before initialization - returns false if not yet initialized.
///
/// Returns false immediately if the user has disabled offline features via
/// the master "Enable Offline Features" toggle, regardless of whether area
/// data still exists on disk — this fully gates the offline-first tile
/// pipeline without needing to touch fetchLocalTile/fetchLocalNodes.
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
if (!AppState.instance.offlineFeaturesEnabled) return false;
if (!_initialized) {
return false; // No offline areas loaded yet
}
@@ -44,7 +50,11 @@ class OfflineAreaService {
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
/// offline-first path for tiles that will never be found locally.
///
/// Also returns false immediately when offline features are disabled (see
/// [hasOfflineAreasForProvider] for rationale).
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
if (!AppState.instance.offlineFeaturesEnabled) return false;
if (!_initialized) return false;
return _areas.any((area) =>
area.status == OfflineAreaStatus.complete &&
@@ -54,6 +64,7 @@ class OfflineAreaService {
zoom <= area.maxZoom
);
}
/// Reset service state and inject areas for unit tests.
@visibleForTesting
+26
View File
@@ -41,11 +41,13 @@ class SettingsState extends ChangeNotifier {
static const String _distanceUnitPrefsKey = 'distance_unit';
static const String _keepScreenAwakePrefsKey = 'keep_screen_awake';
static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls';
static const String _offlineFeaturesEnabledPrefsKey = 'offline_features_enabled';
bool _offlineMode = false;
bool _pauseQueueProcessing = false;
bool _keepScreenAwake = false;
bool _hideZoomControls = false;
bool _offlineFeaturesEnabled = false;
int _maxNodes = kDefaultMaxNodes;
// Default must account for missing secrets (preview builds) even before init() runs
@@ -72,8 +74,10 @@ class SettingsState extends ChangeNotifier {
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
bool get keepScreenAwake => _keepScreenAwake;
bool get hideZoomControls => _hideZoomControls;
bool get offlineFeaturesEnabled => _offlineFeaturesEnabled;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
DistanceUnit get distanceUnit => _distanceUnit;
@@ -152,8 +156,14 @@ class SettingsState extends ChangeNotifier {
// Load hide zoom controls setting
_hideZoomControls = prefs.getBool(_hideZoomControlsPrefsKey) ?? false;
// Load offline features enabled setting (defaults to false; migration
// for existing users with pre-existing offline area data lives in
// migrations.dart / migrate_2_10_5)
_offlineFeaturesEnabled = prefs.getBool(_offlineFeaturesEnabledPrefsKey) ?? false;
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
@@ -432,6 +442,22 @@ class SettingsState extends ChangeNotifier {
}
}
/// Set the master "offline features enabled" toggle. When this is off,
/// offline areas (downloading, browsing, and using cached tiles/nodes) are
/// disabled entirely. Note: any cascading side effects (forcing offline
/// mode off, cancelling active downloads, prompting to delete existing
/// area data) are handled by the caller (see AppState.setOfflineFeaturesEnabled)
/// since those require access to services not owned by SettingsState.
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
if (_offlineFeaturesEnabled != enabled) {
_offlineFeaturesEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_offlineFeaturesEnabledPrefsKey, enabled);
notifyListeners();
}
}
/// Set distance for avoidance of nodes during navigation
Future<void> setNavigationAvoidanceDistance(int distance) async {
@@ -41,8 +41,11 @@ void main() {
mockAppState = MockAppState();
AppState.instance = mockAppState;
// Default stubs: online, no offline areas
// Default stubs: online, no offline areas, offline features enabled
// (so tests exercise the same behavior as before this setting existed)
when(() => mockAppState.offlineMode).thenReturn(false);
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
provider = DeflockTileProvider(
providerId: 'openstreetmap',
@@ -1,10 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/app_state.dart';
import 'package:deflockapp/services/offline_area_service.dart';
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
class MockAppState extends Mock implements AppState {}
OfflineArea _makeArea({
String providerId = 'osm',
String tileTypeId = 'standard',
@@ -26,11 +31,19 @@ OfflineArea _makeArea({
void main() {
final service = OfflineAreaService();
late MockAppState mockAppState;
setUp(() {
service.setAreasForTesting([]);
mockAppState = MockAppState();
AppState.instance = mockAppState;
// These tests exercise the underlying zoom/provider matching logic, so
// offline features are enabled by default here — the "disabled" gating
// itself is covered separately below.
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
});
group('hasOfflineAreasForProviderAtZoom', () {
test('returns true for zoom within range', () {
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
@@ -90,4 +103,30 @@ void main() {
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
});
});
group('offlineFeaturesEnabled gating', () {
test('hasOfflineAreasForProvider returns false when feature disabled, even with matching areas', () {
service.setAreasForTesting([_makeArea(providerId: 'osm', tileTypeId: 'standard')]);
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
expect(service.hasOfflineAreasForProvider('osm', 'standard'), isFalse);
});
test('hasOfflineAreasForProviderAtZoom returns false when feature disabled, even within zoom range', () {
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
});
test('re-enabling offlineFeaturesEnabled restores normal matching', () {
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
});
});
}