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 'package:geolocator/geolocator.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.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 '../models/home_config.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/settings_service.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(),
|
() => GroupsNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -271,7 +274,7 @@ class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
|||||||
|
|
||||||
void setLoading() => state = const GroupsLoadState.loading();
|
void setLoading() => state = const GroupsLoadState.loading();
|
||||||
|
|
||||||
void setData(List<dynamic> groups) {
|
void setData(List<IgnisGroup> groups) {
|
||||||
state = groups.isEmpty
|
state = groups.isEmpty
|
||||||
? const GroupsLoadState.empty()
|
? const GroupsLoadState.empty()
|
||||||
: const GroupsLoadState.data();
|
: const GroupsLoadState.data();
|
||||||
@@ -281,7 +284,7 @@ class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
|
|||||||
state = GroupsLoadState.error(describeLoadError(error));
|
state = GroupsLoadState.error(describeLoadError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
class GroupsNotifier extends Notifier<List<dynamic>> {
|
class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
||||||
IgnisApi get _api => ref.read(apiProvider);
|
IgnisApi get _api => ref.read(apiProvider);
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _polling = false;
|
bool _polling = false;
|
||||||
@@ -298,7 +301,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
final Map<String, Timer> _debounceTimers = {};
|
final Map<String, Timer> _debounceTimers = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<dynamic> build() {
|
List<IgnisGroup> build() {
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_stopPolling(resetStatus: false);
|
_stopPolling(resetStatus: false);
|
||||||
for (final t in _debounceTimers.values) {
|
for (final t in _debounceTimers.values) {
|
||||||
@@ -331,7 +334,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
_api.init(home.url, apiKey);
|
_api.init(home.url, apiKey);
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
if (!_isActiveGeneration(generation)) return;
|
if (!_isActiveGeneration(generation, pollingRequired: true)) return;
|
||||||
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||||||
}
|
}
|
||||||
@@ -352,6 +355,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
/// Полный опрос: загрузить группы + статус каждой
|
/// Полный опрос: загрузить группы + статус каждой
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final generation = _pollingGeneration;
|
final generation = _pollingGeneration;
|
||||||
|
final pollingRequired = _polling;
|
||||||
if (_refreshInFlight && _refreshGeneration == generation) return;
|
if (_refreshInFlight && _refreshGeneration == generation) return;
|
||||||
|
|
||||||
_refreshInFlight = true;
|
_refreshInFlight = true;
|
||||||
@@ -362,77 +366,49 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final resGroups = await _api.getGroups();
|
final resGroups = await _api.getGroups();
|
||||||
List<dynamic> rawList = [];
|
final rawList = IgnisGroup.listFromApi(resGroups.data);
|
||||||
|
|
||||||
if (resGroups.data is Map) {
|
|
||||||
// Бэкенд возвращает {id: GroupModel, ...} -- values уже содержат id внутри
|
|
||||||
rawList = resGroups.data.values.toList();
|
|
||||||
} else if (resGroups.data is List) {
|
|
||||||
rawList = resGroups.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
// Параллельный опрос статусов всех групп
|
// Параллельный опрос статусов всех групп
|
||||||
final updatedList = await Future.wait(
|
final updatedList = await Future.wait(
|
||||||
rawList.map((g) async {
|
rawList.map((group) async {
|
||||||
final map = Map<String, dynamic>.from(g);
|
|
||||||
final id = map['id'].toString();
|
|
||||||
|
|
||||||
// Если группа залочена (недавно управляли) -- берём локальное состояние
|
// Если группа залочена (недавно управляли) -- берём локальное состояние
|
||||||
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
|
if (_lockUntil.containsKey(group.id) &&
|
||||||
|
_lockUntil[group.id]!.isAfter(now)) {
|
||||||
final existing = state.firstWhere(
|
final existing = state.firstWhere(
|
||||||
(old) => old['id'].toString() == id,
|
(old) => old.id == group.id,
|
||||||
orElse: () => null,
|
orElse: () => group,
|
||||||
);
|
);
|
||||||
return existing ?? map;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final resStatus = await _api.getGroupStatus(id);
|
final resStatus = await _api.getGroupStatus(group.id);
|
||||||
// Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
|
final groupState = IgnisGroupState.firstFromStatusResponse(
|
||||||
if (resStatus.data != null &&
|
resStatus.data,
|
||||||
resStatus.data['results'] is List &&
|
fallback: group.state,
|
||||||
resStatus.data['results'].isNotEmpty) {
|
);
|
||||||
// Берём первый результат без ошибки, или просто первый
|
return group.copyWith(state: groupState);
|
||||||
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'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// При ошибке опроса -- сохраняем предыдущее состояние
|
// При ошибке опроса -- сохраняем предыдущее состояние
|
||||||
final existing = state.firstWhere(
|
final existing = state.firstWhere(
|
||||||
(s) => s['id'].toString() == id,
|
(savedGroup) => savedGroup.id == group.id,
|
||||||
orElse: () => null,
|
orElse: () => group,
|
||||||
);
|
);
|
||||||
map['last_state'] =
|
return group.copyWith(state: existing.state);
|
||||||
existing?['last_state'] ??
|
|
||||||
{'state': false, 'brightness': 100, 'temp': 4000};
|
|
||||||
}
|
}
|
||||||
return map;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!_isActiveGeneration(generation)) return;
|
if (!_isActiveGeneration(generation, pollingRequired: pollingRequired)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = updatedList;
|
state = updatedList;
|
||||||
ref.read(groupsLoadStateProvider.notifier).setData(updatedList);
|
ref.read(groupsLoadStateProvider.notifier).setData(updatedList);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_isActiveGeneration(generation)) {
|
if (_isActiveGeneration(generation, pollingRequired: pollingRequired)) {
|
||||||
ref.read(groupsLoadStateProvider.notifier).setError(e);
|
ref.read(groupsLoadStateProvider.notifier).setError(e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -443,8 +419,8 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isActiveGeneration(int generation) =>
|
bool _isActiveGeneration(int generation, {required bool pollingRequired}) =>
|
||||||
generation == _pollingGeneration && _polling;
|
generation == _pollingGeneration && (!pollingRequired || _polling);
|
||||||
|
|
||||||
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
|
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
|
||||||
void _setLock(String id) =>
|
void _setLock(String id) =>
|
||||||
@@ -454,16 +430,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
void _updateLocal(String id, Map<String, dynamic> patch) {
|
void _updateLocal(String id, Map<String, dynamic> patch) {
|
||||||
state = [
|
state = [
|
||||||
for (final g in state)
|
for (final g in state)
|
||||||
if (g['id'].toString() == id)
|
if (g.id == id) g.copyWith(state: g.state.applyPatch(patch)) else g,
|
||||||
{
|
|
||||||
...g,
|
|
||||||
'last_state': {
|
|
||||||
...Map<String, dynamic>.from(g['last_state'] ?? {}),
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
else
|
|
||||||
g,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,13 +572,13 @@ class GroupControlErrorNotifier extends Notifier<GroupControlError?> {
|
|||||||
// ─── Устройства (для создания групп) ─────────────────────────
|
// ─── Устройства (для создания групп) ─────────────────────────
|
||||||
|
|
||||||
final devicesProvider =
|
final devicesProvider =
|
||||||
NotifierProvider<DevicesNotifier, LoadState<List<dynamic>>>(
|
NotifierProvider<DevicesNotifier, LoadState<List<IgnisDevice>>>(
|
||||||
() => DevicesNotifier(),
|
() => DevicesNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
class DevicesNotifier extends Notifier<LoadState<List<IgnisDevice>>> {
|
||||||
@override
|
@override
|
||||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
LoadState<List<IgnisDevice>> build() => const LoadState.idle(<IgnisDevice>[]);
|
||||||
|
|
||||||
/// Загрузить список устройств из текущего дома
|
/// Загрузить список устройств из текущего дома
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
@@ -619,19 +586,7 @@ class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getDevices();
|
final res = await api.getDevices();
|
||||||
final data = res.data;
|
final devices = IgnisDevice.listFromApi(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 должен быть списком устройств');
|
|
||||||
}
|
|
||||||
|
|
||||||
state = devices.isEmpty
|
state = devices.isEmpty
|
||||||
? LoadState.empty(devices)
|
? LoadState.empty(devices)
|
||||||
@@ -645,47 +600,20 @@ class DevicesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
|||||||
// ─── Сцены ───────────────────────────────────────────────────
|
// ─── Сцены ───────────────────────────────────────────────────
|
||||||
|
|
||||||
final scenesProvider =
|
final scenesProvider =
|
||||||
NotifierProvider<ScenesNotifier, LoadState<List<dynamic>>>(
|
NotifierProvider<ScenesNotifier, LoadState<List<IgnisScene>>>(
|
||||||
() => ScenesNotifier(),
|
() => ScenesNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ScenesNotifier extends Notifier<LoadState<List<dynamic>>> {
|
class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
||||||
@override
|
@override
|
||||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
LoadState<List<IgnisScene>> build() => const LoadState.idle(<IgnisScene>[]);
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
state = LoadState.loading(state.data);
|
state = LoadState.loading(state.data);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getScenes();
|
final res = await api.getScenes();
|
||||||
final data = res.data;
|
final scenes = IgnisScene.listFromApi(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 должен быть списком сцен');
|
|
||||||
}
|
|
||||||
|
|
||||||
state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes);
|
state = scenes.isEmpty ? LoadState.empty(scenes) : LoadState.data(scenes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../models/ignis_device.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
@@ -129,8 +130,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
_selectedMacs.clear();
|
_selectedMacs.clear();
|
||||||
} else {
|
} else {
|
||||||
for (final d in devices) {
|
for (final d in devices) {
|
||||||
final mac = _extractMac(d);
|
_selectedMacs.add(d.groupMemberId);
|
||||||
if (mac != null) _selectedMacs.add(mac);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -182,8 +182,8 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDevices(
|
Widget _buildDevices(
|
||||||
LoadState<List<dynamic>> devicesState,
|
LoadState<List<IgnisDevice>> devicesState,
|
||||||
List<dynamic> devices,
|
List<IgnisDevice> devices,
|
||||||
) {
|
) {
|
||||||
if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) {
|
if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -233,25 +233,24 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
|
|
||||||
final deviceIndex = index - statusHeaderCount;
|
final deviceIndex = index - statusHeaderCount;
|
||||||
final d = devices[deviceIndex];
|
final d = devices[deviceIndex];
|
||||||
final mac = _extractMac(d) ?? '';
|
final selected = _selectedMacs.contains(d.groupMemberId);
|
||||||
final name = _extractName(d);
|
|
||||||
final ip = _extractIp(d);
|
|
||||||
final selected = _selectedMacs.contains(mac);
|
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
value: selected,
|
value: selected,
|
||||||
activeColor: Colors.deepOrange,
|
activeColor: Colors.deepOrange,
|
||||||
title: Text(name),
|
title: Text(d.name),
|
||||||
subtitle: Text(
|
subtitle: d.subtitle == null
|
||||||
'$mac${ip != null ? ' - $ip' : ''}',
|
? null
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white38),
|
: Text(
|
||||||
),
|
d.subtitle!,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.white38),
|
||||||
|
),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (v == true) {
|
if (v == true) {
|
||||||
_selectedMacs.add(mac);
|
_selectedMacs.add(d.groupMemberId);
|
||||||
} else {
|
} 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 {
|
Future<void> _save() async {
|
||||||
final id = _idCtrl.text.trim();
|
final id = _idCtrl.text.trim();
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
|
import '../models/ignis_group.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/group_card.dart';
|
import '../widgets/group_card.dart';
|
||||||
import 'homes_screen.dart';
|
import 'homes_screen.dart';
|
||||||
@@ -175,9 +176,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
padding: const EdgeInsets.only(top: 8, bottom: 80),
|
padding: const EdgeInsets.only(top: 8, bottom: 80),
|
||||||
itemCount: groups.length,
|
itemCount: groups.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final g = Map<String, dynamic>.from(groups[index]);
|
final g = groups[index];
|
||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: Key(g['id'].toString()),
|
key: Key(g.id),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
@@ -203,7 +204,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
|
|
||||||
Future<bool> _confirmAndDeleteGroup(
|
Future<bool> _confirmAndDeleteGroup(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Map<String, dynamic> g,
|
IgnisGroup g,
|
||||||
) async {
|
) async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final confirmed =
|
final confirmed =
|
||||||
@@ -211,7 +212,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Удалить группу?'),
|
title: const Text('Удалить группу?'),
|
||||||
content: Text('Удалить "${g['name']}"?'),
|
content: Text('Удалить "${g.name}"?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
@@ -232,7 +233,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(apiProvider).deleteGroup(g['id'].toString());
|
await ref.read(apiProvider).deleteGroup(g.id);
|
||||||
await ref.read(groupsProvider.notifier).refresh();
|
await ref.read(groupsProvider.notifier).refresh();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
|||||||
initialValue: _selectedGroupId,
|
initialValue: _selectedGroupId,
|
||||||
decoration: const InputDecoration(labelText: 'Группа'),
|
decoration: const InputDecoration(labelText: 'Группа'),
|
||||||
items: groups.map((g) {
|
items: groups.map((g) {
|
||||||
final id = g['id'].toString();
|
final id = g.id;
|
||||||
final name = g['name']?.toString() ?? id;
|
final name = g.name;
|
||||||
return DropdownMenuItem(value: id, child: Text(name));
|
return DropdownMenuItem(value: id, child: Text(name));
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (v) => setState(() => _selectedGroupId = v),
|
onChanged: (v) => setState(() => _selectedGroupId = v),
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
|
import '../models/ignis_group.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import 'color_picker.dart';
|
import 'color_picker.dart';
|
||||||
|
|
||||||
/// Карточка одной группы ламп с управлением:
|
/// Карточка одной группы ламп с управлением:
|
||||||
/// вкл/выкл, яркость, температура, цвет, сцена.
|
/// вкл/выкл, яркость, температура, цвет, сцена.
|
||||||
class GroupCard extends ConsumerStatefulWidget {
|
class GroupCard extends ConsumerStatefulWidget {
|
||||||
final Map<String, dynamic> group;
|
final IgnisGroup group;
|
||||||
|
|
||||||
const GroupCard({super.key, required this.group});
|
const GroupCard({super.key, required this.group});
|
||||||
|
|
||||||
@@ -29,14 +30,15 @@ class _GroupCardState extends ConsumerState<GroupCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final g = widget.group;
|
final g = widget.group;
|
||||||
final id = g['id'].toString();
|
final id = g.id;
|
||||||
final name = g['name'] ?? 'Без имени';
|
final name = g.name;
|
||||||
final bool isOn = g['last_state']?['state'] ?? false;
|
final state = g.state;
|
||||||
final int bri = g['last_state']?['brightness'] ?? 100;
|
final bool isOn = state.isOn;
|
||||||
final int temp = g['last_state']?['temp'] ?? 4000;
|
final int bri = state.brightness;
|
||||||
final int r = g['last_state']?['r'] ?? 255;
|
final int temp = state.temp;
|
||||||
final int gVal = g['last_state']?['g'] ?? 200;
|
final int r = state.r;
|
||||||
final int b = g['last_state']?['b'] ?? 100;
|
final int gVal = state.g;
|
||||||
|
final int b = state.b;
|
||||||
|
|
||||||
ref.listen<GroupControlError?>(groupControlErrorProvider, (previous, next) {
|
ref.listen<GroupControlError?>(groupControlErrorProvider, (previous, next) {
|
||||||
if (next == null || next.groupId != id) return;
|
if (next == null || next.groupId != id) return;
|
||||||
@@ -454,26 +456,13 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: scenes.map((scene) {
|
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(
|
return ActionChip(
|
||||||
label: Text(sceneName, style: const TextStyle(fontSize: 12)),
|
label: Text(
|
||||||
|
scene.displayName,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
backgroundColor: Colors.white10,
|
backgroundColor: Colors.white10,
|
||||||
onPressed: () => _setScene(sceneId),
|
onPressed: () => _setScene(scene.id),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ignis_app/app/load_state.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/providers/providers.dart';
|
||||||
import 'package:ignis_app/services/api_client.dart';
|
import 'package:ignis_app/services/api_client.dart';
|
||||||
|
|
||||||
class FakeIgnisApi extends IgnisApi {
|
class FakeIgnisApi extends IgnisApi {
|
||||||
|
Object? groupsData;
|
||||||
|
Object? groupStatusData;
|
||||||
Object? devicesData;
|
Object? devicesData;
|
||||||
Object? scenesData;
|
Object? scenesData;
|
||||||
Object? tasksData;
|
Object? tasksData;
|
||||||
@@ -18,6 +21,8 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
Object? statsError;
|
Object? statsError;
|
||||||
Object? eventLogError;
|
Object? eventLogError;
|
||||||
Object? apiKeysError;
|
Object? apiKeysError;
|
||||||
|
Object? groupsError;
|
||||||
|
Object? groupStatusError;
|
||||||
Object? controlGroupError;
|
Object? controlGroupError;
|
||||||
Object? cancelTaskError;
|
Object? cancelTaskError;
|
||||||
Object? revokeApiKeyError;
|
Object? revokeApiKeyError;
|
||||||
@@ -29,6 +34,8 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
String? revokedApiKey;
|
String? revokedApiKey;
|
||||||
|
|
||||||
FakeIgnisApi({
|
FakeIgnisApi({
|
||||||
|
this.groupsData,
|
||||||
|
this.groupStatusData,
|
||||||
this.devicesData,
|
this.devicesData,
|
||||||
this.scenesData,
|
this.scenesData,
|
||||||
this.tasksData,
|
this.tasksData,
|
||||||
@@ -59,9 +66,29 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> getGroups() async {
|
Future<Response> getGroups() async {
|
||||||
|
final error = groupsError;
|
||||||
|
if (error != null) throw error;
|
||||||
return Response(
|
return Response(
|
||||||
requestOptions: RequestOptions(path: '/devices/groups'),
|
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);
|
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 {
|
test('devices load exposes data state', () async {
|
||||||
final api = FakeIgnisApi(
|
final api = FakeIgnisApi(
|
||||||
devicesData: {
|
devicesData: {
|
||||||
@@ -184,6 +225,8 @@ void main() {
|
|||||||
final state = container.read(devicesProvider);
|
final state = container.read(devicesProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
expect(state.status, LoadStatus.data);
|
||||||
expect(state.data, hasLength(1));
|
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 {
|
test('devices load exposes empty state', () async {
|
||||||
@@ -309,7 +352,25 @@ void main() {
|
|||||||
final state = container.read(scenesProvider);
|
final state = container.read(scenesProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
expect(state.status, LoadStatus.data);
|
||||||
expect(state.data, hasLength(2));
|
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 {
|
test('scenes load exposes empty state', () async {
|
||||||
@@ -418,6 +479,44 @@ void main() {
|
|||||||
expect(state.errorMessage, contains('Backend недоступен'));
|
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 {
|
test('task cancel error is not swallowed', () async {
|
||||||
final api = FakeIgnisApi(tasksData: <Object>[]);
|
final api = FakeIgnisApi(tasksData: <Object>[]);
|
||||||
final container = containerWith(api);
|
final container = containerWith(api);
|
||||||
|
|||||||
Reference in New Issue
Block a user