Kitűzés: pontok importja, szervízek, kitüző panel

This commit is contained in:
2026-07-06 11:47:41 +02:00
parent c01fdcf012
commit 6706b2b1ba
12 changed files with 2482 additions and 4 deletions
@@ -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'),
),
],
),
),
);
}
}