پرش به مطلب اصلی

قطب‌نمای مغناطیسی و نمایشگر جهت

این نمونه جهت را با عدد درجه و یک نشانگر شمال نمایش می‌دهد. در حالت دستی، کاربر زاویه را با لغزنده تغییر می‌دهد. در حالت ورودی متغیر، رابط به تغییرات متغیر اپ واکنش نشان می‌دهد.

انتخاب حالت

با sensor: true نمونه مستقیماً مغناطیس‌سنج sensors_plus را می‌خواند. با حذف آن یا مقدار false، حالت دستی و ورودی متغیر قبلی فعال است. manual: false به‌تنهایی سنسور را فعال نمی‌کند. عملکرد «شروع سنسور» پنل فعلاً مغناطیس‌سنج ندارد؛ حالت مغناطیسی این نمونه از پکیج استفاده می‌کند.

افزودن نمونه​

  1. ویجت Flutter سفارشی را اضافه کنید.
  2. فایل compass.dart را دانلود کنید و در فرم بارگذاری کنید.
  3. ارتفاع را «به اندازه محتوا» قرار دهید؛ برای صفحهٔ قطب‌نما دست‌کم حدود ۲۶۰ واحد منطقی عرض در نظر بگیرید.
  4. پارامترهای زیر را وارد کنید، ذخیره کنید و خروجی جدید بسازید.
{
"title": "جهت‌نما",
"headingVariable": "compass_heading",
"initialHeading": 0,
"manual": true
}

پارامترها​

کلیدنوع و پیش‌فرضکاربرد
titleرشته؛ «قطب‌نما»عنوان کارت
headingVariableرشته؛ compass_headingنام متغیر زاویه، بر حسب درجه
initialHeadingعدد؛ 0زاویهٔ نمایشی پیش از دریافت مقدار متغیر
sensorبولی؛ falseخواندن مستقیم مغناطیس‌سنج؛ بر حالت دستی و متغیر اولویت دارد
headingOffsetعدد؛ 0اصلاح زاویه بر حسب درجه در حالت مغناطیسی
manualبولی؛ trueنمایش لغزندهٔ دستی

مقدار متغیر موجود بر initialHeading اولویت دارد. زاویه برای نمایش به بازهٔ صفر تا کمتر از ۳۶۰ تبدیل می‌شود. لغزندهٔ دستی بازهٔ ۰ تا ۳۵۹ دارد.

قرارداد جهت​

مقدارجهت سر دستگاه در دادهٔ ورودی
0شمال
90شرق
180جنوب
270غرب

حروف جهات روی صفحه ثابت‌اند. نشانگر قرمز جهت شمال را نسبت به زاویهٔ ورودی نشان می‌دهد؛ مثلاً وقتی ورودی ۹۰ باشد، شمال سمت چپ قرار می‌گیرد. این قرارداد دربارهٔ دادهٔ واردشده است و جهت دستگاه را تأیید نمی‌کند.

اتصال به متغیر و عملکرد​

در حالت دستی، حرکت لغزنده مقدار را در متغیر موقت compass_heading می‌نویسد. با رهاکردن لغزنده، عملکرد اختیاری onHeadingChanged اجرا می‌شود؛ برای آن در فهرست عملکردهای ویجت شناسه تعریف کنید.

برای کنترل از بخش دیگری از اپ:

  1. manual را false کنید.
  2. با عملکرد «ایجاد متغیر»، متغیر عددی compass_heading را بسازید.
  3. با دکمه یا عملکرد تغییر مقدار، عددهای ۰، ۹۰، ۱۸۰ و ۲۷۰ را در آن قرار دهید.
  4. رابط قطب‌نما بدون کامپایل دوباره، به تغییر مقدار واکنش نشان می‌دهد.

تغییر متغیر از بیرون، onHeadingChanged این نمونه را اجرا نمی‌کند. اگر داده‌ای دریافت نشده باشد، پیام انتظار و مقدار اولیه نمایش داده می‌شود.

