15 Commits
Author SHA1 Message Date
gitea_admin 443b35fbb9 Jogosultságok projektenkénti kezelése
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-10 00:18:16 +02:00
gitea_admin e2cedaf235 Felhő projekt létrehozásakor keletkett hiba javítása
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-10 00:11:41 +02:00
gitea_admin 071f6c09ac Track szinkronizációs hiba javítás
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-09 23:51:59 +02:00
gitea_admin fcd3971079 Projektkezeléssel kapcsolatos hibajavítások, projekt törlése a szerveren, bejelentkezés
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-09 23:18:06 +02:00
gitea_admin e1128700f0 (fix) felhős projekthez csak bejelentkezés után lehet csatlakozni, név megjelenítése a kapcsolatok mellett a terepbejárás nézetben.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-07 21:25:31 +02:00
gitea_admin 950c2f48f1 Kapcsolat hozzáadása koordinátával a terepbejárás térképen.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-06 16:14:30 +02:00
gitea_admin 7f514da38f Geopackage export: geometriák exportálása dátum szerinti szűréssel
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 7s
2026-07-30 18:37:53 +02:00
gitea_admin b7da3699e8 Gitea runner teszt
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-07-19 08:47:27 +02:00
gitea_admin 688fe736e7 Teszt felhasználó elmentett munkamenetének törlése induláskor 2026-07-15 04:27:05 +02:00
gitea_admin 4660873350 Navigációs térkép és útvoanlrögzítés hibáinak javítása 2026-07-15 04:21:30 +02:00
gitea_admin 204d848ba4 Navigáció javítása, fejlesztése 2026-07-15 03:13:52 +02:00
gitea_admin 0810c528ec Jármű navigáció @3h 2026-07-13 07:58:51 +02:00
gitea_admin 4bf73c6cc7 Szinkronizáció javítása, 2026-07-12 02:14:49 +02:00
gitea_admin c73adaf9a1 AppLogger bővítése, mentés Supabase-be is 2026-07-11 23:32:20 +02:00
gitea_admin d2605b00da Track rögzítés javítása, távolság limit idő alapú módosítása, Fix #9 @30m 2026-07-11 18:28:01 +02:00
39 changed files with 2836 additions and 173 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
+42 -16
View File
@@ -29,7 +29,7 @@ import '../enums/note_type.dart';
class GeoPackageExporter {
// ── Publikus belépési pont ────────────────────────────────────────
Future<void> exportProject() async {
Future<void> exportProject({DateTime? since}) async {
final project = ProjectService.to.activeProject.value;
final projectId = ProjectService.to.activeProjectId;
final name = project?.name ?? 'projekt';
@@ -42,11 +42,11 @@ class GeoPackageExporter {
try {
// 1. GeoPackage létrehozása
final gpkgPath = p.join(workDir.path, '$name.gpkg');
await _buildGpkg(gpkgPath, projectId);
await _buildGpkg(gpkgPath, projectId, since);
// 2. Médiafájlok összegyűjtése
final mediaDir = Directory(p.join(workDir.path, 'media'));
await _collectMedia(projectId, mediaDir);
await _collectMedia(projectId, mediaDir, since);
// 3. ZIP csomagolás
final zipPath = p.join(tmpDir.path, '${name}_$ts.zip');
@@ -65,13 +65,13 @@ class GeoPackageExporter {
// ── GeoPackage (.gpkg) létrehozása ───────────────────────────────
Future<void> _buildGpkg(String path, int? projectId) async {
Future<void> _buildGpkg(String path, int? projectId, DateTime? since) async {
final db = await openDatabase(path);
try {
await _initGpkg(db);
await _exportNoteItems(db, projectId);
await _exportMeasuredPoints(db, projectId);
await _exportTracks(db, projectId);
await _exportNoteItems(db, projectId, since);
await _exportMeasuredPoints(db, projectId, since);
await _exportTracks(db, projectId, since);
} finally {
await db.close();
}
@@ -187,11 +187,17 @@ class GeoPackageExporter {
// ── NoteItem exportok ─────────────────────────────────────────────
Future<void> _exportNoteItems(Database db, int? projectId) async {
Future<void> _exportNoteItems(
Database db, int? projectId, DateTime? since) async {
final items = await AppDatabase.instance.listNoteItems(projectId);
final points = items.where((i) => i.type == NoteType.point).toList();
final lines = items.where((i) => i.type == NoteType.line).toList();
final polygons = items.where((i) => i.type == NoteType.polygon).toList();
final filtered = since != null
? items.where((i) => i.createdAt.isAfter(since)).toList()
: items;
final points = filtered.where((i) => i.type == NoteType.point).toList();
final lines = filtered.where((i) => i.type == NoteType.line).toList();
final polygons = filtered.where((i) => i.type == NoteType.polygon).toList();
if (points.isNotEmpty) await _exportPoints(db, points);
if (lines.isNotEmpty) await _exportLines(db, lines);
@@ -310,12 +316,19 @@ class GeoPackageExporter {
// ── Bemért pontok exportja ────────────────────────────────────────
Future<void> _exportMeasuredPoints(Database db, int? projectId) async {
Future<void> _exportMeasuredPoints(
Database db, int? projectId, DateTime? since) async {
final points = projectId != null
? await AppDatabase.instance.listMeasuredPoints(projectId)
: <MeasuredPoint>[];
if (points.isEmpty) return;
final filtered = since != null
? points.where((p) => p.timestamp.isAfter(since)).toList()
: points;
if (filtered.isEmpty) return;
await db.execute('''
CREATE TABLE measured_points (
id INTEGER PRIMARY KEY,
@@ -353,13 +366,20 @@ class GeoPackageExporter {
// ── Track exportja ────────────────────────────────────────────────
Future<void> _exportTracks(Database db, int? projectId) async {
Future<void> _exportTracks(
Database db, int? projectId, DateTime? since) async {
final tracks = await AppDatabase.instance.listTracks();
final filtered = projectId != null
var filtered = projectId != null
? tracks.where((t) => t.projectId == projectId).toList()
: tracks;
if (filtered.isEmpty) return;
if (since != null) {
filtered = filtered.where((t) => t.startTime.isAfter(since)).toList();
}
if (filtered.isEmpty) return;
await db.execute('''
CREATE TABLE tracks (
id INTEGER PRIMARY KEY,
@@ -395,7 +415,8 @@ class GeoPackageExporter {
// ── Médiafájlok másolása ──────────────────────────────────────────
Future<void> _collectMedia(int? projectId, Directory mediaDir) async {
Future<void> _collectMedia(
int? projectId, Directory mediaDir, DateTime? since) async {
if (projectId == null) return;
final photos = Directory(p.join(mediaDir.path, 'photos'));
@@ -404,7 +425,12 @@ class GeoPackageExporter {
await audios.create(recursive: true);
final items = await AppDatabase.instance.listNoteItems(projectId);
for (final item in items) {
final filtered = since != null
? items.where((i) => i.createdAt.isAfter(since)).toList()
: items;
for (final item in filtered) {
// Fotók
final photoList = await AppDatabase.instance.listNotePhotos(item.id!);
for (final photo in photoList) {
+1 -6
View File
@@ -1,6 +1 @@
enum MapEditTool {
none,
point,
line,
polygon,
}
enum MapEditTool { none, point, line, polygon, contact }
+14
View File
@@ -30,6 +30,7 @@ import 'package:terepi_seged/services/stakeout_sync_service.dart';
import 'package:terepi_seged/services/tilt_service.dart';
import 'package:terepi_seged/services/track_sync_service.dart';
import 'package:terepi_seged/services/ts_sync_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
import 'package:terepi_seged/services/version_gate_service.dart';
Future<void> main() async {
@@ -54,6 +55,8 @@ Future<void> main() async {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: false);
return true;
}
AppLogger.e('PlatformDispatcher', 'Kezeletlen kivétel',
error: error, stack: stack);
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
@@ -64,6 +67,16 @@ Future<void> main() async {
url: dotenv.env['SUPABASE_URL']!,
anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
// Egyszeri tisztítás: a korábbi, kódba égetett teszt-fiókos
// bejelentkezés munkamenete még mindig elmentve lehet a készüléken —
// ezt itt felismerjük és kijelentkeztetjük, hogy helyette a valódi
// Google-bejelentkezés kerülhessen érvénybe. Valódi felhasználót nem
// érint, mert az ő e-mail-címük sosem egyezik ezzel.
final _restoredUser = Supabase.instance.client.auth.currentUser;
if (_restoredUser?.email == 'test.elek.1@email.hu') {
await Supabase.instance.client.auth.signOut();
}
final versionGate = await checkVersionGate();
if (versionGate.blocked) {
runApp(MaterialApp(
@@ -99,6 +112,7 @@ Future<void> main() async {
Get.put(TiltService());
Get.put(PermissionService());
Get.put(ContactService());
Get.put(VehicleIdentityService());
runApp(const MyApp());
}
+34 -3
View File
@@ -14,8 +14,17 @@ class Contact {
final String email;
final String note;
final String? createdBy;
final DateTime? updatedAt;
/// Opcionális helyszín — WGS84 (lat/lon) és EOV (eovY/eovX) is tárolva,
/// akárcsak a többi geometria-típusnál. Csak akkor van kitöltve, ha a
/// térképi "Kapcsolat" eszközzel vették fel, vagy utólag hozzárendelték.
final double? lat;
final double? lon;
final double? eovY;
final double? eovX;
const Contact({
this.id,
required this.projectId,
@@ -26,15 +35,25 @@ class Contact {
this.note = '',
this.createdBy,
this.updatedAt,
this.lat,
this.lon,
this.eovY,
this.eovX,
});
Contact copyWith({
String? name,
bool get hasLocation => lat != null && lon != null;
Contact copyWith(
{String? name,
String? address,
String? phone,
String? email,
String? note,
}) =>
double? lat,
double? lon,
double? eovY,
double? eovX,
bool clearLocation = false}) =>
Contact(
id: id,
projectId: projectId,
@@ -45,6 +64,10 @@ class Contact {
note: note ?? this.note,
createdBy: createdBy,
updatedAt: updatedAt,
lat: clearLocation ? null : (lat ?? this.lat),
lon: clearLocation ? null : (lon ?? this.lon),
eovX: clearLocation ? null : (eovX ?? this.eovX),
eovY: clearLocation ? null : (eovY ?? this.eovY),
);
/// Beszúráshoz/frissítéshez — az id-t csak akkor küldjük, ha van
@@ -58,6 +81,10 @@ class Contact {
'phone': phone.trim(),
'email': email.trim(),
'note': note.trim(),
'lat': lat,
'lon': lon,
'eov_y': eovY,
'eov_x': eovX,
};
factory Contact.fromMap(Map<String, dynamic> m) => Contact(
@@ -72,5 +99,9 @@ class Contact {
updatedAt: m['updated_at'] != null
? DateTime.tryParse(m['updated_at'] as String)
: null,
lat: (m['lat'] as num?)?.toDouble(),
lon: (m['lon'] as num?)?.toDouble(),
eovY: (m['eov_y'] as num?)?.toDouble(),
eovX: (m['eov_x'] as num?)?.toDouble(),
);
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:uuid/uuid.dart';
/// Érzékelő-csatorna: a MŰSZER geometriája — melyik csatornaszám melyik
/// vonal/állomás fizikai pontjához tartozik.
///
/// SZÁNDÉKOSAN önálló modell (nem a StakeoutPoint kiterjesztése): az
/// adat forrása lehet SPS-import (a műszer geometria-exportja), de
/// származhat máshonnan is (kézi rögzítés, jövőbeli más formátum).
/// A kitűzési (GNSS-szel mért) pontokkal a (lineId, station) párossal
/// vetjük össze FUTÁSIDŐBEN (lásd StakeoutService), nem tárolunk
/// másolatot a mért pozícióból — így sosem megy szét a két adat.
class SensorChannel {
final int? id;
final String uuid;
final int projectId;
final int channel;
final String lineId;
final int station;
/// Terv-pozíció az SPS R-fájlból (ha volt hozzá tartozó vevőpont-sor).
/// Null, ha csak a csatorna-hozzárendelés ismert (pl. csak X-fájl volt),
/// ilyenkor a GNSS-mért kitűzési pont adja az egyetlen pozíciót.
final double? planEovY;
final double? planEovX;
final double? planLat;
final double? planLon;
final String source; // 'sps' | 'manual'
final String? importBatch; // egy import-menet azonosítója (törléshez)
final DateTime createdAt;
final DateTime updatedAt;
SensorChannel({
this.id,
String? uuid,
required this.projectId,
required this.channel,
required this.lineId,
required this.station,
this.planEovY,
this.planEovX,
this.planLat,
this.planLon,
this.source = 'sps',
this.importBatch,
DateTime? createdAt,
DateTime? updatedAt,
}) : uuid = uuid ?? const Uuid().v4(),
createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
bool get hasPlanPosition => planEovY != null && planEovX != null;
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'uuid': uuid,
'project_id': projectId,
'channel': channel,
'line_id': lineId,
'station': station,
'plan_eov_y': planEovY,
'plan_eov_x': planEovX,
'plan_lat': planLat,
'plan_lon': planLon,
'source': source,
'import_batch': importBatch,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory SensorChannel.fromMap(Map<String, dynamic> m) => SensorChannel(
id: m['id'] as int?,
uuid: m['uuid'] as String,
projectId: m['project_id'] as int,
channel: m['channel'] as int,
lineId: (m['line_id'] as String?) ?? '',
station: m['station'] as int,
planEovY: (m['plan_eov_y'] as num?)?.toDouble(),
planEovX: (m['plan_eov_x'] as num?)?.toDouble(),
planLat: (m['plan_lat'] as num?)?.toDouble(),
planLon: (m['plan_lon'] as num?)?.toDouble(),
source: (m['source'] as String?) ?? 'sps',
importBatch: m['import_batch'] as String?,
createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ??
DateTime.now(),
updatedAt: DateTime.tryParse((m['updated_at'] as String?) ?? '') ??
DateTime.now(),
);
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:uuid/uuid.dart';
/// Forráspont (vibrátor-állomás) — az SPS S-fájlból importált TERV-pozíció.
///
/// Tisztán referencia-réteg: nincs "kijelölve/meglőve" mutálható állapota
/// (a döntés szerint a tényleges ellenőrzés a periodikus pozíciónapló és
/// a terv UTÓLAGOS összevetéséből adódik — lásd VibratorNavController).
class SourcePoint {
final int? id;
final String uuid;
final int projectId;
final String lineId;
final int station; // VP (vibration point) szám
final double planEovY;
final double planEovX;
final double planLat;
final double planLon;
final String source; // 'sps'
final String? importBatch;
final DateTime createdAt;
SourcePoint({
this.id,
String? uuid,
required this.projectId,
required this.lineId,
required this.station,
required this.planEovY,
required this.planEovX,
required this.planLat,
required this.planLon,
this.source = 'sps',
this.importBatch,
DateTime? createdAt,
}) : uuid = uuid ?? const Uuid().v4(),
createdAt = createdAt ?? DateTime.now();
String get displayId => '$lineId · $station';
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'uuid': uuid,
'project_id': projectId,
'line_id': lineId,
'station': station,
'plan_eov_y': planEovY,
'plan_eov_x': planEovX,
'plan_lat': planLat,
'plan_lon': planLon,
'source': source,
'import_batch': importBatch,
'created_at': createdAt.toIso8601String(),
};
factory SourcePoint.fromMap(Map<String, dynamic> m) => SourcePoint(
id: m['id'] as int?,
uuid: m['uuid'] as String,
projectId: m['project_id'] as int,
lineId: (m['line_id'] as String?) ?? '',
station: m['station'] as int,
planEovY: (m['plan_eov_y'] as num).toDouble(),
planEovX: (m['plan_eov_x'] as num).toDouble(),
planLat: (m['plan_lat'] as num).toDouble(),
planLon: (m['plan_lon'] as num).toDouble(),
source: (m['source'] as String?) ?? 'sps',
importBatch: m['import_batch'] as String?,
createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ??
DateTime.now(),
);
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:uuid/uuid.dart';
/// Periodikus járműpozíció-minta — a navigációs oldal a beállított
/// időközönként ide ment egy sort, amíg a rögzítés fut. Ebből (és a
/// SourcePoint terv-pozíciókból) számítható utólag, mely forráspontok
/// mellett járt ténylegesen a jármű.
class VehiclePositionLog {
final int? id;
final String uuid;
final int projectId;
final String vehicleId; // pl. "V1" / "V2" / "V3"
final double eovY;
final double eovX;
final double lat;
final double lon;
final double? altitude;
final double? speedKmh;
final double? heading;
final int? fixQuality;
final double? accuracy;
final DateTime timestamp;
final String? deviceId;
final String? appInstanceId;
VehiclePositionLog({
this.id,
String? uuid,
required this.projectId,
required this.vehicleId,
required this.eovY,
required this.eovX,
required this.lat,
required this.lon,
this.altitude,
this.speedKmh,
this.heading,
this.fixQuality,
this.accuracy,
DateTime? timestamp,
this.deviceId,
this.appInstanceId,
}) : uuid = uuid ?? const Uuid().v4(),
timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'uuid': uuid,
'project_id': projectId,
'vehicle_id': vehicleId,
'eov_y': eovY,
'eov_x': eovX,
'lat': lat,
'lon': lon,
'altitude': altitude,
'speed_kmh': speedKmh,
'heading': heading,
'fix_quality': fixQuality,
'accuracy': accuracy,
'timestamp': timestamp.toIso8601String(),
'device_id': deviceId,
'app_instance_id': appInstanceId,
};
factory VehiclePositionLog.fromMap(Map<String, dynamic> m) =>
VehiclePositionLog(
id: m['id'] as int?,
uuid: m['uuid'] as String,
projectId: m['project_id'] as int,
vehicleId: (m['vehicle_id'] as String?) ?? '',
eovY: (m['eov_y'] as num).toDouble(),
eovX: (m['eov_x'] as num).toDouble(),
lat: (m['lat'] as num).toDouble(),
lon: (m['lon'] as num).toDouble(),
altitude: (m['altitude'] as num?)?.toDouble(),
speedKmh: (m['speed_kmh'] as num?)?.toDouble(),
heading: (m['heading'] as num?)?.toDouble(),
fixQuality: m['fix_quality'] as int?,
accuracy: (m['accuracy'] as num?)?.toDouble(),
timestamp: DateTime.tryParse((m['timestamp'] as String?) ?? '') ??
DateTime.now(),
deviceId: m['device_id'] as String?,
appInstanceId: m['app_instance_id'] as String?,
);
}
@@ -24,7 +24,8 @@ class ContactEditView extends StatefulWidget {
class _ContactEditViewState extends State<ContactEditView> {
final _formKey = GlobalKey<FormState>();
late final Contact? _original;
Contact? _original;
String? _originalLocalUuid;
late final TextEditingController _name;
late final TextEditingController _address;
@@ -33,16 +34,30 @@ class _ContactEditViewState extends State<ContactEditView> {
late final TextEditingController _note;
bool _saving = false;
late double? _lat;
late double? _lon;
late double? _eovY;
late double? _eovX;
@override
void initState() {
super.initState();
_original = Get.arguments is Contact ? Get.arguments as Contact : null;
final args = Get.arguments;
if (args is ContactWithState) {
_original = args.contact;
_originalLocalUuid = args.isPending ? args.localUuid : null;
} else if (args is Contact) {
_original = args;
}
_name = TextEditingController(text: _original?.name ?? '');
_address = TextEditingController(text: _original?.address ?? '');
_phone = TextEditingController(text: _original?.phone ?? '');
_email = TextEditingController(text: _original?.email ?? '');
_note = TextEditingController(text: _original?.note ?? '');
_lat = _original?.lat;
_lon = _original?.lon;
_eovY = _original?.eovY;
_eovX = _original?.eovX;
}
@override
@@ -69,8 +84,15 @@ class _ContactEditViewState extends State<ContactEditView> {
phone: _phone.text,
email: _email.text,
note: _note.text,
lat: _lat,
lon: _lon,
eovY: _eovY,
eovX: _eovX,
clearLocation: _lat == null,
);
final queued = await ContactService.to.save(contact);
final queued = await ContactService.to
.save(contact, existingLocalUuid: _originalLocalUuid);
Get.back(result: queued); // a lista frissítéshez visszakapja
if (Get.isRegistered<ContactsController>()) {
Get.find<ContactsController>().load(silent: true);
@@ -182,6 +204,27 @@ class _ContactEditViewState extends State<ContactEditView> {
alignLabelWithHint: true,
),
),
const SizedBox(height: 12),
if (_lat != null && _lon != null)
Card(
color: Colors.indigo.withOpacity(0.06),
child: ListTile(
leading: const Icon(Icons.location_on, color: Colors.indigo),
title: Text(
'${_lat!.toStringAsFixed(6)}, ${_lon!.toStringAsFixed(6)}'),
subtitle: const Text('Helyszín rögzítve'),
trailing: IconButton(
icon: const Icon(Icons.close),
tooltip: 'Helyszín törlése',
onPressed: () => setState(() {
_lat = null;
_lon = null;
_eovY = null;
_eovX = null;
}),
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _saving ? null : _save,
@@ -127,7 +127,7 @@ class ContactsView extends StatelessWidget {
item: rows[i],
onEdit: () async {
final saved = await Get.to(() => const ContactEditView(),
arguments: rows[i].contact);
arguments: rows[i]);
if (saved != null) c.load();
},
onDelete: () => _confirmDelete(c, rows[i]),
@@ -157,10 +157,10 @@ class MapViewController extends GetxController {
prefs = await SharedPreferences.getInstance();
authResponse = await Supabase.instance.client.auth
.signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
session = authResponse.session;
user = authResponse.user;
// authResponse = await Supabase.instance.client.auth
// .signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
// session = authResponse.session;
// user = authResponse.user;
Supabase.instance.client
.channel('public:TerepiSeged_Receiver')
@@ -5,6 +5,7 @@ import 'dart:math';
//import 'dart:math' as math;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
@@ -31,22 +32,26 @@ import 'package:terepi_seged/enums/map_survey_mode.dart';
import 'package:terepi_seged/enums/note_type.dart';
import 'package:terepi_seged/eov/convert_coordinate.dart';
import 'package:terepi_seged/eov/eov.dart';
import 'package:terepi_seged/models/contact.dart';
import 'package:terepi_seged/models/measured_point.dart';
import 'package:terepi_seged/models/note_item.dart';
import 'package:terepi_seged/models/point_to_measure.dart';
import 'package:terepi_seged/models/point_with_description_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:terepi_seged/pages/contacts/presentation/views/contact_edit_view.dart';
import 'package:terepi_seged/pages/map_survey/presentations/views/measured_points_table_dialog.dart';
import 'package:terepi_seged/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart';
import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart';
import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/contact_service.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:terepi_seged/services/gnss/gnss_connection.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/ntrip_service.dart';
import 'package:terepi_seged/services/permission_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart';
import 'package:terepi_seged/widgets/map/all_layer_overlay.dart';
@@ -237,6 +242,7 @@ class MapSurveyController extends GetxController implements StyleEditable {
);
case MapEditTool.point:
case MapEditTool.contact:
case MapEditTool.none:
return '';
}
@@ -300,10 +306,14 @@ class MapSurveyController extends GetxController implements StyleEditable {
gpsHeightController.text = '1.8';
ever(ProjectService.to.activeProject, (_) => _loadNoteItems());
ever(ProjectService.to.activeProject, (_) {
_loadNoteItems();
_loadContactMarkers();
});
await _loadNoteItems();
await _loadMeasurePoints();
await _loadContactMarkers();
_subscribeToTeamPosition();
}
@@ -1088,6 +1098,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return Icons.polyline_outlined;
case MapEditTool.polygon:
return Icons.border_outer_outlined;
case MapEditTool.contact:
return Icons.person_pin_circle_outlined;
case MapEditTool.none:
return Icons.edit_location_alt_outlined;
}
@@ -1101,6 +1113,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Vonal rögzítése';
case MapEditTool.polygon:
return 'Terület rögzítése';
case MapEditTool.contact:
return 'Kapcsolat hozzáadása';
case MapEditTool.none:
return '';
}
@@ -1114,6 +1128,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Hosszan nyomj a térképre a töréspontokhoz';
case MapEditTool.polygon:
return 'Hosszan nyomj a térképre a sarokpontokhoz.';
case MapEditTool.contact:
return 'Hosszan nyomj a térképre a kapcsolat helyéhez.';
case MapEditTool.none:
return '';
}
@@ -1127,6 +1143,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return editorPointCount >= 2;
case MapEditTool.polygon:
return editorPointCount >= 3;
case MapEditTool.contact:
return false;
case MapEditTool.none:
return false;
}
@@ -1140,6 +1158,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Kész';
case MapEditTool.polygon:
return 'Lezárás';
case MapEditTool.contact:
return 'Kész';
case MapEditTool.none:
return 'Kész';
}
@@ -1150,6 +1170,17 @@ class MapSurveyController extends GetxController implements StyleEditable {
activeEditLabel.value = '';
}
void startContactTool() {
if (!PermissionService.to
.canContacts(projectId: ProjectService.to.activeProject.value?.uuid)) {
Get.snackbar(
'Nincs jogosultság', 'Kapcsolatok kezeléséhez jogosultság szükséges.',
snackPosition: SnackPosition.BOTTOM);
return;
}
activeEditTool.value = MapEditTool.contact;
}
void startLineTool() {
polygonEditorController.clear();
polygonEditorController.setMode(PolygonEditorMode.line);
@@ -1207,6 +1238,95 @@ class MapSurveyController extends GetxController implements StyleEditable {
//draftPoints.clear();
}
// ── Kapcsolatok a térképen ──────────────────────────────────────────
final contactMarkers = <Marker>[].obs;
/// Nyers lista a feliratozáshoz (a contactMarkers már kész Marker-eket
/// tartalmaz, abból nem érhető el a név) — ugyanaz a szűrt kör, csak
/// Contact-objektumként, hogy a felirat-réteg is el tudja olvasni.
final contactsWithLocation = <ContactWithState>[].obs;
Future<void> saveContactAtPoint(LatLng point) async {
activeEditTool.value = MapEditTool.none;
if (!PermissionService.to.canContacts(
projectId: ProjectService.to.activeProject.value?.uuid)) return;
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) return;
final eov = CoordConverterService.to.wgsToEovPoint(
point.longitude,
point.latitude,
);
final draft = Contact(
projectId: projectId,
name: '',
lat: point.latitude,
lon: point.longitude,
eovY: eov.x,
eovX: eov.y,
);
final result =
await Get.to(() => const ContactEditView(), arguments: draft);
if (result != null) {
await _loadContactMarkers();
}
}
Future<void> _loadContactMarkers() async {
if (!PermissionService.to
.canContacts(projectId: ProjectService.to.activeProject.value?.uuid)) {
contactMarkers.clear();
contactsWithLocation.clear();
return;
}
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) {
contactMarkers.clear();
contactsWithLocation.clear();
return;
}
try {
final all = await ContactService.to.listMerged(projectId);
final located = all.where((c) => c.contact.hasLocation).toList();
contactsWithLocation.value = located;
contactMarkers.value = located.map(_markerFromContact).toList();
} catch (_) {
// Offline/hiba: a meglévő jelölők maradnak, nem törli ki csendben.
}
}
Marker _markerFromContact(ContactWithState c) {
return Marker(
key: ValueKey('contact_${c.contact.id ?? c.localUuid}'),
point: LatLng(c.contact.lat!, c.contact.lon!),
width: 34,
height: 34,
child: GestureDetector(
onTap: () async {
final result =
await Get.to(() => const ContactEditView(), arguments: c.contact);
if (result != null) await _loadContactMarkers();
},
child: Container(
decoration: BoxDecoration(
color: c.isPending ? Colors.orange : Colors.indigo,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 4),
],
),
child: const Icon(Icons.person, color: Colors.white, size: 20),
),
),
);
}
Marker _markerFromNoteItem(NoteItem item) {
return Marker(
key: ValueKey('note_point_${item.id}'),
@@ -1730,6 +1850,7 @@ class MapSurveyController extends GetxController implements StyleEditable {
break;
case MapEditTool.point:
case MapEditTool.contact:
case MapEditTool.none:
draftLengthMeters.value = 0.0;
draftAreaSquareMeters.value = 0.0;
@@ -1895,6 +2016,9 @@ class MapSurveyController extends GetxController implements StyleEditable {
showGeometryLabels.value = !showGeometryLabels.value;
Future<void> exportProject() async {
final since = await _showExportDateDialog();
if (since == false) return; // felhasználó megszakította
Get.dialog(
Center(
child: Material(
@@ -1932,6 +2056,59 @@ class MapSurveyController extends GetxController implements StyleEditable {
}
}
/// Visszaad: DateTime (szűrt export), null (teljes export), false (mégse)
Future<dynamic> _showExportDateDialog() async {
return Get.dialog<dynamic>(AlertDialog(
title: const Text('Export tartalma'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.all_inclusive),
title: const Text('Teljes projekt'),
onTap: () => Get.back(result: null),
),
ListTile(
leading: const Icon(Icons.today),
title: const Text('Mai nap'),
onTap: () => Get.back(
result: DateTime(DateTime.now().year, DateTime.now().month,
DateTime.now().day)),
),
ListTile(
leading: const Icon(Icons.today),
title: const Text('Tegnapi nap'),
onTap: () => Get.back(
result: DateTime(DateTime.now().year, DateTime.now().month,
DateTime.now().day)
.subtract(const Duration(days: 1))),
),
ListTile(
leading: const Icon(Icons.date_range),
title: const Text('Dátum kiválasztása...'),
onTap: () async {
Get.back();
final picked = await showDatePicker(
context: Get.context!,
initialDate: DateTime.now().subtract(const Duration(days: 7)),
firstDate: DateTime(2024),
lastDate: DateTime.now(),
);
if (picked != null) Get.back(result: picked);
},
),
],
),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Mégse'),
),
],
));
}
void _subscribeToTeamPosition() {
_teamChannel = Supabase.instance.client
.channel('public:terepi_seged_device_positions')
@@ -64,6 +64,10 @@ class MapSurveyView extends GetView<MapSurveyController> {
}
return;
}
if (controller.activeEditTool.value == MapEditTool.contact) {
controller.saveContactAtPoint(point);
return;
}
if (controller.activeEditTool.value == MapEditTool.line ||
controller.activeEditTool.value == MapEditTool.polygon) {
controller.polygonEditorController.addPoint(point);
@@ -142,6 +146,13 @@ class MapSurveyView extends GetView<MapSurveyController> {
return MarkerLayer(markers: [...controller.pointNotes]);
}),
Obx(() {
// Kapcsolatok - terepi bejárás
if (controller.mode.value != MapSurveyMode.fieldWalk) {
return const SizedBox.shrink();
}
return MarkerLayer(markers: [...controller.contactMarkers]);
}),
Obx(() {
// Vonalak - terepbejárás
if (controller.mode.value != MapSurveyMode.fieldWalk) {
@@ -162,10 +162,17 @@ class NavigationViewController extends GetxController {
mapController = MapController();
prefs = await SharedPreferences.getInstance();
authResponse = await Supabase.instance.client.auth
.signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
session = authResponse.session;
user = authResponse.user;
// authResponse = await Supabase.instance.client.auth
// .signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
// session = authResponse.session;
// user = authResponse.user;
// A korábbi, kódba égetett teszt-bejelentkezés törölve — az app
// mostantól a valódi Google-bejelentkezést (AuthService) használja.
// Itt csak a JELENLEGI (ha van) munkamenetet olvassuk ki, nem
// erőltetünk újat.
session = Supabase.instance.client.auth.currentSession;
user = Supabase.instance.client.auth.currentUser;
// riveGpsIconController = RiveUtils.getRiveController(Artboard(),
// stateMachineName: "gps_Interactivity");
@@ -329,17 +329,35 @@ class TrackingController extends GetxController {
pos.latitude,
pos.longitude,
);
// Szűrés: ugrásszerű változás (pl. GPS lock elvesztése) ignorálása
if (segmentDist > 100) {
// Szűrés: valóban LEHETETLEN ugrás kiszűrése (pl. GPS lock-
// vesztés utáni téves fix) — az ELTELT IDŐT is figyelembe véve.
// A korábbi, sima 100 m-es távolság-küszöb gyorsabb haladásnál
// (pl. földúton autóval) egy rövid, teljesen normál jel-
// kimaradás után érkező, VALÓS pontokat is kiszűrt, mert
// néhány másodperc alatt egy autó simán megtesz 100+ métert.
final elapsedSec =
point.timestamp.difference(_lastPoint!.timestamp).inMilliseconds /
1000.0;
final impliedSpeedMs =
elapsedSec > 0 ? segmentDist / elapsedSec : double.infinity;
// ~50 m/s = 180 km/h — terepen, földúton ennél gyorsabban senki
// nem halad, tehát ami ennél nagyobb sebességet implikálna, az
// GPS-hiba, nem valódi mozgás.
const maxPlausibleSpeedMs = 50.0;
if (impliedSpeedMs > maxPlausibleSpeedMs) {
AppLogger.w(
'_onPosition',
'GPS ugrás kiszűrve: ${segmentDist.toStringAsFixed(0)}m '
'(pts: ${livePoints.length})');
_lastPoint = point; // reset — következő pont ettől mér
'GPS ugrás kiszűrve: ${segmentDist.toStringAsFixed(0)}m / '
'${elapsedSec.toStringAsFixed(1)}s '
'(${(impliedSpeedMs * 3.6).toStringAsFixed(0)} km/h, '
'pts: ${livePoints.length})');
_lastPoint = point;
return;
}
}
_accumulatedDistance += segmentDist;
sessionDistance.value = _accumulatedDistance;
@@ -0,0 +1,257 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/models/source_point.dart';
import 'package:terepi_seged/models/vechicle_position_log.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
/// Egyszerű navigációs panel a jelgerjesztő (vibrátor) járműhöz.
///
/// A pozíció a KITŰZÉSSEL AZONOS forrásból jön (GnssService — külső
/// BT/BLE GNSS-egység a járműben), semmilyen új eszköz-integráció nem
/// kell hozzá. A forráspontok tisztán referencia-réteg (nincs mutálható
/// "meglőve" állapotuk); az ellenőrzés a periodikus pozíciónapló és a
/// terv UTÓLAGOS összevetéséből adódik (lásd isVerified).
class VibroNavController extends GetxController {
AppDatabase get _db => AppDatabase.instance;
// ── Forráspontok ─────────────────────────────────────────────────
final sourcePoints = <SourcePoint>[].obs;
/// Egyezés-tűrés (m) a naplózott pozíció és a terv-forráspont között.
final tolerance = 15.0.obs;
// ── Élő pozíció (a GnssService-ből, EOV-ra konvertálva) ───────────
final hasPosition = false.obs;
final curEovY = 0.0.obs;
final curEovX = 0.0.obs;
final speedKmh = 0.0.obs;
final sessionDistanceM = 0.0.obs;
/// Haladási irány (fok) — pozíció-előzményből számolva, ALACSONY
/// SEBESSÉGNÉL BEFAGYASZTVA (a nyers GPS-heading álló/lassú helyzetben
/// zajos/értelmetlen — ugyanez a minta, mint a Kitűzésnél).
final travelHeading = Rxn<double>();
static const _headingFreezeSpeedKmh = 5.0;
double? _histY, _histX;
DateTime? _histTime;
// ── Legközelebbi forráspont ───────────────────────────────────────
final nearestPoint = Rxn<SourcePoint>();
final nearestDistance = 0.0.obs;
// ── Periodikus napló ───────────────────────────────────────────────
final isLogging = false.obs;
final logIntervalSec = 5.obs; // felhasználó által állítható
final logs = <VehiclePositionLog>[].obs;
Timer? _logTimer;
// ── Térkép — a MapController itt él, nem a View State-jében ────────
final mapController = MapController();
StreamSubscription? _gnssUpdateSub;
/// A kezdő térképközéppont lekérdezése (gyors, telefon-GPS-alapú)
/// befejeződött-e — a View ez alapján dönti el, mikor építse fel a
/// térképet, hogy ne induljon rossz (pl. budapesti) középponttal.
final mapReady = false.obs;
@override
void onInit() {
super.onInit();
load();
refreshLogs();
if (Get.isRegistered<GnssService>()) {
// FONTOS: a lastGgaLine csak NMEA (külső BT/BLE vevő) esetén
// frissül — telefon GPS-nél sosem. Az onDataUpdated viszont
// MINDKÉT forrásnál lefut, ez a helyes, egységes jelzés.
_gnssUpdateSub =
GnssService.to.onDataUpdated.listen((_) => _onPosition());
GnssService.to.determineInitialPosition().whenComplete(() {
mapReady.value = true;
});
} else {
mapReady.value = true;
}
// Automatikus indítás: amint van pozíció ÉS ki van választva jármű,
// induljon a rögzítés — korábban ez a bekötés hiányzott, a
// _tryAutoStart() metódust semmi nem hívta meg.
ever(hasPosition, (_) => _tryAutoStart());
ever(VehicleIdentityService.to.selectedVehicle, (_) => _tryAutoStart());
}
@override
void onClose() {
_logTimer?.cancel();
_gnssUpdateSub?.cancel();
super.onClose();
}
Future<void> refreshLogs() async {
logs.value = await loadLogs();
}
Future<void> load() async {
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) {
sourcePoints.clear();
return;
}
sourcePoints.value = await _db.listSourcePoints(projectId);
}
// ═════════════════════════════════════════════════════════════════
// Pozíció-feldolgozás
// ═════════════════════════════════════════════════════════════════
void _onPosition() {
final gnss = GnssService.to;
if (gnss.gpsQuality.value <= 0 ||
gnss.latitude.value == 0 ||
!Get.isRegistered<CoordConverterService>()) {
return;
}
final p = CoordConverterService.to
.wgsToEovPoint(gnss.longitude.value, gnss.latitude.value);
final now = DateTime.now();
if (_histY != null && _histTime != null) {
final dy = p.x - _histY!;
final dx = p.y - _histX!;
final dist = math.sqrt(dy * dy + dx * dx);
final dtSec = now.difference(_histTime!).inMilliseconds / 1000.0;
if (dtSec > 0) {
final v = dist / dtSec; // m/s
speedKmh.value = v * 3.6;
sessionDistanceM.value += dist;
// Irány csak akkor frissül, ha a sebesség a fagyasztási küszöb
// felett van — álló/nagyon lassú helyzetben a nyers irány zajos.
if (speedKmh.value >= _headingFreezeSpeedKmh && dist > 0.3) {
travelHeading.value =
(math.atan2(dy, dx) * 180 / math.pi + 360) % 360;
}
}
}
_histY = p.x;
_histX = p.y;
_histTime = now;
curEovY.value = p.x;
curEovX.value = p.y;
hasPosition.value = true;
_updateNearest();
}
void _updateNearest() {
if (sourcePoints.isEmpty) {
nearestPoint.value = null;
return;
}
SourcePoint? best;
var bestDist = double.infinity;
for (final sp in sourcePoints) {
final dy = sp.planEovY - curEovY.value;
final dx = sp.planEovX - curEovX.value;
final d = math.sqrt(dy * dy + dx * dx);
if (d < bestDist) {
bestDist = d;
best = sp;
}
}
nearestPoint.value = best;
nearestDistance.value = bestDist;
}
// ═════════════════════════════════════════════════════════════════
// Terv/napló összevetés — melyik forráspont "igazolt"
// ═════════════════════════════════════════════════════════════════
/// Egyszerű, szinkron ellenőrzés a már betöltött [logs] lista alapján
/// (a nézet előre lekéri, hogy ne fusson adatbázis-lekérdezés minden
/// egyes marker kirajzolásakor).
bool isVerified(SourcePoint sp, List<VehiclePositionLog> logs) {
for (final log in logs) {
final dy = sp.planEovY - log.eovY;
final dx = sp.planEovX - log.eovX;
if (math.sqrt(dy * dy + dx * dx) <= tolerance.value) return true;
}
return false;
}
Future<List<VehiclePositionLog>> loadLogs() async {
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) return [];
return _db.listVehiclePositionLogs(projectId);
}
// ═════════════════════════════════════════════════════════════════
// Periodikus rögzítés
// ═════════════════════════════════════════════════════════════════
void startLogging() {
if (isLogging.value) return;
isLogging.value = true;
_logTick(); // azonnali első pont
_logTimer = Timer.periodic(
Duration(seconds: logIntervalSec.value), (_) => _logTick());
}
void stopLogging() {
isLogging.value = false;
_logTimer?.cancel();
_logTimer = null;
}
void _tryAutoStart() {
if (isLogging.value) return;
if (!hasPosition.value) return;
if (VehicleIdentityService.to.selectedVehicle.value == null) return;
startLogging();
}
Future<void> _logTick() async {
if (!hasPosition.value) return;
final projectId = ProjectService.to.activeProjectId;
final vehicleId = VehicleIdentityService.to.selectedVehicle.value;
if (projectId == null || vehicleId == null) return;
final gnss = GnssService.to;
final entry = VehiclePositionLog(
projectId: projectId,
vehicleId: vehicleId,
eovY: curEovY.value,
eovX: curEovX.value,
lat: gnss.latitude.value,
lon: gnss.longitude.value,
altitude: gnss.altitude.value,
speedKmh: speedKmh.value,
heading: travelHeading.value,
fixQuality: gnss.gpsQuality.value,
accuracy: gnss.horizontalAccuracy,
deviceId: Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.deviceId
: null,
appInstanceId: Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
: null,
);
await _db.insertVehiclePositionLog(entry);
logs.add(entry);
}
}
@@ -0,0 +1,549 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/models/source_point.dart';
import 'package:terepi_seged/models/vechicle_position_log.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_connection.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/layer_import_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/sps_import_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
import 'package:terepi_seged/widgets/map/animated_map_follow.dart';
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
import '../controllers/vibro_nav_controller.dart';
/// Egyszerű navigációs oldal a vibrátor-járműhöz. ÖNÁLLÓ oldal, nem
/// MapSurveyMode — saját MapController kell a forgó (track-up)
/// térképhez, hogy ez semmilyen más módot ne érintsen.
class VibroNavView extends GetView<VibroNavController> {
const VibroNavView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Navigáció'),
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_connected),
tooltip: "Gyors újracsatlakozás a GNSS vevőhöz",
onPressed: _quickReconnectGnss,
),
IconButton(
icon: const Icon(Icons.layers_outlined),
tooltip: 'Réteg importja (GeoJSON/KML/KMZ)',
onPressed: _importLayer,
),
IconButton(
icon: const Icon(Icons.upload_file),
tooltip: 'Forráspontok importja (SPS S-fájl)',
onPressed: _importSourcePoints,
),
IconButton(
icon: const Icon(Icons.local_shipping_outlined),
tooltip: 'Jármű kiválasztása',
onPressed: _pickVehicle,
),
],
),
body: Obx(() {
if (!controller.mapReady.value) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Pozíció lekérése…'),
],
),
);
}
return Stack(
children: [
_buildMap(),
Builder(builder: (context) {
final target = controller.hasPosition.value &&
Get.isRegistered<GnssService>()
? LatLng(GnssService.to.latitude.value,
GnssService.to.longitude.value)
: null;
return AnimatedMapFollow(
mapController: controller.mapController,
target: target,
heading: controller.travelHeading.value,
);
}),
Positioned(
left: 8,
right: 8,
top: 8,
child: Row(
children: [
Expanded(child: _VehicleBadge()),
const SizedBox(width: 8),
const _GnssStatusBadge(),
],
),
),
Positioned(
left: 8,
right: 8,
bottom: 8,
child: _NavPanel(controller: controller),
),
],
);
}));
}
Widget _buildMap() {
return Obx(() {
final points = controller.sourcePoints;
final nearest = controller.nearestPoint.value;
final gnssPos =
Get.isRegistered<GnssService>() && GnssService.to.latitude.value != 0
? LatLng(
GnssService.to.latitude.value, GnssService.to.longitude.value)
: null;
final center = gnssPos ?? const LatLng(47.5, 19.05);
return FlutterMap(
mapController: controller.mapController,
options: MapOptions(
initialCenter: center,
initialZoom: 15,
),
children: [
// TileLayer(
// urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
// userAgentPackageName: 'hu.app_dev.terepi_seged',
// ),
TileLayer(
urlTemplate: 'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}',
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
maxNativeZoom: 18,
),
// Tervezett útvonal + minden más importált háttérréteg — a
// MEGLÉVŐ rétegimport-mechanizmus, nincs hozzá új kód.
const ImportedLayerOverlay(),
MarkerLayer(markers: [
for (final sp in points)
Marker(
point: LatLng(sp.planLat, sp.planLon),
width: 40,
height: 40,
child: _SourcePointMarker(
point: sp,
isNearest: sp.uuid == nearest?.uuid,
isVerified: controller.isVerified(sp, controller.logs),
),
),
if (controller.hasPosition.value)
Marker(
point: LatLng(GnssService.to.latitude.value,
GnssService.to.longitude.value),
width: 34,
height: 34,
child: const _VehicleMarker(),
),
]),
],
);
});
}
// ── Jármű-választás ────────────────────────────────────────────────
Future<void> _pickVehicle() async {
final chosen = await Get.dialog<String>(AlertDialog(
title: const Text('Melyik járműben van ez a tablet?'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in VehicleIdentityService.availableVehicles)
RadioListTile<String>(
title: Text(v),
value: v,
groupValue: VehicleIdentityService.to.selectedVehicle.value,
onChanged: (val) => Get.back(result: val),
),
],
),
));
if (chosen != null) {
await VehicleIdentityService.to.setVehicle(chosen);
}
}
Future<void> _quickReconnectGnss() async {
final device = GnssDeviceService.to.selectedDevice.value;
if (device == null) {
Get.snackbar(
'Nincs korábbi GNSS-eszköz',
'Előbb válassz ki egyet a Kitűzés/Mérés beállításaiban.',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
Get.snackbar(
'Újracsatlakozás',
'${device.name}" (${device.typeLabel}) — folyamatban…',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
await GnssService.to.reconnect();
}
Future<void> _importLayer() async {
try {
final layer = await LayerImportService.to.importFile();
if (layer != null) {
Get.snackbar('Réteg importálva', layer.name,
snackPosition: SnackPosition.BOTTOM);
}
} catch (e) {
Get.snackbar('Import hiba', e.toString(),
snackPosition: SnackPosition.BOTTOM);
}
}
// ── SPS S-fájl (forráspont) import — kompakt, egyetlen dialógus ────
Future<void> _importSourcePoints() async {
final result = await FilePicker.platform.pickFiles(type: FileType.any);
final path = result?.files.single.path;
if (path == null) return;
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) return;
try {
final content = await File(path).readAsString();
final points = SpsParser.parseSourceFile(content);
if (points.isEmpty) {
Get.snackbar(
'Import',
'Nem sikerült forráspontot beolvasni ebből a fájlból — '
'ellenőrizd, hogy valódi SPS S-fájl-e.',
snackPosition: SnackPosition.BOTTOM);
return;
}
final withPos = points.where((p) => p.eovY != null && p.eovX != null);
final ok = await Get.dialog<bool>(AlertDialog(
title: const Text('Forráspontok importja'),
content: Text('${points.length} pont az S-fájlban, '
'${withPos.length} érvényes koordinátával.\n\n'
'Első néhány: ${points.take(3).map((p) => '${p.lineId}·${p.station}').join(', ')}'),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Mégse')),
FilledButton(
onPressed: () => Get.back(result: true),
child: const Text('Import')),
],
));
if (ok != true) return;
final conv = CoordConverterService.to;
final batch = DateTime.now().toIso8601String();
final saved = <SourcePoint>[];
for (final p in withPos) {
final w = conv.eovToWgsPoint(p.eovY!, p.eovX!);
saved.add(SourcePoint(
projectId: projectId,
lineId: p.lineId,
station: p.station,
planEovY: p.eovY!,
planEovX: p.eovX!,
planLat: w.y,
planLon: w.x,
importBatch: batch,
));
}
final inserted = await AppDatabase.instance.insertSourcePoints(saved);
await controller.load();
await controller.refreshLogs();
Get.snackbar('Import kész', '$inserted forráspont importálva',
snackPosition: SnackPosition.BOTTOM);
} catch (e) {
Get.snackbar('Import hiba', e.toString(),
snackPosition: SnackPosition.BOTTOM);
}
}
}
// ═══════════════════════════════════════════════════════════════════
// HUD panel: sebesség, táv, legközelebbi pont, rögzítés vezérlés
// ═══════════════════════════════════════════════════════════════════
class _NavPanel extends StatelessWidget {
final VibroNavController controller;
const _NavPanel({required this.controller});
@override
Widget build(BuildContext context) {
return Obx(() {
final nearest = controller.nearestPoint.value;
return Card(
elevation: 6,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_Stat(
label: 'Sebesség',
value:
'${controller.speedKmh.value.toStringAsFixed(0)} km/h'),
_Stat(
label: 'Megtett táv',
value: _fmtDist(controller.sessionDistanceM.value)),
if (nearest != null)
_Stat(
label: 'Legközelebbi VP',
value: nearest.displayId,
highlight: true),
if (nearest != null)
_Stat(
label: 'Táv odáig',
value: _fmtDist(controller.nearestDistance.value)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: controller.isLogging.value
? OutlinedButton.icon(
icon: const Icon(Icons.stop_circle_outlined),
label: Text('Rögzítés leállítása '
'(${controller.logIntervalSec.value} mp)'),
onPressed: () {
controller.stopLogging();
},
)
: FilledButton.icon(
icon: const Icon(Icons.fiber_manual_record,
color: Colors.red),
label: Text('Rögzítés indítása '
'(${controller.logIntervalSec.value} mp-enként)'),
onPressed: () {
controller.startLogging();
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.timer_outlined),
tooltip: 'Rögzítési időköz',
onPressed: controller.isLogging.value
? null
: () => _pickInterval(controller),
),
],
),
],
),
),
);
});
}
Future<void> _pickInterval(VibroNavController c) async {
final opts = [15, 30, 60, 120, 300];
final chosen = await Get.dialog<int>(AlertDialog(
title: const Text('Rögzítési időköz'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final s in opts)
RadioListTile<int>(
title: Text(s < 60 ? '$s másodperc' : '${s ~/ 60} perc'),
value: s,
groupValue: c.logIntervalSec.value,
onChanged: (v) => Get.back(result: v),
),
],
),
));
if (chosen != null) c.logIntervalSec.value = chosen;
}
static String _fmtDist(double m) => m < 1000
? '${m.toStringAsFixed(0)} m'
: '${(m / 1000).toStringAsFixed(2)} km';
}
class _Stat extends StatelessWidget {
final String label;
final String value;
final bool highlight;
const _Stat(
{required this.label, required this.value, this.highlight = false});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(value,
style: TextStyle(
fontSize: highlight ? 16 : 18,
fontWeight: FontWeight.w700,
color:
highlight ? Theme.of(context).colorScheme.primary : null)),
Text(label,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600)),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════
class _VehicleBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() {
final v = VehicleIdentityService.to.selectedVehicle.value;
return Align(
alignment: Alignment.topLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: v == null
? Colors.orange.withOpacity(0.9)
: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: Text(
v == null ? 'Nincs jármű kiválasztva!' : 'Jármű: $v',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
);
});
}
}
class _SourcePointMarker extends StatelessWidget {
final SourcePoint point;
final bool isNearest;
final bool isVerified;
const _SourcePointMarker(
{required this.point, required this.isNearest, required this.isVerified});
@override
Widget build(BuildContext context) {
final color = isVerified ? Colors.green : Colors.grey.shade600;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isVerified ? Icons.check_circle : Icons.radio_button_unchecked,
color: isNearest ? Colors.red : color,
size: isNearest ? 26 : 18,
),
Text(point.displayId,
style: TextStyle(
fontSize: 9,
fontWeight: isNearest ? FontWeight.w700 : FontWeight.w400,
color: isNearest ? Colors.red : Colors.black87)),
],
);
}
}
class _VehicleMarker extends StatelessWidget {
const _VehicleMarker();
@override
Widget build(BuildContext context) {
// Track-up módban a szimbólum mindig "felfelé" mutat — a világ
// forog körülötte, nem a szimbólum a világ körül.
return Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(color: Colors.blue.withOpacity(0.5), blurRadius: 8)
],
),
child: const Icon(Icons.navigation, color: Colors.white, size: 18),
);
}
}
class _GnssStatusBadge extends StatelessWidget {
const _GnssStatusBadge();
@override
Widget build(BuildContext context) {
if (!Get.isRegistered<GnssService>()) return const SizedBox.shrink();
return Obx(() {
final gnss = GnssService.to;
final state = gnss.connectionState.value;
final quality = gnss.gpsQuality.value;
Color color;
IconData icon;
String label;
if (state == GnssConnectionState.connected && quality > 0) {
color = Colors.green;
icon = Icons.gps_fixed;
label = quality >= 4 ? 'RTK fix' : (quality == 2 ? 'RTK float' : 'GPS');
} else if (state == GnssConnectionState.connected) {
color = Colors.orange;
icon = Icons.gps_not_fixed;
label = 'Nincs fix';
} else if (state == GnssConnectionState.connecting) {
color = Colors.orange;
icon = Icons.gps_not_fixed;
label = 'Csatlakozás…';
} else {
color = Colors.red;
icon = Icons.gps_off;
label = 'Nincs GNSS';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
],
),
);
});
}
}
+12 -1
View File
@@ -27,6 +27,8 @@ import 'package:terepi_seged/pages/start/bindings/start_page_bindings.dart';
import 'package:terepi_seged/pages/start/presentation/views/start_page.dart';
import 'package:terepi_seged/pages/tracking/bindings/tracking_bindings.dart';
import 'package:terepi_seged/pages/tracking/presentation/views/tracking_view.dart';
import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart';
import 'package:terepi_seged/pages/vibro_nav/presentation/views/vibro_nav_view.dart';
import '../pages/map_test/bindings/map_test_bindings.dart';
import '../pages/map_test/presentation/views/map_test_view.dart';
@@ -106,6 +108,15 @@ class AppPages {
GetPage(name: Routes.SETTINGS, page: () => const SettingsView()),
GetPage(
name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()),
GetPage(name: Routes.CONTACTS, page: () => const ContactsView())
GetPage(name: Routes.CONTACTS, page: () => const ContactsView()),
GetPage(
name: Routes.VIBRONAV,
page: () => const VibroNavView(),
binding: BindingsBuilder(() {
if (Get.isRegistered<VibroNavController>()) {
Get.delete<VibroNavController>(force: true);
}
Get.put(VibroNavController());
}))
];
}
+1
View File
@@ -26,4 +26,5 @@ abstract class Routes {
static const SETTINGS = '/settings';
static const STAKEOUT_IMPORT = '/stakeout_import';
static const VIBRONAV = '/vibro_nav';
}
+207 -8
View File
@@ -11,8 +11,11 @@ import 'package:terepi_seged/models/measured_point.dart';
import 'package:terepi_seged/models/note_item.dart';
import 'package:terepi_seged/models/note_item_audio.dart';
import 'package:terepi_seged/models/note_item_photo.dart';
import 'package:terepi_seged/models/source_point.dart';
import 'package:terepi_seged/models/stakeout_point.dart';
import 'package:terepi_seged/models/track.dart';
import 'package:terepi_seged/models/vechicle_position_log.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:uuid/uuid.dart';
import '../models/project.dart';
@@ -44,7 +47,7 @@ class AppDatabase {
final path = p.join(dbDir.path, 'terepi_seged.db');
return openDatabase(path,
version: 5,
version: 8,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: _onCreate,
onUpgrade: _onUpgrade);
@@ -237,7 +240,7 @@ class AppDatabase {
vertical_error REAL,
description TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
project_id INTEGER NOT NULL DEFAULT 2,
project_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending'
)
@@ -269,7 +272,12 @@ class AppDatabase {
await _createStakeoutTable(db);
await _createContactsOutbox(db);
await _addAppInstanceIdColumns(db);
await _addContactLocationColumns(db);
await _addProjectMissingStreakColumn(db);
await _createVibratorNavTables(db);
// Alap projekt létrehozása az első indításhoz
final now = DateTime.now().toIso8601String();
@@ -303,10 +311,13 @@ class AppDatabase {
await _migrateToV4(db);
}
if (oldVersion < 5) {
_createContactsOutbox(db);
await _createContactsOutbox(db);
}
await _addAppInstanceIdColumns(db);
await _createVibratorNavTables(db);
await _addContactLocationColumns(db);
await _addProjectMissingStreakColumn(db);
}
Future<void> _migrateToV4(Database db) async {
@@ -508,7 +519,7 @@ class AppDatabase {
final map = _withSyncColumns(track.toMap());
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
? DeviceIdentityService.to.deviceId
: null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
return db.insert('tracks', map);
@@ -895,7 +906,7 @@ class AppDatabase {
final map = _withSyncColumns(point.toMap());
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
? DeviceIdentityService.to.deviceId
: null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
@@ -992,7 +1003,7 @@ class AppDatabase {
final map = p.toMap();
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
? DeviceIdentityService.to.deviceId
: null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
return db.insert('stakeout_points', map);
@@ -1016,7 +1027,7 @@ class AppDatabase {
final map = p.toMap();
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
? DeviceIdentityService.to.deviceId
: null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
@@ -1322,6 +1333,10 @@ class AppDatabase {
phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
lat REAL,
lon REAL,
eov_y REAL,
eov_x REAL,
created_at TEXT NOT NULL
)
''');
@@ -1329,9 +1344,18 @@ class AppDatabase {
'ON contacts_outbox(project_id)');
}
/// Idempotens oszlop-pótlás, ha a contacts_outbox már létezik (korábbi
/// telepítéseknél) — ugyanaz a minta, mint az app_instance_id-nél.
Future<void> _addContactLocationColumns(Database db) async {
for (final col in ['lat', 'lon', 'eov_y', 'eov_x']) {
await _tryExec(db, 'ALTER TABLE contacts_outbox ADD COLUMN $col REAL');
}
}
Future<void> insertPendingContact(Map<String, dynamic> row) async {
final db = await database;
await db.insert('contacts_outbox', row);
await db.insert('contacts_outbox', row,
conflictAlgorithm: ConflictAlgorithm.replace);
}
/// [projectId] NÉLKÜL (a flush-hoz) MINDEN várólistás sort ad vissza —
@@ -1385,6 +1409,11 @@ class AppDatabase {
}
}
Future<void> _addProjectMissingStreakColumn(Database db) async {
await _tryExec(db,
'ALTER TABLE projects ADD COLUMN missing_streak INTEGER NOT NULL DEFAULT 0');
}
// Future<void> testOnly() async {
// final db = await database;
// await db.execute(
@@ -1392,4 +1421,174 @@ class AppDatabase {
// await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project '
// 'ON contacts_outbox(project_id)');
// }
// ── Vibrátor navigáció: forráspontok + járműpozíció-napló ────────
Future<void> _createVibratorNavTables(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS source_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
line_id TEXT NOT NULL DEFAULT '',
station INTEGER NOT NULL,
plan_eov_y REAL NOT NULL,
plan_eov_x REAL NOT NULL,
plan_lat REAL NOT NULL,
plan_lon REAL NOT NULL,
source TEXT NOT NULL DEFAULT 'sps',
import_batch TEXT,
created_at TEXT NOT NULL
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_source_points_project '
'ON source_points(project_id, line_id, station)');
await db.execute('''
CREATE TABLE IF NOT EXISTS vehicle_position_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
vehicle_id TEXT NOT NULL,
eov_y REAL NOT NULL,
eov_x REAL NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
altitude REAL,
speed_kmh REAL,
heading REAL,
fix_quality INTEGER,
accuracy REAL,
timestamp TEXT NOT NULL,
device_id TEXT,
app_instance_id TEXT
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_vehicle_logs_project '
'ON vehicle_position_logs(project_id, vehicle_id, timestamp)');
}
Future<int> insertSourcePoints(List<SourcePoint> points) async {
final db = await database;
var count = 0;
await db.transaction((txn) async {
for (final p in points) {
await txn.insert('source_points', p.toMap());
count++;
}
});
return count;
}
Future<List<SourcePoint>> listSourcePoints(int projectId) async {
final db = await database;
final rows = await db.query('source_points',
where: 'project_id = ?',
whereArgs: [projectId],
orderBy: 'station ASC');
return rows.map(SourcePoint.fromMap).toList();
}
Future<void> insertVehiclePositionLog(VehiclePositionLog log) async {
final db = await database;
await db.insert('vehicle_position_logs', log.toMap());
}
Future<List<VehiclePositionLog>> listVehiclePositionLogs(
int projectId) async {
final db = await database;
final rows = await db.query('vehicle_position_logs',
where: 'project_id = ?', whereArgs: [projectId]);
return rows.map(VehiclePositionLog.fromMap).toList();
}
/// Összeveti a szerver "aktív tagság" listáját a helyi, SZINKRONIZÁLT
/// (nem csak-lokális) projektekkel. Ami tartósan (több ciklusban)
/// hiányzik onnan, azt — a helyi gyerek-adatokkal EGYÜTT — törli.
/// Szándékosan NEM azonnal töröl egyetlen hiányzás után, hogy egy
/// átmeneti hálózati/RLS-hiba ne okozhasson véletlen adatvesztést.
Future<void> reconcileMissingProjects(Set<String> remoteUuids) async {
const missingThreshold = 3; // ennyi egymást követő ciklus után törlünk
final db = await database;
final localSynced = await db.query('projects',
where: 'is_local_only = 0',
columns: ['id', 'uuid', 'name', 'missing_streak']);
for (final row in localSynced) {
final localId = row['id'] as int;
final uuid = row['uuid'] as String;
final streak = (row['missing_streak'] as int?) ?? 0;
if (remoteUuids.contains(uuid)) {
if (streak != 0) {
await db.update('projects', {'missing_streak': 0},
where: 'id = ?', whereArgs: [localId]);
}
continue;
}
final newStreak = streak + 1;
if (newStreak >= missingThreshold) {
await _cascadeDeleteLocalProject(localId, uuid, row['name'] as String?);
} else {
await db.update('projects', {'missing_streak': newStreak},
where: 'id = ?', whereArgs: [localId]);
}
}
}
/// A projekt ÉS minden helyi, hozzá kötött adat törlése — a szerveren
/// már nem létező (törölt vagy tagságból kikerült) projekt helyi
/// árváinak eltávolítása.
///
/// A track_points (tracks-hoz) és a note_item_photos/note_item_audios
/// (note_items-hez) már ON DELETE CASCADE-del hivatkoznak a szülőre, és
/// a PRAGMA foreign_keys = ON aktív (onConfigure) — ezeket a SQLite
/// automatikusan törli, nem kell kézzel foglalkozni velük.
///
/// A contacts_outbox és a pending_points külön figyelmet igényel:
/// a contacts_outbox a projekt UUID-jével (nem a helyi int id-vel)
/// van kulcsolva, a pending_points viszont a szokásos int id-vel.
Future<void> _cascadeDeleteLocalProject(
int localProjectId, String projectUuid, String? name) async {
final db = await database;
await db.transaction((txn) async {
for (final table in [
'tracks', // → track_points automatikusan
'measured_points',
'note_items', // → note_item_photos/audios automatikusan
'stakeout_points',
'imported_layers',
'source_points',
'vehicle_position_logs',
'pending_points',
]) {
await txn.delete(table,
where: 'project_id = ?', whereArgs: [localProjectId]);
}
// A contacts_outbox a projekt UUID-jét használja kulcsként.
await txn.delete('contacts_outbox',
where: 'project_id = ?', whereArgs: [projectUuid]);
await txn
.delete('projects', where: 'id = ?', whereArgs: [localProjectId]);
});
AppLogger.e(
'AppDatabase',
'Projekt helyi törlése: "$name" (id=$localProjectId) — a szerveren '
'már nem szerepel a tagsági listában (törölve vagy kikerültünk).');
}
/// Egy projekt aktuális szinkron-állapota — nyers oszlop-lekérdezés,
/// hogy a Project modellt ne kelljen ezért bővíteni.
Future<String?> getProjectSyncStatus(int id) async {
final db = await database;
final rows = await db.query('projects',
columns: ['sync_status'], where: 'id = ?', whereArgs: [id], limit: 1);
if (rows.isEmpty) return null;
return rows.first['sync_status'] as String?;
}
}
+110 -3
View File
@@ -9,6 +9,7 @@
// Log fájl helye: /sdcard/Android/data/hu.app_dev.terepi_seged/files/logs/
// Elérhető: Android Studio Device Explorer, adb pull, vagy fájlkezelő app
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
@@ -16,6 +17,8 @@ import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
enum _Level { info, warning, error }
@@ -73,17 +76,23 @@ class AppLogger extends GetxService {
// ── Publikus API ──────────────────────────────────────────────────
/// Info szintű log
static void i(String tag, String message) =>
static void i(String tag, String message) {
_write(_Level.info, tag, message);
_remoteLog("INFO", tag, message, null, null);
}
/// Figyelmeztetés
static void w(String tag, String message, {Object? error}) =>
static void w(String tag, String message, {Object? error}) {
_write(_Level.warning, tag, message, error: error);
_remoteLog("WARN", tag, message, error, null);
}
/// Hiba
static void e(String tag, String message,
{Object? error, StackTrace? stack}) =>
{Object? error, StackTrace? stack}) {
_write(_Level.error, tag, message, error: error, stack: stack);
_remoteLog("ERROR", tag, message, error, stack);
}
/// Szeparátor — jól látható elválasztó a logban
static void separator(String label) {
@@ -236,4 +245,102 @@ class AppLogger extends GetxService {
await files.removeAt(0).delete();
}
}
// ── Távoli (Supabase) hiba-napló ──────────────────────────────────
static final Map<String, DateTime> _remoteLogDedup = {};
static const _remoteLogDedupWindow = Duration(seconds: 60);
static void _remoteLog(
String type, String tag, String message, Object? error, StackTrace? stack,
[String? info, Map<String, dynamic>? params]) {
final key = '$tag|$message';
final last = _remoteLogDedup[key];
final now = DateTime.now();
if (last != null && now.difference(last) < _remoteLogDedupWindow) {
return; // ugyanaz a hiba nemrég már felment — ne floodoljuk
}
_remoteLogDedup[key] = now;
unawaited(() async {
try {
String? deviceId;
String? appVersion;
String? model;
String? appInstanceId;
String? platform;
if (Get.isRegistered<DeviceIdentityService>()) {
final d = DeviceIdentityService.to;
deviceId = d.isReady ? d.deviceId : null;
appVersion = d.isReady ? d.appInfo : null;
model = d.isReady ? d.model : null;
appInstanceId = d.isReady ? d.appInstanceId : null;
platform = d.isReady ? d.info.platform : null;
}
final userId = Supabase.instance.client.auth.currentUser?.id;
await Supabase.instance.client.from('terepi_seged_logs').insert({
'type': type,
'tag': tag,
'message': message,
'error_text': error?.toString(),
'stack_text': stack != null
? stack.toString().split('\n').take(8).join('\n')
: null,
'app_version': appVersion,
'device_id': deviceId,
'user_id': userId,
'app_id': appInstanceId,
'model': model,
'platform': platform,
'info': info,
'params': params
}).timeout(const Duration(seconds: 5));
} catch (_) {
// Szándékosan néma — a távoli naplózás hibája NEM okozhat
// újabb naplóbejegyzést (végtelen ciklus elkerülése).
}
}());
}
// ── Egyszerű, "Firebase Analytics"-stílusú esemény-napló ──────────
// NEM a Firebase-be megy — ugyanabba a Supabase-be, mint a hiba-napló,
// hogy a hiba ÉS a használati esemény is egy helyen, azonnal
// lekérdezhető legyen (a valódi Firebase Analytics konzolja akár
// 24 órás késleltetéssel jelenít meg adatokat).
static void event(String name, [String? info, Map<String, dynamic>? params]) {
unawaited(() async {
try {
String? deviceId;
String? appVersion;
String? model;
String? appInstanceId;
String? platform;
if (Get.isRegistered<DeviceIdentityService>()) {
final d = DeviceIdentityService.to;
deviceId = d.isReady ? d.deviceId : null;
appVersion = d.isReady ? d.appInfo : null;
model = d.isReady ? d.model : null;
appInstanceId = d.isReady ? d.appInstanceId : null;
platform = d.isReady ? d.info.platform : null;
}
final userId = Supabase.instance.client.auth.currentUser?.id;
await Supabase.instance.client.from('terepi_seged_events').insert({
'event_name': name,
'params': params,
'app_version': appVersion,
'device_id': deviceId,
'user_id': userId,
'app_id': appInstanceId,
'model': model,
'platform': platform,
'info': info
}).timeout(const Duration(seconds: 5));
} catch (_) {
// Néma — az esemény-naplózás hibája nem árthat a funkciónak.
}
}());
}
}
+14 -1
View File
@@ -40,7 +40,17 @@ class ContactExportService {
: v;
}
final header = ['Nev', 'Cim', 'Telefon', 'Email', 'Megjegyzes'].join(sep);
final header = [
'Nev',
'Cim',
'Telefon',
'Email',
'Megjegyzes',
'Szélesség',
'Hosszúság',
'EOV_Y',
'EOV_X'
].join(sep);
final sb = StringBuffer();
sb.write('\uFEFF'); // UTF-8 BOM — az Excel enélkül elrontja az ékezeteket
@@ -52,6 +62,9 @@ class ContactExportService {
txt(c.phone),
txt(c.email),
txt(c.note),
c.lat?.toString() ?? '',
c.lon?.toString() ?? '',
c.eovY?.toString() ?? '',
].join(sep));
}
+35 -4
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:uuid/uuid.dart';
import '../models/contact.dart';
@@ -81,16 +82,34 @@ class ContactService extends GetxService {
/// Új kapcsolat vagy szerver-oldali frissítés.
/// Visszaadja, hogy a művelet OFFLINE pufferbe került-e (true = várólistán).
Future<bool> save(Contact c) async {
Future<bool> save(Contact c, {String? existingLocalUuid}) async {
// Meglévő (szerver-oldali) rekord frissítése CSAK online — lásd korlát.
final isUpdate = c.id != null;
if (await _isOnline) {
try {
await _client.from('terepi_seged_contacts').upsert(c.toWriteMap());
// Ha ez korábban egy PENDING (helyi puffer-) sorból indult, és
// most sikerült felmenni, a régi helyi sort törölni kell —
// különben örökre "függőben" maradna, és a flush() is
// duplikálná.
if (existingLocalUuid != null) {
await _db.deletePendingContact(existingLocalUuid);
}
return false; // felment
} catch (e) {
if (isUpdate) rethrow; // frissítést nem pufferelünk
if (e is PostgrestException) {
// Válasz érkezett a szervertől, de hibás (séma, RLS, megkötés) —
// ez NEM hálózati probléma. Ha csendben pufferbe tennénk, ez a
// sor SOSEM jutna fel, és senki nem venné észre — inkább
// azonnal, láthatóan hibázzon.
AppLogger.e('ContactService.save',
'Supabase hiba új kapcsolat mentésekor: $e');
rethrow;
}
// Valódi hálózati/kapcsolati hiba (pl. pillanatnyi kiesés) →
// pufferbe, a flush() majd újrapróbálja.
// Új rekord + online hiba (pl. pillanatnyi kiesés) → pufferbe.
}
} else if (isUpdate) {
@@ -101,13 +120,17 @@ class ContactService extends GetxService {
// Offline (vagy online-hiba) új rekord → outbox.
await _db.insertPendingContact({
'local_uuid': _uuid.v4(),
'local_uuid': existingLocalUuid ?? _uuid.v4(),
'project_id': c.projectId,
'name': c.name.trim(),
'address': c.address.trim(),
'phone': c.phone.trim(),
'email': c.email.trim(),
'note': c.note.trim(),
'lat': c.lat,
'lon': c.lon,
'eov_y': c.eovY,
'eov_x': c.eovX,
'created_at': DateTime.now().toIso8601String(),
});
return true; // várólistán
@@ -160,13 +183,21 @@ class ContactService extends GetxService {
'phone': m['phone'],
'email': m['email'],
'note': m['note'],
'lat': m['lat'],
'lon': m['lon'],
'eov_y': m['eov_y'],
'eov_x': m['eov_x'],
}, onConflict: 'client_uuid', ignoreDuplicates: true);
// Sikeres felküldés → a lokális példány törölhető.
await _db.deletePendingContact(m['local_uuid'] as String);
uploaded++;
} catch (_) {
// A sor marad a pufferben, a következő flush újrapróbálja.
} catch (e) {
// A sor marad a pufferben (legközelebb újrapróbáljuk) — de
// naplózzuk, hogy ne maradjon örökre észrevétlen, ha a hiba nem
// hálózati, hanem tartós (pl. séma-eltérés, RLS).
AppLogger.e('ContactService.flush',
'Kapcsolat feltöltési hiba (local_uuid=${m['local_uuid']}): $e');
}
}
return uploaded;
+32 -3
View File
@@ -13,6 +13,10 @@ class PhoneGpsConnection implements GnssConnection {
final _positionController = StreamController<Position>.broadcast();
StreamSubscription<Position>? _positionSub;
int _retryCount = 0;
static const _maxRetries = 5;
Timer? _retryTimer;
@override
Stream<String> get nmeaLines => const Stream.empty(); // Nincs NMEA
@@ -42,20 +46,44 @@ class PhoneGpsConnection implements GnssConnection {
}
_stateController.add(GnssConnectionState.connected);
_retryCount = 0;
await _startPositionStream();
}
// Belső GPS folyamatos olvasása
Future<void> _startPositionStream() async {
await _positionSub?.cancel();
_positionSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 0, // Folyamatos frissítés
),
).listen((Position pos) {
).listen(
(Position pos) {
_retryCount = 0;
_positionController.add(pos);
});
},
onError: _handleStreamError,
onDone: () =>
_handleStreamError(Exception('A GPS-stream váratlanul lezárult.')),
);
}
/// A Geolocator stream NEM öngyógyuló — lásd a PhoneGpsSource-nál már
/// javított, ugyanilyen hibát. Ez a kapcsolat idáig SEMMILYEN hiba
/// esetén nem próbálkozott újra, csendben, véglegesen elhallgatott.
void _handleStreamError(Object e) {
_retryCount++;
if (_retryCount > _maxRetries) {
_stateController.add(GnssConnectionState.error);
return;
}
_retryTimer?.cancel();
_retryTimer = Timer(const Duration(seconds: 1), _startPositionStream);
}
@override
Future<void> disconnect() async {
_retryTimer?.cancel();
await _positionSub?.cancel();
_stateController.add(GnssConnectionState.disconnected);
}
@@ -67,6 +95,7 @@ class PhoneGpsConnection implements GnssConnection {
@override
void dispose() {
_retryTimer?.cancel();
_positionSub?.cancel();
_positionController.close();
_stateController.close();
+71 -3
View File
@@ -10,7 +10,9 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart';
@@ -300,10 +302,11 @@ class LayerImportService extends GetxService {
defaultPolygonBorderColor: const Color(0xCC1565C0),
defaultPolygonBorderStroke: 1.5,
defaultPolygonIsFilled: true,
markerCreationCallback: _pointMarkerWithLabel,
onMarkerTapCallback: (props) {
final label = props['name'] ?? props['title'] ?? '';
if (label.toString().isNotEmpty) {
Get.snackbar(label.toString(), props['description']?.toString() ?? '',
final label = _extractFeatureName(props);
if (label != null) {
Get.snackbar(label, props['description']?.toString() ?? '',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3));
}
@@ -323,6 +326,71 @@ class LayerImportService extends GetxService {
);
}
// ── Rugalmas névkinyerés — az attribútum neve forrásonként eltérhet ──
static const _nameKeys = [
'name',
'nev',
'név',
'label',
'title',
'megnevezes',
'megnevezés',
'ref',
'point'
];
/// A properties-ből megpróbál egy "nevet" kinyerni, a kulcs pontos
/// nevétől (kis/nagybetű) függetlenül, több elterjedt változatot is
/// figyelembe véve. Null, ha semelyik ismert kulcs alatt nincs
/// érdemi (nem üres) érték.
static String? _extractFeatureName(Map<String, dynamic> properties) {
final lower = {
for (final e in properties.entries) e.key.toLowerCase(): e.value,
};
for (final key in _nameKeys) {
final v = lower[key];
if (v != null && v.toString().trim().isNotEmpty) {
return v.toString().trim();
}
}
return null;
}
/// Pont-marker, ami — ha talál nevet az attribútumok közt — egy kis
/// feliratot is mutat a jelölő fölött, nem csak koppintásra.
static Marker _pointMarkerWithLabel(
LatLng point, Map<String, dynamic> properties) {
final label = _extractFeatureName(properties);
return Marker(
point: point,
width: 100,
height: 46,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (label != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.withOpacity(0.7)),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
),
),
const Icon(Icons.location_pin, color: Colors.red, size: 26),
],
),
);
}
LayerImportSourceType _sourceType(String ext) => switch (ext.toLowerCase()) {
'kml' => LayerImportSourceType.kml,
'kmz' => LayerImportSourceType.kmz,
+37 -22
View File
@@ -1,17 +1,14 @@
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Oldal-/funkció-szintű jogosultságok kezelése.
/// Oldal-/funkció-szintű jogosultságok kezelése — PROJEKTENKÉNT.
///
/// A jogosultságokat egy Supabase `app_permissions` tábla tárolja
/// (user_id + area), és a felhasználó a saját sorait olvashatja (RLS).
/// A service induláskor és bejelentkezéskor betölti a jelenlegi
/// felhasználó jogosultságait egy halmazba, amit a UI reaktívan figyel.
///
/// Bővíthető: új védett terület = új 'area' string (pl. 'admin'),
/// a UI a [can] getterrel kérdez rá — kódmódosítás nélkül. A tényleges
/// védelmet a Supabase-oldali RLS adja (a `contacts`/admin táblákon),
/// ez a service csak a UI-t vezérli (menüpont elrejtése, üzenet).
/// A jogosultságokat a Supabase `app_permissions` tábla tárolja
/// (user_id + area + project_id), ahol a project_id NULL értéke
/// GLOBÁLIS jogot jelent (minden projektre érvényes — pl. admin).
/// A tényleges védelmet a Supabase-oldali RLS adja
/// (`has_permission(area, project_id)`), ez a service csak a UI-t
/// vezérli (menüpont elrejtése, üzenet).
class PermissionService extends GetxService {
static PermissionService get to => Get.find();
@@ -20,18 +17,31 @@ class PermissionService extends GetxService {
SupabaseClient get _client => Supabase.instance.client;
/// A jelenlegi felhasználó engedélyezett területei.
final _areas = <String>{}.obs;
/// projectId → engedélyezett területek. A `null` kulcs a GLOBÁLIS
/// (minden projektre érvényes) jogokat tárolja.
final _areasByProject = <String?, Set<String>>{}.obs;
final isLoaded = false.obs;
bool can(String area) => _areas.contains(area);
bool get canContacts => can(areaContacts);
/// [projectId] nélkül CSAK a globális jogokat nézi — projekt-specifikus
/// ellenőrzéshez mindig add meg az aktív projektet.
bool can(String area, {String? projectId}) {
if (_areasByProject[null]?.contains(area) ?? false) return true;
if (projectId != null) {
return _areasByProject[projectId]?.contains(area) ?? false;
}
return false;
}
bool canContacts({String? projectId}) =>
can(areaContacts, projectId: projectId);
/// Az admin jogot szándékosan globálisnak tartjuk — ha ezt is
/// projektenként szeretnéd, ugyanígy paraméterezhető.
bool get canAdmin => can(areaAdmin);
@override
void onInit() {
super.onInit();
// Induláskor és minden auth-változáskor újratöltjük.
reload();
_client.auth.onAuthStateChange.listen((_) => reload());
}
@@ -39,23 +49,28 @@ class PermissionService extends GetxService {
Future<void> reload() async {
final user = _client.auth.currentUser;
if (user == null) {
_areas.clear();
_areasByProject.clear();
isLoaded.value = true;
return;
}
try {
final rows = await _client
.from('terepi_seged_app_permissions')
.select('area')
.select('area, project_id')
.eq('user_id', user.id);
_areas
final map = <String?, Set<String>>{};
for (final r in rows) {
final pid = r['project_id'] as String?;
map.putIfAbsent(pid, () => {}).add(r['area'] as String);
}
_areasByProject
..clear()
..addAll(rows.map((r) => r['area'] as String));
..addAll(map);
} catch (_) {
// Hálózati hiba: nem adunk jogot (fail-closed), de nem is dobunk.
_areas.clear();
_areasByProject.clear();
} finally {
_areas.refresh();
_areasByProject.refresh();
isLoaded.value = true;
}
}
+44 -5
View File
@@ -20,6 +20,12 @@ class PhoneGpsSource implements LocationSource {
/// Minimális elmozdulás méterben új pont előtt.
final double distanceFilter;
/// Egymást követő sikertelen újraindítási kísérletek száma — véd a
/// végtelen, szoros hiba-ciklus ellen, ha valami tartósan elromlott.
int _retryCount = 0;
static const _maxRetries = 5;
Timer? _retryTimer;
PhoneGpsSource({
this.intervalMs = 1000,
this.distanceFilter = 1.0,
@@ -59,15 +65,17 @@ class PhoneGpsSource implements LocationSource {
// notification biztosítja a jogszerű háttér-használatot.
foregroundNotificationConfig: const ForegroundNotificationConfig(
notificationText: 'Track rögzítése folyamatban',
notificationTitle: 'Terepi Segéd Nyomvonal',
notificationTitle: 'Terepi Segéd - Nyomvonal',
enableWakeLock: true,
),
);
await _positionSub?.cancel();
_positionSub = Geolocator.getPositionStream(
locationSettings: settings,
).listen(
(Position pos) {
).listen((Position pos) {
_retryCount = 0;
_controller?.add(SourcePosition(
latitude: pos.latitude,
longitude: pos.longitude,
@@ -80,12 +88,43 @@ class PhoneGpsSource implements LocationSource {
source: displayName,
));
},
onError: (e) => _controller?.addError(e),
);
onError: (e) => _handleStreamError,
onDone: () =>
_handleStreamError(Exception('A GPS-stream váratlanul lezárult.')));
}
/// A Geolocator stream NEM öngyógyuló: ha egyszer hibát dob (terepen,
/// zötykölődő telefonnal ez elő szokott fordulni), magától többé NEM
/// küld pozíciót. Ezért itt — a korábbi "csak továbbadjuk és feladjuk"
/// helyett — manuálisan újraindítjuk a mögöttes streamet. Végleges,
/// nem-helyreállítható hibáknál (jogosultság/szolgáltatás kikapcsolva)
/// viszont TOVÁBBADJUK, hogy a TrackingController korrekten leállítsa
/// a rögzítést, ahogy eddig is.
void _handleStreamError(Object e) {
if (e is LocationServiceDisabledException ||
e is PermissionDeniedException) {
_controller?.addError(e);
return;
}
_retryCount++;
if (_retryCount > _maxRetries) {
// Tartósan hibás állapot — ezt már valóban jelezni kell.
_controller?.addError(e);
return;
}
// Rövid várakozás, majd friss stream indítása. A _controller
// (amit a TrackingController figyel) változatlan marad — onnan
// nézve ez csak egy pillanatnyi szünet a pozíciókban, nem egy
// végleges leállás.
_retryTimer?.cancel();
_retryTimer = Timer(const Duration(seconds: 1), _startListening);
}
@override
Future<void> dispose() async {
_retryTimer?.cancel();
await _positionSub?.cancel();
await _controller?.close();
_controller = null;
+222 -18
View File
@@ -1,11 +1,39 @@
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:uuid/uuid.dart';
import '../models/project.dart';
import 'app_database.dart';
import 'ts_sync_service.dart';
class ProjectRequiresLoginException implements Exception {
final String message;
ProjectRequiresLoginException(
[this.message = 'Felhős projekt használatához be kell jelentkezni.']);
@override
String toString() => message;
}
class ProjectArchiveBlockedException implements Exception {
final String message;
ProjectArchiveBlockedException(this.message);
@override
String toString() => message;
}
class ProjectNoLongerExistsException implements Exception {
final String message;
ProjectNoLongerExistsException([
this.message = 'Ez a projekt már nem érhető el — törölték, vagy '
'kikerültél a tagságából.',
]);
@override
String toString() => message;
}
class ProjectService extends GetxService {
static ProjectService get to => Get.find();
@@ -19,6 +47,20 @@ class ProjectService extends GetxService {
super.onInit();
await _loadProjects();
await _restoreActiveProject();
Supabase.instance.client.auth.onAuthStateChange.listen((data) {
final loggedOut = data.session == null;
final active = activeProject.value;
if (loggedOut && active != null && !active.isLocalOnly) {
final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly);
if (fallback != null) {
setActiveProject(fallback);
} else {
activeProject.value = null;
}
}
});
}
Future<void> _loadProjects() async {
@@ -37,19 +79,89 @@ class ProjectService extends GetxService {
return;
}
}
// Fallback: az első aktív projekt
if (projects.isNotEmpty) {
await setActiveProject(projects.first);
// Fallback: elsőként lokális projektet próbálunk (mindig biztonságos),
// csak ha nincs, esünk vissza bármelyikre — és a hibát itt is elkapjuk,
// hogy egy bejelentkezés-igénylő projekt ne akassza meg az indulást.
final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ??
(projects.isNotEmpty ? projects.first : null);
if (fallback != null) {
try {
await setActiveProject(fallback);
} catch (_) {
// Nincs aktiválható projekt most (pl. csak felhős van, bejelentkezés
// nélkül) — activeProject marad null, a UI ezt már kezeli.
}
}
}
Future<void> setActiveProject(Project project) async {
if (!project.isLocalOnly &&
Supabase.instance.client.auth.currentUser == null) {
AppLogger.event('project_activate_blocked_no_login', '', {
'device_id': DeviceIdentityService.to.deviceId,
'device_name': DeviceIdentityService.to.deviceLabel.value,
});
throw ProjectRequiresLoginException();
}
if (!project.isLocalOnly) {
// Az élő ellenőrzés CSAK olyan projektnél fusson, ami már egyszer
// BIZONYÍTOTTAN felkerült a szerverre (sync_status == 'synced').
// Egy ÉPP MOST létrehozott, még feltöltés alatt álló projektnél a
// szerveren természetesen még nem létezik a sor — ez NEM törlést
// jelent, csak azt, hogy a háttér-feltöltés még fut/nem futott le,
// és tévesen "törölve" hibát adna, ha itt is lefutna a check.
final syncStatus =
await AppDatabase.instance.getProjectSyncStatus(project.id!);
if (syncStatus == 'synced') {
try {
final row = await Supabase.instance.client
.from('terepi_seged_projects')
.select('deleted_at')
.eq('id', project.uuid)
.maybeSingle()
.timeout(const Duration(seconds: 6));
if (row == null || row['deleted_at'] != null) {
AppLogger.event('project_activate_blocked_deleted', '', {
'project_id': project.id,
'device_id': DeviceIdentityService.to.deviceId,
'device_name': DeviceIdentityService.to.deviceLabel.value,
'user_id': Supabase.instance.client.auth.currentUser?.id,
});
throw ProjectNoLongerExistsException();
}
} on ProjectNoLongerExistsException {
rethrow;
} catch (_) {
// Hálózati hiba/időtúllépés → NEM blokkolunk emiatt (fail-open).
}
}
}
// Helyi (SQLite) meglét ellenőrzése — ne aktiváljunk egy már a
// háttér-reconcile által helyileg törölt projektet sem.
final stillExists = await AppDatabase.instance.getProject(project.id!);
if (stillExists == null) {
await _loadProjects(); // a lista is frissüljön, ne maradjon árva
AppLogger.event('project_activate_blocked_gone', '', {
'project_id': project.id,
'device_id': DeviceIdentityService.to.deviceId,
'device_name': DeviceIdentityService.to.deviceLabel.value,
'user_id': Supabase.instance.client.auth.currentUser?.id,
});
throw ProjectNoLongerExistsException();
}
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
// Csak lokális projektnél frissítjük az updated_at-et (lista-sorrendhez)
// — felhősnél ez feleslegesen szinkron-jelet váltana ki, ütközési
// kockázattal egy másik eszköz közbeni, valódi módosításával szemben.
if (project.isLocalOnly) {
await AppDatabase.instance.updateProject(project.copyWith());
}
await _loadProjects();
}
@@ -104,11 +216,6 @@ class ProjectService extends GetxService {
// Lokálisan mentjük
final id = await AppDatabase.instance.insertProject(project);
// // Supabase-be is
// await Supabase.instance.client
// .from('TerepiSeged_Projects')
// .insert(project.toMap());
await _loadProjects();
// Ha van net, azonnal fel is megy (és owner-tagság is létrejön).
@@ -116,6 +223,8 @@ class ProjectService extends GetxService {
TsSyncService.to.syncNow();
}
AppLogger.event('project_created_online', project.uuid,
{'name': name, 'client': client});
return await AppDatabase.instance.getProject(id) ?? project;
}
@@ -140,6 +249,9 @@ class ProjectService extends GetxService {
// Csak lokálisan
final id = await AppDatabase.instance.insertProject(project);
await _loadProjects();
AppLogger.event('project_created_local', project.uuid,
{'name': name, 'client': client});
return await AppDatabase.instance.getProject(id) ?? project;
}
@@ -164,24 +276,36 @@ class ProjectService extends GetxService {
/// 3. azonnali szinkron, hogy a projekt eddigi adatai lejöjjenek.
Future<Project> joinSharedProject(Map<String, dynamic> sharedRow) async {
final client = Supabase.instance.client;
final user = client.auth.currentUser;
if (user == null) {
AppLogger.event('project_join_blocked_no_login');
throw ProjectRequiresLoginException(
'Közös projekthez csatlakozáshoz be kell jelentkezni.');
}
final projectUuid = sharedRow['id'] as String;
// 1. Tagság (idempotens: ha már tag, nem hiba).
try {
await client.from('terepi_seged_project_members').upsert(
{
'project_id': projectUuid,
'user_id': client.auth.currentUser!.id,
'user_id': user.id,
'role': 'editor',
},
ignoreDuplicates: true,
);
} catch (e) {
AppLogger.e('ProjectService.joinSharedProject',
'Tagság-beszúrás hiba (project=$projectUuid): $e');
rethrow;
}
// 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.
AppLogger.event(
'project_joined', projectUuid, {'user_id': user.id, 'role': 'editor'});
if (Get.isRegistered<TsSyncService>()) {
TsSyncService.to.syncNow();
}
@@ -193,25 +317,105 @@ class ProjectService extends GetxService {
/// a lokális adat megmarad (archiválható külön).
Future<void> leaveSharedProject(Project project) async {
final client = Supabase.instance.client;
final user = client.auth.currentUser;
if (user == null) return;
try {
await client
.from('terepi_seged_project_members')
.delete()
.eq('project_id', project.uuid)
.eq('user_id', client.auth.currentUser!.id);
.eq('user_id', user.id);
AppLogger.event(
'shared_project_left', project.uuid, {'user_id': user.id});
} catch (e) {
AppLogger.e('ProjectService.leaveSharedProject',
'Kilépés hiba (project=${project.uuid}): $e');
rethrow;
}
}
// ═════════════════════════════════════════════════════════════════
Future<void> reloadProjects() => _loadProjects();
/// A projekt-lista frissítése — a háttér-szinkron hívja, miután a
/// reconcile-mechanizmus esetleg helyileg törölt egy, a szerveren már
/// nem létező projektet. Ha épp az AKTÍV projekt tűnt el, biztonságos
/// másikra váltunk, ne maradjon egy már nem létező projektre mutatva.
Future<void> reloadProjects() async {
await _loadProjects();
final active = activeProject.value;
if (active == null) return;
final stillThere = projects.firstWhereOrNull((p) => p.id == active.id);
if (stillThere == null) {
// A projekt eltűnt — biztonságos másikra váltunk.
final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ??
(projects.isNotEmpty ? projects.first : null);
if (fallback != null) {
try {
await setActiveProject(fallback);
} catch (_) {
activeProject.value = null;
}
} else {
activeProject.value = null;
}
} else {
// A projekt megvan — de az ADATAI (pl. a neve) változhattak a
// szerveren. Az activeProject-et is friss példányra cseréljük,
// hogy minden, közvetlenül ezt figyelő UI (appbar, drawer) azonnal
// lássa a változást, ne csak a projekt-választó lista.
activeProject.value = stillThere;
}
}
Future<Map<String, int>> getStats(int projectId) =>
AppDatabase.instance.getProjectStats(projectId);
Future<void> archiveProject(int id) async {
await _loadProjects(); // friss állapot a döntéshez
final target = projects.firstWhereOrNull((p) => p.id == id);
if (target == null) return;
// Sose maradjon nulla aktív HELYI projekt — mindig kell legyen
// legalább egy, bejelentkezés nélkül is használható projekt.
if (target.isLocalOnly) {
final otherLocal = projects.where((p) =>
p.isLocalOnly && p.id != id && p.status == ProjectStatus.active);
if (otherLocal.isEmpty) {
AppLogger.event('project_archive_blocked_last_local', '', {
'project_id': id,
'user_id': Supabase.instance.client.auth.currentUser?.id,
'device_name': DeviceIdentityService.to.deviceLabel.value,
'device_id': DeviceIdentityService.to.deviceId,
});
throw ProjectArchiveBlockedException(
'Ez az utolsó helyi projekt — legalább egynek meg kell '
'maradnia, hogy bejelentkezés nélkül is legyen elérhető '
'projekt.');
}
}
await AppDatabase.instance.archiveProject(id);
AppLogger.event('project_archived', '', {
'project_id': id,
'user_id': Supabase.instance.client.auth.currentUser?.id,
'device_name': DeviceIdentityService.to.deviceLabel.value,
'device_id': DeviceIdentityService.to.deviceId,
});
await _loadProjects();
if (activeProject.value?.id == id) {
activeProject.value = projects.isNotEmpty
? projects.firstWhereOrNull((p) => p.id != id)
: null;
final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ??
projects.firstWhereOrNull((p) => p.id != id);
if (fallback != null) {
try {
await setActiveProject(fallback);
} catch (_) {
activeProject.value = null;
}
} else {
activeProject.value = null;
}
}
}
+320
View File
@@ -0,0 +1,320 @@
import 'package:terepi_seged/models/sensor_chanel.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
/// SPS (Shell Processing Support) fájlok beolvasása — a SEG 1993-as
/// "SPS Format for Land 3D Surveys" specifikációja szerint, fix
/// oszlop-pozíciókkal (1-alapú, záró oszlop is beleértve).
///
/// Csak azt olvassuk ki, ami a csatorna-geometria ellenőrzéséhez kell:
/// * R-fájl (Receiver "Point Record"): vonal, pontszám, EOV Y/X, magasság
/// * X-fájl (Relation Record): csatorna-tartomány → vonal + állomás-tartomány
///
/// A fix oszlopszélességű, évtizedes szabvány gyártónként kicsit eltérő
/// exportokat is szülhet — ezért import előtt MINDIG előnézet van
/// (lásd SpsImportPreview), soha nem mentünk vakon.
class SpsParser {
SpsParser._();
// ── Nyers sor-kivágás (1-alapú, záró oszlop is benne) ─────────────
static String _col(String line, int from, int to) {
if (line.length < from) return '';
final end = line.length < to ? line.length : to;
return line.substring(from - 1, end).trim();
}
static bool _isDataLine(String line, String expectedFirstChar) {
if (line.isEmpty) return false;
if (line.startsWith('EOF')) return false;
if (line[0] == 'H') return false; // fejléc/komment sor
return line[0].toUpperCase() == expectedFirstChar;
}
// ═════════════════════════════════════════════════════════════════
// R-fájl (vevőpont) — "Point Record", cols 1-80
// ═════════════════════════════════════════════════════════════════
// 1 Rekord-azonosító 1-1 "R"
// 2 Vonalnév 2-17
// 3 Pontszám 18-25
// 4 Pont-index 26-26
// 11 EOV Y (easting) 47-55
// 12 EOV X (northing) 56-65
// 13 Magasság 66-71
static List<SpsPointRecord> parseReceiverFile(String content) =>
_parsePointFile(content, 'R');
/// Forráspontok (vibrátor-állomások) — az S-fájl UGYANAZT az
/// oszlop-elrendezést használja, mint az R-fájl, csak a rekord-jelölő
/// betű más.
static List<SpsPointRecord> parseSourceFile(String content) =>
_parsePointFile(content, 'S');
static List<SpsPointRecord> _parsePointFile(
String content, String recordType) {
final result = <SpsPointRecord>[];
for (final raw in content.split(RegExp(r'\r\n|\r|\n'))) {
if (!_isDataLine(raw, recordType)) continue;
final lineId = _col(raw, 2, 17);
final pointStr = _col(raw, 18, 25);
final indexStr = _col(raw, 26, 26);
final eastingStr = _col(raw, 47, 55);
final northingStr = _col(raw, 56, 65);
final elevStr = _col(raw, 66, 71);
// A pontszám ritkán tartalmazhat törtrészt — az egész részt vesszük
// állomásszámként.
final pointNum =
int.tryParse(pointStr.split('.').first.replaceAll(RegExp(r'\D'), ''));
if (pointNum == null) continue;
result.add(SpsPointRecord(
lineId: lineId,
station: pointNum,
pointIndex: int.tryParse(indexStr) ?? 1,
eovY: double.tryParse(eastingStr),
eovX: double.tryParse(northingStr),
elevation: double.tryParse(elevStr),
rawLine: raw,
));
}
return result;
}
// ═════════════════════════════════════════════════════════════════
// X-fájl (kapcsolat) — "Relation Record", cols 1-80
// ═════════════════════════════════════════════════════════════════
// 9 From channel 39-42
// 10 To channel 43-46
// 11 Channel increment 47-47
// 12 Vevő-vonalnév 48-63
// 13 From receiver 64-71
// 14 To receiver 72-79
// 15 Receiver index 80-80
static List<SpsRelationRecord> parseRelationFile(String content) {
final result = <SpsRelationRecord>[];
for (final raw in content.split(RegExp(r'\r\n|\r|\n'))) {
if (!_isDataLine(raw, 'X')) continue;
final fromCh = int.tryParse(_col(raw, 39, 42));
final toCh = int.tryParse(_col(raw, 43, 46));
final chInc = int.tryParse(_col(raw, 47, 47)) ?? 1;
final recvLine = _col(raw, 48, 63);
final fromRecv = int.tryParse(_col(raw, 64, 71));
final toRecv = int.tryParse(_col(raw, 72, 79));
if (fromCh == null ||
toCh == null ||
fromRecv == null ||
toRecv == null) {
continue;
}
result.add(SpsRelationRecord(
fromChannel: fromCh,
toChannel: toCh,
channelIncrement: chInc <= 0 ? 1 : chInc,
recvLineId: recvLine,
fromReceiver: fromRecv,
toReceiver: toRecv,
rawLine: raw,
));
}
return result;
}
/// Egy X-rekord (csatorna-TARTOMÁNY) egyedi (csatorna, vonal, állomás)
/// hármasokra bontása. A normál (egykomponensű) esetben a csatorna- és
/// vevőszám párhuzamosan fut végig a tartományon. Többkomponensű
/// (channel increment > 1) esetet — ritka, pl. 3C geofonoknál — nem
/// bontunk szét channelenként külön állomásra, mert az UGYANAHHOZ az
/// egy fizikai ponthoz tartozna; ilyenkor a tartomány KEZDŐ csatornáját
/// társítjuk a ponthoz, a többit átugorjuk.
static List<({int channel, String lineId, int station})> expandRelation(
SpsRelationRecord r) {
final out = <({int channel, String lineId, int station})>[];
if (r.channelIncrement != 1) {
out.add((
channel: r.fromChannel,
lineId: r.recvLineId,
station: r.fromReceiver
));
return out;
}
final chCount = r.toChannel - r.fromChannel;
final recvCount = r.toReceiver - r.fromReceiver;
if (chCount < 0) return out;
for (var i = 0; i <= chCount; i++) {
final station = chCount == 0
? r.fromReceiver
: r.fromReceiver + (recvCount * i / chCount).round();
out.add(
(channel: r.fromChannel + i, lineId: r.recvLineId, station: station));
}
return out;
}
// ═════════════════════════════════════════════════════════════════
// Előnézet összeállítása — R + X összefésülve
// ═════════════════════════════════════════════════════════════════
/// [receiverPoints] és/vagy [relations] közül legalább az egyik legyen
/// nem üres. Ha csak relations van, a csatorna-hozzárendelés megvan,
/// de terv-koordináta nélkül (a GNSS-mért kitűzési pont adja majd a
/// pozíciót). Ha csak receiverPoints van, nincs csatornaszám — ekkor
/// channel = null marad minden sorban (a UI jelzi, hogy ez hiányos).
static SpsImportPreview buildPreview({
List<SpsPointRecord> receiverPoints = const [],
List<SpsRelationRecord> relations = const [],
}) {
final byLineStation = <String, SpsPointRecord>{};
for (final p in receiverPoints) {
byLineStation['${p.lineId}|${p.station}'] = p;
}
final rows = <SpsPreviewRow>[];
var unmatchedChannels = 0;
if (relations.isNotEmpty) {
for (final rel in relations) {
for (final e in expandRelation(rel)) {
final match = byLineStation['${e.lineId}|${e.station}'];
if (match == null) unmatchedChannels++;
rows.add(SpsPreviewRow(
channel: e.channel,
lineId: e.lineId,
station: e.station,
eovY: match?.eovY,
eovX: match?.eovX,
));
}
}
} else {
// Csak R-fájl: nincs csatornaszám, csak a vevőpontok listája.
for (final p in receiverPoints) {
rows.add(SpsPreviewRow(
channel: null,
lineId: p.lineId,
station: p.station,
eovY: p.eovY,
eovX: p.eovX,
));
}
}
rows.sort(
(a, b) => (a.channel ?? a.station).compareTo(b.channel ?? b.station));
return SpsImportPreview(
rows: rows,
totalReceiverPoints: receiverPoints.length,
totalRelations: relations.length,
unmatchedChannelCount: unmatchedChannels,
);
}
/// A jóváhagyott előnézetből SensorChannel lista építése (mentés előtt).
static List<SensorChannel> buildSensorChannels({
required SpsImportPreview preview,
required int projectId,
required String importBatch,
}) {
final conv = CoordConverterService.to;
final out = <SensorChannel>[];
for (final r in preview.rows) {
if (r.channel == null) continue; // csatornaszám nélkül nincs mit menteni
double? lat, lon;
if (r.eovY != null && r.eovX != null) {
final w = conv.eovToWgsPoint(r.eovY!, r.eovX!);
lon = w.x;
lat = w.y;
}
out.add(SensorChannel(
projectId: projectId,
channel: r.channel!,
lineId: r.lineId,
station: r.station,
planEovY: r.eovY,
planEovX: r.eovX,
planLat: lat,
planLon: lon,
source: 'sps',
importBatch: importBatch,
));
}
return out;
}
}
// ═════════════════════════════════════════════════════════════════════
// Adatszerkezetek
// ═════════════════════════════════════════════════════════════════════
class SpsPointRecord {
final String lineId;
final int station;
final int pointIndex;
final double? eovY;
final double? eovX;
final double? elevation;
final String rawLine;
SpsPointRecord({
required this.lineId,
required this.station,
required this.pointIndex,
this.eovY,
this.eovX,
this.elevation,
required this.rawLine,
});
}
class SpsRelationRecord {
final int fromChannel;
final int toChannel;
final int channelIncrement;
final String recvLineId;
final int fromReceiver;
final int toReceiver;
final String rawLine;
SpsRelationRecord({
required this.fromChannel,
required this.toChannel,
required this.channelIncrement,
required this.recvLineId,
required this.fromReceiver,
required this.toReceiver,
required this.rawLine,
});
}
/// Egy előnézeti sor — ez jelenik meg a felhasználónak import előtt.
class SpsPreviewRow {
final int? channel;
final String lineId;
final int station;
final double? eovY;
final double? eovX;
SpsPreviewRow({
required this.channel,
required this.lineId,
required this.station,
this.eovY,
this.eovX,
});
bool get hasPosition => eovY != null && eovX != null;
}
class SpsImportPreview {
final List<SpsPreviewRow> rows;
final int totalReceiverPoints;
final int totalRelations;
final int unmatchedChannelCount;
SpsImportPreview({
required this.rows,
required this.totalReceiverPoints,
required this.totalRelations,
required this.unmatchedChannelCount,
});
}
+18 -2
View File
@@ -2,6 +2,7 @@ import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'stakeout_service.dart';
@@ -52,8 +53,23 @@ class StakeoutSyncService extends GetxService {
}
Future<bool> _pushThenPull() async {
await _push();
return _pull();
try {
await _push().timeout(const Duration(seconds: 25));
} catch (e, s) {
lastError.value = 'kitűzés push: $e';
AppLogger.e('StackeoutSyncService - _pushThenPull', lastError.value,
error: e, stack: s);
}
try {
return _pull().timeout(const Duration(seconds: 25));
} catch (e, s) {
lastError.value = lastError.value.isEmpty
? 'kitűzés pull: $e'
: '${lastError.value} · kitűzés pull: $e';
AppLogger.e('StackeoutSyncService - _pushThenPull', lastError.value,
error: e, stack: s);
return false;
}
}
// ── PUSH ─────────────────────────────────────────────────────────
+51 -15
View File
@@ -4,8 +4,10 @@ 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/app_logger.dart';
import 'package:terepi_seged/services/contact_service.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/stakeout_sync_service.dart';
import 'app_database.dart';
@@ -81,31 +83,35 @@ class TsSyncService extends GetxService {
lastError.value = '';
try {
lastError.value = '';
// Eszköz-regiszter frissítése (last_seen_at).
if (Get.isRegistered<DeviceIdentityService>()) {
await DeviceIdentityService.to.registerDevice();
await _isolate(
'eszköz regisztációja', DeviceIdentityService.to.registerDevice);
}
await _discoverMemberProjects();
await _push();
await _pull();
await _isolate('tagság felderítése', _discoverMemberProjects);
await _isolate('feltöltés', _push);
await _isolate('letöltés', _pull);
// Megosztott rétegek (5. lépés) — ha a service be van kötve.
if (Get.isRegistered<LayerSyncService>()) {
await LayerSyncService.to.pullAll();
await _isolate('rétegek', LayerSyncService.to.pullAll);
}
if (Get.isRegistered<StakeoutSyncService>()) {
await StakeoutSyncService.to.sync();
await _isolate('kitűzés', StakeoutSyncService.to.sync);
}
if (Get.isRegistered<ContactService>()) {
await ContactService.to.flush();
await _isolate('kapcsolatok', () => ContactService.to.flush());
}
lastSyncedAt.value = DateTime.now();
} catch (e) {
} catch (e, s) {
lastError.value = e.toString();
AppLogger.e('TsSyncService - SyncNow', lastError.value,
error: e, stack: s);
} finally {
await refreshPendingCount();
isSyncing.value = false;
@@ -129,8 +135,22 @@ class TsSyncService extends GetxService {
.select()
.eq('is_member', true);
final remoteUuids = <String>{};
for (final row in rows) {
await _db.upsertProjectFromRemote(Map<String, dynamic>.from(row));
final map = Map<String, dynamic>.from(row);
remoteUuids.add(map['id'] as String);
await _db.upsertProjectFromRemote(map);
}
// Ami tartósan hiányzik erről a listáról (törölve vagy kikerültünk a
// tagságból), azt a helyi gyerek-adatokkal együtt eltávolítjuk.
await _db.reconcileMissingProjects(remoteUuids);
// A ProjectService saját, memóriában tartott listája nem tud
// magától a helyi törlésről — enélkül a projekt-választóban addig
// ottmaradna, amíg valaki újra nem indítja az appot.
if (Get.isRegistered<ProjectService>()) {
await ProjectService.to.reloadProjects();
}
}
@@ -139,11 +159,11 @@ class TsSyncService extends GetxService {
// ═════════════════════════════════════════════════════════════════
Future<void> _push() async {
await _pushProjects();
await _pushMeasuredPoints();
await pushTracks();
await pushTrackPoints();
await _pushNoteItems();
await _isolate('projektek push', _pushProjects);
await _isolate('mérési pontok', _pushMeasuredPoints);
await _isolate('track-ek push', pushTracks);
await _isolate('track-pontok push', pushTrackPoints);
await _isolate('jegyzetek push', _pushNoteItems);
}
Future<void> _pushProjects() async {
@@ -287,7 +307,7 @@ class TsSyncService extends GetxService {
// 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);
await _isolate('letöltés (${p.name})', () => _pullProject(p.id!, p.uuid));
}
}
@@ -446,4 +466,20 @@ class TsSyncService extends GetxService {
if (localIso == null || localIso.isEmpty) return null;
return DateTime.parse(localIso).toUtc().toIso8601String();
}
/// Egy lépés elszigetelt futtatása: hiba vagy időtúllépés esetén NEM
/// dobja tovább — csak feljegyzi és a szinkron a KÖVETKEZŐ lépéssel
/// folytatódik. Enélkül egyetlen hibás sor (típushiba, FK-ütközés stb.)
/// vagy egy beragadt hálózati hívás CSENDBEN leállítaná az összes
/// további lépést, minden ciklusban, örökre.
Future<void> _isolate(String label, Future<void> Function() fn,
{Duration timeout = const Duration(seconds: 25)}) async {
try {
await fn().timeout(timeout);
} catch (e) {
lastError.value = lastError.value.isEmpty
? '$label: $e'
: '${lastError.value} · $label: $e';
}
}
}
@@ -0,0 +1,29 @@
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Melyik járműben van EZ a tablet — eszköz-szintű, tartós beállítás
/// (nem projektfüggő). Több tabletnél/eszközcserénél is egyszerűen
/// újra beállítható, ha a tablet másik járműbe kerül.
class VehicleIdentityService extends GetxService {
static VehicleIdentityService get to => Get.find();
static const _key = 'vehicle_id';
/// Az alapértelmezett választható lista — igény szerint bővíthető.
static const availableVehicles = ['V1', 'V2', 'V3'];
final selectedVehicle = Rxn<String>();
@override
Future<void> onInit() async {
super.onInit();
final prefs = await SharedPreferences.getInstance();
selectedVehicle.value = prefs.getString(_key);
}
Future<void> setVehicle(String vehicleId) async {
selectedVehicle.value = vehicleId;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, vehicleId);
}
}
+10 -1
View File
@@ -109,8 +109,9 @@ class AppDrawer extends StatelessWidget {
),
Obx(() {
final signedIn = AuthService.to.isSignedIn;
final projectId = ProjectService.to.activeProject.value?.uuid;
final allowed = !Get.isRegistered<PermissionService>() ||
PermissionService.to.canContacts;
PermissionService.to.canContacts(projectId: projectId);
if (!signedIn || !allowed) return const SizedBox.shrink();
return ListTile(
leading: const Icon(Icons.phone_outlined),
@@ -145,6 +146,14 @@ class AppDrawer extends StatelessWidget {
// Get.to(() => const NtripSettingsView());
},
),
ListTile(
leading: const Icon(Icons.local_shipping_outlined),
title: const Text('Jármű navigáció'),
onTap: () {
Get.back();
Get.toNamed(Routes.VIBRONAV);
},
),
// ── 3. Beállítások ─────────────────────────────────
const _SectionLabel('Beállítások'),
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
/// Egyenletes, ANIMÁLT térkép-követés — a `mapController.move()`/
/// `.rotate()` közvetlen hívása helyett, amik AZONNAL ugranak az új
/// pozícióra/irányba.
///
/// Ez a widget semmit nem jelenít meg (0 méretű) — kizárólag arra való,
/// hogy a [target] (és opcionálisan [heading]) VÁLTOZÁSAKOR sima
/// animációval, ne ugrással vigye át a térképet a régi pontból az újba.
///
/// Belül a Flutter beépített `TweenAnimationBuilder`-ét használja — ez
/// IMPLICIT módon animál (a cél-érték változásakor magától, a jelenlegi
/// állásból indulva animál az újig). A ticker a Flutter-widget
/// belsejében él, nekünk nem kell saját State/AnimationController-t
/// írnunk hozzá — ez az EGYETLEN pont, ahol animáció miatt egyáltalán
/// szóba kerül egy ticker, de azt is a keretrendszer adja.
class AnimatedMapFollow extends StatelessWidget {
final MapController mapController;
final LatLng? target;
final double? heading; // fok — ha null, nincs forgatás
final Duration duration;
const AnimatedMapFollow({
super.key,
required this.mapController,
required this.target,
this.heading,
this.duration = const Duration(milliseconds: 400),
});
@override
Widget build(BuildContext context) {
final t = target;
if (t == null) return const SizedBox.shrink();
return Stack(
children: [
TweenAnimationBuilder<LatLng>(
tween: _LatLngTween(begin: t, end: t),
duration: duration,
curve: Curves.easeOut,
builder: (context, animatedPos, _) {
try {
mapController.move(animatedPos, mapController.camera.zoom);
} catch (_) {}
return const SizedBox.shrink();
},
),
if (heading != null)
TweenAnimationBuilder<double>(
// A térkép a haladási irány ELLENTETTJÉVEL forog (track-up).
tween: _DegreesTween(begin: -heading!, end: -heading!),
duration: duration,
curve: Curves.easeOut,
builder: (context, animatedRotation, _) {
try {
mapController.rotate(animatedRotation);
} catch (_) {}
return const SizedBox.shrink();
},
),
],
);
}
}
class _LatLngTween extends Tween<LatLng> {
_LatLngTween({required LatLng begin, required LatLng end})
: super(begin: begin, end: end);
@override
LatLng lerp(double t) {
final b = begin!, e = end!;
return LatLng(
b.latitude + (e.latitude - b.latitude) * t,
b.longitude + (e.longitude - b.longitude) * t,
);
}
}
/// Fok-interpoláció a "legrövidebb úton" — enélkül egy 350°→10° váltás
/// hosszan, "visszafelé" animálna 340°-ot ahelyett hogy egyenesen 20°-ot
/// lépne előre.
class _DegreesTween extends Tween<double> {
_DegreesTween({required double begin, required double end})
: super(begin: begin, end: end);
@override
double lerp(double t) {
final b = begin!, e = end!;
var diff = (e - b) % 360;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return b + diff * t;
}
}
@@ -65,6 +65,18 @@ class NoteItemLabelLayer extends StatelessWidget {
}
}
// ----- Kapcsolatok - névvel a személy ikon felett
for (final c in controller.contactsWithLocation) {
markers.add(Marker(
point: LatLng(c.contact.lat!, c.contact.lon!),
width: 130,
height: 48,
alignment: Alignment.bottomCenter,
child: Column(mainAxisSize: MainAxisSize.min, children: [
_LabelBubble(label: c.contact.name, color: Colors.indigo)
])));
}
if (markers.isEmpty) return const SizedBox.shrink();
return MarkerLayer(markers: markers);
});
@@ -32,6 +32,7 @@ class LabelFieldState extends State<LabelField> {
MapEditTool.point => 'Pont neve...',
MapEditTool.line => 'Vonal neve...',
MapEditTool.polygon => 'Terület neve...',
MapEditTool.contact => 'Felirat ...',
MapEditTool.none => 'Felirat...',
};
return Column(
@@ -116,6 +116,8 @@ class LineOrPolygonDrawingContent extends StatelessWidget {
return 'min. 3';
case MapEditTool.point:
return '1 pont';
case MapEditTool.contact:
return '';
case MapEditTool.none:
return '';
}
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:terepi_seged/enums/map_edit_tool.dart';
import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart';
import 'package:terepi_seged/services/permission_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'map_toolbar_action.dart';
import 'map_toolbar_divider.dart';
@@ -65,6 +67,14 @@ class MapEditCompactToolbar extends StatelessWidget {
selected: activeTool == MapEditTool.polygon,
onTap: controller.startPolygonTool,
),
if (PermissionService.to.canContacts(
projectId: ProjectService.to.activeProject.value?.uuid))
ToolbarAction(
icon: Icons.person_pin_circle_outlined,
label: 'Kapcsolat',
selected: activeTool == MapEditTool.contact,
onTap: controller.startContactTool,
),
const ToolbarDivider(),
ToolbarAction(
icon: Icons.list_alt_outlined,
@@ -72,12 +82,12 @@ class MapEditCompactToolbar extends StatelessWidget {
selected: false,
onTap: () {},
),
ToolbarAction(
icon: Icons.layers_outlined,
label: 'Rétegek',
selected: false,
onTap: () {},
),
// ToolbarAction(
// icon: Icons.layers_outlined,
// label: 'Rétegek',
// selected: false,
// onTap: () {},
// ),
],
),
),
+16 -2
View File
@@ -184,8 +184,15 @@ class _ProjectTileState extends State<_ProjectTile> {
],
),
onTap: () async {
try {
await svc.setActiveProject(project);
Get.back();
} catch (e) {
Get.snackbar('Nem lehet aktiválni', e.toString(),
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white);
}
},
);
});
@@ -211,9 +218,16 @@ class _ProjectTileState extends State<_ProjectTile> {
TextButton(onPressed: Get.back, child: const Text('Mégse')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.orange),
onPressed: () {
Get.back();
onPressed: () async {
try {
ProjectService.to.archiveProject(widget.project.id!);
Get.back();
} catch (e) {
Get.snackbar('Nem archiválható', e.toString(),
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white);
}
},
child: const Text('Archiválás'),
),