mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
feat: Add support for offline vector maps using MBTiles
- Introduced new MapLayerType for vector MBTiles. - Enhanced MapLayer class to handle vector-specific properties. - Implemented MBTilesService for managing MBTiles files, including import and deletion functionalities. - Updated MapManagementScreen to allow importing and managing MBTiles files. - Added UI components for displaying and interacting with MBTiles layers. - Integrated vector tile rendering in MapTab, supporting dynamic theme loading. - Updated pubspec.yaml to include necessary dependencies for MBTiles and vector tiles.
This commit is contained in:
@@ -1786,5 +1786,95 @@
|
||||
"you": "You",
|
||||
"@you": {
|
||||
"description": "Label for the current user in message bubbles"
|
||||
},
|
||||
|
||||
"offlineVectorMaps": "Offline Vector Maps",
|
||||
"@offlineVectorMaps": {
|
||||
"description": "Title for offline vector maps section"
|
||||
},
|
||||
|
||||
"offlineVectorMapsDescription": "Import and manage offline vector map tiles (MBTiles format) for use without internet connection",
|
||||
"@offlineVectorMapsDescription": {
|
||||
"description": "Description for offline vector maps section"
|
||||
},
|
||||
|
||||
"importMbtiles": "Import MBTiles File",
|
||||
"@importMbtiles": {
|
||||
"description": "Button to import MBTiles file"
|
||||
},
|
||||
|
||||
"importMbtilesNote": "Supports MBTiles files with vector tiles (PBF/MVT format). Geofabrik extracts work great!",
|
||||
"@importMbtilesNote": {
|
||||
"description": "Note about supported MBTiles file types"
|
||||
},
|
||||
|
||||
"noMbtilesFiles": "No offline vector maps found",
|
||||
"@noMbtilesFiles": {
|
||||
"description": "Message when no MBTiles files are available"
|
||||
},
|
||||
|
||||
"mbtilesImportedSuccessfully": "MBTiles file imported successfully",
|
||||
"@mbtilesImportedSuccessfully": {
|
||||
"description": "Success message after importing MBTiles file"
|
||||
},
|
||||
|
||||
"failedToImportMbtiles": "Failed to import MBTiles file",
|
||||
"@failedToImportMbtiles": {
|
||||
"description": "Error message when MBTiles import fails"
|
||||
},
|
||||
|
||||
"deleteMbtilesConfirmTitle": "Delete Offline Map",
|
||||
"@deleteMbtilesConfirmTitle": {
|
||||
"description": "Title for delete MBTiles confirmation dialog"
|
||||
},
|
||||
|
||||
"deleteMbtilesConfirmMessage": "Are you sure you want to delete \"{name}\"? This will permanently remove the offline map.",
|
||||
"@deleteMbtilesConfirmMessage": {
|
||||
"description": "Confirmation message for deleting MBTiles file",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"mbtilesDeletedSuccessfully": "Offline map deleted successfully",
|
||||
"@mbtilesDeletedSuccessfully": {
|
||||
"description": "Success message after deleting MBTiles file"
|
||||
},
|
||||
|
||||
"failedToDeleteMbtiles": "Failed to delete offline map",
|
||||
"@failedToDeleteMbtiles": {
|
||||
"description": "Error message when MBTiles deletion fails"
|
||||
},
|
||||
|
||||
"vectorTiles": "Vector Tiles",
|
||||
"@vectorTiles": {
|
||||
"description": "Label for vector tile type"
|
||||
},
|
||||
|
||||
"schema": "Schema",
|
||||
"@schema": {
|
||||
"description": "Label for vector tile schema"
|
||||
},
|
||||
|
||||
"unknown": "Unknown",
|
||||
"@unknown": {
|
||||
"description": "Unknown value label"
|
||||
},
|
||||
|
||||
"bounds": "Bounds",
|
||||
"@bounds": {
|
||||
"description": "Label for geographic bounds"
|
||||
},
|
||||
|
||||
"onlineLayers": "Online Layers",
|
||||
"@onlineLayers": {
|
||||
"description": "Section header for online map layers"
|
||||
},
|
||||
|
||||
"offlineLayers": "Offline Layers",
|
||||
"@offlineLayers": {
|
||||
"description": "Section header for offline map layers (MBTiles)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,5 +594,39 @@
|
||||
"txPowerDbm": "TX snaga (dBm)",
|
||||
"maxPowerDbm": "Maks: {power} dBm",
|
||||
|
||||
"you": "Ti"
|
||||
"you": "Ti",
|
||||
|
||||
"offlineVectorMaps": "Offline vektorske karte",
|
||||
|
||||
"offlineVectorMapsDescription": "Uvezite i upravljajte offline vektorskim pločicama karata (MBTiles format) za upotrebu bez internetske veze",
|
||||
|
||||
"importMbtiles": "Uvezi MBTiles datoteku",
|
||||
|
||||
"importMbtilesNote": "Podržava MBTiles datoteke s vektorskim pločicama (PBF/MVT format). Geofabrik izvodi odlično rade!",
|
||||
|
||||
"noMbtilesFiles": "Nisu pronađene offline vektorske karte",
|
||||
|
||||
"mbtilesImportedSuccessfully": "MBTiles datoteka uspješno uvezena",
|
||||
|
||||
"failedToImportMbtiles": "Neuspjeli uvoz MBTiles datoteke",
|
||||
|
||||
"deleteMbtilesConfirmTitle": "Izbriši offline kartu",
|
||||
|
||||
"deleteMbtilesConfirmMessage": "Jeste li sigurni da želite izbrisati \"{name}\"? Ovo će trajno ukloniti offline kartu.",
|
||||
|
||||
"mbtilesDeletedSuccessfully": "Offline karta uspješno izbrisana",
|
||||
|
||||
"failedToDeleteMbtiles": "Neuspjelo brisanje offline karte",
|
||||
|
||||
"vectorTiles": "Vektorske pločice",
|
||||
|
||||
"schema": "Shema",
|
||||
|
||||
"unknown": "Nepoznato",
|
||||
|
||||
"bounds": "Granice",
|
||||
|
||||
"onlineLayers": "Mrežni slojevi",
|
||||
|
||||
"offlineLayers": "Offline slojevi"
|
||||
}
|
||||
|
||||
@@ -1910,6 +1910,108 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'You'**
|
||||
String get you;
|
||||
|
||||
/// Title for offline vector maps section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline Vector Maps'**
|
||||
String get offlineVectorMaps;
|
||||
|
||||
/// Description for offline vector maps section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import and manage offline vector map tiles (MBTiles format) for use without internet connection'**
|
||||
String get offlineVectorMapsDescription;
|
||||
|
||||
/// Button to import MBTiles file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import MBTiles File'**
|
||||
String get importMbtiles;
|
||||
|
||||
/// Note about supported MBTiles file types
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Supports MBTiles files with vector tiles (PBF/MVT format). Geofabrik extracts work great!'**
|
||||
String get importMbtilesNote;
|
||||
|
||||
/// Message when no MBTiles files are available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No offline vector maps found'**
|
||||
String get noMbtilesFiles;
|
||||
|
||||
/// Success message after importing MBTiles file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MBTiles file imported successfully'**
|
||||
String get mbtilesImportedSuccessfully;
|
||||
|
||||
/// Error message when MBTiles import fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to import MBTiles file'**
|
||||
String get failedToImportMbtiles;
|
||||
|
||||
/// Title for delete MBTiles confirmation dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete Offline Map'**
|
||||
String get deleteMbtilesConfirmTitle;
|
||||
|
||||
/// Confirmation message for deleting MBTiles file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to delete \"{name}\"? This will permanently remove the offline map.'**
|
||||
String deleteMbtilesConfirmMessage(String name);
|
||||
|
||||
/// Success message after deleting MBTiles file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline map deleted successfully'**
|
||||
String get mbtilesDeletedSuccessfully;
|
||||
|
||||
/// Error message when MBTiles deletion fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to delete offline map'**
|
||||
String get failedToDeleteMbtiles;
|
||||
|
||||
/// Label for vector tile type
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vector Tiles'**
|
||||
String get vectorTiles;
|
||||
|
||||
/// Label for vector tile schema
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Schema'**
|
||||
String get schema;
|
||||
|
||||
/// Unknown value label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown'**
|
||||
String get unknown;
|
||||
|
||||
/// Label for geographic bounds
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bounds'**
|
||||
String get bounds;
|
||||
|
||||
/// Section header for online map layers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Online Layers'**
|
||||
String get onlineLayers;
|
||||
|
||||
/// Section header for offline map layers (MBTiles)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline Layers'**
|
||||
String get offlineLayers;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1041,4 +1041,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get you => 'You';
|
||||
|
||||
@override
|
||||
String get offlineVectorMaps => 'Offline Vector Maps';
|
||||
|
||||
@override
|
||||
String get offlineVectorMapsDescription =>
|
||||
'Import and manage offline vector map tiles (MBTiles format) for use without internet connection';
|
||||
|
||||
@override
|
||||
String get importMbtiles => 'Import MBTiles File';
|
||||
|
||||
@override
|
||||
String get importMbtilesNote =>
|
||||
'Supports MBTiles files with vector tiles (PBF/MVT format). Geofabrik extracts work great!';
|
||||
|
||||
@override
|
||||
String get noMbtilesFiles => 'No offline vector maps found';
|
||||
|
||||
@override
|
||||
String get mbtilesImportedSuccessfully =>
|
||||
'MBTiles file imported successfully';
|
||||
|
||||
@override
|
||||
String get failedToImportMbtiles => 'Failed to import MBTiles file';
|
||||
|
||||
@override
|
||||
String get deleteMbtilesConfirmTitle => 'Delete Offline Map';
|
||||
|
||||
@override
|
||||
String deleteMbtilesConfirmMessage(String name) {
|
||||
return 'Are you sure you want to delete \"$name\"? This will permanently remove the offline map.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get mbtilesDeletedSuccessfully => 'Offline map deleted successfully';
|
||||
|
||||
@override
|
||||
String get failedToDeleteMbtiles => 'Failed to delete offline map';
|
||||
|
||||
@override
|
||||
String get vectorTiles => 'Vector Tiles';
|
||||
|
||||
@override
|
||||
String get schema => 'Schema';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get bounds => 'Bounds';
|
||||
|
||||
@override
|
||||
String get onlineLayers => 'Online Layers';
|
||||
|
||||
@override
|
||||
String get offlineLayers => 'Offline Layers';
|
||||
}
|
||||
|
||||
@@ -1043,4 +1043,59 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get you => 'Ti';
|
||||
|
||||
@override
|
||||
String get offlineVectorMaps => 'Offline vektorske karte';
|
||||
|
||||
@override
|
||||
String get offlineVectorMapsDescription =>
|
||||
'Uvezite i upravljajte offline vektorskim pločicama karata (MBTiles format) za upotrebu bez internetske veze';
|
||||
|
||||
@override
|
||||
String get importMbtiles => 'Uvezi MBTiles datoteku';
|
||||
|
||||
@override
|
||||
String get importMbtilesNote =>
|
||||
'Podržava MBTiles datoteke s vektorskim pločicama (PBF/MVT format). Geofabrik izvodi odlično rade!';
|
||||
|
||||
@override
|
||||
String get noMbtilesFiles => 'Nisu pronađene offline vektorske karte';
|
||||
|
||||
@override
|
||||
String get mbtilesImportedSuccessfully => 'MBTiles datoteka uspješno uvezena';
|
||||
|
||||
@override
|
||||
String get failedToImportMbtiles => 'Neuspjeli uvoz MBTiles datoteke';
|
||||
|
||||
@override
|
||||
String get deleteMbtilesConfirmTitle => 'Izbriši offline kartu';
|
||||
|
||||
@override
|
||||
String deleteMbtilesConfirmMessage(String name) {
|
||||
return 'Jeste li sigurni da želite izbrisati \"$name\"? Ovo će trajno ukloniti offline kartu.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get mbtilesDeletedSuccessfully => 'Offline karta uspješno izbrisana';
|
||||
|
||||
@override
|
||||
String get failedToDeleteMbtiles => 'Neuspjelo brisanje offline karte';
|
||||
|
||||
@override
|
||||
String get vectorTiles => 'Vektorske pločice';
|
||||
|
||||
@override
|
||||
String get schema => 'Shema';
|
||||
|
||||
@override
|
||||
String get unknown => 'Nepoznato';
|
||||
|
||||
@override
|
||||
String get bounds => 'Granice';
|
||||
|
||||
@override
|
||||
String get onlineLayers => 'Mrežni slojevi';
|
||||
|
||||
@override
|
||||
String get offlineLayers => 'Offline slojevi';
|
||||
}
|
||||
|
||||
@@ -1043,4 +1043,62 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get you => 'Ti';
|
||||
|
||||
@override
|
||||
String get offlineVectorMaps => 'Brezpovezni vektorski zemljevidi';
|
||||
|
||||
@override
|
||||
String get offlineVectorMapsDescription =>
|
||||
'Uvozite in upravljajte brezpovezne vektorske ploščice zemljevidov (format MBTiles) za uporabo brez internetne povezave';
|
||||
|
||||
@override
|
||||
String get importMbtiles => 'Uvozi MBTiles datoteko';
|
||||
|
||||
@override
|
||||
String get importMbtilesNote =>
|
||||
'Podpira MBTiles datoteke z vektorskimi ploščicami (format PBF/MVT). Geofabrik izvozi odlično delujejo!';
|
||||
|
||||
@override
|
||||
String get noMbtilesFiles =>
|
||||
'Ni najdenih brezpoveznih vektorskih zemljevidov';
|
||||
|
||||
@override
|
||||
String get mbtilesImportedSuccessfully => 'MBTiles datoteka uspešno uvožena';
|
||||
|
||||
@override
|
||||
String get failedToImportMbtiles => 'Uvoz MBTiles datoteke ni uspel';
|
||||
|
||||
@override
|
||||
String get deleteMbtilesConfirmTitle => 'Izbriši brezpovezni zemljevid';
|
||||
|
||||
@override
|
||||
String deleteMbtilesConfirmMessage(String name) {
|
||||
return 'Ste prepričani, da želite izbrisati \"$name\"? To bo trajno odstranilo brezpovezni zemljevid.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get mbtilesDeletedSuccessfully =>
|
||||
'Brezpovezni zemljevid uspešno izbrisan';
|
||||
|
||||
@override
|
||||
String get failedToDeleteMbtiles =>
|
||||
'Brisanje brezpoveznega zemljevida ni uspelo';
|
||||
|
||||
@override
|
||||
String get vectorTiles => 'Vektorske ploščice';
|
||||
|
||||
@override
|
||||
String get schema => 'Shema';
|
||||
|
||||
@override
|
||||
String get unknown => 'Neznano';
|
||||
|
||||
@override
|
||||
String get bounds => 'Meje';
|
||||
|
||||
@override
|
||||
String get onlineLayers => 'Spletne plasti';
|
||||
|
||||
@override
|
||||
String get offlineLayers => 'Brezpovezne plasti';
|
||||
}
|
||||
|
||||
@@ -594,5 +594,39 @@
|
||||
"txPowerDbm": "Izhodna moč (dBm)",
|
||||
"maxPowerDbm": "Največ: {power} dBm",
|
||||
|
||||
"you": "Ti"
|
||||
"you": "Ti",
|
||||
|
||||
"offlineVectorMaps": "Brezpovezni vektorski zemljevidi",
|
||||
|
||||
"offlineVectorMapsDescription": "Uvozite in upravljajte brezpovezne vektorske ploščice zemljevidov (format MBTiles) za uporabo brez internetne povezave",
|
||||
|
||||
"importMbtiles": "Uvozi MBTiles datoteko",
|
||||
|
||||
"importMbtilesNote": "Podpira MBTiles datoteke z vektorskimi ploščicami (format PBF/MVT). Geofabrik izvozi odlično delujejo!",
|
||||
|
||||
"noMbtilesFiles": "Ni najdenih brezpoveznih vektorskih zemljevidov",
|
||||
|
||||
"mbtilesImportedSuccessfully": "MBTiles datoteka uspešno uvožena",
|
||||
|
||||
"failedToImportMbtiles": "Uvoz MBTiles datoteke ni uspel",
|
||||
|
||||
"deleteMbtilesConfirmTitle": "Izbriši brezpovezni zemljevid",
|
||||
|
||||
"deleteMbtilesConfirmMessage": "Ste prepričani, da želite izbrisati \"{name}\"? To bo trajno odstranilo brezpovezni zemljevid.",
|
||||
|
||||
"mbtilesDeletedSuccessfully": "Brezpovezni zemljevid uspešno izbrisan",
|
||||
|
||||
"failedToDeleteMbtiles": "Brisanje brezpoveznega zemljevida ni uspelo",
|
||||
|
||||
"vectorTiles": "Vektorske ploščice",
|
||||
|
||||
"schema": "Shema",
|
||||
|
||||
"unknown": "Neznano",
|
||||
|
||||
"bounds": "Meje",
|
||||
|
||||
"onlineLayers": "Spletne plasti",
|
||||
|
||||
"offlineLayers": "Brezpovezne plasti"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -5,6 +6,7 @@ enum MapLayerType {
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
vectorMbtiles,
|
||||
}
|
||||
|
||||
class MapLayer {
|
||||
@@ -14,12 +16,24 @@ class MapLayer {
|
||||
final String attribution;
|
||||
final double maxZoom;
|
||||
|
||||
// Vector tile specific properties
|
||||
final bool isVector;
|
||||
final File? mbtilesFile;
|
||||
final String? styleUrl;
|
||||
final String? sourceName;
|
||||
final bool? isGzipped;
|
||||
|
||||
const MapLayer({
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
required this.maxZoom,
|
||||
this.isVector = false,
|
||||
this.mbtilesFile,
|
||||
this.styleUrl,
|
||||
this.sourceName,
|
||||
this.isGzipped,
|
||||
});
|
||||
|
||||
/// Get localized name for the layer
|
||||
@@ -32,6 +46,9 @@ class MapLayer {
|
||||
return localizations.openTopoMap;
|
||||
case MapLayerType.esriWorldImagery:
|
||||
return localizations.esriSatellite;
|
||||
case MapLayerType.vectorMbtiles:
|
||||
// For vector tiles, use the name from metadata
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +86,28 @@ class MapLayer {
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
return allLayers.firstWhere((layer) => layer.type == type);
|
||||
}
|
||||
|
||||
/// Create a MapLayer from an MBTiles file
|
||||
static MapLayer fromMbtilesFile({
|
||||
required String name,
|
||||
required File mbtilesFile,
|
||||
required String styleUrl,
|
||||
required String sourceName,
|
||||
required double maxZoom,
|
||||
required bool isGzipped,
|
||||
String? attribution,
|
||||
}) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.vectorMbtiles,
|
||||
name: name,
|
||||
urlTemplate: '', // Not used for vector tiles
|
||||
attribution: attribution ?? 'MBTiles',
|
||||
maxZoom: maxZoom,
|
||||
isVector: true,
|
||||
mbtilesFile: mbtilesFile,
|
||||
styleUrl: styleUrl,
|
||||
sourceName: sourceName,
|
||||
isGzipped: isGzipped,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../services/validation_service.dart';
|
||||
import '../services/mbtiles_service.dart';
|
||||
import '../models/map_layer.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -28,6 +31,8 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _statusMessage;
|
||||
Map<String, dynamic>? _cacheStats;
|
||||
final MbtilesService _mbtilesService = MbtilesService();
|
||||
List<MbtilesMetadata> _mbtilesFiles = [];
|
||||
|
||||
// Download parameters
|
||||
late MapLayer _selectedLayer;
|
||||
@@ -77,6 +82,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
}
|
||||
|
||||
_loadCacheStats();
|
||||
_loadMbtilesFiles();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,6 +113,111 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMbtilesFiles() async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final files = await _mbtilesService.getAllMetadata();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_mbtilesFiles = files;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error loading MBTiles files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importMbtilesFile() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['mbtiles'],
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final sourcePath = result.files.first.path;
|
||||
if (sourcePath == null) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final importedFile = await _mbtilesService.importMbtilesFile(sourcePath);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (importedFile != null) {
|
||||
await _loadMbtilesFiles();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.mbtilesImportedSuccessfully),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_showError(AppLocalizations.of(context)!.failedToImportMbtiles);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
_showError('${AppLocalizations.of(context)!.failedToImportMbtiles}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteMbtilesFile(MbtilesMetadata metadata) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.deleteMbtilesConfirmTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.deleteMbtilesConfirmMessage(metadata.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(AppLocalizations.of(context)!.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final success = await _mbtilesService.deleteMbtilesFile(metadata.file);
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (success) {
|
||||
await _loadMbtilesFiles();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.mbtilesDeletedSuccessfully),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_showError(AppLocalizations.of(context)!.failedToDeleteMbtiles);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
_showError('${AppLocalizations.of(context)!.failedToDeleteMbtiles}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadRegion() async {
|
||||
final validator = ValidationService();
|
||||
|
||||
@@ -309,6 +420,10 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
_buildStatisticsCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Offline Vector Maps (MBTiles)
|
||||
_buildMbtilesCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Download Region
|
||||
_buildDownloadCard(),
|
||||
const SizedBox(height: 16),
|
||||
@@ -382,6 +497,172 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMbtilesCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.offlineVectorMaps,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadMbtilesFiles,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.offlineVectorMapsDescription,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// List of MBTiles files
|
||||
if (_mbtilesFiles.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.map_outlined, size: 48, color: Colors.grey[400]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMbtilesFiles,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._mbtilesFiles.map((metadata) => Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
leading: Icon(
|
||||
metadata.isVector ? Icons.layers : Icons.image,
|
||||
color: metadata.isVector ? Colors.blue : Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
metadata.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${metadata.fileSizeFormatted} • ${metadata.format?.toUpperCase() ?? "Unknown"}',
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (metadata.description != null) ...[
|
||||
Text(
|
||||
metadata.description!,
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.zoomLevels,
|
||||
'${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}',
|
||||
),
|
||||
if (metadata.bounds != null)
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.bounds,
|
||||
metadata.bounds!,
|
||||
),
|
||||
if (metadata.isVector) ...[
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.type,
|
||||
AppLocalizations.of(context)!.vectorTiles,
|
||||
),
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.schema,
|
||||
_mbtilesService.getVectorSchema(metadata) ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _deleteMbtilesFile(metadata),
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Import button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _importMbtilesFile,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: Text(AppLocalizations.of(context)!.importMbtiles),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.importMbtilesNote,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(color: Colors.grey[800]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -559,13 +840,17 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_statusMessage ?? AppLocalizations.of(context)!.downloadingDots,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
Expanded(
|
||||
child: Text(
|
||||
_statusMessage ?? AppLocalizations.of(context)!.downloadingDots,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_downloadProgress.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_compass/flutter_compass.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
import 'package:vector_tile_renderer/vector_tile_renderer.dart' as vtr;
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
@@ -21,6 +26,7 @@ import '../services/tile_cache_service.dart';
|
||||
import '../services/background_location_service.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/mbtiles_service.dart';
|
||||
import '../widgets/map_debug_info.dart';
|
||||
import '../widgets/map/map_legend.dart';
|
||||
import '../widgets/map/compass_widget.dart';
|
||||
@@ -61,6 +67,13 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
StreamSubscription<CompassEvent>? _compassStreamSubscription;
|
||||
final BackgroundLocationService _backgroundLocationService = BackgroundLocationService();
|
||||
|
||||
// MBTiles layers
|
||||
List<MapLayer> _mbtilesLayers = [];
|
||||
|
||||
// Vector tile theme
|
||||
vtr.Theme? _vectorTheme;
|
||||
bool _isLoadingTheme = false;
|
||||
|
||||
// Dropped pin state
|
||||
LatLng? _droppedPinLocation;
|
||||
bool _isDraggingPin = false;
|
||||
@@ -81,6 +94,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadMbtilesLayers();
|
||||
_initializeTileCache();
|
||||
_initLocationTracking();
|
||||
_startCompassTracking();
|
||||
@@ -172,6 +186,39 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Load MBTiles layers from file system
|
||||
Future<void> _loadMbtilesLayers() async {
|
||||
try {
|
||||
final mbtilesService = MbtilesService();
|
||||
final metadata = await mbtilesService.getAllMetadata();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mbtilesLayers = metadata.map((meta) {
|
||||
// Determine if data is gzipped (for Geofabrik files)
|
||||
final isGzipped = meta.format == 'pbf';
|
||||
|
||||
return MapLayer.fromMbtilesFile(
|
||||
name: meta.name,
|
||||
mbtilesFile: meta.file,
|
||||
styleUrl: 'https://tiles.openfreemap.org/styles/bright',
|
||||
sourceName: 'openmaptiles',
|
||||
maxZoom: 20.0, // Override to 20 for overzooming
|
||||
isGzipped: isGzipped,
|
||||
attribution: meta.attribution,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
debugPrint('Loaded ${_mbtilesLayers.length} MBTiles layers');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading MBTiles layers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all available layers (default + MBTiles)
|
||||
List<MapLayer> get _allLayers => [...MapLayer.allLayers, ..._mbtilesLayers];
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
@@ -180,8 +227,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
final lastLon = prefs.getDouble('map_last_longitude');
|
||||
final lastZoom = prefs.getDouble('map_last_zoom');
|
||||
|
||||
// Load last map layer if available
|
||||
final lastLayerIndex = prefs.getInt('map_last_layer');
|
||||
// Load last map layer
|
||||
final lastLayerType = prefs.getInt('map_last_layer_type');
|
||||
final lastLayerName = prefs.getString('map_last_layer_name');
|
||||
|
||||
setState(() {
|
||||
_showLegend = prefs.getBool('map_show_legend') ?? false;
|
||||
@@ -202,9 +250,23 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
_savedMapZoom = lastZoom;
|
||||
}
|
||||
|
||||
// Restore last used map layer
|
||||
if (lastLayerIndex != null && lastLayerIndex >= 0 && lastLayerIndex < MapLayer.allLayers.length) {
|
||||
_currentLayer = MapLayer.allLayers[lastLayerIndex];
|
||||
// Restore last used map layer (by type and name for MBTiles)
|
||||
if (lastLayerType != null) {
|
||||
final layerType = MapLayerType.values[lastLayerType];
|
||||
if (layerType == MapLayerType.vectorMbtiles && lastLayerName != null) {
|
||||
// Find MBTiles layer by name
|
||||
final mbtilesLayer = _mbtilesLayers.firstWhere(
|
||||
(layer) => layer.name == lastLayerName,
|
||||
orElse: () => MapLayer.openStreetMap,
|
||||
);
|
||||
_currentLayer = mbtilesLayer;
|
||||
} else {
|
||||
// Use default layer
|
||||
_currentLayer = MapLayer.allLayers.firstWhere(
|
||||
(layer) => layer.type == layerType,
|
||||
orElse: () => MapLayer.openStreetMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -218,7 +280,12 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
await prefs.setBool('map_fullscreen', _isFullscreen);
|
||||
await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance);
|
||||
await prefs.setBool('background_tracking_enabled', _backgroundTrackingEnabled);
|
||||
await prefs.setInt('map_last_layer', MapLayer.allLayers.indexOf(_currentLayer));
|
||||
|
||||
// Save layer type and name (for MBTiles layers)
|
||||
await prefs.setInt('map_last_layer_type', _currentLayer.type.index);
|
||||
if (_currentLayer.type == MapLayerType.vectorMbtiles) {
|
||||
await prefs.setString('map_last_layer_name', _currentLayer.name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveMapPosition() async {
|
||||
@@ -344,6 +411,45 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Load vector tile theme from URL
|
||||
Future<void> _loadVectorTheme(String styleUrl) async {
|
||||
if (_isLoadingTheme) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingTheme = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await http.get(Uri.parse(styleUrl));
|
||||
if (response.statusCode == 200) {
|
||||
final styleJson = jsonDecode(response.body) as Map<String, Object?>;
|
||||
final theme = vtr.ThemeReader().read(styleJson);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_vectorTheme = theme;
|
||||
_isLoadingTheme = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw Exception('Failed to load style: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading vector theme: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingTheme = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to load map style: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showLayerSelector(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -378,20 +484,76 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
...MapLayer.allLayers.map((layer) => ListTile(
|
||||
leading: _currentLayer.type == layer.type
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: const Icon(Icons.radio_button_unchecked),
|
||||
title: Text(layer.getLocalizedName(context)),
|
||||
subtitle: Text(layer.attribution),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentLayer = layer;
|
||||
});
|
||||
_saveSettings();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
)),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
// Online layers section
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.onlineLayers,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
...MapLayer.allLayers.map((layer) => ListTile(
|
||||
leading: _currentLayer == layer
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: const Icon(Icons.radio_button_unchecked),
|
||||
title: Text(layer.getLocalizedName(context)),
|
||||
subtitle: Text(layer.attribution),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_currentLayer = layer;
|
||||
});
|
||||
_saveSettings();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
)),
|
||||
// Offline MBTiles layers section
|
||||
if (_mbtilesLayers.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.offlineLayers,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
..._mbtilesLayers.map((layer) => ListTile(
|
||||
leading: _currentLayer == layer
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: Icon(
|
||||
layer.isVector ? Icons.layers : Icons.image,
|
||||
color: layer.isVector ? Colors.blue : Colors.orange,
|
||||
),
|
||||
title: Text(layer.name),
|
||||
subtitle: Text(layer.attribution),
|
||||
onTap: () async {
|
||||
// Load vector theme if switching to vector layer
|
||||
if (layer.isVector && layer.styleUrl != null) {
|
||||
Navigator.pop(context);
|
||||
await _loadVectorTheme(layer.styleUrl!);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentLayer = layer;
|
||||
});
|
||||
_saveSettings();
|
||||
|
||||
if (!layer.isVector) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -946,12 +1108,23 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: _currentLayer.urlTemplate,
|
||||
tileProvider: _tileCache.getTileProvider(_currentLayer),
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
maxZoom: _currentLayer.maxZoom,
|
||||
),
|
||||
// Render vector or raster tile layer based on layer type
|
||||
if (_currentLayer.isVector && _vectorTheme != null)
|
||||
VectorTileLayer(
|
||||
theme: _vectorTheme!,
|
||||
tileProviders: TileProviders({
|
||||
_currentLayer.sourceName ?? 'default':
|
||||
_tileCache.getVectorTileProvider(_currentLayer)!,
|
||||
}),
|
||||
maximumZoom: _currentLayer.maxZoom,
|
||||
)
|
||||
else if (!_currentLayer.isVector)
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate: _currentLayer.urlTemplate,
|
||||
tileProvider: _tileCache.getTileProvider(_currentLayer),
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
maxZoom: _currentLayer.maxZoom,
|
||||
),
|
||||
// Advertisement path polylines (rendered before markers)
|
||||
Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, _) {
|
||||
|
||||
280
lib/services/mbtiles_service.dart
Normal file
280
lib/services/mbtiles_service.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:mbtiles/mbtiles.dart';
|
||||
|
||||
/// Metadata information extracted from an MBTiles file
|
||||
class MbtilesMetadata {
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? version;
|
||||
final String? attribution;
|
||||
final String? bounds; // "minLon,minLat,maxLon,maxLat"
|
||||
final String? center; // "lon,lat,zoom"
|
||||
final int? minZoom;
|
||||
final int? maxZoom;
|
||||
final String? format; // "pbf", "png", "jpg", etc.
|
||||
final String? type; // "overlay", "baselayer"
|
||||
final String? json; // Additional metadata JSON
|
||||
final File file;
|
||||
final int fileSize;
|
||||
|
||||
const MbtilesMetadata({
|
||||
required this.name,
|
||||
this.description,
|
||||
this.version,
|
||||
this.attribution,
|
||||
this.bounds,
|
||||
this.center,
|
||||
this.minZoom,
|
||||
this.maxZoom,
|
||||
this.format,
|
||||
this.type,
|
||||
this.json,
|
||||
required this.file,
|
||||
required this.fileSize,
|
||||
});
|
||||
|
||||
/// Check if this is a vector tile MBTiles file
|
||||
bool get isVector => format == 'pbf' || format == 'mvt';
|
||||
|
||||
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
|
||||
List<double>? get boundsCoordinates {
|
||||
if (bounds == null) return null;
|
||||
try {
|
||||
final parts = bounds!.split(',');
|
||||
if (parts.length != 4) return null;
|
||||
return parts.map((s) => double.parse(s.trim())).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing bounds: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse center string into [lon, lat, zoom]
|
||||
List<double>? get centerCoordinates {
|
||||
if (center == null) return null;
|
||||
try {
|
||||
final parts = center!.split(',');
|
||||
if (parts.length < 2) return null;
|
||||
return parts.map((s) => double.parse(s.trim())).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing center: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get file size in human-readable format
|
||||
String get fileSizeFormatted {
|
||||
if (fileSize < 1024) {
|
||||
return '$fileSize B';
|
||||
} else if (fileSize < 1024 * 1024) {
|
||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
||||
} else if (fileSize < 1024 * 1024 * 1024) {
|
||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else {
|
||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing MBTiles files for offline vector maps
|
||||
class MbtilesService {
|
||||
static const String _mbtilesDirectory = 'offline_maps';
|
||||
|
||||
/// Get the directory where MBTiles files are stored
|
||||
Future<Directory> getMbtilesDirectory() async {
|
||||
final appDocDir = await getApplicationDocumentsDirectory();
|
||||
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!await mbtilesDir.exists()) {
|
||||
await mbtilesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
return mbtilesDir;
|
||||
}
|
||||
|
||||
/// List all MBTiles files in the offline maps directory
|
||||
Future<List<File>> listMbtilesFiles() async {
|
||||
final dir = await getMbtilesDirectory();
|
||||
|
||||
try {
|
||||
final files = await dir
|
||||
.list()
|
||||
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
return files;
|
||||
} catch (e) {
|
||||
debugPrint('Error listing MBTiles files: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata from an MBTiles file
|
||||
Future<MbtilesMetadata?> getMetadata(File file) async {
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!await file.exists()) {
|
||||
debugPrint('MBTiles file does not exist: ${file.path}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get file size
|
||||
final fileSize = await file.length();
|
||||
|
||||
// Open MBTiles file
|
||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
||||
|
||||
// Get metadata from MBTiles
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
|
||||
// Convert bounds object to string if available
|
||||
String? boundsStr;
|
||||
if (metadata.bounds != null) {
|
||||
boundsStr = metadata.bounds.toString();
|
||||
}
|
||||
|
||||
return MbtilesMetadata(
|
||||
name: metadata.name ?? _getFileNameWithoutExtension(file),
|
||||
description: metadata.description,
|
||||
version: metadata.version?.toString(),
|
||||
attribution: null, // Not available in new API
|
||||
bounds: boundsStr,
|
||||
center: null, // Not available in new API
|
||||
minZoom: metadata.minZoom?.toInt(),
|
||||
maxZoom: metadata.maxZoom?.toInt(),
|
||||
format: metadata.format,
|
||||
type: metadata.type?.name,
|
||||
json: null, // Not available in new API
|
||||
file: file,
|
||||
fileSize: fileSize,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata for all MBTiles files
|
||||
Future<List<MbtilesMetadata>> getAllMetadata() async {
|
||||
final files = await listMbtilesFiles();
|
||||
final metadataList = <MbtilesMetadata>[];
|
||||
|
||||
for (final file in files) {
|
||||
final metadata = await getMetadata(file);
|
||||
if (metadata != null) {
|
||||
metadataList.add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return metadataList;
|
||||
}
|
||||
|
||||
/// Import an MBTiles file from an external location
|
||||
Future<File?> importMbtilesFile(String sourcePath) async {
|
||||
try {
|
||||
final sourceFile = File(sourcePath);
|
||||
|
||||
// Verify source file exists
|
||||
if (!await sourceFile.exists()) {
|
||||
debugPrint('Source file does not exist: $sourcePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get destination directory
|
||||
final destDir = await getMbtilesDirectory();
|
||||
final fileName = _getFileName(sourceFile);
|
||||
final destPath = '${destDir.path}/$fileName';
|
||||
|
||||
// Copy file to destination
|
||||
final destFile = await sourceFile.copy(destPath);
|
||||
debugPrint('Imported MBTiles file to: $destPath');
|
||||
|
||||
return destFile;
|
||||
} catch (e) {
|
||||
debugPrint('Error importing MBTiles file: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an MBTiles file
|
||||
Future<bool> deleteMbtilesFile(File file) async {
|
||||
try {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
debugPrint('Deleted MBTiles file: ${file.path}');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Error deleting MBTiles file: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if data in MBTiles is gzip compressed
|
||||
Future<bool> isGzipCompressed(File file) async {
|
||||
try {
|
||||
// Open MBTiles and check a sample tile
|
||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
||||
|
||||
// Try to get metadata to check for compression hints
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
final format = metadata.format;
|
||||
|
||||
// For Geofabrik files, format is 'pbf' and data is gzipped
|
||||
// We can infer this from common patterns, but ideally we'd check actual tile data
|
||||
if (format == 'pbf') {
|
||||
// Geofabrik MBTiles are typically gzipped
|
||||
// Could also check tile data headers, but this is a reasonable heuristic
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Error checking gzip compression: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the vector tile schema from metadata
|
||||
String? getVectorSchema(MbtilesMetadata metadata) {
|
||||
// Try to infer schema from metadata
|
||||
final json = metadata.json;
|
||||
if (json != null) {
|
||||
if (json.contains('shortbread')) {
|
||||
return 'shortbread';
|
||||
} else if (json.contains('openmaptiles')) {
|
||||
return 'openmaptiles';
|
||||
}
|
||||
}
|
||||
|
||||
// Check description
|
||||
final description = metadata.description?.toLowerCase();
|
||||
if (description != null) {
|
||||
if (description.contains('shortbread')) {
|
||||
return 'shortbread';
|
||||
} else if (description.contains('openmaptiles')) {
|
||||
return 'openmaptiles';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Helper: Get file name without extension
|
||||
String _getFileNameWithoutExtension(File file) {
|
||||
final name = _getFileName(file);
|
||||
final lastDot = name.lastIndexOf('.');
|
||||
return lastDot > 0 ? name.substring(0, lastDot) : name;
|
||||
}
|
||||
|
||||
/// Helper: Get file name from path
|
||||
String _getFileName(File file) {
|
||||
return file.path.split(Platform.pathSeparator).last;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||
import 'package:flutter_map_tile_caching/custom_backend_api.dart';
|
||||
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
|
||||
import 'package:mbtiles/mbtiles.dart';
|
||||
import '../models/map_layer.dart';
|
||||
|
||||
class TileCacheService {
|
||||
@@ -144,6 +146,28 @@ class TileCacheService {
|
||||
};
|
||||
}
|
||||
|
||||
/// Get vector tile provider for MBTiles layers
|
||||
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
|
||||
if (!layer.isVector || layer.mbtilesFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final mbtiles = MbTiles(
|
||||
mbtilesPath: layer.mbtilesFile!.path,
|
||||
gzip: layer.isGzipped ?? false,
|
||||
);
|
||||
|
||||
return MbTilesVectorTileProvider(
|
||||
mbtiles: mbtiles,
|
||||
silenceTileNotFound: true,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error creating vector tile provider: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isInitialized = false;
|
||||
}
|
||||
|
||||
@@ -115,23 +115,24 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
// Battery indicator
|
||||
if (battery != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_getBatteryIcon(battery),
|
||||
size: 16,
|
||||
color: _getBatteryColor(battery),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${battery.round()}%',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: _getBatteryColor(battery),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// Connection type indicator (direct/flood) - shown for all contact types
|
||||
if (contact.type != ContactType.channel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
Reference in New Issue
Block a user