feat: type remote device models
This commit is contained in:
@@ -5,6 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.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 '../models/home_config.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/settings_service.dart';
|
||||
@@ -230,7 +233,7 @@ class UserLocationNotifier extends Notifier<UserLocation> {
|
||||
|
||||
// ─── Группы текущего дома ────────────────────────────────────
|
||||
|
||||
final groupsProvider = NotifierProvider<GroupsNotifier, List<dynamic>>(
|
||||
final groupsProvider = NotifierProvider<GroupsNotifier, List<IgnisGroup>>(
|
||||
() => GroupsNotifier(),
|
||||
);
|
||||
|
||||
@@ -271,7 +274,7 @@ class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
||||
|
||||
void setLoading() => state = const GroupsLoadState.loading();
|
||||
|
||||
void setData(List<dynamic> groups) {
|
||||
void setData(List<IgnisGroup> groups) {
|
||||
state = groups.isEmpty
|
||||
? const GroupsLoadState.empty()
|
||||
: const GroupsLoadState.data();
|
||||
@@ -281,7 +284,7 @@ class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
||||
state = GroupsLoadState.error(describeLoadError(error));
|
||||
}
|
||||
|
||||
class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
||||
IgnisApi get _api => ref.read(apiProvider);
|
||||
Timer? _timer;
|
||||
bool _polling = false;
|
||||
@@ -298,7 +301,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
final Map<String, Timer> _debounceTimers = {};
|
||||
|
||||
@override
|
||||
List<dynamic> build() {
|
||||
List<IgnisGroup> build() {
|
||||
ref.onDispose(() {
|
||||
_stopPolling(resetStatus: false);
|
||||
for (final t in _debounceTimers.values) {
|
||||
@@ -331,7 +334,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
_api.init(home.url, apiKey);
|
||||
|
||||
await refresh();
|
||||
if (!_isActiveGeneration(generation)) return;
|
||||
if (!_isActiveGeneration(generation, pollingRequired: true)) return;
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||||
}
|
||||
@@ -352,6 +355,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
/// Полный опрос: загрузить группы + статус каждой
|
||||
Future<void> refresh() async {
|
||||
final generation = _pollingGeneration;
|
||||
final pollingRequired = _polling;
|
||||
if (_refreshInFlight && _refreshGeneration == generation) return;
|
||||
|
||||
_refreshInFlight = true;
|
||||
@@ -362,77 +366,49 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
|
||||
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 rawList = IgnisGroup.listFromApi(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();
|
||||
|
||||
rawList.map((group) async {
|
||||
// Если группа залочена (недавно управляли) -- берём локальное состояние
|
||||
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
|
||||
if (_lockUntil.containsKey(group.id) &&
|
||||
_lockUntil[group.id]!.isAfter(now)) {
|
||||
final existing = state.firstWhere(
|
||||
(old) => old['id'].toString() == id,
|
||||
orElse: () => null,
|
||||
(old) => old.id == group.id,
|
||||
orElse: () => group,
|
||||
);
|
||||
return existing ?? map;
|
||||
return existing;
|
||||
}
|
||||
|
||||
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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
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(
|
||||
(s) => s['id'].toString() == id,
|
||||
orElse: () => null,
|
||||
(savedGroup) => savedGroup.id == group.id,
|
||||
orElse: () => group,
|
||||
);
|
||||
map['last_state'] =
|
||||
existing?['last_state'] ??
|
||||
{'state': false, 'brightness': 100, 'temp': 4000};
|
||||
return group.copyWith(state: existing.state);
|
||||
}
|
||||
return map;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!_isActiveGeneration(generation)) return;
|
||||
if (!_isActiveGeneration(generation, pollingRequired: pollingRequired)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = updatedList;
|
||||
ref.read(groupsLoadStateProvider.notifier).setData(updatedList);
|
||||
} catch (e) {
|
||||
if (_isActiveGeneration(generation)) {
|
||||
if (_isActiveGeneration(generation, pollingRequired: pollingRequired)) {
|
||||
ref.read(groupsLoadStateProvider.notifier).setError(e);
|
||||
}
|
||||
} finally {
|
||||
@@ -443,8 +419,8 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isActiveGeneration(int generation) =>
|
||||
generation == _pollingGeneration && _polling;
|
||||
bool _isActiveGeneration(int generation, {required bool pollingRequired}) =>
|
||||
generation == _pollingGeneration && (!pollingRequired || _polling);
|
||||
|
||||
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
|
||||
void _setLock(String id) =>
|
||||
@@ -454,16 +430,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
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,
|
||||
if (g.id == id) g.copyWith(state: g.state.applyPatch(patch)) else g,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -605,13 +572,13 @@ class GroupControlErrorNotifier extends Notifier<GroupControlError?> {
|
||||
// ─── Устройства (для создания групп) ─────────────────────────
|
||||
|
||||
final devicesProvider =
|
||||
NotifierProvider<DevicesNotifier, LoadState<List<dynamic>>>(
|
||||
NotifierProvider<DevicesNotifier, LoadState<List<IgnisDevice>>>(
|
||||
() => DevicesNotifier(),
|
||||
);
|
||||
|
||||
class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
class DevicesNotifier extends Notifier<LoadState<List<IgnisDevice>>> {
|
||||
@override
|
||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
||||
LoadState<List<IgnisDevice>> build() => const LoadState.idle(<IgnisDevice>[]);
|
||||
|
||||
/// Загрузить список устройств из текущего дома
|
||||
Future<void> load() async {
|
||||
@@ -619,19 +586,7 @@ class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
final res = await api.getDevices();
|
||||
final data = res.data;
|
||||
late final List<dynamic> devices;
|
||||
if (data is List) {
|
||||
devices = List<dynamic>.from(data);
|
||||
} else if (data is Map) {
|
||||
final value = data['data'] ?? data['devices'] ?? data.values.toList();
|
||||
if (value is! List) {
|
||||
throw FormatException('devices должен быть списком устройств');
|
||||
}
|
||||
devices = List<dynamic>.from(value);
|
||||
} else {
|
||||
throw FormatException('devices должен быть списком устройств');
|
||||
}
|
||||
final devices = IgnisDevice.listFromApi(res.data);
|
||||
|
||||
state = devices.isEmpty
|
||||
? LoadState.empty(devices)
|
||||
@@ -645,47 +600,20 @@ class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
// ─── Сцены ───────────────────────────────────────────────────
|
||||
|
||||
final scenesProvider =
|
||||
NotifierProvider<ScenesNotifier, LoadState<List<dynamic>>>(
|
||||
NotifierProvider<ScenesNotifier, LoadState<List<IgnisScene>>>(
|
||||
() => ScenesNotifier(),
|
||||
);
|
||||
|
||||
class ScenesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
||||
@override
|
||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
||||
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 data = res.data;
|
||||
late final List<dynamic> scenes;
|
||||
if (data is List) {
|
||||
scenes = List<dynamic>.from(data);
|
||||
} else if (data is Map) {
|
||||
// Бэкенд может вернуть {scene_id: "Scene Name", ...}
|
||||
// или {data: [...]} или {scenes: [...]}
|
||||
if (data.containsKey('data')) {
|
||||
final value = data['data'];
|
||||
if (value is! List) {
|
||||
throw FormatException('scenes.data должен быть списком сцен');
|
||||
}
|
||||
scenes = List<dynamic>.from(value);
|
||||
} else if (data.containsKey('scenes')) {
|
||||
final value = data['scenes'];
|
||||
if (value is! List) {
|
||||
throw FormatException('scenes должен быть списком сцен');
|
||||
}
|
||||
scenes = List<dynamic>.from(value);
|
||||
} else {
|
||||
// Map вида {id: name} -- преобразуем в список
|
||||
scenes = data.entries
|
||||
.map((e) => {'id': e.key.toString(), 'name': e.value.toString()})
|
||||
.toList();
|
||||
}
|
||||
} else {
|
||||
throw FormatException('scenes должен быть списком сцен');
|
||||
}
|
||||
final scenes = IgnisScene.listFromApi(res.data);
|
||||
|
||||
state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user