import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/error_message.dart'; import '../../../app/load_state.dart'; import '../../../models/ignis_device.dart'; import '../../../models/ignis_group.dart'; import '../../../models/ignis_scene.dart'; import '../../../services/api_client.dart'; import '../../homes/providers/homes_providers.dart'; import '../../shared/providers/core_providers.dart'; final groupsProvider = NotifierProvider>( () => GroupsNotifier(), ); enum GroupsLoadStatus { idle, loading, data, empty, error } class GroupsLoadState { final GroupsLoadStatus status; final String? errorMessage; const GroupsLoadState._(this.status, {this.errorMessage}); const GroupsLoadState.idle() : this._(GroupsLoadStatus.idle); const GroupsLoadState.loading() : this._(GroupsLoadStatus.loading); const GroupsLoadState.data() : this._(GroupsLoadStatus.data); const GroupsLoadState.empty() : this._(GroupsLoadStatus.empty); const GroupsLoadState.error(String message) : this._(GroupsLoadStatus.error, errorMessage: message); bool get isLoading => status == GroupsLoadStatus.loading; bool get hasError => status == GroupsLoadStatus.error; } final groupsLoadStateProvider = NotifierProvider( GroupsLoadStateNotifier.new, ); class GroupsLoadStateNotifier extends Notifier { @override GroupsLoadState build() => const GroupsLoadState.idle(); void setIdle() => state = const GroupsLoadState.idle(); void setLoading() => state = const GroupsLoadState.loading(); void setData(List groups) { state = groups.isEmpty ? const GroupsLoadState.empty() : const GroupsLoadState.data(); } void setError(Object error) => state = GroupsLoadState.error(describeLoadError(error)); } class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); Timer? _timer; bool _polling = false; bool _refreshInFlight = false; int? _refreshGeneration; int _pollingGeneration = 0; String? _pollingHomeId; final Map _lockUntil = {}; final Map _debounceTimers = {}; @override List build() { ref.onDispose(() { _stopPolling(resetStatus: false); for (final t in _debounceTimers.values) { t.cancel(); } }); return []; } Future startPolling() async { final home = ref.read(currentHomeProvider); if (home == null) { stopPolling(); return; } if (_polling && _pollingHomeId == home.id) { return; } _stopPolling(resetStatus: false); _polling = true; _pollingHomeId = home.id; final generation = ++_pollingGeneration; final apiKey = await ref .read(settingsServiceProvider) .requireHomeApiKey(home.id); _api.init(home.url, apiKey); await refresh(); if (!_isActiveGeneration(generation, pollingRequired: true)) return; _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); } void stopPolling() => _stopPolling(); void _stopPolling({bool resetStatus = true}) { _timer?.cancel(); _timer = null; _polling = false; _pollingHomeId = null; _pollingGeneration++; if (resetStatus) { ref.read(groupsLoadStateProvider.notifier).setIdle(); } } Future refresh() async { final generation = _pollingGeneration; final pollingRequired = _polling; if (_refreshInFlight && _refreshGeneration == generation) return; _refreshInFlight = true; _refreshGeneration = generation; if (state.isEmpty) { ref.read(groupsLoadStateProvider.notifier).setLoading(); } try { final resGroups = await _api.getGroups(); final rawList = IgnisGroup.listFromApi(resGroups.data); final now = DateTime.now(); final updatedList = await Future.wait( rawList.map((group) async { if (_lockUntil.containsKey(group.id) && _lockUntil[group.id]!.isAfter(now)) { final existing = state.firstWhere( (old) => old.id == group.id, orElse: () => group, ); return existing; } try { final resStatus = await _api.getGroupStatus(group.id); final groupState = IgnisGroupState.firstFromStatusResponse( resStatus.data, fallback: group.state, ); return group.copyWith(state: groupState); } catch (e) { final existing = state.firstWhere( (savedGroup) => savedGroup.id == group.id, orElse: () => group, ); return group.copyWith(state: existing.state); } }), ); if (!_isActiveGeneration(generation, pollingRequired: pollingRequired)) { return; } state = updatedList; ref.read(groupsLoadStateProvider.notifier).setData(updatedList); } catch (e) { if (_isActiveGeneration(generation, pollingRequired: pollingRequired)) { ref.read(groupsLoadStateProvider.notifier).setError(e); } } finally { if (_refreshGeneration == generation) { _refreshInFlight = false; _refreshGeneration = null; } } } bool _isActiveGeneration(int generation, {required bool pollingRequired}) => generation == _pollingGeneration && (!pollingRequired || _polling); void _setLock(String id) => _lockUntil[id] = DateTime.now().add(const Duration(seconds: 5)); void _updateLocal(String id, Map patch) { state = [ for (final g in state) if (g.id == id) g.copyWith(state: g.state.applyPatch(patch)) else g, ]; } void _debouncedControl( String id, String key, String action, Map localPatch, Map 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); await refresh(); ref.read(groupControlErrorProvider.notifier).report(id, action, e); } }, ); } Future toggleGroup(String id, bool on) async { _setLock(id); _updateLocal(id, {'state': on}); try { await _api.controlGroup(id, {'state': on}); } catch (e) { _lockUntil.remove(id); await refresh(); rethrow; } } void setBrightness(String id, int value) { _debouncedControl( id, 'brightness', 'яркость', {'brightness': value}, {'brightness': value}, ); } void setTemperature(String id, int value) { _debouncedControl( id, 'temp', 'температуру', {'temp': value}, {'temp': value}, ); } 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}, ); } Future setScene(String id, String scene) async { _setLock(id); _updateLocal(id, {'scene': scene}); try { await _api.controlGroup(id, {'scene': scene}); } catch (e) { _lockUntil.remove(id); await refresh(); rethrow; } } Future setTimer4h(String id) async { await toggleGroup(id, true); await _api.scheduleOnce({ 'target_id': id, 'state': false, 'hours_from_now': 4, 'is_group': true, }); } } class GroupControlError { final String groupId; final String action; final String message; final int sequence; const GroupControlError({ required this.groupId, required this.action, required this.message, required this.sequence, }); } final groupControlErrorProvider = NotifierProvider( () => GroupControlErrorNotifier(), ); class GroupControlErrorNotifier extends Notifier { int _sequence = 0; @override GroupControlError? build() => null; void report(String groupId, String action, Object error) { state = GroupControlError( groupId: groupId, action: action, message: describeLoadError(error), sequence: ++_sequence, ); } } final devicesProvider = NotifierProvider>>( () => DevicesNotifier(), ); class DevicesNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getDevices(); final devices = IgnisDevice.listFromApi(res.data); state = devices.isEmpty ? LoadState.empty(devices) : LoadState.data(devices); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } } final scenesProvider = NotifierProvider>>( () => ScenesNotifier(), ); class ScenesNotifier extends Notifier>> { @override LoadState> build() => const LoadState.idle([]); Future load() async { state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getScenes(); final scenes = IgnisScene.listFromApi(res.data); state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } } }