From 2364b2311c4def117c6e32aa8d59bb4f64812047 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Wed, 10 Jun 2026 15:17:26 +0200 Subject: [PATCH] =?UTF-8?q?Adatb=C3=A1zis=20=C3=BAj=20strukt=C3=BAra=20?= =?UTF-8?q?=C3=A9s=20k=C3=B6nyvt=C3=A1r,=20projektrendszer=20bevezet=C3=A9?= =?UTF-8?q?se,=20ProjectService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 3 + lib/models/project.dart | 86 +++++++ lib/services/app_database.dart | 367 ++++++++++++++++++++++++++++++ lib/services/project_service.dart | 130 +++++++++++ lib/services/sync_service.dart | 155 +++++++++++++ pubspec.yaml | 1 + 6 files changed, 742 insertions(+) create mode 100644 lib/models/project.dart create mode 100644 lib/services/app_database.dart create mode 100644 lib/services/project_service.dart create mode 100644 lib/services/sync_service.dart diff --git a/lib/main.dart b/lib/main.dart index a5a3e4e..c813278 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/routes/app_pages.dart'; +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'; @@ -18,6 +19,8 @@ Future main() async { url: dotenv.env['SUPABASE_URL']!, anonKey: dotenv.env['SUPABASE_ANON_KEY']!); + await AppDatabase.instance.database; + await Get.putAsync( () => CoordConverterService().init()); Get.put(GnssDeviceService()); diff --git a/lib/models/project.dart b/lib/models/project.dart new file mode 100644 index 0000000..eda9d24 --- /dev/null +++ b/lib/models/project.dart @@ -0,0 +1,86 @@ +enum ProjectStatus { active, archived } + +enum ProjectCrs { eov, wgs84 } + +class Project { + final int? id; + final String uuid; + final String name; + final String client; // megrendelő + final String description; + final ProjectCrs crs; + final String color; // hex, a UI-ban azonosításhoz + final ProjectStatus status; + final bool isLocalOnly; + final DateTime? lastSyncedAt; + final DateTime createdAt; + final DateTime updatedAt; + + const Project({ + this.id, + required this.uuid, + required this.name, + this.client = '', + this.description = '', + this.crs = ProjectCrs.eov, + this.color = '#185FA5', + this.status = ProjectStatus.active, + this.isLocalOnly = false, + this.lastSyncedAt, + required this.createdAt, + required this.updatedAt, + }); + + Project copyWith( + {String? name, + String? client, + String? description, + ProjectCrs? crs, + String? color, + ProjectStatus? status}) => + Project( + id: id, + uuid: uuid, + name: name ?? this.name, + client: client ?? this.client, + description: description ?? this.description, + crs: crs ?? this.crs, + color: color ?? this.color, + status: status ?? this.status, + isLocalOnly: isLocalOnly, + lastSyncedAt: lastSyncedAt, + createdAt: createdAt, + updatedAt: DateTime.now(), + ); + + Map toMap() => { + if (id != null) 'id': id, + 'uuid': uuid, + 'name': name, + 'client': client, + 'description': description, + 'crs': crs.name, + 'color': color, + 'status': status.name, + 'isLocalOnly': isLocalOnly, + if (lastSyncedAt != null) + 'last_synced_at': lastSyncedAt!.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory Project.fromMap(Map m) => Project( + id: m['id'] as int?, + uuid: m['uuid'] as String, + name: m['name'] as String, + client: m['client'] as String? ?? '', + description: m['description'] as String? ?? '', + crs: ProjectCrs.values.byName(m['crs'] as String? ?? 'eov'), + color: m['color'] as String? ?? '#185FA5', + status: ProjectStatus.values.byName(m['status'] as String? ?? 'active'), + isLocalOnly: m['is__local_only'] as bool ?? true, + lastSyncedAt: DateTime.parse(m['last_synced_at'] as String), + createdAt: DateTime.parse(m['created_at'] as String), + updatedAt: DateTime.parse(m['updated_at'] as String), + ); +} diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart new file mode 100644 index 0000000..5920a4c --- /dev/null +++ b/lib/services/app_database.dart @@ -0,0 +1,367 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; +import 'package:terepi_seged/models/track.dart'; +import 'package:uuid/uuid.dart'; +import '../models/project.dart'; + +class AppDatabase { + AppDatabase._(); + static final instance = AppDatabase._(); + static Database? _db; + + Future get database async { + _db ??= await _open(); + return _db!; + } + + Future _open() async { + final oldDb = p.join(await getDatabasesPath(), 'terepi_seged.db'); + if (await File(oldDb).exists()) { + await File(oldDb).delete(); + } + + final directory = await getExternalStorageDirectory(); + final path = p.join(directory!.path, 'database', 'terepi_seged.db'); + + return openDatabase(path, + version: 1, onCreate: _onCreate, onUpgrade: _onUpgrade); + } + + Future _onCreate(Database db, int _) async { + // ── Projects ──────────────────────────────────────────────────── + await db.execute(''' + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + client TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + crs TEXT NOT NULL DEFAULT 'eov', + color TEXT NOT NULL DEFAULT '#185FA5', + status TEXT NOT NULL DEFAULT 'active', + is_local_only INTEGER NOT NULL DEFAULT 1, + last_synced_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + // ── Bemért pontok ──────────────────────────────────────────────── + await db.execute(''' + CREATE TABLE IF NOT EXISTS measured_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + eov_y REAL, + eov_x REAL, + eov_z REAL, + latitude REAL, + longitude REAL, + altitude REAL, + accuracy REAL, + fix_quality INTEGER, + timestamp TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '' + ) + '''); + await db + .execute('CREATE INDEX idx_mp_project ON measured_points(project_id)'); + + // ── Track-ek ───────────────────────────────────────────────────── + await db.execute(''' + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT, + status TEXT NOT NULL DEFAULT 'recording', + source TEXT NOT NULL DEFAULT 'Telefon GPS', + distance_m REAL NOT NULL DEFAULT 0, + point_count INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute('CREATE INDEX idx_tracks_project ON tracks(project_id)'); + + // ── Track pontok ───────────────────────────────────────────────── + await db.execute(''' + CREATE TABLE IF NOT EXISTS track_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + accuracy REAL, + speed REAL, + heading REAL, + timestamp TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)'); + + // ── Terepbejárás elemek ────────────────────────────────────────── + await db.execute(''' + CREATE TABLE IF NOT EXISTS note_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type TEXT NOT NULL, + points_json TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#185FA5', + opacity REAL NOT NULL DEFAULT 0.5, + stroke_width REAL NOT NULL DEFAULT 3.0, + stroke_color TEXT NOT NULL DEFAULT '#FFD700', + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ) + '''); + await db + .execute('CREATE INDEX idx_notes_project ON note_items(project_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS pending_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + point_number INTEGER NOT NULL, + gnss_number TEXT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + height_of_geoid REAL, + eov_x REAL, + eov_y REAL, + pole_height REAL, + horizontal_error REAL, + vertical_error REAL, + description TEXT, + is_deleted INTEGER NOT NULL DEFAULT 0, + project_id INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'pending' + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_pp_status ' + 'ON pending_points(sync_status)', + ); + + // Alap projekt létrehozása az első indításhoz + final now = DateTime.now().toIso8601String(); + await db.insert('projects', { + 'uuid': const Uuid().v4(), + 'name': 'Alapértelmezett projekt', + 'created_at': now, + 'updated_at': now, + }); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async {} + + // ── Projects CRUD ───────────────────────────────────────────────── + + Future insertProject(Project p) async => + (await database).insert('projects', p.toMap()); + + Future updateProject(Project p) async => (await database) + .update('projects', p.toMap(), where: 'id = ?', whereArgs: [p.id]); + + Future archiveProject(int id) async => (await database).update( + 'projects', + {'status': 'archived', 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id]); + + Future> listProjects({bool includeArchived = false}) async { + final rows = await (await database).query( + 'projects', + where: includeArchived ? null : "status = 'active'", + orderBy: 'updated_at DESC', + ); + return rows.map(Project.fromMap).toList(); + } + + Future getProject(int id) async { + final rows = await (await database) + .query('projects', where: 'id = ?', whereArgs: [id], limit: 1); + return rows.isEmpty ? null : Project.fromMap(rows.first); + } + + // Projekt statisztikák — a listázáshoz + Future> getProjectStats(int projectId) async { + final db = await database; + final points = Sqflite.firstIntValue(await db.rawQuery( + 'SELECT COUNT(*) FROM measured_points WHERE project_id = ?', + [projectId])) ?? + 0; + final tracks = Sqflite.firstIntValue(await db.rawQuery( + 'SELECT COUNT(*) FROM tracks WHERE project_id = ?', [projectId])) ?? + 0; + final notes = Sqflite.firstIntValue(await db.rawQuery( + 'SELECT COUNT(*) FROM note_items WHERE project_id = ?', + [projectId])) ?? + 0; + return {'points': points, 'tracks': tracks, 'notes': notes}; + } + + Future insertTrack(Track track) async { + final db = await database; + return db.insert('tracks', track.toMap()); + } + + Future updateTrack(Track track) async { + final db = await database; + await db.update( + 'tracks', + track.toMap(), + where: 'id = ?', + whereArgs: [track.id], + ); + } + + Future deleteTrack(int id) async { + final db = await database; + await db.delete('tracks', where: 'id = ?', whereArgs: [id]); + } + + Future> listTracks() async { + final db = await database; + final rows = await db.query('tracks', orderBy: 'start_time DESC'); + return rows.map(Track.fromMap).toList(); + } + + Future getTrack(int id) async { + final db = await database; + final rows = await db.query( + 'tracks', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : Track.fromMap(rows.first); + } + + // ═══════════════════════════════════════════════════════════════ + // TRACK POINTS + // ═══════════════════════════════════════════════════════════════ + + Future addPoint(TrackPoint point, double newDistance) async { + final db = await database; + await db.transaction((txn) async { + await txn.insert('track_points', point.toMap()); + await txn.rawUpdate(''' + UPDATE tracks + SET distance_meters = ?, + point_count = point_count + 1 + WHERE id = ? + ''', [newDistance, point.trackId]); + }); + } + + Future> getPoints(int trackId) async { + final db = await database; + final rows = await db.query( + 'track_points', + where: 'track_id = ?', + whereArgs: [trackId], + orderBy: 'timestamp ASC', + ); + return rows.map(TrackPoint.fromMap).toList(); + } + + Future> getLatLons(int trackId) async { + final db = await database; + final rows = await db.query( + 'track_points', + columns: ['latitude', 'longitude'], + where: 'track_id = ?', + whereArgs: [trackId], + orderBy: 'timestamp ASC', + ); + return rows + .map((r) => ( + lat: r['latitude'] as double, + lon: r['longitude'] as double, + )) + .toList(); + } + + // ═══════════════════════════════════════════════════════════════ + // PENDING POINTS (szinkron queue) + // ═══════════════════════════════════════════════════════════════ + + Future insertPendingPoint(Map point) async { + final db = await database; + return db.insert('pending_points', { + 'point_number': point['pointNumber'], + 'gnss_number': point['gnssNumber'], + 'latitude': point['latitude'], + 'longitude': point['longitude'], + 'altitude': point['altitude'], + 'height_of_geoid': point['heightOfGeoid'], + 'eov_x': point['eovX'], + 'eov_y': point['eovY'], + 'pole_height': point['poleHeight'], + 'horizontal_error': point['horizontalError'], + 'vertical_error': point['verticalError'], + 'description': point['description'], + 'is_deleted': (point['isDeleted'] == true) ? 1 : 0, + 'project_id': point['projectId'] ?? 2, + 'created_at': DateTime.now().toIso8601String(), + 'sync_status': 'pending', + }); + } + + Future>> getPendingPoints() async { + final db = await database; + // JOIN a projects táblával — lokális projektek kiszűrve + return db.rawQuery(''' + SELECT pp.* + FROM pending_points pp + LEFT JOIN projects pr ON pr.id = pp.project_id + WHERE pp.sync_status = 'pending' + AND (pr.is_local_only = 0 OR pr.is_local_only IS NULL) + ORDER BY pp.id ASC + '''); + } + + Future getPendingCount() async { + final db = await database; + final result = await db.rawQuery( + "SELECT COUNT(*) AS cnt FROM pending_points " + "WHERE sync_status = 'pending'", + ); + return (result.first['cnt'] as int?) ?? 0; + } + + Future markPointSynced(int id) async { + final db = await database; + await db.update( + 'pending_points', + {'sync_status': 'synced'}, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future markPointError(int id) async { + final db = await database; + await db.update( + 'pending_points', + {'sync_status': 'error'}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Sikeresen szinkronizált pontok törlése (takarítás) + Future purgeSyncedPoints() async { + final db = await database; + await db.delete( + 'pending_points', + where: 'sync_status = ?', + whereArgs: ['synced'], + ); + } +} diff --git a/lib/services/project_service.dart b/lib/services/project_service.dart new file mode 100644 index 0000000..3f26b9b --- /dev/null +++ b/lib/services/project_service.dart @@ -0,0 +1,130 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../models/project.dart'; +import 'app_database.dart'; + +class ProjectService extends GetxService { + static ProjectService get to => Get.find(); + + final activeProject = Rxn(); + final projects = [].obs; + + int? get activeProjectId => activeProject.value?.id; + + @override + Future onInit() async { + super.onInit(); + await _loadProjects(); + await _restoreActiveProject(); + } + + Future _loadProjects() async { + projects.value = await AppDatabase.instance.listProjects(); + } + + // Az utoljára aktív projektet tárolja a SharedPreferences + Future _restoreActiveProject() async { + final prefs = await SharedPreferences.getInstance(); + final savedId = prefs.getInt('active_project_id'); + + if (savedId != null) { + final project = await AppDatabase.instance.getProject(savedId); + if (project != null && project.status == ProjectStatus.active) { + activeProject.value = project; + return; + } + } + // Fallback: az első aktív projekt + if (projects.isNotEmpty) { + await setActiveProject(projects.first); + } + } + + Future setActiveProject(Project project) async { + activeProject.value = project; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('active_project_id', project.id!); + + // Frissítjük az updated_at-et hogy a lista tetejére kerüljön + await AppDatabase.instance.updateProject(project.copyWith()); + await _loadProjects(); + } + + Future createProject({ + required String name, + String client = '', + String description = '', + ProjectCrs crs = ProjectCrs.eov, + String color = '#185FA5', + }) async { + final now = DateTime.now(); + final project = Project( + uuid: const Uuid().v4(), + name: name, + client: client, + description: description, + crs: crs, + color: color, + createdAt: now, + updatedAt: now, + ); + final id = await AppDatabase.instance.insertProject(project); + final saved = project.copyWith(); + await _loadProjects(); + return await AppDatabase.instance.getProject(id) ?? saved; + } + + /// Online projekt — szinkronizál Supabase-szel + Future createOnlineProject({ + required String name, + String client = '', + ProjectCrs crs = ProjectCrs.eov, + String color = '#185FA5', + }) async { + final project = Project( + uuid: const Uuid().v4(), + name: name, + client: client, + crs: crs, + color: color, + isLocalOnly: false, // ← szinkronizált + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + // Lokálisan mentjük + final id = await AppDatabase.instance.insertProject(project); + + // Supabase-be is + await Supabase.instance.client.from('projects').insert(project.toMap()); + + await _loadProjects(); + return await AppDatabase.instance.getProject(id) ?? project; + } + + /// Lokális projekt — soha nem kerül Supabase-be + Future createLocalProject({ + required String name, + String client = '', + ProjectCrs crs = ProjectCrs.eov, + String color = '#185FA5', + }) async { + final project = Project( + uuid: const Uuid().v4(), + name: name, + client: client, + crs: crs, + color: color, + isLocalOnly: true, // ← csak lokális + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + // Csak lokálisan + final id = await AppDatabase.instance.insertProject(project); + await _loadProjects(); + return await AppDatabase.instance.getProject(id) ?? project; + } +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..2d2de76 --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'app_database.dart'; + +enum SyncStatus { idle, syncing, error } + +class SyncService extends GetxService { + static SyncService get to => Get.find(); + + final syncStatus = SyncStatus.idle.obs; + final pendingCount = 0.obs; + + StreamSubscription? _connectivitySub; + final _supabase = Supabase.instance.client; + + // AppDatabase — nincs külön DB fájl + AppDatabase get _db => AppDatabase.instance; + + @override + Future onInit() async { + super.onInit(); + await _updatePendingCount(); + _startConnectivityListener(); + + if (await _hasConnectivity()) { + unawaited(_syncPendingItems()); + } + } + + @override + Future onClose() async { + await _connectivitySub?.cancel(); + super.onClose(); + } + + // ── Publikus API ────────────────────────────────────────────────── + + Future saveMeasuredPoint(Map point) async { + final projectId = point['projectId'] as int?; + final isLocalOnly = await _isLocalOnlyProject(projectId); + + final localId = await _db.insertPendingPoint(point); + + if (isLocalOnly) { + await _db.markPointSynced(localId); + return; + } + await _updatePendingCount(); + + if (await _hasConnectivity()) { + await _syncSinglePoint(localId, point); + } + } + + // ── Szinkronizálás ──────────────────────────────────────────────── + + Future _syncSinglePoint(int localId, Map point) async { + try { + await _supabase.from('TerepiSeged_MeasuredPoints').insert(point); + + await _supabase + .from('TerepiSeged_Receiver') + .update({'isMeasured': true}).eq('pointNumber', point['pointNumber']); + + await _db.markPointSynced(localId); + await _updatePendingCount(); + } catch (_) { + await _db.markPointError(localId); + } + } + + Future _syncPendingItems() async { + if (syncStatus.value == SyncStatus.syncing) return; + + final pending = await _db.getPendingPoints(); + if (pending.isEmpty) return; + + syncStatus.value = SyncStatus.syncing; + + for (final row in pending) { + final localId = row['id'] as int; + try { + final supaPoint = { + 'pointNumber': row['point_number'], + 'gnssNumber': row['gnss_number'], + 'latitude': row['latitude'], + 'longitude': row['longitude'], + 'altitude': row['altitude'], + 'heightOfGeoid': row['height_of_geoid'], + 'eovX': row['eov_x'], + 'eovY': row['eov_y'], + 'poleHeight': row['pole_height'], + 'horizontalError': row['horizontal_error'], + 'verticalError': row['vertical_error'], + 'description': row['description'], + 'isDeleted': row['is_deleted'] == 1, + 'projectId': row['project_id'], + }; + + await _supabase.from('TerepiSeged_MeasuredPoints').insert(supaPoint); + + await _supabase.from('TerepiSeged_Receiver').update( + {'isMeasured': true}).eq('pointNumber', row['point_number']); + + await _db.markPointSynced(localId); + } catch (_) { + // Következő alkalomra halaszt + } + } + + await _updatePendingCount(); + syncStatus.value = SyncStatus.idle; + await _db.purgeSyncedPoints(); + } + + // ── Segédek ─────────────────────────────────────────────────────── + + Future _updatePendingCount() async { + pendingCount.value = await _db.getPendingCount(); + } + + void _startConnectivityListener() { + _connectivitySub = Connectivity() + .onConnectivityChanged + .listen((List results) async { + final hasNet = results.any((r) => r != ConnectivityResult.none); + if (hasNet && pendingCount.value > 0) { + await _syncPendingItems(); + } + }); + } + + Future _hasConnectivity() async { + final results = await Connectivity().checkConnectivity(); + return results.any((r) => r != ConnectivityResult.none); + } + + Future _isLocalOnlyProject(int? projectId) async { + if (projectId == null) return false; + final db = await AppDatabase.instance.database; + final rows = await db.query( + 'projects', + columns: ['is_local_only'], + where: 'id = ?', + whereArgs: [projectId], + limit: 1, + ); + if (rows.isEmpty) return false; + return (rows.first['is_local_only'] as int?) == 1; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index bed3762..1ef56c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: flutter: sdk: flutter + uuid: ^4.5.3 dev_dependencies: