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
+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),
),
],
);
},
);
}
}