mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
324 lines
13 KiB
Dart
324 lines
13 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.withOpacity(0.1)
|
|
: Colors.green.withOpacity(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.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.orange.withOpacity(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 {
|
|
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
|
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
|
final dir = "${appDocDir.path}/$id";
|
|
|
|
// Get current tile provider info
|
|
final appState = context.read<AppState>();
|
|
final selectedProvider = appState.selectedTileProvider;
|
|
final selectedTileType = appState.selectedTileType;
|
|
|
|
// 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) {
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} |