Közös projekt szinkronizációja
This commit is contained in:
+5
-1
@@ -17,12 +17,14 @@ import 'package:terepi_seged/services/firebase_logger.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';
|
||||||
import 'package:terepi_seged/services/layer_import_service.dart';
|
import 'package:terepi_seged/services/layer_import_service.dart';
|
||||||
|
import 'package:terepi_seged/services/layer_sync_service.dart';
|
||||||
import 'package:terepi_seged/services/note_audio_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/note_photo_service.dart';
|
||||||
import 'package:terepi_seged/services/ntrip_service.dart';
|
import 'package:terepi_seged/services/ntrip_service.dart';
|
||||||
import 'package:terepi_seged/services/project_service.dart';
|
import 'package:terepi_seged/services/project_service.dart';
|
||||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||||
import 'package:terepi_seged/services/track_sync_service.dart';
|
import 'package:terepi_seged/services/track_sync_service.dart';
|
||||||
|
import 'package:terepi_seged/services/ts_sync_service.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -45,6 +47,7 @@ Future<void> main() async {
|
|||||||
anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
|
anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
|
||||||
|
|
||||||
Get.put(AuthService());
|
Get.put(AuthService());
|
||||||
|
Get.put(DeviceIdentityService(), permanent: true);
|
||||||
await AppDatabase.instance.database;
|
await AppDatabase.instance.database;
|
||||||
Get.put(ProjectService(), permanent: true);
|
Get.put(ProjectService(), permanent: true);
|
||||||
|
|
||||||
@@ -61,8 +64,9 @@ Future<void> main() async {
|
|||||||
Get.put(NotePhotoService(), permanent: true);
|
Get.put(NotePhotoService(), permanent: true);
|
||||||
Get.put(NoteAudioService(), permanent: true);
|
Get.put(NoteAudioService(), permanent: true);
|
||||||
Get.put(LayerImportService(), permanent: true);
|
Get.put(LayerImportService(), permanent: true);
|
||||||
Get.put(DeviceIdentityService(), permanent: true);
|
|
||||||
Get.put(TrackSyncService(), permanent: true);
|
Get.put(TrackSyncService(), permanent: true);
|
||||||
|
Get.put(LayerSyncService());
|
||||||
|
Get.put(TsSyncService());
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class DeviceInfoModel {
|
|||||||
/// Reinstall után új értéket kap.
|
/// Reinstall után új értéket kap.
|
||||||
final String appInstanceId;
|
final String appInstanceId;
|
||||||
|
|
||||||
|
// Android vagy IOS
|
||||||
|
final String platform;
|
||||||
|
|
||||||
// ── Hardver ───────────────────────────────────────────────────────
|
// ── Hardver ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
final String manufacturer; // "Samsung"
|
final String manufacturer; // "Samsung"
|
||||||
@@ -36,6 +39,7 @@ class DeviceInfoModel {
|
|||||||
const DeviceInfoModel({
|
const DeviceInfoModel({
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
required this.appInstanceId,
|
required this.appInstanceId,
|
||||||
|
required this.platform,
|
||||||
required this.manufacturer,
|
required this.manufacturer,
|
||||||
required this.model,
|
required this.model,
|
||||||
required this.brand,
|
required this.brand,
|
||||||
@@ -52,6 +56,7 @@ class DeviceInfoModel {
|
|||||||
Map<String, dynamic> toRegistrationMap({required String label}) => {
|
Map<String, dynamic> toRegistrationMap({required String label}) => {
|
||||||
'device_id': deviceId,
|
'device_id': deviceId,
|
||||||
'app_instance_id': appInstanceId,
|
'app_instance_id': appInstanceId,
|
||||||
|
'platform': platform,
|
||||||
'label': label,
|
'label': label,
|
||||||
'manufacturer': manufacturer,
|
'manufacturer': manufacturer,
|
||||||
'model': model,
|
'model': model,
|
||||||
|
|||||||
+236
-27
@@ -20,6 +20,8 @@ class AppDatabase {
|
|||||||
static final instance = AppDatabase._();
|
static final instance = AppDatabase._();
|
||||||
static Database? _db;
|
static Database? _db;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
_db ??= await _open();
|
_db ??= await _open();
|
||||||
return _db!;
|
return _db!;
|
||||||
@@ -40,7 +42,7 @@ class AppDatabase {
|
|||||||
final path = p.join(dbDir.path, 'terepi_seged.db');
|
final path = p.join(dbDir.path, 'terepi_seged.db');
|
||||||
|
|
||||||
return openDatabase(path,
|
return openDatabase(path,
|
||||||
version: 3,
|
version: 4,
|
||||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade);
|
onUpgrade: _onUpgrade);
|
||||||
@@ -48,6 +50,10 @@ class AppDatabase {
|
|||||||
|
|
||||||
Future<void> _onCreate(Database db, int _) async {
|
Future<void> _onCreate(Database db, int _) async {
|
||||||
// ── Projects ────────────────────────────────────────────────────
|
// ── Projects ────────────────────────────────────────────────────
|
||||||
|
// uuid: a projekt globális azonosítója. Közös projekthez csatlakozáskor
|
||||||
|
// a Supabase-en lévő projekt uuid-jával jön létre a lokális sor, így a
|
||||||
|
// lokális és a távoli projekt ugyanaz az entitás.
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -62,7 +68,10 @@ class AppDatabase {
|
|||||||
is_local_only INTEGER NOT NULL DEFAULT 1,
|
is_local_only INTEGER NOT NULL DEFAULT 1,
|
||||||
last_synced_at TEXT,
|
last_synced_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_pulled_at TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
@@ -70,6 +79,7 @@ class AppDatabase {
|
|||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS measured_points (
|
CREATE TABLE IF NOT EXISTS measured_points (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
eov_y REAL,
|
eov_y REAL,
|
||||||
@@ -81,16 +91,24 @@ class AppDatabase {
|
|||||||
accuracy REAL,
|
accuracy REAL,
|
||||||
fix_quality INTEGER,
|
fix_quality INTEGER,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT ''
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT,
|
||||||
|
device_id TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
deleted_at TEXT,
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db
|
await db
|
||||||
.execute('CREATE INDEX idx_mp_project ON measured_points(project_id)');
|
.execute('CREATE INDEX idx_mp_project ON measured_points(project_id)');
|
||||||
|
await db
|
||||||
|
.execute('CREATE INDEX idx_mp_sync ON measured_points(sync_status)');
|
||||||
|
|
||||||
// ── Track-ek ─────────────────────────────────────────────────────
|
// ── Track-ek ─────────────────────────────────────────────────────
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS tracks (
|
CREATE TABLE IF NOT EXISTS tracks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
start_time TEXT NOT NULL,
|
start_time TEXT NOT NULL,
|
||||||
@@ -101,15 +119,23 @@ class AppDatabase {
|
|||||||
point_count INTEGER NOT NULL DEFAULT 0,
|
point_count INTEGER NOT NULL DEFAULT 0,
|
||||||
is_local_only INTEGER NOT NULL DEFAULT 0,
|
is_local_only INTEGER NOT NULL DEFAULT 0,
|
||||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
supabase_id TEXT
|
supabase_id TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
device_id TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
deleted_at TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute('CREATE INDEX idx_tracks_project ON tracks(project_id)');
|
await db.execute('CREATE INDEX idx_tracks_project ON tracks(project_id)');
|
||||||
|
|
||||||
// ── Track pontok ─────────────────────────────────────────────────
|
// ── Track pontok ─────────────────────────────────────────────────
|
||||||
|
// uuid pontonként: az upsert-alapú feltöltés így idempotens —
|
||||||
|
// ha egy batch kétszer megy fel (pl. hálózati hiba után), nem duplikál.
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS track_points (
|
CREATE TABLE IF NOT EXISTS track_points (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
latitude REAL NOT NULL,
|
latitude REAL NOT NULL,
|
||||||
longitude REAL NOT NULL,
|
longitude REAL NOT NULL,
|
||||||
@@ -117,16 +143,19 @@ class AppDatabase {
|
|||||||
accuracy REAL,
|
accuracy REAL,
|
||||||
speed REAL,
|
speed REAL,
|
||||||
heading REAL,
|
heading REAL,
|
||||||
timestamp TEXT NOT NULL
|
timestamp TEXT NOT NULL,
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)');
|
'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)');
|
||||||
|
await db.execute('CREATE INDEX idx_tp_sync ON track_points(sync_status)');
|
||||||
|
|
||||||
// ── Terepbejárás elemek ──────────────────────────────────────────
|
// ── Terepbejárás elemek ──────────────────────────────────────────
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS note_items (
|
CREATE TABLE IF NOT EXISTS note_items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
points_json TEXT NOT NULL,
|
points_json TEXT NOT NULL,
|
||||||
@@ -136,6 +165,10 @@ class AppDatabase {
|
|||||||
stroke_color TEXT NOT NULL DEFAULT '#FFD700',
|
stroke_color TEXT NOT NULL DEFAULT '#FFD700',
|
||||||
label TEXT NOT NULL DEFAULT '',
|
label TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
|
created_by TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
deleted_at TEXT,
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db
|
await db
|
||||||
@@ -210,7 +243,8 @@ class AppDatabase {
|
|||||||
is_visible INTEGER NOT NULL DEFAULT 1,
|
is_visible INTEGER NOT NULL DEFAULT 1,
|
||||||
project_id INTEGER,
|
project_id INTEGER,
|
||||||
imported_at TEXT NOT NULL,
|
imported_at TEXT NOT NULL,
|
||||||
synced_at TEXT
|
synced_at TEXT,
|
||||||
|
version INTEGER NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -221,10 +255,10 @@ class AppDatabase {
|
|||||||
// Alap projekt létrehozása az első indításhoz
|
// Alap projekt létrehozása az első indításhoz
|
||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
await db.insert('projects', {
|
await db.insert('projects', {
|
||||||
'uuid': const Uuid().v4(),
|
'uuid': _uuid.v4(),
|
||||||
'name': 'Alapértelmezett projekt',
|
'name': 'Alapértelmezett projekt',
|
||||||
'is_default': 1,
|
'is_default': 1,
|
||||||
'is_local_only': 0,
|
'is_local_only': 1,
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
'created_at': now,
|
'created_at': now,
|
||||||
'updated_at': now,
|
'updated_at': now,
|
||||||
@@ -246,26 +280,169 @@ class AppDatabase {
|
|||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
await _createStakeoutTable(db);
|
await _createStakeoutTable(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
await _migrateToV4(db);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateToV4(Database db) async {
|
||||||
|
// Új oszlopok. (Az SQLite ALTER TABLE nem tud UNIQUE megkötést
|
||||||
|
// hozzáadni, ezért a uuid-egyediséget külön unique index adja
|
||||||
|
// a backfill UTÁN.)
|
||||||
|
await db.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT");
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE projects ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
|
|
||||||
|
await db.execute("ALTER TABLE measured_points ADD COLUMN uuid TEXT");
|
||||||
|
await db.execute("ALTER TABLE measured_points ADD COLUMN created_by TEXT");
|
||||||
|
await db.execute("ALTER TABLE measured_points ADD COLUMN updated_at TEXT");
|
||||||
|
await db.execute("ALTER TABLE measured_points ADD COLUMN deleted_at TEXT");
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE measured_points ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
|
// A meglévő pontoknál a mérés időpontja jó kiindulás updated_at-nek.
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE measured_points SET updated_at = timestamp WHERE updated_at IS NULL");
|
||||||
|
|
||||||
|
await db.execute("ALTER TABLE tracks ADD COLUMN uuid TEXT");
|
||||||
|
await db.execute("ALTER TABLE tracks ADD COLUMN created_by TEXT");
|
||||||
|
await db.execute("ALTER TABLE tracks ADD COLUMN updated_at TEXT");
|
||||||
|
await db.execute("ALTER TABLE tracks ADD COLUMN deleted_at TEXT");
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE tracks SET updated_at = COALESCE(end_time, start_time) "
|
||||||
|
"WHERE updated_at IS NULL");
|
||||||
|
// Ha egy tracknek már van Supabase-azonosítója, azt örökli uuid-ként,
|
||||||
|
// így a korábban feltöltött track ugyanaz az entitás marad.
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE tracks SET uuid = supabase_id WHERE uuid IS NULL AND supabase_id IS NOT NULL");
|
||||||
|
|
||||||
|
await db.execute("ALTER TABLE track_points ADD COLUMN uuid TEXT");
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE track_points ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
|
|
||||||
|
await db.execute("ALTER TABLE note_items ADD COLUMN uuid TEXT");
|
||||||
|
await db.execute("ALTER TABLE note_items ADD COLUMN created_by TEXT");
|
||||||
|
await db.execute("ALTER TABLE note_items ADD COLUMN updated_at TEXT");
|
||||||
|
await db.execute("ALTER TABLE note_items ADD COLUMN deleted_at TEXT");
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE note_items ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE note_items SET updated_at = created_at WHERE updated_at IS NULL");
|
||||||
|
|
||||||
|
await db.execute('ALTER TABLE projects ADD COLUMN last_pulled_at TEXT');
|
||||||
|
await db.execute('ALTER TABLE measured_points ADD COLUMN device_id TEXT');
|
||||||
|
await db.execute('ALTER TABLE tracks ADD COLUMN device_id TEXT');
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE imported_layers ADD COLUMN version INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
|
// uuid-backfill a meglévő sorokra (Dartból, mert az SQLite nem tud
|
||||||
|
// uuid-t generálni). Batch-elve, hogy sok sor esetén is gyors legyen.
|
||||||
|
await _backfillUuids(db, 'measured_points');
|
||||||
|
await _backfillUuids(db, 'tracks');
|
||||||
|
await _backfillUuids(db, 'track_points');
|
||||||
|
await _backfillUuids(db, 'note_items');
|
||||||
|
|
||||||
|
// Egyediség kikényszerítése a backfill után.
|
||||||
|
await db.execute(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_mp_uuid ON measured_points(uuid)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_uuid ON tracks(uuid)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_tp_uuid ON track_points(uuid)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uuid ON note_items(uuid)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_mp_sync ON measured_points(sync_status)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_tp_sync ON track_points(sync_status)');
|
||||||
|
|
||||||
|
// Egyszeri árva-takarítás: a foreign key-ek eddig nem érvényesültek,
|
||||||
|
// így a korábbi törlések árva sorokat hagyhattak.
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM tracks
|
||||||
|
WHERE project_id IS NOT NULL
|
||||||
|
AND project_id NOT IN (SELECT id FROM projects)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM track_points
|
||||||
|
WHERE track_id NOT IN (SELECT id FROM tracks)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM measured_points
|
||||||
|
WHERE project_id NOT IN (SELECT id FROM projects)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM note_items
|
||||||
|
WHERE project_id IS NOT NULL
|
||||||
|
AND project_id NOT IN (SELECT id FROM projects)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM note_item_photos
|
||||||
|
WHERE note_item_id NOT IN (SELECT id FROM note_items)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
DELETE FROM note_item_audios
|
||||||
|
WHERE note_item_id NOT IN (SELECT id FROM note_items)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// uuid-t ad minden olyan sornak, ahol még NULL — 500-as batch-ekben.
|
||||||
|
Future<void> _backfillUuids(Database db, String table) async {
|
||||||
|
while (true) {
|
||||||
|
final rows = await db.query(table,
|
||||||
|
columns: ['id'], where: 'uuid IS NULL', limit: 500);
|
||||||
|
if (rows.isEmpty) break;
|
||||||
|
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final row in rows) {
|
||||||
|
batch.update(table, {'uuid': _uuid.v4()},
|
||||||
|
where: 'id = ?', whereArgs: [row['id']]);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Segédfüggvények a szinkron-oszlopokhoz
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Beszúrandó map kiegészítése a szinkron-oszlopokkal, ha hiányoznak.
|
||||||
|
/// Így a modellek egyelőre változatlanok maradhatnak — a DB-réteg
|
||||||
|
/// garantálja, hogy minden új sor szinkron-képes legyen.
|
||||||
|
Map<String, dynamic> _withSyncColumns(Map<String, dynamic> map) {
|
||||||
|
final m = Map<String, dynamic>.from(map);
|
||||||
|
m['uuid'] ??= _uuid.v4();
|
||||||
|
m['updated_at'] ??= DateTime.now().toIso8601String();
|
||||||
|
m['sync_status'] ??= 'pending';
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _now => DateTime.now().toIso8601String();
|
||||||
|
|
||||||
// ── Projects CRUD ─────────────────────────────────────────────────
|
// ── Projects CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<int> insertProject(Project p) async =>
|
Future<int> insertProject(Project p) async =>
|
||||||
(await database).insert('projects', p.toMap());
|
(await database).insert('projects', p.toMap());
|
||||||
|
|
||||||
Future<void> updateProject(Project p) async => (await database)
|
Future<void> updateProject(Project p) async => (await database).update(
|
||||||
.update('projects', p.toMap(), where: 'id = ?', whereArgs: [p.id]);
|
'projects', {...p.toMap(), 'sync_status': 'pending'},
|
||||||
|
where: 'id = ?', whereArgs: [p.id]);
|
||||||
|
|
||||||
Future<void> archiveProject(int id) async => (await database).update(
|
Future<void> archiveProject(int id) async => (await database).update(
|
||||||
'projects',
|
'projects',
|
||||||
{'status': 'archived', 'updated_at': DateTime.now().toIso8601String()},
|
{
|
||||||
|
'status': 'archived',
|
||||||
|
'updated_at': DateTime.now().toIso8601String(),
|
||||||
|
'sync_status': 'pending'
|
||||||
|
},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id]);
|
whereArgs: [id]);
|
||||||
|
|
||||||
Future<List<Project>> listProjects({bool includeArchived = false}) async {
|
Future<List<Project>> listProjects({bool includeArchived = false}) async {
|
||||||
final rows = await (await database).query(
|
final rows = await (await database).query(
|
||||||
'projects',
|
'projects',
|
||||||
where: includeArchived ? null : "status = 'active'",
|
where: includeArchived
|
||||||
|
? 'deleted_at IS NULL'
|
||||||
|
: "status = 'active' AND deleted_at IS NULL",
|
||||||
orderBy: 'updated_at DESC',
|
orderBy: 'updated_at DESC',
|
||||||
);
|
);
|
||||||
return rows.map(Project.fromMap).toList();
|
return rows.map(Project.fromMap).toList();
|
||||||
@@ -304,27 +481,37 @@ class AppDatabase {
|
|||||||
|
|
||||||
Future<int> insertTrack(Track track) async {
|
Future<int> insertTrack(Track track) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return db.insert('tracks', track.toMap());
|
final map = _withSyncColumns(track.toMap());
|
||||||
|
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||||
|
return db.insert('tracks', map);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateTrack(Track track) async {
|
Future<void> updateTrack(Track track) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
'tracks',
|
'tracks',
|
||||||
track.toMap(),
|
{...track.toMap(), 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [track.id],
|
whereArgs: [track.id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Soft delete — a törlés ténye is szinkronizálódik a többi eszközre.
|
||||||
|
/// (A track pontjai a szerveroldalon CASCADE-del törlődnek majd.)
|
||||||
Future<void> deleteTrack(int id) async {
|
Future<void> deleteTrack(int id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('tracks', where: 'id = ?', whereArgs: [id]);
|
await db.update(
|
||||||
|
'tracks',
|
||||||
|
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Track>> listTracks() async {
|
Future<List<Track>> listTracks() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query('tracks', orderBy: 'start_time DESC');
|
final rows = await db.query('tracks',
|
||||||
|
where: 'deleted_at IS NULL', orderBy: 'start_time DESC');
|
||||||
return rows.map(Track.fromMap).toList();
|
return rows.map(Track.fromMap).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +534,7 @@ class AppDatabase {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
try {
|
try {
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
await txn.insert('track_points', point.toMap());
|
await txn.insert('track_points', _withTrackPointColumns(point.toMap()));
|
||||||
await txn.rawUpdate('''
|
await txn.rawUpdate('''
|
||||||
UPDATE tracks
|
UPDATE tracks
|
||||||
SET distance_m = ?,
|
SET distance_m = ?,
|
||||||
@@ -361,6 +548,14 @@ class AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// track_points: nincs updated_at (append-only), csak uuid + sync_status.
|
||||||
|
Map<String, dynamic> _withTrackPointColumns(Map<String, dynamic> map) {
|
||||||
|
final m = Map<String, dynamic>.from(map);
|
||||||
|
m['uuid'] ??= _uuid.v4();
|
||||||
|
m['sync_status'] ??= 'pending';
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<TrackPoint>> getPoints(int trackId) async {
|
Future<List<TrackPoint>> getPoints(int trackId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
@@ -472,7 +667,7 @@ class AppDatabase {
|
|||||||
/// Elem mentése - visszaadja a kapott AQLite id-t
|
/// Elem mentése - visszaadja a kapott AQLite id-t
|
||||||
Future<int> insertNoteItem(NoteItem item) async {
|
Future<int> insertNoteItem(NoteItem item) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return db.insert('note_items', item.toMap());
|
return db.insert('note_items', _withSyncColumns(item.toMap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Elem frissítése (szín, label, koordináták módosítása után).
|
/// Elem frissítése (szín, label, koordináták módosítása után).
|
||||||
@@ -480,7 +675,7 @@ class AppDatabase {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
'note_items',
|
'note_items',
|
||||||
item.toMap(),
|
{...item.toMap(), 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [item.id],
|
whereArgs: [item.id],
|
||||||
);
|
);
|
||||||
@@ -489,8 +684,9 @@ class AppDatabase {
|
|||||||
/// Egy elem törlése.
|
/// Egy elem törlése.
|
||||||
Future<void> deleteNoteItem(int id) async {
|
Future<void> deleteNoteItem(int id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete(
|
await db.update(
|
||||||
'note_items',
|
'note_items',
|
||||||
|
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
@@ -533,12 +729,13 @@ class AppDatabase {
|
|||||||
return rows.isEmpty ? null : NoteItem.fromMap(rows.first);
|
return rows.isEmpty ? null : NoteItem.fromMap(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Projekt összes elemének törlése.
|
/// Projekt összes elemének törlése — soft delete.
|
||||||
Future<void> deleteAllNoteItems(int projectId) async {
|
Future<void> deleteAllNoteItems(int projectId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete(
|
await db.update(
|
||||||
'note_items',
|
'note_items',
|
||||||
where: 'project_id = ?',
|
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
|
where: 'project_id = ? AND deleted_at IS NULL',
|
||||||
whereArgs: [projectId],
|
whereArgs: [projectId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -661,18 +858,30 @@ class AppDatabase {
|
|||||||
|
|
||||||
Future<int> insertMeasuredPoint(MeasuredPoint point) async {
|
Future<int> insertMeasuredPoint(MeasuredPoint point) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return db.insert('measured_points', point.toMap());
|
final map = _withSyncColumns(point.toMap());
|
||||||
|
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||||
|
return db.insert('measured_points', map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Soft delete — a törlés is szinkronizálódik.
|
||||||
Future<void> deleteMeasuredPoint(int id) async {
|
Future<void> deleteMeasuredPoint(int id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('measured_points', where: 'id = ?', whereArgs: [id]);
|
await db.update(
|
||||||
|
'measured_points',
|
||||||
|
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteAllMeasuredPoints(int projectId) async {
|
Future<void> deleteAllMeasuredPoints(int projectId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('measured_points',
|
await db.update(
|
||||||
where: 'project_id = ?', whereArgs: [projectId]);
|
'measured_points',
|
||||||
|
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||||
|
where: 'project_id = ? AND deleted_at IS NULL',
|
||||||
|
whereArgs: [projectId],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<MeasuredPoint>> listMeasuredPoints(int projectId) async {
|
Future<List<MeasuredPoint>> listMeasuredPoints(int projectId) async {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:terepi_seged/services/app_logger.dart';
|
import 'package:terepi_seged/services/app_logger.dart';
|
||||||
import 'package:terepi_seged/services/firebase_logger.dart';
|
import 'package:terepi_seged/services/firebase_logger.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -58,8 +59,8 @@ class DeviceIdentityService extends GetxService {
|
|||||||
super.onReady();
|
super.onReady();
|
||||||
await _load();
|
await _load();
|
||||||
// Háttérben regisztrálás — nem blokkolja az UI-t
|
// Háttérben regisztrálás — nem blokkolja az UI-t
|
||||||
unawaited(_registerDevice());
|
|
||||||
_isReady = true;
|
_isReady = true;
|
||||||
|
unawaited(registerDevice());
|
||||||
|
|
||||||
FirebaseLogger.setDeviceIdentifier(info.deviceId, deviceLabel.value);
|
FirebaseLogger.setDeviceIdentifier(info.deviceId, deviceLabel.value);
|
||||||
FirebaseLogger.setKey('model', model);
|
FirebaseLogger.setKey('model', model);
|
||||||
@@ -84,6 +85,15 @@ class DeviceIdentityService extends GetxService {
|
|||||||
final storedLabel = results[2] as String?;
|
final storedLabel = results[2] as String?;
|
||||||
final android = results[3] as AndroidDeviceInfo;
|
final android = results[3] as AndroidDeviceInfo;
|
||||||
final pkg = results[4] as PackageInfo;
|
final pkg = results[4] as PackageInfo;
|
||||||
|
String platform = '';
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
platform = 'Android';
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
platform = 'iOS';
|
||||||
|
} else if (Platform.isMacOS) {
|
||||||
|
platform = 'macOS';
|
||||||
|
}
|
||||||
|
|
||||||
// ANDROID_ID — MethodChannel-en keresztül (Settings.Secure.ANDROID_ID)
|
// ANDROID_ID — MethodChannel-en keresztül (Settings.Secure.ANDROID_ID)
|
||||||
final androidId = await _getAndroidId() ?? android.fingerprint;
|
final androidId = await _getAndroidId() ?? android.fingerprint;
|
||||||
@@ -92,6 +102,7 @@ class DeviceIdentityService extends GetxService {
|
|||||||
info = DeviceInfoModel(
|
info = DeviceInfoModel(
|
||||||
deviceId: androidId,
|
deviceId: androidId,
|
||||||
appInstanceId: instanceId,
|
appInstanceId: instanceId,
|
||||||
|
platform: platform,
|
||||||
manufacturer: android.manufacturer,
|
manufacturer: android.manufacturer,
|
||||||
model: android.model,
|
model: android.model,
|
||||||
brand: android.brand,
|
brand: android.brand,
|
||||||
@@ -106,6 +117,12 @@ class DeviceIdentityService extends GetxService {
|
|||||||
// Label: tárolt érték > rendszer neve
|
// Label: tárolt érték > rendszer neve
|
||||||
deviceLabel.value = storedLabel ?? systemName;
|
deviceLabel.value = storedLabel ?? systemName;
|
||||||
print('Device label: ${deviceLabel.value}');
|
print('Device label: ${deviceLabel.value}');
|
||||||
|
print('DeviceId: ${info.deviceId}');
|
||||||
|
print('AppInstanceId: ${info.appInstanceId}');
|
||||||
|
print('SystemDeviceName: ${info.systemDeviceName}');
|
||||||
|
print('AppVersion: ${info.appVersion}');
|
||||||
|
print('buildNumber: ${info.buildNumber}');
|
||||||
|
print('model: ${info.model}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SecureStorage műveletek ───────────────────────────────────────
|
// ── SecureStorage műveletek ───────────────────────────────────────
|
||||||
@@ -162,13 +179,29 @@ class DeviceIdentityService extends GetxService {
|
|||||||
deviceLabel.value = trimmed;
|
deviceLabel.value = trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(_registerDevice());
|
unawaited(registerDevice());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Supabase regisztráció ─────────────────────────────────────────
|
// ── Supabase regisztráció ─────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _registerDevice() async {
|
Future<void> registerDevice() async {
|
||||||
// await Supabase.instance.client
|
final user = Supabase.instance.client.auth.currentUser;
|
||||||
|
if (user == null || deviceId.isEmpty) return;
|
||||||
|
|
||||||
|
await Supabase.instance.client.from('terepi_seged_devices').upsert({
|
||||||
|
'id': appInstanceId,
|
||||||
|
'user_id': user.id,
|
||||||
|
'name': info.systemDeviceName,
|
||||||
|
'device_id': deviceId,
|
||||||
|
'platform': info.platform,
|
||||||
|
'model': model,
|
||||||
|
'os_version': info.osVersion,
|
||||||
|
'app_version': appInfo,
|
||||||
|
'last_seen_at': DateTime.now().toUtc().toIso8601String()
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// // await Supabase.instance.client
|
||||||
// .from('devices')
|
// .from('devices')
|
||||||
// .upsert(
|
// .upsert(
|
||||||
// info.toRegistrationMap(label: deviceLabel.value),
|
// info.toRegistrationMap(label: deviceLabel.value),
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'app_database.dart';
|
||||||
|
import '../models/imported_layer_meta.dart';
|
||||||
|
|
||||||
|
/// Megosztott referencia-rétegek (pl. gázhálózat nyomvonala) kezelése.
|
||||||
|
///
|
||||||
|
/// Modell: a réteg egy FÁJL (GeoJSON) a Supabase Storage-ban + egy
|
||||||
|
/// metaadat-sor a ts_layers táblában, verziószámmal.
|
||||||
|
///
|
||||||
|
/// * PULL: minden tagsági projekt rétegeinél a verziószámot hasonlítjuk a
|
||||||
|
/// lokálishoz — csak akkor töltünk le, ha nőtt. A letöltött fájl a
|
||||||
|
/// meglévő imported_layers mechanizmusba kerül, így a térkép ugyanúgy
|
||||||
|
/// jeleníti meg, mint a kézzel importált rétegeket.
|
||||||
|
/// * OFFLINE: a lokálisan cache-elt fájl net nélkül is ott van — a réteg
|
||||||
|
/// mindig a legutóbb letöltött állapotában látszik.
|
||||||
|
/// * PUBLISH: egy lokálisan importált réteg megosztása = fájl feltöltése
|
||||||
|
/// a Storage-ba + ts_layers sor (verzió +1 újrapublikáláskor).
|
||||||
|
class LayerSyncService extends GetxService {
|
||||||
|
static LayerSyncService get to => Get.find();
|
||||||
|
|
||||||
|
static const bucket = 'ts-layers';
|
||||||
|
|
||||||
|
final isSyncing = false.obs;
|
||||||
|
|
||||||
|
SupabaseClient get _client => Supabase.instance.client;
|
||||||
|
AppDatabase get _db => AppDatabase.instance;
|
||||||
|
|
||||||
|
/// A rétegfájlok lokális cache-könyvtára.
|
||||||
|
Future<Directory> _layerDir() async {
|
||||||
|
final base = await getExternalStorageDirectory() ??
|
||||||
|
await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory(p.join(base.path, 'layers'));
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// PULL — a TsSyncService hívja minden szinkron végén
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> pullAll() async {
|
||||||
|
if (_client.auth.currentUser == null) return;
|
||||||
|
if (isSyncing.value) return;
|
||||||
|
isSyncing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Csak a szinkronizált (nem lokális) projektek rétegei érdekelnek.
|
||||||
|
final projects = await _db.listProjects();
|
||||||
|
for (final project in projects.where((pr) => !pr.isLocalOnly)) {
|
||||||
|
await _pullProjectLayers(project.id!, project.uuid);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSyncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pullProjectLayers(
|
||||||
|
int localProjectId, String projectUuid) async {
|
||||||
|
final rows = await _client
|
||||||
|
.from('ts_layers')
|
||||||
|
.select()
|
||||||
|
.eq('project_id', projectUuid)
|
||||||
|
.filter('deleted_at', 'is', null);
|
||||||
|
|
||||||
|
for (final r in rows) {
|
||||||
|
final id = r['id'] as String;
|
||||||
|
final remoteVersion = (r['version'] as int?) ?? 1;
|
||||||
|
final localVersion = await _db.getImportedLayerVersion(id);
|
||||||
|
|
||||||
|
// Már megvan a legfrissebb verzió → nincs teendő.
|
||||||
|
if (localVersion >= remoteVersion) continue;
|
||||||
|
|
||||||
|
// Letöltés a Storage-ból, lokális cache-be.
|
||||||
|
final storagePath = r['storage_path'] as String;
|
||||||
|
final bytes = await _client.storage.from(bucket).download(storagePath);
|
||||||
|
|
||||||
|
final dir = await _layerDir();
|
||||||
|
final localPath = p.join(dir.path, '$id.geojson');
|
||||||
|
await File(localPath).writeAsBytes(bytes, flush: true);
|
||||||
|
|
||||||
|
// Metaadat upsert a meglévő imported_layers táblába — a térkép
|
||||||
|
// rétegkezelője innentől ugyanúgy látja, mint a kézi importot.
|
||||||
|
await _db.upsertImportedLayerRaw({
|
||||||
|
'id': id,
|
||||||
|
'name': r['name'],
|
||||||
|
'source_type': r['source_type'] ?? 'geojson',
|
||||||
|
'local_path': localPath,
|
||||||
|
'storage_path': storagePath,
|
||||||
|
'is_visible': 1,
|
||||||
|
'project_id': localProjectId,
|
||||||
|
'imported_at': r['created_at'] ?? DateTime.now().toIso8601String(),
|
||||||
|
'synced_at': DateTime.now().toIso8601String(),
|
||||||
|
'version': remoteVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// PUBLISH — lokális réteg megosztása a projekt tagjaival
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Feltölti a réteg fájlját és létrehozza/frissíti a ts_layers sorát.
|
||||||
|
/// Újrapublikáláskor a verziószám nő → a többi eszköz a következő
|
||||||
|
/// szinkronnál automatikusan frissíti a példányát.
|
||||||
|
Future<void> publishLayer({
|
||||||
|
required ImportedLayerMeta layer,
|
||||||
|
required String projectUuid,
|
||||||
|
}) async {
|
||||||
|
final id = layer.id.isNotEmpty ? layer.id : const Uuid().v4();
|
||||||
|
final fileName = p.basename(layer.localPath);
|
||||||
|
final storagePath = '$projectUuid/$id/$fileName';
|
||||||
|
|
||||||
|
// 1. Fájl a Storage-ba (upsert: újrapublikálásnál felülírja).
|
||||||
|
await _client.storage.from(bucket).upload(
|
||||||
|
storagePath,
|
||||||
|
File(layer.localPath),
|
||||||
|
fileOptions: const FileOptions(upsert: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Verzió léptetése, ha már létezik a sor.
|
||||||
|
final existing = await _client
|
||||||
|
.from('ts_layers')
|
||||||
|
.select('version')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
final newVersion = ((existing?['version'] as int?) ?? 0) + 1;
|
||||||
|
|
||||||
|
await _client.from('ts_layers').upsert({
|
||||||
|
'id': id,
|
||||||
|
'project_id': projectUuid,
|
||||||
|
'name': layer.name,
|
||||||
|
'source_type': layer.sourceType.name,
|
||||||
|
'storage_path': storagePath,
|
||||||
|
'version': newVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Lokális meta frissítése, hogy a saját eszköz ne töltse le újra.
|
||||||
|
await _db.upsertImportedLayerRaw({
|
||||||
|
...layer.toMap(),
|
||||||
|
'id': id,
|
||||||
|
'storage_path': storagePath,
|
||||||
|
'synced_at': DateTime.now().toIso8601String(),
|
||||||
|
'version': newVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réteg visszavonása a megosztásból (soft delete a szerveren;
|
||||||
|
/// a lokális cache-példányok megmaradnak az eszközökön).
|
||||||
|
Future<void> unpublishLayer(String layerId) async {
|
||||||
|
await _client.from('ts_layers').update({
|
||||||
|
'deleted_at': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
}).eq('id', layerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/project.dart';
|
import '../models/project.dart';
|
||||||
import 'app_database.dart';
|
import 'app_database.dart';
|
||||||
|
import 'ts_sync_service.dart';
|
||||||
|
|
||||||
class ProjectService extends GetxService {
|
class ProjectService extends GetxService {
|
||||||
static ProjectService get to => Get.find();
|
static ProjectService get to => Get.find();
|
||||||
@@ -76,7 +77,13 @@ class ProjectService extends GetxService {
|
|||||||
return await AppDatabase.instance.getProject(id) ?? saved;
|
return await AppDatabase.instance.getProject(id) ?? saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Online projekt — szinkronizál Supabase-szel
|
/// Közös (szinkronizált) projekt.
|
||||||
|
///
|
||||||
|
/// OFFLINE-FIRST: csak lokálisan jön létre (sync_status = 'pending'),
|
||||||
|
/// a feltöltést a TsSyncService végzi, amint van hálózat — így a
|
||||||
|
/// projekt-létrehozás net nélkül is működik, és nem ír többé
|
||||||
|
/// közvetlenül a régi Supabase-táblába.
|
||||||
|
|
||||||
Future<Project> createOnlineProject({
|
Future<Project> createOnlineProject({
|
||||||
required String name,
|
required String name,
|
||||||
String client = '',
|
String client = '',
|
||||||
@@ -97,12 +104,18 @@ class ProjectService extends GetxService {
|
|||||||
// Lokálisan mentjük
|
// Lokálisan mentjük
|
||||||
final id = await AppDatabase.instance.insertProject(project);
|
final id = await AppDatabase.instance.insertProject(project);
|
||||||
|
|
||||||
// Supabase-be is
|
// // Supabase-be is
|
||||||
await Supabase.instance.client
|
// await Supabase.instance.client
|
||||||
.from('TerepiSeged_Projects')
|
// .from('TerepiSeged_Projects')
|
||||||
.insert(project.toMap());
|
// .insert(project.toMap());
|
||||||
|
|
||||||
await _loadProjects();
|
await _loadProjects();
|
||||||
|
|
||||||
|
// Ha van net, azonnal fel is megy (és owner-tagság is létrejön).
|
||||||
|
if (Get.isRegistered<TsSyncService>()) {
|
||||||
|
TsSyncService.to.syncNow();
|
||||||
|
}
|
||||||
|
|
||||||
return await AppDatabase.instance.getProject(id) ?? project;
|
return await AppDatabase.instance.getProject(id) ?? project;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +143,64 @@ class ProjectService extends GetxService {
|
|||||||
return await AppDatabase.instance.getProject(id) ?? project;
|
return await AppDatabase.instance.getProject(id) ?? project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// Közös projektek — listázás és csatlakozás
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// A Supabase-en elérhető közös projektek listája
|
||||||
|
/// (név, tulajdonos, tagszám, is_member) — a ts_shared_projects view-ból.
|
||||||
|
Future<List<Map<String, dynamic>>> fetchSharedProjects() async {
|
||||||
|
final rows = await Supabase.instance.client
|
||||||
|
.from('terepi_seged_shared_projects')
|
||||||
|
.select()
|
||||||
|
.order('name', ascending: true);
|
||||||
|
return List<Map<String, dynamic>>.from(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Csatlakozás egy közös projekthez (egyszerűsített modell):
|
||||||
|
/// 1. tagság-sor beszúrása (az RLS csak saját magunkat engedi felvenni),
|
||||||
|
/// 2. a projekt lokális sora létrejön a TÁVOLI uuid-dal — innentől a
|
||||||
|
/// szinkron ugyanúgy kezeli, mint a sajátokat,
|
||||||
|
/// 3. azonnali szinkron, hogy a projekt eddigi adatai lejöjjenek.
|
||||||
|
Future<Project> joinSharedProject(Map<String, dynamic> sharedRow) async {
|
||||||
|
final client = Supabase.instance.client;
|
||||||
|
final projectUuid = sharedRow['id'] as String;
|
||||||
|
|
||||||
|
// 1. Tagság (idempotens: ha már tag, nem hiba).
|
||||||
|
await client.from('terepi_seged_project_members').upsert(
|
||||||
|
{
|
||||||
|
'project_id': projectUuid,
|
||||||
|
'user_id': client.auth.currentUser!.id,
|
||||||
|
'role': 'editor',
|
||||||
|
},
|
||||||
|
ignoreDuplicates: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Lokális projekt-sor a távoli uuid-dal.
|
||||||
|
final localId =
|
||||||
|
await AppDatabase.instance.upsertProjectFromRemote(sharedRow);
|
||||||
|
await _loadProjects();
|
||||||
|
|
||||||
|
// 3. Adatok letöltése háttérben.
|
||||||
|
if (Get.isRegistered<TsSyncService>()) {
|
||||||
|
TsSyncService.to.syncNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await AppDatabase.instance.getProject(localId))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kilépés egy közös projektből: a tagság törlődik a szerveren,
|
||||||
|
/// a lokális adat megmarad (archiválható külön).
|
||||||
|
Future<void> leaveSharedProject(Project project) async {
|
||||||
|
final client = Supabase.instance.client;
|
||||||
|
await client
|
||||||
|
.from('terepi_seged_project_members')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', project.uuid)
|
||||||
|
.eq('user_id', client.auth.currentUser!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
Future<void> reloadProjects() => _loadProjects();
|
Future<void> reloadProjects() => _loadProjects();
|
||||||
|
|
||||||
Future<Map<String, int>> getStats(int projectId) =>
|
Future<Map<String, int>> getStats(int projectId) =>
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:terepi_seged/services/device_identity_service.dart';
|
||||||
|
|
||||||
|
import 'app_database.dart';
|
||||||
|
import 'layer_sync_service.dart';
|
||||||
|
|
||||||
|
/// Outbox-alapú, kétirányú szinkron a ts_* Supabase-táblákkal.
|
||||||
|
///
|
||||||
|
/// Elvek:
|
||||||
|
/// * OFFLINE-FIRST: a UI mindig a lokális DB-ből dolgozik, a szinkron
|
||||||
|
/// háttérfolyamat. Net nélkül minden működik, a változások sorban állnak.
|
||||||
|
/// * A "csak lokális" (is_local_only = 1) projektek és adataik SOHA nem
|
||||||
|
/// kerülnek feltöltésre — a kizárást az AppDatabase pending-lekérdezései
|
||||||
|
/// végzik SQL-szinten, így nem múlhat hívási fegyelmen.
|
||||||
|
/// * PUSH → PULL sorrend: előbb a saját 'pending' sorok mennek fel
|
||||||
|
/// (uuid-ra upsert, ezért idempotens — ismételt feltöltés nem duplikál),
|
||||||
|
/// utána jön le a többiek munkája (updated_at > kurzor).
|
||||||
|
/// * LWW (last-write-wins) az updated_at alapján, amit a SZERVER állít.
|
||||||
|
///
|
||||||
|
/// Triggerek: hálózat visszatérése (connectivity_plus), periodikus időzítő,
|
||||||
|
/// és kézi syncNow() (pl. pull-to-refresh, csatlakozás után).
|
||||||
|
class TsSyncService extends GetxService {
|
||||||
|
static TsSyncService get to => Get.find();
|
||||||
|
|
||||||
|
final isSyncing = false.obs;
|
||||||
|
final lastSyncedAt = Rxn<DateTime>();
|
||||||
|
final lastError = ''.obs;
|
||||||
|
final pendingCount = 0.obs;
|
||||||
|
|
||||||
|
SupabaseClient get _client => Supabase.instance.client;
|
||||||
|
AppDatabase get _db => AppDatabase.instance;
|
||||||
|
|
||||||
|
StreamSubscription? _connSub;
|
||||||
|
Timer? _timer;
|
||||||
|
bool _syncRequestedWhileRunning = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
// Hálózat visszatérésekor azonnali szinkron.
|
||||||
|
_connSub = Connectivity().onConnectivityChanged.listen((results) {
|
||||||
|
final online = results.any((r) => r != ConnectivityResult.none);
|
||||||
|
if (online) syncNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Biztonsági háló: 3 percenként akkor is, ha nem volt esemény.
|
||||||
|
_timer = Timer.periodic(const Duration(minutes: 3), (_) => syncNow());
|
||||||
|
|
||||||
|
refreshPendingCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_connSub?.cancel();
|
||||||
|
_timer?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshPendingCount() async {
|
||||||
|
pendingCount.value = await _db.pendingSyncCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teljes szinkron-ciklus. Újrahívás futás közben nem indít párhuzamos
|
||||||
|
/// ciklust, csak megjegyzi, hogy a végén még egyszer le kell futnia.
|
||||||
|
Future<void> syncNow() async {
|
||||||
|
if (_client.auth.currentUser == null) return;
|
||||||
|
|
||||||
|
if (isSyncing.value) {
|
||||||
|
_syncRequestedWhileRunning = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSyncing.value = true;
|
||||||
|
lastError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Eszköz-regiszter frissítése (last_seen_at).
|
||||||
|
if (Get.isRegistered<DeviceIdentityService>()) {
|
||||||
|
await DeviceIdentityService.to.registerDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _discoverMemberProjects();
|
||||||
|
await _push();
|
||||||
|
await _pull();
|
||||||
|
|
||||||
|
// Megosztott rétegek (5. lépés) — ha a service be van kötve.
|
||||||
|
if (Get.isRegistered<LayerSyncService>()) {
|
||||||
|
await LayerSyncService.to.pullAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncedAt.value = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e.toString();
|
||||||
|
} finally {
|
||||||
|
await refreshPendingCount();
|
||||||
|
isSyncing.value = false;
|
||||||
|
if (_syncRequestedWhileRunning) {
|
||||||
|
_syncRequestedWhileRunning = false;
|
||||||
|
unawaited(syncNow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// Tagság-felderítés
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Ha a felhasználó egy MÁSIK eszközén csatlakozott egy közös
|
||||||
|
/// projekthez, ez hozza létre a lokális projekt-sort itt is — így a
|
||||||
|
/// több-eszközös használat magától konzisztens marad.
|
||||||
|
Future<void> _discoverMemberProjects() async {
|
||||||
|
final rows = await _client
|
||||||
|
.from('terepi_seged_shared_projects')
|
||||||
|
.select()
|
||||||
|
.eq('is_member', true);
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
await _db.upsertProjectFromRemote(Map<String, dynamic>.from(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// PUSH — a 'pending' sorok feltöltése (upsert, idempotens)
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> _push() async {
|
||||||
|
await _pushProjects();
|
||||||
|
await _pushMeasuredPoints();
|
||||||
|
await _pushTracks();
|
||||||
|
await _pushTrackPoints();
|
||||||
|
await _pushNoteItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushProjects() async {
|
||||||
|
final rows = await _db.pendingProjects();
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
|
final payload = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r['uuid'],
|
||||||
|
'name': r['name'],
|
||||||
|
'client': r['client'],
|
||||||
|
'description': r['description'],
|
||||||
|
'crs': r['crs'],
|
||||||
|
'color': r['color'],
|
||||||
|
'status': r['status'],
|
||||||
|
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _client.from('terepi_seged_projects').upsert(payload);
|
||||||
|
await _db.markSyncedByUuid(
|
||||||
|
'projects', rows.map((r) => r['uuid'] as String).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushMeasuredPoints() async {
|
||||||
|
final rows = await _db.pendingMeasuredPoints();
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
|
final payload = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r['uuid'],
|
||||||
|
'project_id': r['project_uuid'],
|
||||||
|
'name': r['name'],
|
||||||
|
'eov_y': r['eov_y'],
|
||||||
|
'eov_x': r['eov_x'],
|
||||||
|
'eov_z': r['eov_z'],
|
||||||
|
'latitude': r['latitude'],
|
||||||
|
'longitude': r['longitude'],
|
||||||
|
'altitude': r['altitude'],
|
||||||
|
'accuracy': r['accuracy'],
|
||||||
|
'fix_quality': r['fix_quality'],
|
||||||
|
'measured_at': _toUtc(r['timestamp'] as String?),
|
||||||
|
'note': r['note'],
|
||||||
|
'device_id': r['device_id'],
|
||||||
|
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _client.from('terepi_seged_measured_points').upsert(payload);
|
||||||
|
await _db.markSyncedByUuid(
|
||||||
|
'measured_points', rows.map((r) => r['uuid'] as String).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushTracks() async {
|
||||||
|
final rows = await _db.pendingTracks();
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
|
final payload = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r['uuid'],
|
||||||
|
'project_id': r['project_uuid'],
|
||||||
|
'name': r['name'],
|
||||||
|
'start_time': _toUtc(r['start_time'] as String?),
|
||||||
|
'end_time': _toUtc(r['end_time'] as String?),
|
||||||
|
'status': r['status'],
|
||||||
|
'source': r['source'],
|
||||||
|
'distance_m': r['distance_m'],
|
||||||
|
'point_count': r['point_count'],
|
||||||
|
'device_id': r['device_id'],
|
||||||
|
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _client.from('terepi_seged_tracks').upsert(payload);
|
||||||
|
await _db.markSyncedByUuid(
|
||||||
|
'tracks', rows.map((r) => r['uuid'] as String).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track-pontok: nagy mennyiség lehet, ezért 500-as batch-ekben, ciklusban,
|
||||||
|
/// amíg el nem fogy. A szerveren nincs UPDATE policy (append-only), ezért
|
||||||
|
/// ignoreDuplicates — az ismételt feltöltés csendben kimarad.
|
||||||
|
Future<void> _pushTrackPoints() async {
|
||||||
|
while (true) {
|
||||||
|
final rows = await _db.pendingTrackPoints(limit: 500);
|
||||||
|
if (rows.isEmpty) break;
|
||||||
|
|
||||||
|
final payload = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r['uuid'],
|
||||||
|
'track_id': r['track_uuid'],
|
||||||
|
'project_id': r['project_uuid'],
|
||||||
|
'latitude': r['latitude'],
|
||||||
|
'longitude': r['longitude'],
|
||||||
|
'altitude': r['altitude'],
|
||||||
|
'accuracy': r['accuracy'],
|
||||||
|
'speed': r['speed'],
|
||||||
|
'heading': r['heading'],
|
||||||
|
'recorded_at': _toUtc(r['timestamp'] as String?),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _client
|
||||||
|
.from('terepi_seged_track_points')
|
||||||
|
.upsert(payload, ignoreDuplicates: true);
|
||||||
|
await _db.markSyncedByUuid(
|
||||||
|
'track_points', rows.map((r) => r['uuid'] as String).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushNoteItems() async {
|
||||||
|
final rows = await _db.pendingNoteItems();
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
|
final payload = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r['uuid'],
|
||||||
|
'project_id': r['project_uuid'],
|
||||||
|
'type': r['type'],
|
||||||
|
'points_json': jsonDecode(r['points_json'] as String),
|
||||||
|
'color': r['color'],
|
||||||
|
'opacity': r['opacity'],
|
||||||
|
'stroke_width': r['stroke_width'],
|
||||||
|
'stroke_color': r['stroke_color'],
|
||||||
|
'label': r['label'],
|
||||||
|
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _client.from('terepi_seged_note_items').upsert(payload);
|
||||||
|
await _db.markSyncedByUuid(
|
||||||
|
'note_items', rows.map((r) => r['uuid'] as String).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// PULL — inkrementális letöltés projektenként (updated_at > kurzor)
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> _pull() async {
|
||||||
|
// Minden szinkronizált (nem lokális) projekt.
|
||||||
|
final projects = await AppDatabase.instance.listProjects();
|
||||||
|
for (final p in projects.where((p) => !p.isLocalOnly)) {
|
||||||
|
await _pullProject(p.id!, p.uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pullProject(int localId, String projectUuid) async {
|
||||||
|
final cursor =
|
||||||
|
await _db.getProjectCursor(localId) ?? '1970-01-01T00:00:00Z';
|
||||||
|
String maxSeen = cursor;
|
||||||
|
|
||||||
|
String track(String? ts) {
|
||||||
|
if (ts != null && DateTime.parse(ts).isAfter(DateTime.parse(maxSeen))) {
|
||||||
|
maxSeen = ts;
|
||||||
|
}
|
||||||
|
return ts ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bemért pontok ───────────────────────────────────────────────
|
||||||
|
final mps = await _client
|
||||||
|
.from('terepi_seged_measured_points')
|
||||||
|
.select()
|
||||||
|
.eq('project_id', projectUuid)
|
||||||
|
.gt('updated_at', cursor)
|
||||||
|
.order('updated_at', ascending: true)
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
for (final r in mps) {
|
||||||
|
track(r['updated_at'] as String?);
|
||||||
|
await _db.applyRemoteRow('measured_points', {
|
||||||
|
'uuid': r['id'],
|
||||||
|
'project_id': localId,
|
||||||
|
'name': r['name'],
|
||||||
|
'eov_y': r['eov_y'],
|
||||||
|
'eov_x': r['eov_x'],
|
||||||
|
'eov_z': r['eov_z'],
|
||||||
|
'latitude': r['latitude'],
|
||||||
|
'longitude': r['longitude'],
|
||||||
|
'altitude': r['altitude'],
|
||||||
|
'accuracy': r['accuracy'],
|
||||||
|
'fix_quality': r['fix_quality'],
|
||||||
|
'timestamp': r['measured_at'],
|
||||||
|
'note': r['note'] ?? '',
|
||||||
|
'created_by': r['created_by'],
|
||||||
|
'device_id': r['device_id'],
|
||||||
|
'updated_at': r['updated_at'],
|
||||||
|
'deleted_at': r['deleted_at'],
|
||||||
|
'sync_status': 'synced',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trackek ─────────────────────────────────────────────────────
|
||||||
|
final tracks = await _client
|
||||||
|
.from('terepi_seged_tracks')
|
||||||
|
.select()
|
||||||
|
.eq('project_id', projectUuid)
|
||||||
|
.gt('updated_at', cursor)
|
||||||
|
.order('updated_at', ascending: true)
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
for (final r in tracks) {
|
||||||
|
track(r['updated_at'] as String?);
|
||||||
|
await _db.applyRemoteRow('tracks', {
|
||||||
|
'uuid': r['id'],
|
||||||
|
'project_id': localId,
|
||||||
|
'name': r['name'],
|
||||||
|
'start_time': r['start_time'],
|
||||||
|
'end_time': r['end_time'],
|
||||||
|
'status': r['status'],
|
||||||
|
'source': r['source'] ?? 'Telefon GPS',
|
||||||
|
'distance_m': r['distance_m'] ?? 0,
|
||||||
|
'point_count': r['point_count'] ?? 0,
|
||||||
|
'is_local_only': 0,
|
||||||
|
'created_by': r['created_by'],
|
||||||
|
'device_id': r['device_id'],
|
||||||
|
'updated_at': r['updated_at'],
|
||||||
|
'deleted_at': r['deleted_at'],
|
||||||
|
'sync_status': 'synced',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Track-pontok (append-only; created_at a szűrő) ─────────────
|
||||||
|
final tps = await _client
|
||||||
|
.from('terepi_seged_track_points')
|
||||||
|
.select()
|
||||||
|
.eq('project_id', projectUuid)
|
||||||
|
.gt('created_at', cursor)
|
||||||
|
.order('created_at', ascending: true)
|
||||||
|
.limit(2000);
|
||||||
|
|
||||||
|
// Távoli track-uuid → lokális track-id feloldás.
|
||||||
|
final trackIds = <String, int?>{};
|
||||||
|
final toInsert = <Map<String, dynamic>>[];
|
||||||
|
for (final r in tps) {
|
||||||
|
track(r['created_at'] as String?);
|
||||||
|
final tUuid = r['track_id'] as String;
|
||||||
|
trackIds[tUuid] ??= await _db.getTrackIdByUuid(tUuid);
|
||||||
|
final localTrackId = trackIds[tUuid];
|
||||||
|
if (localTrackId == null) continue; // a track a következő körben jön
|
||||||
|
|
||||||
|
toInsert.add({
|
||||||
|
'uuid': r['id'],
|
||||||
|
'track_id': localTrackId,
|
||||||
|
'latitude': r['latitude'],
|
||||||
|
'longitude': r['longitude'],
|
||||||
|
'altitude': r['altitude'],
|
||||||
|
'accuracy': r['accuracy'],
|
||||||
|
'speed': r['speed'],
|
||||||
|
'heading': r['heading'],
|
||||||
|
'timestamp': r['recorded_at'],
|
||||||
|
'sync_status': 'synced',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _db.insertRemoteTrackPoints(toInsert);
|
||||||
|
|
||||||
|
// ── Terepbejárás-elemek ────────────────────────────────────────
|
||||||
|
final notes = await _client
|
||||||
|
.from('terepi_seged_note_items')
|
||||||
|
.select()
|
||||||
|
.eq('project_id', projectUuid)
|
||||||
|
.gt('updated_at', cursor)
|
||||||
|
.order('updated_at', ascending: true)
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
for (final r in notes) {
|
||||||
|
track(r['updated_at'] as String?);
|
||||||
|
await _db.applyRemoteRow('note_items', {
|
||||||
|
'uuid': r['id'],
|
||||||
|
'project_id': localId,
|
||||||
|
'type': r['type'],
|
||||||
|
'points_json': jsonEncode(r['points_json']),
|
||||||
|
'color': r['color'],
|
||||||
|
'opacity': r['opacity'],
|
||||||
|
'stroke_width': r['stroke_width'],
|
||||||
|
'stroke_color': r['stroke_color'],
|
||||||
|
'label': r['label'] ?? '',
|
||||||
|
'created_at': r['created_at'],
|
||||||
|
'created_by': r['created_by'],
|
||||||
|
'updated_at': r['updated_at'],
|
||||||
|
'deleted_at': r['deleted_at'],
|
||||||
|
'sync_status': 'synced',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxSeen != cursor) {
|
||||||
|
await _db.setProjectCursor(localId, maxSeen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// A lokális időbélyegek zóna nélküliek (DateTime.now().toIso8601String()),
|
||||||
|
/// a Postgres timestamptz viszont zóna nélküli értéket UTC-nek értelmezne
|
||||||
|
/// → küldés előtt explicit UTC-re konvertálunk, különben 1-2 órát csúszna
|
||||||
|
/// minden időpont.
|
||||||
|
String? _toUtc(String? localIso) {
|
||||||
|
if (localIso == null || localIso.isEmpty) return null;
|
||||||
|
return DateTime.parse(localIso).toUtc().toIso8601String();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,28 +32,25 @@ class _JoinProjectSheetState extends State<JoinProjectSheet> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// TODO
|
_future = ProjectService.to.fetchSharedProjects();
|
||||||
// _future = ProjectService.to.fetchSharedProjects();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
void _reload() {
|
||||||
setState(() {
|
setState(() {
|
||||||
// TODO
|
_future = ProjectService.to.fetchSharedProjects();
|
||||||
//_future = ProjectService.to.fetchSharedProjects();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _join(Map<String, dynamic> row) async {
|
Future<void> _join(Map<String, dynamic> row) async {
|
||||||
setState(() => _joiningId = row['id'] as String);
|
setState(() => _joiningId = row['id'] as String);
|
||||||
try {
|
try {
|
||||||
// TODO
|
final project = await ProjectService.to.joinSharedProject(row);
|
||||||
//final project = await ProjectService.to.joinSharedProject(row);
|
|
||||||
Get.back();
|
Get.back();
|
||||||
// Get.snackbar(
|
Get.snackbar(
|
||||||
// 'Csatlakozva',
|
'Csatlakozva',
|
||||||
// '„${project.name}" — az adatok letöltése a háttérben fut.',
|
'„${project.name}" — az adatok letöltése a háttérben fut.',
|
||||||
// snackPosition: SnackPosition.BOTTOM,
|
snackPosition: SnackPosition.BOTTOM,
|
||||||
// );
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Get.snackbar(
|
Get.snackbar(
|
||||||
'Hiba a csatlakozáskor',
|
'Hiba a csatlakozáskor',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:terepi_seged/widgets/project/join_project_sheet.dart';
|
||||||
|
|
||||||
import '../../models/project.dart';
|
import '../../models/project.dart';
|
||||||
import '../../services/project_service.dart';
|
import '../../services/project_service.dart';
|
||||||
@@ -24,6 +25,11 @@ class ProjectPickerView extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Projekt választó'),
|
title: const Text('Projekt választó'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.group_outlined),
|
||||||
|
tooltip: 'Csatlakozás közös projekthez',
|
||||||
|
onPressed: () => JoinProjectSheet.show(),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'Új projekt',
|
tooltip: 'Új projekt',
|
||||||
|
|||||||
Reference in New Issue
Block a user