استفاده از مغناطیس‌سنج دستگاه​

پارامترهای پیشنهادی:

{"title":"قطب‌نمای مغناطیسی","sensor":true,"headingOffset":0}

نیازی به نصب پکیج یا تعریف عملکرد شروع سنسور نیست؛ sensors_plus: 6.1.2 در خروجی‌های جدید موجود است. نمونه فقط روی Android/iOS دریافت را شروع می‌کند؛ روی وب و دسکتاپ پیام عدم پشتیبانی نشان می‌دهد. ممکن است خود دستگاه مغناطیس‌سنج نداشته باشد؛ خطا یا نرسیدن داده در پنج ثانیه پیام مناسب می‌دهد. محدودیت‌های رسمی sensors_plus را برای پلتفرم مقصد ببینید.

گوشی را افقی، صفحه رو به بالا و در جهت portrait نگه دارید. زاویه از atan2(-x, y) به دست می‌آید؛ جبران شیب، تصحیح چرخش نمایشگر، حذف تداخل فلزی و تبدیل شمال مغناطیسی به شمال جغرافیایی در این مثال پیاده نشده است. headingOffset اصلاح ثابت زاویه است و جای کالیبراسیون واقعی را نمی‌گیرد. این نمونه ابزار آموزشی است؛ نزدیک آهنربا، قاب مغناطیسی و فلز نتیجه منحرف می‌شود.

با پوشیده‌شدن مسیر یا رفتن اپ به پس‌زمینه اشتراک لغو می‌شود و با بازگشت از سر گرفته می‌شود؛ در dispose نیز اشتراک و تایمر آزاد می‌شوند. دکمهٔ «شروع مجدد دریافت» برای تلاش دوباره پس از خطا است. زاویه در حالت سنسور فقط داخل ویجت نمایش داده می‌شود؛ در headingVariable نوشته نمی‌شود و onHeadingChanged اجرا نمی‌شود. آن اتصال‌ها مخصوص حالت دستی قبلی‌اند.

امتحان در خروجی​

  • در حالت دستی، لغزنده را جابه‌جا کنید؛ عدد و نشانگر باید تغییر کنند.
  • همان متغیر را در نمایشگر متغیر نشان دهید؛ مقدار باید با لغزنده هماهنگ باشد.
  • در حالت متغیر، از پنل عدد ۹۰ را بنویسید؛ نشانگر شمال به چپ می‌چرخد.
  • با sensor: true گوشی افقی را آرام حول محور عمودی بچرخانید؛ جهت باید تغییر کند. پس‌زمینه و بازگشت به صفحه و حالت دستگاه بدون سنسور را نیز بررسی کنید.

کد کامل​

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;

State<CompassWidget> createState() => _CompassWidgetState();
}

class _CompassWidgetState extends State<CompassWidget> {
late String _variable;
late Stream<dynamic> _headings;

void _bind() {
_variable =
widget.app.params['headingVariable'] as String? ?? 'compass_heading';
_headings = widget.app.watchVar(_variable);
}


void initState() {
super.initState();
_bind();
}


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<void> _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');
}
}


Widget build(BuildContext context) {
final args = widget.app.params;
final manual = args['manual'] != false;
return StreamBuilder<dynamic>(
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;

State<MagneticCompass> createState() => _MagneticCompassState();
}

class _MagneticCompassState extends State<MagneticCompass>
with WidgetsBindingObserver {
StreamSubscription<MagnetometerEvent>? _subscription;
Timer? _timeout;
double? _heading;
String _status = 'در انتظار دادهٔ مغناطیسی';
int _epoch = 0;
bool _visible = false;


void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _sync();
});
}


void didUpdateWidget(covariant MagneticCompass oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.app, widget.app)) {
_stop();
_heading = null;
_sync();
}
}


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


void dispose() {
_stop();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}


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('شروع مجدد دریافت')),
]),
));
}
}