feat: type remote device models

This commit is contained in:
Artem Kokos
2026-04-23 20:44:51 +07:00
parent 736a61d54b
commit fa403bfcce
9 changed files with 619 additions and 189 deletions

View File

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