From d3857f7fde1166bad881f17b2ca2bd09548953bd Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Tue, 7 Jul 2026 02:48:37 +0200 Subject: [PATCH] =?UTF-8?q?Libella=20megjeln=C3=ADt=C3=A9se=20m=C3=A9r?= =?UTF-8?q?=C5=91r=C3=BAdhoz,=20transzverz=C3=A1lis=20rajzol=C3=A1sa=20az?= =?UTF-8?q?=20aktu=C3=A1lis=20kit=C5=B1zend=C5=91=20ponthoz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 2 + .../widgets/stakeout_map_layer.dart | 51 +++ .../presentations/widgets/stakeout_panel.dart | 3 + .../presentations/widgets/tilt_bubble.dart | 335 ++++++++++++++++++ lib/services/stakeout_service.dart | 8 + lib/services/tilt_service.dart | 229 ++++++++++++ pubspec.yaml | 1 + 7 files changed, 629 insertions(+) create mode 100644 lib/pages/map_survey/presentations/widgets/tilt_bubble.dart create mode 100644 lib/services/tilt_service.dart diff --git a/lib/main.dart b/lib/main.dart index 374f86c..585a3b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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/project_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/ts_sync_service.dart'; @@ -67,6 +68,7 @@ Future main() async { Get.put(TrackSyncService(), permanent: true); Get.put(LayerSyncService()); Get.put(TsSyncService()); + Get.put(TiltService()); runApp(const MyApp()); } diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart b/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart index 15d768b..0e743e5 100644 --- a/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart +++ b/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart @@ -1,7 +1,10 @@ +import 'dart:math' as math; + 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/coord_converter_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart'; @@ -43,12 +46,59 @@ class StakeoutMapLayers extends StatelessWidget { 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 = []; + final transversalTicks = []; + if (target != null && Get.isRegistered()) { + 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). final showLabels = points.length <= 300; return Stack(children: [ PolylineLayer(polylines: [ + if (transversal.isNotEmpty) + Polyline( + points: transversal, + color: Colors.orange.withValues(alpha: 0.8), + strokeWidth: 2), ...linePolys, if (hasPos && target != null) Polyline( @@ -61,6 +111,7 @@ class StakeoutMapLayers extends StatelessWidget { ), ]), MarkerLayer(markers: [ + ...transversalTicks, for (final p in points) Marker( point: LatLng(p.planLat, p.planLon), diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart index 181f827..2423aaf 100644 --- a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart +++ b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.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'; @@ -134,6 +135,8 @@ class StakeoutPanel extends StatelessWidget { tooltip: 'Haptikus visszajelzés', onPressed: () => svc.hapticsEnabled.toggle(), )), + const TiltBubble(), + const SizedBox(width: 2), IconButton( icon: const Icon(Icons.list_alt), visualDensity: VisualDensity.compact, diff --git a/lib/pages/map_survey/presentations/widgets/tilt_bubble.dart b/lib/pages/map_survey/presentations/widgets/tilt_bubble.dart new file mode 100644 index 0000000..ce1d666 --- /dev/null +++ b/lib/pages/map_survey/presentations/widgets/tilt_bubble.dart @@ -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()) 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 _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 _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(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 _captureStep({required String title, required String text}) { + return Get.dialog(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; +} diff --git a/lib/services/stakeout_service.dart b/lib/services/stakeout_service.dart index 0331dd5..cd03732 100644 --- a/lib/services/stakeout_service.dart +++ b/lib/services/stakeout_service.dart @@ -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/gnss/gnss_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 '../../models/stakeout_point.dart'; @@ -53,12 +54,14 @@ class StakeoutService extends GetxService { active.value = value; if (value) { WakelockPlus.enable(); + if (Get.isRegistered()) TiltService.to.setActive(true); await load(); _applyPhase(); _scheduleHaptic(); } else { _hapticTimer?.cancel(); WakelockPlus.disable(); + if (Get.isRegistered()) TiltService.to.setActive(false); } } @@ -467,6 +470,11 @@ class StakeoutService extends GetxService { devInline: inline, devCrossline: crossline, devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null, + tiltDeg: Get.isRegistered() && + TiltService.to.isCalibrated.value && + !TiltService.to.isMoving.value + ? TiltService.to.tiltDeg.value + : null, fixQuality: gnss.gpsQuality.value, accuracy: gnss.horizontalAccuracy, stakedAt: DateTime.now(), diff --git a/lib/services/tilt_service.dart b/lib/services/tilt_service.dart new file mode 100644 index 0000000..01dd11a --- /dev/null +++ b/lib/services/tilt_service.dart @@ -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 = []; + static const _windowLen = 25; // ~0,5 s 50 Hz-en + static const _movingStdThreshold = 0.35; // m/s² + + DateTime _lastUiPush = DateTime.fromMillisecondsSinceEpoch(0); + + @override + Future 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; + _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(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 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 setPoleHeight(double meters) async { + if (meters <= 0) return; + poleHeight.value = meters; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('tilt_pole_height', meters); + } + + Future clearCalibration() async { + isCalibrated.value = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('tilt_reference'); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 479e318..9249aac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: shared_preferences: ^2.5.5 device_info_plus: ^12.4.0 wakelock_plus: ^1.2.11 + sensors_plus: ^7.1.0 flutter: sdk: flutter