Kml, kmz és geojson import és térképi megjelnítésük.

This commit is contained in:
torok.istvan 2026-06-21 10:07:35 +02:00
parent 0828630a5b
commit 7c29bc7ae5
14 changed files with 1095 additions and 13 deletions

272
lib/core/kml_parser.dart Normal file
View File

@ -0,0 +1,272 @@
// KML és KMZ flutter_map objektumok
//
// FONTOS: KML koordináta sorrend: lon,lat,alt
// Flutter LatLng(lat, lon) tehát fordítva kell olvasni!
//
// KMZ = ZIP archív, benne doc.kml
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart';
import 'package:uuid/uuid.dart';
import 'package:xml/xml.dart';
import '../models/imported_layer.dart';
class KmlParser {
// Alapértelmezett stílusok felülírhatók konstruktorban
final Color defaultPolylineColor;
final double defaultPolylineStroke;
final Color defaultPolygonFillColor;
final Color defaultPolygonBorderColor;
final double defaultPolygonBorderStroke;
final Color defaultMarkerColor;
const KmlParser({
this.defaultPolylineColor = const Color(0xCC1565C0), // kék
this.defaultPolylineStroke = 3.0,
this.defaultPolygonFillColor = const Color(0x4D1565C0),
this.defaultPolygonBorderColor = const Color(0xCC1565C0),
this.defaultPolygonBorderStroke = 1.5,
this.defaultMarkerColor = Colors.red,
});
// KMZ (ZIP KML)
ImportedLayer parseKmz(Uint8List bytes, String fileName,
{String? id, bool isVisible = true}) {
final archive = ZipDecoder().decodeBytes(bytes);
ArchiveFile? kmlFile = archive.findFile('doc.kml');
kmlFile ??= archive.files.firstWhere(
(f) => f.name.toLowerCase().endsWith('.kml'),
orElse: () => throw Exception('Nem található .kml fájl a KMZ-ben'),
);
final kmlString = String.fromCharCodes(kmlFile.content as List<int>);
return _parse(kmlString, fileName, LayerImportSourceType.kmz,
id: id, isVisible: isVisible);
}
// KML (szöveg)
ImportedLayer parseKml(String kmlString, String fileName,
{String? id, bool isVisible = true}) =>
_parse(kmlString, fileName, LayerImportSourceType.kml,
id: id, isVisible: isVisible);
// Belső parse
ImportedLayer _parse(
String kmlString, String fileName, LayerImportSourceType source,
{String? id, bool isVisible = true}) {
final doc = XmlDocument.parse(kmlString);
final markers = <Marker>[];
final polylines = <Polyline>[];
final polygons = <Polygon>[];
for (final pm in doc.findAllElements('Placemark')) {
final style = _style(pm);
final label = pm.findElements('name').firstOrNull?.innerText.trim() ?? '';
// Point
final point = pm.findElements('Point').firstOrNull;
if (point != null) {
final pt = _singleCoord(point);
if (pt != null) {
markers.add(Marker(
point: pt,
width: label.isNotEmpty ? 120 : 24,
height: label.isNotEmpty ? 40 : 24,
alignment: Alignment.bottomCenter,
child: _markerWidget(label, style.lineColor),
));
}
}
// LineString
final line = pm.findElements('LineString').firstOrNull;
if (line != null) {
final pts = _coordList(line);
if (pts.isNotEmpty) {
polylines.add(Polyline(
points: pts,
color: style.lineColor,
strokeWidth: style.lineWidth,
borderColor: style.lineColor.withOpacity(0.3),
borderStrokeWidth: 1.0,
));
}
}
// Polygon
final poly = pm.findElements('Polygon').firstOrNull;
if (poly != null) {
final rings = _polygonRings(poly);
if (rings.isNotEmpty) {
polygons.add(Polygon(
points: rings[0],
holePointsList: rings.length > 1 ? rings.sublist(1) : null,
color: style.fillColor,
borderColor: style.lineColor,
borderStrokeWidth: style.lineWidth,
label: label.isNotEmpty ? label : null,
));
}
}
// MultiGeometry rekurzív feldolgozás az első egyszerű elemre
final multi = pm.findElements('MultiGeometry').firstOrNull;
if (multi != null) {
for (final ls in multi.findElements('LineString')) {
final pts = _coordList(ls);
if (pts.isNotEmpty) {
polylines.add(Polyline(
points: pts,
color: style.lineColor,
strokeWidth: style.lineWidth,
));
}
}
}
}
return ImportedLayer(
id: id ?? const Uuid().v4(),
isVisible: isVisible,
name: fileName,
sourceType: source,
markers: markers,
polylines: polylines,
polygons: polygons,
importedAt: DateTime.now(),
);
}
// Koordináta parserek
LatLng? _singleCoord(XmlElement geom) {
final raw = geom.findElements('coordinates').firstOrNull?.innerText.trim();
if (raw == null) return null;
return _oneCoord(raw.split(RegExp(r'\s+')).first);
}
List<LatLng> _coordList(XmlElement geom) {
final raw = geom.findElements('coordinates').firstOrNull?.innerText.trim();
if (raw == null) return [];
return raw
.split(RegExp(r'\s+'))
.map(_oneCoord)
.whereType<LatLng>()
.toList();
}
List<List<LatLng>> _polygonRings(XmlElement polygon) {
final rings = <List<LatLng>>[];
final outer = polygon
.findElements('outerBoundaryIs')
.firstOrNull
?.findElements('LinearRing')
.firstOrNull;
if (outer != null) rings.add(_coordList(outer));
for (final inner in polygon.findElements('innerBoundaryIs')) {
final ring = inner.findElements('LinearRing').firstOrNull;
if (ring != null) rings.add(_coordList(ring));
}
return rings.where((r) => r.isNotEmpty).toList();
}
/// KML: lon,lat,alt LatLng(lat, lon)
LatLng? _oneCoord(String s) {
final p = s.split(',');
if (p.length < 2) return null;
try {
return LatLng(
double.parse(p[1].trim()), // lat
double.parse(p[0].trim()), // lon
);
} catch (_) {
return null;
}
}
// Stílus kiolvasás
_Style _style(XmlElement pm) {
final style = pm.findElements('Style').firstOrNull;
if (style == null) return _Style.defaults(this);
Color lineColor = defaultPolylineColor;
double lineWidth = defaultPolylineStroke;
Color fillColor = defaultPolygonFillColor;
final ls = style.findElements('LineStyle').firstOrNull;
if (ls != null) {
final c = ls.findElements('color').firstOrNull?.innerText;
if (c != null) lineColor = _kmlColor(c);
final w = ls.findElements('width').firstOrNull?.innerText;
if (w != null) lineWidth = double.tryParse(w) ?? lineWidth;
}
final ps = style.findElements('PolyStyle').firstOrNull;
if (ps != null) {
final c = ps.findElements('color').firstOrNull?.innerText;
if (c != null) fillColor = _kmlColor(c);
}
return _Style(
lineColor: lineColor, lineWidth: lineWidth, fillColor: fillColor);
}
/// KML szín formátum: aabbggrr Flutter Color(argb)
Color _kmlColor(String s) {
if (s.length != 8) return defaultPolylineColor;
try {
final a = int.parse(s.substring(0, 2), radix: 16);
final b = int.parse(s.substring(2, 4), radix: 16);
final g = int.parse(s.substring(4, 6), radix: 16);
final r = int.parse(s.substring(6, 8), radix: 16);
return Color.fromARGB(a, r, g, b);
} catch (_) {
return defaultPolylineColor;
}
}
// Marker widget
Widget _markerWidget(String label, Color color) {
if (label.isEmpty) {
return Icon(Icons.location_pin, color: color, size: 24);
}
return Column(mainAxisSize: MainAxisSize.min, children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration:
BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
child: Text(label,
style: const TextStyle(
color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
Icon(Icons.location_pin, color: color, size: 16),
]);
}
}
class _Style {
final Color lineColor;
final double lineWidth;
final Color fillColor;
const _Style(
{required this.lineColor,
required this.lineWidth,
required this.fillColor});
factory _Style.defaults(KmlParser p) => _Style(
lineColor: p.defaultPolylineColor,
lineWidth: p.defaultPolylineStroke,
fillColor: p.defaultPolygonFillColor,
);
}

View File

@ -0,0 +1 @@
enum LayerImportSourceType { geoJson, kml, kmz }

View File

@ -9,6 +9,7 @@ import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/layer_import_service.dart';
import 'package:terepi_seged/services/note_audio_service.dart';
import 'package:terepi_seged/services/note_photo_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart';
@ -34,6 +35,7 @@ Future<void> main() async {
Get.put(TrackingController(), permanent: true);
Get.put(NotePhotoService(), permanent: true);
Get.put(NoteAudioService(), permanent: true);
Get.put(LayerImportService(), permanent: true);
runApp(const MyApp());
}

View File

@ -0,0 +1,96 @@
// Importált réteg modellje.
// A GeoJsonParser közvetlenül flutter_map objektumokat gyárt,
// ezeket csomagoljuk egy kezelhető rétegbe.
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart';
import 'package:uuid/uuid.dart';
class ImportedLayer {
final String id;
final String name;
final LayerImportSourceType sourceType;
// flutter_map objektumok közvetlenül a réteg widgetekbe kerülnek
final List<Marker> markers;
final List<Polyline> polylines;
final List<Polygon> polygons;
final bool isVisible;
final DateTime importedAt;
const ImportedLayer({
required this.id,
required this.name,
required this.sourceType,
required this.markers,
required this.polylines,
required this.polygons,
this.isVisible = true,
required this.importedAt,
});
// Statisztikák
int get featureCount => markers.length + polylines.length + polygons.length;
bool get isEmpty => featureCount == 0;
// Bounding box zoom a rétegre
LatLngBounds? get bounds {
final points = <LatLng>[
...markers.map((m) => m.point),
...polylines.expand((p) => p.points),
...polygons.expand((p) => p.points),
];
if (points.isEmpty) return null;
var minLat = points.first.latitude;
var maxLat = points.first.latitude;
var minLon = points.first.longitude;
var maxLon = points.first.longitude;
for (final p in points) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
return LatLngBounds(
LatLng(minLat, minLon),
LatLng(maxLat, maxLon),
);
}
ImportedLayer copyWith({bool? isVisible}) => ImportedLayer(
id: id,
name: name,
sourceType: sourceType,
markers: markers,
polylines: polylines,
polygons: polygons,
isVisible: isVisible ?? this.isVisible,
importedAt: importedAt,
);
// Gyártó: GeoJsonParser kimenetéből
static ImportedLayer fromGeoJsonParser({
required dynamic parser, // GeoJsonParser
required String name,
LayerImportSourceType source = LayerImportSourceType.geoJson,
}) {
return ImportedLayer(
id: const Uuid().v4(),
name: name,
sourceType: source,
markers: List.from(parser.markers as List),
polylines: List.from(parser.polylines as List),
polygons: List.from(parser.polygons as List),
importedAt: DateTime.now(),
);
}
}

View File

@ -0,0 +1,72 @@
import 'package:terepi_seged/enums/layer_import_source_type.dart';
class ImportedLayerMeta {
final String id;
final String name;
final LayerImportSourceType sourceType;
final String localPath; // fájl elérési út
final String? storagePath; // Supabase Storage (megosztáskor)
final bool isVisible;
final int? projectId;
final DateTime importedAt;
final DateTime? syncedAt;
const ImportedLayerMeta({
required this.id,
required this.name,
required this.sourceType,
required this.localPath,
this.storagePath,
this.isVisible = true,
this.projectId,
required this.importedAt,
this.syncedAt,
});
bool get isSynced => storagePath != null;
Map<String, dynamic> toMap() => {
'id': id,
'name': name,
'source_type': sourceType.name,
'local_path': localPath,
'storage_path': storagePath,
'is_visible': isVisible ? 1 : 0,
'project_id': projectId,
'imported_at': importedAt.toIso8601String(),
'synced_at': syncedAt?.toIso8601String(),
};
factory ImportedLayerMeta.fromMap(Map<String, dynamic> m) =>
ImportedLayerMeta(
id: m['id'] as String,
name: m['name'] as String,
sourceType:
LayerImportSourceType.values.byName(m['source_type'] as String),
localPath: m['local_path'] as String,
storagePath: m['storage_path'] as String?,
isVisible: (m['is_visible'] as int) == 1,
projectId: m['project_id'] as int?,
importedAt: DateTime.parse(m['imported_at'] as String),
syncedAt: m['synced_at'] != null
? DateTime.parse(m['synced_at'] as String)
: null,
);
ImportedLayerMeta copyWith({
bool? isVisible,
String? storagePath,
DateTime? syncedAt,
}) =>
ImportedLayerMeta(
id: id,
name: name,
sourceType: sourceType,
localPath: localPath,
storagePath: storagePath ?? this.storagePath,
isVisible: isVisible ?? this.isVisible,
projectId: projectId,
importedAt: importedAt,
syncedAt: syncedAt ?? this.syncedAt,
);
}

View File

@ -43,6 +43,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
import 'package:terepi_seged/widgets/map_edit_tools/map_feature_save_sheet.dart';
import 'package:terepi_seged/widgets/shared_map_widgets.dart';
@ -1623,4 +1624,82 @@ class MapSurveyController extends GetxController {
break;
}
}
void openLayerPanel() {
Get.bottomSheet(
DraggableScrollableSheet(
initialChildSize: 0.45,
minChildSize: 0.3,
maxChildSize: 0.85,
snap: true,
snapSizes: const [0.3, 0.45, 0.85],
expand: false,
builder: (_, scrollCtrl) => Container(
decoration: BoxDecoration(
color: Get.theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 16,
offset: const Offset(0, -4),
),
],
),
child: CustomScrollView(
controller: scrollCtrl,
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle
Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Importált rétegek',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
ImportLayerPanel(
onFitBounds: fitImportedLayer,
),
],
),
),
],
),
),
],
),
),
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
ignoreSafeArea: false,
);
}
void fitImportedLayer(LatLngBounds bounds) {
_isMapProgrammaticMove = true;
mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
);
}
}

