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 createState() => _DownloadAreaDialogState(); } class _DownloadAreaDialogState extends State { 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(); 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(); 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(); 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(); 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), ), ], ); }, ); } }