diff --git a/lib/models/ignis_device.dart b/lib/models/ignis_device.dart new file mode 100644 index 0000000..c3133d2 --- /dev/null +++ b/lib/models/ignis_device.dart @@ -0,0 +1,98 @@ +class IgnisDevice { + final String id; + final String? mac; + final String name; + final String? model; + final String? ip; + + const IgnisDevice({ + required this.id, + required this.name, + this.mac, + this.model, + this.ip, + }); + + String get groupMemberId => mac ?? id; + + String? get subtitle { + final parts = [?mac, ?ip]; + return parts.isEmpty ? null : parts.join(' - '); + } + + static IgnisDevice fromApi(Object? value, {String? fallbackId}) { + if (value is Map) { + final map = Map.from(value); + final id = + _stringValue(map, const ['id', 'mac', 'mac_address', 'device_id']) ?? + fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('device не содержит id/mac'); + } + + final mac = _stringValue(map, const ['mac', 'mac_address']); + final model = _stringValue(map, const ['model']); + final name = + _stringValue(map, const ['name', 'label', 'model']) ?? mac ?? id; + final ip = _stringValue(map, const ['ip', 'address']); + + return IgnisDevice(id: id, mac: mac, name: name, model: model, ip: ip); + } + + final id = value?.toString() ?? fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('device должен быть объектом или id'); + } + return IgnisDevice(id: id, mac: id, name: id); + } + + static List listFromApi(Object? data) { + final values = _collectionValues(data, const ['data', 'devices']); + return values + .map( + (value) => value.entryKey == null + ? IgnisDevice.fromApi(value.value) + : IgnisDevice.fromApi(value.value, fallbackId: value.entryKey), + ) + .toList(); + } +} + +String? _stringValue(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return null; +} + +List<_CollectionValue> _collectionValues(Object? data, List wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.from(data); + for (final wrapper in wrappers) { + final value = map[wrapper]; + if (value is List) { + return value.map((item) => _CollectionValue(item)).toList(); + } + } + + return map.entries + .map((entry) => _CollectionValue(entry.value, entryKey: entry.key)) + .toList(); + } + + throw const FormatException('ожидался список или объект'); +} + +class _CollectionValue { + final Object? value; + final String? entryKey; + + const _CollectionValue(this.value, {this.entryKey}); +} diff --git a/lib/models/ignis_group.dart b/lib/models/ignis_group.dart new file mode 100644 index 0000000..8a1285e --- /dev/null +++ b/lib/models/ignis_group.dart @@ -0,0 +1,203 @@ +class IgnisGroup { + final String id; + final String name; + final List macs; + final IgnisGroupState state; + + const IgnisGroup({ + required this.id, + required this.name, + this.macs = const [], + this.state = const IgnisGroupState(), + }); + + IgnisGroup copyWith({ + String? id, + String? name, + List? macs, + IgnisGroupState? state, + }) { + return IgnisGroup( + id: id ?? this.id, + name: name ?? this.name, + macs: macs ?? this.macs, + state: state ?? this.state, + ); + } + + static IgnisGroup fromApi(Object? value, {String? fallbackId}) { + if (value is! Map) { + final id = value?.toString() ?? fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('group должен быть объектом или id'); + } + return IgnisGroup(id: id, name: id); + } + + final map = Map.from(value); + final id = _stringValue(map, const ['id', 'group_id']) ?? fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('group не содержит id'); + } + + final name = _stringValue(map, const ['name', 'label']) ?? id; + final macs = _stringList( + map['macs'] ?? map['devices'] ?? map['device_ids'], + ); + final state = IgnisGroupState.fromApi( + map['last_state'] ?? map['state'] ?? map['status'], + ); + + return IgnisGroup(id: id, name: name, macs: macs, state: state); + } + + static List listFromApi(Object? data) { + final values = _collectionValues(data, const ['data', 'groups']); + return values.map((value) { + if (value.entryKey == null) return IgnisGroup.fromApi(value.value); + return IgnisGroup.fromApi(value.value, fallbackId: value.entryKey); + }).toList(); + } +} + +class IgnisGroupState { + final bool isOn; + final int brightness; + final int temp; + final int r; + final int g; + final int b; + final String? sceneId; + + const IgnisGroupState({ + this.isOn = false, + this.brightness = 100, + this.temp = 4000, + this.r = 255, + this.g = 200, + this.b = 100, + this.sceneId, + }); + + IgnisGroupState copyWith({ + bool? isOn, + int? brightness, + int? temp, + int? r, + int? g, + int? b, + String? sceneId, + }) { + return IgnisGroupState( + isOn: isOn ?? this.isOn, + brightness: brightness ?? this.brightness, + temp: temp ?? this.temp, + r: r ?? this.r, + g: g ?? this.g, + b: b ?? this.b, + sceneId: sceneId ?? this.sceneId, + ); + } + + IgnisGroupState applyPatch(Map patch) { + return copyWith( + isOn: patch.containsKey('state') ? patch['state'] == true : null, + brightness: _intValue(patch['brightness'] ?? patch['dimming']), + temp: _intValue(patch['temp']), + r: _intValue(patch['r']), + g: _intValue(patch['g']), + b: _intValue(patch['b']), + sceneId: patch['scene']?.toString(), + ); + } + + static IgnisGroupState fromApi(Object? value, {IgnisGroupState? fallback}) { + final base = fallback ?? const IgnisGroupState(); + if (value is! Map) return base; + + final map = Map.from(value); + return base.applyPatch({ + 'state': map['state'], + 'brightness': map['brightness'] ?? map['dimming'], + 'temp': map['temp'], + 'r': map['r'], + 'g': map['g'], + 'b': map['b'], + 'scene': map['scene'], + }); + } + + static IgnisGroupState? firstFromStatusResponse( + Object? data, { + IgnisGroupState? fallback, + }) { + if (data is! Map) return fallback; + final map = Map.from(data); + final results = map['results']; + if (results is! List || results.isEmpty) return fallback; + + Object? validResult; + for (final result in results) { + if (result is Map && + result['status'] != null && + result['error'] == null) { + validResult = result; + break; + } + } + validResult ??= results.first; + + if (validResult is! Map) return fallback; + return IgnisGroupState.fromApi(validResult['status'], fallback: fallback); + } +} + +String? _stringValue(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return null; +} + +int? _intValue(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); +} + +List _stringList(Object? value) { + if (value is! List) return const []; + return value.map((item) => item.toString()).toList(); +} + +List<_CollectionValue> _collectionValues(Object? data, List wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.from(data); + for (final wrapper in wrappers) { + final value = map[wrapper]; + if (value is List) { + return value.map((item) => _CollectionValue(item)).toList(); + } + } + + return map.entries + .map((entry) => _CollectionValue(entry.value, entryKey: entry.key)) + .toList(); + } + + throw const FormatException('ожидался список или объект'); +} + +class _CollectionValue { + final Object? value; + final String? entryKey; + + const _CollectionValue(this.value, {this.entryKey}); +} diff --git a/lib/models/ignis_scene.dart b/lib/models/ignis_scene.dart new file mode 100644 index 0000000..3bc0354 --- /dev/null +++ b/lib/models/ignis_scene.dart @@ -0,0 +1,139 @@ +class IgnisScene { + final String id; + final String displayName; + + const IgnisScene({required this.id, required this.displayName}); + + static IgnisScene fromApi(Object? value, {String? fallbackId}) { + if (value is Map) { + final map = Map.from(value); + final id = + _stringValue(map, const ['id', 'scene', 'scene_id', 'value']) ?? + fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('scene не содержит id'); + } + + final explicitName = _stringValue(map, const [ + 'name', + 'label', + 'display_name', + 'title', + ]); + return IgnisScene( + id: id, + displayName: explicitName ?? displayNameFor(id), + ); + } + + final id = value?.toString() ?? fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('scene должен быть объектом или id'); + } + return IgnisScene(id: id, displayName: displayNameFor(id)); + } + + static List listFromApi(Object? data) { + final values = _collectionValues(data, const ['data', 'scenes']); + return values.map((value) { + if (value.entryKey == null) { + return IgnisScene.fromApi(value.value); + } + + if (value.value is String || value.value is num) { + final name = value.value.toString(); + return IgnisScene( + id: value.entryKey!, + displayName: _looksLikeTechnicalId(name) + ? displayNameFor(name) + : name, + ); + } + + return IgnisScene.fromApi(value.value, fallbackId: value.entryKey); + }).toList(); + } + + static String displayNameFor(String id) { + final normalized = id.trim(); + final knownName = _wizSceneNames[normalized]; + if (knownName != null) return knownName; + return 'Сцена $normalized'; + } +} + +const Map _wizSceneNames = { + '1': 'Океан', + '2': 'Романтика', + '3': 'Закат', + '4': 'Вечеринка', + '5': 'Камин', + '6': 'Уют', + '7': 'Лес', + '8': 'Пастель', + '9': 'Пробуждение', + '10': 'Сон', + '11': 'Тёплый белый', + '12': 'Дневной свет', + '13': 'Холодный белый', + '14': 'Ночник', + '15': 'Фокус', + '16': 'Расслабление', + '17': 'Настоящие цвета', + '18': 'ТВ', + '19': 'Рост растений', + '20': 'Весна', + '21': 'Лето', + '22': 'Осень', + '23': 'Погружение', + '24': 'Джунгли', + '25': 'Мохито', + '26': 'Клуб', + '27': 'Рождество', + '28': 'Хэллоуин', + '29': 'Свеча', + '30': 'Золотистый белый', + '31': 'Пульс', + '32': 'Стимпанк', +}; + +String? _stringValue(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return null; +} + +bool _looksLikeTechnicalId(String value) => int.tryParse(value.trim()) != null; + +List<_CollectionValue> _collectionValues(Object? data, List wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.from(data); + for (final wrapper in wrappers) { + final value = map[wrapper]; + if (value is List) { + return value.map((item) => _CollectionValue(item)).toList(); + } + } + + return map.entries + .map((entry) => _CollectionValue(entry.value, entryKey: entry.key)) + .toList(); + } + + throw const FormatException('ожидался список или объект'); +} + +class _CollectionValue { + final Object? value; + final String? entryKey; + + const _CollectionValue(this.value, {this.entryKey}); +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index c9e0cd1..ab07996 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -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 { // ─── Группы текущего дома ──────────────────────────────────── -final groupsProvider = NotifierProvider>( +final groupsProvider = NotifierProvider>( () => GroupsNotifier(), ); @@ -271,7 +274,7 @@ class GroupsLoadStateNotifier extends Notifier { void setLoading() => state = const GroupsLoadState.loading(); - void setData(List groups) { + void setData(List groups) { state = groups.isEmpty ? const GroupsLoadState.empty() : const GroupsLoadState.data(); @@ -281,7 +284,7 @@ class GroupsLoadStateNotifier extends Notifier { state = GroupsLoadState.error(describeLoadError(error)); } -class GroupsNotifier extends Notifier> { +class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); Timer? _timer; bool _polling = false; @@ -298,7 +301,7 @@ class GroupsNotifier extends Notifier> { final Map _debounceTimers = {}; @override - List build() { + List build() { ref.onDispose(() { _stopPolling(resetStatus: false); for (final t in _debounceTimers.values) { @@ -331,7 +334,7 @@ class GroupsNotifier extends Notifier> { _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> { /// Полный опрос: загрузить группы + статус каждой Future refresh() async { final generation = _pollingGeneration; + final pollingRequired = _polling; if (_refreshInFlight && _refreshGeneration == generation) return; _refreshInFlight = true; @@ -362,77 +366,49 @@ class GroupsNotifier extends Notifier> { try { final resGroups = await _api.getGroups(); - List 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.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> { } } - 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> { void _updateLocal(String id, Map patch) { state = [ for (final g in state) - if (g['id'].toString() == id) - { - ...g, - 'last_state': { - ...Map.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 { // ─── Устройства (для создания групп) ───────────────────────── final devicesProvider = - NotifierProvider>>( + NotifierProvider>>( () => DevicesNotifier(), ); -class DevicesNotifier extends Notifier>> { +class DevicesNotifier extends Notifier>> { @override - LoadState> build() => const LoadState.idle([]); + LoadState> build() => const LoadState.idle([]); /// Загрузить список устройств из текущего дома Future load() async { @@ -619,19 +586,7 @@ class DevicesNotifier extends Notifier>> { try { final api = ref.read(apiProvider); final res = await api.getDevices(); - final data = res.data; - late final List devices; - if (data is List) { - devices = List.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.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>> { // ─── Сцены ─────────────────────────────────────────────────── final scenesProvider = - NotifierProvider>>( + NotifierProvider>>( () => ScenesNotifier(), ); -class ScenesNotifier extends Notifier>> { +class ScenesNotifier extends Notifier>> { @override - LoadState> build() => const LoadState.idle([]); + 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 data = res.data; - late final List scenes; - if (data is List) { - scenes = List.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.from(value); - } else if (data.containsKey('scenes')) { - final value = data['scenes']; - if (value is! List) { - throw FormatException('scenes должен быть списком сцен'); - } - scenes = List.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) { diff --git a/lib/screens/group_edit_screen.dart b/lib/screens/group_edit_screen.dart index 896d491..49e6a92 100644 --- a/lib/screens/group_edit_screen.dart +++ b/lib/screens/group_edit_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; import '../app/load_state.dart'; +import '../models/ignis_device.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; @@ -129,8 +130,7 @@ class _GroupEditScreenState extends ConsumerState { _selectedMacs.clear(); } else { for (final d in devices) { - final mac = _extractMac(d); - if (mac != null) _selectedMacs.add(mac); + _selectedMacs.add(d.groupMemberId); } } }); @@ -182,8 +182,8 @@ class _GroupEditScreenState extends ConsumerState { } Widget _buildDevices( - LoadState> devicesState, - List devices, + LoadState> devicesState, + List devices, ) { if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) { return const Center( @@ -233,25 +233,24 @@ class _GroupEditScreenState extends ConsumerState { final deviceIndex = index - statusHeaderCount; final d = devices[deviceIndex]; - final mac = _extractMac(d) ?? ''; - final name = _extractName(d); - final ip = _extractIp(d); - final selected = _selectedMacs.contains(mac); + final selected = _selectedMacs.contains(d.groupMemberId); return CheckboxListTile( value: selected, activeColor: Colors.deepOrange, - title: Text(name), - subtitle: Text( - '$mac${ip != null ? ' - $ip' : ''}', - style: const TextStyle(fontSize: 11, color: Colors.white38), - ), + title: Text(d.name), + subtitle: d.subtitle == null + ? null + : Text( + d.subtitle!, + style: const TextStyle(fontSize: 11, color: Colors.white38), + ), onChanged: (v) { setState(() { if (v == true) { - _selectedMacs.add(mac); + _selectedMacs.add(d.groupMemberId); } else { - _selectedMacs.remove(mac); + _selectedMacs.remove(d.groupMemberId); } }); }, @@ -260,32 +259,6 @@ class _GroupEditScreenState extends ConsumerState { ); } - /// Извлечь MAC-адрес из объекта устройства - String? _extractMac(dynamic device) { - if (device is Map) { - return (device['mac'] ?? device['id'] ?? device['mac_address']) - ?.toString(); - } - return device?.toString(); - } - - /// Извлечь имя устройства - String _extractName(dynamic device) { - if (device is Map) { - return (device['name'] ?? device['model'] ?? device['mac'] ?? 'Лампа') - .toString(); - } - return device?.toString() ?? 'Лампа'; - } - - /// Извлечь IP-адрес - String? _extractIp(dynamic device) { - if (device is Map) { - return (device['ip'] ?? device['address'])?.toString(); - } - return null; - } - Future _save() async { final id = _idCtrl.text.trim(); final name = _nameCtrl.text.trim(); diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 47313a0..95de4a0 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; +import '../models/ignis_group.dart'; import '../providers/providers.dart'; import '../widgets/group_card.dart'; import 'homes_screen.dart'; @@ -175,9 +176,9 @@ class _RemoteScreenState extends ConsumerState { padding: const EdgeInsets.only(top: 8, bottom: 80), itemCount: groups.length, itemBuilder: (context, index) { - final g = Map.from(groups[index]); + final g = groups[index]; return Dismissible( - key: Key(g['id'].toString()), + key: Key(g.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, @@ -203,7 +204,7 @@ class _RemoteScreenState extends ConsumerState { Future _confirmAndDeleteGroup( BuildContext context, - Map g, + IgnisGroup g, ) async { final messenger = ScaffoldMessenger.of(context); final confirmed = @@ -211,7 +212,7 @@ class _RemoteScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Удалить группу?'), - content: Text('Удалить "${g['name']}"?'), + content: Text('Удалить "${g.name}"?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -232,7 +233,7 @@ class _RemoteScreenState extends ConsumerState { if (!confirmed) return false; try { - await ref.read(apiProvider).deleteGroup(g['id'].toString()); + await ref.read(apiProvider).deleteGroup(g.id); await ref.read(groupsProvider.notifier).refresh(); return true; } catch (e) { diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index 0c0ac7b..be79279 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -296,8 +296,8 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { initialValue: _selectedGroupId, decoration: const InputDecoration(labelText: 'Группа'), items: groups.map((g) { - final id = g['id'].toString(); - final name = g['name']?.toString() ?? id; + final id = g.id; + final name = g.name; return DropdownMenuItem(value: id, child: Text(name)); }).toList(), onChanged: (v) => setState(() => _selectedGroupId = v), diff --git a/lib/widgets/group_card.dart b/lib/widgets/group_card.dart index fad774b..0c83b83 100644 --- a/lib/widgets/group_card.dart +++ b/lib/widgets/group_card.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; +import '../models/ignis_group.dart'; import '../providers/providers.dart'; import 'color_picker.dart'; /// Карточка одной группы ламп с управлением: /// вкл/выкл, яркость, температура, цвет, сцена. class GroupCard extends ConsumerStatefulWidget { - final Map group; + final IgnisGroup group; const GroupCard({super.key, required this.group}); @@ -29,14 +30,15 @@ class _GroupCardState extends ConsumerState { @override Widget build(BuildContext context) { final g = widget.group; - final id = g['id'].toString(); - final name = g['name'] ?? 'Без имени'; - final bool isOn = g['last_state']?['state'] ?? false; - final int bri = g['last_state']?['brightness'] ?? 100; - final int temp = g['last_state']?['temp'] ?? 4000; - final int r = g['last_state']?['r'] ?? 255; - final int gVal = g['last_state']?['g'] ?? 200; - final int b = g['last_state']?['b'] ?? 100; + final id = g.id; + final name = g.name; + final state = g.state; + final bool isOn = state.isOn; + final int bri = state.brightness; + final int temp = state.temp; + final int r = state.r; + final int gVal = state.g; + final int b = state.b; ref.listen(groupControlErrorProvider, (previous, next) { if (next == null || next.groupId != id) return; @@ -454,26 +456,13 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> { spacing: 8, runSpacing: 4, children: scenes.map((scene) { - String sceneName; - String sceneId; - - if (scene is String) { - sceneName = scene; - sceneId = scene; - } else if (scene is Map) { - sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()) - .toString(); - sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()) - .toString(); - } else { - sceneName = scene.toString(); - sceneId = scene.toString(); - } - return ActionChip( - label: Text(sceneName, style: const TextStyle(fontSize: 12)), + label: Text( + scene.displayName, + style: const TextStyle(fontSize: 12), + ), backgroundColor: Colors.white10, - onPressed: () => _setScene(sceneId), + onPressed: () => _setScene(scene.id), ); }).toList(), ), diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index d3c9920..5848570 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -2,10 +2,13 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ignis_app/app/load_state.dart'; +import 'package:ignis_app/models/ignis_group.dart'; import 'package:ignis_app/providers/providers.dart'; import 'package:ignis_app/services/api_client.dart'; class FakeIgnisApi extends IgnisApi { + Object? groupsData; + Object? groupStatusData; Object? devicesData; Object? scenesData; Object? tasksData; @@ -18,6 +21,8 @@ class FakeIgnisApi extends IgnisApi { Object? statsError; Object? eventLogError; Object? apiKeysError; + Object? groupsError; + Object? groupStatusError; Object? controlGroupError; Object? cancelTaskError; Object? revokeApiKeyError; @@ -29,6 +34,8 @@ class FakeIgnisApi extends IgnisApi { String? revokedApiKey; FakeIgnisApi({ + this.groupsData, + this.groupStatusData, this.devicesData, this.scenesData, this.tasksData, @@ -59,9 +66,29 @@ class FakeIgnisApi extends IgnisApi { @override Future getGroups() async { + final error = groupsError; + if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices/groups'), - data: [], + data: groupsData ?? [], + ); + } + + @override + Future getGroupStatus(String id) async { + final error = groupStatusError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/control/group/$id/status'), + data: + groupStatusData ?? + { + 'results': [ + { + 'status': {'state': false, 'dimming': 100, 'temp': 4000}, + }, + ], + }, ); } @@ -169,6 +196,20 @@ void main() { expect(api.requestedDays, 14); }); + test('group status parser maps backend status shape', () { + final state = IgnisGroupState.firstFromStatusResponse({ + 'results': [ + { + 'status': {'state': true, 'dimming': 42, 'temp': 3000}, + }, + ], + }); + + expect(state?.isOn, isTrue); + expect(state?.brightness, 42); + expect(state?.temp, 3000); + }); + test('devices load exposes data state', () async { final api = FakeIgnisApi( devicesData: { @@ -184,6 +225,8 @@ void main() { final state = container.read(devicesProvider); expect(state.status, LoadStatus.data); expect(state.data, hasLength(1)); + expect(state.data.single.groupMemberId, 'AA:BB'); + expect(state.data.single.name, 'Kitchen bulb'); }); test('devices load exposes empty state', () async { @@ -309,7 +352,25 @@ void main() { final state = container.read(scenesProvider); expect(state.status, LoadStatus.data); expect(state.data, hasLength(2)); - expect(state.data.first, containsPair('id', 'party')); + expect(state.data.first.id, 'party'); + expect(state.data.first.displayName, 'Party'); + }); + + test('scenes load maps numeric scene ids to display names', () async { + final api = FakeIgnisApi( + scenesData: { + 'scenes': ['1', '4'], + }, + ); + final container = containerWith(api); + + await container.read(scenesProvider.notifier).load(); + + final state = container.read(scenesProvider); + expect(state.status, LoadStatus.data); + expect(state.data.first.id, '1'); + expect(state.data.first.displayName, 'Океан'); + expect(state.data.last.displayName, 'Вечеринка'); }); test('scenes load exposes empty state', () async { @@ -418,6 +479,44 @@ void main() { expect(state.errorMessage, contains('Backend недоступен')); }); + test('groups refresh maps groups and status to typed state', () async { + final api = FakeIgnisApi( + groupsData: { + 'kitchen': { + 'name': 'Kitchen', + 'macs': ['AA:BB'], + }, + }, + groupStatusData: { + 'results': [ + { + 'status': { + 'state': true, + 'dimming': 42, + 'temp': 3000, + 'r': 1, + 'g': 2, + 'b': 3, + 'scene': '4', + }, + }, + ], + }, + ); + final container = containerWith(api); + + await container.read(groupsProvider.notifier).refresh(); + + final groups = container.read(groupsProvider); + expect(groups, hasLength(1)); + expect(groups.single.id, 'kitchen'); + expect(groups.single.name, 'Kitchen'); + expect(groups.single.macs, ['AA:BB']); + expect(groups.single.state.isOn, isTrue); + expect(groups.single.state.brightness, 42); + expect(groups.single.state.sceneId, '4'); + }); + test('task cancel error is not swallowed', () async { final api = FakeIgnisApi(tasksData: []); final container = containerWith(api);