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

@@ -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 = <String>[?mac, ?ip];
return parts.isEmpty ? null : parts.join(' - ');
}
static IgnisDevice fromApi(Object? value, {String? fallbackId}) {
if (value is Map) {
final map = Map<String, dynamic>.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<IgnisDevice> 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<String, dynamic> map, List<String> 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<String> wrappers) {
if (data is List) {
return data.map((value) => _CollectionValue(value)).toList();
}
if (data is Map) {
final map = Map<String, dynamic>.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});
}

203
lib/models/ignis_group.dart Normal file
View File

@@ -0,0 +1,203 @@
class IgnisGroup {
final String id;
final String name;
final List<String> 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<String>? 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<String, dynamic>.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<IgnisGroup> 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<String, dynamic> 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<String, dynamic>.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<String, dynamic>.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<String, dynamic> map, List<String> 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<String> _stringList(Object? value) {
if (value is! List) return const [];
return value.map((item) => item.toString()).toList();
}
List<_CollectionValue> _collectionValues(Object? data, List<String> wrappers) {
if (data is List) {
return data.map((value) => _CollectionValue(value)).toList();
}
if (data is Map) {
final map = Map<String, dynamic>.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});
}

139
lib/models/ignis_scene.dart Normal file
View File

@@ -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<String, dynamic>.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<IgnisScene> 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<String, String> _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<String, dynamic> map, List<String> 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<String> wrappers) {
if (data is List) {
return data.map((value) => _CollectionValue(value)).toList();
}
if (data is Map) {
final map = Map<String, dynamic>.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});
}

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

View File

@@ -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<GroupEditScreen> {
_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<GroupEditScreen> {
}
Widget _buildDevices(
LoadState<List<dynamic>> devicesState,
List<dynamic> devices,
LoadState<List<IgnisDevice>> devicesState,
List<IgnisDevice> devices,
) {
if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) {
return const Center(
@@ -233,25 +233,24 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
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<GroupEditScreen> {
);
}
/// Извлечь 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<void> _save() async {
final id = _idCtrl.text.trim();
final name = _nameCtrl.text.trim();

View File

@@ -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<RemoteScreen> {
padding: const EdgeInsets.only(top: 8, bottom: 80),
itemCount: groups.length,
itemBuilder: (context, index) {
final g = Map<String, dynamic>.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<RemoteScreen> {
Future<bool> _confirmAndDeleteGroup(
BuildContext context,
Map<String, dynamic> g,
IgnisGroup g,
) async {
final messenger = ScaffoldMessenger.of(context);
final confirmed =
@@ -211,7 +212,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
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<RemoteScreen> {
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) {

View File

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

View File

@@ -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<String, dynamic> group;
final IgnisGroup group;
const GroupCard({super.key, required this.group});
@@ -29,14 +30,15 @@ class _GroupCardState extends ConsumerState<GroupCard> {
@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<GroupControlError?>(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(),
),

View File

@@ -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<Response> getGroups() async {
final error = groupsError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/devices/groups'),
data: <Object>[],
data: groupsData ?? <Object>[],
);
}
@override
Future<Response> 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: <Object>[]);
final container = containerWith(api);