diff --git a/lib/app/error_message.dart b/lib/app/error_message.dart new file mode 100644 index 0000000..e5aa5a0 --- /dev/null +++ b/lib/app/error_message.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; + +String describeLoadError(Object error) { + if (error is DioException) { + final statusCode = error.response?.statusCode; + + if (statusCode == 401 || statusCode == 403) { + return 'Нет доступа: API key отклонён ($statusCode).'; + } + + if (_isNetworkFailure(error)) { + return 'Backend недоступен: ${error.message ?? error.type.name}.'; + } + + final responseMessage = _responseMessage(error); + if (responseMessage != null) { + return 'Backend вернул ошибку $statusCode: $responseMessage'; + } + + if (statusCode != null) { + return 'Backend вернул ошибку $statusCode.'; + } + + return 'Ошибка запроса: ${error.message ?? error.type.name}.'; + } + + if (error is FormatException) { + return 'Неожиданный ответ backend: ${error.message}'; + } + + return error.toString(); +} + +bool _isNetworkFailure(DioException error) { + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.connectionError || + DioExceptionType.unknown => true, + _ => false, + }; +} + +String? _responseMessage(DioException error) { + final data = error.response?.data; + if (data is Map) { + final value = data['detail'] ?? data['message'] ?? data['error']; + return value?.toString(); + } + if (data is String && data.trim().isNotEmpty) { + return data.trim(); + } + return null; +} diff --git a/lib/app/load_state.dart b/lib/app/load_state.dart new file mode 100644 index 0000000..83dcd78 --- /dev/null +++ b/lib/app/load_state.dart @@ -0,0 +1,34 @@ +enum LoadStatus { idle, loading, data, empty, error } + +class LoadState { + final LoadStatus status; + final T data; + final String? errorMessage; + + const LoadState.idle(this.data) + : status = LoadStatus.idle, + errorMessage = null; + + const LoadState.loading(this.data) + : status = LoadStatus.loading, + errorMessage = null; + + const LoadState.data(this.data) + : status = LoadStatus.data, + errorMessage = null; + + const LoadState.empty(this.data) + : status = LoadStatus.empty, + errorMessage = null; + + const LoadState.error(this.data, this.errorMessage) + : status = LoadStatus.error; + + bool get isIdle => status == LoadStatus.idle; + + bool get isLoading => status == LoadStatus.loading; + + bool get hasError => status == LoadStatus.error; + + bool get isEmpty => status == LoadStatus.empty; +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 26713e7..09477cf 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -3,6 +3,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; +import '../app/error_message.dart'; +import '../app/load_state.dart'; import '../models/home_config.dart'; import '../services/api_client.dart'; import '../services/settings_service.dart'; @@ -694,50 +696,69 @@ class TasksNotifier extends Notifier> { // ─── Статистика ────────────────────────────────────────────── -final statsProvider = NotifierProvider>( - () => StatsNotifier(), -); +final statsProvider = + NotifierProvider>>( + () => StatsNotifier(), + ); -class StatsNotifier extends Notifier> { +class StatsNotifier extends Notifier>> { @override - Map build() => {}; + LoadState> build() => + const LoadState.idle({}); Future load({int days = 7}) async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getStatsSummary(days: days); final data = res.data; - if (data is Map) { - state = Map.from(data); + if (data is! Map) { + throw FormatException('stats summary должен быть объектом'); } + + final stats = Map.from(data); + final groups = stats['groups']; + final hasGroups = groups is List && groups.isNotEmpty; + state = hasGroups ? LoadState.data(stats) : LoadState.empty(stats); } catch (e) { - debugPrint("Ошибка загрузки статистики: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } } // ─── Лог событий ───────────────────────────────────────────── -final eventLogProvider = NotifierProvider>( - () => EventLogNotifier(), -); +final eventLogProvider = + NotifierProvider>>( + () => EventLogNotifier(), + ); -class EventLogNotifier extends Notifier> { +class EventLogNotifier extends Notifier>> { @override - List build() => []; + LoadState> build() => const LoadState.idle([]); Future load({int limit = 100}) async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getStatsLog(limit: limit); final data = res.data; + late final List events; if (data is List) { - state = data; + events = List.from(data); } else if (data is Map) { - state = data['data'] ?? data['events'] ?? data.values.toList(); + final value = data['data'] ?? data['events'] ?? data.values.toList(); + if (value is! List) { + throw FormatException('stats log должен быть списком событий'); + } + events = List.from(value); + } else { + throw FormatException('stats log должен быть списком событий'); } + + state = events.isEmpty ? LoadState.empty(events) : LoadState.data(events); } catch (e) { - debugPrint("Ошибка загрузки логов: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } } diff --git a/lib/screens/event_log_screen.dart b/lib/screens/event_log_screen.dart index 4a6ca5d..3027083 100644 --- a/lib/screens/event_log_screen.dart +++ b/lib/screens/event_log_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/load_state.dart'; import '../providers/providers.dart'; +import '../widgets/load_error_view.dart'; /// Экран просмотра лога событий. class EventLogScreen extends ConsumerStatefulWidget { @@ -11,7 +13,6 @@ class EventLogScreen extends ConsumerStatefulWidget { } class _EventLogScreenState extends ConsumerState { - bool _loading = true; int _limit = 100; @override @@ -21,14 +22,13 @@ class _EventLogScreenState extends ConsumerState { } Future _load() async { - setState(() => _loading = true); await ref.read(eventLogProvider.notifier).load(limit: _limit); - if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { - final events = ref.watch(eventLogProvider); + final eventsState = ref.watch(eventLogProvider); + final events = eventsState.data; return Scaffold( appBar: AppBar( @@ -47,31 +47,68 @@ class _EventLogScreenState extends ConsumerState { ), ], ), - body: _loading - ? const Center( - child: CircularProgressIndicator(color: Colors.deepOrange), - ) - : events.isEmpty - ? const Center( - child: Text( - 'Нет событий', - style: TextStyle(color: Colors.white54), - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: _load, - child: ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: events.length, - itemBuilder: (context, index) { - final event = events[index]; - return _EventRow( - event: event is Map ? Map.from(event) : {}, - ); - }, - ), - ), + body: _buildContent(eventsState, events), + ); + } + + Widget _buildContent( + LoadState> eventsState, + List events, + ) { + if ((eventsState.isIdle || eventsState.isLoading) && events.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ); + } + + if (eventsState.hasError && events.isEmpty) { + return LoadErrorView( + title: 'Не удалось загрузить лог событий', + message: eventsState.errorMessage, + icon: Icons.list_alt, + onRetry: _load, + ); + } + + if (events.isEmpty) { + return const Center( + child: Text('Нет событий', style: TextStyle(color: Colors.white54)), + ); + } + + final hasStatusHeader = eventsState.isLoading || eventsState.hasError; + final statusHeaderCount = hasStatusHeader ? 1 : 0; + + return RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: events.length + statusHeaderCount, + itemBuilder: (context, index) { + if (hasStatusHeader && index == 0) { + if (eventsState.isLoading) { + return const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(color: Colors.deepOrange), + ); + } + + return LoadErrorBanner( + title: 'Не удалось обновить лог событий', + message: eventsState.errorMessage, + onRetry: _load, + ); + } + + final eventIndex = index - statusHeaderCount; + final event = events[eventIndex]; + return _EventRow( + event: event is Map ? Map.from(event) : {}, + ); + }, + ), ); } } diff --git a/lib/screens/stats_screen.dart b/lib/screens/stats_screen.dart index a1da0bb..fbb0692 100644 --- a/lib/screens/stats_screen.dart +++ b/lib/screens/stats_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/load_state.dart'; import '../providers/providers.dart'; +import '../widgets/load_error_view.dart'; /// Экран просмотра статистики. /// Показывает сводку по группам за выбранный период. @@ -12,7 +14,6 @@ class StatsScreen extends ConsumerStatefulWidget { } class _StatsScreenState extends ConsumerState { - bool _loading = true; int _days = 7; @override @@ -22,14 +23,13 @@ class _StatsScreenState extends ConsumerState { } Future _load() async { - setState(() => _loading = true); await ref.read(statsProvider.notifier).load(days: _days); - if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { - final stats = ref.watch(statsProvider); + final statsState = ref.watch(statsProvider); + final stats = statsState.data; final groups = (stats['groups'] as List?) ?? []; return Scaffold( @@ -62,37 +62,70 @@ class _StatsScreenState extends ConsumerState { ), // ─── Содержимое ─── - Expanded( - child: _loading - ? const Center( - child: CircularProgressIndicator(color: Colors.deepOrange), - ) - : groups.isEmpty - ? const Center( - child: Text( - 'Нет данных', - style: TextStyle(color: Colors.white54), - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: _load, - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: groups.length, - itemBuilder: (context, index) { - final g = groups[index]; - return _StatsCard( - data: g is Map ? Map.from(g) : {}, - ); - }, - ), - ), - ), + Expanded(child: _buildContent(statsState, groups)), ], ), ); } + + Widget _buildContent( + LoadState> statsState, + List groups, + ) { + if ((statsState.isIdle || statsState.isLoading) && groups.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ); + } + + if (statsState.hasError && groups.isEmpty) { + return LoadErrorView( + title: 'Не удалось загрузить статистику', + message: statsState.errorMessage, + icon: Icons.bar_chart, + onRetry: _load, + ); + } + + if (groups.isEmpty) { + return const Center( + child: Text('Нет данных', style: TextStyle(color: Colors.white54)), + ); + } + + final hasStatusHeader = statsState.isLoading || statsState.hasError; + final statusHeaderCount = hasStatusHeader ? 1 : 0; + + return RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(12), + itemCount: groups.length + statusHeaderCount, + itemBuilder: (context, index) { + if (hasStatusHeader && index == 0) { + if (statsState.isLoading) { + return const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(color: Colors.deepOrange), + ); + } + + return LoadErrorBanner( + title: 'Не удалось обновить статистику', + message: statsState.errorMessage, + onRetry: _load, + ); + } + + final groupIndex = index - statusHeaderCount; + final g = groups[groupIndex]; + return _StatsCard(data: g is Map ? Map.from(g) : {}); + }, + ), + ); + } } /// Карточка статистики одной группы diff --git a/lib/widgets/load_error_view.dart b/lib/widgets/load_error_view.dart new file mode 100644 index 0000000..26e8566 --- /dev/null +++ b/lib/widgets/load_error_view.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +class LoadErrorView extends StatelessWidget { + final String title; + final String? message; + final VoidCallback onRetry; + final IconData icon; + + const LoadErrorView({ + super.key, + required this.title, + required this.onRetry, + this.message, + this.icon = Icons.wifi_off_rounded, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: Colors.deepOrange), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Повторить'), + ), + ], + ), + ), + ); + } +} + +class LoadErrorBanner extends StatelessWidget { + final String title; + final String? message; + final VoidCallback onRetry; + + const LoadErrorBanner({ + super.key, + required this.title, + required this.onRetry, + this.message, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 4, 12, 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.deepOrange.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.deepOrange, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + ), + ), + if (message != null) ...[ + const SizedBox(height: 4), + Text( + message!, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + ], + ), + ), + IconButton( + tooltip: 'Повторить', + onPressed: onRetry, + icon: const Icon(Icons.refresh), + ), + ], + ), + ); + } +} diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart new file mode 100644 index 0000000..dc573d2 --- /dev/null +++ b/test/read_only_load_state_test.dart @@ -0,0 +1,121 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/app/load_state.dart'; +import 'package:ignis_app/providers/providers.dart'; +import 'package:ignis_app/services/api_client.dart'; + +class FakeIgnisApi extends IgnisApi { + Object? statsData; + Object? eventLogData; + Object? statsError; + Object? eventLogError; + int? requestedDays; + int? requestedLimit; + + FakeIgnisApi({this.statsData, this.eventLogData}); + + @override + Future getStatsSummary({int days = 7}) async { + requestedDays = days; + final error = statsError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/stats/summary'), + data: statsData, + ); + } + + @override + Future getStatsLog({int limit = 100}) async { + requestedLimit = limit; + final error = eventLogError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/stats/log'), + data: eventLogData, + ); + } +} + +void main() { + ProviderContainer containerWith(FakeIgnisApi api) { + final container = ProviderContainer( + overrides: [apiProvider.overrideWithValue(api)], + ); + addTearDown(container.dispose); + return container; + } + + test('stats load exposes data state', () async { + final api = FakeIgnisApi( + statsData: { + 'groups': [ + {'id': 'kitchen', 'total_commands': 3}, + ], + }, + ); + final container = containerWith(api); + + await container.read(statsProvider.notifier).load(days: 14); + + final state = container.read(statsProvider); + expect(state.status, LoadStatus.data); + expect(state.data['groups'], hasLength(1)); + expect(api.requestedDays, 14); + }); + + test('stats load exposes empty state for empty groups', () async { + final api = FakeIgnisApi(statsData: {'groups': []}); + final container = containerWith(api); + + await container.read(statsProvider.notifier).load(); + + final state = container.read(statsProvider); + expect(state.status, LoadStatus.empty); + expect(state.data['groups'], isEmpty); + }); + + test('event log load accepts map response and exposes data state', () async { + final api = FakeIgnisApi( + eventLogData: { + 'events': [ + {'action': 'toggle', 'target_id': 'kitchen'}, + ], + }, + ); + final container = containerWith(api); + + await container.read(eventLogProvider.notifier).load(limit: 50); + + final state = container.read(eventLogProvider); + expect(state.status, LoadStatus.data); + expect(state.data, hasLength(1)); + expect(api.requestedLimit, 50); + }); + + test('load error keeps previous stats data and exposes message', () async { + final api = FakeIgnisApi( + statsData: { + 'groups': [ + {'id': 'kitchen'}, + ], + }, + ); + final container = containerWith(api); + + await container.read(statsProvider.notifier).load(); + api.statsError = DioException( + requestOptions: RequestOptions(path: '/stats/summary'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(statsProvider.notifier).load(); + + final state = container.read(statsProvider); + expect(state.status, LoadStatus.error); + expect(state.data['groups'], hasLength(1)); + expect(state.errorMessage, contains('Backend недоступен')); + }); +}