mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Modernize map selection and tile cache
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
74
pubspec.lock
74
pubspec.lock
@@ -129,6 +129,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
codec2_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -647,6 +655,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gsettings:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -655,6 +671,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -807,6 +831,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -856,6 +888,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1096,6 +1136,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1144,6 +1192,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
record_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1270,7 +1326,7 @@ packages:
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
@@ -1293,6 +1349,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0+3"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1309,6 +1373,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -96,6 +96,7 @@ dependencies:
|
||||
|
||||
# Persistent storage
|
||||
shared_preferences: ^2.3.3
|
||||
sqflite: ^2.4.2
|
||||
|
||||
# Package info
|
||||
package_info_plus: ^9.0.0
|
||||
@@ -139,6 +140,7 @@ dev_dependencies:
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: "^0.14.4"
|
||||
fake_async: ^1.3.3
|
||||
sqflite_common_ffi: ^2.3.6
|
||||
|
||||
dependency_overrides:
|
||||
# Keep local package overrides in pubspec_overrides.yaml so CI uses the
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
final cache = OfflineTileCacheService.instance;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_');
|
||||
await cache.resetForTesting();
|
||||
cache.setBaseDirForTesting('${tempDir.path}/tiles');
|
||||
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cache.resetForTesting();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('parses query-style tile URLs used by Google layers', () {
|
||||
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
|
||||
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
|
||||
@@ -35,4 +63,48 @@ void main() {
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns cached raw bytes directly', () async {
|
||||
const url =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267';
|
||||
final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url);
|
||||
final styleHash = cache.styleHashFromUrl(template);
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
|
||||
await cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: 'Esri',
|
||||
urlTemplate: template,
|
||||
);
|
||||
await cache.putTile(
|
||||
styleHash,
|
||||
9,
|
||||
267,
|
||||
173,
|
||||
bytes,
|
||||
contentType: 'image/jpeg',
|
||||
sourceUrl: url,
|
||||
);
|
||||
|
||||
final provider = OfflineMapCachingProvider(_NullMapCachingProvider());
|
||||
final tile = await provider.getTile(url);
|
||||
|
||||
expect(tile, isNotNull);
|
||||
expect(tile!.bytes, bytes);
|
||||
});
|
||||
}
|
||||
|
||||
class _NullMapCachingProvider implements MapCachingProvider {
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async => null;
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
118
test/services/offline_tile_cache_service_test.dart
Normal file
118
test/services/offline_tile_cache_service_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -61,7 +61,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('received bubbles show signal chips on double tap', (
|
||||
testWidgets('received bubbles open details on double tap', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _TestHarness.create();
|
||||
@@ -95,17 +95,19 @@ void main() {
|
||||
expect(find.text('-84'), findsNothing);
|
||||
|
||||
await _doubleTap(tester, find.text('Inbound message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 hop'), findsOneWidget);
|
||||
expect(find.text('Fair'), findsOneWidget);
|
||||
expect(find.text('-84'), findsOneWidget);
|
||||
expect(find.text('6.0'), findsOneWidget);
|
||||
expect(find.text('Message details'), findsOneWidget);
|
||||
expect(find.text('1 hop'), findsNothing);
|
||||
expect(find.text('Fair'), findsNothing);
|
||||
expect(find.text('-84'), findsNothing);
|
||||
expect(find.text('6.0'), findsNothing);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('delivered direct bubbles show timing chips on double tap', (
|
||||
testWidgets('delivered direct bubbles open details on double tap', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _TestHarness.create();
|
||||
@@ -137,15 +139,17 @@ void main() {
|
||||
expect(find.text('320ms'), findsNothing);
|
||||
|
||||
await _doubleTap(tester, find.text('Outbound message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Direct'), findsOneWidget);
|
||||
expect(find.text('320ms'), findsOneWidget);
|
||||
expect(find.text('Message details'), findsOneWidget);
|
||||
expect(find.text('Direct'), findsNothing);
|
||||
expect(find.text('320ms'), findsNothing);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('sent channel bubbles show echo chips on double tap', (
|
||||
testWidgets('sent channel bubbles open details on double tap', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _TestHarness.create();
|
||||
@@ -180,10 +184,12 @@ void main() {
|
||||
expect(find.text('-76'), findsNothing);
|
||||
|
||||
await _doubleTap(tester, find.text('Broadcast message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('x2'), findsOneWidget);
|
||||
expect(find.text('-76'), findsOneWidget);
|
||||
expect(find.text('5.0'), findsOneWidget);
|
||||
expect(find.text('Message details'), findsOneWidget);
|
||||
expect(find.text('x2'), findsNothing);
|
||||
expect(find.text('-76'), findsNothing);
|
||||
expect(find.text('5.0'), findsNothing);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user