diff --git a/lib/core/kml_parser.dart b/lib/core/kml_parser.dart new file mode 100644 index 0000000..2f7b54f --- /dev/null +++ b/lib/core/kml_parser.dart @@ -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); + 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 = []; + final polylines = []; + final polygons = []; + + 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 _coordList(XmlElement geom) { + final raw = geom.findElements('coordinates').firstOrNull?.innerText.trim(); + if (raw == null) return []; + return raw + .split(RegExp(r'\s+')) + .map(_oneCoord) + .whereType() + .toList(); + } + + List> _polygonRings(XmlElement polygon) { + final rings = >[]; + 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, + ); +} diff --git a/lib/enums/layer_import_source_type.dart b/lib/enums/layer_import_source_type.dart new file mode 100644 index 0000000..3f55acc --- /dev/null +++ b/lib/enums/layer_import_source_type.dart @@ -0,0 +1 @@ +enum LayerImportSourceType { geoJson, kml, kmz } diff --git a/lib/main.dart b/lib/main.dart index 7c6de84..056dc49 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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()); } diff --git a/lib/models/imported_layer.dart b/lib/models/imported_layer.dart new file mode 100644 index 0000000..4fc7162 --- /dev/null +++ b/lib/models/imported_layer.dart @@ -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 markers; + final List polylines; + final List 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 = [ + ...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(), + ); + } +} diff --git a/lib/models/imported_layer_meta.dart b/lib/models/imported_layer_meta.dart new file mode 100644 index 0000000..289eea1 --- /dev/null +++ b/lib/models/imported_layer_meta.dart @@ -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 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 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, + ); +} diff --git a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart index 66947ed..8e4d0e3 100644 --- a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart +++ b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart @@ -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), + ), + ); + } } diff --git a/lib/pages/map_survey/presentations/views/map_survey_view.dart b/lib/pages/map_survey/presentations/views/map_survey_view.dart index 62fc2d1..4dc0ba1 100644 --- a/lib/pages/map_survey/presentations/views/map_survey_view.dart +++ b/lib/pages/map_survey/presentations/views/map_survey_view.dart @@ -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 { 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 { 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( diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index e8e79a0..2c4a8d5 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -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 insertImportedLayer(ImportedLayerMeta meta) async { + final db = await database; + await db.insert('imported_layers', meta.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future updateImportedLayer(ImportedLayerMeta meta) async { + final db = await database; + await db.update('imported_layers', meta.toMap(), + where: 'id = ?', whereArgs: [meta.id]); + } + + Future deleteImportedLayer(String id) async { + final db = await database; + await db.delete('imported_layers', where: 'id = ?', whereArgs: [id]); + } + + Future> 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(); + } } diff --git a/lib/services/layer_import_service.dart b/lib/services/layer_import_service.dart new file mode 100644 index 0000000..a0d789d --- /dev/null +++ b/lib/services/layer_import_service.dart @@ -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 = [].obs; + final isLoading = false.obs; + final isSyncing = false.obs; + final lastError = Rxn(); + + 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 onReady() async { + super.onReady(); + await _initLayerDir(); + await _loadPersistedLayers(); + } + + Future _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 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 _loadPersistedLayers() async { + try { + final projectId = ProjectService.to.activeProjectId; + final metas = + await AppDatabase.instance.listImportedLayers(projectId: projectId); + + final loaded = []; + 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 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 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 removeAll() async { + for (final l in List.from(layers)) { + await removeLayer(l.id); + } + } + + List get visibleLayers => + layers.where((l) => l.isVisible).toList(); + + // ── Supabase megosztás (előkészítve) ────────────────────────────────────── + // + // Implementáció a megosztási feladatnál: + // + // Future 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 _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, + }; +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index b7ec551..90748ee 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -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'), diff --git a/lib/widgets/appbar/shell_map_appbar.dart b/lib/widgets/appbar/shell_map_appbar.dart index 69c947c..0fb77bc 100644 --- a/lib/widgets/appbar/shell_map_appbar.dart +++ b/lib/widgets/appbar/shell_map_appbar.dart @@ -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, diff --git a/lib/widgets/map/imported_layer_overlay.dart b/lib/widgets/map/imported_layer_overlay.dart new file mode 100644 index 0000000..7fe1740 --- /dev/null +++ b/lib/widgets/map/imported_layer_overlay.dart @@ -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()) { + 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 = []; + 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(' · '); + } +} diff --git a/lib/widgets/map_mode_menu_anchor.dart b/lib/widgets/map_mode_menu_anchor.dart index f2a6bf3..b65a7dc 100644 --- a/lib/widgets/map_mode_menu_anchor.dart +++ b/lib/widgets/map_mode_menu_anchor.dart @@ -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, diff --git a/pubspec.yaml b/pubspec.yaml index e4a7d31..ce4c3b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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