feat: Modernize map selection and tile cache

This commit is contained in:
Janez T
2026-04-26 08:41:33 +02:00
parent b040a8bd3a
commit e1400cf1ec
12 changed files with 1985 additions and 533 deletions

View File

@@ -59,6 +59,7 @@ class OfflineTilesProvider extends ChangeNotifier {
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
DrawingMode _downloadSelectionMode = DrawingMode.none;
final List<List<LatLng>> _polygons = [];
List<LatLng> _currentVertices = [];
LatLng? _rectangleFirstCorner;
@@ -94,6 +95,7 @@ class OfflineTilesProvider extends ChangeNotifier {
// Getters
DrawingMode get drawingMode => _drawingMode;
DrawingMode get downloadSelectionMode => _downloadSelectionMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> 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<void> 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 = <TileOverlay>[];
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,

View File

@@ -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<MapTab> 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<MapTab> with AutomaticKeepAliveClientMixin {
// Restore background tracking state
_restoreBackgroundTracking();
final offlineProvider = context.read<offline.OfflineTilesProvider>();
_offlineTilesProvider = offlineProvider;
offlineProvider.refreshCacheSize();
offlineProvider.refreshLocalStyles();
offlineProvider.startPeerDiscovery();
});
}
@@ -468,6 +476,7 @@ class _MapTabState extends State<MapTab> 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<MapTab> with AutomaticKeepAliveClientMixin {
}
}
double _getMapZoom() {
if (!_isMapReady) return _savedMapZoom ?? _defaultZoom;
try {
return _mapController.camera.zoom;
} catch (e) {
return _savedMapZoom ?? _defaultZoom;
}
}
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
return _markerService.calculateCenter(
contacts: contacts,
@@ -528,15 +546,26 @@ class _MapTabState extends State<MapTab> 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<offline.OfflineTilesProvider>()
.setSelectedLayerIfDifferent(_currentLayer);
}
rootContext.read<offline.OfflineTilesProvider>().refreshCacheSize();
rootContext.read<offline.OfflineTilesProvider>().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<MapTab> 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<MapProvider>(
ListView(
children: [
Consumer<MapProvider>(
builder: (context, mapProvider, _) {
final customMapConfig = mapProvider.customMapConfig;
if (customMapConfig == null) {
@@ -1001,16 +1039,635 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
],
);
},
),
],
),
_buildMapDownloadTab(rootContext),
_buildMapCachedTab(rootContext),
],
),
),
],
),
),
),
);
}
Widget _buildMapDownloadTab(BuildContext rootContext) {
return Consumer<offline.OfflineTilesProvider>(
builder: (context, provider, _) {
final loc = AppLocalizations.of(context)!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (!provider.isDownloading) ...[
DropdownButtonFormField<MapLayer>(
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<offline.StyleInfo>(
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<offline.DrawingMode>(
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<offline.OfflineTilesProvider>(
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<Contact> contacts,
@@ -2127,6 +2784,15 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
return;
}
final offlineProvider =
context.read<offline.OfflineTilesProvider>();
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<MapTab> 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<MapTab> 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<MapTab> 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<MapTab> 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<offline.OfflineTilesProvider>(
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<int> 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,
),
),
],
);
}
}

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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<Uint8List?> fetchTileFromPeer(
/// Fetch a single tile from a peer. Returns raw tile bytes or null.
Future<PeerTileResponse?> 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<Uint8List?> fetchFromAnyPeer(
Future<PeerTileResponse?> 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;
}

View File

@@ -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<MessageBubble> {
caseSensitive: false,
);
bool _isExpanded = false;
bool _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
@@ -106,7 +104,6 @@ class _MessageBubbleState extends State<MessageBubble> {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.id != widget.message.id) {
_isExpanded = false;
_showReceivedStats = false;
return;
}
@@ -323,19 +320,6 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.onTap?.call();
}
void _handleBubbleDoubleTap({
required bool isSarMarker,
required bool isDrawing,
}) {
if (widget.isCompact || isSarMarker || isDrawing) {
return;
}
setState(() {
_showReceivedStats = !_showReceivedStats;
});
}
Future<void> _retryFailedMessage(
BuildContext context,
Message failedMessage,
@@ -876,37 +860,7 @@ class _MessageBubbleState extends State<MessageBubble> {
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<MessageBubble> {
);
}
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<MessageBubble> {
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<MessageBubble> {
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<MessageBubble> {
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<MessageBubble> {
// 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),
],
],
],
),