Libella megjelnítése mérőrúdhoz, transzverzális rajzolása az aktuális kitűzendő ponthoz
This commit is contained in:
@@ -23,6 +23,7 @@ import 'package:terepi_seged/services/note_photo_service.dart';
|
|||||||
import 'package:terepi_seged/services/ntrip_service.dart';
|
import 'package:terepi_seged/services/ntrip_service.dart';
|
||||||
import 'package:terepi_seged/services/project_service.dart';
|
import 'package:terepi_seged/services/project_service.dart';
|
||||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||||
|
import 'package:terepi_seged/services/tilt_service.dart';
|
||||||
import 'package:terepi_seged/services/track_sync_service.dart';
|
import 'package:terepi_seged/services/track_sync_service.dart';
|
||||||
import 'package:terepi_seged/services/ts_sync_service.dart';
|
import 'package:terepi_seged/services/ts_sync_service.dart';
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ Future<void> main() async {
|
|||||||
Get.put(TrackSyncService(), permanent: true);
|
Get.put(TrackSyncService(), permanent: true);
|
||||||
Get.put(LayerSyncService());
|
Get.put(LayerSyncService());
|
||||||
Get.put(TsSyncService());
|
Get.put(TsSyncService());
|
||||||
|
Get.put(TiltService());
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||||
|
|
||||||
@@ -43,12 +46,59 @@ class StakeoutMapLayers extends StatelessWidget {
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Transzverzális az aktuális célnál: a vonalra merőleges segédvonal
|
||||||
|
// ±30 m hosszan, 10 m-enkénti méter-címkékkel — terepi akadálynál
|
||||||
|
// e mentén látszik, hova tolható el a pont.
|
||||||
|
final transversal = <LatLng>[];
|
||||||
|
final transversalTicks = <Marker>[];
|
||||||
|
if (target != null && Get.isRegistered<CoordConverterService>()) {
|
||||||
|
final lb = svc.lineBearingAt(target);
|
||||||
|
if (lb != null) {
|
||||||
|
final perpRad = (lb + 90) * math.pi / 180;
|
||||||
|
LatLng at(double d) {
|
||||||
|
final y = target.planEovY + d * math.sin(perpRad);
|
||||||
|
final x = target.planEovX + d * math.cos(perpRad);
|
||||||
|
final w = CoordConverterService.to.eovToWgsPoint(y, x);
|
||||||
|
return LatLng(w.y, w.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
transversal
|
||||||
|
..add(at(-30))
|
||||||
|
..add(at(30));
|
||||||
|
|
||||||
|
for (final d in const [-30.0, -20.0, -10.0, 10.0, 20.0, 30.0]) {
|
||||||
|
transversalTicks.add(Marker(
|
||||||
|
point: at(d),
|
||||||
|
width: 24,
|
||||||
|
height: 16,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.85),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
d.abs().toStringAsFixed(0),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 300 pont fölött a címkék csak a célon (teljesítmény).
|
// 300 pont fölött a címkék csak a célon (teljesítmény).
|
||||||
final showLabels = points.length <= 300;
|
final showLabels = points.length <= 300;
|
||||||
|
|
||||||
return Stack(children: [
|
return Stack(children: [
|
||||||
PolylineLayer(polylines: [
|
PolylineLayer(polylines: [
|
||||||
|
if (transversal.isNotEmpty)
|
||||||
|
Polyline(
|
||||||
|
points: transversal,
|
||||||
|
color: Colors.orange.withValues(alpha: 0.8),
|
||||||
|
strokeWidth: 2),
|
||||||
...linePolys,
|
...linePolys,
|
||||||
if (hasPos && target != null)
|
if (hasPos && target != null)
|
||||||
Polyline(
|
Polyline(
|
||||||
@@ -61,6 +111,7 @@ class StakeoutMapLayers extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
MarkerLayer(markers: [
|
MarkerLayer(markers: [
|
||||||
|
...transversalTicks,
|
||||||
for (final p in points)
|
for (final p in points)
|
||||||
Marker(
|
Marker(
|
||||||
point: LatLng(p.planLat, p.planLon),
|
point: LatLng(p.planLat, p.planLon),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.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_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/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/routes/app_pages.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||||
@@ -134,6 +135,8 @@ class StakeoutPanel extends StatelessWidget {
|
|||||||
tooltip: 'Haptikus visszajelzés',
|
tooltip: 'Haptikus visszajelzés',
|
||||||
onPressed: () => svc.hapticsEnabled.toggle(),
|
onPressed: () => svc.hapticsEnabled.toggle(),
|
||||||
)),
|
)),
|
||||||
|
const TiltBubble(),
|
||||||
|
const SizedBox(width: 2),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.list_alt),
|
icon: const Icon(Icons.list_alt),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../../../services/tilt_service.dart';
|
||||||
|
|
||||||
|
/// Rúd-libella buborék — kis kör alakú widget a kitűzőpanel fejlécébe.
|
||||||
|
///
|
||||||
|
/// Színküszöbök a DŐLÉSBŐL SZÁMOLT VÍZSZINTES HIBA szerint (a rúdmagasság
|
||||||
|
/// figyelembevételével), mert terepen ez a beszédes érték:
|
||||||
|
/// zöld < 1,5 cm · sárga < 3,5 cm · piros felette.
|
||||||
|
/// Mozgás közben és kalibrálatlanul szürke/kérdőjeles.
|
||||||
|
/// Koppintásra részletes nézet: nagy buborék, számok, rúdmagasság,
|
||||||
|
/// vezetett (1 vagy 2 lépéses) kalibráció.
|
||||||
|
class TiltBubble extends StatelessWidget {
|
||||||
|
const TiltBubble({super.key, this.size = 34});
|
||||||
|
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!Get.isRegistered<TiltService>()) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
final t = TiltService.to;
|
||||||
|
final inactive = !t.isCalibrated.value || t.isMoving.value;
|
||||||
|
|
||||||
|
final errCm = t.horizontalErrorCm;
|
||||||
|
final color = inactive
|
||||||
|
? Colors.grey
|
||||||
|
: errCm < 1.5
|
||||||
|
? Colors.green
|
||||||
|
: errCm < 3.5
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.red;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: !t.isCalibrated.value
|
||||||
|
? 'Libella: nincs kalibrálva — koppints!'
|
||||||
|
: t.isMoving.value
|
||||||
|
? 'Libella: mozgásban'
|
||||||
|
: 'Dőlés ${t.tiltDeg.value.toStringAsFixed(1)}° '
|
||||||
|
'≈ ${errCm.toStringAsFixed(1)} cm',
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showDetail(context),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: _BubbleFace(
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
inactive: inactive,
|
||||||
|
uncalibrated: !t.isCalibrated.value,
|
||||||
|
// A buborék a "lejtés" irányába csúszik — skála: 1 fok
|
||||||
|
// dőlés ≈ a sugár ~25%-a, telítésig.
|
||||||
|
dx: t.bubbleX.value,
|
||||||
|
dy: t.bubbleY.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// Részletes nézet + kalibráció
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> _showDetail(BuildContext context) async {
|
||||||
|
final t = TiltService.to;
|
||||||
|
final poleCtrl =
|
||||||
|
TextEditingController(text: t.poleHeight.value.toStringAsFixed(2));
|
||||||
|
|
||||||
|
await Get.dialog(AlertDialog(
|
||||||
|
title: const Text('Rúd-libella'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Obx(() {
|
||||||
|
final errCm = t.horizontalErrorCm;
|
||||||
|
final inactive = !t.isCalibrated.value || t.isMoving.value;
|
||||||
|
final color = inactive
|
||||||
|
? Colors.grey
|
||||||
|
: errCm < 1.5
|
||||||
|
? Colors.green
|
||||||
|
: errCm < 3.5
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.red;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
child: _BubbleFace(
|
||||||
|
size: 140,
|
||||||
|
color: color,
|
||||||
|
inactive: inactive,
|
||||||
|
uncalibrated: !t.isCalibrated.value,
|
||||||
|
dx: t.bubbleX.value,
|
||||||
|
dy: t.bubbleY.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
!t.isCalibrated.value
|
||||||
|
? 'Nincs kalibrálva'
|
||||||
|
: t.isMoving.value
|
||||||
|
? 'Mozgásban — állj meg a leolvasáshoz'
|
||||||
|
: '${t.tiltDeg.value.toStringAsFixed(2)}° ≈ '
|
||||||
|
'${errCm.toStringAsFixed(1)} cm vízszintes hiba',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: inactive ? Colors.grey : color),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'@ ${t.poleHeight.value.toStringAsFixed(2)} m rúdmagasság',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: poleCtrl,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rúdmagasság (m)',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (v) {
|
||||||
|
final m = double.tryParse(v.replaceAll(',', '.'));
|
||||||
|
if (m != null) t.setPoleHeight(m);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'A jelzés csak akkor érvényes, ha a telefon MEREVEN, '
|
||||||
|
'tartóban van a rúdon. Tartó-áthelyezés után kalibrálj újra.',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (TiltService.to.isCalibrated.value)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => TiltService.to.clearCalibration(),
|
||||||
|
child: const Text('Kalibráció törlése'),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: Get.back, child: const Text('Bezár')),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.architecture, size: 18),
|
||||||
|
label: const Text('Kalibrálás'),
|
||||||
|
onPressed: () async {
|
||||||
|
final m = double.tryParse(poleCtrl.text.replaceAll(',', '.'));
|
||||||
|
if (m != null) await TiltService.to.setPoleHeight(m);
|
||||||
|
Get.back();
|
||||||
|
await _calibrationFlow();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vezetett kalibráció: 1. lépés kötelező, 2. (180°-os átforgatásos)
|
||||||
|
/// opcionális — utóbbi a tartó ferdeségét is kiejti.
|
||||||
|
Future<void> _calibrationFlow() async {
|
||||||
|
final t = TiltService.to;
|
||||||
|
|
||||||
|
final step1Ok = await _captureStep(
|
||||||
|
title: 'Kalibráció — 1/2',
|
||||||
|
text: 'Állítsd a rudat FÜGGŐLEGESRE a szelencés libella szerint, '
|
||||||
|
'és tartsd mozdulatlanul. A mérés 2 másodpercig tart.',
|
||||||
|
);
|
||||||
|
if (step1Ok != true) return;
|
||||||
|
final v1 = await t.captureVector();
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
final doStep2 = await Get.dialog<bool>(AlertDialog(
|
||||||
|
title: const Text('Kalibráció — pontosítás?'),
|
||||||
|
content: const Text(
|
||||||
|
'A pontosabb (tartó-ferdeséget is kiejtő) kalibrációhoz forgasd '
|
||||||
|
'át a telefont a RÚD TENGELYE körül 180°-kal (a rúd maradjon '
|
||||||
|
'függőleges), majd folytasd. Ki is hagyhatod — akkor az 1. '
|
||||||
|
'lépés lesz a referencia.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Get.back(result: false),
|
||||||
|
child: const Text('Kihagyom')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Get.back(result: true),
|
||||||
|
child: const Text('Átforgattam — folytatás')),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
if (doStep2 == true) {
|
||||||
|
final v2 = await t.captureVector();
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
await t.saveReference(v1, v2);
|
||||||
|
} else {
|
||||||
|
await t.saveReference(v1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Get.snackbar('Libella', 'Kalibráció kész — a buborék él.',
|
||||||
|
snackPosition: SnackPosition.BOTTOM,
|
||||||
|
backgroundColor: const Color(0xFF2E7D32),
|
||||||
|
colorText: const Color(0xFFFFFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _captureStep({required String title, required String text}) {
|
||||||
|
return Get.dialog<bool>(AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(text),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Get.back(result: false),
|
||||||
|
child: const Text('Mégse')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Get.back(result: true),
|
||||||
|
child: const Text('Indítás')),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _BubbleFace extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
final Color color;
|
||||||
|
final bool inactive;
|
||||||
|
final bool uncalibrated;
|
||||||
|
final double dx;
|
||||||
|
final double dy;
|
||||||
|
|
||||||
|
const _BubbleFace({
|
||||||
|
required this.size,
|
||||||
|
required this.color,
|
||||||
|
required this.inactive,
|
||||||
|
required this.uncalibrated,
|
||||||
|
required this.dx,
|
||||||
|
required this.dy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Skála: sin(4°) dőlésnél ér a széléhez.
|
||||||
|
final r = size / 2;
|
||||||
|
final scale = (r - size * 0.14) / 0.07;
|
||||||
|
var ox = dx * scale;
|
||||||
|
var oy = -dy * scale; // képernyő-y lefelé nő
|
||||||
|
final d = (ox * ox + oy * oy);
|
||||||
|
final maxR = r - size * 0.14;
|
||||||
|
if (d > maxR * maxR) {
|
||||||
|
final f = maxR / math.sqrt(d);
|
||||||
|
ox *= f;
|
||||||
|
oy *= f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _BubblePainter(
|
||||||
|
color: color,
|
||||||
|
inactive: inactive,
|
||||||
|
ox: ox,
|
||||||
|
oy: oy,
|
||||||
|
dotRadius: size * 0.13,
|
||||||
|
),
|
||||||
|
child: uncalibrated
|
||||||
|
? Center(
|
||||||
|
child: Text('?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: size * 0.45,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.grey)))
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BubblePainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
final bool inactive;
|
||||||
|
final double ox;
|
||||||
|
final double oy;
|
||||||
|
final double dotRadius;
|
||||||
|
|
||||||
|
_BubblePainter({
|
||||||
|
required this.color,
|
||||||
|
required this.inactive,
|
||||||
|
required this.ox,
|
||||||
|
required this.oy,
|
||||||
|
required this.dotRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final c = Offset(size.width / 2, size.height / 2);
|
||||||
|
final r = size.width / 2;
|
||||||
|
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r - 1,
|
||||||
|
Paint()
|
||||||
|
..color = color.withOpacity(inactive ? 0.06 : 0.12)
|
||||||
|
..style = PaintingStyle.fill);
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r - 1,
|
||||||
|
Paint()
|
||||||
|
..color = color.withOpacity(inactive ? 0.5 : 0.9)
|
||||||
|
..strokeWidth = 1.6
|
||||||
|
..style = PaintingStyle.stroke);
|
||||||
|
// Belső célkör
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r * 0.35,
|
||||||
|
Paint()
|
||||||
|
..color = color.withOpacity(0.5)
|
||||||
|
..strokeWidth = 1
|
||||||
|
..style = PaintingStyle.stroke);
|
||||||
|
|
||||||
|
if (!inactive) {
|
||||||
|
canvas.drawCircle(c + Offset(ox, oy), dotRadius, Paint()..color = color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_BubblePainter old) =>
|
||||||
|
old.ox != ox ||
|
||||||
|
old.oy != oy ||
|
||||||
|
old.color != color ||
|
||||||
|
old.inactive != inactive;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:terepi_seged/services/app_database.dart';
|
|||||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||||
import 'package:terepi_seged/services/project_service.dart';
|
import 'package:terepi_seged/services/project_service.dart';
|
||||||
|
import 'package:terepi_seged/services/tilt_service.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
import '../../models/stakeout_point.dart';
|
import '../../models/stakeout_point.dart';
|
||||||
@@ -53,12 +54,14 @@ class StakeoutService extends GetxService {
|
|||||||
active.value = value;
|
active.value = value;
|
||||||
if (value) {
|
if (value) {
|
||||||
WakelockPlus.enable();
|
WakelockPlus.enable();
|
||||||
|
if (Get.isRegistered<TiltService>()) TiltService.to.setActive(true);
|
||||||
await load();
|
await load();
|
||||||
_applyPhase();
|
_applyPhase();
|
||||||
_scheduleHaptic();
|
_scheduleHaptic();
|
||||||
} else {
|
} else {
|
||||||
_hapticTimer?.cancel();
|
_hapticTimer?.cancel();
|
||||||
WakelockPlus.disable();
|
WakelockPlus.disable();
|
||||||
|
if (Get.isRegistered<TiltService>()) TiltService.to.setActive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +470,11 @@ class StakeoutService extends GetxService {
|
|||||||
devInline: inline,
|
devInline: inline,
|
||||||
devCrossline: crossline,
|
devCrossline: crossline,
|
||||||
devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null,
|
devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null,
|
||||||
|
tiltDeg: Get.isRegistered<TiltService>() &&
|
||||||
|
TiltService.to.isCalibrated.value &&
|
||||||
|
!TiltService.to.isMoving.value
|
||||||
|
? TiltService.to.tiltDeg.value
|
||||||
|
: null,
|
||||||
fixQuality: gnss.gpsQuality.value,
|
fixQuality: gnss.gpsQuality.value,
|
||||||
accuracy: gnss.horizontalAccuracy,
|
accuracy: gnss.horizontalAccuracy,
|
||||||
stakedAt: DateTime.now(),
|
stakedAt: DateTime.now(),
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// "Telefon rúd-libella" — a kitűzőrúd függőlegességének jelzése a
|
||||||
|
/// telefon gyorsulásmérőjéből.
|
||||||
|
///
|
||||||
|
/// NEM tilt-kompenzáció (a pozíciót nem javítja), hanem tilt-JELZÉS:
|
||||||
|
/// a fizikai szelencés libellát váltja ki a képernyőn. Előny az
|
||||||
|
/// iránytűvel szemben: csak a gravitáció irányát méri, ezért a vevő és
|
||||||
|
/// a fém rúd MÁGNESES zavara nem érinti.
|
||||||
|
///
|
||||||
|
/// Kalibráció (mert a telefontartó sosem áll pontosan a rúd tengelyében):
|
||||||
|
/// 1. lépés: rúd függőlegesen a szelencés libella szerint → 2 s átlagolt
|
||||||
|
/// gravitáció-vektor = referencia.
|
||||||
|
/// 2. lépés (opcionális, pontosabb): ugyanez a telefon rúd-tengely
|
||||||
|
/// körüli 180°-os átforgatása után — a két vektor átlagában a tartó
|
||||||
|
/// ferdesége kiesik, a valódi rúd-tengely marad.
|
||||||
|
///
|
||||||
|
/// Használhatósági részletek:
|
||||||
|
/// * MOZGÁS-detektálás: séta közben a gyorsulásmérő a lépések
|
||||||
|
/// gyorsulását is méri, a "gravitáció" iránya hamis → a jelzés
|
||||||
|
/// ilyenkor inaktív (szürke), csak megálláskor élesedik. Ez egybeesik
|
||||||
|
/// a munkafolyamattal: a libella a tárolás előtti pillanatban számít.
|
||||||
|
/// * SIMÍTÁS: exponenciális aluláteresztő szűrő, ~10 Hz-es kijelzési
|
||||||
|
/// frissítés — enélkül a buborék idegesítően remegne.
|
||||||
|
/// * A dőlésből számolt VÍZSZINTES HIBA a beszédes érték:
|
||||||
|
/// hiba ≈ rúdmagasság × sin(dőlés) — 2 m-es rúdnál 0,5° már ~1,7 cm.
|
||||||
|
class TiltService extends GetxService {
|
||||||
|
static TiltService get to => Get.find();
|
||||||
|
|
||||||
|
// ── Reaktív állapot ──────────────────────────────────────────────
|
||||||
|
final active = false.obs;
|
||||||
|
final isCalibrated = false.obs;
|
||||||
|
final isMoving = false.obs;
|
||||||
|
final isCapturing = false.obs; // kalibrációs mintavétel fut
|
||||||
|
|
||||||
|
/// Dőlés a kalibrált rúd-tengelyhez képest (fok).
|
||||||
|
final tiltDeg = 0.0.obs;
|
||||||
|
|
||||||
|
/// A buborék 2D iránya a telefon képernyő-síkjában (egység: a dőlés
|
||||||
|
/// szinusza; a widget skálázza). x: jobbra +, y: felfelé +.
|
||||||
|
final bubbleX = 0.0.obs;
|
||||||
|
final bubbleY = 0.0.obs;
|
||||||
|
|
||||||
|
/// Rúdmagasság (m) — a vízszintes hiba számításához; szerkeszthető.
|
||||||
|
final poleHeight = 2.0.obs;
|
||||||
|
|
||||||
|
/// Dőlésből számolt vízszintes eltolódás (cm).
|
||||||
|
double get horizontalErrorCm =>
|
||||||
|
poleHeight.value * math.sin(tiltDeg.value * math.pi / 180) * 100;
|
||||||
|
|
||||||
|
// ── Belső állapot ────────────────────────────────────────────────
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
|
||||||
|
// Referencia (rúd-tengely) egységvektor a telefon koordinátáiban.
|
||||||
|
double _refX = 0, _refY = 0, _refZ = 1;
|
||||||
|
|
||||||
|
// Szűrt gyorsulás-vektor.
|
||||||
|
double _fx = 0, _fy = 0, _fz = 0;
|
||||||
|
bool _filterSeeded = false;
|
||||||
|
static const _alpha = 0.15; // aluláteresztő szűrő együttható
|
||||||
|
|
||||||
|
// Mozgás-detektálás: |a| szórása csúszóablakban.
|
||||||
|
final _magWindow = <double>[];
|
||||||
|
static const _windowLen = 25; // ~0,5 s 50 Hz-en
|
||||||
|
static const _movingStdThreshold = 0.35; // m/s²
|
||||||
|
|
||||||
|
DateTime _lastUiPush = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
super.onInit();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
poleHeight.value = prefs.getDouble('tilt_pole_height') ?? 2.0;
|
||||||
|
final refJson = prefs.getString('tilt_reference');
|
||||||
|
if (refJson != null) {
|
||||||
|
final r = jsonDecode(refJson) as Map<String, dynamic>;
|
||||||
|
_refX = (r['x'] as num).toDouble();
|
||||||
|
_refY = (r['y'] as num).toDouble();
|
||||||
|
_refZ = (r['z'] as num).toDouble();
|
||||||
|
isCalibrated.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A szenzor-stream csak aktív kitűzés/mérés módban fut (akku-kímélés).
|
||||||
|
void setActive(bool value) {
|
||||||
|
if (active.value == value) return;
|
||||||
|
active.value = value;
|
||||||
|
if (value) {
|
||||||
|
_filterSeeded = false;
|
||||||
|
_magWindow.clear();
|
||||||
|
_sub = accelerometerEventStream(
|
||||||
|
samplingPeriod: const Duration(milliseconds: 20))
|
||||||
|
.listen(_onSample);
|
||||||
|
} else {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSample(AccelerometerEvent e) {
|
||||||
|
// Szűrés.
|
||||||
|
if (!_filterSeeded) {
|
||||||
|
_fx = e.x;
|
||||||
|
_fy = e.y;
|
||||||
|
_fz = e.z;
|
||||||
|
_filterSeeded = true;
|
||||||
|
} else {
|
||||||
|
_fx += _alpha * (e.x - _fx);
|
||||||
|
_fy += _alpha * (e.y - _fy);
|
||||||
|
_fz += _alpha * (e.z - _fz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mozgás-detektálás a NYERS magnitúdó szórásából.
|
||||||
|
final mag = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
|
||||||
|
_magWindow.add(mag);
|
||||||
|
if (_magWindow.length > _windowLen) _magWindow.removeAt(0);
|
||||||
|
if (_magWindow.length >= _windowLen ~/ 2) {
|
||||||
|
final mean = _magWindow.reduce((a, b) => a + b) / _magWindow.length;
|
||||||
|
final varSum =
|
||||||
|
_magWindow.fold<double>(0, (s, v) => s + (v - mean) * (v - mean));
|
||||||
|
final std = math.sqrt(varSum / _magWindow.length);
|
||||||
|
isMoving.value = std > _movingStdThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-frissítés max. ~12 Hz-en.
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.difference(_lastUiPush).inMilliseconds < 80) return;
|
||||||
|
_lastUiPush = now;
|
||||||
|
|
||||||
|
_updateTilt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateTilt() {
|
||||||
|
final fmag = math.sqrt(_fx * _fx + _fy * _fy + _fz * _fz);
|
||||||
|
if (fmag < 1e-6) return;
|
||||||
|
|
||||||
|
// Dőlés = a szűrt gravitáció és a referencia-tengely szöge.
|
||||||
|
final dot = (_fx * _refX + _fy * _refY + _fz * _refZ) / fmag;
|
||||||
|
tiltDeg.value = math.acos(dot.clamp(-1.0, 1.0)) * 180 / math.pi;
|
||||||
|
|
||||||
|
// Buborék-irány: a gravitáció referencia-síkra vetített komponense,
|
||||||
|
// a telefon képernyő-tengelyeiben. (x: jobbra, y: a képernyőn felfelé
|
||||||
|
// — a szenzor y-tengelye a képernyőn felfelé mutat.)
|
||||||
|
final gx = _fx / fmag, gy = _fy / fmag, gz = _fz / fmag;
|
||||||
|
final proj = gx * _refX + gy * _refY + gz * _refZ;
|
||||||
|
bubbleX.value = gx - proj * _refX;
|
||||||
|
bubbleY.value = gy - proj * _refY;
|
||||||
|
// A z-komponenst a 2D buborék nem jeleníti meg — függőleges tartónál
|
||||||
|
// (telefon képernyője ~vízszintes normálissal) x/y hordozza az infót.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
// Kalibráció
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// [seconds] másodpercig átlagolja a NYERS gravitáció-vektort.
|
||||||
|
/// Mozgás esetén a minta zajos lesz — a hívó UI figyelmeztet előtte.
|
||||||
|
Future<({double x, double y, double z})> captureVector(
|
||||||
|
{int seconds = 2}) async {
|
||||||
|
isCapturing.value = true;
|
||||||
|
double sx = 0, sy = 0, sz = 0;
|
||||||
|
var n = 0;
|
||||||
|
final sub = accelerometerEventStream(
|
||||||
|
samplingPeriod: const Duration(milliseconds: 20))
|
||||||
|
.listen((e) {
|
||||||
|
sx += e.x;
|
||||||
|
sy += e.y;
|
||||||
|
sz += e.z;
|
||||||
|
n++;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Future.delayed(Duration(seconds: seconds));
|
||||||
|
} finally {
|
||||||
|
await sub.cancel();
|
||||||
|
isCapturing.value = false;
|
||||||
|
}
|
||||||
|
if (n == 0) return (x: 0.0, y: 0.0, z: 1.0);
|
||||||
|
final mag = math.sqrt(sx * sx + sy * sy + sz * sz);
|
||||||
|
return (x: sx / mag, y: sy / mag, z: sz / mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Referencia mentése. Egy lépésnél [v2] = null; két lépésnél
|
||||||
|
/// (180°-os átforgatás) a két egységvektor ÁTLAGA a rúd-tengely —
|
||||||
|
/// a tartó ferdesége a két mérésben ellentétes előjelű, így kiesik.
|
||||||
|
Future<void> saveReference(({double x, double y, double z}) v1,
|
||||||
|
[({double x, double y, double z})? v2]) async {
|
||||||
|
double x = v1.x, y = v1.y, z = v1.z;
|
||||||
|
if (v2 != null) {
|
||||||
|
x = (v1.x + v2.x) / 2;
|
||||||
|
y = (v1.y + v2.y) / 2;
|
||||||
|
z = (v1.z + v2.z) / 2;
|
||||||
|
}
|
||||||
|
final mag = math.sqrt(x * x + y * y + z * z);
|
||||||
|
if (mag < 1e-6) return;
|
||||||
|
_refX = x / mag;
|
||||||
|
_refY = y / mag;
|
||||||
|
_refZ = z / mag;
|
||||||
|
isCalibrated.value = true;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
'tilt_reference', jsonEncode({'x': _refX, 'y': _refY, 'z': _refZ}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPoleHeight(double meters) async {
|
||||||
|
if (meters <= 0) return;
|
||||||
|
poleHeight.value = meters;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setDouble('tilt_pole_height', meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCalibration() async {
|
||||||
|
isCalibrated.value = false;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('tilt_reference');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
device_info_plus: ^12.4.0
|
device_info_plus: ^12.4.0
|
||||||
wakelock_plus: ^1.2.11
|
wakelock_plus: ^1.2.11
|
||||||
|
sensors_plus: ^7.1.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user