Adatbázis új struktúra és könyvtár, projektrendszer bevezetése, ProjectService

This commit is contained in:
torok.istvan 2026-06-10 15:17:26 +02:00
parent 8b4c25f475
commit 2364b2311c
6 changed files with 742 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/routes/app_pages.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/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_device_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/gnss/gnss_service.dart';
@ -18,6 +19,8 @@ Future<void> main() async {
url: dotenv.env['SUPABASE_URL']!, url: dotenv.env['SUPABASE_URL']!,
anonKey: dotenv.env['SUPABASE_ANON_KEY']!); anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
await AppDatabase.instance.database;
await Get.putAsync<CoordConverterService>( await Get.putAsync<CoordConverterService>(
() => CoordConverterService().init()); () => CoordConverterService().init());
Get.put(GnssDeviceService()); Get.put(GnssDeviceService());

86
lib/models/project.dart Normal file
View File

@ -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<String, dynamic> 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<String, dynamic> 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),
);
}

View File

@ -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<Database> get database async {
_db ??= await _open();
return _db!;
}
Future<Database> _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<void> _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<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {}
// Projects CRUD
Future<int> insertProject(Project p) async =>
(await database).insert('projects', p.toMap());
Future<void> updateProject(Project p) async => (await database)
.update('projects', p.toMap(), where: 'id = ?', whereArgs: [p.id]);
Future<void> archiveProject(int id) async => (await database).update(
'projects',
{'status': 'archived', 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id]);
Future<List<Project>> 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<Project?> 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<Map<String, int>> 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<int> insertTrack(Track track) async {
final db = await database;
return db.insert('tracks', track.toMap());
}
Future<void> updateTrack(Track track) async {
final db = await database;
await db.update(
'tracks',
track.toMap(),
where: 'id = ?',
whereArgs: [track.id],
);
}
Future<void> deleteTrack(int id) async {
final db = await database;
await db.delete('tracks', where: 'id = ?', whereArgs: [id]);
}
Future<List<Track>> listTracks() async {
final db = await database;
final rows = await db.query('tracks', orderBy: 'start_time DESC');
return rows.map(Track.fromMap).toList();
}
Future<Track?> 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<void> 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<List<TrackPoint>> 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<List<({double lat, double lon})>> 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<int> insertPendingPoint(Map<String, dynamic> 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<List<Map<String, dynamic>>> 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<int> 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<void> markPointSynced(int id) async {
final db = await database;
await db.update(
'pending_points',
{'sync_status': 'synced'},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> 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<void> purgeSyncedPoints() async {
final db = await database;
await db.delete(
'pending_points',
where: 'sync_status = ?',
whereArgs: ['synced'],
);
}
}

View File

@ -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<Project>();
final projects = <Project>[].obs;
int? get activeProjectId => activeProject.value?.id;
@override
Future<void> onInit() async {
super.onInit();
await _loadProjects();
await _restoreActiveProject();
}
Future<void> _loadProjects() async {
projects.value = await AppDatabase.instance.listProjects();
}
// Az utoljára aktív projektet tárolja a SharedPreferences
Future<void> _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<void> 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<Project> 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<Project> 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<Project> 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;
}
}

View File

@ -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<void> onInit() async {
super.onInit();
await _updatePendingCount();
_startConnectivityListener();
if (await _hasConnectivity()) {
unawaited(_syncPendingItems());
}
}
@override
Future<void> onClose() async {
await _connectivitySub?.cancel();
super.onClose();
}
// Publikus API
Future<void> saveMeasuredPoint(Map<String, dynamic> 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<void> _syncSinglePoint(int localId, Map<String, dynamic> 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<void> _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<void> _updatePendingCount() async {
pendingCount.value = await _db.getPendingCount();
}
void _startConnectivityListener() {
_connectivitySub = Connectivity()
.onConnectivityChanged
.listen((List<ConnectivityResult> results) async {
final hasNet = results.any((r) => r != ConnectivityResult.none);
if (hasNet && pendingCount.value > 0) {
await _syncPendingItems();
}
});
}
Future<bool> _hasConnectivity() async {
final results = await Connectivity().checkConnectivity();
return results.any((r) => r != ConnectivityResult.none);
}
Future<bool> _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;
}
}

View File

@ -75,6 +75,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
uuid: ^4.5.3
dev_dependencies: dev_dependencies: