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 // Drawing state
DrawingMode _drawingMode = DrawingMode.none; DrawingMode _drawingMode = DrawingMode.none;
DrawingMode _downloadSelectionMode = DrawingMode.none;
final List<List<LatLng>> _polygons = []; final List<List<LatLng>> _polygons = [];
List<LatLng> _currentVertices = []; List<LatLng> _currentVertices = [];
LatLng? _rectangleFirstCorner; LatLng? _rectangleFirstCorner;
@@ -94,6 +95,7 @@ class OfflineTilesProvider extends ChangeNotifier {
// Getters // Getters
DrawingMode get drawingMode => _drawingMode; DrawingMode get drawingMode => _drawingMode;
DrawingMode get downloadSelectionMode => _downloadSelectionMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons); List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices); List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
LatLng? get rectangleFirstCorner => _rectangleFirstCorner; LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
@@ -131,6 +133,15 @@ class OfflineTilesProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void startDownloadSelectionMode(DrawingMode mode) {
_polygons.clear();
_currentVertices = [];
_rectangleFirstCorner = null;
_downloadSelectionMode = mode;
_drawingMode = mode;
notifyListeners();
}
void addVertex(LatLng point) { void addVertex(LatLng point) {
if (_drawingMode == DrawingMode.polygon) { if (_drawingMode == DrawingMode.polygon) {
_currentVertices = [..._currentVertices, point]; _currentVertices = [..._currentVertices, point];
@@ -181,6 +192,27 @@ class OfflineTilesProvider extends ChangeNotifier {
notifyListeners(); 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() { void undoLastVertex() {
if (_currentVertices.isNotEmpty) { if (_currentVertices.isNotEmpty) {
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1); _currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
@@ -207,6 +239,15 @@ class OfflineTilesProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setSelectedLayerIfDifferent(MapLayer layer) {
if (_selectedLayer.type == layer.type &&
_selectedLayer.urlTemplate == layer.urlTemplate) {
return;
}
_selectedLayer = layer;
notifyListeners();
}
// Download control // Download control
Future<void> startDownload() async { Future<void> startDownload() async {
@@ -273,10 +314,12 @@ class OfflineTilesProvider extends ChangeNotifier {
case TileDownloadComplete(): case TileDownloadComplete():
_isDownloading = false; _isDownloading = false;
_tileOverlays.clear();
notifyListeners(); notifyListeners();
case TileDownloadCancelled(): case TileDownloadCancelled():
_isDownloading = false; _isDownloading = false;
_tileOverlays.clear();
notifyListeners(); notifyListeners();
} }
} }
@@ -341,19 +384,12 @@ class OfflineTilesProvider extends ChangeNotifier {
_coverageOverlays = []; _coverageOverlays = [];
notifyListeners(); 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>[]; final overlays = <TileOverlay>[];
for (final key in manifest) { for (final tile in tiles) {
final parts = key.split('/'); final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
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);
overlays.add(TileOverlay( overlays.add(TileOverlay(
north: bounds.north, north: bounds.north,
south: bounds.south, south: bounds.south,

View File

@@ -19,6 +19,7 @@ import '../providers/map_provider.dart';
import '../providers/drawing_provider.dart'; import '../providers/drawing_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/offline_tiles_provider.dart' as offline;
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/custom_map_config.dart'; import '../models/custom_map_config.dart';
import '../models/map_coordinate_space.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_layer.dart';
import '../widgets/map/drawing_toolbar.dart'; import '../widgets/map/drawing_toolbar.dart';
import '../widgets/map/location_trail_layer.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/trail_controls.dart';
import '../widgets/map/map_message_overlay.dart'; import '../widgets/map/map_message_overlay.dart';
import '../widgets/messages/custom_map_sar_update_sheet.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 '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/offline_map_caching_provider.dart'; import '../services/offline_map_caching_provider.dart';
import 'offline_map_screen.dart';
class MapTab extends StatefulWidget { class MapTab extends StatefulWidget {
final Function(bool)? onFullscreenChanged; final Function(bool)? onFullscreenChanged;
@@ -86,6 +87,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
BackgroundLocationService(); BackgroundLocationService();
bool _isDisposing = false; // Flag to prevent updates during disposal bool _isDisposing = false; // Flag to prevent updates during disposal
MapProvider? _mapProvider; MapProvider? _mapProvider;
offline.OfflineTilesProvider? _offlineTilesProvider;
// Store original location callback to restore in dispose // Store original location callback to restore in dispose
void Function(Position)? _originalLocationCallback; void Function(Position)? _originalLocationCallback;
@@ -150,6 +152,12 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
// Restore background tracking state // Restore background tracking state
_restoreBackgroundTracking(); _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(); _saveMapPosition();
_mapProvider?.removeListener(_handleMapNavigation); _mapProvider?.removeListener(_handleMapNavigation);
_offlineTilesProvider?.stopPeerDiscovery();
// DO NOT stop location tracking - it's managed by AppProvider // DO NOT stop location tracking - it's managed by AppProvider
// Restore the original callback instead of setting to null // 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) { LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
return _markerService.calculateCenter( return _markerService.calculateCenter(
contacts: contacts, contacts: contacts,
@@ -528,15 +546,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
await _saveSettings(); await _saveSettings();
} }
void _showLayerSelector(BuildContext context) { void _showLayerSelector(BuildContext context, {int initialTab = 0}) {
final rootContext = this.context; 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( showModalBottomSheet(
context: context, context: context,
builder: (context) => Container( isScrollControlled: true,
padding: const EdgeInsets.symmetric(vertical: 16), builder: (context) => DefaultTabController(
child: Column( length: 3,
mainAxisSize: MainAxisSize.min, initialIndex: initialTab,
children: [ child: SizedBox(
height: MediaQuery.of(context).size.height * 0.82,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
@@ -555,11 +584,20 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
), ),
const Divider(), 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( Expanded(
child: ListView( child: TabBarView(
shrinkWrap: true,
children: [ children: [
Consumer<MapProvider>( ListView(
children: [
Consumer<MapProvider>(
builder: (context, mapProvider, _) { builder: (context, mapProvider, _) {
final customMapConfig = mapProvider.customMapConfig; final customMapConfig = mapProvider.customMapConfig;
if (customMapConfig == null) { 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( void _showDetailedCompass(
BuildContext context, BuildContext context,
List<Contact> contacts, List<Contact> contacts,
@@ -2127,6 +2784,15 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
return; return;
} }
final offlineProvider =
context.read<offline.OfflineTilesProvider>();
if (!isCustomMapMode &&
offlineProvider.drawingMode !=
offline.DrawingMode.none) {
offlineProvider.addVertex(point);
return;
}
// Handle drawing mode taps // Handle drawing mode taps
if (drawingProvider.drawingMode == DrawingMode.line) { if (drawingProvider.drawingMode == DrawingMode.line) {
if (drawingProvider.currentLinePoints.isEmpty) { if (drawingProvider.currentLinePoints.isEmpty) {
@@ -2216,6 +2882,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
maxZoom: _currentLayer.maxZoom, maxZoom: _currentLayer.maxZoom,
), ),
if (!isCustomMapMode) ...[ if (!isCustomMapMode) ...[
CoverageLayer(currentZoom: _getMapZoom()),
const PolygonDrawLayer(),
const DownloadProgressLayer(),
// WMS Overlays (rendered after base layer, before polylines) // WMS Overlays (rendered after base layer, before polylines)
// Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system)
// Cadastral parcels overlay // 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) // Map controls - right side (hidden in fullscreen mode)
if (!_isFullscreen) if (!_isFullscreen)
Positioned( Positioned(
@@ -3233,17 +3907,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: const Icon(Icons.layers), child: const Icon(Icons.layers),
), ),
const SizedBox(height: 8), 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( FloatingActionButton.small(
heroTag: 'fullscreen_toggle', heroTag: 'fullscreen_toggle',
onPressed: () { 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'; 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. /// falling through to the built-in cache/network path.
/// ///
/// This allows preloaded tiles to be served during normal map browsing. /// This allows preloaded tiles to be served during normal map browsing.
@@ -23,12 +23,16 @@ class OfflineMapCachingProvider implements MapCachingProvider {
if (coords != null) { if (coords != null) {
final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url)); final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url));
// Check local AVIF cache first // Check local offline cache first
final pngBytes = await _cache.getTileAsPng( final cachedTile = await _cache.getTileData(
styleHash, coords.z, coords.x, coords.y); styleHash,
if (pngBytes != null) { coords.z,
coords.x,
coords.y,
);
if (cachedTile != null) {
return ( return (
bytes: pngBytes, bytes: cachedTile.bytes,
metadata: CachedMapTileMetadata( metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)), staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null, 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}'); return TileFailed(tile, 'HTTP ${response.statusCode}');
} }
// Store tile (PNG → AVIF conversion happens inside cache service)
await _cache.putTile( 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( return TileDownloaded(
north: bounds.north, north: bounds.north,

View File

@@ -36,6 +36,16 @@ class PeerCatalog {
const PeerCatalog({required this.peer, required this.styles}); 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. /// Progress events during a P2P sync.
sealed class PeerSyncEvent {} sealed class PeerSyncEvent {}
@@ -69,13 +79,13 @@ class PeerSyncComplete extends PeerSyncEvent {
class PeerSyncCancelled 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. /// local network, with mDNS advertisement, peer discovery, and P2P sync.
/// ///
/// Protocol: /// Protocol:
/// GET /styles → JSON array of StyleInfo /// GET /styles → JSON array of StyleInfo
/// GET /tiles/{hash}/list → JSON array of {z, x, y} /// 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 { class TileSharingService {
TileSharingService._(); TileSharingService._();
static final instance = TileSharingService._(); static final instance = TileSharingService._();
@@ -222,8 +232,8 @@ class TileSharingService {
} }
} }
/// Fetch a single tile from a peer. Returns raw AVIF bytes or null. /// Fetch a single tile from a peer. Returns raw tile bytes or null.
Future<Uint8List?> fetchTileFromPeer( Future<PeerTileResponse?> fetchTileFromPeer(
TilePeer peer, TilePeer peer,
String styleHash, String styleHash,
int z, int z,
@@ -231,11 +241,15 @@ class TileSharingService {
int y, int y,
) async { ) async {
try { try {
final uri = final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y');
Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y.avif');
final response = final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 5)); 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) { } catch (e) {
// Silently fail — caller will try next peer // 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). /// Try fetching a tile from any available peer (for the caching provider).
Future<Uint8List?> fetchFromAnyPeer( Future<PeerTileResponse?> fetchFromAnyPeer(
String styleHash, String styleHash,
int z, int z,
int x, int x,
int y, int y,
) async { ) async {
for (final peer in _discoveredPeers) { for (final peer in _discoveredPeers) {
final bytes = await fetchTileFromPeer(peer, styleHash, z, x, y); final tile = await fetchTileFromPeer(peer, styleHash, z, x, y);
if (bytes != null) return bytes; if (tile != null) return tile;
} }
return null; return null;
} }
@@ -299,6 +313,7 @@ class TileSharingService {
styleHash, styleHash,
displayName: styleMeta.displayName, displayName: styleMeta.displayName,
urlTemplate: styleMeta.urlTemplate, urlTemplate: styleMeta.urlTemplate,
region: styleMeta.region,
); );
// Collect tile lists from all peers and merge (union) // Collect tile lists from all peers and merge (union)
@@ -356,20 +371,26 @@ class TileSharingService {
} }
// Try this peer, then fallback to others // Try this peer, then fallback to others
Uint8List? bytes = PeerTileResponse? tileResponse =
await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y); await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y);
if (bytes == null) { if (tileResponse == null) {
for (final fallback in peers) { for (final fallback in peers) {
if (fallback == peer) continue; if (fallback == peer) continue;
bytes = await fetchTileFromPeer( tileResponse = await fetchTileFromPeer(
fallback, styleHash, tile.z, tile.x, tile.y); 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( await _cache.putRawTile(
styleHash, tile.z, tile.x, tile.y, bytes); styleHash,
tile.z,
tile.x,
tile.y,
tileResponse.bytes,
contentType: tileResponse.contentType,
);
downloaded++; downloaded++;
controller.add(PeerSyncTileDownloaded( controller.add(PeerSyncTileDownloaded(
downloaded: downloaded, total: total)); downloaded: downloaded, total: total));
@@ -457,9 +478,9 @@ class TileSharingService {
return; return;
} }
// GET /tiles/{hash}/{z}/{x}/{y}.avif → tile bytes // GET /tiles/{hash}/{z}/{x}/{y} → tile bytes
final tilePattern = 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); final tileMatch = tilePattern.firstMatch(path);
if (tileMatch != null) { if (tileMatch != null) {
final styleHash = tileMatch.group(1)!; final styleHash = tileMatch.group(1)!;
@@ -467,12 +488,14 @@ class TileSharingService {
final x = int.parse(tileMatch.group(3)!); final x = int.parse(tileMatch.group(3)!);
final y = int.parse(tileMatch.group(4)!); final y = int.parse(tileMatch.group(4)!);
final bytes = await _cache.getRawTile(styleHash, z, x, y); final tile = await _cache.getTileData(styleHash, z, x, y);
if (bytes != null) { if (tile != null) {
request.response request.response
..statusCode = HttpStatus.ok ..statusCode = HttpStatus.ok
..headers.contentType = ContentType('image', 'avif') ..headers.contentType = tile.contentType == null
..add(bytes); ? ContentType.binary
: ContentType.parse(tile.contentType!)
..add(tile.bytes);
await request.response.close(); await request.response.close();
return; return;
} }

View File

@@ -30,7 +30,6 @@ import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart'; import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/message_airtime_estimator.dart';
import '../../utils/tictactoe_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -92,7 +91,6 @@ class _MessageBubbleState extends State<MessageBubble> {
caseSensitive: false, caseSensitive: false,
); );
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = []; final List<TapGestureRecognizer> _linkRecognizers = [];
@override @override
@@ -106,7 +104,6 @@ class _MessageBubbleState extends State<MessageBubble> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message.id != widget.message.id) { if (oldWidget.message.id != widget.message.id) {
_isExpanded = false; _isExpanded = false;
_showReceivedStats = false;
return; return;
} }
@@ -323,19 +320,6 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.onTap?.call(); widget.onTap?.call();
} }
void _handleBubbleDoubleTap({
required bool isSarMarker,
required bool isDrawing,
}) {
if (widget.isCompact || isSarMarker || isDrawing) {
return;
}
setState(() {
_showReceivedStats = !_showReceivedStats;
});
}
Future<void> _retryFailedMessage( Future<void> _retryFailedMessage(
BuildContext context, BuildContext context,
Message failedMessage, Message failedMessage,
@@ -876,37 +860,7 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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) ...[ if (messageLocationSnapshot != null) ...[
const SizedBox(height: 12),
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.location_on, 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( Widget _detailRow(
BuildContext context, { BuildContext context, {
required String label, required String label,
@@ -2130,25 +2059,6 @@ class _MessageBubbleState extends State<MessageBubble> {
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey); 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); final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name // Look up contact information for rich display name
@@ -2265,10 +2175,9 @@ class _MessageBubbleState extends State<MessageBubble> {
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
isDrawing: message.isDrawing, isDrawing: message.isDrawing,
), ),
onDoubleTap: () => _handleBubbleDoubleTap( onDoubleTap: widget.isCompact
isSarMarker: isSarMarker, ? null
isDrawing: message.isDrawing, : () => _showTechnicalDetails(context),
),
onLongPress: widget.isCompact onLongPress: widget.isCompact
? null ? null
: () => _showMessageOptions(context), : () => _showMessageOptions(context),
@@ -2835,21 +2744,6 @@ class _MessageBubbleState extends State<MessageBubble> {
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)
_buildMessageTextContent(message.text, baseBodyStyle), _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) // Delivery status for sent messages (skip in compact mode)
if (message.isSentMessage && !widget.isCompact) ...[ if (message.isSentMessage && !widget.isCompact) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -3040,129 +2934,81 @@ class _MessageBubbleState extends State<MessageBubble> {
// Show single message delivery status // Show single message delivery status
else if (!message.isChannelMessage || else if (!message.isChannelMessage ||
message.deliveryStatus == MessageDeliveryStatus.failed) message.deliveryStatus == MessageDeliveryStatus.failed)
Builder( Row(
builder: (context) { mainAxisSize: MainAxisSize.max,
final txEstimate = estimateMessageTransmitDuration( children: [
message, if (message.isContactMessage)
radioBw: connectionProvider.deviceInfo.radioBw, _buildDirectMessageStatusIndicator(context, message)
radioSf: connectionProvider.deviceInfo.radioSf, else ...[
radioCr: connectionProvider.deviceInfo.radioCr, Icon(
); getDeliveryStatusIcon(message.deliveryStatus),
final showSentDirectStats = size: 12,
message.isContactMessage && color: getDeliveryStatusColor(
message.deliveryStatus == 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,
),
),
],
),
),
),
],
],
), ),
if (showSentDirectStats) ...[ ),
const SizedBox(height: 6), const SizedBox(width: 3),
buildSentDirectSignalStatus( Expanded(
context, child: Align(
message, alignment: Alignment.centerLeft,
roundTripTimeMs: message.roundTripTimeMs!, child: Text(
txEstimate: txEstimate, 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),
],
], ],
], ],
), ),

View File

@@ -129,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: codec2_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -647,6 +655,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings: gsettings:
dependency: transitive dependency: transitive
description: description:
@@ -655,6 +671,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.8" version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -807,6 +831,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -856,6 +888,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: nested:
dependency: transitive dependency: transitive
description: description:
@@ -1096,6 +1136,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" 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: record:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1144,6 +1192,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" 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: record_web:
dependency: transitive dependency: transitive
description: description:
@@ -1270,7 +1326,7 @@ packages:
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
sqflite: sqflite:
dependency: transitive dependency: "direct main"
description: description:
name: sqflite name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
@@ -1293,6 +1349,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" 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: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -1309,6 +1373,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@@ -96,6 +96,7 @@ dependencies:
# Persistent storage # Persistent storage
shared_preferences: ^2.3.3 shared_preferences: ^2.3.3
sqflite: ^2.4.2
# Package info # Package info
package_info_plus: ^9.0.0 package_info_plus: ^9.0.0
@@ -139,6 +140,7 @@ dev_dependencies:
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.4" flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3 fake_async: ^1.3.3
sqflite_common_ffi: ^2.3.6
dependency_overrides: dependency_overrides:
# Keep local package overrides in pubspec_overrides.yaml so CI uses the # Keep local package overrides in pubspec_overrides.yaml so CI uses the

View File

@@ -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:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_map_caching_provider.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() { 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', () { test('parses query-style tile URLs used by Google layers', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting( final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13', '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}', '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<CachedMapTile?> getTile(String url) async => null;
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) async {}
} }

View File

@@ -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);
});
}

View File

@@ -61,7 +61,7 @@ void main() {
); );
}); });
testWidgets('received bubbles show signal chips on double tap', ( testWidgets('received bubbles open details on double tap', (
tester, tester,
) async { ) async {
final harness = await _TestHarness.create(); final harness = await _TestHarness.create();
@@ -95,17 +95,19 @@ void main() {
expect(find.text('-84'), findsNothing); expect(find.text('-84'), findsNothing);
await _doubleTap(tester, find.text('Inbound message')); await _doubleTap(tester, find.text('Inbound message'));
await tester.pumpAndSettle();
expect(find.text('1 hop'), findsOneWidget); expect(find.text('Message details'), findsOneWidget);
expect(find.text('Fair'), findsOneWidget); expect(find.text('1 hop'), findsNothing);
expect(find.text('-84'), findsOneWidget); expect(find.text('Fair'), findsNothing);
expect(find.text('6.0'), findsOneWidget); expect(find.text('-84'), findsNothing);
expect(find.text('6.0'), findsNothing);
} finally { } finally {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }
}); });
testWidgets('delivered direct bubbles show timing chips on double tap', ( testWidgets('delivered direct bubbles open details on double tap', (
tester, tester,
) async { ) async {
final harness = await _TestHarness.create(); final harness = await _TestHarness.create();
@@ -137,15 +139,17 @@ void main() {
expect(find.text('320ms'), findsNothing); expect(find.text('320ms'), findsNothing);
await _doubleTap(tester, find.text('Outbound message')); await _doubleTap(tester, find.text('Outbound message'));
await tester.pumpAndSettle();
expect(find.text('Direct'), findsOneWidget); expect(find.text('Message details'), findsOneWidget);
expect(find.text('320ms'), findsOneWidget); expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
} finally { } finally {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }
}); });
testWidgets('sent channel bubbles show echo chips on double tap', ( testWidgets('sent channel bubbles open details on double tap', (
tester, tester,
) async { ) async {
final harness = await _TestHarness.create(); final harness = await _TestHarness.create();
@@ -180,10 +184,12 @@ void main() {
expect(find.text('-76'), findsNothing); expect(find.text('-76'), findsNothing);
await _doubleTap(tester, find.text('Broadcast message')); await _doubleTap(tester, find.text('Broadcast message'));
await tester.pumpAndSettle();
expect(find.text('x2'), findsOneWidget); expect(find.text('Message details'), findsOneWidget);
expect(find.text('-76'), findsOneWidget); expect(find.text('x2'), findsNothing);
expect(find.text('5.0'), findsOneWidget); expect(find.text('-76'), findsNothing);
expect(find.text('5.0'), findsNothing);
} finally { } finally {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }