Files
deflock-app/lib/widgets/download_area_dialog.dart
T
Doug Borg f3f40f36ef Allow OSM offline downloads, disable button for restricted providers
Allow offline area downloads for OSM tile server. Move the "downloads
not permitted" check from inside the download dialog to the download
button itself — the button is now disabled (greyed out) when the
current tile type doesn't support offline downloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:34:01 -07:00

354 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/offline_area_service.dart';
import '../services/offline_areas/offline_tile_utils.dart';
import 'download_started_dialog.dart';
class DownloadAreaDialog extends StatefulWidget {
final MapController controller;
const DownloadAreaDialog({super.key, required this.controller});
@override
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
}
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
double _zoom = 15;
int? _minZoom;
int? _maxPossibleZoom;
int? _tileCount;
double? _mbEstimate;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
}
void _recomputeEstimates() {
var bounds = widget.controller.camera.visibleBounds;
// If the visible area is nearly zero, nudge the bounds for estimation
const double epsilon = 0.0002;
final latSpan = (bounds.north - bounds.south).abs();
final lngSpan = (bounds.east - bounds.west).abs();
if (latSpan < epsilon && lngSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
);
} else if (latSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
);
} else if (lngSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
);
}
final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out
// Calculate maximum possible zoom based on tile count limit and tile provider max zoom
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
// Clamp current zoom to the effective maximum if it exceeds it
if (_zoom > maxPossibleZoom) {
_zoom = maxPossibleZoom.toDouble();
}
final actualMaxZoom = _zoom.toInt();
final nTiles = computeTileList(bounds, minZoom, actualMaxZoom).length;
final tileEstimateKb = _getTileEstimateKb();
final totalMb = (nTiles * tileEstimateKb) / 1024.0;
final roundedMb = (totalMb * 10).round() / 10; // Round to nearest tenth
setState(() {
_minZoom = minZoom;
_maxPossibleZoom = maxPossibleZoom;
_tileCount = nTiles;
_mbEstimate = roundedMb;
});
}
/// Calculate the maximum zoom level that keeps tile count under the absolute limit
/// and respects the selected tile type's maximum zoom level
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
final appState = context.read<AppState>();
final selectedTileType = appState.selectedTileType;
// Use tile type's max zoom if available, otherwise fall back to absolute max
final effectiveMaxZoom = selectedTileType?.maxZoom ?? kAbsoluteMaxZoom;
for (int zoom = minZoom; zoom <= effectiveMaxZoom; zoom++) {
final tileCount = computeTileList(bounds, minZoom, zoom).length;
if (tileCount > kAbsoluteMaxTileCount) {
// Return the previous zoom level that was still under the absolute limit
return math.max(minZoom, zoom - 1);
}
}
return effectiveMaxZoom;
}
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
double _getTileEstimateKb() {
final appState = context.read<AppState>();
final selectedTileType = appState.selectedTileType;
if (selectedTileType?.previewTile != null) {
// Use actual preview tile size
final previewSizeBytes = selectedTileType!.previewTile!.length;
final previewSizeKb = previewSizeBytes / 1024.0;
return previewSizeKb;
} else {
// Fall back to configured estimate
return kFallbackTileEstimateKb;
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final bounds = widget.controller.camera.visibleBounds;
final isOfflineMode = appState.offlineMode;
// Use the calculated max possible zoom instead of fixed span
final sliderMin = _minZoom?.toDouble() ?? 12.0;
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
return AlertDialog(
title: Row(
children: [
const Icon(Icons.download_for_offline),
const SizedBox(width: 10),
Text(locService.t('download.title')),
],
),
content: SizedBox(
width: 350,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('download.maxZoomLevel')),
Text('Z${_zoom.toStringAsFixed(0)}'),
],
),
Slider(
min: sliderMin,
max: sliderMax,
divisions: sliderDivisions,
label: 'Z${_zoom.toStringAsFixed(0)}',
value: sliderValue,
onChanged: (v) {
setState(() => _zoom = v);
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('download.storageEstimate')),
Expanded(
child: Text(
_mbEstimate == null
? '…'
: locService.t('download.tilesAndSize', params: [
_tileCount.toString(),
_mbEstimate!.toStringAsFixed(1)
]),
textAlign: TextAlign.end,
style: const TextStyle(fontSize: 14),
),
),
],
),
if (_maxPossibleZoom != null && _tileCount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange.withValues(alpha: 0.1)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_tileCount! > kMaxReasonableTileCount
? 'Above recommended limit (Z$_maxPossibleZoom)'
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[700]
: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? 'Current selection exceeds $kMaxReasonableTileCount recommended tile limit but is within $kAbsoluteMaxTileCount absolute limit'
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
style: TextStyle(
fontSize: 11,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[600]
: Colors.green[600],
),
),
],
),
),
),
if (isOfflineMode)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('download.offlineModeWarning'),
style: TextStyle(
fontSize: 12,
color: Colors.orange[700],
),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.cancel),
),
ElevatedButton(
onPressed: isOfflineMode ? null : () async {
try {
// Get current tile provider info
final appState = context.read<AppState>();
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
// Guard: provider and tile type must be non-null for a
// useful offline area (fetchLocalTile requires exact match).
if (selectedProvider == null || selectedTileType == null) {
if (!context.mounted) return;
final navigator = Navigator.of(context);
navigator.pop();
showDialog(
context: navigator.context,
builder: (context) => AlertDialog(
title: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 10),
Text(locService.t('download.title')),
],
),
content: Text(locService.t('download.noTileProviderSelected')),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.ok')),
),
],
),
);
return;
}
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
if (!context.mounted) return;
final dir = "${appDocDir.path}/$id";
// Fire and forget: don't await download, so dialog closes immediately
// ignore: unawaited_futures
OfflineAreaService().downloadArea(
id: id,
bounds: bounds,
minZoom: _minZoom ?? 1,
maxZoom: _zoom.toInt(),
directory: dir,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: selectedProvider.id,
tileProviderName: selectedProvider.name,
tileTypeId: selectedTileType.id,
tileTypeName: selectedTileType.name,
);
Navigator.pop(context);
showDialog(
context: context,
builder: (context) => const DownloadStartedDialog(),
);
} catch (e) {
if (!context.mounted) return;
Navigator.pop(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 10),
Text(locService.t('download.title')),
],
),
content: Text(locService.t('download.downloadFailed', params: [e.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.ok')),
),
],
),
);
}
},
child: Text(locService.download),
),
],
);
},
);
}
}