import 'dart:math' as math; import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; //import 'package:flutter/widget_previews.dart'; import 'package:get/get.dart'; import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart'; import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart'; import 'package:terepi_seged/pages/map_survey/presentations/widgets/tilt_bubble.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, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), onPressed: () => svc.advance(backwards: true), ), // A cím rugalmas: szűk helyen ZSUGORODIK (FittedBox), // sosem tördelődik karakterenként. Expanded( child: Column( children: [ FittedBox( fit: BoxFit.scaleDown, child: Text( t.displayId, maxLines: 1, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700), ), ), if (progress != null) Text( '${progress.staked}/${progress.total} kitűzve' '${t.isOffset ? ' · ELTOLT' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, color: Colors.grey.shade600), ), ], ), ), IconButton( icon: const Icon(Icons.skip_next), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), onPressed: () => svc.advance(), ), const TiltBubble(), PopupMenuButton( icon: Icon(switch (svc.orderMode.value) { StakeoutOrder.idAsc => Icons.trending_up, StakeoutOrder.idDesc => Icons.trending_down, StakeoutOrder.nearest => Icons.near_me, }), padding: EdgeInsets.zero, 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')), ], ), // A ritkán használt műveletek EGY menüben — a fejléc // így keskeny kijelzőn / nagy betűméretnél is kifér. PopupMenuButton( icon: const Icon(Icons.more_vert), padding: EdgeInsets.zero, tooltip: 'További műveletek', onSelected: (a) { switch (a) { case 'list': StakeoutListSheet.show(); case 'settings': showStakeoutSettingsDialog(); case 'import': Get.toNamed(Routes.STAKEOUT_IMPORT); case 'haptics': svc.hapticsEnabled.toggle(); } }, itemBuilder: (_) => [ const PopupMenuItem( value: 'list', child: Text('Pontlista / export')), const PopupMenuItem( value: 'settings', child: Text('Kitűzési beállítások')), const PopupMenuItem( value: 'import', child: Text('Pontok importja')), CheckedPopupMenuItem( value: 'haptics', checked: svc.hapticsEnabled.value, child: const Text('Haptikus visszajelzés')), ], ), ], ), // ── 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), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), tooltip: svc.nearPhase.value ? 'Térkép-nézet' : 'Céltábla-nézet', onPressed: svc.togglePhase, ), OutlinedButton( onPressed: svc.skipCurrent, style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text('Kihagy'), ), const SizedBox(width: 6), OutlinedButton.icon( onPressed: () => _offsetDialog(svc), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), icon: const Icon(Icons.alt_route, size: 18), label: const Text('Eltolás'), ), const SizedBox(width: 6), // Expanded: a Tárol tölti ki a maradék helyet — // fix-szélesség-összeg helyett, így nincs overflow. Expanded( child: FilledButton.icon( onPressed: svc.isAveraging.value ? null : () => _store(svc), style: FilledButton.styleFrom( backgroundColor: within ? Colors.green : null, visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), ), icon: svc.isAveraging.value ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.push_pin, size: 18), label: FittedBox( fit: BoxFit.scaleDown, child: Text(svc.isAveraging.value ? 'Átlagolás ${svc.averagingElapsed.value}/' '${svc.averagingSeconds.value} s' : '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; final both = false.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, )), Obx(() => CheckboxListTile( contentPadding: EdgeInsets.zero, dense: true, title: const Text('Pár létrehozása (bal ÉS jobb)'), subtitle: const Text('az akadály két oldalára', style: TextStyle(fontSize: 11)), value: both.value, onChanged: (v) => both.value = v ?? false, )) ], ), 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; // Megrendelői offset-korlát ellenőrzése (beállításokból). if (svc.maxOffset.value > 0 && d > svc.maxOffset.value) { final ok = await Get.dialog(AlertDialog( icon: const Icon(Icons.warning_amber, color: Colors.orange), title: const Text('Offset-korlát túllépése'), content: Text('A megadott ${d.toStringAsFixed(1)} m nagyobb ' 'a beállított max. ${svc.maxOffset.value.toStringAsFixed(1)} m ' 'crossline eltolásnál.'), actions: [ TextButton( onPressed: () => Get.back(result: false), child: const Text('Mégse')), FilledButton( onPressed: () => Get.back(result: true), child: const Text('Mégis')), ], )); if (ok != true) return; } Get.back(); if (both.value) { // Előbb az ellenoldali, aztán a választott — így a // választott oldal lesz az aktív cél. await svc.createOffset(dist: d, toRight: !toRight.value); await svc.createOffset(dist: d, toRight: toRight.value); Get.snackbar('Eltolt pontpár létrehozva', '±${d.toStringAsFixed(2)} m a vonalra merőlegesen.', snackPosition: SnackPosition.BOTTOM); } else { 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}); //@Preview(name: 'Example') @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; }