refactor: split providers into feature modules
This commit is contained in:
375
lib/features/remote/providers/remote_providers.dart
Normal file
375
lib/features/remote/providers/remote_providers.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
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, List<IgnisGroup>>(
|
||||
() => 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, GroupsLoadState>(
|
||||
GroupsLoadStateNotifier.new,
|
||||
);
|
||||
|
||||
class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
||||
@override
|
||||
GroupsLoadState build() => const GroupsLoadState.idle();
|
||||
|
||||
void setIdle() => state = const GroupsLoadState.idle();
|
||||
|
||||
void setLoading() => state = const GroupsLoadState.loading();
|
||||
|
||||
void setData(List<IgnisGroup> groups) {
|
||||
state = groups.isEmpty
|
||||
? const GroupsLoadState.empty()
|
||||
: const GroupsLoadState.data();
|
||||
}
|
||||
|
||||
void setError(Object error) =>
|
||||
state = GroupsLoadState.error(describeLoadError(error));
|
||||
}
|
||||
|
||||
class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
||||
IgnisApi get _api => ref.read(apiProvider);
|
||||
Timer? _timer;
|
||||
bool _polling = false;
|
||||
bool _refreshInFlight = false;
|
||||
int? _refreshGeneration;
|
||||
int _pollingGeneration = 0;
|
||||
String? _pollingHomeId;
|
||||
|
||||
final Map<String, DateTime> _lockUntil = {};
|
||||
final Map<String, Timer> _debounceTimers = {};
|
||||
|
||||
@override
|
||||
List<IgnisGroup> build() {
|
||||
ref.onDispose(() {
|
||||
_stopPolling(resetStatus: false);
|
||||
for (final t in _debounceTimers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<String, dynamic> 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<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);
|
||||
await refresh();
|
||||
ref.read(groupControlErrorProvider.notifier).report(id, action, e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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<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);
|
||||
await refresh();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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, GroupControlError?>(
|
||||
() => GroupControlErrorNotifier(),
|
||||
);
|
||||
|
||||
class GroupControlErrorNotifier extends Notifier<GroupControlError?> {
|
||||
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, LoadState<List<IgnisDevice>>>(
|
||||
() => DevicesNotifier(),
|
||||
);
|
||||
|
||||
class DevicesNotifier extends Notifier<LoadState<List<IgnisDevice>>> {
|
||||
@override
|
||||
LoadState<List<IgnisDevice>> build() => const LoadState.idle(<IgnisDevice>[]);
|
||||
|
||||
Future<void> 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, LoadState<List<IgnisScene>>>(
|
||||
() => ScenesNotifier(),
|
||||
);
|
||||
|
||||
class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
||||
@override
|
||||
LoadState<List<IgnisScene>> build() => const LoadState.idle(<IgnisScene>[]);
|
||||
|
||||
Future<void> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user