diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 42324ee..9762c5f 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -503,7 +503,8 @@ class GroupsNotifier extends Notifier> { await _api.controlGroup(id, {'state': on}); } catch (e) { _lockUntil.remove(id); - refresh(); + await refresh(); + rethrow; } } @@ -540,7 +541,8 @@ class GroupsNotifier extends Notifier> { await _api.controlGroup(id, {'scene': scene}); } catch (e) { _lockUntil.remove(id); - refresh(); + await refresh(); + rethrow; } } @@ -558,64 +560,92 @@ class GroupsNotifier extends Notifier> { // ─── Устройства (для создания групп) ───────────────────────── -final devicesProvider = NotifierProvider>( - () => DevicesNotifier(), -); +final devicesProvider = + NotifierProvider>>( + () => DevicesNotifier(), + ); -class DevicesNotifier extends Notifier> { +class DevicesNotifier extends Notifier>> { @override - List build() => []; + LoadState> build() => const LoadState.idle([]); /// Загрузить список устройств из текущего дома Future load() async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getDevices(); - if (res.data is List) { - state = res.data; - } else if (res.data is Map) { - state = - res.data['data'] ?? res.data['devices'] ?? res.data.values.toList(); + final data = res.data; + late final List devices; + if (data is List) { + devices = List.from(data); + } else if (data is Map) { + final value = data['data'] ?? data['devices'] ?? data.values.toList(); + if (value is! List) { + throw FormatException('devices должен быть списком устройств'); + } + devices = List.from(value); + } else { + throw FormatException('devices должен быть списком устройств'); } + + state = devices.isEmpty + ? LoadState.empty(devices) + : LoadState.data(devices); } catch (e) { - debugPrint("Ошибка загрузки устройств: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── Сцены ─────────────────────────────────────────────────── -final scenesProvider = NotifierProvider>( - () => ScenesNotifier(), -); +final scenesProvider = + NotifierProvider>>( + () => ScenesNotifier(), + ); -class ScenesNotifier extends Notifier> { +class ScenesNotifier extends Notifier>> { @override - List build() => []; + LoadState> build() => const LoadState.idle([]); Future load() async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getScenes(); final data = res.data; + late final List scenes; if (data is List) { - state = data; + scenes = List.from(data); } else if (data is Map) { // Бэкенд может вернуть {scene_id: "Scene Name", ...} // или {data: [...]} или {scenes: [...]} - if (data.containsKey('data') && data['data'] is List) { - state = data['data']; - } else if (data.containsKey('scenes') && data['scenes'] is List) { - state = data['scenes']; + if (data.containsKey('data')) { + final value = data['data']; + if (value is! List) { + throw FormatException('scenes.data должен быть списком сцен'); + } + scenes = List.from(value); + } else if (data.containsKey('scenes')) { + final value = data['scenes']; + if (value is! List) { + throw FormatException('scenes должен быть списком сцен'); + } + scenes = List.from(value); } else { // Map вида {id: name} -- преобразуем в список - state = data.entries + 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); } catch (e) { - debugPrint("Ошибка загрузки сцен: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } } diff --git a/lib/screens/group_edit_screen.dart b/lib/screens/group_edit_screen.dart index d95e3d6..896d491 100644 --- a/lib/screens/group_edit_screen.dart +++ b/lib/screens/group_edit_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; +import '../app/load_state.dart'; import '../providers/providers.dart'; +import '../widgets/load_error_view.dart'; /// Экран создания новой группы ламп. /// Загружает список устройств, позволяет выбрать нужные. @@ -15,7 +18,6 @@ class _GroupEditScreenState extends ConsumerState { final _idCtrl = TextEditingController(); final _nameCtrl = TextEditingController(); final Set _selectedMacs = {}; - bool _loading = true; bool _saving = false; bool _rescanning = false; @@ -34,7 +36,6 @@ class _GroupEditScreenState extends ConsumerState { Future _loadDevices() async { await ref.read(devicesProvider.notifier).load(); - if (mounted) setState(() => _loading = false); } /// Пересканировать сеть и перезагрузить устройства @@ -47,17 +48,21 @@ class _GroupEditScreenState extends ConsumerState { await ref.read(devicesProvider.notifier).load(); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Ошибка сканирования: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка сканирования: ${describeLoadError(e)}'), + ), + ); } + } finally { + if (mounted) setState(() => _rescanning = false); } - if (mounted) setState(() => _rescanning = false); } @override Widget build(BuildContext context) { - final devices = ref.watch(devicesProvider); + final devicesState = ref.watch(devicesProvider); + final devices = devicesState.data; return Scaffold( appBar: AppBar( @@ -77,50 +82,48 @@ class _GroupEditScreenState extends ConsumerState { ), ], ), - body: _loading - ? const Center( - child: CircularProgressIndicator(color: Colors.deepOrange), - ) - : Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ID группы - TextField( - controller: _idCtrl, - decoration: const InputDecoration( - labelText: 'ID группы (например "bedroom")', - prefixIcon: Icon(Icons.tag), - ), - ), - const SizedBox(height: 12), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ID группы + TextField( + controller: _idCtrl, + decoration: const InputDecoration( + labelText: 'ID группы (например "bedroom")', + prefixIcon: Icon(Icons.tag), + ), + ), + const SizedBox(height: 12), - // Название группы - TextField( - controller: _nameCtrl, - decoration: const InputDecoration( - labelText: 'Название (например "Спальня")', - prefixIcon: Icon(Icons.label), - ), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 16), + // Название группы + TextField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Название (например "Спальня")', + prefixIcon: Icon(Icons.label), + ), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 16), - // Заголовок списка устройств - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Устройства (${_selectedMacs.length} выбрано)', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.white70, - ), - ), - TextButton( - onPressed: () { + // Заголовок списка устройств + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Устройства (${_selectedMacs.length} выбрано)', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + TextButton( + onPressed: devices.isEmpty + ? null + : () { setState(() { if (_selectedMacs.length == devices.length) { _selectedMacs.clear(); @@ -132,90 +135,128 @@ class _GroupEditScreenState extends ConsumerState { } }); }, - child: Text( - _selectedMacs.length == devices.length - ? 'Снять все' - : 'Выбрать все', - style: const TextStyle(fontSize: 12), - ), - ), - ], + child: Text( + _selectedMacs.length == devices.length + ? 'Снять все' + : 'Выбрать все', + style: const TextStyle(fontSize: 12), ), + ), + ], + ), - // Список устройств - Expanded( - child: devices.isEmpty - ? const Center( - child: Text( - 'Устройства не найдены.\nПопробуйте пересканировать сеть.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white38), - ), - ) - : ListView.builder( - itemCount: devices.length, - itemBuilder: (context, index) { - final d = devices[index]; - final mac = _extractMac(d) ?? ''; - final name = _extractName(d); - final ip = _extractIp(d); - final selected = _selectedMacs.contains(mac); + // Список устройств + Expanded(child: _buildDevices(devicesState, devices)), - return CheckboxListTile( - value: selected, - activeColor: Colors.deepOrange, - title: Text(name), - subtitle: Text( - '$mac${ip != null ? ' - $ip' : ''}', - style: const TextStyle( - fontSize: 11, - color: Colors.white38, - ), - ), - onChanged: (v) { - setState(() { - if (v == true) { - _selectedMacs.add(mac); - } else { - _selectedMacs.remove(mac); - } - }); - }, - ); - }, + // Кнопка сохранения + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 8, + ), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + ), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, ), - ), - - // Кнопка сохранения - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), - child: SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepOrange, - foregroundColor: Colors.white, - ), - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('СОЗДАТЬ ГРУППУ'), - ), - ), - ), - ], + ) + : const Text('СОЗДАТЬ ГРУППУ'), + ), ), ), + ], + ), + ), + ); + } + + Widget _buildDevices( + LoadState> devicesState, + List devices, + ) { + if ((devicesState.isIdle || devicesState.isLoading) && devices.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ); + } + + if (devicesState.hasError && devices.isEmpty) { + return LoadErrorView( + title: 'Не удалось загрузить устройства', + message: devicesState.errorMessage, + icon: Icons.lightbulb_outline, + onRetry: _loadDevices, + ); + } + + if (devices.isEmpty) { + return const Center( + child: Text( + 'Устройства не найдены.\nПопробуйте пересканировать сеть.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white38), + ), + ); + } + + final hasStatusHeader = devicesState.isLoading || devicesState.hasError; + final statusHeaderCount = hasStatusHeader ? 1 : 0; + + return ListView.builder( + itemCount: devices.length + statusHeaderCount, + itemBuilder: (context, index) { + if (hasStatusHeader && index == 0) { + if (devicesState.isLoading) { + return const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(color: Colors.deepOrange), + ); + } + + return LoadErrorBanner( + title: 'Не удалось обновить устройства', + message: devicesState.errorMessage, + onRetry: _loadDevices, + ); + } + + 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); + + return CheckboxListTile( + value: selected, + activeColor: Colors.deepOrange, + title: Text(name), + subtitle: Text( + '$mac${ip != null ? ' - $ip' : ''}', + style: const TextStyle(fontSize: 11, color: Colors.white38), + ), + onChanged: (v) { + setState(() { + if (v == true) { + _selectedMacs.add(mac); + } else { + _selectedMacs.remove(mac); + } + }); + }, + ); + }, ); } @@ -272,9 +313,9 @@ class _GroupEditScreenState extends ConsumerState { if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Ошибка создания: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка создания: ${describeLoadError(e)}')), + ); } } diff --git a/lib/widgets/group_card.dart b/lib/widgets/group_card.dart index c2e309b..d4bbaa7 100644 --- a/lib/widgets/group_card.dart +++ b/lib/widgets/group_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; import '../providers/providers.dart'; import 'color_picker.dart'; @@ -90,14 +91,12 @@ class _GroupCardState extends ConsumerState { color: Colors.white38, ), tooltip: 'Включить на 4 часа', - onPressed: () => - ref.read(groupsProvider.notifier).setTimer4h(id), + onPressed: () => _setTimer4h(id), ), Switch( value: isOn, activeThumbColor: Colors.deepOrange, - onChanged: (v) => - ref.read(groupsProvider.notifier).toggleGroup(id, v), + onChanged: (v) => _toggleGroup(id, v), ), ], ), @@ -204,6 +203,32 @@ class _GroupCardState extends ConsumerState { ), ); } + + Future _toggleGroup(String id, bool value) async { + try { + await ref.read(groupsProvider.notifier).toggleGroup(id, value); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка управления группой: ${describeLoadError(e)}'), + ), + ); + } + } + + Future _setTimer4h(String id) async { + try { + await ref.read(groupsProvider.notifier).setTimer4h(id); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка создания таймера: ${describeLoadError(e)}'), + ), + ); + } + } } /// Слайдер с иконкой и подписью @@ -324,16 +349,18 @@ class _SceneSelector extends ConsumerStatefulWidget { } class _SceneSelectorState extends ConsumerState<_SceneSelector> { - bool _loadStarted = false; + @override + void initState() { + super.initState(); + Future.microtask(() => ref.read(scenesProvider.notifier).load()); + } @override Widget build(BuildContext context) { - final scenes = ref.watch(scenesProvider); + final scenesState = ref.watch(scenesProvider); + final scenes = scenesState.data; - if (scenes.isEmpty && !_loadStarted) { - // Загрузить сцены при первом показе - _loadStarted = true; - Future.microtask(() => ref.read(scenesProvider.notifier).load()); + if ((scenesState.isIdle || scenesState.isLoading) && scenes.isEmpty) { return const Padding( padding: EdgeInsets.all(8.0), child: Center( @@ -346,6 +373,34 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> { ); } + if (scenesState.hasError && scenes.isEmpty) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Не удалось загрузить сцены', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + if (scenesState.errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + scenesState.errorMessage!, + style: const TextStyle(color: Colors.white30, fontSize: 11), + ), + ), + TextButton.icon( + onPressed: _loadScenes, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Повторить'), + ), + ], + ), + ); + } + if (scenes.isEmpty) { return const Padding( padding: EdgeInsets.all(8.0), @@ -356,34 +411,75 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> { ); } - return Wrap( - spacing: 8, - runSpacing: 4, - children: scenes.map((scene) { - String sceneName; - String sceneId; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (scenesState.hasError) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.deepOrange, + ), + const SizedBox(width: 6), + const Expanded( + child: Text( + 'Сцены не обновились', + style: TextStyle(color: Colors.white38, fontSize: 12), + ), + ), + IconButton( + tooltip: 'Повторить', + onPressed: _loadScenes, + icon: const Icon(Icons.refresh, size: 16), + ), + ], + ), + ), + Wrap( + 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(); - } + 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)), - backgroundColor: Colors.white10, - onPressed: () => ref - .read(groupsProvider.notifier) - .setScene(widget.groupId, sceneId), - ); - }).toList(), + return ActionChip( + label: Text(sceneName, style: const TextStyle(fontSize: 12)), + backgroundColor: Colors.white10, + onPressed: () => _setScene(sceneId), + ); + }).toList(), + ), + ], ); } + + Future _loadScenes() => ref.read(scenesProvider.notifier).load(); + + Future _setScene(String sceneId) async { + try { + await ref.read(groupsProvider.notifier).setScene(widget.groupId, sceneId); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка сцены: ${describeLoadError(e)}')), + ); + } + } } diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index 4856a83..dc92b88 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -6,28 +6,77 @@ import 'package:ignis_app/providers/providers.dart'; import 'package:ignis_app/services/api_client.dart'; class FakeIgnisApi extends IgnisApi { + Object? devicesData; + Object? scenesData; Object? tasksData; Object? statsData; Object? eventLogData; Object? apiKeysData; + Object? devicesError; + Object? scenesError; Object? tasksError; Object? statsError; Object? eventLogError; Object? apiKeysError; + Object? controlGroupError; Object? cancelTaskError; Object? revokeApiKeyError; + String? controlledGroupId; + Map? controlGroupParams; int? requestedDays; int? requestedLimit; String? cancelledJobId; String? revokedApiKey; FakeIgnisApi({ + this.devicesData, + this.scenesData, this.tasksData, this.statsData, this.eventLogData, this.apiKeysData, }); + @override + Future getDevices() async { + final error = devicesError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices'), + data: devicesData, + ); + } + + @override + Future getScenes() async { + final error = scenesError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices/scenes'), + data: scenesData, + ); + } + + @override + Future getGroups() async { + return Response( + requestOptions: RequestOptions(path: '/devices/groups'), + data: [], + ); + } + + @override + Future controlGroup(String id, Map params) async { + controlledGroupId = id; + controlGroupParams = params; + final error = controlGroupError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/control/group/$id'), + data: {'ok': true}, + ); + } + @override Future getTasks() async { final error = tasksError; @@ -120,6 +169,57 @@ void main() { expect(api.requestedDays, 14); }); + test('devices load exposes data state', () async { + final api = FakeIgnisApi( + devicesData: { + 'devices': [ + {'mac': 'AA:BB', 'name': 'Kitchen bulb'}, + ], + }, + ); + final container = containerWith(api); + + await container.read(devicesProvider.notifier).load(); + + final state = container.read(devicesProvider); + expect(state.status, LoadStatus.data); + expect(state.data, hasLength(1)); + }); + + test('devices load exposes empty state', () async { + final api = FakeIgnisApi(devicesData: {'devices': []}); + final container = containerWith(api); + + await container.read(devicesProvider.notifier).load(); + + final state = container.read(devicesProvider); + expect(state.status, LoadStatus.empty); + expect(state.data, isEmpty); + }); + + test('devices load error exposes message', () async { + final api = FakeIgnisApi( + devicesData: [ + {'mac': 'AA:BB'}, + ], + ); + final container = containerWith(api); + + await container.read(devicesProvider.notifier).load(); + api.devicesError = DioException( + requestOptions: RequestOptions(path: '/devices'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(devicesProvider.notifier).load(); + + final state = container.read(devicesProvider); + expect(state.status, LoadStatus.error); + expect(state.data, hasLength(1)); + expect(state.errorMessage, contains('Backend недоступен')); + }); + test('tasks load exposes data state', () async { final api = FakeIgnisApi( tasksData: { @@ -200,6 +300,48 @@ void main() { expect(api.requestedLimit, 50); }); + test('scenes load accepts id-name map and exposes data state', () async { + final api = FakeIgnisApi(scenesData: {'party': 'Party', 'relax': 'Relax'}); + final container = containerWith(api); + + await container.read(scenesProvider.notifier).load(); + + final state = container.read(scenesProvider); + expect(state.status, LoadStatus.data); + expect(state.data, hasLength(2)); + expect(state.data.first, containsPair('id', 'party')); + }); + + test('scenes load exposes empty state', () async { + final api = FakeIgnisApi(scenesData: {'scenes': []}); + final container = containerWith(api); + + await container.read(scenesProvider.notifier).load(); + + final state = container.read(scenesProvider); + expect(state.status, LoadStatus.empty); + expect(state.data, isEmpty); + }); + + test('scenes load error exposes message', () async { + final api = FakeIgnisApi(scenesData: ['Party']); + final container = containerWith(api); + + await container.read(scenesProvider.notifier).load(); + api.scenesError = DioException( + requestOptions: RequestOptions(path: '/devices/scenes'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(scenesProvider.notifier).load(); + + final state = container.read(scenesProvider); + expect(state.status, LoadStatus.error); + expect(state.data, hasLength(1)); + expect(state.errorMessage, contains('Backend недоступен')); + }); + test('api keys load exposes data state', () async { final api = FakeIgnisApi( apiKeysData: { @@ -315,4 +457,25 @@ void main() { ); expect(api.revokedApiKey, 'secret'); }); + + test('set scene error is not swallowed', () async { + final api = FakeIgnisApi(); + final container = containerWith(api); + final error = DioException( + requestOptions: RequestOptions(path: '/control/group/kitchen'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/control/group/kitchen'), + statusCode: 500, + ), + ); + api.controlGroupError = error; + + await expectLater( + container.read(groupsProvider.notifier).setScene('kitchen', 'party'), + throwsA(same(error)), + ); + expect(api.controlledGroupId, 'kitchen'); + expect(api.controlGroupParams, containsPair('scene', 'party')); + }); }