Kitűzés: pontok importja, szervízek, kitüző panel
This commit is contained in:
+3
-1
@@ -21,6 +21,7 @@ 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/project_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
import 'package:terepi_seged/services/track_sync_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -43,6 +44,7 @@ Future<void> main() async {
|
||||
url: dotenv.env['SUPABASE_URL']!,
|
||||
anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
|
||||
|
||||
Get.put(AuthService());
|
||||
await AppDatabase.instance.database;
|
||||
Get.put(ProjectService(), permanent: true);
|
||||
|
||||
@@ -52,6 +54,7 @@ Future<void> main() async {
|
||||
() => CoordConverterService().init());
|
||||
Get.put(GnssDeviceService());
|
||||
Get.put(GnssService());
|
||||
Get.put(StakeoutService());
|
||||
Get.put(NtripService());
|
||||
NtripService.to.onRtcmData = (data) => GnssService.to.sendToReceiver(data);
|
||||
Get.put(TrackingController(), permanent: true);
|
||||
@@ -60,7 +63,6 @@ Future<void> main() async {
|
||||
Get.put(LayerImportService(), permanent: true);
|
||||
Get.put(DeviceIdentityService(), permanent: true);
|
||||
Get.put(TrackSyncService(), permanent: true);
|
||||
Get.put(AuthService());
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Kitűzési pont állapota.
|
||||
enum StakeoutStatus { pending, staked, skipped }
|
||||
|
||||
/// Kitűzendő pont — szeizmikus line/station modellel.
|
||||
///
|
||||
/// A pont természetes azonosítója a (lineId, station) páros. A rekord a
|
||||
/// TERV-koordinátákat ÉS a kitűzéskori MÉRT pozíciót is tárolja, az
|
||||
/// inline/crossline eltérésekkel együtt — így az export nemcsak azt
|
||||
/// dokumentálja, MIT tűztünk ki, hanem azt is, MILYEN pontossággal.
|
||||
///
|
||||
/// A séma szinkron-kész (uuid, updated_at, deleted_at, sync_status,
|
||||
/// created_by, device_id) — a Supabase-oldal a 4. ütemben épül rá.
|
||||
class StakeoutPoint {
|
||||
final int? id;
|
||||
final String uuid;
|
||||
final int projectId;
|
||||
|
||||
// ── Identitás ────────────────────────────────────────────────────
|
||||
final String lineId; // szeizmikus vonal azonosító ('' = vonal nélküli)
|
||||
final int station; // numerikus állomásszám (kötelező!)
|
||||
final String name; // megjelenítendő név (alapból a station szövege)
|
||||
final String pointType; // geofon / forras / egyeb — SPS-re előkészítve
|
||||
final String source; // csv / geojson / supabase / offset / kezi
|
||||
|
||||
// ── Terv-koordináták (mindkét rendszerben tárolva) ───────────────
|
||||
final double planEovY;
|
||||
final double planEovX;
|
||||
final double? planEovZ;
|
||||
final double planLat;
|
||||
final double planLon;
|
||||
|
||||
// ── Állapot + mért adatok ────────────────────────────────────────
|
||||
final StakeoutStatus status;
|
||||
final double? measuredEovY;
|
||||
final double? measuredEovX;
|
||||
final double? measuredEovZ;
|
||||
final double? measuredLat;
|
||||
final double? measuredLon;
|
||||
final double?
|
||||
devInline; // vonal menti eltérés (m, + = station-növekedés felé)
|
||||
final double? devCrossline; // vonalra merőleges eltérés (m, + = jobbra)
|
||||
final double? devDz; // magassági eltérés (m, terv − mért)
|
||||
final int? fixQuality; // GGA quality tároláskor (4 = RTK fixed)
|
||||
final double? accuracy; // vízszintes hiba tároláskor (m)
|
||||
final double? tiltDeg; // rúd-dőlés tároláskor (később: libella-modul)
|
||||
final DateTime? stakedAt;
|
||||
|
||||
// ── Eltolt (transzverzális) pont ─────────────────────────────────
|
||||
final bool isOffset;
|
||||
final String? parentUuid; // az eredeti tervpont uuid-ja
|
||||
final double? offsetDist; // eltolás nagysága (m)
|
||||
final double? offsetBearing; // eltolás iránya (fok, EOV-észak = 0)
|
||||
|
||||
// ── Szinkron / audit ─────────────────────────────────────────────
|
||||
final String? createdBy;
|
||||
final String? deviceId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final String syncStatus;
|
||||
|
||||
StakeoutPoint({
|
||||
this.id,
|
||||
String? uuid,
|
||||
required this.projectId,
|
||||
this.lineId = '',
|
||||
required this.station,
|
||||
String? name,
|
||||
this.pointType = 'geofon',
|
||||
this.source = 'csv',
|
||||
required this.planEovY,
|
||||
required this.planEovX,
|
||||
this.planEovZ,
|
||||
required this.planLat,
|
||||
required this.planLon,
|
||||
this.status = StakeoutStatus.pending,
|
||||
this.measuredEovY,
|
||||
this.measuredEovX,
|
||||
this.measuredEovZ,
|
||||
this.measuredLat,
|
||||
this.measuredLon,
|
||||
this.devInline,
|
||||
this.devCrossline,
|
||||
this.devDz,
|
||||
this.fixQuality,
|
||||
this.accuracy,
|
||||
this.tiltDeg,
|
||||
this.stakedAt,
|
||||
this.isOffset = false,
|
||||
this.parentUuid,
|
||||
this.offsetDist,
|
||||
this.offsetBearing,
|
||||
this.createdBy,
|
||||
this.deviceId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
this.deletedAt,
|
||||
this.syncStatus = 'pending',
|
||||
}) : uuid = uuid ?? const Uuid().v4(),
|
||||
name = name ?? station.toString(),
|
||||
createdAt = createdAt ?? DateTime.now(),
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
||||
/// Teljes azonosító megjelenítésre: "L1024 · 105" vagy csak "105".
|
||||
String get displayId => lineId.isEmpty ? name : '$lineId · $name';
|
||||
|
||||
StakeoutPoint copyWith({
|
||||
int? id,
|
||||
StakeoutStatus? status,
|
||||
double? measuredEovY,
|
||||
double? measuredEovX,
|
||||
double? measuredEovZ,
|
||||
double? measuredLat,
|
||||
double? measuredLon,
|
||||
double? devInline,
|
||||
double? devCrossline,
|
||||
double? devDz,
|
||||
int? fixQuality,
|
||||
double? accuracy,
|
||||
double? tiltDeg,
|
||||
DateTime? stakedAt,
|
||||
DateTime? updatedAt,
|
||||
String? syncStatus,
|
||||
}) {
|
||||
return StakeoutPoint(
|
||||
id: id ?? this.id,
|
||||
uuid: uuid,
|
||||
projectId: projectId,
|
||||
lineId: lineId,
|
||||
station: station,
|
||||
name: name,
|
||||
pointType: pointType,
|
||||
source: source,
|
||||
planEovY: planEovY,
|
||||
planEovX: planEovX,
|
||||
planEovZ: planEovZ,
|
||||
planLat: planLat,
|
||||
planLon: planLon,
|
||||
status: status ?? this.status,
|
||||
measuredEovY: measuredEovY ?? this.measuredEovY,
|
||||
measuredEovX: measuredEovX ?? this.measuredEovX,
|
||||
measuredEovZ: measuredEovZ ?? this.measuredEovZ,
|
||||
measuredLat: measuredLat ?? this.measuredLat,
|
||||
measuredLon: measuredLon ?? this.measuredLon,
|
||||
devInline: devInline ?? this.devInline,
|
||||
devCrossline: devCrossline ?? this.devCrossline,
|
||||
devDz: devDz ?? this.devDz,
|
||||
fixQuality: fixQuality ?? this.fixQuality,
|
||||
accuracy: accuracy ?? this.accuracy,
|
||||
tiltDeg: tiltDeg ?? this.tiltDeg,
|
||||
stakedAt: stakedAt ?? this.stakedAt,
|
||||
isOffset: isOffset,
|
||||
parentUuid: parentUuid,
|
||||
offsetDist: offsetDist,
|
||||
offsetBearing: offsetBearing,
|
||||
createdBy: createdBy,
|
||||
deviceId: deviceId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
deletedAt: deletedAt,
|
||||
syncStatus: syncStatus ?? 'pending',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
if (id != null) 'id': id,
|
||||
'uuid': uuid,
|
||||
'project_id': projectId,
|
||||
'line_id': lineId,
|
||||
'station': station,
|
||||
'name': name,
|
||||
'point_type': pointType,
|
||||
'source': source,
|
||||
'plan_eov_y': planEovY,
|
||||
'plan_eov_x': planEovX,
|
||||
'plan_eov_z': planEovZ,
|
||||
'plan_lat': planLat,
|
||||
'plan_lon': planLon,
|
||||
'status': status.name,
|
||||
'measured_eov_y': measuredEovY,
|
||||
'measured_eov_x': measuredEovX,
|
||||
'measured_eov_z': measuredEovZ,
|
||||
'measured_lat': measuredLat,
|
||||
'measured_lon': measuredLon,
|
||||
'dev_inline': devInline,
|
||||
'dev_crossline': devCrossline,
|
||||
'dev_dz': devDz,
|
||||
'fix_quality': fixQuality,
|
||||
'accuracy': accuracy,
|
||||
'tilt_deg': tiltDeg,
|
||||
'staked_at': stakedAt?.toIso8601String(),
|
||||
'is_offset': isOffset ? 1 : 0,
|
||||
'parent_uuid': parentUuid,
|
||||
'offset_dist': offsetDist,
|
||||
'offset_bearing': offsetBearing,
|
||||
'created_by': createdBy,
|
||||
'device_id': deviceId,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'deleted_at': deletedAt?.toIso8601String(),
|
||||
'sync_status': syncStatus,
|
||||
};
|
||||
|
||||
factory StakeoutPoint.fromMap(Map<String, dynamic> m) => StakeoutPoint(
|
||||
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,
|
||||
name: (m['name'] as String?) ?? '${m['station']}',
|
||||
pointType: (m['point_type'] as String?) ?? 'geofon',
|
||||
source: (m['source'] as String?) ?? 'csv',
|
||||
planEovY: (m['plan_eov_y'] as num).toDouble(),
|
||||
planEovX: (m['plan_eov_x'] as num).toDouble(),
|
||||
planEovZ: (m['plan_eov_z'] as num?)?.toDouble(),
|
||||
planLat: (m['plan_lat'] as num).toDouble(),
|
||||
planLon: (m['plan_lon'] as num).toDouble(),
|
||||
status: StakeoutStatus.values.firstWhere((s) => s.name == m['status'],
|
||||
orElse: () => StakeoutStatus.pending),
|
||||
measuredEovY: (m['measured_eov_y'] as num?)?.toDouble(),
|
||||
measuredEovX: (m['measured_eov_x'] as num?)?.toDouble(),
|
||||
measuredEovZ: (m['measured_eov_z'] as num?)?.toDouble(),
|
||||
measuredLat: (m['measured_lat'] as num?)?.toDouble(),
|
||||
measuredLon: (m['measured_lon'] as num?)?.toDouble(),
|
||||
devInline: (m['dev_inline'] as num?)?.toDouble(),
|
||||
devCrossline: (m['dev_crossline'] as num?)?.toDouble(),
|
||||
devDz: (m['dev_dz'] as num?)?.toDouble(),
|
||||
fixQuality: m['fix_quality'] as int?,
|
||||
accuracy: (m['accuracy'] as num?)?.toDouble(),
|
||||
tiltDeg: (m['tilt_deg'] as num?)?.toDouble(),
|
||||
stakedAt: m['staked_at'] != null
|
||||
? DateTime.tryParse(m['staked_at'] as String)
|
||||
: null,
|
||||
isOffset: (m['is_offset'] as int? ?? 0) == 1,
|
||||
parentUuid: m['parent_uuid'] as String?,
|
||||
offsetDist: (m['offset_dist'] as num?)?.toDouble(),
|
||||
offsetBearing: (m['offset_bearing'] as num?)?.toDouble(),
|
||||
createdBy: m['created_by'] as String?,
|
||||
deviceId: m['device_id'] as String?,
|
||||
createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ??
|
||||
DateTime.now(),
|
||||
updatedAt: DateTime.tryParse((m['updated_at'] as String?) ?? '') ??
|
||||
DateTime.now(),
|
||||
deletedAt: m['deleted_at'] != null
|
||||
? DateTime.tryParse(m['deleted_at'] as String)
|
||||
: null,
|
||||
syncStatus: (m['sync_status'] as String?) ?? 'pending',
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ 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/project_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
import 'package:terepi_seged/widgets/map/all_layer_overlay.dart';
|
||||
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
|
||||
import 'package:terepi_seged/widgets/map/team_member_widget.dart';
|
||||
@@ -452,6 +453,12 @@ class MapSurveyController extends GetxController implements StyleEditable {
|
||||
// - térképi tap viselkedés
|
||||
// - aktív kártyák
|
||||
// - track indítás/leállítás figyelmeztetés stb.
|
||||
|
||||
// A kitűzési mód: a StakeoutService aktiválása/deaktiválása
|
||||
// aktiváláskor betölti a pontokat és indul a haptika/fázis logika
|
||||
if (Get.isRegistered<StakeoutService>()) {
|
||||
StakeoutService.to.setActive(newMode == MapSurveyMode.stakeout);
|
||||
}
|
||||
}
|
||||
|
||||
String get currentModeLabel => switch (mode.value) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:terepi_seged/enums/map_measure_type.dart';
|
||||
import 'package:terepi_seged/enums/map_survey_mode.dart';
|
||||
import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart';
|
||||
import 'package:terepi_seged/pages/map_survey/presentations/views/settings_dialog.dart';
|
||||
import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_map_layer.dart';
|
||||
import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_panel.dart';
|
||||
import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart';
|
||||
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||
@@ -240,6 +242,7 @@ class MapSurveyView extends GetView<MapSurveyController> {
|
||||
return MarkerLayer(markers: markers);
|
||||
}),
|
||||
DistanceOrAreaMeasureLayer(controller: controller),
|
||||
const StakeoutMapLayers(),
|
||||
Obx(() {
|
||||
final isGpsActive = GnssService.to.activeConnectionType.value !=
|
||||
GnssConnectionType.none;
|
||||
@@ -335,7 +338,14 @@ class MapSurveyView extends GetView<MapSurveyController> {
|
||||
),
|
||||
]
|
||||
]));
|
||||
})
|
||||
}),
|
||||
Obx(() {
|
||||
if (controller.mode.value != MapSurveyMode.stakeout) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
return const Positioned(
|
||||
left: 0, right: 0, bottom: 0, child: StakeoutPanel());
|
||||
}),
|
||||
// Positioned(top: 8, left: 0, right: 0, child: _ModeSelector()),
|
||||
// Positioned(
|
||||
// bottom: 80,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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/services/gnss/gnss_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
|
||||
import '../../../../models/stakeout_point.dart';
|
||||
|
||||
/// Kitűzési térképréteg — a SharedMapWidget children listájába illesztendő.
|
||||
///
|
||||
/// Csak akkor rajzol, ha a StakeoutService aktív (mode == stakeout), így
|
||||
/// feltétel nélkül bent maradhat a rétegek között. Tartalma: vonalanként
|
||||
/// polyline (station-sorrendben), státusz-színezett markerek (koppintás =
|
||||
/// célváltás), vezetővonal a pozíciótól a célig.
|
||||
class StakeoutMapLayers extends StatelessWidget {
|
||||
const StakeoutMapLayers({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Get.isRegistered<StakeoutService>()) return const SizedBox.shrink();
|
||||
|
||||
return Obx(() {
|
||||
final svc = StakeoutService.to;
|
||||
if (!svc.active.value || svc.points.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final points = svc.points;
|
||||
final target = svc.target.value;
|
||||
final g = GnssService.to;
|
||||
final hasPos = svc.hasPosition.value && g.latitude.value != 0;
|
||||
|
||||
// Vonalanként polyline (station-sorrendben, eltolt pontok nélkül).
|
||||
final linePolys = <Polyline>[];
|
||||
for (final line in svc.lines) {
|
||||
final lp = points.where((p) => p.lineId == line && !p.isOffset).toList()
|
||||
..sort((a, b) => a.station.compareTo(b.station));
|
||||
if (lp.length < 2) continue;
|
||||
linePolys.add(Polyline(
|
||||
points: [for (final p in lp) LatLng(p.planLat, p.planLon)],
|
||||
color: Colors.blueGrey.withOpacity(0.5),
|
||||
strokeWidth: 2,
|
||||
));
|
||||
}
|
||||
|
||||
// 300 pont fölött a címkék csak a célon (teljesítmény).
|
||||
final showLabels = points.length <= 300;
|
||||
|
||||
return Stack(children: [
|
||||
PolylineLayer(polylines: [
|
||||
...linePolys,
|
||||
if (hasPos && target != null)
|
||||
Polyline(
|
||||
points: [
|
||||
LatLng(g.latitude.value, g.longitude.value),
|
||||
LatLng(target.planLat, target.planLon),
|
||||
],
|
||||
color: Colors.blue,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
]),
|
||||
MarkerLayer(markers: [
|
||||
for (final p in points)
|
||||
Marker(
|
||||
point: LatLng(p.planLat, p.planLon),
|
||||
width: 56,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTap: () => svc.setTarget(p),
|
||||
child: _StakeoutMarker(
|
||||
point: p,
|
||||
isTarget: p.uuid == target?.uuid,
|
||||
showLabel: showLabels || p.uuid == target?.uuid,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _StakeoutMarker extends StatelessWidget {
|
||||
final StakeoutPoint point;
|
||||
final bool isTarget;
|
||||
final bool showLabel;
|
||||
const _StakeoutMarker(
|
||||
{required this.point, required this.isTarget, required this.showLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, icon) = switch (point.status) {
|
||||
StakeoutStatus.staked => (Colors.green, Icons.check_circle),
|
||||
StakeoutStatus.skipped => (Colors.grey, Icons.block),
|
||||
StakeoutStatus.pending => point.isOffset
|
||||
? (Colors.purple, Icons.change_history)
|
||||
: (Colors.deepOrange, Icons.change_history),
|
||||
};
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: isTarget ? 26 : 16, color: isTarget ? Colors.red : color),
|
||||
if (showLabel)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
point.name,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: isTarget ? FontWeight.w700 : FontWeight.w400,
|
||||
color: isTarget ? Colors.red : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show FontFeature;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:terepi_seged/routes/app_pages.dart';
|
||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
|
||||
/// Kitűzési alsó panel — a map_survey nézet overlay-jébe illesztendő
|
||||
/// (mode == MapSurveyMode.stakeout esetén). Kétfázisú: 5 m felett nagy
|
||||
/// irány-nyíl + távolság, alatta céltábla cm-es eltérésekkel. A tárolás,
|
||||
/// kihagyás és transzverzális eltolás gombjai is itt vannak.
|
||||
class StakeoutPanel extends StatelessWidget {
|
||||
const StakeoutPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Get.isRegistered<StakeoutService>()) return const SizedBox.shrink();
|
||||
final svc = StakeoutService.to;
|
||||
|
||||
return Obx(() {
|
||||
// Üres állapot: import-hívás.
|
||||
if (svc.points.isEmpty) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Nincsenek kitűzési pontok az aktív '
|
||||
'projektben.'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () => Get.toNamed(Routes.STAKEOUT_IMPORT),
|
||||
icon: const Icon(Icons.upload_file, size: 18),
|
||||
label: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final t = svc.target.value;
|
||||
if (t == null) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Minden pont kitűzve vagy kihagyva. '
|
||||
'(${svc.points.length} pont)'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final within = svc.withinTolerance.value;
|
||||
final progress = svc.lineProgress[t.lineId];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
elevation: 6,
|
||||
color: within
|
||||
? Colors.green.shade50
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: within
|
||||
? const BorderSide(color: Colors.green, width: 2)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Fejléc ───────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => svc.advance(backwards: true),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(t.displayId,
|
||||
style: const TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.w700)),
|
||||
if (progress != null)
|
||||
Text(
|
||||
'${progress.staked}/${progress.total} kitűzve'
|
||||
'${t.isOffset ? ' · ELTOLT PONT' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => svc.advance(),
|
||||
),
|
||||
PopupMenuButton<StakeoutOrder>(
|
||||
icon: Icon(switch (svc.orderMode.value) {
|
||||
StakeoutOrder.idAsc => Icons.trending_up,
|
||||
StakeoutOrder.idDesc => Icons.trending_down,
|
||||
StakeoutOrder.nearest => Icons.near_me,
|
||||
}),
|
||||
tooltip: 'Sorrend',
|
||||
onSelected: (m) => svc.orderMode.value = m,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: StakeoutOrder.idAsc,
|
||||
child: Text('Állomás növekvő')),
|
||||
PopupMenuItem(
|
||||
value: StakeoutOrder.idDesc,
|
||||
child: Text('Állomás csökkenő')),
|
||||
PopupMenuItem(
|
||||
value: StakeoutOrder.nearest,
|
||||
child: Text('Legközelebbi')),
|
||||
],
|
||||
),
|
||||
Obx(() => IconButton(
|
||||
icon: Icon(svc.hapticsEnabled.value
|
||||
? Icons.vibration
|
||||
: Icons.phonelink_erase),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Haptikus visszajelzés',
|
||||
onPressed: () => svc.hapticsEnabled.toggle(),
|
||||
)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.upload_file),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Pontok importja',
|
||||
onPressed: () => Get.toNamed(Routes.STAKEOUT_IMPORT),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Fázis-tartalom ───────────────────────────────────
|
||||
if (!svc.hasPosition.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Text('Várakozás GNSS pozícióra…',
|
||||
style: TextStyle(color: Colors.orange)),
|
||||
)
|
||||
else if (svc.nearPhase.value)
|
||||
_NearPhase(svc: svc)
|
||||
else
|
||||
_FarPhase(svc: svc),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// ── Gombsor ──────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(svc.nearPhase.value ? Icons.map : Icons.adjust),
|
||||
tooltip:
|
||||
svc.nearPhase.value ? 'Térkép-nézet' : 'Céltábla-nézet',
|
||||
onPressed: svc.togglePhase,
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: svc.skipCurrent,
|
||||
child: const Text('Kihagy'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _offsetDialog(svc),
|
||||
icon: const Icon(Icons.alt_route, size: 18),
|
||||
label: const Text('Eltolás'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _store(svc),
|
||||
style: within
|
||||
? FilledButton.styleFrom(backgroundColor: Colors.green)
|
||||
: null,
|
||||
icon: const Icon(Icons.push_pin),
|
||||
label: const Text('Tárol'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Tárolás — figyelmeztetés, ha nem RTK fixed (de NEM tiltás)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _store(StakeoutService svc) async {
|
||||
if (svc.target.value == null || !svc.hasPosition.value) return;
|
||||
|
||||
final quality = GnssService.to.gpsQuality.value;
|
||||
if (quality != 4) {
|
||||
final proceed = await Get.dialog<bool>(AlertDialog(
|
||||
icon: const Icon(Icons.warning_amber, color: Colors.orange),
|
||||
title: const Text('Nincs RTK fixed'),
|
||||
content: Text(
|
||||
'A jelenlegi megoldás: ${_fixLabel(quality)}.\n'
|
||||
'A pont tárolható, de a pontosság csökkent lehet — a '
|
||||
'fix-minőség bekerül a jegyzőkönyvbe.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: false),
|
||||
child: const Text('Mégse')),
|
||||
FilledButton(
|
||||
onPressed: () => Get.back(result: true),
|
||||
child: const Text('Tárolás így is')),
|
||||
],
|
||||
));
|
||||
if (proceed != true) return;
|
||||
}
|
||||
|
||||
final stored = await svc.storeCurrent();
|
||||
if (stored == null) return;
|
||||
|
||||
Get.snackbar(
|
||||
'✓ ${stored.displayId} kitűzve',
|
||||
'inline ${_cm(stored.devInline)} · crossline ${_cm(stored.devCrossline)}'
|
||||
'${stored.devDz != null ? ' · dZ ${_cm(stored.devDz)}' : ''}'
|
||||
' · ${_fixLabel(stored.fixQuality ?? 0)}',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
backgroundColor: const Color(0xFF2E7D32),
|
||||
colorText: const Color(0xFFFFFFFF),
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
svc.advance();
|
||||
}
|
||||
|
||||
Future<void> _offsetDialog(StakeoutService svc) async {
|
||||
final distCtrl = TextEditingController(text: '2');
|
||||
final toRight = true.obs;
|
||||
|
||||
await Get.dialog(AlertDialog(
|
||||
title: const Text('Eltolt pont (transzverzális)'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'A vonalra merőlegesen, a tervponttól mért távolságra. '
|
||||
'Az eltolás iránya és nagysága bekerül a jegyzőkönyvbe.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final d in ['0.5', '1', '2', '5'])
|
||||
ActionChip(
|
||||
label: Text('$d m'), onPressed: () => distCtrl.text = d),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: distCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Távolság (m)', isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Obx(() => SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text('Balra'),
|
||||
icon: Icon(Icons.west)),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text('Jobbra'),
|
||||
icon: Icon(Icons.east)),
|
||||
],
|
||||
selected: {toRight.value},
|
||||
onSelectionChanged: (s) => toRight.value = s.first,
|
||||
)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final d = double.tryParse(distCtrl.text.replaceAll(',', '.'));
|
||||
if (d == null || d <= 0) return;
|
||||
Get.back();
|
||||
final p = await svc.createOffset(dist: d, toRight: toRight.value);
|
||||
if (p != null) {
|
||||
Get.snackbar(
|
||||
'Eltolt pont létrehozva',
|
||||
'${p.displayId} · ${d.toStringAsFixed(2)} m '
|
||||
'${toRight.value ? 'jobbra' : 'balra'} — ez az új cél.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
}
|
||||
},
|
||||
child: const Text('Létrehozás'),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
static String _cm(double? v) => v == null
|
||||
? '—'
|
||||
: v.abs() < 1
|
||||
? '${(v * 100).toStringAsFixed(0)} cm'
|
||||
: '${v.toStringAsFixed(2)} m';
|
||||
|
||||
static String _fixLabel(int q) => switch (q) {
|
||||
4 => 'RTK FIXED',
|
||||
5 => 'RTK FLOAT',
|
||||
2 => 'DGPS',
|
||||
1 => 'GPS',
|
||||
0 => 'nincs fix',
|
||||
_ => 'fix: $q',
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Távoli fázis: nagy nyíl + távolság
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class _FarPhase extends StatelessWidget {
|
||||
final StakeoutService svc;
|
||||
const _FarPhase({required this.svc});
|
||||
|
||||
static const _compass = ['É', 'ÉK', 'K', 'DK', 'D', 'DNy', 'Ny', 'ÉNy'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final heading = svc.travelHeading.value;
|
||||
final bearing = svc.bearingToTarget.value;
|
||||
final compass = _compass[((bearing + 22.5) % 360 ~/ 45)];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// A nyíl a HALADÁSI IRÁNYHOZ képest forog; álló helyzetben
|
||||
// iránytű-ikon + égtáj-szöveg.
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: heading != null
|
||||
? Transform.rotate(
|
||||
angle: (bearing - heading) * math.pi / 180,
|
||||
child: const Icon(Icons.navigation,
|
||||
size: 58, color: Colors.blue),
|
||||
)
|
||||
: const Icon(Icons.explore, size: 52, color: Colors.blueGrey),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_fmtDist(svc.distance.value),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
heading != null
|
||||
? '$compass felé (${bearing.toStringAsFixed(0)}°)'
|
||||
: 'Indulj el — a nyíl követi a haladásod · $compass',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
if (svc.devDz.value != null)
|
||||
Text(
|
||||
'dZ: ${svc.devDz.value! >= 0 ? 'fel' : 'le'} '
|
||||
'${_fmtDist(svc.devDz.value!.abs())}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static String _fmtDist(double d) => d >= 1000
|
||||
? '${(d / 1000).toStringAsFixed(2)} km'
|
||||
: d >= 10
|
||||
? '${d.toStringAsFixed(1)} m'
|
||||
: '${d.toStringAsFixed(2)} m';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Közeli fázis: céltábla + cm-es eltérések
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class _NearPhase extends StatelessWidget {
|
||||
final StakeoutService svc;
|
||||
const _NearPhase({required this.svc});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final fwd = svc.devForward.value;
|
||||
final right = svc.devRight.value;
|
||||
final dz = svc.devDz.value;
|
||||
final within = svc.withinTolerance.value;
|
||||
final ne = svc.deviationMode.value == DeviationMode.northEast;
|
||||
|
||||
final (fwdLabel, rightLabel) = switch (svc.deviationMode.value) {
|
||||
DeviationMode.line => ('Inline', 'Crossline'),
|
||||
DeviationMode.travel => ('Előre/hátra', 'Jobbra/balra'),
|
||||
DeviationMode.northEast => ('Észak', 'Kelet'),
|
||||
};
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SegmentedButton<DeviationMode>(
|
||||
segments: [
|
||||
for (final m in DeviationMode.values)
|
||||
ButtonSegment(
|
||||
value: m,
|
||||
label: Text(m.label, style: const TextStyle(fontSize: 10))),
|
||||
],
|
||||
selected: {svc.deviationMode.value},
|
||||
onSelectionChanged: (s) => svc.deviationMode.value = s.first,
|
||||
showSelectedIcon: false,
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
height: 140,
|
||||
child: CustomPaint(
|
||||
painter: _BullseyePainter(
|
||||
devForward: fwd,
|
||||
devRight: right,
|
||||
tolerance: svc.toleranceXY.value,
|
||||
within: within,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DevRow(
|
||||
label: fwdLabel,
|
||||
value: fwd,
|
||||
posText: ne ? 'É' : 'előre',
|
||||
negText: ne ? 'D' : 'hátra'),
|
||||
_DevRow(
|
||||
label: rightLabel,
|
||||
value: right,
|
||||
posText: ne ? 'K' : 'jobbra',
|
||||
negText: ne ? 'Ny' : 'balra'),
|
||||
if (dz != null)
|
||||
_DevRow(
|
||||
label: 'Magasság',
|
||||
value: dz,
|
||||
posText: 'fel',
|
||||
negText: 'le'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
within
|
||||
? '✓ Tűrésen belül '
|
||||
'(${(svc.toleranceXY.value * 100).toStringAsFixed(0)} cm)'
|
||||
: 'Távolság: '
|
||||
'${(svc.distance.value * 100).toStringAsFixed(0)} cm',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: within ? Colors.green : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _DevRow extends StatelessWidget {
|
||||
final String label;
|
||||
final double value;
|
||||
final String posText;
|
||||
final String negText;
|
||||
const _DevRow(
|
||||
{required this.label,
|
||||
required this.value,
|
||||
required this.posText,
|
||||
required this.negText});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cm = value.abs() * 100;
|
||||
final txt = cm < 100
|
||||
? '${cm.toStringAsFixed(0)} cm'
|
||||
: '${value.abs().toStringAsFixed(2)} m';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 78,
|
||||
child: Text(label,
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||
),
|
||||
Text(
|
||||
'${value >= 0 ? posText : negText} $txt',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Céltábla: középen a CÉL, a kék pötty a MI pozíciónk a célhoz képest.
|
||||
/// A "felfelé" tengely az aktuális eltérés-mód "előre" iránya.
|
||||
class _BullseyePainter extends CustomPainter {
|
||||
final double devForward;
|
||||
final double devRight;
|
||||
final double tolerance;
|
||||
final bool within;
|
||||
|
||||
_BullseyePainter({
|
||||
required this.devForward,
|
||||
required this.devRight,
|
||||
required this.tolerance,
|
||||
required this.within,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = Offset(size.width / 2, size.height / 2);
|
||||
final maxR = size.width / 2 - 4;
|
||||
const viewRadiusM = 1.2;
|
||||
final scale = maxR / viewRadiusM;
|
||||
|
||||
final ring = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = Colors.grey.shade400;
|
||||
|
||||
for (final r in [1.0, 0.5]) {
|
||||
canvas.drawCircle(c, r * scale, ring);
|
||||
}
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
tolerance * scale,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = within ? Colors.green : Colors.grey.shade600);
|
||||
|
||||
canvas.drawLine(Offset(c.dx - maxR, c.dy), Offset(c.dx + maxR, c.dy), ring);
|
||||
canvas.drawLine(Offset(c.dx, c.dy - maxR), Offset(c.dx, c.dy + maxR), ring);
|
||||
|
||||
canvas.drawCircle(
|
||||
c, 4, Paint()..color = within ? Colors.green : Colors.red);
|
||||
|
||||
// A MI pozíciónk a célhoz képest: offset = −(eltérés) az
|
||||
// (előre, jobbra) bázisban; képernyő-y felfelé = előre.
|
||||
var px = -devRight * scale;
|
||||
var py = devForward * scale;
|
||||
final d = math.sqrt(px * px + py * py);
|
||||
if (d > maxR) {
|
||||
px = px / d * maxR;
|
||||
py = py / d * maxR;
|
||||
}
|
||||
final me = c + Offset(px, py);
|
||||
canvas.drawCircle(me, 7, Paint()..color = Colors.blue);
|
||||
canvas.drawCircle(
|
||||
me,
|
||||
7,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = Colors.white);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_BullseyePainter old) =>
|
||||
old.devForward != devForward ||
|
||||
old.devRight != devRight ||
|
||||
old.within != within ||
|
||||
old.tolerance != tolerance;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
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/stakeout_point.dart';
|
||||
import 'package:terepi_seged/services/app_database.dart';
|
||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||
import 'package:terepi_seged/services/project_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_import_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
|
||||
/// Kitűzési pontok importja (CSV / GeoJSON) — felismerés + előnézet.
|
||||
///
|
||||
/// Folyamat: fájlválasztás → automatikus elemzés (elválasztó, tizedesjel,
|
||||
/// fejléc, oszlopszerepek, koordináta-rendszer) → ELŐNÉZET: minta-táblázat
|
||||
/// oszloponkénti szerep-választóval + mini-térkép vizuális ellenőrzéshez →
|
||||
/// import az aktív projektbe. Soha nem importálunk vakon.
|
||||
class StakeoutImportView extends StatefulWidget {
|
||||
const StakeoutImportView({super.key});
|
||||
|
||||
@override
|
||||
State<StakeoutImportView> createState() => _StakeoutImportViewState();
|
||||
}
|
||||
|
||||
class _StakeoutImportViewState extends State<StakeoutImportView> {
|
||||
CsvPreview? _preview;
|
||||
List<ColumnRole> _roles = [];
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_busy = true;
|
||||
});
|
||||
try {
|
||||
// FileType.any: a Google Drive (és más felhő-providerek) a tárolt
|
||||
// MIME-típus alapján szűrnek — a .geojson-nak nincs regisztrált
|
||||
// MIME-je, a Drive a csv/txt fájlokat is gyakran más MIME-mal
|
||||
// tartja nyilván, ezért custom szűrővel szürkék maradnának.
|
||||
// A kiterjesztést a kiválasztás UTÁN mi ellenőrizzük.
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.any);
|
||||
final picked = result?.files.single;
|
||||
final path = picked?.path;
|
||||
if (path == null) return;
|
||||
|
||||
final name = picked!.name.toLowerCase();
|
||||
const allowed = ['.csv', '.txt', '.geojson', '.json'];
|
||||
if (!allowed.any(name.endsWith)) {
|
||||
setState(() => _error = 'Nem támogatott fájltípus: ${picked.name} — '
|
||||
'CSV, TXT vagy GeoJSON fájlt válassz.');
|
||||
return;
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final isGeojson = name.endsWith('.json') || name.endsWith('.geojson');
|
||||
final preview = isGeojson
|
||||
? await StakeoutImportService.analyzeGeojson(file)
|
||||
: await StakeoutImportService.analyzeCsv(file);
|
||||
|
||||
setState(() {
|
||||
_preview = preview;
|
||||
_roles = List.of(preview.guessedRoles);
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Az aktuális szerep-kiosztással felépített pontok (a mini-térképhez
|
||||
/// és az importhoz ugyanaz a kód fut — amit látsz, azt kapod).
|
||||
({List<StakeoutPoint> points, int skipped})? _build() {
|
||||
final preview = _preview;
|
||||
final projectId = ProjectService.to.activeProjectId;
|
||||
if (preview == null || projectId == null) return null;
|
||||
if (!Get.isRegistered<CoordConverterService>()) return null;
|
||||
try {
|
||||
return StakeoutImportService.buildPoints(
|
||||
preview: preview,
|
||||
roles: _roles,
|
||||
projectId: projectId,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _import() async {
|
||||
final built = _build();
|
||||
if (built == null || built.points.isEmpty) {
|
||||
Get.snackbar(
|
||||
'Import',
|
||||
'Nincs importálható pont — ellenőrizd az '
|
||||
'oszlop-megfeleltetést.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final inserted =
|
||||
await AppDatabase.instance.insertStakeoutPoints(built.points);
|
||||
final dup = built.points.length - inserted;
|
||||
if (Get.isRegistered<StakeoutService>()) {
|
||||
await StakeoutService.to.load();
|
||||
}
|
||||
Get.back();
|
||||
Get.snackbar(
|
||||
'Import kész',
|
||||
'$inserted pont importálva'
|
||||
'${dup > 0 ? ' · $dup már létező kihagyva' : ''}'
|
||||
'${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
} finally {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = _preview;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Kitűzési pontok importja')),
|
||||
body: _busy && preview == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: preview == null
|
||||
? _EmptyState(onPick: _pickFile, error: _error)
|
||||
: _buildPreview(context, preview),
|
||||
bottomNavigationBar: preview == null
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : _pickFile,
|
||||
child: const Text('Másik fájl'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _import,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.download_done),
|
||||
label: const Text('Import az aktív projektbe'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context, CsvPreview preview) {
|
||||
final built = _build();
|
||||
final crsLabel =
|
||||
_roles.contains(ColumnRole.eovY) && _roles.contains(ColumnRole.eovX)
|
||||
? 'EOV'
|
||||
: _roles.contains(ColumnRole.lat) && _roles.contains(ColumnRole.lon)
|
||||
? 'WGS84'
|
||||
: 'nincs koordináta kijelölve!';
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Összegző chipek ─────────────────────────────────────────
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: const Icon(Icons.description, size: 16),
|
||||
label:
|
||||
Text(preview.fileName, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
Chip(
|
||||
label: Text('${preview.rowCount} sor',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
crsLabel.startsWith('nincs')
|
||||
? Icons.warning_amber
|
||||
: Icons.public,
|
||||
size: 16,
|
||||
color: crsLabel.startsWith('nincs') ? Colors.orange : null,
|
||||
),
|
||||
label: Text('Rendszer: $crsLabel',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
if (built != null)
|
||||
Chip(
|
||||
label: Text(
|
||||
'${built.points.length} érvényes pont'
|
||||
'${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Megfeleltetési táblázat ────────────────────────────────
|
||||
Text('Oszlop-megfeleltetés',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ellenőrizd a felismert szerepeket — az oszlopok fölött '
|
||||
'módosíthatók.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowHeight: 96,
|
||||
columnSpacing: 16,
|
||||
columns: [
|
||||
for (var c = 0; c < preview.headers.length; c++)
|
||||
DataColumn(
|
||||
label: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(preview.headers[c],
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.grey)),
|
||||
DropdownButton<ColumnRole>(
|
||||
value: _roles[c],
|
||||
isDense: true,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: _roles[c] == ColumnRole.ignore
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: _roles[c] == ColumnRole.ignore
|
||||
? Colors.grey
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
items: [
|
||||
for (final r in ColumnRole.values)
|
||||
DropdownMenuItem(value: r, child: Text(r.label)),
|
||||
],
|
||||
onChanged: (r) {
|
||||
if (r == null) return;
|
||||
setState(() {
|
||||
// Egy szerep csak egy oszlopé lehet.
|
||||
if (r != ColumnRole.ignore) {
|
||||
for (var i = 0; i < _roles.length; i++) {
|
||||
if (_roles[i] == r) {
|
||||
_roles[i] = ColumnRole.ignore;
|
||||
}
|
||||
}
|
||||
}
|
||||
_roles[c] = r;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: [
|
||||
for (final row in preview.sampleRows)
|
||||
DataRow(cells: [
|
||||
for (var c = 0; c < preview.headers.length; c++)
|
||||
DataCell(Text(
|
||||
c < row.length ? row[c] : '',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Mini-térkép: vizuális ellenőrzés ───────────────────────
|
||||
if (built != null && built.points.isNotEmpty) ...[
|
||||
Text('Előnézet a térképen',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ha a pontok ott vannak, ahol lenniük kell, a megfeleltetés jó.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _PreviewMap(points: built.points),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewMap extends StatelessWidget {
|
||||
final List<StakeoutPoint> points;
|
||||
const _PreviewMap({required this.points});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Max. 500 markert rajzolunk — előnézetnek bőven elég.
|
||||
final shown = points.length > 500
|
||||
? [
|
||||
for (var i = 0; i < points.length; i += points.length ~/ 500)
|
||||
points[i]
|
||||
]
|
||||
: points;
|
||||
|
||||
final lats = shown.map((p) => p.planLat);
|
||||
final lons = shown.map((p) => p.planLon);
|
||||
final center = LatLng(
|
||||
(lats.reduce((a, b) => a + b)) / shown.length,
|
||||
(lons.reduce((a, b) => a + b)) / shown.length,
|
||||
);
|
||||
|
||||
return FlutterMap(
|
||||
options: MapOptions(initialCenter: center, initialZoom: 13),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'hu.appdev.terepi_seged',
|
||||
),
|
||||
MarkerLayer(markers: [
|
||||
for (final p in shown)
|
||||
Marker(
|
||||
point: LatLng(p.planLat, p.planLon),
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final VoidCallback onPick;
|
||||
final String? error;
|
||||
const _EmptyState({required this.onPick, this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.upload_file, size: 56, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Válassz CSV vagy GeoJSON fájlt.\n'
|
||||
'A program felismeri az elválasztót, a tizedesjelet és az '
|
||||
'oszlopok szerepét (EOV / WGS84), import előtt pedig '
|
||||
'ellenőrizheted az eredményt.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Hiba: $error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onPick,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: const Text('Fájl kiválasztása'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import 'package:terepi_seged/pages/rtcm_test/presentation/views/rtcm_test_view.d
|
||||
import 'package:terepi_seged/pages/settings/presentation/views/settings_view.dart';
|
||||
import 'package:terepi_seged/pages/socket_test/bindings/socket_test_bindings.dart';
|
||||
import 'package:terepi_seged/pages/socket_test/presentation/views/socket_test_view.dart';
|
||||
import 'package:terepi_seged/pages/stakeout_import/presentation/views/stakeout_import_view.dart';
|
||||
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';
|
||||
@@ -101,6 +102,8 @@ class AppPages {
|
||||
name: Routes.TRACKING,
|
||||
binding: TrackingBinding(),
|
||||
page: () => const TrackingView()),
|
||||
GetPage(name: Routes.SETTINGS, page: () => const SettingsView())
|
||||
GetPage(name: Routes.SETTINGS, page: () => const SettingsView()),
|
||||
GetPage(
|
||||
name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView())
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,4 +24,5 @@ abstract class Routes {
|
||||
static const SHELL = '/shell';
|
||||
|
||||
static const SETTINGS = '/settings';
|
||||
static const STAKEOUT_IMPORT = '/stakeout_import';
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ 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/stakeout_point.dart';
|
||||
import 'package:terepi_seged/models/track.dart';
|
||||
import 'package:terepi_seged/services/device_identity_service.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/project.dart';
|
||||
|
||||
@@ -38,7 +40,7 @@ class AppDatabase {
|
||||
final path = p.join(dbDir.path, 'terepi_seged.db');
|
||||
|
||||
return openDatabase(path,
|
||||
version: 2,
|
||||
version: 3,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade);
|
||||
@@ -214,6 +216,8 @@ class AppDatabase {
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
|
||||
|
||||
await _createStakeoutTable(db);
|
||||
|
||||
// Alap projekt létrehozása az első indításhoz
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await db.insert('projects', {
|
||||
@@ -239,6 +243,9 @@ class AppDatabase {
|
||||
ALTER TABLE imported_layers ADD COLUMN stroke_width REAL;
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await _createStakeoutTable(db);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Projects CRUD ─────────────────────────────────────────────────
|
||||
@@ -670,4 +677,114 @@ class AppDatabase {
|
||||
);
|
||||
return rows.map(MeasuredPoint.fromMap).toList();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// KITŰZÉS (stakeout_points) — szeizmikus line/station modell
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _createStakeoutTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS stakeout_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,
|
||||
name TEXT NOT NULL,
|
||||
point_type TEXT NOT NULL DEFAULT 'geofon',
|
||||
source TEXT NOT NULL DEFAULT 'csv',
|
||||
plan_eov_y REAL NOT NULL,
|
||||
plan_eov_x REAL NOT NULL,
|
||||
plan_eov_z REAL,
|
||||
plan_lat REAL NOT NULL,
|
||||
plan_lon REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
measured_eov_y REAL,
|
||||
measured_eov_x REAL,
|
||||
measured_eov_z REAL,
|
||||
measured_lat REAL,
|
||||
measured_lon REAL,
|
||||
dev_inline REAL,
|
||||
dev_crossline REAL,
|
||||
dev_dz REAL,
|
||||
fix_quality INTEGER,
|
||||
accuracy REAL,
|
||||
tilt_deg REAL,
|
||||
staked_at TEXT,
|
||||
is_offset INTEGER NOT NULL DEFAULT 0,
|
||||
parent_uuid TEXT,
|
||||
offset_dist REAL,
|
||||
offset_bearing REAL,
|
||||
created_by TEXT,
|
||||
device_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_proj_line '
|
||||
'ON stakeout_points(project_id, line_id, station)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_status '
|
||||
'ON stakeout_points(status)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_sync '
|
||||
'ON stakeout_points(sync_status)');
|
||||
}
|
||||
|
||||
Future<int> insertStakeoutPoint(StakeoutPoint p) async {
|
||||
final db = await database;
|
||||
final map = p.toMap();
|
||||
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||
return db.insert('stakeout_points', map);
|
||||
}
|
||||
|
||||
/// Tömeges beszúrás importhoz — tranzakcióban; az azonos
|
||||
/// (projekt, vonal, station) sorokat kihagyja. Visszaadja a
|
||||
/// ténylegesen beszúrt darabszámot.
|
||||
Future<int> insertStakeoutPoints(List<StakeoutPoint> points) async {
|
||||
final db = await database;
|
||||
var inserted = 0;
|
||||
await db.transaction((txn) async {
|
||||
for (final p in points) {
|
||||
final dup = await txn.query('stakeout_points',
|
||||
columns: ['id'],
|
||||
where: 'project_id = ? AND line_id = ? AND station = ? '
|
||||
'AND deleted_at IS NULL',
|
||||
whereArgs: [p.projectId, p.lineId, p.station],
|
||||
limit: 1);
|
||||
if (dup.isNotEmpty) continue;
|
||||
final map = p.toMap();
|
||||
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||
await txn.insert('stakeout_points', map);
|
||||
inserted++;
|
||||
}
|
||||
});
|
||||
return inserted;
|
||||
}
|
||||
|
||||
Future<void> updateStakeoutPoint(StakeoutPoint p) async {
|
||||
final db = await database;
|
||||
final map = p.toMap()
|
||||
..['updated_at'] = DateTime.now().toIso8601String()
|
||||
..['sync_status'] = 'pending';
|
||||
await db.update('stakeout_points', map, where: 'id = ?', whereArgs: [p.id]);
|
||||
}
|
||||
|
||||
/// Soft delete — a törlés is szinkronizálható lesz (4. ütem).
|
||||
Future<void> softDeleteStakeoutPoint(int id) async {
|
||||
final db = await database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await db.update('stakeout_points',
|
||||
{'deleted_at': now, 'updated_at': now, 'sync_status': 'pending'},
|
||||
where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<List<StakeoutPoint>> listStakeoutPoints(int projectId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query('stakeout_points',
|
||||
where: 'project_id = ? AND deleted_at IS NULL',
|
||||
whereArgs: [projectId],
|
||||
orderBy: 'line_id ASC, station ASC');
|
||||
return rows.map(StakeoutPoint.fromMap).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||
|
||||
import '../../models/stakeout_point.dart';
|
||||
|
||||
/// Oszlop-szerepek a CSV-megfeleltetéshez.
|
||||
enum ColumnRole {
|
||||
ignore('—'),
|
||||
station('Állomás / pontszám'),
|
||||
line('Vonal'),
|
||||
name('Megnevezés'),
|
||||
eovY('EOV Y (kelet)'),
|
||||
eovX('EOV X (észak)'),
|
||||
lat('Szélesség (lat)'),
|
||||
lon('Hosszúság (lon)'),
|
||||
elevation('Magasság');
|
||||
|
||||
final String label;
|
||||
const ColumnRole(this.label);
|
||||
}
|
||||
|
||||
/// Az elemzés eredménye — ebből épül az előnézeti képernyő.
|
||||
class CsvPreview {
|
||||
final String fileName;
|
||||
final String delimiter;
|
||||
final bool hasHeader;
|
||||
final bool decimalComma;
|
||||
final List<String> headers; // fejléc vagy "1. oszlop" ...
|
||||
final List<List<String>> sampleRows; // első max. 6 adatsor
|
||||
final List<List<String>> allRows; // minden adatsor (nyers)
|
||||
final List<ColumnRole> guessedRoles;
|
||||
final String detectedCrs; // 'EOV' | 'WGS84' | '?'
|
||||
final int badRowCount;
|
||||
|
||||
CsvPreview({
|
||||
required this.fileName,
|
||||
required this.delimiter,
|
||||
required this.hasHeader,
|
||||
required this.decimalComma,
|
||||
required this.headers,
|
||||
required this.sampleRows,
|
||||
required this.allRows,
|
||||
required this.guessedRoles,
|
||||
required this.detectedCrs,
|
||||
required this.badRowCount,
|
||||
});
|
||||
|
||||
int get rowCount => allRows.length;
|
||||
}
|
||||
|
||||
/// CSV / GeoJSON kitűzési pont import — magyar terepi sajátosságokkal:
|
||||
/// * elválasztó-felismerés (';' a magyar Excel alapértelmezése, ',', tab)
|
||||
/// * tizedesVESSZŐ kezelése
|
||||
/// * kódolás: UTF-8, hibánál Latin-1 visszaesés (a koordináták így is
|
||||
/// hibátlanok, legfeljebb az ő/ű torzulhat a nevekben)
|
||||
/// * oszlopszerep-felismerés ÉRTÉKTARTOMÁNY alapján — Magyarországon az
|
||||
/// EOV Y (421–960 ezer), EOV X (48–384 ezer), lat (45,5–48,8) és
|
||||
/// lon (16–23) tartományok páronként diszjunktak, így a felismerés
|
||||
/// nagyon megbízható; a fejlécnevek csak megerősítésként számítanak.
|
||||
class StakeoutImportService {
|
||||
StakeoutImportService._();
|
||||
|
||||
// ── Magyar értéktartományok ──────────────────────────────────────
|
||||
static bool _isEovY(double v) => v >= 421000 && v <= 960000;
|
||||
static bool _isEovX(double v) => v >= 48000 && v <= 384000;
|
||||
static bool _isLat(double v) => v >= 45.5 && v <= 48.8;
|
||||
static bool _isLon(double v) => v >= 16.0 && v <= 23.0;
|
||||
static bool _isElev(double v) => v >= -50 && v <= 3000;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// CSV elemzés
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static Future<CsvPreview> analyzeCsv(File file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
String text;
|
||||
try {
|
||||
text = utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
text = latin1.decode(bytes); // Windows-1250 közelítése
|
||||
}
|
||||
// BOM eltávolítás
|
||||
if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) {
|
||||
text = text.substring(1);
|
||||
}
|
||||
|
||||
final lines = text
|
||||
.split(RegExp(r'\r\n|\r|\n'))
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (lines.isEmpty) {
|
||||
throw const FormatException('A fájl üres.');
|
||||
}
|
||||
|
||||
// 1. Elválasztó: amelyikből a legtöbb van KONZISZTENSEN a sorokban.
|
||||
final delimiter = _detectDelimiter(lines.take(20).toList());
|
||||
|
||||
// 2. Sorok felbontása.
|
||||
var rows = lines.map((l) => _splitLine(l, delimiter)).toList();
|
||||
final colCount = rows.map((r) => r.length).reduce((a, b) => a > b ? a : b);
|
||||
// Rövid sorok kipótlása üres cellákkal, hogy a táblázat téglalap legyen.
|
||||
rows = rows
|
||||
.map((r) => [...r, ...List.filled(colCount - r.length, '')])
|
||||
.toList();
|
||||
|
||||
// 3. Tizedesvessző? Ha ';' az elválasztó és sok "123,45" mintájú cella
|
||||
// van, akkor a vessző tizedesjel.
|
||||
final decimalComma = _detectDecimalComma(rows.take(30).toList());
|
||||
|
||||
// 4. Fejléc: az első sor akkor fejléc, ha a cellái NEM számok, de a
|
||||
// második soréi többségében igen.
|
||||
final hasHeader = rows.length > 1 &&
|
||||
_numericRatio(rows.first, decimalComma) < 0.5 &&
|
||||
_numericRatio(rows[1], decimalComma) >= 0.5;
|
||||
|
||||
final headers = hasHeader
|
||||
? rows.first.map((h) => h.trim()).toList()
|
||||
: List.generate(colCount, (i) => '${i + 1}. oszlop');
|
||||
final dataRows = hasHeader ? rows.sublist(1) : rows;
|
||||
|
||||
// 5. Oszlopszerepek felismerése.
|
||||
final roles = _guessRoles(headers, dataRows, decimalComma);
|
||||
|
||||
final crs =
|
||||
roles.contains(ColumnRole.eovY) && roles.contains(ColumnRole.eovX)
|
||||
? 'EOV'
|
||||
: roles.contains(ColumnRole.lat) && roles.contains(ColumnRole.lon)
|
||||
? 'WGS84'
|
||||
: '?';
|
||||
|
||||
return CsvPreview(
|
||||
fileName: file.uri.pathSegments.last,
|
||||
delimiter: delimiter,
|
||||
hasHeader: hasHeader,
|
||||
decimalComma: decimalComma,
|
||||
headers: headers,
|
||||
sampleRows: dataRows.take(6).toList(),
|
||||
allRows: dataRows,
|
||||
guessedRoles: roles,
|
||||
detectedCrs: crs,
|
||||
badRowCount: 0, // a tényleges építéskor derül ki
|
||||
);
|
||||
}
|
||||
|
||||
static String _detectDelimiter(List<String> lines) {
|
||||
var best = ';';
|
||||
var bestScore = -1;
|
||||
for (final d in [';', ',', '\t']) {
|
||||
final counts = lines.map((l) => d.allMatches(l).length).toList();
|
||||
final min = counts.reduce((a, b) => a < b ? a : b);
|
||||
// Pontszám: minden sorban legyen legalább 1, és konzisztens legyen.
|
||||
final score = min > 0 ? min * 10 - (counts.toSet().length - 1) : -1;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Egyszerű felbontás idézőjel-kezeléssel ("a;b" egyben marad).
|
||||
static List<String> _splitLine(String line, String delimiter) {
|
||||
final cells = <String>[];
|
||||
final sb = StringBuffer();
|
||||
var inQuotes = false;
|
||||
for (var i = 0; i < line.length; i++) {
|
||||
final c = line[i];
|
||||
if (c == '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (c == delimiter && !inQuotes) {
|
||||
cells.add(sb.toString().trim());
|
||||
sb.clear();
|
||||
} else {
|
||||
sb.write(c);
|
||||
}
|
||||
}
|
||||
cells.add(sb.toString().trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
static final _decimalCommaRe = RegExp(r'^-?\d+,\d+$');
|
||||
static final _decimalDotRe = RegExp(r'^-?\d+\.\d+$');
|
||||
|
||||
static bool _detectDecimalComma(List<List<String>> rows) {
|
||||
var comma = 0, dot = 0;
|
||||
for (final row in rows) {
|
||||
for (final cell in row) {
|
||||
if (_decimalCommaRe.hasMatch(cell)) comma++;
|
||||
if (_decimalDotRe.hasMatch(cell)) dot++;
|
||||
}
|
||||
}
|
||||
return comma > dot;
|
||||
}
|
||||
|
||||
/// Cella → szám, a tizedesjel-beállítás figyelembevételével.
|
||||
static double? parseNum(String cell, bool decimalComma) {
|
||||
var s = cell.trim();
|
||||
if (s.isEmpty) return null;
|
||||
if (decimalComma) {
|
||||
s = s.replaceAll(' ', '').replaceAll(',', '.');
|
||||
}
|
||||
return double.tryParse(s);
|
||||
}
|
||||
|
||||
static double _numericRatio(List<String> row, bool decimalComma) {
|
||||
if (row.isEmpty) return 0;
|
||||
final n = row.where((c) => parseNum(c, decimalComma) != null).length;
|
||||
return n / row.length;
|
||||
}
|
||||
|
||||
// ── Szerepfelismerés ─────────────────────────────────────────────
|
||||
|
||||
static List<ColumnRole> _guessRoles(
|
||||
List<String> headers, List<List<String>> rows, bool decimalComma) {
|
||||
final n = headers.length;
|
||||
final sample = rows.take(200).toList();
|
||||
final roles = List<ColumnRole>.filled(n, ColumnRole.ignore);
|
||||
|
||||
// Oszloponkénti statisztika.
|
||||
final stats = List.generate(n, (c) {
|
||||
final values = <double>[];
|
||||
final raw = <String>[];
|
||||
for (final row in sample) {
|
||||
if (c >= row.length || row[c].isEmpty) continue;
|
||||
raw.add(row[c]);
|
||||
final v = parseNum(row[c], decimalComma);
|
||||
if (v != null) values.add(v);
|
||||
}
|
||||
final distinct = raw.toSet().length;
|
||||
final allInt =
|
||||
values.isNotEmpty && values.every((v) => v == v.roundToDouble());
|
||||
double share(bool Function(double) test) =>
|
||||
values.isEmpty ? 0 : values.where(test).length / values.length;
|
||||
return (
|
||||
numericRatio: raw.isEmpty ? 0.0 : values.length / raw.length,
|
||||
values: values,
|
||||
distinct: distinct,
|
||||
count: raw.length,
|
||||
allInt: allInt,
|
||||
eovY: share(_isEovY),
|
||||
eovX: share(_isEovX),
|
||||
lat: share(_isLat),
|
||||
lon: share(_isLon),
|
||||
elev: share(_isElev),
|
||||
);
|
||||
});
|
||||
|
||||
String h(int c) => headers[c].toLowerCase();
|
||||
bool hHas(int c, List<String> keys) => keys.any((k) => h(c).contains(k));
|
||||
|
||||
// 1. Koordináták — tartomány alapján (95% feletti találat kell),
|
||||
// fejlécnév csak döntetlennél számít.
|
||||
int pick(double Function(int) score, List<String> headerKeys) {
|
||||
var best = -1;
|
||||
var bestScore = 0.94;
|
||||
for (var c = 0; c < n; c++) {
|
||||
if (roles[c] != ColumnRole.ignore) continue;
|
||||
var s = score(c);
|
||||
if (s > 0.94 && hHas(c, headerKeys)) s += 0.05;
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
final cEovY = pick((c) => stats[c].eovY, ['y', 'kelet', 'east']);
|
||||
if (cEovY >= 0) roles[cEovY] = ColumnRole.eovY;
|
||||
final cEovX = pick((c) => stats[c].eovX, ['x', 'észak', 'eszak', 'north']);
|
||||
if (cEovX >= 0) roles[cEovX] = ColumnRole.eovX;
|
||||
final cLat = pick((c) => stats[c].lat, ['lat', 'fi', 'szél', 'szel']);
|
||||
if (cLat >= 0) roles[cLat] = ColumnRole.lat;
|
||||
final cLon = pick((c) => stats[c].lon, ['lon', 'lambda', 'hossz']);
|
||||
if (cLon >= 0) roles[cLon] = ColumnRole.lon;
|
||||
|
||||
// 2. Magasság: numerikus, elfogadható tartomány, NEM egész-azonosító
|
||||
// jellegű; fejléc segít.
|
||||
for (var c = 0; c < n; c++) {
|
||||
if (roles[c] != ColumnRole.ignore) continue;
|
||||
final s = stats[c];
|
||||
if (s.numericRatio > 0.9 &&
|
||||
s.elev > 0.94 &&
|
||||
(hHas(c, ['z', 'mag', 'elev', 'h']) || !s.allInt)) {
|
||||
roles[c] = ColumnRole.elevation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Állomás/pontszám: egész, (közel) egyedi értékek.
|
||||
var bestStation = -1;
|
||||
var bestUnique = 0.9;
|
||||
for (var c = 0; c < n; c++) {
|
||||
if (roles[c] != ColumnRole.ignore) continue;
|
||||
final s = stats[c];
|
||||
if (s.numericRatio > 0.95 && s.allInt && s.count > 0) {
|
||||
var unique = s.distinct / s.count;
|
||||
if (hHas(c, ['psz', 'pont', 'station', 'áll', 'all', 'id'])) {
|
||||
unique += 0.05;
|
||||
}
|
||||
if (unique > bestUnique) {
|
||||
bestUnique = unique;
|
||||
bestStation = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestStation >= 0) roles[bestStation] = ColumnRole.station;
|
||||
|
||||
// 4. Vonal: kevés egyedi értékű oszlop (a pontszám sokszorosa tartozik
|
||||
// egy vonalhoz); fejléc segít.
|
||||
for (var c = 0; c < n; c++) {
|
||||
if (roles[c] != ColumnRole.ignore) continue;
|
||||
final s = stats[c];
|
||||
final lowCardinality =
|
||||
s.count >= 10 && s.distinct <= (s.count / 4).ceil();
|
||||
if (hHas(c, ['line', 'vonal', 'ln']) || lowCardinality) {
|
||||
roles[c] = ColumnRole.line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Név: az első még szabad, többségében szöveges oszlop.
|
||||
for (var c = 0; c < n; c++) {
|
||||
if (roles[c] != ColumnRole.ignore) continue;
|
||||
if (stats[c].numericRatio < 0.5 && stats[c].count > 0) {
|
||||
roles[c] = ColumnRole.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Pontok építése a megfeleltetés alapján
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A (kézzel jóváhagyott) szerep-kiosztás alapján felépíti a pontokat.
|
||||
/// Visszaadja a pontokat és a kihagyott (hibás) sorok számát.
|
||||
static ({List<StakeoutPoint> points, int skipped}) buildPoints({
|
||||
required CsvPreview preview,
|
||||
required List<ColumnRole> roles,
|
||||
required int projectId,
|
||||
}) {
|
||||
int col(ColumnRole r) => roles.indexOf(r);
|
||||
final cStation = col(ColumnRole.station);
|
||||
final cLine = col(ColumnRole.line);
|
||||
final cName = col(ColumnRole.name);
|
||||
final cEovY = col(ColumnRole.eovY);
|
||||
final cEovX = col(ColumnRole.eovX);
|
||||
final cLat = col(ColumnRole.lat);
|
||||
final cLon = col(ColumnRole.lon);
|
||||
final cElev = col(ColumnRole.elevation);
|
||||
|
||||
final isEov = cEovY >= 0 && cEovX >= 0;
|
||||
if (!isEov && !(cLat >= 0 && cLon >= 0)) {
|
||||
throw const FormatException(
|
||||
'Hiányzó koordináta-oszlopok: EOV Y+X vagy lat+lon kell.');
|
||||
}
|
||||
|
||||
final conv = CoordConverterService.to;
|
||||
final points = <StakeoutPoint>[];
|
||||
var skipped = 0;
|
||||
var autoStation = 1;
|
||||
|
||||
for (final row in preview.allRows) {
|
||||
double? get(int c) => c >= 0 && c < row.length
|
||||
? parseNum(row[c], preview.decimalComma)
|
||||
: null;
|
||||
|
||||
double eovY, eovX, lat, lon;
|
||||
if (isEov) {
|
||||
final y = get(cEovY), x = get(cEovX);
|
||||
if (y == null || x == null || !_isEovY(y) || !_isEovX(x)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
eovY = y;
|
||||
eovX = x;
|
||||
final p = conv.eovToWgsPoint(y, x);
|
||||
lon = p.x;
|
||||
lat = p.y;
|
||||
} else {
|
||||
final la = get(cLat), lo = get(cLon);
|
||||
if (la == null || lo == null || !_isLat(la) || !_isLon(lo)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
lat = la;
|
||||
lon = lo;
|
||||
final p = conv.wgsToEovPoint(lo, la);
|
||||
eovY = p.x;
|
||||
eovX = p.y;
|
||||
}
|
||||
|
||||
final stationVal = get(cStation);
|
||||
final station = stationVal?.round() ?? autoStation++;
|
||||
final line = cLine >= 0 && cLine < row.length ? row[cLine].trim() : '';
|
||||
final name = cName >= 0 && cName < row.length && row[cName].isNotEmpty
|
||||
? row[cName].trim()
|
||||
: station.toString();
|
||||
|
||||
points.add(StakeoutPoint(
|
||||
projectId: projectId,
|
||||
lineId: line,
|
||||
station: station,
|
||||
name: name,
|
||||
source: 'csv',
|
||||
planEovY: eovY,
|
||||
planEovX: eovX,
|
||||
planEovZ: get(cElev),
|
||||
planLat: lat,
|
||||
planLon: lon,
|
||||
));
|
||||
}
|
||||
return (points: points, skipped: skipped);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// GeoJSON
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// GeoJSON → CsvPreview-kompatibilis táblázat: a property-k az oszlopok,
|
||||
/// plusz a kinyert lon/lat. Így ugyanaz az előnézeti/megfeleltetési
|
||||
/// képernyő szolgálja ki, mint a CSV-t.
|
||||
static Future<CsvPreview> analyzeGeojson(File file) async {
|
||||
final json = jsonDecode(await file.readAsString());
|
||||
final features = (json['features'] as List?) ?? [];
|
||||
|
||||
final propKeys = <String>{};
|
||||
final rows = <List<String>>[];
|
||||
for (final f in features) {
|
||||
final geom = f['geometry'];
|
||||
if (geom == null || geom['type'] != 'Point') continue;
|
||||
propKeys.addAll(
|
||||
((f['properties'] as Map?) ?? {}).keys.map((k) => k.toString()));
|
||||
}
|
||||
final keys = propKeys.toList();
|
||||
|
||||
for (final f in features) {
|
||||
final geom = f['geometry'];
|
||||
if (geom == null || geom['type'] != 'Point') continue;
|
||||
final coords = geom['coordinates'] as List;
|
||||
final props = (f['properties'] as Map?) ?? {};
|
||||
rows.add([
|
||||
for (final k in keys) '${props[k] ?? ''}',
|
||||
'${coords[0]}', // lon
|
||||
'${coords[1]}', // lat
|
||||
if (coords.length > 2) '${coords[2]}' else '',
|
||||
]);
|
||||
}
|
||||
if (rows.isEmpty) {
|
||||
throw const FormatException('A GeoJSON nem tartalmaz Point elemet.');
|
||||
}
|
||||
|
||||
final headers = [...keys, 'lon', 'lat', 'z'];
|
||||
final roles = _guessRoles(headers, rows, false);
|
||||
// A GeoJSON szabvány szerint mindig WGS84 — a koordináta-oszlopokat
|
||||
// ismerjük, kényszerítjük.
|
||||
roles[headers.length - 3] = ColumnRole.lon;
|
||||
roles[headers.length - 2] = ColumnRole.lat;
|
||||
if (rows.any((r) => r.last.isNotEmpty)) {
|
||||
roles[headers.length - 1] = ColumnRole.elevation;
|
||||
}
|
||||
|
||||
return CsvPreview(
|
||||
fileName: file.uri.pathSegments.last,
|
||||
delimiter: ',',
|
||||
hasHeader: true,
|
||||
decimalComma: false,
|
||||
headers: headers,
|
||||
sampleRows: rows.take(6).toList(),
|
||||
allRows: rows,
|
||||
guessedRoles: roles,
|
||||
detectedCrs: 'WGS84',
|
||||
badRowCount: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.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_service.dart';
|
||||
import 'package:terepi_seged/services/project_service.dart';
|
||||
|
||||
import '../../models/stakeout_point.dart';
|
||||
|
||||
/// Kitűzési sorrend.
|
||||
enum StakeoutOrder { idAsc, idDesc, nearest }
|
||||
|
||||
/// Eltérés-kijelzési mód a közeli fázisban.
|
||||
enum DeviationMode {
|
||||
line('Inline / crossline'),
|
||||
travel('Haladási irány'),
|
||||
northEast('Észak / kelet');
|
||||
|
||||
final String label;
|
||||
const DeviationMode(this.label);
|
||||
}
|
||||
|
||||
/// A kitűzés "agya": célpont-kezelés, sorrend, vonal-geometria,
|
||||
/// eltérés-számítás, tárolás, haptikus visszajelzés.
|
||||
///
|
||||
/// Az egyetlen térképnézetbe illeszkedik: a MapSurveyController a
|
||||
/// setMode()-ban hívja a [setActive]-ot (mode == MapSurveyMode.stakeout),
|
||||
/// a service pedig csak aktív állapotban dolgozik (haptika, fázis).
|
||||
/// Minden adatbázisművelet az AppDatabase-en keresztül megy.
|
||||
///
|
||||
/// Geometria: minden számítás EOV-SÍKBAN, méterben. A crossline irány a
|
||||
/// szomszédos állomások szakaszaiból SZÁMÍTÓDIK — a vonal első pontjánál
|
||||
/// az első szakasz hátrafelé, az utolsónál az utolsó szakasz előre
|
||||
/// extrapolálásával, belső töréspontnál a két irány átlagával; virtuális
|
||||
/// segédpontot nem tárolunk.
|
||||
class StakeoutService extends GetxService {
|
||||
static StakeoutService get to => Get.find();
|
||||
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
|
||||
// ── Mód-aktiválás ────────────────────────────────────────────────
|
||||
final active = false.obs;
|
||||
|
||||
/// A MapSurveyController hívja módváltáskor.
|
||||
Future<void> setActive(bool value) async {
|
||||
if (active.value == value) return;
|
||||
active.value = value;
|
||||
if (value) {
|
||||
await load();
|
||||
_applyPhase();
|
||||
_scheduleHaptic();
|
||||
} else {
|
||||
_hapticTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Állapot ──────────────────────────────────────────────────────
|
||||
final points = <StakeoutPoint>[].obs;
|
||||
final target = Rxn<StakeoutPoint>();
|
||||
final orderMode = StakeoutOrder.idAsc.obs;
|
||||
final deviationMode = DeviationMode.line.obs;
|
||||
|
||||
/// Tűrés (m) — tárolásnál és a céltábla belső körénél.
|
||||
final toleranceXY = 0.03.obs;
|
||||
|
||||
/// Közeli fázis (céltábla-nézet); automatikus 5 m alatt, kézzel
|
||||
/// felülbírálható.
|
||||
final nearPhase = false.obs;
|
||||
static const nearPhaseDistance = 5.0;
|
||||
bool? _manualPhase;
|
||||
|
||||
final hapticsEnabled = true.obs;
|
||||
|
||||
// ── Aktuális pozíció (EOV) és navigációs értékek ────────────────
|
||||
final hasPosition = false.obs;
|
||||
final curEovY = 0.0.obs;
|
||||
final curEovX = 0.0.obs;
|
||||
final curAlt = 0.0.obs;
|
||||
|
||||
final distance = 0.0.obs;
|
||||
final bearingToTarget = 0.0.obs; // fok, EOV-észak = 0, óramutató
|
||||
final travelHeading = Rxn<double>(); // null = állunk
|
||||
|
||||
/// Eltérés az aktuális [deviationMode] szerint: előre(+)/hátra(−),
|
||||
/// jobbra(+)/balra(−); dz: fel(+)/le(−).
|
||||
final devForward = 0.0.obs;
|
||||
final devRight = 0.0.obs;
|
||||
final devDz = Rxn<double>();
|
||||
final withinTolerance = false.obs;
|
||||
|
||||
double? _histY, _histX;
|
||||
Timer? _hapticTimer;
|
||||
bool _toleranceAnnounced = false;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
if (Get.isRegistered<GnssService>()) {
|
||||
ever(GnssService.to.lastGgaLine, (_) => _onPosition());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_hapticTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Betöltés / cél-kezelés
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> load() async {
|
||||
final projectId = ProjectService.to.activeProjectId;
|
||||
if (projectId == null) {
|
||||
points.clear();
|
||||
target.value = null;
|
||||
return;
|
||||
}
|
||||
points.value = await _db.listStakeoutPoints(projectId);
|
||||
if (target.value == null ||
|
||||
!points.any((p) => p.uuid == target.value!.uuid)) {
|
||||
target.value = _firstPending();
|
||||
}
|
||||
_recompute();
|
||||
}
|
||||
|
||||
List<String> get lines =>
|
||||
points.map((p) => p.lineId).toSet().toList()..sort();
|
||||
|
||||
Map<String, ({int total, int staked})> get lineProgress {
|
||||
final m = <String, ({int total, int staked})>{};
|
||||
for (final p in points) {
|
||||
final cur = m[p.lineId] ?? (total: 0, staked: 0);
|
||||
m[p.lineId] = (
|
||||
total: cur.total + 1,
|
||||
staked: cur.staked + (p.status == StakeoutStatus.staked ? 1 : 0),
|
||||
);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
void setTarget(StakeoutPoint p) {
|
||||
target.value = p;
|
||||
_manualPhase = null; // új célnál vissza automatikus fázisra
|
||||
_toleranceAnnounced = false;
|
||||
_recompute();
|
||||
}
|
||||
|
||||
void togglePhase() {
|
||||
_manualPhase = !nearPhase.value;
|
||||
_applyPhase();
|
||||
}
|
||||
|
||||
StakeoutPoint? _firstPending() {
|
||||
final pending =
|
||||
points.where((p) => p.status == StakeoutStatus.pending).toList();
|
||||
if (pending.isEmpty) return null;
|
||||
pending.sort((a, b) => a.lineId != b.lineId
|
||||
? a.lineId.compareTo(b.lineId)
|
||||
: a.station.compareTo(b.station));
|
||||
return orderMode.value == StakeoutOrder.idDesc
|
||||
? pending.last
|
||||
: pending.first;
|
||||
}
|
||||
|
||||
/// Következő cél a sorrend-mód szerint — a CÉL VONALÁN belül lép,
|
||||
/// elfogyva a következő vonalra.
|
||||
StakeoutPoint? nextTarget({bool backwards = false}) {
|
||||
final cur = target.value;
|
||||
if (cur == null) return _firstPending();
|
||||
final pending = points
|
||||
.where((p) => p.status == StakeoutStatus.pending && p.uuid != cur.uuid)
|
||||
.toList();
|
||||
if (pending.isEmpty) return null;
|
||||
|
||||
final sameLine = pending.where((p) => p.lineId == cur.lineId).toList()
|
||||
..sort((a, b) => a.station.compareTo(b.station));
|
||||
|
||||
switch (orderMode.value) {
|
||||
case StakeoutOrder.nearest:
|
||||
final pool = sameLine.isNotEmpty ? sameLine : pending;
|
||||
pool.sort((a, b) => _distTo(a).compareTo(_distTo(b)));
|
||||
return pool.first;
|
||||
|
||||
case StakeoutOrder.idAsc:
|
||||
case StakeoutOrder.idDesc:
|
||||
final asc = (orderMode.value == StakeoutOrder.idAsc) != backwards;
|
||||
final candidates = sameLine.where(
|
||||
(p) => asc ? p.station > cur.station : p.station < cur.station);
|
||||
if (candidates.isNotEmpty) {
|
||||
return asc
|
||||
? candidates.reduce((a, b) => a.station < b.station ? a : b)
|
||||
: candidates.reduce((a, b) => a.station > b.station ? a : b);
|
||||
}
|
||||
final others = pending.where((p) => p.lineId != cur.lineId).toList();
|
||||
if (others.isEmpty) return null;
|
||||
others.sort((a, b) => a.lineId != b.lineId
|
||||
? a.lineId.compareTo(b.lineId)
|
||||
: a.station.compareTo(b.station));
|
||||
return asc ? others.first : others.last;
|
||||
}
|
||||
}
|
||||
|
||||
void advance({bool backwards = false}) {
|
||||
final n = nextTarget(backwards: backwards);
|
||||
if (n != null) setTarget(n);
|
||||
}
|
||||
|
||||
double _distTo(StakeoutPoint p) {
|
||||
final dy = p.planEovY - curEovY.value;
|
||||
final dx = p.planEovX - curEovX.value;
|
||||
return math.sqrt(dy * dy + dx * dx);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Pozíció + eltéré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);
|
||||
curEovY.value = p.x;
|
||||
curEovX.value = p.y;
|
||||
curAlt.value = gnss.altitude.value;
|
||||
hasPosition.value = true;
|
||||
|
||||
// Haladási irány: legalább 0,5 m elmozdulásból (állva zajos lenne).
|
||||
if (_histY != null) {
|
||||
final dy = curEovY.value - _histY!;
|
||||
final dx = curEovX.value - _histX!;
|
||||
if (math.sqrt(dy * dy + dx * dx) >= 0.5) {
|
||||
travelHeading.value = _bearingDeg(dy, dx);
|
||||
_histY = curEovY.value;
|
||||
_histX = curEovX.value;
|
||||
}
|
||||
} else {
|
||||
_histY = curEovY.value;
|
||||
_histX = curEovX.value;
|
||||
}
|
||||
|
||||
_recompute();
|
||||
}
|
||||
|
||||
void _recompute() {
|
||||
final t = target.value;
|
||||
if (t == null || !hasPosition.value) {
|
||||
withinTolerance.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final dy = t.planEovY - curEovY.value;
|
||||
final dx = t.planEovX - curEovX.value;
|
||||
distance.value = math.sqrt(dy * dy + dx * dx);
|
||||
bearingToTarget.value = _bearingDeg(dy, dx);
|
||||
|
||||
final double fwdBearing;
|
||||
switch (deviationMode.value) {
|
||||
case DeviationMode.line:
|
||||
fwdBearing = lineBearingAt(t) ?? travelHeading.value ?? 0;
|
||||
case DeviationMode.travel:
|
||||
fwdBearing = travelHeading.value ?? 0;
|
||||
case DeviationMode.northEast:
|
||||
fwdBearing = 0;
|
||||
}
|
||||
|
||||
final rad = fwdBearing * math.pi / 180;
|
||||
devForward.value = dy * math.sin(rad) + dx * math.cos(rad);
|
||||
devRight.value = dy * math.cos(rad) - dx * math.sin(rad);
|
||||
devDz.value = t.planEovZ != null ? t.planEovZ! - curAlt.value : null;
|
||||
|
||||
final wasWithin = withinTolerance.value;
|
||||
withinTolerance.value = distance.value <= toleranceXY.value;
|
||||
|
||||
if (active.value) {
|
||||
_applyPhase();
|
||||
if (withinTolerance.value && !wasWithin && !_toleranceAnnounced) {
|
||||
_toleranceAnnounced = true;
|
||||
if (hapticsEnabled.value) HapticFeedback.heavyImpact();
|
||||
SystemSound.play(SystemSoundType.alert);
|
||||
} else if (!withinTolerance.value) {
|
||||
_toleranceAnnounced = false;
|
||||
}
|
||||
_scheduleHaptic();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyPhase() {
|
||||
nearPhase.value = _manualPhase ??
|
||||
(hasPosition.value &&
|
||||
target.value != null &&
|
||||
distance.value <= nearPhaseDistance);
|
||||
}
|
||||
|
||||
/// Haptikus "geiger": közeledve sűrűsödő pulzus, tűrésen belül gyors.
|
||||
void _scheduleHaptic() {
|
||||
_hapticTimer?.cancel();
|
||||
if (!active.value ||
|
||||
!hapticsEnabled.value ||
|
||||
!hasPosition.value ||
|
||||
target.value == null) {
|
||||
return;
|
||||
}
|
||||
final d = distance.value;
|
||||
if (d > 30) return;
|
||||
|
||||
final ms = withinTolerance.value
|
||||
? 150
|
||||
: d <= 1
|
||||
? 250
|
||||
: d <= 3
|
||||
? 450
|
||||
: d <= 10
|
||||
? 800
|
||||
: 1500;
|
||||
|
||||
_hapticTimer = Timer(Duration(milliseconds: ms), () {
|
||||
if (withinTolerance.value) {
|
||||
HapticFeedback.mediumImpact();
|
||||
} else {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
_scheduleHaptic();
|
||||
});
|
||||
}
|
||||
|
||||
static double _bearingDeg(double dy, double dx) {
|
||||
final b = math.atan2(dy, dx) * 180 / math.pi;
|
||||
return (b + 360) % 360;
|
||||
}
|
||||
|
||||
// ── Vonal-geometria ──────────────────────────────────────────────
|
||||
|
||||
List<StakeoutPoint> _linePoints(String lineId) {
|
||||
final lp = points.where((p) => p.lineId == lineId && !p.isOffset).toList()
|
||||
..sort((a, b) => a.station.compareTo(b.station));
|
||||
return lp;
|
||||
}
|
||||
|
||||
/// A vonal iránya (fok) az adott pontnál, a station-növekedés felé.
|
||||
double? lineBearingAt(StakeoutPoint p) {
|
||||
final lp = _linePoints(p.lineId);
|
||||
if (lp.length < 2) return null;
|
||||
var idx = lp.indexWhere((e) => e.uuid == p.uuid);
|
||||
if (idx < 0) {
|
||||
// Eltolt pont: a legközelebbi vonalpont szerint.
|
||||
var bestD = double.infinity;
|
||||
for (var k = 0; k < lp.length; k++) {
|
||||
final d = math.pow(lp[k].planEovY - p.planEovY, 2) +
|
||||
math.pow(lp[k].planEovX - p.planEovX, 2);
|
||||
if (d < bestD) {
|
||||
bestD = d.toDouble();
|
||||
idx = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double segBearing(StakeoutPoint a, StakeoutPoint b) =>
|
||||
_bearingDeg(b.planEovY - a.planEovY, b.planEovX - a.planEovX);
|
||||
|
||||
if (idx == 0) return segBearing(lp[0], lp[1]);
|
||||
if (idx == lp.length - 1) return segBearing(lp[idx - 1], lp[idx]);
|
||||
final b1 = segBearing(lp[idx - 1], lp[idx]) * math.pi / 180;
|
||||
final b2 = segBearing(lp[idx], lp[idx + 1]) * math.pi / 180;
|
||||
return _bearingDeg(
|
||||
math.sin(b1) + math.sin(b2), math.cos(b1) + math.cos(b2));
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Műveletek (adatbázis: AppDatabase)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Tárolás a jelenlegi mért pozícióval. A rekordba az eltérés mindig
|
||||
/// vonal-relatívan (inline/crossline) kerül; vonal híján É/K bontásban.
|
||||
Future<StakeoutPoint?> storeCurrent() async {
|
||||
final t = target.value;
|
||||
if (t == null || !hasPosition.value) return null;
|
||||
final gnss = GnssService.to;
|
||||
|
||||
final dy = t.planEovY - curEovY.value;
|
||||
final dx = t.planEovX - curEovX.value;
|
||||
final lineBearing = lineBearingAt(t);
|
||||
final rad = (lineBearing ?? 0) * math.pi / 180;
|
||||
final inline =
|
||||
lineBearing != null ? dy * math.sin(rad) + dx * math.cos(rad) : dx;
|
||||
final crossline =
|
||||
lineBearing != null ? dy * math.cos(rad) - dx * math.sin(rad) : dy;
|
||||
|
||||
final updated = t.copyWith(
|
||||
status: StakeoutStatus.staked,
|
||||
measuredEovY: curEovY.value,
|
||||
measuredEovX: curEovX.value,
|
||||
measuredEovZ: curAlt.value,
|
||||
measuredLat: gnss.latitude.value,
|
||||
measuredLon: gnss.longitude.value,
|
||||
devInline: inline,
|
||||
devCrossline: crossline,
|
||||
devDz: t.planEovZ != null ? t.planEovZ! - curAlt.value : null,
|
||||
fixQuality: gnss.gpsQuality.value,
|
||||
accuracy: gnss.horizontalAccuracy,
|
||||
stakedAt: DateTime.now(),
|
||||
);
|
||||
await _db.updateStakeoutPoint(updated);
|
||||
final i = points.indexWhere((p) => p.uuid == t.uuid);
|
||||
if (i >= 0) points[i] = updated;
|
||||
points.refresh();
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<void> skipCurrent() async {
|
||||
final t = target.value;
|
||||
if (t == null) return;
|
||||
final updated = t.copyWith(status: StakeoutStatus.skipped);
|
||||
await _db.updateStakeoutPoint(updated);
|
||||
final i = points.indexWhere((p) => p.uuid == t.uuid);
|
||||
if (i >= 0) points[i] = updated;
|
||||
points.refresh();
|
||||
advance();
|
||||
}
|
||||
|
||||
/// Transzverzális eltolt pont: a vonalra merőlegesen [dist] méterre
|
||||
/// ([toRight] = jobbra a station-növekedés irányából nézve). Az új
|
||||
/// pont lesz a cél; az eltolás-vektor a rekordba kerül.
|
||||
Future<StakeoutPoint?> createOffset(
|
||||
{required double dist, required bool toRight}) async {
|
||||
final t = target.value;
|
||||
if (t == null) return null;
|
||||
|
||||
final base = lineBearingAt(t) ?? travelHeading.value ?? 0;
|
||||
final bearing = (base + (toRight ? 90 : -90) + 360) % 360;
|
||||
final rad = bearing * math.pi / 180;
|
||||
final eovY = t.planEovY + dist * math.sin(rad);
|
||||
final eovX = t.planEovX + dist * math.cos(rad);
|
||||
final w = CoordConverterService.to.eovToWgsPoint(eovY, eovX);
|
||||
|
||||
final offset = StakeoutPoint(
|
||||
projectId: t.projectId,
|
||||
lineId: t.lineId,
|
||||
station: t.station,
|
||||
name: '${t.name}/E',
|
||||
pointType: t.pointType,
|
||||
source: 'offset',
|
||||
planEovY: eovY,
|
||||
planEovX: eovX,
|
||||
planEovZ: t.planEovZ,
|
||||
planLat: w.y,
|
||||
planLon: w.x,
|
||||
isOffset: true,
|
||||
parentUuid: t.uuid,
|
||||
offsetDist: dist,
|
||||
offsetBearing: bearing,
|
||||
);
|
||||
final id = await _db.insertStakeoutPoint(offset);
|
||||
final saved = offset.copyWith(id: id);
|
||||
points.add(saved);
|
||||
setTarget(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user