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