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()) 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( 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 _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(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 _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( 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( 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; }