import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:sensors_plus/sensors_plus.dart'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:my_app/appeditor/custom/AppEditorWidgetContext.dart'; // Includes an optional horizontal magnetic compass for Android/iOS. Widget buildCustomWidget(BuildContext context, AppEditorWidgetContext app) { return app.params['sensor'] == true ? MagneticCompass(app: app) : CompassWidget(app: app); } class CompassWidget extends StatefulWidget { const CompassWidget({super.key, required this.app}); final AppEditorWidgetContext app; @override State createState() => _CompassWidgetState(); } class _CompassWidgetState extends State { late String _variable; late Stream _headings; void _bind() { _variable = widget.app.params['headingVariable'] as String? ?? 'compass_heading'; _headings = widget.app.watchVar(_variable); } @override void initState() { super.initState(); _bind(); } @override void didUpdateWidget(covariant CompassWidget oldWidget) { super.didUpdateWidget(oldWidget); final name = widget.app.params['headingVariable'] as String? ?? 'compass_heading'; if (name != _variable || !identical(widget.app, oldWidget.app)) _bind(); } double _number(dynamic value, double fallback) { final result = value is num ? value.toDouble() : double.tryParse('$value'); return result != null && result.isFinite ? result : fallback; } Future _publish(double value, {bool notify = false}) async { try { await widget.app.addVar(name: _variable, value: value, overwrite: true); if (notify && mounted && widget.app.hasAction('onHeadingChanged')) { await widget.app.runAction('onHeadingChanged'); } } catch (error) { widget.app.showMessage('تغییر جهت ممکن نشد: $error'); } } @override Widget build(BuildContext context) { final args = widget.app.params; final manual = args['manual'] != false; return StreamBuilder( stream: _headings, builder: (context, snapshot) { final initial = _number(args['initialHeading'], 0); final heading = _number(snapshot.data, initial) % 360; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( args['title'] as String? ?? 'قطب‌نما', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Text( manual ? 'حالت دستی — داده سنسور خوانده نمی‌شود' : 'جهت از متغیر اپ', ), const SizedBox(height: 12), SizedBox.square( dimension: 210, child: Stack( alignment: Alignment.center, children: [ Container( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Theme.of(context).colorScheme.outline, ), ), ), const Positioned(top: 8, child: Text('N · شمال')), const Positioned(bottom: 8, child: Text('S · جنوب')), const Positioned(right: 8, child: Text('E')), const Positioned(left: 8, child: Text('W')), Transform.rotate( // If the device points east, north is to its left. angle: -heading * math.pi / 180, child: const Icon( Icons.navigation, size: 100, color: Colors.red, ), ), ], ), ), const SizedBox(height: 12), Text( '${heading.toStringAsFixed(1)}°', textDirection: TextDirection.ltr, ), if (!snapshot.hasData && !manual) const Text( 'هنوز داده‌ای دریافت نشده؛ مقدار اولیه نمایش داده می‌شود.', ), if (manual) Directionality( textDirection: TextDirection.ltr, child: Slider( min: 0, max: 359, value: heading.clamp(0, 359), onChanged: (value) => _publish(value), onChangeEnd: (value) => _publish(value, notify: true), ), ), ], ), ), ); }, ); } } /// Magnetic north for a portrait phone held flat, screen facing upward. /// No tilt compensation or geographic declination correction is applied. class MagneticCompass extends StatefulWidget { const MagneticCompass({super.key, required this.app}); final AppEditorWidgetContext app; @override State createState() => _MagneticCompassState(); } class _MagneticCompassState extends State with WidgetsBindingObserver { StreamSubscription? _subscription; Timer? _timeout; double? _heading; String _status = 'در انتظار دادهٔ مغناطیسی'; int _epoch = 0; bool _visible = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _sync(); }); } @override void didUpdateWidget(covariant MagneticCompass oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.app, widget.app)) { _stop(); _heading = null; _sync(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) => _sync(); void _stop() { _epoch++; _timeout?.cancel(); _timeout = null; final subscription = _subscription; _subscription = null; if (subscription != null) unawaited(subscription.cancel()); } void _sync() { if (!mounted) return; final lifecycle = WidgetsBinding.instance.lifecycleState; if (!_visible || !widget.app.mounted || (lifecycle != null && lifecycle != AppLifecycleState.resumed)) { _stop(); setState(() => _status = 'دریافت متوقف است'); return; } if (_subscription != null) return; if (kIsWeb || (defaultTargetPlatform != TargetPlatform.android && defaultTargetPlatform != TargetPlatform.iOS)) { setState(() => _status = 'قطب‌نمای مغناطیسی این نمونه فقط روی Android/iOS اجرا می‌شود'); return; } final epoch = ++_epoch; setState(() { _heading = null; _status = 'در انتظار دادهٔ مغناطیسی'; }); void fail() { if (!mounted || epoch != _epoch) return; _stop(); setState(() { _heading = null; _status = 'سنسور مغناطیسی در دسترس نیست؛ دریافت را دوباره شروع کنید'; }); } _timeout = Timer(const Duration(seconds: 5), fail); try { _subscription = magnetometerEventStream( samplingPeriod: const Duration(milliseconds: 200), ).listen((event) { if (!mounted || epoch != _epoch) return; if (!event.x.isFinite || !event.y.isFinite || event.x * event.x + event.y * event.y < 0.01) return; _timeout?.cancel(); final offset = (widget.app.params['headingOffset'] as num?)?.toDouble() ?? 0; final angle = (math.atan2(-event.x, event.y) * 180 / math.pi + offset) % 360; if (!angle.isFinite) return; setState(() { _heading = angle; _status = 'شمال مغناطیسی؛ گوشی افقی و بالای آن رو به جلو'; }); }, onError: (Object error) => fail(), onDone: fail, cancelOnError: true); } catch (_) { fail(); } } @override void dispose() { _stop(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) { // TickerMode is disabled by Flutter when this route is covered. final visible = TickerMode.of(context); if (visible != _visible) { _visible = visible; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _sync(); }); } return Card(child: Padding( padding: const EdgeInsets.all(16), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text(widget.app.params['title'] as String? ?? 'قطب‌نمای مغناطیسی', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 12), Text(_status, textAlign: TextAlign.center), const SizedBox(height: 16), if (_heading != null) ...[ SizedBox.square(dimension: 210, child: Stack( alignment: Alignment.center, children: [ const Positioned(top: 0, child: Text('N · شمال')), const Positioned(bottom: 0, child: Text('S · جنوب')), const Positioned(right: 0, child: Text('E')), const Positioned(left: 0, child: Text('W')), Transform.rotate(angle: -_heading! * math.pi / 180, child: const Icon(Icons.navigation, size: 100, color: Colors.red)), ], )), Text('${_heading!.toStringAsFixed(1)}°', textDirection: TextDirection.ltr), ], TextButton(onPressed: () { _stop(); _sync(); }, child: const Text('شروع مجدد دریافت')), ]), )); } }