Files
MobilApp/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart
T

724 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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,
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<StakeoutOrder>(
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<String>(
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<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;
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<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,
)),
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<bool>(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<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});
//@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;
}