View File

@ -13,6 +13,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/utils/rive_utils.dart';
import 'package:terepi_seged/widgets/coordinate_panel.dart';
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
import 'package:terepi_seged/widgets/map_bottom_panel.dart';
import 'package:terepi_seged/widgets/map_edit_tools/map_edit_drawing_toolbar.dart';
import 'package:terepi_seged/widgets/map_edit_tools/map_edit_toolbar.dart';
@ -74,16 +75,7 @@ class MapSurveyView extends GetView<MapSurveyController> {
controller.clearNoteItemSelection();
},
layers: [
Obx(() {
final isGpsActive = GnssService.to.activeConnectionType.value !=
GnssConnectionType.none;
if (isGpsActive) {
return MarkerLayer(
markers: controller.currentLocationMarker.toList());
}
return const SizedBox.shrink();
}),
const ImportedLayerOverlay(),
// Track polyline
Obx(() {
final isTracking = TrackingController.to.isRecording.value;
@ -166,6 +158,15 @@ class MapSurveyView extends GetView<MapSurveyController> {
controller: controller.polygonEditorController,
throttleDuration: Duration.zero);
}),
Obx(() {
final isGpsActive = GnssService.to.activeConnectionType.value !=
GnssConnectionType.none;
if (isGpsActive) {
return MarkerLayer(
markers: controller.currentLocationMarker.toList());
}
return const SizedBox.shrink();
}),
],
),
Positioned(

View File

@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:terepi_seged/enums/note_type.dart';
import 'package:terepi_seged/models/imported_layer_meta.dart';
import 'package:terepi_seged/models/note_item.dart';
import 'package:terepi_seged/models/note_item_audio.dart';
import 'package:terepi_seged/models/note_item_photo.dart';
@ -190,6 +191,22 @@ class AppDatabase {
'ON pending_points(sync_status)',
);
await db.execute('''
CREATE TABLE IF NOT EXISTS imported_layers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_type TEXT NOT NULL,
local_path TEXT NOT NULL,
storage_path TEXT,
is_visible INTEGER NOT NULL DEFAULT 1,
project_id INTEGER,
imported_at TEXT NOT NULL,
synced_at TEXT
)
''');
await db.execute(
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
// Alap projekt létrehozása az első indításhoz
final now = DateTime.now().toIso8601String();
await db.insert('projects', {
@ -559,4 +576,33 @@ class AppDatabase {
);
return rows.map(NoteItemAudio.fromMap).toList();
}
// ----------- Layer meta adatok
Future<void> insertImportedLayer(ImportedLayerMeta meta) async {
final db = await database;
await db.insert('imported_layers', meta.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> updateImportedLayer(ImportedLayerMeta meta) async {
final db = await database;
await db.update('imported_layers', meta.toMap(),
where: 'id = ?', whereArgs: [meta.id]);
}
Future<void> deleteImportedLayer(String id) async {
final db = await database;
await db.delete('imported_layers', where: 'id = ?', whereArgs: [id]);
}
Future<List<ImportedLayerMeta>> listImportedLayers({int? projectId}) async {
final db = await database;
final rows = await db.query(
'imported_layers',
where: projectId != null ? 'project_id = ?' : null,
whereArgs: projectId != null ? [projectId] : null,
orderBy: 'imported_at DESC',
);
return rows.map(ImportedLayerMeta.fromMap).toList();
}
}

View File

@ -0,0 +1,274 @@
// Geo fájl import service perzisztens verzió
//
// TÁRHELY STRATÉGIA:
// Fájlrendszer /files/layers/{id}.geojson|kml|kmz (nyers forrás)
// SQLite imported_layers tábla (metadata)
// Supabase Storage (megosztás, később)
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart';
import 'package:uuid/uuid.dart';
import '../models/imported_layer.dart';
import '../models/imported_layer_meta.dart';
import '../services/app_database.dart';
import '../controls/geojson_parser.dart';
import '../core/kml_parser.dart';
import '../services/project_service.dart';
class LayerImportService extends GetxService {
static LayerImportService get to => Get.find();
final layers = <ImportedLayer>[].obs;
final isLoading = false.obs;
final isSyncing = false.obs;
final lastError = Rxn<String>();
final _kmlParser = const KmlParser(
defaultPolylineColor: Color(0xCC1565C0),
defaultPolylineStroke: 3.0,
defaultPolygonFillColor: Color(0x4D1565C0),
defaultPolygonBorderColor: Color(0xCC1565C0),
defaultPolygonBorderStroke: 1.5,
);
String? _layerDir;
static const _uuid = Uuid();
// Inicializálás
@override
Future<void> onReady() async {
super.onReady();
await _initLayerDir();
await _loadPersistedLayers();
}
Future<void> _initLayerDir() async {
final ext = await getExternalStorageDirectory();
final dir = Directory(p.join(ext!.path, 'layers'));
if (!await dir.exists()) await dir.create(recursive: true);
_layerDir = dir.path;
}
// Fájl import
Future<ImportedLayer?> importFile() async {
try {
isLoading.value = true;
lastError.value = null;
final result = await FilePicker.platform.pickFiles(
// type: FileType.custom,
// allowedExtensions: ['geojson', 'json', 'kml', 'kmz'],
type: FileType.any,
withData: true,
);
if (result == null || result.files.isEmpty) return null;
final file = result.files.first;
final fileName = file.name;
final ext = fileName.split('.').last.toLowerCase();
final bytes = file.bytes!;
final id = _uuid.v4();
if (!['geojson', 'json', 'kml', 'kmz'].contains(ext)) {
throw Exception('Nem támogatott formátum: .$ext');
}
// 1. Fájl mentése tartós tárhelyre
final localPath = await _saveFile(id, ext, bytes);
// 2. Parse
final layer = _parse(bytes, fileName, id, ext);
if (layer.isEmpty) {
await File(localPath).delete();
throw Exception('Nem tartalmaz feldolgozható geometriát.');
}
// 3. SQLite metadata
final meta = ImportedLayerMeta(
id: id,
name: fileName,
sourceType: _sourceType(ext),
localPath: localPath,
isVisible: true,
projectId: ProjectService.to.activeProjectId,
importedAt: DateTime.now(),
);
await AppDatabase.instance.insertImportedLayer(meta);
layers.add(layer);
return layer;
} catch (e) {
lastError.value = e.toString();
Get.snackbar('Import hiba', e.toString(),
snackPosition: SnackPosition.BOTTOM);
return null;
} finally {
isLoading.value = false;
}
}
// Perzisztens rétegek betöltése induláskor
Future<void> _loadPersistedLayers() async {
try {
final projectId = ProjectService.to.activeProjectId;
final metas =
await AppDatabase.instance.listImportedLayers(projectId: projectId);
final loaded = <ImportedLayer>[];
for (final meta in metas) {
final file = File(meta.localPath);
if (!await file.exists()) {
// Fájl törlődött SQLite rekord is törlendő
await AppDatabase.instance.deleteImportedLayer(meta.id);
continue;
}
try {
final bytes = await file.readAsBytes();
final ext = meta.localPath.split('.').last;
loaded.add(_parse(bytes, meta.name, meta.id, ext,
isVisible: meta.isVisible));
} catch (e) {
debugPrint('Réteg betöltés hiba (${meta.name}): $e');
}
}
layers.assignAll(loaded);
} catch (e) {
debugPrint('Perzisztens rétegek betöltési hiba: $e');
}
}
// Réteg kezelés
Future<void> toggleLayer(String id) async {
final idx = layers.indexWhere((l) => l.id == id);
if (idx < 0) return;
final newVisible = !layers[idx].isVisible;
layers[idx] = layers[idx].copyWith(isVisible: newVisible);
layers.refresh();
// SQLite szinkron
final metas = await AppDatabase.instance.listImportedLayers();
final meta = metas.firstWhereOrNull((m) => m.id == id);
if (meta != null) {
await AppDatabase.instance
.updateImportedLayer(meta.copyWith(isVisible: newVisible));
}
}
Future<void> removeLayer(String id) async {
final metas = await AppDatabase.instance.listImportedLayers();
final meta = metas.firstWhereOrNull((m) => m.id == id);
if (meta != null) {
final f = File(meta.localPath);
if (await f.exists()) await f.delete();
await AppDatabase.instance.deleteImportedLayer(id);
}
layers.removeWhere((l) => l.id == id);
}
Future<void> removeAll() async {
for (final l in List.from(layers)) {
await removeLayer(l.id);
}
}
List<ImportedLayer> get visibleLayers =>
layers.where((l) => l.isVisible).toList();
// Supabase megosztás (előkészítve)
//
// Implementáció a megosztási feladatnál:
//
// Future<void> syncLayerToSupabase(String id) async {
// final meta = ...
// final bytes = await File(meta.localPath).readAsBytes();
// final ext = meta.localPath.split('.').last;
// final path = 'layers/$projectUuid/${meta.id}.$ext';
//
// await supabase.storage.from('geo_layers').uploadBinary(path, bytes);
//
// await AppDatabase.instance.updateImportedLayer(
// meta.copyWith(storagePath: path, syncedAt: DateTime.now()));
//
// await supabase.from('terepi_seged_shared_layers').upsert({
// 'id': meta.id, 'name': meta.name, 'project_uuid': projectUuid,
// 'storage_path': path, 'source_type': meta.sourceType.name,
// 'created_at': DateTime.now().toIso8601String(),
// });
// }
// Belső segédek
Future<String> _saveFile(String id, String ext, Uint8List bytes) async {
final path = p.join(_layerDir!, '$id.$ext');
await File(path).writeAsBytes(bytes);
return path;
}
ImportedLayer _parse(Uint8List bytes, String name, String id, String ext,
{bool isVisible = true}) {
switch (ext.toLowerCase()) {
case 'geojson':
case 'json':
return _parseGeoJson(String.fromCharCodes(bytes), name, id,
isVisible: isVisible);
case 'kml':
return _kmlParser.parseKml(String.fromCharCodes(bytes), name,
id: id, isVisible: isVisible);
case 'kmz':
return _kmlParser.parseKmz(bytes, name, id: id, isVisible: isVisible);
default:
throw Exception('Nem támogatott formátum: .$ext');
}
}
ImportedLayer _parseGeoJson(String content, String name, String id,
{bool isVisible = true}) {
final parser = GeoJsonParser(
defaultMarkerColor: Colors.red.withOpacity(0.9),
defaultMarkerIcon: Icons.location_pin,
defaultPolylineColor: const Color(0xCC1565C0),
defaultPolylineStroke: 3.0,
defaultPolygonFillColor: const Color(0x4D1565C0),
defaultPolygonBorderColor: const Color(0xCC1565C0),
defaultPolygonBorderStroke: 1.5,
defaultPolygonIsFilled: true,
onMarkerTapCallback: (props) {
final label = props['name'] ?? props['title'] ?? '';
if (label.toString().isNotEmpty) {
Get.snackbar(label.toString(), props['description']?.toString() ?? '',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3));
}
},
);
parser.parseGeoJsonAsString(content);
return ImportedLayer(
id: id,
name: name,
sourceType: LayerImportSourceType.geoJson,
markers: parser.markers,
polylines: parser.polylines,
polygons: parser.polygons,
isVisible: isVisible,
importedAt: DateTime.now(),
);
}
LayerImportSourceType _sourceType(String ext) => switch (ext.toLowerCase()) {
'kml' => LayerImportSourceType.kml,
'kmz' => LayerImportSourceType.kmz,
_ => LayerImportSourceType.geoJson,
};
}

View File

@ -87,7 +87,35 @@ class AppDrawer extends StatelessWidget {
);
}),
const Divider(height: 24),
//const Divider(height: 24),
const Divider(height: 12),
// ----------- További funkciók
const _SectionLabel('Funkciók'),
ListTile(
leading: const Icon(Icons.message_outlined),
title: const Text('Üzenetek'),
onTap: () {
Get.back();
// Get.to(() => const NtripSettingsView());
},
),
ListTile(
leading: const Icon(Icons.phone_outlined),
title: const Text('Kapcsolatok'),
onTap: () {
Get.back();
// Get.to(() => const NtripSettingsView());
},
),
ListTile(
leading: const Icon(Icons.data_exploration_outlined),
title: const Text('Mérés'),
onTap: () {
Get.back();
// Get.to(() => const NtripSettingsView());
},
),
// 3. Beállítások
const _SectionLabel('Beállítások'),

View File

@ -156,7 +156,11 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget {
PopupMenuButton(
tooltip: 'További funkciók',
icon: const Icon(Icons.more_vert),
onSelected: null,
onSelected: (value) {
if (value == 1) {
controller.openLayerPanel();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: 1,

View File

@ -0,0 +1,200 @@
// Térkép overlay és panel widget az importált rétegekhez.
// Az ImportedLayer már kész flutter_map objektumokat tartalmaz
// itt csak megjelenítjük őket.
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart';
import '../../models/imported_layer.dart';
import '../../services/layer_import_service.dart';
//
// Térkép réteg a flutter_map layers listájába kerül
//
class ImportedLayerOverlay extends StatelessWidget {
const ImportedLayerOverlay({super.key});
@override
Widget build(BuildContext context) {
if (!Get.isRegistered<LayerImportService>()) {
return const SizedBox.shrink();
}
return Obx(() {
final visible = LayerImportService.to.visibleLayers;
if (visible.isEmpty) return const SizedBox.shrink();
// Az összes látható réteg objektumait összegyűjtjük
final polylines = visible.expand((l) => l.polylines).toList();
final polygons = visible.expand((l) => l.polygons).toList();
final markers = visible.expand((l) => l.markers).toList();
return Stack(children: [
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
]);
});
}
}
//
// Réteg panel import gomb + betöltött rétegek listája
//
class ImportLayerPanel extends StatelessWidget {
/// Ha meg van adva, a rétegre zoom gomb ezt hívja
final void Function(LatLngBounds bounds)? onFitBounds;
const ImportLayerPanel({super.key, this.onFitBounds});
@override
Widget build(BuildContext context) {
final svc = LayerImportService.to;
return Obx(() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Import gomb
_ImportButton(svc: svc),
// Réteg lista
if (svc.layers.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 8),
...svc.layers.map((layer) => _LayerTile(
layer: layer,
onToggle: () => svc.toggleLayer(layer.id),
onRemove: () => svc.removeLayer(layer.id),
onZoomTo: onFitBounds != null
? () {
final b = layer.bounds;
if (b != null) onFitBounds!(b);
}
: null,
)),
],
],
));
}
}
// Import gomb
class _ImportButton extends StatelessWidget {
final LayerImportService svc;
const _ImportButton({required this.svc});
@override
Widget build(BuildContext context) {
return Obx(() => svc.isLoading.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
Text('Betöltés...', style: TextStyle(fontSize: 13)),
]),
)
: OutlinedButton.icon(
onPressed: () => svc.importFile(),
icon: const Icon(Icons.file_open_outlined, size: 18),
label: const Text('GeoJSON / KML / KMZ'),
));
}
}
// Egy réteg sor
class _LayerTile extends StatelessWidget {
final ImportedLayer layer;
final VoidCallback onToggle;
final VoidCallback onRemove;
final VoidCallback? onZoomTo;
const _LayerTile({
required this.layer,
required this.onToggle,
required this.onRemove,
this.onZoomTo,
});
@override
Widget build(BuildContext context) {
final icon = switch (layer.sourceType) {
LayerImportSourceType.geoJson => Icons.data_object,
LayerImportSourceType.kml => Icons.map_outlined,
LayerImportSourceType.kmz => Icons.folder_zip_outlined,
};
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(children: [
// Láthatóság
Transform.scale(
scale: 0.85,
child: Switch.adaptive(
value: layer.isVisible,
onChanged: (_) => onToggle(),
),
),
// Ikon
Icon(icon, size: 15, color: Colors.grey.shade500),
const SizedBox(width: 6),
// Név + statisztika
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(layer.name,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text(_stats(),
style: TextStyle(fontSize: 10, color: Colors.grey.shade500)),
],
),
),
// Zoom gomb
if (onZoomTo != null)
IconButton(
icon: const Icon(Icons.fit_screen, size: 16),
onPressed: onZoomTo,
tooltip: 'Ráközelítés',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
color: Colors.grey.shade500,
),
// Törlés
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: onRemove,
tooltip: 'Eltávolítás',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
color: Colors.grey.shade400,
),
]),
);
}
String _stats() {
final parts = <String>[];
if (layer.markers.isNotEmpty) parts.add('${layer.markers.length} pont');
if (layer.polylines.isNotEmpty)
parts.add('${layer.polylines.length} vonal');
if (layer.polygons.isNotEmpty)
parts.add('${layer.polygons.length} terület');
return parts.isEmpty ? 'Üres réteg' : parts.join(' · ');
}
}

View File

@ -79,7 +79,12 @@ class MapModeMenuAnchor extends StatelessWidget {
fontSize: 18, fontWeight: FontWeight.w600)),
const Icon(Icons.arrow_drop_down, size: 22),
]),
Text(p == null ? 'Projekt' : p.name,
Text(
p == null
? 'Projekt'
: (p.name.length > 18
? '${p.name.substring(0, 18)}...'
: p.name),
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,

View File

@ -75,6 +75,8 @@ dependencies:
image_picker: ^1.2.2
record: ^7.1.0
audioplayers: ^6.7.1
archive: ^4.0.9
xml: ^7.0.1
flutter:
sdk: flutter