// GeoPackage (.gpkg) export — OGC szabvány, QGIS-kompatibilis // // Tartalom: // - note_items_point : rögzített pontok // - note_items_line : rögzített vonalak // - note_items_polygon : rögzített területek // - measured_points : bemért pontok // - tracks : GPS nyomvonalak // // Kimenet: ZIP archív (.gpkg + médiafájlok) import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive_io.dart'; import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:sqflite/sqflite.dart'; import 'package:terepi_seged/models/measured_point.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/project_service.dart'; import '../models/note_item.dart'; import '../enums/note_type.dart'; class GeoPackageExporter { // ── Publikus belépési pont ──────────────────────────────────────── Future exportProject() async { final project = ProjectService.to.activeProject.value; final projectId = ProjectService.to.activeProjectId; final name = project?.name ?? 'projekt'; final ts = DateFormat('yyyyMMdd_HHmm').format(DateTime.now()); final tmpDir = await getTemporaryDirectory(); final workDir = Directory(p.join(tmpDir.path, 'gpkg_export_$ts')); await workDir.create(recursive: true); try { // 1. GeoPackage létrehozása final gpkgPath = p.join(workDir.path, '$name.gpkg'); await _buildGpkg(gpkgPath, projectId); // 2. Médiafájlok összegyűjtése final mediaDir = Directory(p.join(workDir.path, 'media')); await _collectMedia(projectId, mediaDir); // 3. ZIP csomagolás final zipPath = p.join(tmpDir.path, '${name}_$ts.zip'); await _packZip(workDir, zipPath); // 4. Megosztás await SharePlus.instance.share(ShareParams( files: [XFile(zipPath, mimeType: 'application/zip')], subject: 'GeoPackage export — $name', )); } finally { // Ideiglenes munkamappa törlése await workDir.delete(recursive: true).catchError((_) {}); } } // ── GeoPackage (.gpkg) létrehozása ─────────────────────────────── Future _buildGpkg(String path, int? projectId) async { final db = await openDatabase(path); try { await _initGpkg(db); await _exportNoteItems(db, projectId); await _exportMeasuredPoints(db, projectId); await _exportTracks(db, projectId); } finally { await db.close(); } } // ── GeoPackage kötelező táblák ──────────────────────────────────── Future _initGpkg(Database db) async { // GeoPackage magic numbers await db.execute('PRAGMA application_id = 1196444487'); await db.execute('PRAGMA user_version = 10200'); // Spatial reference systems await db.execute(''' CREATE TABLE gpkg_spatial_ref_sys ( srs_name TEXT NOT NULL, srs_id INTEGER NOT NULL PRIMARY KEY, organization TEXT NOT NULL, organization_coordsys_id INTEGER NOT NULL, definition TEXT NOT NULL, description TEXT )'''); // WGS84 bejegyzés await db.insert('gpkg_spatial_ref_sys', { 'srs_name': 'WGS 84 geographic 2D', 'srs_id': 4326, 'organization': 'EPSG', 'organization_coordsys_id': 4326, 'definition': 'GEOGCS["WGS 84",DATUM["WGS_1984",' 'SPHEROID["WGS 84",6378137,298.257223563]],' 'PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]', 'description': 'longitude/latitude coordinates in decimal degrees', }); // Undefined srs await db.insert('gpkg_spatial_ref_sys', { 'srs_name': 'Undefined Cartesian SRS', 'srs_id': -1, 'organization': 'NONE', 'organization_coordsys_id': -1, 'definition': 'undefined', }); await db.insert('gpkg_spatial_ref_sys', { 'srs_name': 'Undefined geographic SRS', 'srs_id': 0, 'organization': 'NONE', 'organization_coordsys_id': 0, 'definition': 'undefined', }); // Contents tábla await db.execute(''' CREATE TABLE gpkg_contents ( table_name TEXT NOT NULL PRIMARY KEY, data_type TEXT NOT NULL, identifier TEXT, description TEXT DEFAULT '', last_change DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', CURRENT_TIMESTAMP)), min_x REAL, min_y REAL, max_x REAL, max_y REAL, srs_id INTEGER, CONSTRAINT fk_gc_r_srs_id FOREIGN KEY (srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id) )'''); // Geometry columns tábla await db.execute(''' CREATE TABLE gpkg_geometry_columns ( table_name TEXT NOT NULL, column_name TEXT NOT NULL, geometry_type_name TEXT NOT NULL, srs_id INTEGER NOT NULL, z TINYINT NOT NULL, m TINYINT NOT NULL, CONSTRAINT pk_geom_cols PRIMARY KEY (table_name, column_name), CONSTRAINT fk_gc_tn FOREIGN KEY (table_name) REFERENCES gpkg_contents(table_name), CONSTRAINT fk_gc_srs FOREIGN KEY (srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id) )'''); } // ── Feature tábla regisztrálása ─────────────────────────────────── Future _registerLayer( Database db, { required String tableName, required String geomType, // 'POINT', 'LINESTRING', 'POLYGON' required String identifier, List? extent, // [minX, minY, maxX, maxY] }) async { await db.insert('gpkg_contents', { 'table_name': tableName, 'data_type': 'features', 'identifier': identifier, 'description': '', 'last_change': DateTime.now().toUtc().toIso8601String(), 'min_x': extent?[0], 'min_y': extent?[1], 'max_x': extent?[2], 'max_y': extent?[3], 'srs_id': 4326, }); await db.insert('gpkg_geometry_columns', { 'table_name': tableName, 'column_name': 'geom', 'geometry_type_name': geomType, 'srs_id': 4326, 'z': 0, 'm': 0, }); } // ── NoteItem exportok ───────────────────────────────────────────── Future _exportNoteItems(Database db, int? projectId) async { final items = await AppDatabase.instance.listNoteItems(projectId); final points = items.where((i) => i.type == NoteType.point).toList(); final lines = items.where((i) => i.type == NoteType.line).toList(); final polygons = items.where((i) => i.type == NoteType.polygon).toList(); if (points.isNotEmpty) await _exportPoints(db, points); if (lines.isNotEmpty) await _exportLines(db, lines); if (polygons.isNotEmpty) await _exportPolygons(db, polygons); } Future _exportPoints(Database db, List items) async { await db.execute(''' CREATE TABLE note_items_point ( id INTEGER PRIMARY KEY, geom BLOB NOT NULL, label TEXT, color_hex TEXT, created_at TEXT, photo_count INTEGER DEFAULT 0, audio_count INTEGER DEFAULT 0 )'''); await _registerLayer(db, tableName: 'note_items_point', geomType: 'POINT', identifier: 'Rögzített pontok', extent: _extent(items)); for (final item in items) { final pt = item.points.first; final pc = await AppDatabase.instance.countNotePhotos(item.id!); final ac = await AppDatabase.instance.countNoteAudios(item.id!); await db.insert('note_items_point', { 'id': item.id, 'geom': _gpkgPoint(pt.longitude, pt.latitude), 'label': item.label, 'color_hex': '#${item.color.value.toRadixString(16).padLeft(8, '0').substring(2)}', 'created_at': item.createdAt.toIso8601String(), 'photo_count': pc, 'audio_count': ac, }); } } Future _exportLines(Database db, List items) async { await db.execute(''' CREATE TABLE note_items_line ( id INTEGER PRIMARY KEY, geom BLOB NOT NULL, label TEXT, color_hex TEXT, stroke_width REAL, point_count INTEGER, created_at TEXT, photo_count INTEGER DEFAULT 0, audio_count INTEGER DEFAULT 0 )'''); await _registerLayer(db, tableName: 'note_items_line', geomType: 'LINESTRING', identifier: 'Rögzített vonalak', extent: _extent(items)); for (final item in items) { final pc = await AppDatabase.instance.countNotePhotos(item.id!); final ac = await AppDatabase.instance.countNoteAudios(item.id!); await db.insert('note_items_line', { 'id': item.id, 'geom': _gpkgLineString(item.points), 'label': item.label, 'color_hex': '#${item.color.value.toRadixString(16).padLeft(8, '0').substring(2)}', 'stroke_width': item.strokeWidth, 'point_count': item.points.length, 'created_at': item.createdAt.toIso8601String(), 'photo_count': pc, 'audio_count': ac, }); } } Future _exportPolygons(Database db, List items) async { await db.execute(''' CREATE TABLE note_items_polygon ( id INTEGER PRIMARY KEY, geom BLOB NOT NULL, label TEXT, color_hex TEXT, opacity REAL, stroke_width REAL, point_count INTEGER, created_at TEXT, photo_count INTEGER DEFAULT 0, audio_count INTEGER DEFAULT 0 )'''); await _registerLayer(db, tableName: 'note_items_polygon', geomType: 'POLYGON', identifier: 'Rögzített területek', extent: _extent(items)); for (final item in items) { final pc = await AppDatabase.instance.countNotePhotos(item.id!); final ac = await AppDatabase.instance.countNoteAudios(item.id!); await db.insert('note_items_polygon', { 'id': item.id, 'geom': _gpkgPolygon(item.points), 'label': item.label, 'color_hex': '#${item.color.value.toRadixString(16).padLeft(8, '0').substring(2)}', 'opacity': item.opacity, 'stroke_width': item.strokeWidth, 'point_count': item.points.length, 'created_at': item.createdAt.toIso8601String(), 'photo_count': pc, 'audio_count': ac, }); } } // ── Bemért pontok exportja ──────────────────────────────────────── Future _exportMeasuredPoints(Database db, int? projectId) async { final points = projectId != null ? await AppDatabase.instance.listMeasuredPoints(projectId) : []; if (points.isEmpty) return; await db.execute(''' CREATE TABLE measured_points ( id INTEGER PRIMARY KEY, geom BLOB NOT NULL, name TEXT, eov_y REAL, eov_x REAL, eov_z REAL, accuracy REAL, fix_quality INTEGER, timestamp TEXT, note TEXT )'''); await _registerLayer(db, tableName: 'measured_points', geomType: 'POINT', identifier: 'Bemért pontok'); for (final pt in points) { if (pt.latitude == null || pt.longitude == null) continue; await db.insert('measured_points', { 'id': pt.id, 'geom': _gpkgPoint(pt.longitude!, pt.latitude!), 'name': pt.name, 'eov_y': pt.eovY, 'eov_x': pt.eovX, 'eov_z': pt.eovZ, 'accuracy': pt.accuracy, 'fix_quality': pt.fixQuality, 'timestamp': pt.timestamp.toIso8601String(), 'note': pt.note, }); } } // ── Track exportja ──────────────────────────────────────────────── Future _exportTracks(Database db, int? projectId) async { final tracks = await AppDatabase.instance.listTracks(); final filtered = projectId != null ? tracks.where((t) => t.projectId == projectId).toList() : tracks; if (filtered.isEmpty) return; await db.execute(''' CREATE TABLE tracks ( id INTEGER PRIMARY KEY, geom BLOB NOT NULL, name TEXT, distance_meters REAL, start_time TEXT, end_time TEXT, point_count INTEGER )'''); await _registerLayer(db, tableName: 'tracks', geomType: 'LINESTRING', identifier: 'GPS nyomvonalak'); for (final track in filtered) { final pts = await AppDatabase.instance.getLatLons(track.id!); if (pts.length < 2) continue; // Egyszerű record list → LatLng-szerű párok final coords = pts.map((p) => (lon: p.lon, lat: p.lat)).toList(); await db.insert('tracks', { 'id': track.id, 'geom': _gpkgLineStringFromRecords(coords), 'name': track.name, 'distance_meters': track.distanceMeters, 'start_time': track.startTime.toIso8601String(), 'end_time': track.endTime?.toIso8601String(), 'point_count': pts.length, }); } } // ── Médiafájlok másolása ────────────────────────────────────────── Future _collectMedia(int? projectId, Directory mediaDir) async { if (projectId == null) return; final photos = Directory(p.join(mediaDir.path, 'photos')); final audios = Directory(p.join(mediaDir.path, 'audio')); await photos.create(recursive: true); await audios.create(recursive: true); final items = await AppDatabase.instance.listNoteItems(projectId); for (final item in items) { // Fotók final photoList = await AppDatabase.instance.listNotePhotos(item.id!); for (final photo in photoList) { final src = File(photo.localPath); if (await src.exists()) { final dst = File(p.join(photos.path, p.basename(photo.localPath))); await src.copy(dst.path); } } // Hangok final audioList = await AppDatabase.instance.listNoteAudios(item.id!); for (final audio in audioList) { final src = File(audio.localPath); if (await src.exists()) { final dst = File(p.join(audios.path, p.basename(audio.localPath))); await src.copy(dst.path); } } } } // ── ZIP csomagolás ──────────────────────────────────────────────── Future _packZip(Directory workDir, String zipPath) async { final archive = Archive(); for (final entity in workDir.listSync(recursive: true)) { if (entity is! File) continue; final bytes = entity.readAsBytesSync(); final rel = p.relative(entity.path, from: workDir.path); final archiveName = rel.replaceAll('\\', '/'); // Windows path fix archive.addFile(ArchiveFile(archiveName, bytes.length, bytes)); } final encoded = ZipEncoder().encode(archive); if (encoded == null || encoded.isEmpty) { throw Exception( 'ZIP encoding sikertelen — nincsenek exportálható adatok'); } await File(zipPath).writeAsBytes(Uint8List.fromList(encoded)); } // ── WKB/GeoPackage bináris kódolás ─────────────────────────────── // // GPKG binary header (8 byte): // 0x47 0x50 – 'GP' magic // 0x00 – version 0 // 0x01 – flags: little-endian, no envelope // srs_id (int32) – 4326 (WGS84) // + WKB geometry Uint8List _gpkgPoint(double lon, double lat) { final b = ByteData(8 + 21); // header + wkb point _writeGpkgHeader(b, 0); b.setUint8(8, 1); // WKB: little endian b.setUint32(9, 1, Endian.little); // type: Point b.setFloat64(13, lon, Endian.little); b.setFloat64(21, lat, Endian.little); return b.buffer.asUint8List(); } Uint8List _gpkgLineString(List points) { final n = points.length; final b = ByteData(8 + 9 + n * 16); _writeGpkgHeader(b, 0); int o = 8; b.setUint8(o++, 1); b.setUint32(o, 2, Endian.little); o += 4; // type: LineString b.setUint32(o, n, Endian.little); o += 4; for (final pt in points) { b.setFloat64(o, pt.longitude, Endian.little); o += 8; b.setFloat64(o, pt.latitude, Endian.little); o += 8; } return b.buffer.asUint8List(); } Uint8List _gpkgLineStringFromRecords( List<({double lon, double lat})> coords) { final n = coords.length; final b = ByteData(8 + 9 + n * 16); _writeGpkgHeader(b, 0); int o = 8; b.setUint8(o++, 1); b.setUint32(o, 2, Endian.little); o += 4; b.setUint32(o, n, Endian.little); o += 4; for (final c in coords) { b.setFloat64(o, c.lon, Endian.little); o += 8; b.setFloat64(o, c.lat, Endian.little); o += 8; } return b.buffer.asUint8List(); } Uint8List _gpkgPolygon(List ring) { final n = ring.length; final b = ByteData(8 + 9 + 4 + n * 16); _writeGpkgHeader(b, 0); int o = 8; b.setUint8(o++, 1); b.setUint32(o, 3, Endian.little); o += 4; // type: Polygon b.setUint32(o, 1, Endian.little); o += 4; // 1 ring b.setUint32(o, n, Endian.little); o += 4; for (final pt in ring) { b.setFloat64(o, pt.longitude, Endian.little); o += 8; b.setFloat64(o, pt.latitude, Endian.little); o += 8; } return b.buffer.asUint8List(); } void _writeGpkgHeader(ByteData b, int offset) { b.setUint8(offset, 0x47); // 'G' b.setUint8(offset + 1, 0x50); // 'P' b.setUint8(offset + 2, 0x00); // version b.setUint8(offset + 3, 0x01); // flags: little-endian, no envelope b.setInt32(offset + 4, 4326, Endian.little); // WGS84 } // ── Segédek ─────────────────────────────────────────────────────── /// Befoglaló téglalap az összes NoteItem pontjaiból List? _extent(List items) { final pts = items.expand((i) => i.points).toList(); if (pts.isEmpty) return null; return [ pts.map((p) => p.longitude).reduce((a, b) => a < b ? a : b), pts.map((p) => p.latitude).reduce((a, b) => a < b ? a : b), pts.map((p) => p.longitude).reduce((a, b) => a > b ? a : b), pts.map((p) => p.latitude).reduce((a, b) => a > b ? a : b), ]; } }