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/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<void> main() async {
|
||||
Get.put(TrackSyncService(), permanent: true);
|
||||
Get.put(LayerSyncService());
|
||||
Get.put(TsSyncService());
|
||||
Get.put(TiltService());
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -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 = <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).
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/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>()) TiltService.to.setActive(true);
|
||||
await load();
|
||||
_applyPhase();
|
||||
_scheduleHaptic();
|
||||
} else {
|
||||
_hapticTimer?.cancel();
|
||||
WakelockPlus.disable();
|
||||
if (Get.isRegistered<TiltService>()) 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>() &&
|
||||
TiltService.to.isCalibrated.value &&
|
||||
!TiltService.to.isMoving.value
|
||||
? TiltService.to.tiltDeg.value
|
||||
: null,
|
||||
fixQuality: gnss.gpsQuality.value,
|
||||
accuracy: gnss.horizontalAccuracy,
|
||||
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
|
||||
device_info_plus: ^12.4.0
|
||||
wakelock_plus: ^1.2.11
|
||||
sensors_plus: ^7.1.0
|
||||
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user