// 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:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:get/get.dart'; import 'package:latlong2/latlong.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:terepi_seged/services/layer_sync_service.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 ImportedLayerStyle { final Color color; final double opacity; final double strokeWidth; const ImportedLayerStyle({ required this.color, this.opacity = 0.35, this.strokeWidth = 2.5, }); ImportedLayerStyle copyWith({ Color? color, double? opacity, double? strokeWidth, }) => ImportedLayerStyle( color: color ?? this.color, opacity: opacity ?? this.opacity, strokeWidth: strokeWidth ?? this.strokeWidth, ); } 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(); final layerStyles = {}.obs; // ── Inicializálás ────────────────────────────────────────────────────────── @override Future onReady() async { super.onReady(); await _initLayerDir(); await _loadPersistedLayers(); // A service permanent (app-életciklusnyi) — enélkül projektváltáskor // a térképen a RÉGI projekt rétegei maradnának látszódva egészen // újraindításig. ever(ProjectService.to.activeProject, (_) => reload()); } Future reload() => _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)); final style = meta.getStyle(); if (style != null) { layerStyles[meta.id] = style; } } 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)); } } /// Réteg megosztása a projekt csapatával (feltöltés a Storage-ba + /// metaadat a terepi_seged_layers táblába). Future publishLayer(String id) async { if (!Get.isRegistered()) return; final projectUuid = ProjectService.to.activeProject.value?.uuid; if (projectUuid == null) { throw Exception('Nincs aktív (megosztott) projekt.'); } final metas = await AppDatabase.instance.listImportedLayers(); final meta = metas.firstWhereOrNull((m) => m.id == id); if (meta == null) throw Exception('A réteg nem található.'); await LayerSyncService.to .publishLayer(layer: meta, projectUuid: projectUuid); } 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); return _kmlParser.parseKml(KmlParser.decodeText(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, markerCreationCallback: _pointMarkerWithLabel, onMarkerTapCallback: (props) { final label = _extractFeatureName(props); if (label != null) { Get.snackbar(label, 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(), ); } // ── Rugalmas névkinyerés — az attribútum neve forrásonként eltérhet ── static const _nameKeys = [ 'name', 'nev', 'név', 'label', 'title', 'megnevezes', 'megnevezés', 'ref', 'point' ]; /// A properties-ből megpróbál egy "nevet" kinyerni, a kulcs pontos /// nevétől (kis/nagybetű) függetlenül, több elterjedt változatot is /// figyelembe véve. Null, ha semelyik ismert kulcs alatt nincs /// érdemi (nem üres) érték. static String? _extractFeatureName(Map properties) { final lower = { for (final e in properties.entries) e.key.toLowerCase(): e.value, }; for (final key in _nameKeys) { final v = lower[key]; if (v != null && v.toString().trim().isNotEmpty) { return v.toString().trim(); } } return null; } /// Pont-marker, ami — ha talál nevet az attribútumok közt — egy kis /// feliratot is mutat a jelölő fölött, nem csak koppintásra. static Marker _pointMarkerWithLabel( LatLng point, Map properties) { final label = _extractFeatureName(properties); return Marker( point: point, width: 100, height: 46, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (label != null) Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: Colors.white.withOpacity(0.9), borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.red.withOpacity(0.7)), ), child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), ), ), const Icon(Icons.location_pin, color: Colors.red, size: 26), ], ), ); } LayerImportSourceType _sourceType(String ext) => switch (ext.toLowerCase()) { 'kml' => LayerImportSourceType.kml, 'kmz' => LayerImportSourceType.kmz, _ => LayerImportSourceType.geoJson, }; void setLayerStyle(String id, ImportedLayerStyle style) async { layerStyles[id] = style; layerStyles.refresh(); layers.refresh(); final metas = await AppDatabase.instance.listImportedLayers(); final meta = metas.firstWhereOrNull((m) => m.id == id); if (meta != null) { final hex = '#${style.color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; await AppDatabase.instance.updateImportedLayer(meta.copyWith( colorHex: hex, opacity: style.opacity, strokeWidth: style.strokeWidth)); } } ImportedLayerStyle? getLayerStyle(String id) => layerStyles[id]; }