diff --git a/lib/providers/offline_tiles_provider.dart b/lib/providers/offline_tiles_provider.dart index 84443d5..5aa9a40 100644 --- a/lib/providers/offline_tiles_provider.dart +++ b/lib/providers/offline_tiles_provider.dart @@ -59,6 +59,7 @@ class OfflineTilesProvider extends ChangeNotifier { // Drawing state DrawingMode _drawingMode = DrawingMode.none; + DrawingMode _downloadSelectionMode = DrawingMode.none; final List> _polygons = []; List _currentVertices = []; LatLng? _rectangleFirstCorner; @@ -94,6 +95,7 @@ class OfflineTilesProvider extends ChangeNotifier { // Getters DrawingMode get drawingMode => _drawingMode; + DrawingMode get downloadSelectionMode => _downloadSelectionMode; List> get polygons => List.unmodifiable(_polygons); List get currentVertices => List.unmodifiable(_currentVertices); LatLng? get rectangleFirstCorner => _rectangleFirstCorner; @@ -131,6 +133,15 @@ class OfflineTilesProvider extends ChangeNotifier { notifyListeners(); } + void startDownloadSelectionMode(DrawingMode mode) { + _polygons.clear(); + _currentVertices = []; + _rectangleFirstCorner = null; + _downloadSelectionMode = mode; + _drawingMode = mode; + notifyListeners(); + } + void addVertex(LatLng point) { if (_drawingMode == DrawingMode.polygon) { _currentVertices = [..._currentVertices, point]; @@ -181,6 +192,27 @@ class OfflineTilesProvider extends ChangeNotifier { notifyListeners(); } + void setCurrentViewBounds({ + required double north, + required double south, + required double east, + required double west, + }) { + _polygons + ..clear() + ..add([ + LatLng(north, west), + LatLng(north, east), + LatLng(south, east), + LatLng(south, west), + ]); + _currentVertices = []; + _rectangleFirstCorner = null; + _downloadSelectionMode = DrawingMode.none; + _drawingMode = DrawingMode.none; + notifyListeners(); + } + void undoLastVertex() { if (_currentVertices.isNotEmpty) { _currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1); @@ -207,6 +239,15 @@ class OfflineTilesProvider extends ChangeNotifier { notifyListeners(); } + void setSelectedLayerIfDifferent(MapLayer layer) { + if (_selectedLayer.type == layer.type && + _selectedLayer.urlTemplate == layer.urlTemplate) { + return; + } + _selectedLayer = layer; + notifyListeners(); + } + // Download control Future startDownload() async { @@ -273,10 +314,12 @@ class OfflineTilesProvider extends ChangeNotifier { case TileDownloadComplete(): _isDownloading = false; + _tileOverlays.clear(); notifyListeners(); case TileDownloadCancelled(): _isDownloading = false; + _tileOverlays.clear(); notifyListeners(); } } @@ -341,19 +384,12 @@ class OfflineTilesProvider extends ChangeNotifier { _coverageOverlays = []; notifyListeners(); - final manifest = await _cache.loadManifest(style.hash); + final tiles = await _cache.listTilesForStyle(style.hash); - // Convert manifest keys to tile bound overlays + // Convert cached tile coordinates to bound overlays final overlays = []; - for (final key in manifest) { - final parts = key.split('/'); - if (parts.length != 3) continue; - final z = int.tryParse(parts[0]); - final x = int.tryParse(parts[1]); - final y = int.tryParse(parts[2]); - if (z == null || x == null || y == null) continue; - - final bounds = TileMathService.tileBounds(x, y, z); + for (final tile in tiles) { + final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z); overlays.add(TileOverlay( north: bounds.north, south: bounds.south, diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 224ee46..5f4a283 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -19,6 +19,7 @@ import '../providers/map_provider.dart'; import '../providers/drawing_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/offline_tiles_provider.dart' as offline; import '../models/contact.dart'; import '../models/custom_map_config.dart'; import '../models/map_coordinate_space.dart'; @@ -36,6 +37,7 @@ import '../widgets/map/detailed_compass_dialog.dart'; import '../widgets/map/drawing_layer.dart'; import '../widgets/map/drawing_toolbar.dart'; import '../widgets/map/location_trail_layer.dart'; +import '../widgets/map/polygon_draw_handler.dart' hide DrawingToolbar; import '../widgets/map/trail_controls.dart'; import '../widgets/map/map_message_overlay.dart'; import '../widgets/messages/custom_map_sar_update_sheet.dart'; @@ -44,7 +46,6 @@ import '../utils/key_comparison.dart'; import '../utils/sar_message_parser.dart'; import '../l10n/app_localizations.dart'; import '../services/offline_map_caching_provider.dart'; -import 'offline_map_screen.dart'; class MapTab extends StatefulWidget { final Function(bool)? onFullscreenChanged; @@ -86,6 +87,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { BackgroundLocationService(); bool _isDisposing = false; // Flag to prevent updates during disposal MapProvider? _mapProvider; + offline.OfflineTilesProvider? _offlineTilesProvider; // Store original location callback to restore in dispose void Function(Position)? _originalLocationCallback; @@ -150,6 +152,12 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Restore background tracking state _restoreBackgroundTracking(); + + final offlineProvider = context.read(); + _offlineTilesProvider = offlineProvider; + offlineProvider.refreshCacheSize(); + offlineProvider.refreshLocalStyles(); + offlineProvider.startPeerDiscovery(); }); } @@ -468,6 +476,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { _saveMapPosition(); _mapProvider?.removeListener(_handleMapNavigation); + _offlineTilesProvider?.stopPeerDiscovery(); // DO NOT stop location tracking - it's managed by AppProvider // Restore the original callback instead of setting to null @@ -501,6 +510,15 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } + double _getMapZoom() { + if (!_isMapReady) return _savedMapZoom ?? _defaultZoom; + try { + return _mapController.camera.zoom; + } catch (e) { + return _savedMapZoom ?? _defaultZoom; + } + } + LatLng _calculateCenter(List contacts, List sarMarkers) { return _markerService.calculateCenter( contacts: contacts, @@ -528,15 +546,26 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { await _saveSettings(); } - void _showLayerSelector(BuildContext context) { + void _showLayerSelector(BuildContext context, {int initialTab = 0}) { final rootContext = this.context; + if (_currentLayer.urlTemplate.isNotEmpty) { + rootContext + .read() + .setSelectedLayerIfDifferent(_currentLayer); + } + rootContext.read().refreshCacheSize(); + rootContext.read().refreshLocalStyles(); showModalBottomSheet( context: context, - builder: (context) => Container( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ + isScrollControlled: true, + builder: (context) => DefaultTabController( + length: 3, + initialIndex: initialTab, + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.82, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -555,11 +584,20 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), const Divider(), + const TabBar( + tabs: [ + Tab(icon: Icon(Icons.layers), text: 'Layers'), + Tab(icon: Icon(Icons.download), text: 'Download'), + Tab(icon: Icon(Icons.offline_pin), text: 'Cached'), + ], + ), + const Divider(height: 1), Expanded( - child: ListView( - shrinkWrap: true, + child: TabBarView( children: [ - Consumer( + ListView( + children: [ + Consumer( builder: (context, mapProvider, _) { final customMapConfig = mapProvider.customMapConfig; if (customMapConfig == null) { @@ -1001,16 +1039,635 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ], ); }, + ), + ], ), + _buildMapDownloadTab(rootContext), + _buildMapCachedTab(rootContext), ], ), ), ], ), ), + ), ); } + Widget _buildMapDownloadTab(BuildContext rootContext) { + return Consumer( + builder: (context, provider, _) { + final loc = AppLocalizations.of(context)!; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (!provider.isDownloading) ...[ + DropdownButtonFormField( + initialValue: provider.selectedLayer, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.layers, size: 20), + labelText: loc.mapStyle, + border: const OutlineInputBorder(), + ), + isExpanded: true, + items: MapLayer.allLayers + .map((layer) => DropdownMenuItem( + value: layer, + child: Text(layer.getLocalizedName(context)), + )) + .toList(), + onChanged: (layer) { + if (layer != null) provider.setSelectedLayer(layer); + }, + ), + const SizedBox(height: 12), + if (provider.localStyles.any((s) => s.region != null)) ...[ + DropdownButtonFormField( + decoration: InputDecoration( + prefixIcon: const Icon(Icons.bookmark, size: 20), + labelText: loc.loadASavedRegion, + border: const OutlineInputBorder(), + ), + isExpanded: true, + items: provider.localStyles + .where((style) => style.region != null) + .map((style) => DropdownMenuItem( + value: style, + child: Text( + '${style.displayName} ' + '(z${style.region!.minZoom}-${style.region!.maxZoom}, ' + '${_formatNumber(style.tileCount)} tiles)', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (style) { + if (style == null) return; + provider.loadPreset(style); + _fitOfflineMapToPolygons(provider); + }, + ), + const SizedBox(height: 12), + ], + SegmentedButton( + segments: const [ + ButtonSegment( + value: offline.DrawingMode.rectangle, + icon: Icon(Icons.crop_square), + label: Text('Rectangle'), + ), + ButtonSegment( + value: offline.DrawingMode.polygon, + icon: Icon(Icons.polyline), + label: Text('Polygon'), + ), + ButtonSegment( + value: offline.DrawingMode.none, + icon: Icon(Icons.pan_tool_alt), + label: Text('Current view'), + ), + ], + selected: {provider.downloadSelectionMode}, + onSelectionChanged: (selection) { + final mode = selection.first; + if (mode == offline.DrawingMode.none) { + _setOfflineSelectionToCurrentView(provider); + return; + } + provider.startDownloadSelectionMode(mode); + }, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _setOfflineSelectionToCurrentView(provider), + icon: const Icon(Icons.crop_free), + label: const Text('Use current view'), + ), + const SizedBox(height: 12), + if (provider.drawingMode == offline.DrawingMode.polygon) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: provider.currentVertices.length >= 3 + ? provider.finishPolygon + : null, + icon: const Icon(Icons.check), + label: const Text('Finish polygon'), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: provider.currentVertices.isNotEmpty + ? provider.undoLastVertex + : null, + icon: const Icon(Icons.undo), + tooltip: loc.undo, + ), + ], + ), + if (provider.hasPolygons) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: provider.clearPolygons, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear selected area'), + ), + ], + const SizedBox(height: 12), + _MapZoomSelector( + label: loc.minZoom, + value: provider.minZoom, + onChanged: provider.setMinZoom, + ), + _MapZoomSelector( + label: loc.maxZoom, + value: provider.maxZoom, + onChanged: provider.setMaxZoom, + ), + const SizedBox(height: 8), + Text( + provider.hasPolygons + ? '~${_formatNumber(provider.estimatedTileCount)} tiles' + : 'Select an area on the map to download', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + Text( + 'Cache: ${_formatBytes(provider.cacheSizeBytes)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + if (provider.isDownloading) ...[ + LinearProgressIndicator(value: provider.progress.percent), + const SizedBox(height: 8), + Text( + '${(provider.progress.percent * 100).toStringAsFixed(1)}% - ' + 'Downloaded: ${provider.progress.downloaded}, ' + 'Cached: ${provider.progress.skipped}, ' + 'Failed: ${provider.progress.failed} / ' + '${provider.progress.total}', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + if (!provider.isDownloading && provider.progress.isComplete) ...[ + const SizedBox(height: 8), + Text( + 'Done. ${provider.progress.downloaded} downloaded, ' + '${provider.progress.skipped} cached, ' + '${provider.progress.failed} failed', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + if (provider.isDownloading) + OutlinedButton.icon( + onPressed: provider.cancelDownload, + icon: const Icon(Icons.cancel), + label: Text(loc.cancel), + style: OutlinedButton.styleFrom(foregroundColor: Colors.red), + ) + else + FilledButton.icon( + onPressed: provider.hasPolygons + ? () { + provider.startDownload(); + } + : null, + icon: const Icon(Icons.download), + label: Text(loc.download), + ), + ], + ); + }, + ); + } + + Widget _buildMapCachedTab(BuildContext rootContext) { + return Consumer( + builder: (context, provider, _) { + final loc = AppLocalizations.of(context)!; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + title: Text(loc.shareMyTiles), + subtitle: Text( + provider.isServerRunning + ? 'Other devices can fetch tiles from this device' + : 'Start serving cached tiles to nearby devices', + ), + secondary: Icon( + provider.isServerRunning + ? Icons.wifi_tethering + : Icons.wifi_tethering_off, + ), + value: provider.isServerRunning, + onChanged: (_) => provider.toggleServer(), + ), + if (provider.localStyles.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Cached maps', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ...provider.localStyles.map((style) { + final isShowing = provider.coverageStyle?.hash == style.hash; + return ListTile( + leading: const Icon(Icons.map, size: 20), + title: Text(style.displayName), + subtitle: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + isShowing ? Icons.visibility : Icons.visibility_off, + color: isShowing ? Colors.blue : null, + ), + tooltip: isShowing ? 'Hide on map' : 'Show on map', + onPressed: () => provider.showCoverage(style), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: loc.delete, + onPressed: () => _confirmDeleteOfflineStyle( + context, + provider, + style, + ), + ), + ], + ), + ); + }), + if (provider.cacheSizeBytes > 0) + OutlinedButton.icon( + onPressed: () => _confirmClearOfflineCache(context, provider), + icon: const Icon(Icons.delete_forever), + label: Text(loc.clearCache), + ), + ], + const Divider(), + Row( + children: [ + Expanded( + child: Text( + 'Nearby devices', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (provider.isFetchingCatalogs) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: loc.refresh, + onPressed: provider.refreshPeerCatalogs, + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + tooltip: loc.addPeerManually, + onPressed: () => _showAddOfflinePeerDialog(context, provider), + ), + ], + ), + if (provider.discoveredPeers.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No peers found on the local network.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ), + ...provider.peerCatalogs.map((catalog) => _buildOfflinePeerCard( + context, + provider, + catalog, + )), + ...provider.discoveredPeers + .where((peer) => + !provider.peerCatalogs.any((catalog) => catalog.peer == peer)) + .map((peer) => ListTile( + leading: const Icon(Icons.devices), + title: Text(peer.ipAddress), + subtitle: Text(loc.fetchingCatalog), + trailing: IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => provider.removePeer(peer), + ), + )), + if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[ + const Divider(), + if (provider.isSyncing) + LinearProgressIndicator( + value: provider.syncProgress > 0 ? provider.syncProgress : null, + ), + ListTile( + title: Text( + provider.syncStatus, + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: provider.isSyncing + ? TextButton( + onPressed: provider.cancelSync, + child: Text(loc.cancel), + ) + : null, + ), + ], + ], + ); + }, + ); + } + + Widget _buildOfflinePeerCard( + BuildContext context, + offline.OfflineTilesProvider provider, + offline.PeerCatalog catalog, + ) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.devices, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + catalog.peer.ipAddress, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 18), + onPressed: () => provider.removePeer(catalog.peer), + visualDensity: VisualDensity.compact, + ), + ], + ), + if (catalog.styles.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'No cached tiles on this device', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ), + ...catalog.styles.map((style) { + final localMatch = provider.localStyles.where( + (localStyle) => localStyle.hash == style.hash, + ); + final localCount = + localMatch.isNotEmpty ? localMatch.first.tileCount : 0; + final missingTiles = style.tileCount - localCount; + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.map_outlined, size: 20), + title: Text(style.displayName), + subtitle: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)}' + '${localCount > 0 ? ' (you have ${_formatNumber(localCount)})' : ''}', + ), + trailing: missingTiles > 0 + ? TextButton.icon( + onPressed: provider.isSyncing + ? null + : () => provider.syncStyleFromPeers(style), + icon: const Icon(Icons.download, size: 16), + label: Text( + missingTiles == style.tileCount + ? 'Get all' + : '+${_formatNumber(missingTiles)}', + ), + ) + : const Icon(Icons.check_circle, color: Colors.green), + ); + }), + ], + ), + ), + ); + } + + void _fitOfflineMapToPolygons(offline.OfflineTilesProvider provider) { + if (provider.polygons.isEmpty || !_isMapReady) return; + + var minLat = 90.0, maxLat = -90.0; + var minLng = 180.0, maxLng = -180.0; + for (final poly in provider.polygons) { + for (final point in poly) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLng) minLng = point.longitude; + if (point.longitude > maxLng) maxLng = point.longitude; + } + } + + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds( + LatLng(minLat, minLng), + LatLng(maxLat, maxLng), + ), + padding: const EdgeInsets.all(50), + ), + ); + } + + void _setOfflineSelectionToCurrentView( + offline.OfflineTilesProvider provider, + ) { + if (!_isMapReady) return; + + final bounds = _mapController.camera.visibleBounds; + provider.setCurrentViewBounds( + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: bounds.west, + ); + } + + void _confirmOfflineSelection( + BuildContext context, + offline.OfflineTilesProvider provider, + ) { + if (provider.drawingMode == offline.DrawingMode.polygon && + provider.currentVertices.length >= 3) { + provider.finishPolygon(); + } else { + provider.setDrawingMode(offline.DrawingMode.none); + } + + if (provider.hasPolygons) { + _showLayerSelector(context, initialTab: 1); + } + } + + void _showAddOfflinePeerDialog( + BuildContext context, + offline.OfflineTilesProvider provider, + ) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.addPeer), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'IP Address', + hintText: '192.168.1.100', + ), + keyboardType: TextInputType.number, + autofocus: true, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + provider.addManualPeer(value.trim()); + Navigator.pop(context); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () { + final ip = controller.text.trim(); + if (ip.isNotEmpty) { + provider.addManualPeer(ip); + Navigator.pop(context); + } + }, + child: Text(AppLocalizations.of(context)!.add), + ), + ], + ), + ); + } + + void _confirmDeleteOfflineStyle( + BuildContext context, + offline.OfflineTilesProvider provider, + offline.StyleInfo style, + ) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text( + AppLocalizations.of(context)!.deleteStyleConfirm(style.displayName), + ), + content: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)} will be deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + provider.deleteStyle(style); + Navigator.pop(dialogContext); + }, + child: Text( + AppLocalizations.of(dialogContext)!.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + + void _confirmClearOfflineCache( + BuildContext context, + offline.OfflineTilesProvider provider, + ) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearOfflineCache), + content: Text( + 'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + provider.clearCache(); + Navigator.pop(dialogContext); + }, + child: Text( + AppLocalizations.of(dialogContext)!.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String _formatNumber(int n) { + if (n < 1000) return '$n'; + if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K'; + return '${(n / 1000000).toStringAsFixed(1)}M'; + } + void _showDetailedCompass( BuildContext context, List contacts, @@ -2127,6 +2784,15 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { return; } + final offlineProvider = + context.read(); + if (!isCustomMapMode && + offlineProvider.drawingMode != + offline.DrawingMode.none) { + offlineProvider.addVertex(point); + return; + } + // Handle drawing mode taps if (drawingProvider.drawingMode == DrawingMode.line) { if (drawingProvider.currentLinePoints.isEmpty) { @@ -2216,6 +2882,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { maxZoom: _currentLayer.maxZoom, ), if (!isCustomMapMode) ...[ + CoverageLayer(currentZoom: _getMapZoom()), + const PolygonDrawLayer(), + const DownloadProgressLayer(), // WMS Overlays (rendered after base layer, before polylines) // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) // Cadastral parcels overlay @@ -3096,6 +3765,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), ), + if (!isCustomMapMode && !_isFullscreen) + _OfflineSelectionControls( + onConfirm: _confirmOfflineSelection, + onCurrentView: _setOfflineSelectionToCurrentView, + ), // Map controls - right side (hidden in fullscreen mode) if (!_isFullscreen) Positioned( @@ -3233,17 +3907,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: const Icon(Icons.layers), ), const SizedBox(height: 8), - FloatingActionButton.small( - heroTag: 'offline_maps', - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const OfflineMapScreen(), - ), - ), - child: const Icon(Icons.download_for_offline), - ), - const SizedBox(height: 8), FloatingActionButton.small( heroTag: 'fullscreen_toggle', onPressed: () { @@ -3276,3 +3939,149 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } } + +class _OfflineSelectionControls extends StatelessWidget { + final void Function( + BuildContext context, + offline.OfflineTilesProvider provider, + ) onConfirm; + final void Function(offline.OfflineTilesProvider provider) onCurrentView; + + const _OfflineSelectionControls({ + required this.onConfirm, + required this.onCurrentView, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final hasDraft = provider.currentVertices.isNotEmpty || + provider.rectangleFirstCorner != null; + if (provider.drawingMode == offline.DrawingMode.none && + !provider.hasPolygons && + !hasDraft) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final title = switch (provider.drawingMode) { + offline.DrawingMode.rectangle => provider.rectangleFirstCorner == null + ? 'Tap first corner' + : 'Tap opposite corner', + offline.DrawingMode.polygon => + '${provider.currentVertices.length} polygon points', + offline.DrawingMode.none => '${provider.polygons.length} area selected', + }; + + return Positioned( + left: 16, + right: 88, + bottom: 16, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface.withValues(alpha: 0.96), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + provider.drawingMode == offline.DrawingMode.none + ? Icons.check_circle + : Icons.edit_location_alt, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (provider.drawingMode == offline.DrawingMode.polygon) + FilledButton.icon( + onPressed: provider.currentVertices.length >= 3 + ? () => onConfirm(context, provider) + : null, + icon: const Icon(Icons.check, size: 18), + label: const Text('Confirm'), + ) + else if (provider.hasPolygons) + FilledButton.icon( + onPressed: () { + onConfirm(context, provider); + }, + icon: const Icon(Icons.check, size: 18), + label: const Text('Confirm'), + ), + if (provider.hasPolygons || hasDraft) + OutlinedButton.icon( + onPressed: provider.clearPolygons, + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Clear'), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _MapZoomSelector extends StatelessWidget { + final String label; + final int value; + final ValueChanged onChanged; + + const _MapZoomSelector({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: 8), + Expanded( + child: Slider( + value: value.toDouble(), + min: 0, + max: 19, + divisions: 19, + label: '$value', + onChanged: (v) => onChanged(v.round()), + ), + ), + SizedBox( + width: 24, + child: Text( + '$value', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} diff --git a/lib/services/offline_map_caching_provider.dart b/lib/services/offline_map_caching_provider.dart index f1f745a..ae89227 100644 --- a/lib/services/offline_map_caching_provider.dart +++ b/lib/services/offline_map_caching_provider.dart @@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'offline_tile_cache_service.dart'; -/// A [MapCachingProvider] that checks the offline AVIF tile cache before +/// A [MapCachingProvider] that checks the offline tile cache before /// falling through to the built-in cache/network path. /// /// This allows preloaded tiles to be served during normal map browsing. @@ -23,12 +23,16 @@ class OfflineMapCachingProvider implements MapCachingProvider { if (coords != null) { final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url)); - // Check local AVIF cache first - final pngBytes = await _cache.getTileAsPng( - styleHash, coords.z, coords.x, coords.y); - if (pngBytes != null) { + // Check local offline cache first + final cachedTile = await _cache.getTileData( + styleHash, + coords.z, + coords.x, + coords.y, + ); + if (cachedTile != null) { return ( - bytes: pngBytes, + bytes: cachedTile.bytes, metadata: CachedMapTileMetadata( staleAt: DateTime.now().add(const Duration(days: 365)), lastModified: null, diff --git a/lib/services/offline_tile_cache_service.dart b/lib/services/offline_tile_cache_service.dart index 006772d..194ee3d 100644 --- a/lib/services/offline_tile_cache_service.dart +++ b/lib/services/offline_tile_cache_service.dart @@ -1,11 +1,11 @@ +import 'dart:collection'; import 'dart:convert'; import 'dart:io'; -import 'dart:ui' as ui; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_avif/flutter_avif.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; /// A saved download region (polygons + zoom range) for quick re-download. class DownloadRegion { @@ -98,17 +98,44 @@ class CachedTileCoord { ); } -/// Manages the offline AVIF tile cache on disk. -/// -/// Tiles are stored as `{baseDir}/offline_tiles/{styleHash}/{z}/{x}/{y}.avif`. -/// Style metadata is stored as `{baseDir}/offline_tiles/{styleHash}/meta.json`. -/// This cache is separate from flutter_map's built-in cache and is used for -/// proactively downloaded tiles and WiFi sharing. +class CachedTileData { + final Uint8List bytes; + final String? contentType; + + const CachedTileData({ + required this.bytes, + required this.contentType, + }); +} + +class _CachedTileLocation { + final String relativePath; + final String? contentType; + + const _CachedTileLocation({ + required this.relativePath, + required this.contentType, + }); +} + +/// Manages the offline tile cache on disk with a SQLite metadata index. class OfflineTileCacheService { OfflineTileCacheService._(); static final instance = OfflineTileCacheService._(); + static const int _memoryCacheMaxEntries = 4096; + static const int _memoryCacheMaxBytes = 64 * 1024 * 1024; + String? _baseDir; + String? _databasePath; + Database? _database; + int _tileMemoryBytes = 0; + + final Map> _manifests = {}; + final Map> _styleHydrations = {}; + final Map _tileLocationCache = {}; + final LinkedHashMap _tileMemoryCache = + LinkedHashMap(); Future get baseDir async { if (_baseDir != null) return _baseDir!; @@ -117,6 +144,53 @@ class OfflineTileCacheService { return _baseDir!; } + Future get _dbPath async { + if (_databasePath != null) return _databasePath!; + final docs = await getApplicationSupportDirectory(); + _databasePath = '${docs.path}/offline_tiles.db'; + return _databasePath!; + } + + Future get _db async { + if (_database != null) return _database!; + final path = await _dbPath; + _database = await openDatabase( + path, + version: 1, + onCreate: (db, version) async { + await _createSchema(db); + }, + ); + return _database!; + } + + @visibleForTesting + void setBaseDirForTesting(String path) { + _baseDir = path; + } + + @visibleForTesting + void setDatabasePathForTesting(String path) { + _databasePath = path; + } + + @visibleForTesting + Future resetForTesting() async { + await _database?.close(); + _database = null; + _baseDir = null; + _databasePath = null; + _manifests.clear(); + clearMemoryCacheForTesting(); + } + + @visibleForTesting + void clearMemoryCacheForTesting() { + _tileLocationCache.clear(); + _tileMemoryCache.clear(); + _tileMemoryBytes = 0; + } + /// Derive a short deterministic hash from a URL template. String styleHashFromUrl(String urlTemplate) { final bytes = sha256.convert(urlTemplate.codeUnits).bytes; @@ -126,167 +200,229 @@ class OfflineTileCacheService { .join(); } - String _tilePath(String base, String styleHash, int z, int x, int y) { - return '$base/$styleHash/$z/$x/$y.avif'; - } + static String _tileKey(int z, int x, int y) => '$z/$x/$y'; String _tileDir(String base, String styleHash, int z, int x) { return '$base/$styleHash/$z/$x'; } - // In-memory manifest cache: styleHash → set of "z/x/y" keys. - // Loaded lazily, kept in sync with writes. - final Map> _manifests = {}; - - static String _tileKey(int z, int x, int y) => '$z/$x/$y'; - String _manifestPath(String base, String styleHash) => '$base/$styleHash/manifest.txt'; + Future _createSchema(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS offline_tile_styles ( + style_hash TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + url_template TEXT NOT NULL, + region_json TEXT, + tile_count INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS offline_tile_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + style_hash TEXT NOT NULL, + z INTEGER NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + relative_path TEXT NOT NULL, + content_type TEXT, + size_bytes INTEGER NOT NULL, + cached_at INTEGER NOT NULL, + UNIQUE(style_hash, z, x, y) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS offline_tile_entries_style_idx ' + 'ON offline_tile_entries(style_hash)', + ); + } + /// Load the manifest for a style into memory (if not already loaded). Future> loadManifest(String styleHash) async { if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!; - final base = await baseDir; - final file = File(_manifestPath(base, styleHash)); - final Set manifest; - if (await file.exists()) { - final lines = await file.readAsLines(); - manifest = lines.where((l) => l.isNotEmpty).toSet(); - } else { - // First time — scan the filesystem and build the manifest - manifest = {}; - final styleDir = Directory('$base/$styleHash'); - if (await styleDir.exists()) { - final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$'); - await for (final entity in styleDir.list(recursive: true)) { - if (entity is! File) continue; - final match = avifPattern.firstMatch(entity.path); - if (match != null) { - manifest.add('${match.group(1)}/${match.group(2)}/${match.group(3)}'); - } - } - // Persist the scanned manifest - await _writeManifest(base, styleHash, manifest); - } + var rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + if (rows.isEmpty) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + ); } + + final manifest = rows + .map((row) => _tileKey( + row['z'] as int, + row['x'] as int, + row['y'] as int, + )) + .toSet(); _manifests[styleHash] = manifest; return manifest; } - Future _writeManifest( - String base, String styleHash, Set manifest) async { - final dir = Directory('$base/$styleHash'); - if (!await dir.exists()) await dir.create(recursive: true); - await File(_manifestPath(base, styleHash)) - .writeAsString(manifest.join('\n'), flush: true); - } - - /// Append a tile key to the manifest (both in-memory and on disk). - Future _addToManifest( - String base, String styleHash, String key) async { - _manifests[styleHash] ??= {}; - if (_manifests[styleHash]!.add(key)) { - final file = File(_manifestPath(base, styleHash)); - await file.writeAsString('$key\n', - mode: FileMode.append, flush: true); + Future getTileData( + String styleHash, + int z, + int x, + int y, + ) async { + final cacheKey = '$styleHash/${_tileKey(z, x, y)}'; + final cached = _tileMemoryCache.remove(cacheKey); + if (cached != null) { + _tileMemoryCache[cacheKey] = cached; + return cached; } + + var location = _tileLocationCache[cacheKey]; + if (location == null) { + final row = await _loadTileRow(styleHash, z, x, y); + if (row == null) return null; + location = _CachedTileLocation( + relativePath: row['relative_path'] as String, + contentType: row['content_type'] as String?, + ); + _tileLocationCache[cacheKey] = location; + } + + final base = await baseDir; + final file = File('$base/${location.relativePath}'); + if (!await file.exists()) { + await _deleteTileEntry(styleHash, z, x, y); + return null; + } + + final Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } on FileSystemException { + await _deleteTileEntry(styleHash, z, x, y); + return null; + } + final data = CachedTileData( + bytes: bytes, + contentType: location.contentType, + ); + _rememberTile(cacheKey, data); + return data; } - /// Check if a tile exists in the cache (uses in-memory manifest). + /// Read a cached tile's raw bytes (for serving to peers). + Future getRawTile(String styleHash, int z, int x, int y) async { + final tile = await getTileData(styleHash, z, x, y); + return tile?.bytes; + } + + /// Check if a tile exists in the cache. Future hasTile(String styleHash, int z, int x, int y) async { + final cacheKey = '$styleHash/${_tileKey(z, x, y)}'; + if (_tileLocationCache.containsKey(cacheKey)) { + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + return true; + } + + final row = await _loadTileRow(styleHash, z, x, y); + if (row != null) { + _tileLocationCache[cacheKey] = _CachedTileLocation( + relativePath: row['relative_path'] as String, + contentType: row['content_type'] as String?, + ); + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + return true; + } + final manifest = await loadManifest(styleHash); return manifest.contains(_tileKey(z, x, y)); } - /// Read a cached tile's raw AVIF bytes (for serving to peers). - Future getRawTile(String styleHash, int z, int x, int y) async { - final base = await baseDir; - final file = File(_tilePath(base, styleHash, z, x, y)); - if (!await file.exists()) return null; - return file.readAsBytes(); - } - - /// Read a cached tile and decode AVIF → PNG bytes for flutter_map display. - Future getTileAsPng( - String styleHash, int z, int x, int y) async { - final avifBytes = await getRawTile(styleHash, z, x, y); - if (avifBytes == null) return null; - return _avifToPng(avifBytes); - } - - /// Store a tile: encode PNG bytes → AVIF, write to disk, update manifest. + /// Store a tile's original bytes and update the SQLite index. Future putTile( String styleHash, int z, int x, int y, - Uint8List pngBytes, - ) async { + Uint8List bytes, { + String? contentType, + String? sourceUrl, + }) async { + final resolvedContentType = _normalizeContentType(contentType); + final fileExtension = _fileExtensionForTile( + contentType: resolvedContentType, + sourceUrl: sourceUrl, + ); + final relativePath = '$styleHash/$z/$x/$y.$fileExtension'; final base = await baseDir; final dir = Directory(_tileDir(base, styleHash, z, x)); if (!await dir.exists()) { await dir.create(recursive: true); } - final avifBytes = await _pngToAvif(pngBytes); - if (avifBytes == null) { - await File(_tilePath(base, styleHash, z, x, y)) - .writeAsBytes(pngBytes, flush: true); - } else { - await File(_tilePath(base, styleHash, z, x, y)) - .writeAsBytes(avifBytes, flush: true); - } + final file = File('$base/$relativePath'); + await file.writeAsBytes(bytes, flush: true); + await _upsertTileEntry( + styleHash: styleHash, + z: z, + x: x, + y: y, + relativePath: relativePath, + contentType: resolvedContentType, + sizeBytes: bytes.length, + ); - await _addToManifest(base, styleHash, _tileKey(z, x, y)); + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + await _writeManifest(base, styleHash, _manifests[styleHash]!); + _rememberTile( + '$styleHash/${_tileKey(z, x, y)}', + CachedTileData(bytes: bytes, contentType: resolvedContentType), + ); } - /// Store raw AVIF bytes directly (from a peer), update manifest. + /// Store raw tile bytes directly (from a peer), update the SQLite index. Future putRawTile( String styleHash, int z, int x, int y, - Uint8List avifBytes, - ) async { - final base = await baseDir; - final dir = Directory(_tileDir(base, styleHash, z, x)); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - await File(_tilePath(base, styleHash, z, x, y)) - .writeAsBytes(avifBytes, flush: true); - await _addToManifest(base, styleHash, _tileKey(z, x, y)); + Uint8List bytes, { + String? contentType, + }) async { + await putTile( + styleHash, + z, + x, + y, + bytes, + contentType: contentType, + ); } /// Get total cache size in bytes. Future getCacheSize() async { - final base = await baseDir; - final dir = Directory(base); - if (!await dir.exists()) return 0; - - var totalSize = 0; - await for (final entity in dir.list(recursive: true)) { - if (entity is File) { - totalSize += await entity.length(); - } - } - return totalSize; + await _hydrateAllStylesIfDatabaseIsEmpty(); + final rows = await (await _db).rawQuery( + 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM offline_tile_entries', + ); + return (rows.first['total'] as int?) ?? 0; } /// List all style hashes that have cached tiles. Future> listStyles() async { - final base = await baseDir; - final dir = Directory(base); - if (!await dir.exists()) return []; - - final styles = []; - await for (final entity in dir.list()) { - if (entity is Directory) { - styles.add(entity.path.split('/').last); - } - } - return styles; + final styles = await listStylesDetailed(); + return styles.map((style) => style.hash).toList(); } /// Save metadata for a style (name, URL template, download region). @@ -302,28 +438,48 @@ class OfflineTileCacheService { await dir.create(recursive: true); } - // Merge with existing meta to preserve region if not provided final metaFile = File('$base/$styleHash/meta.json'); - Map meta = { + final existing = await getStyleMeta(styleHash); + final regionJson = region != null + ? jsonEncode(region.toJson()) + : existing?.region == null + ? null + : jsonEncode(existing!.region!.toJson()); + final meta = { 'displayName': displayName, 'urlTemplate': urlTemplate, + if (regionJson != null) 'region': jsonDecode(regionJson), }; - if (region != null) { - meta['region'] = region.toJson(); - } else if (await metaFile.exists()) { - try { - final existing = jsonDecode(await metaFile.readAsString()); - if (existing['region'] != null) { - meta['region'] = existing['region']; - } - } catch (_) {} - } - await metaFile.writeAsString(jsonEncode(meta), flush: true); + + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': displayName, + 'url_template': urlTemplate, + 'region_json': regionJson, + 'tile_count': existing?.tileCount ?? 0, + 'size_bytes': existing?.sizeBytes ?? 0, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } /// Read metadata for a style. Future getStyleMeta(String styleHash) async { + final rows = await (await _db).query( + 'offline_tile_styles', + where: 'style_hash = ?', + whereArgs: [styleHash], + limit: 1, + ); + if (rows.isNotEmpty) { + return _styleInfoFromRow(rows.first); + } + final base = await baseDir; final metaFile = File('$base/$styleHash/meta.json'); if (!await metaFile.exists()) return null; @@ -344,138 +500,440 @@ class OfflineTileCacheService { /// List all styles with metadata, tile counts, and sizes. Future> listStylesDetailed() async { - final base = await baseDir; - final dir = Directory(base); - if (!await dir.exists()) return []; - - final results = []; - await for (final entity in dir.list()) { - if (entity is! Directory) continue; - final hash = entity.path.split('/').last; - - // Read meta - String displayName = hash; - String urlTemplate = ''; - DownloadRegion? region; - final metaFile = File('${entity.path}/meta.json'); - if (await metaFile.exists()) { - try { - final json = jsonDecode(await metaFile.readAsString()); - displayName = json['displayName'] as String? ?? hash; - urlTemplate = json['urlTemplate'] as String? ?? ''; - if (json['region'] != null) { - region = DownloadRegion.fromJson( - json['region'] as Map); - } - } catch (_) {} - } - - // Count tiles and size - var tileCount = 0; - var sizeBytes = 0; - await for (final file in entity.list(recursive: true)) { - if (file is File && file.path.endsWith('.avif')) { - tileCount++; - sizeBytes += await file.length(); - } - } - - results.add(StyleInfo( - hash: hash, - displayName: displayName, - urlTemplate: urlTemplate, - tileCount: tileCount, - sizeBytes: sizeBytes, - region: region, - )); - } - return results; + await _hydrateAllStylesIfDatabaseIsEmpty(); + final rows = await (await _db).query( + 'offline_tile_styles', + orderBy: 'updated_at DESC', + ); + return rows.map(_styleInfoFromRow).toList(); } /// List all tile coordinates cached for a given style. Future> listTilesForStyle(String styleHash) async { - final base = await baseDir; - final styleDir = Directory('$base/$styleHash'); - if (!await styleDir.exists()) return []; - - final tiles = []; - final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$'); - - await for (final entity in styleDir.list(recursive: true)) { - if (entity is! File) continue; - final match = avifPattern.firstMatch(entity.path); - if (match != null) { - tiles.add(CachedTileCoord( - int.parse(match.group(1)!), - int.parse(match.group(2)!), - int.parse(match.group(3)!), - )); - } + var rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + orderBy: 'z ASC, x ASC, y ASC', + ); + if (rows.isEmpty) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + orderBy: 'z ASC, x ASC, y ASC', + ); } - return tiles; + + return rows + .map((row) => CachedTileCoord( + row['z'] as int, + row['x'] as int, + row['y'] as int, + )) + .toList(); } /// Delete a single style's tiles, manifest, and metadata. Future deleteStyle(String styleHash) async { _manifests.remove(styleHash); + _removeStyleFromMemory(styleHash); final base = await baseDir; final dir = Directory('$base/$styleHash'); if (await dir.exists()) { await dir.delete(recursive: true); } + await (await _db).delete( + 'offline_tile_entries', + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + await (await _db).delete( + 'offline_tile_styles', + where: 'style_hash = ?', + whereArgs: [styleHash], + ); } /// Delete all cached tiles, manifests, and metadata. Future clearCache() async { _manifests.clear(); + clearMemoryCacheForTesting(); final base = await baseDir; final dir = Directory(base); if (await dir.exists()) { await dir.delete(recursive: true); } + await (await _db).delete('offline_tile_entries'); + await (await _db).delete('offline_tile_styles'); } - /// Decode AVIF bytes to PNG (static helper for use by caching provider). - static Future getTileAsPngStatic(Uint8List avifBytes) { - return _avifToPng(avifBytes); + StyleInfo _styleInfoFromRow(Map row) { + final regionJson = row['region_json'] as String?; + return StyleInfo( + hash: row['style_hash'] as String, + displayName: row['display_name'] as String, + urlTemplate: row['url_template'] as String, + tileCount: row['tile_count'] as int? ?? 0, + sizeBytes: row['size_bytes'] as int? ?? 0, + region: regionJson == null + ? null + : DownloadRegion.fromJson(jsonDecode(regionJson) as Map), + ); } - /// Encode PNG → AVIF for tile storage. - /// Uses moderate quality for good compression with acceptable quality. - static Future _pngToAvif(Uint8List pngBytes) async { - try { - final avif = await encodeAvif( - pngBytes, - maxThreads: 2, - maxQuantizer: 40, // Good quality (0=lossless, 63=worst) - minQuantizer: 25, - maxQuantizerAlpha: 63, - minQuantizerAlpha: 63, - speed: 6, - keepExif: false, + Future _hydrateAllStylesIfDatabaseIsEmpty() async { + final rows = await (await _db).rawQuery( + 'SELECT COUNT(*) AS count FROM offline_tile_styles', + ); + if (((rows.first['count'] as int?) ?? 0) > 0) return; + await _hydrateAllStylesFromDisk(); + } + + Future _hydrateAllStylesFromDisk() async { + final base = await baseDir; + final dir = Directory(base); + if (!await dir.exists()) return; + + await for (final entity in dir.list()) { + if (entity is Directory) { + final styleHash = entity.path.split('/').last; + await _hydrateStyleFromDisk(styleHash); + } + } + } + + Future _hydrateStyleFromDisk(String styleHash) { + final existing = _styleHydrations[styleHash]; + if (existing != null) return existing; + + final hydration = _hydrateStyleFromDiskLocked(styleHash); + _styleHydrations[styleHash] = hydration; + return hydration.whenComplete(() { + _styleHydrations.remove(styleHash); + }); + } + + Future _hydrateStyleFromDiskLocked(String styleHash) async { + final base = await baseDir; + final styleDir = Directory('$base/$styleHash'); + if (!await styleDir.exists()) return; + + String displayName = styleHash; + String urlTemplate = ''; + DownloadRegion? region; + final metaFile = File('$base/$styleHash/meta.json'); + if (await metaFile.exists()) { + try { + final json = jsonDecode(await metaFile.readAsString()); + displayName = json['displayName'] as String? ?? styleHash; + urlTemplate = json['urlTemplate'] as String? ?? ''; + if (json['region'] != null) { + region = DownloadRegion.fromJson(json['region'] as Map); + } + } catch (_) {} + } + + final entries = <({int z, int x, int y, String relativePath, String? contentType, int sizeBytes})>[]; + await for (final entity in styleDir.list(recursive: true)) { + if (entity is! File) continue; + final relativePath = entity.path.replaceFirst('$base/', ''); + final segments = relativePath.split('/'); + if (segments.length != 4) continue; + if (segments[3] == 'meta.json' || segments[3] == 'manifest.txt') continue; + + final z = int.tryParse(segments[1]); + final x = int.tryParse(segments[2]); + final ySegment = segments[3].split('.').first; + final y = int.tryParse(ySegment); + if (z == null || x == null || y == null) continue; + + final int sizeBytes; + try { + if (!await entity.exists()) continue; + sizeBytes = await entity.length(); + } on FileSystemException { + continue; + } + + entries.add(( + z: z, + x: x, + y: y, + relativePath: relativePath, + contentType: _contentTypeFromPath(relativePath), + sizeBytes: sizeBytes, + )); + } + + final now = DateTime.now().millisecondsSinceEpoch; + final totalSize = entries.fold( + 0, + (total, entry) => total + entry.sizeBytes, + ); + await (await _db).transaction((txn) async { + await txn.delete( + 'offline_tile_entries', + where: 'style_hash = ?', + whereArgs: [styleHash], ); - if (avif.isEmpty) return null; - return avif; - } catch (e) { - debugPrint('[OfflineTileCache] AVIF encode error: $e'); - return null; + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': displayName, + 'url_template': urlTemplate, + 'region_json': region == null ? null : jsonEncode(region.toJson()), + 'tile_count': entries.length, + 'size_bytes': totalSize, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final entry in entries) { + await txn.insert( + 'offline_tile_entries', + { + 'style_hash': styleHash, + 'z': entry.z, + 'x': entry.x, + 'y': entry.y, + 'relative_path': entry.relativePath, + 'content_type': entry.contentType, + 'size_bytes': entry.sizeBytes, + 'cached_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); + + _manifests[styleHash] = + entries.map((entry) => _tileKey(entry.z, entry.x, entry.y)).toSet(); + for (final entry in entries) { + _tileLocationCache['$styleHash/${_tileKey(entry.z, entry.x, entry.y)}'] = + _CachedTileLocation( + relativePath: entry.relativePath, + contentType: entry.contentType, + ); + } + await _writeManifest(base, styleHash, _manifests[styleHash]!); + } + + Future _upsertTileEntry({ + required String styleHash, + required int z, + required int x, + required int y, + required String relativePath, + required String? contentType, + required int sizeBytes, + }) async { + final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false); + final existingStyle = await getStyleMeta(styleHash); + if (existingTile != null && + existingTile['relative_path'] != null && + existingTile['relative_path'] != relativePath) { + final base = await baseDir; + final oldFile = File('$base/${existingTile['relative_path']}'); + if (await oldFile.exists()) { + await oldFile.delete(); + } + } + + final tileCountDelta = existingTile == null ? 1 : 0; + final sizeDelta = sizeBytes - ((existingTile?['size_bytes'] as int?) ?? 0); + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).transaction((txn) async { + await txn.insert( + 'offline_tile_entries', + { + 'style_hash': styleHash, + 'z': z, + 'x': x, + 'y': y, + 'relative_path': relativePath, + 'content_type': contentType, + 'size_bytes': sizeBytes, + 'cached_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': existingStyle?.displayName ?? styleHash, + 'url_template': existingStyle?.urlTemplate ?? '', + 'region_json': existingStyle?.region == null + ? null + : jsonEncode(existingStyle!.region!.toJson()), + 'tile_count': (existingStyle?.tileCount ?? 0) + tileCountDelta, + 'size_bytes': (existingStyle?.sizeBytes ?? 0) + sizeDelta, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }); + + _tileLocationCache['$styleHash/${_tileKey(z, x, y)}'] = + _CachedTileLocation( + relativePath: relativePath, + contentType: contentType, + ); + } + + Future _deleteTileEntry(String styleHash, int z, int x, int y) async { + final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false); + if (existingTile == null) return; + + final existingStyle = await getStyleMeta(styleHash); + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).transaction((txn) async { + await txn.delete( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + ); + if (existingStyle != null) { + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': existingStyle.displayName, + 'url_template': existingStyle.urlTemplate, + 'region_json': existingStyle.region == null + ? null + : jsonEncode(existingStyle.region!.toJson()), + 'tile_count': (existingStyle.tileCount - 1).clamp(0, existingStyle.tileCount), + 'size_bytes': (existingStyle.sizeBytes - (existingTile['size_bytes'] as int)) + .clamp(0, existingStyle.sizeBytes), + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); + + _manifests[styleHash]?.remove(_tileKey(z, x, y)); + _tileLocationCache.remove('$styleHash/${_tileKey(z, x, y)}'); + final removed = _tileMemoryCache.remove('$styleHash/${_tileKey(z, x, y)}'); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + final base = await baseDir; + final manifest = _manifests[styleHash]; + if (manifest != null) { + await _writeManifest(base, styleHash, manifest); } } - /// Decode AVIF → PNG bytes for display. - static Future _avifToPng(Uint8List avifBytes) async { - try { - // Use Flutter's image codec which can handle AVIF via flutter_avif - final codec = await ui.instantiateImageCodec(avifBytes); - final frame = await codec.getNextFrame(); - final image = frame.image; - final byteData = - await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - return byteData?.buffer.asUint8List(); - } catch (e) { - debugPrint('[OfflineTileCache] AVIF decode error: $e'); - return null; + Future _writeManifest( + String base, String styleHash, Set manifest) async { + final dir = Directory('$base/$styleHash'); + if (!await dir.exists()) await dir.create(recursive: true); + await File(_manifestPath(base, styleHash)) + .writeAsString(manifest.join('\n'), flush: true); + } + + void _rememberTile(String key, CachedTileData data) { + final existing = _tileMemoryCache.remove(key); + if (existing != null) { + _tileMemoryBytes -= existing.bytes.length; } + _tileMemoryCache[key] = data; + _tileMemoryBytes += data.bytes.length; + + while (_tileMemoryCache.length > _memoryCacheMaxEntries || + _tileMemoryBytes > _memoryCacheMaxBytes) { + final firstKey = _tileMemoryCache.keys.first; + final removed = _tileMemoryCache.remove(firstKey); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + } + } + + void _removeStyleFromMemory(String styleHash) { + final prefix = '$styleHash/'; + _tileLocationCache.removeWhere((key, _) => key.startsWith(prefix)); + final keys = _tileMemoryCache.keys.where((key) => key.startsWith(prefix)).toList(); + for (final key in keys) { + final removed = _tileMemoryCache.remove(key); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + } + } + + String? _normalizeContentType(String? contentType) { + if (contentType == null || contentType.isEmpty) return null; + return contentType.split(';').first.trim().toLowerCase(); + } + + String _fileExtensionForTile({ + required String? contentType, + required String? sourceUrl, + }) { + if (contentType != null) { + switch (contentType) { + case 'image/png': + return 'png'; + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + case 'image/avif': + return 'avif'; + } + } + + final uri = sourceUrl == null ? null : Uri.tryParse(sourceUrl); + final segment = uri?.pathSegments.isNotEmpty == true + ? uri!.pathSegments.last + : ''; + final match = RegExp(r'\.(png|jpg|jpeg|webp|avif)$', caseSensitive: false) + .firstMatch(segment); + final extension = match?.group(1)?.toLowerCase(); + if (extension == 'jpeg') return 'jpg'; + return extension ?? 'tile'; + } + + String? _contentTypeFromPath(String relativePath) { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.jpg') || relativePath.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (relativePath.endsWith('.webp')) return 'image/webp'; + if (relativePath.endsWith('.avif')) return 'image/avif'; + return null; + } + + Future?> _loadTileRow( + String styleHash, + int z, + int x, + int y, { + bool hydrate = true, + }) async { + var rows = await (await _db).query( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + limit: 1, + ); + if (rows.isEmpty && hydrate) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + limit: 1, + ); + } + return rows.isEmpty ? null : rows.first; } } diff --git a/lib/services/tile_download_service.dart b/lib/services/tile_download_service.dart index e6f2dba..e565ed1 100644 --- a/lib/services/tile_download_service.dart +++ b/lib/services/tile_download_service.dart @@ -252,9 +252,15 @@ class TileDownloadService { return TileFailed(tile, 'HTTP ${response.statusCode}'); } - // Store tile (PNG → AVIF conversion happens inside cache service) await _cache.putTile( - styleHash, tile.z, tile.x, tile.y, response.bodyBytes); + styleHash, + tile.z, + tile.x, + tile.y, + response.bodyBytes, + contentType: response.headers['content-type'], + sourceUrl: url, + ); return TileDownloaded( north: bounds.north, diff --git a/lib/services/tile_sharing_service.dart b/lib/services/tile_sharing_service.dart index 07fe41f..a3804aa 100644 --- a/lib/services/tile_sharing_service.dart +++ b/lib/services/tile_sharing_service.dart @@ -36,6 +36,16 @@ class PeerCatalog { const PeerCatalog({required this.peer, required this.styles}); } +class PeerTileResponse { + final Uint8List bytes; + final String? contentType; + + const PeerTileResponse({ + required this.bytes, + required this.contentType, + }); +} + /// Progress events during a P2P sync. sealed class PeerSyncEvent {} @@ -69,13 +79,13 @@ class PeerSyncComplete extends PeerSyncEvent { class PeerSyncCancelled extends PeerSyncEvent {} -/// HTTP server that serves cached AVIF tiles to other devices on the +/// HTTP server that serves cached tiles to other devices on the /// local network, with mDNS advertisement, peer discovery, and P2P sync. /// /// Protocol: /// GET /styles → JSON array of StyleInfo /// GET /tiles/{hash}/list → JSON array of {z, x, y} -/// GET /tiles/{hash}/{z}/{x}/{y}.avif → AVIF bytes | 404 +/// GET /tiles/{hash}/{z}/{x}/{y} → tile bytes | 404 class TileSharingService { TileSharingService._(); static final instance = TileSharingService._(); @@ -222,8 +232,8 @@ class TileSharingService { } } - /// Fetch a single tile from a peer. Returns raw AVIF bytes or null. - Future fetchTileFromPeer( + /// Fetch a single tile from a peer. Returns raw tile bytes or null. + Future fetchTileFromPeer( TilePeer peer, String styleHash, int z, @@ -231,11 +241,15 @@ class TileSharingService { int y, ) async { try { - final uri = - Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y.avif'); + final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y'); final response = await _httpClient.get(uri).timeout(const Duration(seconds: 5)); - if (response.statusCode == 200) return response.bodyBytes; + if (response.statusCode == 200) { + return PeerTileResponse( + bytes: response.bodyBytes, + contentType: response.headers['content-type'], + ); + } } catch (e) { // Silently fail — caller will try next peer } @@ -243,15 +257,15 @@ class TileSharingService { } /// Try fetching a tile from any available peer (for the caching provider). - Future fetchFromAnyPeer( + Future fetchFromAnyPeer( String styleHash, int z, int x, int y, ) async { for (final peer in _discoveredPeers) { - final bytes = await fetchTileFromPeer(peer, styleHash, z, x, y); - if (bytes != null) return bytes; + final tile = await fetchTileFromPeer(peer, styleHash, z, x, y); + if (tile != null) return tile; } return null; } @@ -299,6 +313,7 @@ class TileSharingService { styleHash, displayName: styleMeta.displayName, urlTemplate: styleMeta.urlTemplate, + region: styleMeta.region, ); // Collect tile lists from all peers and merge (union) @@ -356,20 +371,26 @@ class TileSharingService { } // Try this peer, then fallback to others - Uint8List? bytes = + PeerTileResponse? tileResponse = await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y); - if (bytes == null) { + if (tileResponse == null) { for (final fallback in peers) { if (fallback == peer) continue; - bytes = await fetchTileFromPeer( + tileResponse = await fetchTileFromPeer( fallback, styleHash, tile.z, tile.x, tile.y); - if (bytes != null) break; + if (tileResponse != null) break; } } - if (bytes != null) { + if (tileResponse != null) { await _cache.putRawTile( - styleHash, tile.z, tile.x, tile.y, bytes); + styleHash, + tile.z, + tile.x, + tile.y, + tileResponse.bytes, + contentType: tileResponse.contentType, + ); downloaded++; controller.add(PeerSyncTileDownloaded( downloaded: downloaded, total: total)); @@ -457,9 +478,9 @@ class TileSharingService { return; } - // GET /tiles/{hash}/{z}/{x}/{y}.avif → tile bytes + // GET /tiles/{hash}/{z}/{x}/{y} → tile bytes final tilePattern = - RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)\.avif$'); + RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)(?:\.avif)?$'); final tileMatch = tilePattern.firstMatch(path); if (tileMatch != null) { final styleHash = tileMatch.group(1)!; @@ -467,12 +488,14 @@ class TileSharingService { final x = int.parse(tileMatch.group(3)!); final y = int.parse(tileMatch.group(4)!); - final bytes = await _cache.getRawTile(styleHash, z, x, y); - if (bytes != null) { + final tile = await _cache.getTileData(styleHash, z, x, y); + if (tile != null) { request.response ..statusCode = HttpStatus.ok - ..headers.contentType = ContentType('image', 'avif') - ..add(bytes); + ..headers.contentType = tile.contentType == null + ? ContentType.binary + : ContentType.parse(tile.contentType!) + ..add(tile.bytes); await request.response.close(); return; } diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 3aeb696..5622c96 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -30,7 +30,6 @@ import '../../utils/sar_message_parser.dart'; import '../../utils/key_comparison.dart'; import '../../utils/voice_message_parser.dart'; import '../../utils/image_message_parser.dart'; -import '../../utils/message_airtime_estimator.dart'; import '../../utils/tictactoe_message_parser.dart'; import '../../utils/location_formats.dart'; import '../../l10n/app_localizations.dart'; @@ -92,7 +91,6 @@ class _MessageBubbleState extends State { caseSensitive: false, ); bool _isExpanded = false; - bool _showReceivedStats = false; final List _linkRecognizers = []; @override @@ -106,7 +104,6 @@ class _MessageBubbleState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.message.id != widget.message.id) { _isExpanded = false; - _showReceivedStats = false; return; } @@ -323,19 +320,6 @@ class _MessageBubbleState extends State { widget.onTap?.call(); } - void _handleBubbleDoubleTap({ - required bool isSarMarker, - required bool isDrawing, - }) { - if (widget.isCompact || isSarMarker || isDrawing) { - return; - } - - setState(() { - _showReceivedStats = !_showReceivedStats; - }); - } - Future _retryFailedMessage( BuildContext context, Message failedMessage, @@ -876,37 +860,7 @@ class _MessageBubbleState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _techBadge( - sheetContext, - icon: Icons.message, - label: widget.message.messageType.name - .toUpperCase(), - ), - _techBadge( - sheetContext, - icon: Icons.route, - label: hopDisplayLabel(widget.message), - ), - _techBadge( - sheetContext, - icon: Icons.account_tree_outlined, - label: - '${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}', - ), - if (widget.message.channelIdx != null) - _techBadge( - sheetContext, - icon: Icons.group_work, - label: 'CH ${widget.message.channelIdx}', - ), - ], - ), if (messageLocationSnapshot != null) ...[ - const SizedBox(height: 12), _techSection( sheetContext, icon: Icons.location_on, @@ -1518,31 +1472,6 @@ class _MessageBubbleState extends State { ); } - Widget _techBadge( - BuildContext context, { - required IconData icon, - required String label, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), - ), - ], - ), - ); - } - Widget _detailRow( BuildContext context, { required String label, @@ -2130,25 +2059,6 @@ class _MessageBubbleState extends State { final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); - final receptionDetails = !isOwnMessage - ? messagesProvider.getMessageReceptionDetails(message.id) - : null; - final matchedRxLog = !isOwnMessage - ? _findBestMatchingRxLog( - connectionProvider.bleService.packetLogs, - message, - ) - : null; - final snrDb = - receptionDetails?.snrDb ?? - matchedRxLog?.logRxDataInfo?.snrDb ?? - (message.lastEchoSnrRaw != null - ? (message.lastEchoSnrRaw!.toSigned(8) / 4.0) - : null); - final rssiDbm = - receptionDetails?.rssiDbm ?? - matchedRxLog?.logRxDataInfo?.rssiDbm ?? - message.lastEchoRssiDbm; final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id); // Look up contact information for rich display name @@ -2265,10 +2175,9 @@ class _MessageBubbleState extends State { isSarMarker: isSarMarker, isDrawing: message.isDrawing, ), - onDoubleTap: () => _handleBubbleDoubleTap( - isSarMarker: isSarMarker, - isDrawing: message.isDrawing, - ), + onDoubleTap: widget.isCompact + ? null + : () => _showTechnicalDetails(context), onLongPress: widget.isCompact ? null : () => _showMessageOptions(context), @@ -2835,21 +2744,6 @@ class _MessageBubbleState extends State { else if (!message.isDrawing || widget.isCompact) _buildMessageTextContent(message.text, baseBodyStyle), - if (!widget.isCompact && - !isSarMarker && - !message.isDrawing && - !message.isSentMessage && - _showReceivedStats) ...[ - const SizedBox(height: 6), - buildReceivedSignalStatus( - context, - message, - receptionDetails: receptionDetails, - rssiDbm: rssiDbm, - snrDb: snrDb, - ), - ], - // Delivery status for sent messages (skip in compact mode) if (message.isSentMessage && !widget.isCompact) ...[ const SizedBox(height: 6), @@ -3040,129 +2934,81 @@ class _MessageBubbleState extends State { // Show single message delivery status else if (!message.isChannelMessage || message.deliveryStatus == MessageDeliveryStatus.failed) - Builder( - builder: (context) { - final txEstimate = estimateMessageTransmitDuration( - message, - radioBw: connectionProvider.deviceInfo.radioBw, - radioSf: connectionProvider.deviceInfo.radioSf, - radioCr: connectionProvider.deviceInfo.radioCr, - ); - final showSentDirectStats = - message.isContactMessage && - message.deliveryStatus == - MessageDeliveryStatus.delivered && - _showReceivedStats && - message.roundTripTimeMs != null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - children: [ - if (message.isContactMessage) - _buildDirectMessageStatusIndicator( - context, - message, - ) - else ...[ - Icon( - getDeliveryStatusIcon(message.deliveryStatus), - size: 12, - color: getDeliveryStatusColor( - message.deliveryStatus, - ), - ), - const SizedBox(width: 3), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text( - message.getLocalizedDeliveryStatus( - context, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: getDeliveryStatusColor( - message.deliveryStatus, - ), - fontStyle: FontStyle.italic, - ), - ), - ), - ), - ], - // Show retry button for failed messages - if (message.deliveryStatus == - MessageDeliveryStatus.failed) ...[ - const SizedBox(width: 6), - GestureDetector( - onTap: () => - _retryFailedMessage(context, message), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.orange.withValues( - alpha: 0.2, - ), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.orange, - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.refresh, - size: 12, - color: Colors.orange, - ), - const SizedBox(width: 4), - Text( - 'Retry', - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ], + Row( + mainAxisSize: MainAxisSize.max, + children: [ + if (message.isContactMessage) + _buildDirectMessageStatusIndicator(context, message) + else ...[ + Icon( + getDeliveryStatusIcon(message.deliveryStatus), + size: 12, + color: getDeliveryStatusColor( + message.deliveryStatus, ), - if (showSentDirectStats) ...[ - const SizedBox(height: 6), - buildSentDirectSignalStatus( - context, - message, - roundTripTimeMs: message.roundTripTimeMs!, - txEstimate: txEstimate, + ), + const SizedBox(width: 3), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + message.getLocalizedDeliveryStatus(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: getDeliveryStatusColor( + message.deliveryStatus, + ), + fontStyle: FontStyle.italic, + ), ), - ], - ], - ); - }, + ), + ), + ], + // Show retry button for failed messages + if (message.deliveryStatus == + MessageDeliveryStatus.failed) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => _retryFailedMessage(context, message), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.orange, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.refresh, + size: 12, + color: Colors.orange, + ), + const SizedBox(width: 4), + Text( + 'Retry', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ], ), - if (_showReceivedStats && - shouldShowSentChannelStats(message)) ...[ - const SizedBox(height: 6), - buildChannelEchoStatus(context, message), - ], ], ], ), diff --git a/pubspec.lock b/pubspec.lock index 2cf6a70..e70d805 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" codec2_flutter: dependency: "direct main" description: @@ -647,6 +655,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gsettings: dependency: transitive description: @@ -655,6 +671,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http: dependency: "direct main" description: @@ -807,6 +831,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -856,6 +888,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nested: dependency: transitive description: @@ -1096,6 +1136,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" record: dependency: "direct main" description: @@ -1144,6 +1192,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" record_web: dependency: transitive description: @@ -1270,7 +1326,7 @@ packages: source: hosted version: "7.0.0" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 @@ -1293,6 +1349,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.6" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 + url: "https://pub.dev" + source: hosted + version: "2.4.0+3" sqflite_darwin: dependency: transitive description: @@ -1309,6 +1373,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" + url: "https://pub.dev" + source: hosted + version: "3.3.1" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 94126d8..4196288 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -96,6 +96,7 @@ dependencies: # Persistent storage shared_preferences: ^2.3.3 + sqflite: ^2.4.2 # Package info package_info_plus: ^9.0.0 @@ -139,6 +140,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: "^0.14.4" fake_async: ^1.3.3 + sqflite_common_ffi: ^2.3.6 dependency_overrides: # Keep local package overrides in pubspec_overrides.yaml so CI uses the diff --git a/test/services/offline_map_caching_provider_test.dart b/test/services/offline_map_caching_provider_test.dart index 9b8d3fa..5a3a935 100644 --- a/test/services/offline_map_caching_provider_test.dart +++ b/test/services/offline_map_caching_provider_test.dart @@ -1,7 +1,35 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart'; +import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void main() { + late Directory tempDir; + final cache = OfflineTileCacheService.instance; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_'); + await cache.resetForTesting(); + cache.setBaseDirForTesting('${tempDir.path}/tiles'); + cache.setDatabasePathForTesting('${tempDir.path}/tiles.db'); + }); + + tearDown(() async { + await cache.resetForTesting(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + test('parses query-style tile URLs used by Google layers', () { final coords = OfflineMapCachingProvider.parseTileUrlForTesting( 'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13', @@ -35,4 +63,48 @@ void main() { 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', ); }); + + test('returns cached raw bytes directly', () async { + const url = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267'; + final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url); + final styleHash = cache.styleHashFromUrl(template); + final bytes = Uint8List.fromList([1, 2, 3]); + + await cache.saveStyleMeta( + styleHash, + displayName: 'Esri', + urlTemplate: template, + ); + await cache.putTile( + styleHash, + 9, + 267, + 173, + bytes, + contentType: 'image/jpeg', + sourceUrl: url, + ); + + final provider = OfflineMapCachingProvider(_NullMapCachingProvider()); + final tile = await provider.getTile(url); + + expect(tile, isNotNull); + expect(tile!.bytes, bytes); + }); +} + +class _NullMapCachingProvider implements MapCachingProvider { + @override + bool get isSupported => true; + + @override + Future getTile(String url) async => null; + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async {} } diff --git a/test/services/offline_tile_cache_service_test.dart b/test/services/offline_tile_cache_service_test.dart new file mode 100644 index 0000000..ab07cfd --- /dev/null +++ b/test/services/offline_tile_cache_service_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + late Directory tempDir; + final cache = OfflineTileCacheService.instance; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('offline_tile_cache_test_'); + await cache.resetForTesting(); + cache.setBaseDirForTesting('${tempDir.path}/tiles'); + cache.setDatabasePathForTesting('${tempDir.path}/tiles.db'); + }); + + tearDown(() async { + await cache.resetForTesting(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('stores original bytes and content type without conversion', () async { + const styleHash = 'abcdef123456'; + final bytes = Uint8List.fromList([1, 2, 3, 4]); + + await cache.saveStyleMeta( + styleHash, + displayName: 'Test layer', + urlTemplate: 'https://example.com/{z}/{x}/{y}.png', + ); + await cache.putTile( + styleHash, + 8, + 12, + 34, + bytes, + contentType: 'image/png; charset=binary', + sourceUrl: 'https://example.com/8/12/34.png', + ); + + final tile = await cache.getTileData(styleHash, 8, 12, 34); + expect(tile, isNotNull); + expect(tile!.bytes, bytes); + expect(tile.contentType, 'image/png'); + expect(await cache.hasTile(styleHash, 8, 12, 34), isTrue); + expect(await cache.getCacheSize(), bytes.length); + + final styles = await cache.listStylesDetailed(); + expect(styles, hasLength(1)); + expect(styles.single.tileCount, 1); + expect(styles.single.sizeBytes, bytes.length); + }); + + test('hydrates existing disk cache into sqlite on first access', () async { + const styleHash = 'fedcba654321'; + final base = Directory('${tempDir.path}/tiles/$styleHash/9/13'); + await base.create(recursive: true); + final file = File('${base.path}/42.avif'); + await file.writeAsBytes([9, 8, 7], flush: true); + await File('${tempDir.path}/tiles/$styleHash/meta.json').writeAsString( + jsonEncode({ + 'displayName': 'Legacy layer', + 'urlTemplate': 'https://example.com/{z}/{x}/{y}.avif', + }), + flush: true, + ); + + final tiles = await cache.listTilesForStyle(styleHash); + expect(tiles, hasLength(1)); + expect(tiles.single.z, 9); + expect(tiles.single.x, 13); + expect(tiles.single.y, 42); + + final tile = await cache.getTileData(styleHash, 9, 13, 42); + expect(tile, isNotNull); + expect(tile!.bytes, Uint8List.fromList([9, 8, 7])); + expect(tile.contentType, 'image/avif'); + + final styles = await cache.listStylesDetailed(); + expect(styles.single.displayName, 'Legacy layer'); + expect(styles.single.tileCount, 1); + expect(styles.single.sizeBytes, 3); + }); + + test('deletes style metadata and files', () async { + const styleHash = 'aabbccddeeff'; + + await cache.saveStyleMeta( + styleHash, + displayName: 'Delete me', + urlTemplate: 'https://example.com/{z}/{x}/{y}.jpg', + ); + await cache.putTile( + styleHash, + 1, + 2, + 3, + Uint8List.fromList([5, 6]), + contentType: 'image/jpeg', + ); + + expect(await cache.getCacheSize(), 2); + await cache.deleteStyle(styleHash); + expect(await cache.getCacheSize(), 0); + expect(await cache.getTileData(styleHash, 1, 2, 3), isNull); + expect(await Directory('${tempDir.path}/tiles/$styleHash').exists(), isFalse); + }); +} diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart index a2351cc..f8641aa 100644 --- a/test/widgets/message_bubble_test.dart +++ b/test/widgets/message_bubble_test.dart @@ -61,7 +61,7 @@ void main() { ); }); - testWidgets('received bubbles show signal chips on double tap', ( + testWidgets('received bubbles open details on double tap', ( tester, ) async { final harness = await _TestHarness.create(); @@ -95,17 +95,19 @@ void main() { expect(find.text('-84'), findsNothing); await _doubleTap(tester, find.text('Inbound message')); + await tester.pumpAndSettle(); - expect(find.text('1 hop'), findsOneWidget); - expect(find.text('Fair'), findsOneWidget); - expect(find.text('-84'), findsOneWidget); - expect(find.text('6.0'), findsOneWidget); + expect(find.text('Message details'), findsOneWidget); + expect(find.text('1 hop'), findsNothing); + expect(find.text('Fair'), findsNothing); + expect(find.text('-84'), findsNothing); + expect(find.text('6.0'), findsNothing); } finally { await _disposeHarness(tester, harness); } }); - testWidgets('delivered direct bubbles show timing chips on double tap', ( + testWidgets('delivered direct bubbles open details on double tap', ( tester, ) async { final harness = await _TestHarness.create(); @@ -137,15 +139,17 @@ void main() { expect(find.text('320ms'), findsNothing); await _doubleTap(tester, find.text('Outbound message')); + await tester.pumpAndSettle(); - expect(find.text('Direct'), findsOneWidget); - expect(find.text('320ms'), findsOneWidget); + expect(find.text('Message details'), findsOneWidget); + expect(find.text('Direct'), findsNothing); + expect(find.text('320ms'), findsNothing); } finally { await _disposeHarness(tester, harness); } }); - testWidgets('sent channel bubbles show echo chips on double tap', ( + testWidgets('sent channel bubbles open details on double tap', ( tester, ) async { final harness = await _TestHarness.create(); @@ -180,10 +184,12 @@ void main() { expect(find.text('-76'), findsNothing); await _doubleTap(tester, find.text('Broadcast message')); + await tester.pumpAndSettle(); - expect(find.text('x2'), findsOneWidget); - expect(find.text('-76'), findsOneWidget); - expect(find.text('5.0'), findsOneWidget); + expect(find.text('Message details'), findsOneWidget); + expect(find.text('x2'), findsNothing); + expect(find.text('-76'), findsNothing); + expect(find.text('5.0'), findsNothing); } finally { await _disposeHarness(tester, harness); }