Geofence
This commit is contained in:
@@ -11,6 +11,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -42,3 +43,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="ignis_app"
|
||||
android:name="${applicationName}"
|
||||
@@ -33,6 +35,9 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="ignis_geofence" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'providers/providers.dart';
|
||||
import 'screens/homes_screen.dart';
|
||||
import 'screens/remote_screen.dart';
|
||||
import 'services/geofence_worker.dart';
|
||||
|
||||
/// Top-level callback для workmanager (выполняется в отдельном изоляте).
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
Workmanager().executeTask((taskName, inputData) async {
|
||||
if (taskName == geofenceTaskName) {
|
||||
return await executeGeofenceCheck();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Инициализация workmanager
|
||||
Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
|
||||
|
||||
runApp(const ProviderScope(child: IgnisApp()));
|
||||
}
|
||||
|
||||
@@ -72,6 +90,10 @@ class _MainGateState extends ConsumerState<MainGate> {
|
||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||
// Загружаем info об авторизации (admin / не admin)
|
||||
await ref.read(authInfoProvider.notifier).load();
|
||||
|
||||
// Запускаем / обновляем геофенс-таск если нужно
|
||||
await syncGeofenceTask(ref.read(homesProvider));
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||
|
||||
@@ -7,6 +7,7 @@ class HomeConfig {
|
||||
final String apiKey; // ключ авторизации
|
||||
final double? latitude; // GPS-широта дома (для гео-автоматизации)
|
||||
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
|
||||
final bool geofenceEnabled; // автовыключение при уходе из дома
|
||||
|
||||
HomeConfig({
|
||||
required this.id,
|
||||
@@ -15,11 +16,15 @@ class HomeConfig {
|
||||
required this.apiKey,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.geofenceEnabled = false,
|
||||
});
|
||||
|
||||
/// Есть ли координаты у дома
|
||||
bool get hasCoordinates => latitude != null && longitude != null;
|
||||
|
||||
/// Готов ли геофенс к работе: включён + координаты заданы
|
||||
bool get geofenceReady => geofenceEnabled && hasCoordinates;
|
||||
|
||||
/// Сериализация в JSON для хранения в SharedPreferences
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@@ -28,6 +33,7 @@ class HomeConfig {
|
||||
'apiKey': apiKey,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
'geofenceEnabled': geofenceEnabled,
|
||||
};
|
||||
|
||||
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
||||
@@ -37,6 +43,7 @@ class HomeConfig {
|
||||
apiKey: json['apiKey'] as String,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
|
||||
);
|
||||
|
||||
/// Копирование с изменениями
|
||||
@@ -46,6 +53,7 @@ class HomeConfig {
|
||||
String? apiKey,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? geofenceEnabled,
|
||||
bool clearCoordinates = false,
|
||||
}) =>
|
||||
HomeConfig(
|
||||
@@ -55,5 +63,9 @@ class HomeConfig {
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
latitude: clearCoordinates ? null : (latitude ?? this.latitude),
|
||||
longitude: clearCoordinates ? null : (longitude ?? this.longitude),
|
||||
// Если очищаем координаты -- геофенс тоже выключается
|
||||
geofenceEnabled: clearCoordinates
|
||||
? false
|
||||
: (geofenceEnabled ?? this.geofenceEnabled),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:geolocator/geolocator.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/geofence_worker.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
// ─── Сервисы ─────────────────────────────────────────────────
|
||||
|
||||
@@ -683,6 +685,37 @@ class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
|
||||
bool get isAdmin => state?['is_admin'] == true;
|
||||
}
|
||||
|
||||
// ─── Геофенс: управление фоновым таском ─────────────────────
|
||||
|
||||
/// Синхронизировать состояние фонового таска с настройками домов.
|
||||
/// Вызывать при старте приложения и при изменении настроек.
|
||||
///
|
||||
/// Если хотя бы один дом имеет geofenceReady -- регистрируем
|
||||
/// периодический таск. Иначе -- отменяем.
|
||||
Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
|
||||
final needGeofence = homes.any((h) => h.geofenceReady);
|
||||
|
||||
if (needGeofence) {
|
||||
// Сбрасываем флаг "сработал" -- при открытии приложения
|
||||
// считаем что пользователь снова дома (или осознанно включил)
|
||||
await resetGeofenceFired();
|
||||
|
||||
await Workmanager().registerPeriodicTask(
|
||||
geofenceTaskUniqueName,
|
||||
geofenceTaskName,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(
|
||||
networkType: NetworkType.connected,
|
||||
),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay: const Duration(minutes: 1),
|
||||
);
|
||||
} else {
|
||||
await Workmanager().cancelByUniqueName(geofenceTaskUniqueName);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Утилита: расчёт расстояния (Haversine) ──────────────────
|
||||
|
||||
double calculateDistanceKm(
|
||||
|
||||
@@ -19,10 +19,15 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final _keyCtrl = TextEditingController();
|
||||
final _latCtrl = TextEditingController();
|
||||
final _lonCtrl = TextEditingController();
|
||||
bool _geofenceEnabled = false;
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isEdit => widget.home != null;
|
||||
|
||||
/// Координаты заполнены (оба поля непустые)
|
||||
bool get _hasCoordinates =>
|
||||
_latCtrl.text.trim().isNotEmpty && _lonCtrl.text.trim().isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -36,11 +41,27 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
if (widget.home!.longitude != null) {
|
||||
_lonCtrl.text = widget.home!.longitude.toString();
|
||||
}
|
||||
_geofenceEnabled = widget.home!.geofenceEnabled;
|
||||
}
|
||||
|
||||
// Следим за полями координат чтобы обновлять доступность Switch
|
||||
_latCtrl.addListener(_onCoordsChanged);
|
||||
_lonCtrl.addListener(_onCoordsChanged);
|
||||
}
|
||||
|
||||
void _onCoordsChanged() {
|
||||
// Если координаты очистили -- выключаем геофенс
|
||||
if (!_hasCoordinates && _geofenceEnabled) {
|
||||
setState(() => _geofenceEnabled = false);
|
||||
} else {
|
||||
setState(() {}); // перерисовать Switch enabled/disabled
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_latCtrl.removeListener(_onCoordsChanged);
|
||||
_lonCtrl.removeListener(_onCoordsChanged);
|
||||
_nameCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
_keyCtrl.dispose();
|
||||
@@ -134,6 +155,43 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ─── Геофенс ───
|
||||
SwitchListTile(
|
||||
title: const Text('Выключать свет при уходе'),
|
||||
subtitle: Text(
|
||||
_hasCoordinates
|
||||
? 'Автовыключение при удалении на 500 м'
|
||||
: 'Задайте координаты для активации',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _hasCoordinates ? Colors.white38 : Colors.white24,
|
||||
),
|
||||
),
|
||||
value: _geofenceEnabled,
|
||||
activeColor: Colors.deepOrange,
|
||||
onChanged: _hasCoordinates
|
||||
? (v) => setState(() => _geofenceEnabled = v)
|
||||
: null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: Icon(
|
||||
Icons.directions_walk,
|
||||
color: _geofenceEnabled && _hasCoordinates
|
||||
? Colors.deepOrange
|
||||
: Colors.white24,
|
||||
),
|
||||
),
|
||||
if (_geofenceEnabled && _hasCoordinates)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||||
child: Text(
|
||||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
||||
'Работает в фоне, без постоянной нотификации.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -193,6 +251,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
final clearCoords = latText.isEmpty && lonText.isEmpty;
|
||||
|
||||
final home = _isEdit
|
||||
? widget.home!.copyWith(
|
||||
name: name,
|
||||
@@ -200,7 +260,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
clearCoordinates: latText.isEmpty && lonText.isEmpty,
|
||||
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
||||
clearCoordinates: clearCoords,
|
||||
)
|
||||
: HomeConfig(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
@@ -209,6 +270,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
geofenceEnabled: _geofenceEnabled,
|
||||
);
|
||||
|
||||
if (_isEdit) {
|
||||
@@ -217,6 +279,10 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
await ref.read(homesProvider.notifier).add(home);
|
||||
}
|
||||
|
||||
// Синхронизировать фоновый таск с новыми настройками
|
||||
final allHomes = ref.read(homesProvider);
|
||||
await syncGeofenceTask(allHomes);
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,8 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await ref.read(homesProvider.notifier).remove(home.id);
|
||||
// Синхронизировать фоновый таск (мог быть удалён дом с геофенсом)
|
||||
await syncGeofenceTask(ref.read(homesProvider));
|
||||
},
|
||||
child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
|
||||
252
lib/services/geofence_worker.dart
Normal file
252
lib/services/geofence_worker.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Порог расстояния для срабатывания геофенса (метры)
|
||||
const double geofenceThresholdMeters = 500.0;
|
||||
|
||||
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
|
||||
const String _firedKey = 'ignis_geofence_fired';
|
||||
|
||||
/// Имя задачи в workmanager
|
||||
const String geofenceTaskName = 'ignis_geofence_check';
|
||||
|
||||
/// Уникальное имя для registerPeriodicTask
|
||||
const String geofenceTaskUniqueName = 'ignis_geofence_periodic';
|
||||
|
||||
/// ID notification channel (должен совпадать с AndroidManifest)
|
||||
const String _channelId = 'ignis_geofence';
|
||||
const String _channelName = 'Геофенс';
|
||||
const String _channelDesc = 'Уведомления об автовыключении света';
|
||||
|
||||
/// Основная логика фонового таска.
|
||||
/// Вызывается из workmanager callback (в отдельном изоляте).
|
||||
/// Возвращает true если таск выполнен успешно (workmanager convention).
|
||||
Future<bool> executeGeofenceCheck() async {
|
||||
try {
|
||||
// 1. Проверяем, не сработал ли уже
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_firedKey) == true) {
|
||||
// Уже сработал -- ничего не делаем.
|
||||
// Таск будет отменён при следующем запуске приложения.
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Загружаем дома из SharedPreferences
|
||||
final raw = prefs.getString('ignis_homes');
|
||||
if (raw == null || raw.isEmpty) return true;
|
||||
|
||||
final List<dynamic> homesList = jsonDecode(raw);
|
||||
final currentHomeId = prefs.getString('ignis_current_home_id');
|
||||
|
||||
// Ищем текущий дом с включённым геофенсом
|
||||
Map<String, dynamic>? targetHome;
|
||||
for (final h in homesList) {
|
||||
final map = h as Map<String, dynamic>;
|
||||
final isTarget = (currentHomeId != null)
|
||||
? map['id'] == currentHomeId
|
||||
: true; // если нет текущего -- берём первый подходящий
|
||||
if (isTarget &&
|
||||
map['geofenceEnabled'] == true &&
|
||||
map['latitude'] != null &&
|
||||
map['longitude'] != null) {
|
||||
targetHome = map;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetHome == null) return true; // нет дома с геофенсом
|
||||
|
||||
// 3. Получаем текущую позицию
|
||||
if (!await Geolocator.isLocationServiceEnabled()) return true;
|
||||
|
||||
final perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
return true; // нет пермишена -- молча выходим
|
||||
}
|
||||
|
||||
Position? pos;
|
||||
try {
|
||||
// Сначала lastKnown (мгновенно)
|
||||
pos = await Geolocator.getLastKnownPosition();
|
||||
// Если старше 5 минут -- запрашиваем свежую
|
||||
if (pos == null ||
|
||||
DateTime.now().difference(pos.timestamp).inMinutes > 5) {
|
||||
pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
return true; // не удалось получить позицию
|
||||
}
|
||||
|
||||
if (pos == null) return true;
|
||||
|
||||
// 4. Считаем расстояние
|
||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
||||
final distMeters =
|
||||
_haversineMeters(pos.latitude, pos.longitude, homeLat, homeLon);
|
||||
|
||||
if (distMeters <= geofenceThresholdMeters) {
|
||||
return true; // всё ещё рядом с домом
|
||||
}
|
||||
|
||||
// 5. Ушли за порог -- выключаем все группы
|
||||
final url = _normalizeUrl(targetHome['url'] as String);
|
||||
final apiKey = targetHome['apiKey'] as String;
|
||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
||||
|
||||
int groupCount = 0;
|
||||
try {
|
||||
groupCount = await _turnOffAllGroups(url, apiKey);
|
||||
} catch (_) {
|
||||
// Даже если не удалось выключить -- помечаем как сработавший,
|
||||
// чтобы не спамить запросами
|
||||
}
|
||||
|
||||
// 6. Помечаем как сработавший
|
||||
await prefs.setBool(_firedKey, true);
|
||||
|
||||
// 7. Показываем уведомление
|
||||
final distText = distMeters < 1000
|
||||
? '${distMeters.round()} м'
|
||||
: '${(distMeters / 1000).toStringAsFixed(1)} км';
|
||||
await _showNotification(
|
||||
title: 'Свет выключен',
|
||||
body: '$homeName -- вы ушли на $distText. '
|
||||
'Выключено групп: $groupCount.',
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Любая ошибка -- не крашим воркер
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Сбросить флаг "сработал" -- вызывать при включении геофенса
|
||||
/// или при возврате в приложение.
|
||||
Future<void> resetGeofenceFired() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_firedKey);
|
||||
}
|
||||
|
||||
/// Проверить, сработал ли геофенс
|
||||
Future<bool> isGeofenceFired() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_firedKey) == true;
|
||||
}
|
||||
|
||||
// ─── Уведомления ─────────────────────────────────────────────
|
||||
|
||||
/// Показать локальное уведомление из фонового изолята
|
||||
Future<void> _showNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Инициализация (в изоляте нужна заново)
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await plugin.initialize(initSettings);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDesc,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const details = NotificationDetails(android: androidDetails);
|
||||
|
||||
await plugin.show(
|
||||
42, // фиксированный id -- перезаписывает предыдущее уведомление
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Внутренние хелперы ──────────────────────────────────────
|
||||
|
||||
/// Выключить все группы на сервере. Возвращает кол-во выключенных.
|
||||
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {'X-API-Key': apiKey},
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
|
||||
try {
|
||||
// Получаем список групп
|
||||
final res = await dio.get('/devices/groups');
|
||||
List<String> groupIds = [];
|
||||
|
||||
if (res.data is Map) {
|
||||
// {id: {...}, ...} -- ключи и есть id
|
||||
final map = res.data as Map;
|
||||
for (final entry in map.entries) {
|
||||
final id = entry.key.toString();
|
||||
groupIds.add(id);
|
||||
}
|
||||
} else if (res.data is List) {
|
||||
for (final g in res.data) {
|
||||
if (g is Map && g['id'] != null) {
|
||||
groupIds.add(g['id'].toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Выключаем каждую группу
|
||||
int success = 0;
|
||||
await Future.wait(groupIds.map((id) async {
|
||||
try {
|
||||
await dio.post('/control/group/$id', queryParameters: {'state': false});
|
||||
success++;
|
||||
} catch (_) {
|
||||
// Одна группа упала -- не останавливаем остальные
|
||||
}
|
||||
}));
|
||||
|
||||
return success;
|
||||
} finally {
|
||||
dio.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Нормализация URL
|
||||
String _normalizeUrl(String url) {
|
||||
var u = url.trim();
|
||||
if (!u.startsWith('http')) u = 'https://$u';
|
||||
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
|
||||
return u;
|
||||
}
|
||||
|
||||
/// Расстояние в метрах (Haversine)
|
||||
double _haversineMeters(
|
||||
double lat1, double lon1, double lat2, double lon2) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _degToRad(lat2 - lat1);
|
||||
final dLon = _degToRad(lon2 - lon1);
|
||||
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_degToRad(lat1)) *
|
||||
math.cos(_degToRad(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
math.sin(dLon / 2);
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
return earthRadiusM * c;
|
||||
}
|
||||
|
||||
double _degToRad(double deg) => deg * (math.pi / 180);
|
||||
@@ -38,6 +38,8 @@ dependencies:
|
||||
flutter_riverpod: ^3.3.1
|
||||
shared_preferences: ^2.5.5
|
||||
geolocator: ^13.0.2
|
||||
workmanager: ^0.9.0+3
|
||||
flutter_local_notifications: ^19.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user