This commit is contained in:
Artem Kokos
2026-04-14 00:02:02 +07:00
parent 1d31767ee0
commit 8198ea09ae
9 changed files with 403 additions and 4 deletions

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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()),

View File

@@ -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),
);
}

View File

@@ -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(

View File

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

View File

@@ -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)),
),

View 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);

View File

@@ -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: