feat: type remote device models
This commit is contained in:
98
lib/models/ignis_device.dart
Normal file
98
lib/models/ignis_device.dart
Normal 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
203
lib/models/ignis_group.dart
Normal 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
139
lib/models/ignis_scene.dart
Normal 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});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user