Files
ignis_app/lib/providers/providers.dart
2026-04-13 22:13:56 +07:00

713 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../models/home_config.dart';
import '../services/api_client.dart';
import '../services/settings_service.dart';
// ─── Сервисы ─────────────────────────────────────────────────
/// Синглтон сервиса настроек
final settingsServiceProvider = Provider((ref) => SettingsService());
/// API-клиент -- пересоздаётся при смене дома
final apiProvider = Provider((ref) => IgnisApi());
// ─── Текущий дом ─────────────────────────────────────────────
/// Текущий выбранный дом (null если ни одного нет)
final currentHomeProvider =
NotifierProvider<CurrentHomeNotifier, HomeConfig?>(() => CurrentHomeNotifier());
class CurrentHomeNotifier extends Notifier<HomeConfig?> {
@override
HomeConfig? build() => null;
/// Загрузить текущий дом из SharedPreferences
Future<void> load() async {
final svc = ref.read(settingsServiceProvider);
state = await svc.getCurrentHome();
if (state != null) {
_initApi(state!);
}
}
/// Переключиться на другой дом
Future<void> switchTo(HomeConfig home) async {
final svc = ref.read(settingsServiceProvider);
await svc.setCurrentHomeId(home.id);
state = home;
_initApi(home);
// Перезагрузить группы для нового дома
await ref.read(groupsProvider.notifier).initAndRefresh();
}
/// Инициализировать API-клиент текущим домом
void _initApi(HomeConfig home) {
ref.read(apiProvider).init(home.url, home.apiKey);
}
}
// ─── Список домов ────────────────────────────────────────────
final homesProvider =
NotifierProvider<HomesNotifier, List<HomeConfig>>(() => HomesNotifier());
class HomesNotifier extends Notifier<List<HomeConfig>> {
@override
List<HomeConfig> build() => [];
Future<void> load() async {
state = await ref.read(settingsServiceProvider).getHomes();
}
Future<void> add(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
await load();
}
Future<void> remove(String id) async {
await ref.read(settingsServiceProvider).deleteHome(id);
await load();
}
Future<void> update(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
await load();
}
}
// ─── Геолокация пользователя ─────────────────────────────────
/// Состояние геолокации: позиция или причина отсутствия.
/// Запрашивается один раз, кешируется до перезапуска провайдера.
class UserLocation {
final Position? position;
final String? error; // null -- всё ок, иначе причина
const UserLocation({this.position, this.error});
bool get hasPosition => position != null;
/// Расстояние в км до точки. Возвращает null если нет позиции
/// или у цели нет координат.
double? distanceToKm(double? lat, double? lon) {
if (position == null || lat == null || lon == null) return null;
return calculateDistanceKm(
position!.latitude, position!.longitude, lat, lon,
);
}
}
final userLocationProvider =
NotifierProvider<UserLocationNotifier, UserLocation>(
() => UserLocationNotifier());
class UserLocationNotifier extends Notifier<UserLocation> {
StreamSubscription<Position>? _sub;
int _watchers = 0;
@override
UserLocation build() {
ref.onDispose(() {
_sub?.cancel();
_sub = null;
});
return const UserLocation();
}
/// Запросить текущую позицию. Первый вызов проверяет пермишены
/// и отдаёт lastKnown мгновенно (если есть).
Future<void> fetch() async {
if (state.hasPosition) return;
final err = await _ensurePermission();
if (err != null) {
state = UserLocation(error: err);
return;
}
try {
// getLastKnownPosition -- мгновенно, без GPS-фикса
final last = await Geolocator.getLastKnownPosition();
if (last != null) {
state = UserLocation(position: last);
return;
}
// Если lastKnown нет -- одноразовый запрос
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 10),
),
);
state = UserLocation(position: pos);
} catch (e) {
state = UserLocation(error: 'Ошибка: $e');
}
}
/// Начать непрерывное отслеживание. Вызывать из initState экрана.
/// Ref-counted: несколько экранов могут вызвать startWatching,
/// стрим остановится только когда все вызовут stopWatching.
Future<void> startWatching() async {
_watchers++;
if (_sub != null) return; // уже слушаем
final err = await _ensurePermission();
if (err != null) {
state = UserLocation(error: err);
return;
}
// Отдать lastKnown сразу, пока стрим ещё не дал первый event
if (!state.hasPosition) {
try {
final last = await Geolocator.getLastKnownPosition();
if (last != null) {
state = UserLocation(position: last);
}
} catch (_) {}
}
const settings = LocationSettings(
accuracy: LocationAccuracy.low,
distanceFilter: 20, // минимум 20м между событиями
);
_sub = Geolocator.getPositionStream(locationSettings: settings).listen(
(pos) => state = UserLocation(position: pos),
onError: (e) {
// Не затираем последнюю позицию -- просто логируем
debugPrint('Ошибка стрима геолокации: $e');
},
);
}
/// Остановить отслеживание. Вызывать из dispose экрана.
void stopWatching() {
_watchers = (_watchers - 1).clamp(0, 999);
if (_watchers == 0) {
_sub?.cancel();
_sub = null;
}
}
/// Проверить сервис и пермишены. Возвращает null если всё ок,
/// иначе строку с причиной ошибки.
Future<String?> _ensurePermission() async {
if (!await Geolocator.isLocationServiceEnabled()) {
return 'Геолокация выключена';
}
var perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied) {
return 'Нет разрешения';
}
if (perm == LocationPermission.deniedForever) {
return 'Разрешение запрещено навсегда';
}
return null;
}
}
// ─── Группы текущего дома ────────────────────────────────────
final groupsProvider =
NotifierProvider<GroupsNotifier, List<dynamic>>(() => GroupsNotifier());
class GroupsNotifier extends Notifier<List<dynamic>> {
IgnisApi get _api => ref.read(apiProvider);
Timer? _timer;
/// Блокировка обновления для группы после управления --
/// чтобы UI не прыгал пока лампа ещё не ответила.
final Map<String, DateTime> _lockUntil = {};
/// Debounce-таймеры для слайдеров (яркость, темп, цвет)
final Map<String, Timer> _debounceTimers = {};
@override
List<dynamic> build() {
ref.onDispose(() {
_timer?.cancel();
for (final t in _debounceTimers.values) {
t.cancel();
}
});
return [];
}
/// Инициализация: настроить API и начать периодический опрос
Future<void> initAndRefresh() async {
final home = ref.read(currentHomeProvider);
if (home == null) return;
_api.init(home.url, home.apiKey);
await refresh();
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
}
/// Полный опрос: загрузить группы + статус каждой
Future<void> refresh() async {
try {
final resGroups = await _api.getGroups();
List<dynamic> rawList = [];
if (resGroups.data is Map) {
// Бэкенд возвращает {id: GroupModel, ...} -- values уже содержат id внутри
rawList = resGroups.data.values.toList();
} else if (resGroups.data is List) {
rawList = resGroups.data;
}
final now = DateTime.now();
// Параллельный опрос статусов всех групп
final updatedList = await Future.wait(rawList.map((g) async {
final map = Map<String, dynamic>.from(g);
final id = map['id'].toString();
// Если группа залочена (недавно управляли) -- берём локальное состояние
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
final existing = state.cast<dynamic?>().firstWhere(
(old) => old?['id'].toString() == id,
orElse: () => null);
return existing ?? map;
}
try {
final resStatus = await _api.getGroupStatus(id);
// Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
if (resStatus.data != null &&
resStatus.data['results'] is List &&
resStatus.data['results'].isNotEmpty) {
// Берём первый результат без ошибки, или просто первый
final results = resStatus.data['results'] as List;
final validResult = results.firstWhere(
(r) => r['status'] != null && r['error'] == null,
orElse: () => results.first,
);
final data = validResult['status'];
if (data != null) {
map['last_state'] = {
'state': data['state'] == true,
'brightness': (data['dimming'] ?? 100).toInt(),
'temp': (data['temp'] ?? 4000).toInt(),
'r': (data['r'] ?? 0).toInt(),
'g': (data['g'] ?? 0).toInt(),
'b': (data['b'] ?? 0).toInt(),
'scene': data['scene'],
};
}
}
} catch (e) {
// При ошибке опроса -- сохраняем предыдущее состояние
final existing = state.cast<dynamic?>().firstWhere(
(s) => s?['id'].toString() == id,
orElse: () => null);
map['last_state'] = existing?['last_state'] ??
{'state': false, 'brightness': 100, 'temp': 4000};
}
return map;
}));
state = updatedList;
} catch (e) {
debugPrint("Ошибка глобального опроса: $e");
}
}
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
void _setLock(String id) =>
_lockUntil[id] = DateTime.now().add(const Duration(seconds: 5));
/// Обновить локальное состояние группы (оптимистичный UI)
void _updateLocal(String id, Map<String, dynamic> patch) {
state = [
for (final g in state)
if (g['id'].toString() == id)
{
...g,
'last_state': {
...Map<String, dynamic>.from(g['last_state'] ?? {}),
...patch
}
}
else
g
];
}
/// Debounce: отправить API-запрос с задержкой, но UI обновить сразу.
/// Если значение меняется быстро (слайдер тянут), отправляется только
/// последнее значение после паузы.
void _debouncedControl(String id, String key, Map<String, dynamic> localPatch,
Map<String, dynamic> apiParams) {
_setLock(id);
_updateLocal(id, localPatch);
final timerKey = '$id:$key';
_debounceTimers[timerKey]?.cancel();
_debounceTimers[timerKey] = Timer(
const Duration(milliseconds: 300),
() async {
try {
await _api.controlGroup(id, apiParams);
} catch (e) {
_lockUntil.remove(id);
refresh();
}
},
);
}
/// Включить/выключить группу (без debounce -- мгновенно)
Future<void> toggleGroup(String id, bool on) async {
_setLock(id);
_updateLocal(id, {'state': on});
try {
await _api.controlGroup(id, {'state': on});
} catch (e) {
_lockUntil.remove(id);
refresh();
}
}
/// Установить яркость (0-100) -- с debounce
void setBrightness(String id, int value) {
_debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value});
}
/// Установить цветовую температуру (2700-6500K) -- с debounce
void setTemperature(String id, int value) {
_debouncedControl(id, 'temp', {'temp': value}, {'temp': value});
}
/// Установить RGB-цвет -- с debounce
void setColor(String id, int r, int g, int b) {
_debouncedControl(id, 'color', {'r': r, 'g': g, 'b': b}, {'r': r, 'g': g, 'b': b});
}
/// Установить сцену (без debounce)
Future<void> setScene(String id, String scene) async {
_setLock(id);
_updateLocal(id, {'scene': scene});
try {
await _api.controlGroup(id, {'scene': scene});
} catch (e) {
_lockUntil.remove(id);
refresh();
}
}
/// Таймер: включить на 4 часа
Future<void> setTimer4h(String id) async {
await toggleGroup(id, true);
await _api.scheduleOnce({
'target_id': id,
'state': false,
'hours_from_now': 4,
'is_group': true,
});
}
}
// ─── Устройства (для создания групп) ─────────────────────────
final devicesProvider =
NotifierProvider<DevicesNotifier, List<dynamic>>(() => DevicesNotifier());
class DevicesNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
/// Загрузить список устройств из текущего дома
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getDevices();
if (res.data is List) {
state = res.data;
} else if (res.data is Map) {
state = res.data['data'] ?? res.data['devices'] ?? res.data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки устройств: $e");
}
}
}
// ─── Сцены ───────────────────────────────────────────────────
final scenesProvider =
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier());
class ScenesNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getScenes();
final data = res.data;
if (data is List) {
state = data;
} else if (data is Map) {
// Бэкенд может вернуть {scene_id: "Scene Name", ...}
// или {data: [...]} или {scenes: [...]}
if (data.containsKey('data') && data['data'] is List) {
state = data['data'];
} else if (data.containsKey('scenes') && data['scenes'] is List) {
state = data['scenes'];
} else {
// Map вида {id: name} -- преобразуем в список
state = data.entries
.map((e) => {'id': e.key.toString(), 'name': e.value.toString()})
.toList();
}
}
} catch (e) {
debugPrint("Ошибка загрузки сцен: $e");
}
}
}
// ─── Расписания ──────────────────────────────────────────────
final tasksProvider =
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier());
class TasksNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getTasks();
final data = res.data;
if (data is List) {
state = data;
} else if (data is Map) {
state = data['tasks'] ?? data['data'] ?? data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки расписаний: $e");
}
}
Future<void> cancel(String jobId) async {
try {
await ref.read(apiProvider).cancelTask(jobId);
await load();
} catch (e) {
debugPrint("Ошибка отмены задачи: $e");
}
}
/// Создать одноразовый таймер
Future<void> addOnce({
required String targetId,
required bool targetState,
int? hoursFromNow,
String? runAt,
bool isGroup = true,
}) async {
final params = <String, dynamic>{
'target_id': targetId,
'state': targetState,
'is_group': isGroup,
};
if (hoursFromNow != null) params['hours_from_now'] = hoursFromNow;
if (runAt != null) params['run_at'] = runAt;
await ref.read(apiProvider).scheduleOnce(params);
await load();
}
/// Создать cron-задачу
Future<void> addCron({
required String targetId,
required String hour,
required String minute,
String dayOfWeek = '*',
bool isGroup = true,
bool targetState = true,
}) async {
await ref.read(apiProvider).scheduleCron({
'target_id': targetId,
'hour': hour,
'minute': minute,
'day_of_week': dayOfWeek,
'is_group': isGroup,
'state': targetState,
});
await load();
}
}
// ─── Статистика ──────────────────────────────────────────────
final statsProvider =
NotifierProvider<StatsNotifier, Map<String, dynamic>>(() => StatsNotifier());
class StatsNotifier extends Notifier<Map<String, dynamic>> {
@override
Map<String, dynamic> build() => {};
Future<void> load({int days = 7}) async {
try {
final api = ref.read(apiProvider);
final res = await api.getStatsSummary(days: days);
final data = res.data;
if (data is Map) {
state = Map<String, dynamic>.from(data);
}
} catch (e) {
debugPrint("Ошибка загрузки статистики: $e");
}
}
}
// ─── Лог событий ─────────────────────────────────────────────
final eventLogProvider =
NotifierProvider<EventLogNotifier, List<dynamic>>(() => EventLogNotifier());
class EventLogNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load({int limit = 100}) async {
try {
final api = ref.read(apiProvider);
final res = await api.getStatsLog(limit: limit);
final data = res.data;
if (data is List) {
state = data;
} else if (data is Map) {
state = data['data'] ?? data['events'] ?? data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки логов: $e");
}
}
}
// ─── API-ключи ───────────────────────────────────────────────
final apiKeysProvider =
NotifierProvider<ApiKeysNotifier, List<dynamic>>(() => ApiKeysNotifier());
class ApiKeysNotifier extends Notifier<List<dynamic>> {
@override
List<dynamic> build() => [];
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getApiKeys();
final data = res.data;
if (data is List) {
state = data;
} else if (data is Map) {
state = data['data'] ?? data['keys'] ?? data.values.toList();
}
} catch (e) {
debugPrint("Ошибка загрузки API-ключей: $e");
}
}
Future<String?> create(String name, {bool isAdmin = false}) async {
try {
final api = ref.read(apiProvider);
final res = await api.createApiKey(name, isAdmin: isAdmin);
await load();
if (res.data is Map) {
return res.data['key']?.toString();
}
return null;
} catch (e) {
debugPrint("Ошибка создания ключа: $e");
return null;
}
}
Future<void> revoke(String key) async {
try {
await ref.read(apiProvider).revokeApiKey(key);
await load();
} catch (e) {
debugPrint("Ошибка отзыва ключа: $e");
}
}
Future<void> activate(String key) async {
try {
await ref.read(apiProvider).activateApiKey(key);
await load();
} catch (e) {
debugPrint("Ошибка активации ключа: $e");
}
}
}
// ─── Информация об авторизации ────────────────────────────────
final authInfoProvider =
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(() => AuthInfoNotifier());
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
@override
Map<String, dynamic>? build() => null;
Future<void> load() async {
try {
final api = ref.read(apiProvider);
final res = await api.getAuthMe();
if (res.data is Map) {
state = Map<String, dynamic>.from(res.data);
}
} catch (e) {
debugPrint("Ошибка загрузки auth/me: $e");
}
}
bool get isAdmin => state?['is_admin'] == true;
}
// ─── Утилита: расчёт расстояния (Haversine) ──────────────────
double calculateDistanceKm(
double lat1, double lon1, double lat2, double lon2) {
const earthRadiusKm = 6371.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 earthRadiusKm * c;
}
double _degToRad(double deg) => deg * (math.pi / 180);
/// Форматирование расстояния в человекочитаемый вид
String formatDistance(double km) {
if (km < 1.0) {
return '${(km * 1000).round()} м';
} else if (km < 10.0) {
return '${km.toStringAsFixed(1)} км';
} else {
return '${km.round()} км';
}
}