16 Commits
Author SHA1 Message Date
gitea_admin 47f7687e40 Ingatlanok tulajdoni lapok és tulajdonosok táblázatos és térképi megjelenítése.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
2026-08-31 20:38:18 +02:00
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
49 changed files with 4435 additions and 199 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 }}."
+1 -1
View File
@@ -1,6 +1,6 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=false
# This builtInKotlin flag was added automatically by Flutter migrator # This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator # This newDsl flag was added automatically by Flutter migrator
+42 -16
View File
@@ -29,7 +29,7 @@ import '../enums/note_type.dart';
class GeoPackageExporter { class GeoPackageExporter {
// ── Publikus belépési pont ──────────────────────────────────────── // ── Publikus belépési pont ────────────────────────────────────────
Future<void> exportProject() async { Future<void> exportProject({DateTime? since}) async {
final project = ProjectService.to.activeProject.value; final project = ProjectService.to.activeProject.value;
final projectId = ProjectService.to.activeProjectId; final projectId = ProjectService.to.activeProjectId;
final name = project?.name ?? 'projekt'; final name = project?.name ?? 'projekt';
@@ -42,11 +42,11 @@ class GeoPackageExporter {
try { try {
// 1. GeoPackage létrehozása // 1. GeoPackage létrehozása
final gpkgPath = p.join(workDir.path, '$name.gpkg'); final gpkgPath = p.join(workDir.path, '$name.gpkg');
await _buildGpkg(gpkgPath, projectId); await _buildGpkg(gpkgPath, projectId, since);
// 2. Médiafájlok összegyűjtése // 2. Médiafájlok összegyűjtése
final mediaDir = Directory(p.join(workDir.path, 'media')); final mediaDir = Directory(p.join(workDir.path, 'media'));
await _collectMedia(projectId, mediaDir); await _collectMedia(projectId, mediaDir, since);
// 3. ZIP csomagolás // 3. ZIP csomagolás
final zipPath = p.join(tmpDir.path, '${name}_$ts.zip'); final zipPath = p.join(tmpDir.path, '${name}_$ts.zip');
@@ -65,13 +65,13 @@ class GeoPackageExporter {
// ── GeoPackage (.gpkg) létrehozása ─────────────────────────────── // ── 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); final db = await openDatabase(path);
try { try {
await _initGpkg(db); await _initGpkg(db);
await _exportNoteItems(db, projectId); await _exportNoteItems(db, projectId, since);
await _exportMeasuredPoints(db, projectId); await _exportMeasuredPoints(db, projectId, since);
await _exportTracks(db, projectId); await _exportTracks(db, projectId, since);
} finally { } finally {
await db.close(); await db.close();
} }
@@ -187,11 +187,17 @@ class GeoPackageExporter {
// ── NoteItem exportok ───────────────────────────────────────────── // ── 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 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 filtered = since != null
final polygons = items.where((i) => i.type == NoteType.polygon).toList(); ? 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 (points.isNotEmpty) await _exportPoints(db, points);
if (lines.isNotEmpty) await _exportLines(db, lines); if (lines.isNotEmpty) await _exportLines(db, lines);
@@ -310,12 +316,19 @@ class GeoPackageExporter {
// ── Bemért pontok exportja ──────────────────────────────────────── // ── 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 final points = projectId != null
? await AppDatabase.instance.listMeasuredPoints(projectId) ? await AppDatabase.instance.listMeasuredPoints(projectId)
: <MeasuredPoint>[]; : <MeasuredPoint>[];
if (points.isEmpty) return; if (points.isEmpty) return;
final filtered = since != null
? points.where((p) => p.timestamp.isAfter(since)).toList()
: points;
if (filtered.isEmpty) return;
await db.execute(''' await db.execute('''
CREATE TABLE measured_points ( CREATE TABLE measured_points (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -353,13 +366,20 @@ class GeoPackageExporter {
// ── Track exportja ──────────────────────────────────────────────── // ── 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 tracks = await AppDatabase.instance.listTracks();
final filtered = projectId != null var filtered = projectId != null
? tracks.where((t) => t.projectId == projectId).toList() ? tracks.where((t) => t.projectId == projectId).toList()
: tracks; : tracks;
if (filtered.isEmpty) return; if (filtered.isEmpty) return;
if (since != null) {
filtered = filtered.where((t) => t.startTime.isAfter(since)).toList();
}
if (filtered.isEmpty) return;
await db.execute(''' await db.execute('''
CREATE TABLE tracks ( CREATE TABLE tracks (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -395,7 +415,8 @@ class GeoPackageExporter {
// ── Médiafájlok másolása ────────────────────────────────────────── // ── 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; if (projectId == null) return;
final photos = Directory(p.join(mediaDir.path, 'photos')); final photos = Directory(p.join(mediaDir.path, 'photos'));
@@ -404,7 +425,12 @@ class GeoPackageExporter {
await audios.create(recursive: true); await audios.create(recursive: true);
final items = await AppDatabase.instance.listNoteItems(projectId); 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 // Fotók
final photoList = await AppDatabase.instance.listNotePhotos(item.id!); final photoList = await AppDatabase.instance.listNotePhotos(item.id!);
for (final photo in photoList) { for (final photo in photoList) {
+1 -6
View File
@@ -1,6 +1 @@
enum MapEditTool { enum MapEditTool { none, point, line, polygon, contact }
none,
point,
line,
polygon,
}
+18
View File
@@ -15,6 +15,7 @@ import 'package:terepi_seged/services/auth_service.dart';
import 'package:terepi_seged/services/contact_service.dart'; import 'package:terepi_seged/services/contact_service.dart';
import 'package:terepi_seged/services/coord_converter_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/device_identity_service.dart';
import 'package:terepi_seged/services/field_property_service.dart';
import 'package:terepi_seged/services/firebase_logger.dart'; import 'package:terepi_seged/services/firebase_logger.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart';
@@ -23,6 +24,7 @@ import 'package:terepi_seged/services/layer_sync_service.dart';
import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_audio_service.dart';
import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart';
import 'package:terepi_seged/services/parcel_geometry_service.dart';
import 'package:terepi_seged/services/permission_service.dart'; import 'package:terepi_seged/services/permission_service.dart';
import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart';
@@ -30,6 +32,7 @@ import 'package:terepi_seged/services/stakeout_sync_service.dart';
import 'package:terepi_seged/services/tilt_service.dart'; import 'package:terepi_seged/services/tilt_service.dart';
import 'package:terepi_seged/services/track_sync_service.dart'; import 'package:terepi_seged/services/track_sync_service.dart';
import 'package:terepi_seged/services/ts_sync_service.dart'; 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'; import 'package:terepi_seged/services/version_gate_service.dart';
Future<void> main() async { Future<void> main() async {
@@ -54,6 +57,8 @@ Future<void> main() async {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: false); FirebaseCrashlytics.instance.recordError(error, stack, fatal: false);
return true; return true;
} }
AppLogger.e('PlatformDispatcher', 'Kezeletlen kivétel',
error: error, stack: stack);
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true; return true;
}; };
@@ -64,6 +69,16 @@ Future<void> main() async {
url: dotenv.env['SUPABASE_URL']!, url: dotenv.env['SUPABASE_URL']!,
anonKey: dotenv.env['SUPABASE_ANON_KEY']!); anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
// 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(); final versionGate = await checkVersionGate();
if (versionGate.blocked) { if (versionGate.blocked) {
runApp(MaterialApp( runApp(MaterialApp(
@@ -99,6 +114,9 @@ Future<void> main() async {
Get.put(TiltService()); Get.put(TiltService());
Get.put(PermissionService()); Get.put(PermissionService());
Get.put(ContactService()); Get.put(ContactService());
Get.put(VehicleIdentityService());
Get.put(FieldPropertyService());
Get.put(ParcelGeometryService());
runApp(const MyApp()); runApp(const MyApp());
} }
+34 -3
View File
@@ -14,8 +14,17 @@ class Contact {
final String email; final String email;
final String note; final String note;
final String? createdBy; final String? createdBy;
final DateTime? updatedAt; 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({ const Contact({
this.id, this.id,
required this.projectId, required this.projectId,
@@ -26,15 +35,25 @@ class Contact {
this.note = '', this.note = '',
this.createdBy, this.createdBy,
this.updatedAt, this.updatedAt,
this.lat,
this.lon,
this.eovY,
this.eovX,
}); });
Contact copyWith({ bool get hasLocation => lat != null && lon != null;
String? name,
Contact copyWith(
{String? name,
String? address, String? address,
String? phone, String? phone,
String? email, String? email,
String? note, String? note,
}) => double? lat,
double? lon,
double? eovY,
double? eovX,
bool clearLocation = false}) =>
Contact( Contact(
id: id, id: id,
projectId: projectId, projectId: projectId,
@@ -45,6 +64,10 @@ class Contact {
note: note ?? this.note, note: note ?? this.note,
createdBy: createdBy, createdBy: createdBy,
updatedAt: updatedAt, 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 /// 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(), 'phone': phone.trim(),
'email': email.trim(), 'email': email.trim(),
'note': note.trim(), 'note': note.trim(),
'lat': lat,
'lon': lon,
'eov_y': eovY,
'eov_x': eovX,
}; };
factory Contact.fromMap(Map<String, dynamic> m) => Contact( factory Contact.fromMap(Map<String, dynamic> m) => Contact(
@@ -72,5 +99,9 @@ class Contact {
updatedAt: m['updated_at'] != null updatedAt: m['updated_at'] != null
? DateTime.tryParse(m['updated_at'] as String) ? DateTime.tryParse(m['updated_at'] as String)
: null, : 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(),
); );
} }
+28
View File
@@ -0,0 +1,28 @@
/// Egy tulajdonos/kezelő a levél-pipeline importjából — tisztán online,
/// nincs helyi tükör.
class FieldParty {
final String id;
final String publicationId;
final String name;
final String partyType;
final String? settlement;
final int propertyCount;
const FieldParty({
required this.id,
required this.publicationId,
required this.name,
required this.partyType,
this.settlement,
required this.propertyCount,
});
factory FieldParty.fromMap(Map<String, dynamic> m) => FieldParty(
id: m['id'] as String,
publicationId: m['publication_id'] as String,
name: m['name'] as String? ?? '',
partyType: m['party_type'] as String? ?? '',
settlement: m['settlement'] as String?,
propertyCount: (m['property_count'] as num?)?.toInt() ?? 0,
);
}
+69
View File
@@ -0,0 +1,69 @@
/// Egy ingatlan (helyrajzi szám) a levél-pipeline importjából.
///
/// SZÁNDÉKOSAN nincs hozzá helyi tábla/toMap — ez a funkció tisztán
/// online, a `terepi_seged_field_properties_view` élő lekérdezéséből él,
/// sosem kerül a készülék adatbázisába.
class FieldProperty {
final String id;
final String projectId;
final String publicationId;
final String settlement;
final String parcelNumber;
final String normalizedParcelNumber;
final String areaType;
final String mailingStatus;
final int? totalAreaSquareMeters;
final double? totalCadastralIncome;
final String? caseIdentifier;
final int recipientCount;
final int selectedRecipientCount;
final int generatedLetterCount;
final int sentLetterCount;
final String ownerNames;
final String landUseSummary;
final DateTime createdAt;
const FieldProperty({
required this.id,
required this.projectId,
required this.publicationId,
required this.settlement,
required this.parcelNumber,
required this.normalizedParcelNumber,
required this.areaType,
required this.mailingStatus,
this.totalAreaSquareMeters,
this.totalCadastralIncome,
this.caseIdentifier,
required this.recipientCount,
required this.selectedRecipientCount,
required this.generatedLetterCount,
required this.sentLetterCount,
required this.ownerNames,
this.landUseSummary = '',
required this.createdAt,
});
factory FieldProperty.fromMap(Map<String, dynamic> m) => FieldProperty(
id: m['id'] as String,
projectId: m['project_id'] as String,
publicationId: m['publication_id'] as String,
settlement: m['settlement'] as String? ?? '',
parcelNumber: m['parcel_number'] as String? ?? '',
normalizedParcelNumber: m['normalized_parcel_number'] as String? ?? '',
areaType: m['area_type'] as String? ?? '',
mailingStatus: m['mailing_status'] as String? ?? 'unknown',
totalAreaSquareMeters: (m['total_area_square_meters'] as num?)?.toInt(),
totalCadastralIncome: (m['total_cadastral_income'] as num?)?.toDouble(),
caseIdentifier: m['case_identifier'] as String?,
recipientCount: (m['recipient_count'] as num?)?.toInt() ?? 0,
selectedRecipientCount:
(m['selected_recipient_count'] as num?)?.toInt() ?? 0,
generatedLetterCount:
(m['generated_letter_count'] as num?)?.toInt() ?? 0,
sentLetterCount: (m['sent_letter_count'] as num?)?.toInt() ?? 0,
ownerNames: m['owner_names'] as String? ?? '',
landUseSummary: m['land_use_summary'] as String? ?? '',
createdAt: DateTime.parse(m['created_at'] as String),
);
}
+11
View File
@@ -90,6 +90,17 @@ class Project {
createdAt: DateTime.parse(m['created_at'] as String), createdAt: DateTime.parse(m['created_at'] as String),
updatedAt: DateTime.parse(m['updated_at'] as String), updatedAt: DateTime.parse(m['updated_at'] as String),
); );
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Project &&
runtimeType == other.runtimeType &&
id == other.id &&
updatedAt == other.updatedAt;
@override
int get hashCode => Object.hash(id, updatedAt);
} }
bool _readBool( bool _readBool(
+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> { class _ContactEditViewState extends State<ContactEditView> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late final Contact? _original; Contact? _original;
String? _originalLocalUuid;
late final TextEditingController _name; late final TextEditingController _name;
late final TextEditingController _address; late final TextEditingController _address;
@@ -33,16 +34,30 @@ class _ContactEditViewState extends State<ContactEditView> {
late final TextEditingController _note; late final TextEditingController _note;
bool _saving = false; bool _saving = false;
late double? _lat;
late double? _lon;
late double? _eovY;
late double? _eovX;
@override @override
void initState() { void initState() {
super.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 ?? ''); _name = TextEditingController(text: _original?.name ?? '');
_address = TextEditingController(text: _original?.address ?? ''); _address = TextEditingController(text: _original?.address ?? '');
_phone = TextEditingController(text: _original?.phone ?? ''); _phone = TextEditingController(text: _original?.phone ?? '');
_email = TextEditingController(text: _original?.email ?? ''); _email = TextEditingController(text: _original?.email ?? '');
_note = TextEditingController(text: _original?.note ?? ''); _note = TextEditingController(text: _original?.note ?? '');
_lat = _original?.lat;
_lon = _original?.lon;
_eovY = _original?.eovY;
_eovX = _original?.eovX;
} }
@override @override
@@ -69,8 +84,15 @@ class _ContactEditViewState extends State<ContactEditView> {
phone: _phone.text, phone: _phone.text,
email: _email.text, email: _email.text,
note: _note.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 Get.back(result: queued); // a lista frissítéshez visszakapja
if (Get.isRegistered<ContactsController>()) { if (Get.isRegistered<ContactsController>()) {
Get.find<ContactsController>().load(silent: true); Get.find<ContactsController>().load(silent: true);
@@ -182,6 +204,27 @@ class _ContactEditViewState extends State<ContactEditView> {
alignLabelWithHint: true, 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), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
@@ -26,7 +26,8 @@ class ContactsView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Jogosultság-kapu — ha nincs joga, be sem töltjük a listát. // Jogosultság-kapu — ha nincs joga, be sem töltjük a listát.
if (Get.isRegistered<PermissionService>() && if (Get.isRegistered<PermissionService>() &&
!PermissionService.to.canContacts) { !PermissionService.to.canContacts(
projectId: ProjectService.to.activeProject.value?.uuid)) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Kapcsolatok')), appBar: AppBar(title: const Text('Kapcsolatok')),
body: const _NoAccess(), body: const _NoAccess(),
@@ -127,7 +128,7 @@ class ContactsView extends StatelessWidget {
item: rows[i], item: rows[i],
onEdit: () async { onEdit: () async {
final saved = await Get.to(() => const ContactEditView(), final saved = await Get.to(() => const ContactEditView(),
arguments: rows[i].contact); arguments: rows[i]);
if (saved != null) c.load(); if (saved != null) c.load();
}, },
onDelete: () => _confirmDelete(c, rows[i]), onDelete: () => _confirmDelete(c, rows[i]),
@@ -157,10 +157,10 @@ class MapViewController extends GetxController {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
authResponse = await Supabase.instance.client.auth // authResponse = await Supabase.instance.client.auth
.signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo'); // .signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
session = authResponse.session; // session = authResponse.session;
user = authResponse.user; // user = authResponse.user;
Supabase.instance.client Supabase.instance.client
.channel('public:TerepiSeged_Receiver') .channel('public:TerepiSeged_Receiver')
@@ -5,6 +5,7 @@ import 'dart:math';
//import 'dart:math' as math; //import 'dart:math' as math;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.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/enums/note_type.dart';
import 'package:terepi_seged/eov/convert_coordinate.dart'; import 'package:terepi_seged/eov/convert_coordinate.dart';
import 'package:terepi_seged/eov/eov.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/measured_point.dart';
import 'package:terepi_seged/models/note_item.dart'; import 'package:terepi_seged/models/note_item.dart';
import 'package:terepi_seged/models/point_to_measure.dart'; import 'package:terepi_seged/models/point_to_measure.dart';
import 'package:terepi_seged/models/point_with_description_model.dart'; import 'package:terepi_seged/models/point_with_description_model.dart';
import 'package:shared_preferences/shared_preferences.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/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/controllers/ntrip_settings_controller.dart';
import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.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/pages/tracking/presentation/controllers/tracking_controller.dart';
import 'package:terepi_seged/services/app_database.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/coord_converter_service.dart';
import 'package:terepi_seged/services/device_identity_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_connection.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/ntrip_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/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart';
import 'package:terepi_seged/widgets/map/all_layer_overlay.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.point:
case MapEditTool.contact:
case MapEditTool.none: case MapEditTool.none:
return ''; return '';
} }
@@ -300,10 +306,14 @@ class MapSurveyController extends GetxController implements StyleEditable {
gpsHeightController.text = '1.8'; gpsHeightController.text = '1.8';
ever(ProjectService.to.activeProject, (_) => _loadNoteItems()); ever(ProjectService.to.activeProject, (_) {
_loadNoteItems();
_loadContactMarkers();
});
await _loadNoteItems(); await _loadNoteItems();
await _loadMeasurePoints(); await _loadMeasurePoints();
await _loadContactMarkers();
_subscribeToTeamPosition(); _subscribeToTeamPosition();
} }
@@ -1088,6 +1098,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return Icons.polyline_outlined; return Icons.polyline_outlined;
case MapEditTool.polygon: case MapEditTool.polygon:
return Icons.border_outer_outlined; return Icons.border_outer_outlined;
case MapEditTool.contact:
return Icons.person_pin_circle_outlined;
case MapEditTool.none: case MapEditTool.none:
return Icons.edit_location_alt_outlined; return Icons.edit_location_alt_outlined;
} }
@@ -1101,6 +1113,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Vonal rögzítése'; return 'Vonal rögzítése';
case MapEditTool.polygon: case MapEditTool.polygon:
return 'Terület rögzítése'; return 'Terület rögzítése';
case MapEditTool.contact:
return 'Kapcsolat hozzáadása';
case MapEditTool.none: case MapEditTool.none:
return ''; return '';
} }
@@ -1114,6 +1128,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Hosszan nyomj a térképre a töréspontokhoz'; return 'Hosszan nyomj a térképre a töréspontokhoz';
case MapEditTool.polygon: case MapEditTool.polygon:
return 'Hosszan nyomj a térképre a sarokpontokhoz.'; 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: case MapEditTool.none:
return ''; return '';
} }
@@ -1127,6 +1143,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return editorPointCount >= 2; return editorPointCount >= 2;
case MapEditTool.polygon: case MapEditTool.polygon:
return editorPointCount >= 3; return editorPointCount >= 3;
case MapEditTool.contact:
return false;
case MapEditTool.none: case MapEditTool.none:
return false; return false;
} }
@@ -1140,6 +1158,8 @@ class MapSurveyController extends GetxController implements StyleEditable {
return 'Kész'; return 'Kész';
case MapEditTool.polygon: case MapEditTool.polygon:
return 'Lezárás'; return 'Lezárás';
case MapEditTool.contact:
return 'Kész';
case MapEditTool.none: case MapEditTool.none:
return 'Kész'; return 'Kész';
} }
@@ -1150,6 +1170,17 @@ class MapSurveyController extends GetxController implements StyleEditable {
activeEditLabel.value = ''; 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() { void startLineTool() {
polygonEditorController.clear(); polygonEditorController.clear();
polygonEditorController.setMode(PolygonEditorMode.line); polygonEditorController.setMode(PolygonEditorMode.line);
@@ -1207,6 +1238,95 @@ class MapSurveyController extends GetxController implements StyleEditable {
//draftPoints.clear(); //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) { Marker _markerFromNoteItem(NoteItem item) {
return Marker( return Marker(
key: ValueKey('note_point_${item.id}'), key: ValueKey('note_point_${item.id}'),
@@ -1730,6 +1850,7 @@ class MapSurveyController extends GetxController implements StyleEditable {
break; break;
case MapEditTool.point: case MapEditTool.point:
case MapEditTool.contact:
case MapEditTool.none: case MapEditTool.none:
draftLengthMeters.value = 0.0; draftLengthMeters.value = 0.0;
draftAreaSquareMeters.value = 0.0; draftAreaSquareMeters.value = 0.0;
@@ -1895,6 +2016,9 @@ class MapSurveyController extends GetxController implements StyleEditable {
showGeometryLabels.value = !showGeometryLabels.value; showGeometryLabels.value = !showGeometryLabels.value;
Future<void> exportProject() async { Future<void> exportProject() async {
final since = await _showExportDateDialog();
if (since == false) return; // felhasználó megszakította
Get.dialog( Get.dialog(
Center( Center(
child: Material( 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() { void _subscribeToTeamPosition() {
_teamChannel = Supabase.instance.client _teamChannel = Supabase.instance.client
.channel('public:terepi_seged_device_positions') .channel('public:terepi_seged_device_positions')
@@ -64,6 +64,10 @@ class MapSurveyView extends GetView<MapSurveyController> {
} }
return; return;
} }
if (controller.activeEditTool.value == MapEditTool.contact) {
controller.saveContactAtPoint(point);
return;
}
if (controller.activeEditTool.value == MapEditTool.line || if (controller.activeEditTool.value == MapEditTool.line ||
controller.activeEditTool.value == MapEditTool.polygon) { controller.activeEditTool.value == MapEditTool.polygon) {
controller.polygonEditorController.addPoint(point); controller.polygonEditorController.addPoint(point);
@@ -142,6 +146,13 @@ class MapSurveyView extends GetView<MapSurveyController> {
return MarkerLayer(markers: [...controller.pointNotes]); 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(() { Obx(() {
// Vonalak - terepbejárás // Vonalak - terepbejárás
if (controller.mode.value != MapSurveyMode.fieldWalk) { if (controller.mode.value != MapSurveyMode.fieldWalk) {
@@ -162,10 +162,17 @@ class NavigationViewController extends GetxController {
mapController = MapController(); mapController = MapController();
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
authResponse = await Supabase.instance.client.auth // authResponse = await Supabase.instance.client.auth
.signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo'); // .signInWithPassword(email: 'test.elek.1@email.hu', password: 'demo');
session = authResponse.session; // session = authResponse.session;
user = authResponse.user; // 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(), // riveGpsIconController = RiveUtils.getRiveController(Artboard(),
// stateMachineName: "gps_Interactivity"); // stateMachineName: "gps_Interactivity");
@@ -1,3 +1,263 @@
import 'package:get/get.dart'; import 'dart:async';
import 'dart:io';
class PropertyListController extends GetxController {} import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:terepi_seged/models/field_party.dart';
import 'package:terepi_seged/models/field_property.dart';
import 'package:terepi_seged/pages/property_list/presentations/views/parcel_map_view.dart';
import 'package:terepi_seged/services/field_property_service.dart';
import 'package:terepi_seged/services/parcel_geometry_service.dart';
import 'package:terepi_seged/services/project_service.dart';
enum FieldViewMode { properties, parties }
class PropertyListController extends GetxController {
static PropertyListController get to => Get.find();
final viewMode = FieldViewMode.properties.obs;
// ── Ingatlanok fül ───────────────────────────────────────────────
final searchText = ''.obs;
final settlementFilter = Rxn<String>();
final mailingStatusFilter = Rxn<String>();
final isLoading = false.obs;
final properties = <FieldProperty>[].obs;
final settlements = <String>[].obs;
/// Ha nem null, a lista egy KIVÁLASZTOTT tulajdonos ingatlanjait
/// mutatja, nem az általános keresést/szűrést.
final selectedParty = Rxn<FieldParty>();
// ── Tulajdonosok fül ─────────────────────────────────────────────
final partySearchText = ''.obs;
final isLoadingParties = false.obs;
final parties = <FieldParty>[].obs;
Timer? _propertyDebounce;
Timer? _partyDebounce;
final partySearchController = TextEditingController();
@override
@override
void onInit() {
super.onInit();
loadProperties();
searchParties(); // a tulajdonos-lista is azonnal töltődjön, ne csak gépelésre
ever(ProjectService.to.activeProject, (_) {
selectedParty.value = null;
loadProperties();
searchParties();
});
}
@override
void onClose() {
_propertyDebounce?.cancel();
_partyDebounce?.cancel();
partySearchController.dispose();
super.onClose();
}
// ── Ingatlan-keresés/szűrés ──────────────────────────────────────
void onSearchChanged(String v) {
searchText.value = v;
_propertyDebounce?.cancel();
_propertyDebounce =
Timer(const Duration(milliseconds: 400), loadProperties);
}
void setSettlementFilter(String? v) {
settlementFilter.value = v;
loadProperties();
}
void setMailingStatusFilter(String? v) {
mailingStatusFilter.value = v;
loadProperties();
}
Future<void> loadProperties() async {
// Ha épp egy tulajdonosra van szűkítve a nézet, a sima keresés ne
// írja felül azt — a felhasználó explicit törli (clearPartySelection).
if (selectedParty.value != null) return;
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) {
properties.clear();
settlements.clear();
return;
}
isLoading.value = true;
try {
final list = await FieldPropertyService.to.listProperties(
projectId: projectId,
search: searchText.value,
settlement: settlementFilter.value,
mailingStatus: mailingStatusFilter.value,
);
properties.value = list;
// A település-szűrő lista a TELJES (szűretlen) körből épülne fel
// igazán jól — egyszerűség kedvéért most a jelenlegi találatokból
// számoljuk, ami induláskor (szűrés nélkül) helyes.
if (settlementFilter.value == null) {
settlements.value = list.map((p) => p.settlement).toSet().toList()
..sort();
}
} catch (e) {
Get.snackbar('Hiba', 'Nem sikerült betölteni az ingatlanokat: $e',
snackPosition: SnackPosition.BOTTOM);
} finally {
isLoading.value = false;
}
}
// ── Tulajdonos-keresés ───────────────────────────────────────────
void onPartySearchChanged(String v) {
partySearchText.value = v;
_partyDebounce?.cancel();
_partyDebounce = Timer(const Duration(milliseconds: 400), searchParties);
}
Future<void> searchParties() async {
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) return;
isLoadingParties.value = true;
try {
parties.value = await FieldPropertyService.to.searchParties(
projectId: projectId,
search: partySearchText.value,
);
} catch (e) {
Get.snackbar('Hiba', 'Nem sikerült betölteni a tulajdonosokat: $e',
snackPosition: SnackPosition.BOTTOM);
} finally {
isLoadingParties.value = false;
}
}
/// Determinisztikus törlés — nem várja meg a debounce-ot, azonnal
/// visszahozza a teljes (szűretlen) listát.
void clearPartySearch() {
partySearchController.clear();
partySearchText.value = '';
_partyDebounce?.cancel();
searchParties();
}
/// Egy tulajdonos kiválasztása → átvált az Ingatlanok fülre, az ő
/// ingatlanjaival.
Future<void> selectParty(FieldParty party) async {
selectedParty.value = party;
viewMode.value = FieldViewMode.properties;
isLoading.value = true;
try {
properties.value =
await FieldPropertyService.to.listPropertiesForParty(party.id);
} catch (e) {
Get.snackbar('Hiba', 'Nem sikerült betölteni az ingatlanokat: $e',
snackPosition: SnackPosition.BOTTOM);
} finally {
isLoading.value = false;
}
}
void clearPartySelection() {
selectedParty.value = null;
viewMode.value = FieldViewMode.parties;
loadProperties();
// A korábban a tulajdonos MEGTALÁLÁSÁHOZ begépelt keresőszöveg is
// törlődjön — különben a Tulajdonosok fülre visszaváltva a lista
// továbbra is arra az egy szűrt találatra maradna korlátozva.
clearPartySearch();
}
// ── GeoJSON import (geometria-katalógus) ────────────────────────────
Future<void> importGeoJsonFile() async {
// FONTOS: nem FileType.custom + allowedExtensions — a .geojson nincs
// benne Android beépített MIME-táblázatában, emiatt felhős forrásoknál
// (Google Drive stb.) a fájlok szürkén, kiválaszthatatlanul jelennek
// meg. Helyette mindent engedünk, és utólag, kézzel ellenőrizzük a
// kiterjesztést.
final result = await FilePicker.platform.pickFiles(type: FileType.any);
if (result == null || result.files.single.path == null) return;
final pickedName = result.files.single.name.toLowerCase();
if (!pickedName.endsWith('.geojson') && !pickedName.endsWith('.json')) {
Get.snackbar('Nem támogatott fájl',
'Válassz .geojson vagy .json kiterjesztésű fájlt.',
snackPosition: SnackPosition.BOTTOM);
return;
}
final file = File(result.files.single.path!);
final content = await file.readAsString();
Get.dialog(const Center(child: CircularProgressIndicator()),
barrierDismissible: false);
try {
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) {
Get.back();
Get.snackbar('Nincs aktív projekt',
'Válassz aktív projektet az importálás előtt.',
snackPosition: SnackPosition.BOTTOM);
return;
}
final res = await ParcelGeometryService.to.importGeoJson(content,
projectId: projectId, sourceFileName: result.files.single.name);
Get.back(); // töltő-dialógus bezárása
if (res.skipped.isNotEmpty) {
Get.dialog(AlertDialog(
title: Text(
'Import: ${res.imported} sikeres, ${res.skipped.length} kihagyva'),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: res.skipped
.take(20)
.map(
(s) => Text('$s', style: const TextStyle(fontSize: 12)))
.toList(),
),
),
actions: [
TextButton(onPressed: Get.back, child: const Text('Rendben')),
],
));
} else {
Get.snackbar('Import kész', '${res.imported} parcella importálva.',
snackPosition: SnackPosition.BOTTOM);
}
} catch (e) {
Get.back();
Get.snackbar('Import hiba', '$e', snackPosition: SnackPosition.BOTTOM);
}
}
// ── Térképi megjelenítés ─────────────────────────────────────────
Future<void> viewOnMap({
required String title,
required List<FieldProperty> targetProperties,
}) async {
if (targetProperties.isEmpty) return;
Get.dialog(const Center(child: CircularProgressIndicator()),
barrierDismissible: false);
try {
final rows =
await ParcelGeometryService.to.fetchGeometriesFor(targetProperties);
Get.back();
Get.to(() => ParcelMapView(title: title, geometryRows: rows));
} catch (e) {
Get.back();
Get.snackbar('Hiba', 'Nem sikerült betölteni a térképi adatot: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
/// Egy vagy több parcella kiemelt megjelenítése térképen — a lekérdezett
/// geometria-sorokból épül, a bbox alapján automatikusan a megfelelő
/// nézetre közelít/távolít.
///
/// StatefulWidget itt indokolt kivétel: a kamera-illesztés (fitCamera)
/// csak a térkép első felépülése UTÁN hívható biztonságosan — ehhez egy
/// post-frame callback kell, nincs benne üzleti logika.
class ParcelMapView extends StatefulWidget {
final String title;
final List<Map<String, dynamic>> geometryRows;
const ParcelMapView({
super.key,
required this.title,
required this.geometryRows,
});
@override
State<ParcelMapView> createState() => _ParcelMapViewState();
}
class _ParcelMapViewState extends State<ParcelMapView> {
final _mapController = MapController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fitBounds());
}
void _fitBounds() {
final bounds = _computeBounds(widget.geometryRows);
if (bounds == null) return;
_mapController.fitCamera(
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
);
}
LatLngBounds? _computeBounds(List<Map<String, dynamic>> rows) {
if (rows.isEmpty) return null;
double minLat = double.infinity, maxLat = -double.infinity;
double minLon = double.infinity, maxLon = -double.infinity;
for (final row in rows) {
final a = (row['bbox_min_lat'] as num).toDouble();
final b = (row['bbox_max_lat'] as num).toDouble();
final c = (row['bbox_min_lon'] as num).toDouble();
final d = (row['bbox_max_lon'] as num).toDouble();
if (a < minLat) minLat = a;
if (b > maxLat) maxLat = b;
if (c < minLon) minLon = c;
if (d > maxLon) maxLon = d;
}
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
}
List<Polygon> _parsePolygons(List<Map<String, dynamic>> rows) {
final polygons = <Polygon>[];
for (final row in rows) {
final geom = row['geometry'] as Map;
final coords = geom['coordinates'] as List;
for (final polygon in coords) {
final outerRing = (polygon as List).first as List;
final points = outerRing
.map((p) =>
LatLng((p[1] as num).toDouble(), (p[0] as num).toDouble()))
.toList();
polygons.add(Polygon(
points: points,
color: Colors.deepOrange.withOpacity(0.35),
borderColor: Colors.deepOrange,
borderStrokeWidth: 2,
));
}
}
return polygons;
}
/// A parcella-szám feliratai, a poligon TÉNYLEGES súlypontjában (nem a
/// csúcspontok egyszerű átlagában — egy hosszú, elnyúlt teleknél az
/// utóbbi könnyen a parcellán kívülre esne).
List<Marker> _buildLabelMarkers(List<Map<String, dynamic>> rows) {
final markers = <Marker>[];
for (final row in rows) {
// A nyers (nem normalizált, vezető nullákkal írt) formátumot
// mutatjuk, ha van — ez a "hivatalos" alak. Régebbi, e nélkül
// importált soroknál a normalizáltra esik vissza.
final parcelNumber = (row['parcel_number_raw'] as String?) ??
(row['normalized_parcel_number'] as String? ?? '');
final geom = row['geometry'] as Map;
final coords = geom['coordinates'] as List;
for (final polygon in coords) {
final outerRing = (polygon as List).first as List;
final points = outerRing
.map((p) =>
LatLng((p[1] as num).toDouble(), (p[0] as num).toDouble()))
.toList();
final centroid = _polygonCentroid(points);
markers.add(Marker(
point: centroid,
width: 90,
height: 26,
child: IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.85),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.deepOrange.withOpacity(0.6)),
),
child: Center(
child: Text(
parcelNumber,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
),
),
));
}
}
return markers;
}
/// Sokszög-súlypont (terület szerint súlyozva, nem csúcspont-átlag) —
/// szabványos, "shoelace"-alapú képlet.
LatLng _polygonCentroid(List<LatLng> points) {
double area = 0, cx = 0, cy = 0;
final n = points.length;
for (var i = 0; i < n; i++) {
final p0 = points[i];
final p1 = points[(i + 1) % n];
final cross = p0.longitude * p1.latitude - p1.longitude * p0.latitude;
area += cross;
cx += (p0.longitude + p1.longitude) * cross;
cy += (p0.latitude + p1.latitude) * cross;
}
area *= 0.5;
if (area.abs() < 1e-12) {
// Degenerált eset (pl. egyenes vonalra eső pontok) — egyszerű átlag.
final avgLat = points.map((p) => p.latitude).reduce((a, b) => a + b) / n;
final avgLon = points.map((p) => p.longitude).reduce((a, b) => a + b) / n;
return LatLng(avgLat, avgLon);
}
cx /= (6 * area);
cy /= (6 * area);
return LatLng(cy, cx);
}
@override
Widget build(BuildContext context) {
final polygons = _parsePolygons(widget.geometryRows);
final bounds = _computeBounds(widget.geometryRows);
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: widget.geometryRows.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Ehhez az ingatlanhoz/tulajdonoshoz nincs importált '
'geometria — ellenőrizd, importálva van-e a megfelelő '
'GeoJSON.',
textAlign: TextAlign.center,
),
),
)
: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: bounds?.center ?? const LatLng(47.1, 19.5),
initialZoom: 15,
maxZoom: 22,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'hu.app_dev.terepi_seged',
maxNativeZoom: 18,
),
PolygonLayer(polygons: polygons),
MarkerLayer(markers: _buildLabelMarkers(widget.geometryRows))
],
),
);
}
}
@@ -1,12 +1,665 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:terepi_seged/pages/property_list/presentations/controllers/property_list_controller.dart'; import 'package:terepi_seged/models/field_party.dart';
import 'package:terepi_seged/models/field_property.dart';
import 'package:terepi_seged/services/field_property_service.dart';
import '../controllers/property_list_controller.dart';
class PropertyListView extends GetView<PropertyListController> { class PropertyListView extends GetView<PropertyListController> {
const PropertyListView({Key? key}) : super(key: key); const PropertyListView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container(); return Scaffold(
appBar: AppBar(
title: const Text('Ingatlanok'),
actions: [
IconButton(
icon: const Icon(Icons.upload_file_outlined),
tooltip: 'Geometria feltöltése',
onPressed: controller.importGeoJsonFile,
)
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Obx(() => SegmentedButton<FieldViewMode>(
segments: const [
ButtonSegment(
value: FieldViewMode.properties,
label: Text('Ingatlanok'),
icon: Icon(Icons.home_work_outlined)),
ButtonSegment(
value: FieldViewMode.parties,
label: Text('Tulajdonosok'),
icon: Icon(Icons.people_outline)),
],
selected: {controller.viewMode.value},
onSelectionChanged: (s) =>
controller.viewMode.value = s.first,
)),
),
Expanded(
child: Obx(() =>
controller.viewMode.value == FieldViewMode.properties
? const _PropertiesTab()
: const _PartiesTab()),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// Ingatlanok fül
// ═══════════════════════════════════════════════════════════════════
class _PropertiesTab extends StatelessWidget {
const _PropertiesTab();
@override
Widget build(BuildContext context) {
final c = PropertyListController.to;
return Column(
children: [
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Keresés (helyrajzi szám, tulajdonos, település)…',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: c.onSearchChanged,
),
),
const SizedBox(height: 8),
Obx(() {
final party = c.selectedParty.value;
if (party != null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Align(
alignment: Alignment.centerLeft,
child: InputChip(
avatar: const Icon(Icons.person, size: 18),
label: Text('Tulajdonos: ${party.name}'),
onDeleted: c.clearPartySelection,
),
),
);
}
return const _FilterRow();
}),
const SizedBox(height: 4),
Obx(() {
if (c.properties.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.map_outlined, size: 18),
label: Text(c.selectedParty.value != null
? 'Mind a térképen (${c.properties.length})'
: 'Találatok a térképen (${c.properties.length})'),
onPressed: () => c.viewOnMap(
title:
c.selectedParty.value?.name ?? 'Ingatlanok a térképen',
targetProperties: c.properties),
),
),
);
}),
Expanded(
child: Obx(() {
if (c.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (c.properties.isEmpty) {
return const Center(child: Text('Nincs találat.'));
}
return ListView.builder(
itemCount: c.properties.length,
itemBuilder: (context, i) =>
_PropertyCard(property: c.properties[i]),
);
}),
),
],
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow();
@override
Widget build(BuildContext context) {
final c = PropertyListController.to;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Expanded(
child: Obx(() => DropdownButtonFormField<String?>(
value: c.settlementFilter.value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Település', isDense: true),
items: [
const DropdownMenuItem(value: null, child: Text('Mind')),
...c.settlements
.map((s) => DropdownMenuItem(value: s, child: Text(s))),
],
onChanged: c.setSettlementFilter,
)),
),
const SizedBox(width: 8),
Expanded(
child: Obx(() => DropdownButtonFormField<String?>(
value: c.mailingStatusFilter.value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Levél-állapot', isDense: true),
items: const [
DropdownMenuItem(value: null, child: Text('Mind')),
DropdownMenuItem(
value: 'unknown', child: Text('Ismeretlen')),
DropdownMenuItem(
value: 'not_required', child: Text('Nem szükséges')),
DropdownMenuItem(value: 'excluded', child: Text('Kizárva')),
DropdownMenuItem(
value: 'ready', child: Text('Előkészítve')),
DropdownMenuItem(
value: 'generated', child: Text('Legenerálva')),
DropdownMenuItem(value: 'sent', child: Text('Elküldve')),
DropdownMenuItem(
value: 'partial', child: Text('Részleges')),
],
onChanged: c.setMailingStatusFilter,
)),
),
],
),
);
}
}
class _PropertyCard extends StatelessWidget {
final FieldProperty property;
const _PropertyCard({required this.property});
@override
Widget build(BuildContext context) {
final color = _statusColor(property.mailingStatus);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text('${property.settlement}${property.parcelNumber}',
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (property.ownerNames.isNotEmpty)
Text(property.ownerNames,
maxLines: 2, overflow: TextOverflow.ellipsis),
if (property.totalAreaSquareMeters != null)
Text('${property.totalAreaSquareMeters}',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (property.landUseSummary.isNotEmpty)
Text(
'${property.landUseSummary}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
)
],
),
isThreeLine: property.ownerNames.isNotEmpty &&
property.totalAreaSquareMeters != null,
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(_statusLabel(property.mailingStatus),
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700, color: color)),
),
onTap: () => _showPropertyDetail(context, property),
),
);
}
Color _statusColor(String status) {
switch (status) {
case 'sent':
return Colors.green;
case 'generated':
case 'ready':
return Colors.blue;
case 'partial':
return Colors.orange;
case 'excluded':
case 'not_required':
return Colors.grey;
default:
return Colors.red;
}
}
String _statusLabel(String status) {
switch (status) {
case 'sent':
return 'Elküldve';
case 'generated':
return 'Legenerálva';
case 'ready':
return 'Előkészítve';
case 'partial':
return 'Részleges';
case 'excluded':
return 'Kizárva';
case 'not_required':
return 'Nem szükséges';
default:
return 'Ismeretlen';
}
}
}
void _showPropertyDetail(BuildContext context, FieldProperty property) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _PropertyDetailSheet(property: property),
);
}
class _PropertyDetailSheet extends StatefulWidget {
final FieldProperty property;
const _PropertyDetailSheet({required this.property});
@override
State<_PropertyDetailSheet> createState() => _PropertyDetailSheetState();
}
class _PropertyDetailSheetState extends State<_PropertyDetailSheet> {
// StatefulWidget itt indokolt kivétel: egyszeri, aszinkron betöltés
// az ownership-bontáshoz, nincs benne üzleti logika — ugyanaz a minta,
// amit a QC-fotó előnézetnél is alkalmaztunk.
List<Map<String, dynamic>>? _owners;
@override
void initState() {
super.initState();
FieldPropertyService.to
.propertyOwnershipDetail(widget.property.id)
.then((v) => setState(() => _owners = v));
}
@override
Widget build(BuildContext context) {
final p = widget.property;
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) => ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
Text('${p.settlement}${p.parcelNumber}',
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Norm. hrsz.: ${p.normalizedParcelNumber}',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
const Divider(height: 24),
if (p.totalAreaSquareMeters != null)
_kv('Terület', '${p.totalAreaSquareMeters}'),
if (p.totalCadastralIncome != null)
_kv('Aranykorona', '${p.totalCadastralIncome}'),
if (p.landUseSummary.isNotEmpty) _kv('Művelési ág', p.landUseSummary),
_kv('Címzettek', '${p.recipientCount}'),
_kv('Legenerált levél', '${p.generatedLetterCount}'),
_kv('Elküldött levél', '${p.sentLetterCount}'),
const Divider(height: 24),
const Text('Tulajdonosok / kezelők',
style: TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
if (_owners == null)
const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator()))
else if (_owners!.isEmpty)
const Text('Nincs rögzített tulajdonos.')
else
for (final o in _owners!) _OwnerRow(data: o),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.map_outlined),
label: const Text('Megtekintés a térképen'),
onPressed: () {
Navigator.of(context).pop();
PropertyListController.to.viewOnMap(
title:
'${widget.property.settlement} - ${widget.property.parcelNumber}',
targetProperties: [widget.property]);
},
)
],
),
);
}
Widget _kv(String k, String v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(k, style: const TextStyle(color: Colors.grey)),
Text(v, style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
);
}
class _OwnerRow extends StatelessWidget {
final Map<String, dynamic> data;
const _OwnerRow({required this.data});
@override
Widget build(BuildContext context) {
final party = data['terepi_seged_field_parties'] as Map?;
final name = party?['name'] as String? ?? '(ismeretlen)';
final shareText = data['share_original_text'] as String?;
final role = data['role'] as String?;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.person_outline),
title: Text(name),
subtitle: Text([
if (role != null) role,
if (shareText != null) shareText,
].join(' · ')),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// Tulajdonosok fül
// ═══════════════════════════════════════════════════════════════════
class _PartiesTab extends StatefulWidget {
const _PartiesTab();
@override
State<_PartiesTab> createState() => _PartiesTabState();
}
class _PartiesTabState extends State<_PartiesTab> {
final _scrollController = ScrollController();
static const _itemHeight = 72.0;
// A húzás közben aktív betű + a buborék függőleges pozíciója — külön
// ValueNotifier, hogy NE váltson ki teljes Obx-újraépítést minden egyes
// ujjmozdulatnál, csak a buborék widgetjét frissítse.
final _activeLetter = ValueNotifier<String?>(null);
final _bubbleY = ValueNotifier<double>(0);
@override
void dispose() {
_scrollController.dispose();
_activeLetter.dispose();
_bubbleY.dispose();
super.dispose();
}
String _bucketLetter(String name) {
if (name.isEmpty) return '#';
final ch = name[0].toUpperCase();
const map = {
'Á': 'A',
'É': 'E',
'Í': 'I',
'Ó': 'O',
'Ö': 'O',
'Ő': 'O',
'Ú': 'U',
'Ü': 'U',
'Ű': 'U',
};
return map[ch] ?? ch;
}
void _jumpToLetter(String letter, List<FieldParty> parties) {
final index = parties.indexWhere((p) => _bucketLetter(p.name) == letter);
if (index < 0 || !_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final target = (index * _itemHeight).clamp(0.0, maxScroll);
// jumpTo, NEM animateTo — húzás közben a listának AZONNAL, ujjal
// együtt kell mozognia, nem egy lassabb animációt kergetve.
_scrollController.jumpTo(target);
}
@override
Widget build(BuildContext context) {
final c = PropertyListController.to;
return Column(
children: [
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TextField(
controller: c.partySearchController,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
suffixIcon: Obx(() => c.partySearchText.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: c.clearPartySearch,
)
: const SizedBox.shrink()),
hintText: 'Tulajdonos neve…',
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: c.onPartySearchChanged,
),
),
const SizedBox(height: 8),
Expanded(
child: Obx(() {
if (c.isLoadingParties.value) {
return const Center(child: CircularProgressIndicator());
}
if (c.parties.isEmpty) {
return const Center(child: Text('Nincs tulajdonos.'));
}
final parties = c.parties;
final letters = <String>{};
for (final p in parties) {
letters.add(_bucketLetter(p.name));
}
final sortedLetters = letters.toList()..sort();
return Stack(
children: [
ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(right: 28),
itemCount: parties.length,
itemBuilder: (context, i) {
final party = parties[i];
return SizedBox(
height: _itemHeight,
child: ListTile(
leading: const Icon(Icons.person_outline),
title: Text(party.name,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${party.propertyCount} ingatlan'
'${party.settlement != null ? ' · ${party.settlement}' : ''}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => c.selectParty(party),
),
);
},
),
if (sortedLetters.length > 3) ...[
Positioned(
right: 0,
top: 0,
bottom: 0,
child: _AlphabetIndexBar(
letters: sortedLetters,
onLetterChanged: (letter, dy) {
_activeLetter.value = letter;
_bubbleY.value = dy;
_jumpToLetter(letter, parties);
},
onDragEnd: () => _activeLetter.value = null,
),
),
ValueListenableBuilder<String?>(
valueListenable: _activeLetter,
builder: (context, letter, _) {
if (letter == null) return const SizedBox.shrink();
return ValueListenableBuilder<double>(
valueListenable: _bubbleY,
builder: (context, y, __) => Positioned(
right: 56,
top: (y - 32).clamp(
0.0, MediaQuery.of(context).size.height - 64),
child: _LetterBubble(letter: letter),
),
);
},
),
],
],
);
}),
),
],
);
}
}
/// Az egész sáv EGYETLEN, folyamatos húzás-érzékelő terület — nem
/// betűnkénti, apró koppintás-célpontok. Az ujj bárhol a sávon
/// (fel-le mozgatva is) azonnal a megfelelő betűre ugrik, pontosan
/// úgy, ahogy a natív Kontaktok-app ABC-sávja működik.
class _AlphabetIndexBar extends StatefulWidget {
final List<String> letters;
final void Function(String letter, double dy) onLetterChanged;
final VoidCallback onDragEnd;
const _AlphabetIndexBar({
required this.letters,
required this.onLetterChanged,
required this.onDragEnd,
});
@override
State<_AlphabetIndexBar> createState() => _AlphabetIndexBarState();
}
class _AlphabetIndexBarState extends State<_AlphabetIndexBar> {
final _key = GlobalKey();
String? _lastLetter;
void _handlePosition(Offset globalPosition) {
final box = _key.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(globalPosition);
final height = box.size.height;
if (height <= 0) return;
final ratio = (local.dy / height).clamp(0.0, 0.999);
final index = (ratio * widget.letters.length)
.floor()
.clamp(0, widget.letters.length - 1);
final letter = widget.letters[index];
widget.onLetterChanged(letter, local.dy);
// Csak akkor rezegjen, amikor TÉNYLEGESEN új betűre lép — ne
// minden egyes pixelnyi mozdulatnál.
if (letter != _lastLetter) {
_lastLetter = letter;
HapticFeedback.selectionClick();
}
}
void _handleEnd() {
_lastLetter = null;
widget.onDragEnd();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (d) => _handlePosition(d.globalPosition),
onVerticalDragUpdate: (d) => _handlePosition(d.globalPosition),
onVerticalDragEnd: (_) => _handleEnd(),
onTapDown: (d) => _handlePosition(d.globalPosition),
onTapUp: (_) => _handleEnd(),
child: Container(
key: _key,
width: 28,
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final letter in widget.letters)
Text(letter,
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w600)),
],
),
),
);
}
}
/// A húzás közben megjelenő, nagy, kiemelt betű-buborék — a Kontaktok-app
/// mintájára, hogy az ujj alatt is jól látszódjon, épp hol tartasz.
class _LetterBubble extends StatelessWidget {
final String letter;
const _LetterBubble({required this.letter});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8)],
),
alignment: Alignment.center,
child: Text(
letter,
style: const TextStyle(
fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white),
),
),
);
} }
} }
@@ -329,17 +329,35 @@ class TrackingController extends GetxController {
pos.latitude, pos.latitude,
pos.longitude, 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( AppLogger.w(
'_onPosition', '_onPosition',
'GPS ugrás kiszűrve: ${segmentDist.toStringAsFixed(0)}m ' 'GPS ugrás kiszűrve: ${segmentDist.toStringAsFixed(0)}m / '
'(pts: ${livePoints.length})'); '${elapsedSec.toStringAsFixed(1)}s '
_lastPoint = point; // reset — következő pont ettől mér '(${(impliedSpeedMs * 3.6).toStringAsFixed(0)} km/h, '
'pts: ${livePoints.length})');
_lastPoint = point;
return; return;
} }
} }
_accumulatedDistance += segmentDist; _accumulatedDistance += segmentDist;
sessionDistance.value = _accumulatedDistance; 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/start/presentation/views/start_page.dart';
import 'package:terepi_seged/pages/tracking/bindings/tracking_bindings.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/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/bindings/map_test_bindings.dart';
import '../pages/map_test/presentation/views/map_test_view.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.SETTINGS, page: () => const SettingsView()),
GetPage( GetPage(
name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()), 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 SETTINGS = '/settings';
static const STAKEOUT_IMPORT = '/stakeout_import'; 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.dart';
import 'package:terepi_seged/models/note_item_audio.dart'; import 'package:terepi_seged/models/note_item_audio.dart';
import 'package:terepi_seged/models/note_item_photo.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/stakeout_point.dart';
import 'package:terepi_seged/models/track.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:terepi_seged/services/device_identity_service.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/project.dart'; import '../models/project.dart';
@@ -44,7 +47,7 @@ class AppDatabase {
final path = p.join(dbDir.path, 'terepi_seged.db'); final path = p.join(dbDir.path, 'terepi_seged.db');
return openDatabase(path, return openDatabase(path,
version: 5, version: 8,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade); onUpgrade: _onUpgrade);
@@ -237,7 +240,7 @@ class AppDatabase {
vertical_error REAL, vertical_error REAL,
description TEXT, description TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0, 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, created_at TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending' sync_status TEXT NOT NULL DEFAULT 'pending'
) )
@@ -269,7 +272,12 @@ class AppDatabase {
await _createStakeoutTable(db); await _createStakeoutTable(db);
await _createContactsOutbox(db); await _createContactsOutbox(db);
await _addAppInstanceIdColumns(db); await _addAppInstanceIdColumns(db);
await _addContactLocationColumns(db);
await _addProjectMissingStreakColumn(db);
await _createVibratorNavTables(db);
// Alap projekt létrehozása az első indításhoz // Alap projekt létrehozása az első indításhoz
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
@@ -303,10 +311,13 @@ class AppDatabase {
await _migrateToV4(db); await _migrateToV4(db);
} }
if (oldVersion < 5) { if (oldVersion < 5) {
_createContactsOutbox(db); await _createContactsOutbox(db);
} }
await _addAppInstanceIdColumns(db); await _addAppInstanceIdColumns(db);
await _createVibratorNavTables(db);
await _addContactLocationColumns(db);
await _addProjectMissingStreakColumn(db);
} }
Future<void> _migrateToV4(Database db) async { Future<void> _migrateToV4(Database db) async {
@@ -508,7 +519,7 @@ class AppDatabase {
final map = _withSyncColumns(track.toMap()); final map = _withSyncColumns(track.toMap());
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() && map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId ? DeviceIdentityService.to.deviceId
: null; : null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId; map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
return db.insert('tracks', map); return db.insert('tracks', map);
@@ -895,7 +906,7 @@ class AppDatabase {
final map = _withSyncColumns(point.toMap()); final map = _withSyncColumns(point.toMap());
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() && map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId ? DeviceIdentityService.to.deviceId
: null; : null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId; map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
@@ -992,7 +1003,7 @@ class AppDatabase {
final map = p.toMap(); final map = p.toMap();
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() && map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId ? DeviceIdentityService.to.deviceId
: null; : null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId; map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
return db.insert('stakeout_points', map); return db.insert('stakeout_points', map);
@@ -1016,7 +1027,7 @@ class AppDatabase {
final map = p.toMap(); final map = p.toMap();
map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() && map['device_id'] ??= Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId ? DeviceIdentityService.to.deviceId
: null; : null;
map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId; map['app_instance_id'] ??= DeviceIdentityService.to.appInstanceId;
@@ -1322,6 +1333,10 @@ class AppDatabase {
phone TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
lat REAL,
lon REAL,
eov_y REAL,
eov_x REAL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )
'''); ''');
@@ -1329,9 +1344,18 @@ class AppDatabase {
'ON contacts_outbox(project_id)'); '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 { Future<void> insertPendingContact(Map<String, dynamic> row) async {
final db = await database; 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 — /// [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 { // Future<void> testOnly() async {
// final db = await database; // final db = await database;
// await db.execute( // await db.execute(
@@ -1392,4 +1421,174 @@ class AppDatabase {
// await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project ' // await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project '
// 'ON contacts_outbox(project_id)'); // '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/ // 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 // Elérhető: Android Studio Device Explorer, adb pull, vagy fájlkezelő app
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get/get.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/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.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 } enum _Level { info, warning, error }
@@ -73,17 +76,23 @@ class AppLogger extends GetxService {
// ── Publikus API ────────────────────────────────────────────────── // ── Publikus API ──────────────────────────────────────────────────
/// Info szintű log /// Info szintű log
static void i(String tag, String message) => static void i(String tag, String message) {
_write(_Level.info, tag, message); _write(_Level.info, tag, message);
_remoteLog("INFO", tag, message, null, null);
}
/// Figyelmeztetés /// 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); _write(_Level.warning, tag, message, error: error);
_remoteLog("WARN", tag, message, error, null);
}
/// Hiba /// Hiba
static void e(String tag, String message, static void e(String tag, String message,
{Object? error, StackTrace? stack}) => {Object? error, StackTrace? stack}) {
_write(_Level.error, tag, message, error: error, stack: 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 /// Szeparátor — jól látható elválasztó a logban
static void separator(String label) { static void separator(String label) {
@@ -236,4 +245,102 @@ class AppLogger extends GetxService {
await files.removeAt(0).delete(); 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; : 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(); final sb = StringBuffer();
sb.write('\uFEFF'); // UTF-8 BOM — az Excel enélkül elrontja az ékezeteket 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.phone),
txt(c.email), txt(c.email),
txt(c.note), txt(c.note),
c.lat?.toString() ?? '',
c.lon?.toString() ?? '',
c.eovY?.toString() ?? '',
].join(sep)); ].join(sep));
} }
+35 -4
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -81,16 +82,34 @@ class ContactService extends GetxService {
/// Új kapcsolat vagy szerver-oldali frissítés. /// Új kapcsolat vagy szerver-oldali frissítés.
/// Visszaadja, hogy a művelet OFFLINE pufferbe került-e (true = várólistán). /// 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. // Meglévő (szerver-oldali) rekord frissítése CSAK online — lásd korlát.
final isUpdate = c.id != null; final isUpdate = c.id != null;
if (await _isOnline) { if (await _isOnline) {
try { try {
await _client.from('terepi_seged_contacts').upsert(c.toWriteMap()); 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 return false; // felment
} catch (e) { } catch (e) {
if (isUpdate) rethrow; // frissítést nem pufferelünk 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. // Új rekord + online hiba (pl. pillanatnyi kiesés) → pufferbe.
} }
} else if (isUpdate) { } else if (isUpdate) {
@@ -101,13 +120,17 @@ class ContactService extends GetxService {
// Offline (vagy online-hiba) új rekord → outbox. // Offline (vagy online-hiba) új rekord → outbox.
await _db.insertPendingContact({ await _db.insertPendingContact({
'local_uuid': _uuid.v4(), 'local_uuid': existingLocalUuid ?? _uuid.v4(),
'project_id': c.projectId, 'project_id': c.projectId,
'name': c.name.trim(), 'name': c.name.trim(),
'address': c.address.trim(), 'address': c.address.trim(),
'phone': c.phone.trim(), 'phone': c.phone.trim(),
'email': c.email.trim(), 'email': c.email.trim(),
'note': c.note.trim(), 'note': c.note.trim(),
'lat': c.lat,
'lon': c.lon,
'eov_y': c.eovY,
'eov_x': c.eovX,
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
}); });
return true; // várólistán return true; // várólistán
@@ -160,13 +183,21 @@ class ContactService extends GetxService {
'phone': m['phone'], 'phone': m['phone'],
'email': m['email'], 'email': m['email'],
'note': m['note'], 'note': m['note'],
'lat': m['lat'],
'lon': m['lon'],
'eov_y': m['eov_y'],
'eov_x': m['eov_x'],
}, onConflict: 'client_uuid', ignoreDuplicates: true); }, onConflict: 'client_uuid', ignoreDuplicates: true);
// Sikeres felküldés → a lokális példány törölhető. // Sikeres felküldés → a lokális példány törölhető.
await _db.deletePendingContact(m['local_uuid'] as String); await _db.deletePendingContact(m['local_uuid'] as String);
uploaded++; uploaded++;
} catch (_) { } catch (e) {
// A sor marad a pufferben, a következő flush újrapróbálja. // 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; return uploaded;
+5 -8
View File
@@ -190,6 +190,7 @@ class DeviceIdentityService extends GetxService {
final user = Supabase.instance.client.auth.currentUser; final user = Supabase.instance.client.auth.currentUser;
if (user == null || deviceId.isEmpty) return; if (user == null || deviceId.isEmpty) return;
try {
await Supabase.instance.client.from('terepi_seged_devices').upsert({ await Supabase.instance.client.from('terepi_seged_devices').upsert({
'id': appInstanceId, 'id': appInstanceId,
'user_id': user.id, 'user_id': user.id,
@@ -201,14 +202,10 @@ class DeviceIdentityService extends GetxService {
'app_version': appInfo, 'app_version': appInfo,
'last_seen_at': DateTime.now().toUtc().toIso8601String() 'last_seen_at': DateTime.now().toUtc().toIso8601String()
}); });
} catch (e) {
// AppLogger.e('DeviceIdentityService.registerDevice',
// // await Supabase.instance.client 'Eszköz-regisztráció hiba (user=${user.id} - device=$deviceId): $e');
// .from('devices') }
// .upsert(
// info.toRegistrationMap(label: deviceLabel.value),
// onConflict: 'device_id',
// );
} }
// ── Gyors elérők (kényelemért) ──────────────────────────────────── // ── Gyors elérők (kényelemért) ────────────────────────────────────
+98
View File
@@ -0,0 +1,98 @@
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/field_party.dart';
import '../models/field_property.dart';
/// Ingatlan-nyilvántartás lekérdezései — SZÁNDÉKOSAN tisztán online,
/// nincs helyi outbox/tükör-tábla (a kapcsolatokkal/mérésekkel
/// ellentétben). Ez az adat nem kerülhet a készülék adatbázisába.
class FieldPropertyService extends GetxService {
static FieldPropertyService get to => Get.find();
SupabaseClient get _client => Supabase.instance.client;
/// Ingatlanok listája, kereséssel/szűréssel.
Future<List<FieldProperty>> listProperties({
required String projectId,
String? search,
String? settlement,
String? mailingStatus,
}) async {
var query = _client
.from('terepi_seged_field_properties_view')
.select()
.eq('project_id', projectId);
if (settlement != null && settlement.isNotEmpty) {
query = query.eq('settlement', settlement);
}
if (mailingStatus != null && mailingStatus.isNotEmpty) {
query = query.eq('mailing_status', mailingStatus);
}
if (search != null && search.trim().isNotEmpty) {
final s = search.trim().replaceAll(',', ' ');
query = query.or(
'parcel_number.ilike.%$s%,owner_names.ilike.%$s%,settlement.ilike.%$s%',
);
}
final rows = await query
.order('settlement', ascending: true)
.order('parcel_number', ascending: true)
.limit(1000);
return rows.map((r) => FieldProperty.fromMap(r)).toList();
}
/// Tulajdonosok keresése név szerint.
Future<List<FieldParty>> searchParties({
required String projectId,
String? search,
}) async {
var query = _client
.from('terepi_seged_field_parties_view')
.select()
.eq('project_id', projectId);
if (search != null && search.trim().isNotEmpty) {
query = query.ilike('name', '%${search.trim()}%');
}
final rows = await query.order('name', ascending: true).limit(1000);
return rows.map((r) => FieldParty.fromMap(r)).toList();
}
/// Egy adott tulajdonos ÖSSZES ingatlana.
Future<List<FieldProperty>> listPropertiesForParty(String partyId) async {
final links = await _client
.from('terepi_seged_field_property_parties')
.select('property_id')
.eq('party_id', partyId)
.eq('is_current', true);
final propertyIds =
links.map((r) => r['property_id'] as String).toSet().toList();
if (propertyIds.isEmpty) return [];
final rows = await _client
.from('terepi_seged_field_properties_view')
.select()
.inFilter('id', propertyIds)
.order('settlement')
.order('parcel_number');
return rows.map((r) => FieldProperty.fromMap(r)).toList();
}
/// Egy ingatlan tulajdonosi/kezelői bontása, tulajdoni hányaddal — a
/// részletnézethez.
Future<List<Map<String, dynamic>>> propertyOwnershipDetail(
String propertyId) async {
final rows = await _client
.from('terepi_seged_field_property_parties')
.select('*, terepi_seged_field_parties(name, party_type)')
.eq('property_id', propertyId)
.eq('is_current', true);
return List<Map<String, dynamic>>.from(rows);
}
}
+32 -3
View File
@@ -13,6 +13,10 @@ class PhoneGpsConnection implements GnssConnection {
final _positionController = StreamController<Position>.broadcast(); final _positionController = StreamController<Position>.broadcast();
StreamSubscription<Position>? _positionSub; StreamSubscription<Position>? _positionSub;
int _retryCount = 0;
static const _maxRetries = 5;
Timer? _retryTimer;
@override @override
Stream<String> get nmeaLines => const Stream.empty(); // Nincs NMEA Stream<String> get nmeaLines => const Stream.empty(); // Nincs NMEA
@@ -42,20 +46,44 @@ class PhoneGpsConnection implements GnssConnection {
} }
_stateController.add(GnssConnectionState.connected); _stateController.add(GnssConnectionState.connected);
_retryCount = 0;
await _startPositionStream();
}
// Belső GPS folyamatos olvasása Future<void> _startPositionStream() async {
await _positionSub?.cancel();
_positionSub = Geolocator.getPositionStream( _positionSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high, accuracy: LocationAccuracy.high,
distanceFilter: 0, // Folyamatos frissítés distanceFilter: 0, // Folyamatos frissítés
), ),
).listen((Position pos) { ).listen(
(Position pos) {
_retryCount = 0;
_positionController.add(pos); _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 @override
Future<void> disconnect() async { Future<void> disconnect() async {
_retryTimer?.cancel();
await _positionSub?.cancel(); await _positionSub?.cancel();
_stateController.add(GnssConnectionState.disconnected); _stateController.add(GnssConnectionState.disconnected);
} }
@@ -67,6 +95,7 @@ class PhoneGpsConnection implements GnssConnection {
@override @override
void dispose() { void dispose() {
_retryTimer?.cancel();
_positionSub?.cancel(); _positionSub?.cancel();
_positionController.close(); _positionController.close();
_stateController.close(); _stateController.close();
+71 -3
View File
@@ -10,7 +10,9 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:terepi_seged/enums/layer_import_source_type.dart'; import 'package:terepi_seged/enums/layer_import_source_type.dart';
@@ -300,10 +302,11 @@ class LayerImportService extends GetxService {
defaultPolygonBorderColor: const Color(0xCC1565C0), defaultPolygonBorderColor: const Color(0xCC1565C0),
defaultPolygonBorderStroke: 1.5, defaultPolygonBorderStroke: 1.5,
defaultPolygonIsFilled: true, defaultPolygonIsFilled: true,
markerCreationCallback: _pointMarkerWithLabel,
onMarkerTapCallback: (props) { onMarkerTapCallback: (props) {
final label = props['name'] ?? props['title'] ?? ''; final label = _extractFeatureName(props);
if (label.toString().isNotEmpty) { if (label != null) {
Get.snackbar(label.toString(), props['description']?.toString() ?? '', Get.snackbar(label, props['description']?.toString() ?? '',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3)); 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()) { LayerImportSourceType _sourceType(String ext) => switch (ext.toLowerCase()) {
'kml' => LayerImportSourceType.kml, 'kml' => LayerImportSourceType.kml,
'kmz' => LayerImportSourceType.kmz, 'kmz' => LayerImportSourceType.kmz,
+241
View File
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/field_property.dart';
class GeoJsonImportResult {
final int imported;
final List<String> skipped;
const GeoJsonImportResult({required this.imported, required this.skipped});
}
/// Ingatlan-parcellák geometriája (GeoJSON-import + térképi lekérdezés).
///
/// SZÁNDÉKOSAN tisztán online, nincs helyi tükör-tábla. A tábla
/// PROJEKT-FÜGGETLEN, közös katalógus — egy fizikai telek geometriája
/// nem egyetlen projekthez tartozik.
class ParcelGeometryService extends GetxService {
static ParcelGeometryService get to => Get.find();
SupabaseClient get _client => Supabase.instance.client;
// ═════════════════════════════════════════════════════════════════
// Normalizálás — PONTOSAN a levél-pipeline szabálya (ellenőrzött
// minta alapján): minden "/"-lel elválasztott számjegy-szegmensről
// levágja a vezető nullákat.
// "0173/4" → "173/4", "0620" → "620", "011/9" → "11/9"
// ═════════════════════════════════════════════════════════════════
static String normalizeParcelNumber(String raw) {
final trimmed = raw.trim();
final parts = trimmed.split('/');
final normalized = parts.map((p) {
final stripped = p.replaceFirst(RegExp(r'^0+(?=\d)'), '');
return stripped.isEmpty ? '0' : stripped;
}).join('/');
return normalized;
}
// ═════════════════════════════════════════════════════════════════
// Import
// ═════════════════════════════════════════════════════════════════
static const _settlementKeys = ['telepules', 'település', 'settlement'];
static const _parcelKeys = [
'hrsz',
'helyrajziszam',
'helyrajzi_szam',
'parcel_number',
];
static String? _extractByKeys(Map<String, dynamic> props, List<String> keys) {
final lower = {for (final e in props.entries) e.key.toLowerCase(): e.value};
for (final k in keys) {
final v = lower[k];
if (v != null && v.toString().trim().isNotEmpty)
return v.toString().trim();
}
return null;
}
/// A GeoJSON tartalmának importja — Feature-önként upsert, egy hibás
/// elem nem akasztja meg a többit.
Future<GeoJsonImportResult> importGeoJson(String content,
{required String projectId, String? sourceFileName}) async {
final dynamic json = jsonDecode(content);
final features = (json is Map && json['features'] is List)
? json['features'] as List
: <dynamic>[];
var imported = 0;
final skipped = <String>[];
for (final f in features) {
try {
if (f is! Map) {
skipped.add('érvénytelen elem');
continue;
}
final geometry = f['geometry'];
if (geometry is! Map) {
skipped.add('geometria nélküli elem');
continue;
}
final geomType = geometry['type'] as String?;
List coords;
if (geomType == 'MultiPolygon') {
coords = geometry['coordinates'] as List;
} else if (geomType == 'Polygon') {
// Egységesítjük MultiPolygon-alakúra tároláshoz, hogy a
// megjelenítés mindig ugyanazt a szerkezetet olvassa.
coords = [geometry['coordinates']];
} else {
skipped.add('nem támogatott geometria-típus: $geomType');
continue;
}
final props = (f['properties'] as Map?)?.cast<String, dynamic>() ?? {};
final settlement = _extractByKeys(props, _settlementKeys);
final rawParcel = _extractByKeys(props, _parcelKeys);
if (settlement == null || rawParcel == null) {
skipped.add(
'hiányzó település/helyrajzi szám (talált kulcsok: ${props.keys.join(", ")})');
continue;
}
final normalized = normalizeParcelNumber(rawParcel);
final bbox = _computeBbox(coords);
await _client.from('terepi_seged_field_parcel_geometries').upsert({
'project_id': projectId,
'settlement': settlement,
'normalized_parcel_number': normalized,
'parcel_number_raw': rawParcel,
'geometry': {'type': 'MultiPolygon', 'coordinates': coords},
'bbox_min_lat': bbox.minLat,
'bbox_max_lat': bbox.maxLat,
'bbox_min_lon': bbox.minLon,
'bbox_max_lon': bbox.maxLon,
'source_file': sourceFileName,
}, onConflict: 'project_id,settlement,normalized_parcel_number');
imported++;
} catch (e) {
skipped.add('hiba: $e');
}
}
return GeoJsonImportResult(imported: imported, skipped: skipped);
}
_Bbox _computeBbox(List multiPolygonCoords) {
double minLat = double.infinity, maxLat = -double.infinity;
double minLon = double.infinity, maxLon = -double.infinity;
for (final polygon in multiPolygonCoords) {
for (final ring in polygon as List) {
for (final point in ring as List) {
final lon = (point[0] as num).toDouble();
final lat = (point[1] as num).toDouble();
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
}
}
return _Bbox(
minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon);
}
// ═════════════════════════════════════════════════════════════════
// Lekérdezés a térképi megjelenítéshez
// ═════════════════════════════════════════════════════════════════
/// Egy vagy több ingatlan geometriája (settlement + normalized_parcel_number
/// párok alapján) — a property-lista bármely részhalmazára hívható.
Future<List<Map<String, dynamic>>> fetchGeometriesFor(
List<FieldProperty> properties) async {
if (properties.isEmpty) return [];
// Minden átadott property ugyanahhoz az (aktív) projekthez tartozik —
// a geometria is erre a projektre szűkül.
final projectId = properties.first.projectId;
final bySettlement = <String, Set<String>>{};
for (final p in properties) {
bySettlement
.putIfAbsent(p.settlement, () => {})
.add(p.normalizedParcelNumber);
}
final result = <Map<String, dynamic>>[];
for (final entry in bySettlement.entries) {
final rows = await _client
.from('terepi_seged_field_parcel_geometries')
.select()
.eq('project_id', projectId)
.eq('settlement', entry.key)
.inFilter('normalized_parcel_number', entry.value.toList());
result.addAll(List<Map<String, dynamic>>.from(rows));
}
return result;
}
// ═════════════════════════════════════════════════════════════════
// flutter_map segédek
// ═════════════════════════════════════════════════════════════════
List<Polygon> parsePolygons(List<Map<String, dynamic>> rows,
{Color color = Colors.deepOrange}) {
final polygons = <Polygon>[];
for (final row in rows) {
final geom = row['geometry'] as Map;
final coords = geom['coordinates'] as List; // MultiPolygon
for (final polygon in coords) {
final outerRing = (polygon as List).first as List;
final points = outerRing
.map((p) =>
LatLng((p[1] as num).toDouble(), (p[0] as num).toDouble()))
.toList();
polygons.add(Polygon(
points: points,
color: color.withOpacity(0.35),
borderColor: color,
borderStrokeWidth: 2,
));
}
}
return polygons;
}
LatLngBounds? computeBounds(List<Map<String, dynamic>> rows) {
if (rows.isEmpty) return null;
double minLat = double.infinity, maxLat = -double.infinity;
double minLon = double.infinity, maxLon = -double.infinity;
for (final row in rows) {
minLat = math.min(minLat, (row['bbox_min_lat'] as num).toDouble());
maxLat = math.max(maxLat, (row['bbox_max_lat'] as num).toDouble());
minLon = math.min(minLon, (row['bbox_min_lon'] as num).toDouble());
maxLon = math.max(maxLon, (row['bbox_max_lon'] as num).toDouble());
}
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
}
}
class _Bbox {
final double minLat, maxLat, minLon, maxLon;
const _Bbox({
required this.minLat,
required this.maxLat,
required this.minLon,
required this.maxLon,
});
}
+37 -22
View File
@@ -1,17 +1,14 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.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 /// A jogosultságokat a Supabase `app_permissions` tábla tárolja
/// (user_id + area), és a felhasználó a saját sorait olvashatja (RLS). /// (user_id + area + project_id), ahol a project_id NULL értéke
/// A service induláskor és bejelentkezéskor betölti a jelenlegi /// GLOBÁLIS jogot jelent (minden projektre érvényes — pl. admin).
/// felhasználó jogosultságait egy halmazba, amit a UI reaktívan figyel. /// A tényleges védelmet a Supabase-oldali RLS adja
/// /// (`has_permission(area, project_id)`), ez a service csak a UI-t
/// Bővíthető: új védett terület = új 'area' string (pl. 'admin'), /// vezérli (menüpont elrejtése, üzenet).
/// 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).
class PermissionService extends GetxService { class PermissionService extends GetxService {
static PermissionService get to => Get.find(); static PermissionService get to => Get.find();
@@ -20,18 +17,31 @@ class PermissionService extends GetxService {
SupabaseClient get _client => Supabase.instance.client; SupabaseClient get _client => Supabase.instance.client;
/// A jelenlegi felhasználó engedélyezett területei. /// projectId → engedélyezett területek. A `null` kulcs a GLOBÁLIS
final _areas = <String>{}.obs; /// (minden projektre érvényes) jogokat tárolja.
final _areasByProject = <String?, Set<String>>{}.obs;
final isLoaded = false.obs; final isLoaded = false.obs;
bool can(String area) => _areas.contains(area); /// [projectId] nélkül CSAK a globális jogokat nézi — projekt-specifikus
bool get canContacts => can(areaContacts); /// 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); bool get canAdmin => can(areaAdmin);
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Induláskor és minden auth-változáskor újratöltjük.
reload(); reload();
_client.auth.onAuthStateChange.listen((_) => reload()); _client.auth.onAuthStateChange.listen((_) => reload());
} }
@@ -39,23 +49,28 @@ class PermissionService extends GetxService {
Future<void> reload() async { Future<void> reload() async {
final user = _client.auth.currentUser; final user = _client.auth.currentUser;
if (user == null) { if (user == null) {
_areas.clear(); _areasByProject.clear();
isLoaded.value = true; isLoaded.value = true;
return; return;
} }
try { try {
final rows = await _client final rows = await _client
.from('terepi_seged_app_permissions') .from('terepi_seged_app_permissions')
.select('area') .select('area, project_id')
.eq('user_id', user.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() ..clear()
..addAll(rows.map((r) => r['area'] as String)); ..addAll(map);
} catch (_) { } catch (_) {
// Hálózati hiba: nem adunk jogot (fail-closed), de nem is dobunk. _areasByProject.clear();
_areas.clear();
} finally { } finally {
_areas.refresh(); _areasByProject.refresh();
isLoaded.value = true; 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. /// Minimális elmozdulás méterben új pont előtt.
final double distanceFilter; 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({ PhoneGpsSource({
this.intervalMs = 1000, this.intervalMs = 1000,
this.distanceFilter = 1.0, this.distanceFilter = 1.0,
@@ -59,15 +65,17 @@ class PhoneGpsSource implements LocationSource {
// notification biztosítja a jogszerű háttér-használatot. // notification biztosítja a jogszerű háttér-használatot.
foregroundNotificationConfig: const ForegroundNotificationConfig( foregroundNotificationConfig: const ForegroundNotificationConfig(
notificationText: 'Track rögzítése folyamatban', notificationText: 'Track rögzítése folyamatban',
notificationTitle: 'Terepi Segéd Nyomvonal', notificationTitle: 'Terepi Segéd - Nyomvonal',
enableWakeLock: true, enableWakeLock: true,
), ),
); );
await _positionSub?.cancel();
_positionSub = Geolocator.getPositionStream( _positionSub = Geolocator.getPositionStream(
locationSettings: settings, locationSettings: settings,
).listen( ).listen((Position pos) {
(Position pos) { _retryCount = 0;
_controller?.add(SourcePosition( _controller?.add(SourcePosition(
latitude: pos.latitude, latitude: pos.latitude,
longitude: pos.longitude, longitude: pos.longitude,
@@ -80,12 +88,43 @@ class PhoneGpsSource implements LocationSource {
source: displayName, 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 @override
Future<void> dispose() async { Future<void> dispose() async {
_retryTimer?.cancel();
await _positionSub?.cancel(); await _positionSub?.cancel();
await _controller?.close(); await _controller?.close();
_controller = null; _controller = null;
+222 -18
View File
@@ -1,11 +1,39 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.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 'package:uuid/uuid.dart';
import '../models/project.dart'; import '../models/project.dart';
import 'app_database.dart'; import 'app_database.dart';
import 'ts_sync_service.dart'; 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 { class ProjectService extends GetxService {
static ProjectService get to => Get.find(); static ProjectService get to => Get.find();
@@ -19,6 +47,20 @@ class ProjectService extends GetxService {
super.onInit(); super.onInit();
await _loadProjects(); await _loadProjects();
await _restoreActiveProject(); 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 { Future<void> _loadProjects() async {
@@ -37,19 +79,89 @@ class ProjectService extends GetxService {
return; return;
} }
} }
// Fallback: az első aktív projekt // Fallback: elsőként lokális projektet próbálunk (mindig biztonságos),
if (projects.isNotEmpty) { // csak ha nincs, esünk vissza bármelyikre — és a hibát itt is elkapjuk,
await setActiveProject(projects.first); // 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 { 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; activeProject.value = project;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('active_project_id', project.id!); 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 AppDatabase.instance.updateProject(project.copyWith());
}
await _loadProjects(); await _loadProjects();
} }
@@ -104,11 +216,6 @@ class ProjectService extends GetxService {
// Lokálisan mentjük // Lokálisan mentjük
final id = await AppDatabase.instance.insertProject(project); final id = await AppDatabase.instance.insertProject(project);
// // Supabase-be is
// await Supabase.instance.client
// .from('TerepiSeged_Projects')
// .insert(project.toMap());
await _loadProjects(); await _loadProjects();
// Ha van net, azonnal fel is megy (és owner-tagság is létrejön). // 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(); TsSyncService.to.syncNow();
} }
AppLogger.event('project_created_online', project.uuid,
{'name': name, 'client': client});
return await AppDatabase.instance.getProject(id) ?? project; return await AppDatabase.instance.getProject(id) ?? project;
} }
@@ -140,6 +249,9 @@ class ProjectService extends GetxService {
// Csak lokálisan // Csak lokálisan
final id = await AppDatabase.instance.insertProject(project); final id = await AppDatabase.instance.insertProject(project);
await _loadProjects(); await _loadProjects();
AppLogger.event('project_created_local', project.uuid,
{'name': name, 'client': client});
return await AppDatabase.instance.getProject(id) ?? project; 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. /// 3. azonnali szinkron, hogy a projekt eddigi adatai lejöjjenek.
Future<Project> joinSharedProject(Map<String, dynamic> sharedRow) async { Future<Project> joinSharedProject(Map<String, dynamic> sharedRow) async {
final client = Supabase.instance.client; 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; 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( await client.from('terepi_seged_project_members').upsert(
{ {
'project_id': projectUuid, 'project_id': projectUuid,
'user_id': client.auth.currentUser!.id, 'user_id': user.id,
'role': 'editor', 'role': 'editor',
}, },
ignoreDuplicates: true, 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 = final localId =
await AppDatabase.instance.upsertProjectFromRemote(sharedRow); await AppDatabase.instance.upsertProjectFromRemote(sharedRow);
await _loadProjects(); 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>()) { if (Get.isRegistered<TsSyncService>()) {
TsSyncService.to.syncNow(); TsSyncService.to.syncNow();
} }
@@ -193,25 +317,105 @@ class ProjectService extends GetxService {
/// a lokális adat megmarad (archiválható külön). /// a lokális adat megmarad (archiválható külön).
Future<void> leaveSharedProject(Project project) async { Future<void> leaveSharedProject(Project project) async {
final client = Supabase.instance.client; final client = Supabase.instance.client;
final user = client.auth.currentUser;
if (user == null) return;
try {
await client await client
.from('terepi_seged_project_members') .from('terepi_seged_project_members')
.delete() .delete()
.eq('project_id', project.uuid) .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) => Future<Map<String, int>> getStats(int projectId) =>
AppDatabase.instance.getProjectStats(projectId); AppDatabase.instance.getProjectStats(projectId);
Future<void> archiveProject(int id) async { 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); 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) { if (activeProject.value?.id == id) {
activeProject.value = projects.isNotEmpty final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ??
? projects.firstWhereOrNull((p) => p.id != id) projects.firstWhereOrNull((p) => p.id != id);
: null; 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:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'stakeout_service.dart'; import 'stakeout_service.dart';
@@ -52,8 +53,23 @@ class StakeoutSyncService extends GetxService {
} }
Future<bool> _pushThenPull() async { Future<bool> _pushThenPull() async {
await _push(); try {
return _pull(); 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 ───────────────────────────────────────────────────────── // ── PUSH ─────────────────────────────────────────────────────────
+51 -15
View File
@@ -4,8 +4,10 @@ import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:terepi_seged/services/contact_service.dart'; import 'package:terepi_seged/services/contact_service.dart';
import 'package:terepi_seged/services/device_identity_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 'package:terepi_seged/services/stakeout_sync_service.dart';
import 'app_database.dart'; import 'app_database.dart';
@@ -81,31 +83,35 @@ class TsSyncService extends GetxService {
lastError.value = ''; lastError.value = '';
try { try {
lastError.value = '';
// Eszköz-regiszter frissítése (last_seen_at). // Eszköz-regiszter frissítése (last_seen_at).
if (Get.isRegistered<DeviceIdentityService>()) { if (Get.isRegistered<DeviceIdentityService>()) {
await DeviceIdentityService.to.registerDevice(); await _isolate(
'eszköz regisztációja', DeviceIdentityService.to.registerDevice);
} }
await _discoverMemberProjects(); await _isolate('tagság felderítése', _discoverMemberProjects);
await _push(); await _isolate('feltöltés', _push);
await _pull(); await _isolate('letöltés', _pull);
// Megosztott rétegek (5. lépés) — ha a service be van kötve. // Megosztott rétegek (5. lépés) — ha a service be van kötve.
if (Get.isRegistered<LayerSyncService>()) { if (Get.isRegistered<LayerSyncService>()) {
await LayerSyncService.to.pullAll(); await _isolate('rétegek', LayerSyncService.to.pullAll);
} }
if (Get.isRegistered<StakeoutSyncService>()) { if (Get.isRegistered<StakeoutSyncService>()) {
await StakeoutSyncService.to.sync(); await _isolate('kitűzés', StakeoutSyncService.to.sync);
} }
if (Get.isRegistered<ContactService>()) { if (Get.isRegistered<ContactService>()) {
await ContactService.to.flush(); await _isolate('kapcsolatok', () => ContactService.to.flush());
} }
lastSyncedAt.value = DateTime.now(); lastSyncedAt.value = DateTime.now();
} catch (e) { } catch (e, s) {
lastError.value = e.toString(); lastError.value = e.toString();
AppLogger.e('TsSyncService - SyncNow', lastError.value,
error: e, stack: s);
} finally { } finally {
await refreshPendingCount(); await refreshPendingCount();
isSyncing.value = false; isSyncing.value = false;
@@ -129,8 +135,22 @@ class TsSyncService extends GetxService {
.select() .select()
.eq('is_member', true); .eq('is_member', true);
final remoteUuids = <String>{};
for (final row in rows) { 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 { Future<void> _push() async {
await _pushProjects(); await _isolate('projektek push', _pushProjects);
await _pushMeasuredPoints(); await _isolate('mérési pontok', _pushMeasuredPoints);
await pushTracks(); await _isolate('track-ek push', pushTracks);
await pushTrackPoints(); await _isolate('track-pontok push', pushTrackPoints);
await _pushNoteItems(); await _isolate('jegyzetek push', _pushNoteItems);
} }
Future<void> _pushProjects() async { Future<void> _pushProjects() async {
@@ -287,7 +307,7 @@ class TsSyncService extends GetxService {
// Minden szinkronizált (nem lokális) projekt. // Minden szinkronizált (nem lokális) projekt.
final projects = await AppDatabase.instance.listProjects(); final projects = await AppDatabase.instance.listProjects();
for (final p in projects.where((p) => !p.isLocalOnly)) { 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; if (localIso == null || localIso.isEmpty) return null;
return DateTime.parse(localIso).toUtc().toIso8601String(); 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);
}
}
+26 -1
View File
@@ -109,8 +109,9 @@ class AppDrawer extends StatelessWidget {
), ),
Obx(() { Obx(() {
final signedIn = AuthService.to.isSignedIn; final signedIn = AuthService.to.isSignedIn;
final projectId = ProjectService.to.activeProject.value?.uuid;
final allowed = !Get.isRegistered<PermissionService>() || final allowed = !Get.isRegistered<PermissionService>() ||
PermissionService.to.canContacts; PermissionService.to.canContacts(projectId: projectId);
if (!signedIn || !allowed) return const SizedBox.shrink(); if (!signedIn || !allowed) return const SizedBox.shrink();
return ListTile( return ListTile(
leading: const Icon(Icons.phone_outlined), leading: const Icon(Icons.phone_outlined),
@@ -121,6 +122,22 @@ 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(projectId: projectId);
final allowed = true;
if (!signedIn || !allowed) return const SizedBox.shrink();
return ListTile(
leading: const Icon(Icons.house_outlined),
title: const Text('Ingatlanok'),
onTap: () {
Get.back();
Get.toNamed(Routes.PROPERTYLIST);
},
);
}),
Obx(() => ListTile( Obx(() => ListTile(
leading: TsSyncService.to.isSyncing.value leading: TsSyncService.to.isSyncing.value
? const SizedBox( ? const SizedBox(
@@ -145,6 +162,14 @@ class AppDrawer extends StatelessWidget {
// Get.to(() => const NtripSettingsView()); // 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 ───────────────────────────────── // ── 3. Beállítások ─────────────────────────────────
const _SectionLabel('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(); if (markers.isEmpty) return const SizedBox.shrink();
return MarkerLayer(markers: markers); return MarkerLayer(markers: markers);
}); });
@@ -32,6 +32,7 @@ class LabelFieldState extends State<LabelField> {
MapEditTool.point => 'Pont neve...', MapEditTool.point => 'Pont neve...',
MapEditTool.line => 'Vonal neve...', MapEditTool.line => 'Vonal neve...',
MapEditTool.polygon => 'Terület neve...', MapEditTool.polygon => 'Terület neve...',
MapEditTool.contact => 'Felirat ...',
MapEditTool.none => 'Felirat...', MapEditTool.none => 'Felirat...',
}; };
return Column( return Column(
@@ -116,6 +116,8 @@ class LineOrPolygonDrawingContent extends StatelessWidget {
return 'min. 3'; return 'min. 3';
case MapEditTool.point: case MapEditTool.point:
return '1 pont'; return '1 pont';
case MapEditTool.contact:
return '';
case MapEditTool.none: case MapEditTool.none:
return ''; return '';
} }
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:terepi_seged/enums/map_edit_tool.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/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_action.dart';
import 'map_toolbar_divider.dart'; import 'map_toolbar_divider.dart';
@@ -65,6 +67,14 @@ class MapEditCompactToolbar extends StatelessWidget {
selected: activeTool == MapEditTool.polygon, selected: activeTool == MapEditTool.polygon,
onTap: controller.startPolygonTool, 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(), const ToolbarDivider(),
ToolbarAction( ToolbarAction(
icon: Icons.list_alt_outlined, icon: Icons.list_alt_outlined,
@@ -72,12 +82,12 @@ class MapEditCompactToolbar extends StatelessWidget {
selected: false, selected: false,
onTap: () {}, onTap: () {},
), ),
ToolbarAction( // ToolbarAction(
icon: Icons.layers_outlined, // icon: Icons.layers_outlined,
label: 'Rétegek', // label: 'Rétegek',
selected: false, // selected: false,
onTap: () {}, // onTap: () {},
), // ),
], ],
), ),
), ),
+16 -2
View File
@@ -184,8 +184,15 @@ class _ProjectTileState extends State<_ProjectTile> {
], ],
), ),
onTap: () async { onTap: () async {
try {
await svc.setActiveProject(project); await svc.setActiveProject(project);
Get.back(); 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')), TextButton(onPressed: Get.back, child: const Text('Mégse')),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.orange), style: FilledButton.styleFrom(backgroundColor: Colors.orange),
onPressed: () { onPressed: () async {
Get.back(); try {
ProjectService.to.archiveProject(widget.project.id!); 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'), child: const Text('Archiválás'),
), ),