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; }