From 0c7474865044b71d20900f067ec19b6b7984b2be Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Thu, 23 Apr 2026 21:05:37 +0700 Subject: [PATCH] feat: type stats and event log models --- lib/models/event_log_item.dart | 116 +++++++++++++++++++++++ lib/models/stats_summary.dart | 141 ++++++++++++++++++++++++++++ lib/providers/providers.dart | 48 ++++------ lib/screens/event_log_screen.dart | 45 +++------ lib/screens/stats_screen.dart | 39 +++----- test/read_only_load_state_test.dart | 10 +- 6 files changed, 303 insertions(+), 96 deletions(-) create mode 100644 lib/models/event_log_item.dart create mode 100644 lib/models/stats_summary.dart diff --git a/lib/models/event_log_item.dart b/lib/models/event_log_item.dart new file mode 100644 index 0000000..86648ee --- /dev/null +++ b/lib/models/event_log_item.dart @@ -0,0 +1,116 @@ +class EventLogItem { + final String timestamp; + final String action; + final String targetId; + final Object? params; + final String actor; + + const EventLogItem({ + required this.timestamp, + required this.action, + required this.targetId, + this.params, + this.actor = '', + }); + + String get title => '$action - $targetId'; + + String get paramsText { + final value = params; + if (value == null) return ''; + return value.toString(); + } + + String get formattedTime { + if (timestamp.isEmpty) return ''; + try { + final date = DateTime.parse(timestamp); + String pad(int n) => n.toString().padLeft(2, '0'); + return '${pad(date.day)}.${pad(date.month)} ' + '${pad(date.hour)}:${pad(date.minute)}:${pad(date.second)}'; + } catch (_) { + return timestamp; + } + } + + static EventLogItem fromApi(Object? data, {String? fallbackId}) { + if (data is! Map) { + final action = data?.toString() ?? fallbackId; + if (action == null || action.isEmpty) { + throw const FormatException('event log item должен быть объектом'); + } + return EventLogItem(timestamp: '', action: action, targetId: ''); + } + + final map = Map.from(data); + return EventLogItem( + timestamp: + _stringValue(map, const ['timestamp', 'time', 'created_at']) ?? '', + action: _stringValue(map, const ['action', 'command', 'type']) ?? '', + targetId: + _stringValue(map, const ['target_id', 'target', 'group_id']) ?? '', + params: map['params'] ?? map['details'], + actor: _stringValue(map, const ['actor', 'user', 'key_name']) ?? '', + ); + } + + static List listFromApi(Object? data) { + final values = _collectionValues(data, const ['data', 'events', 'log']); + return values.map((value) { + if (value.entryKey == null) return EventLogItem.fromApi(value.value); + return EventLogItem.fromApi(value.value, fallbackId: value.entryKey); + }).toList(); + } +} + +String? _stringValue(Map map, List 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 wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.from(data); + for (final wrapper in wrappers) { + final value = map[wrapper]; + if (value is List) { + return value.map((item) => _CollectionValue(item)).toList(); + } + } + + if (_looksLikeEventItem(map)) { + return <_CollectionValue>[_CollectionValue(map)]; + } + + return map.entries + .map((entry) => _CollectionValue(entry.value, entryKey: entry.key)) + .toList(); + } + + throw const FormatException('stats log должен быть списком событий'); +} + +bool _looksLikeEventItem(Map map) { + return map.containsKey('timestamp') || + map.containsKey('time') || + map.containsKey('created_at') || + map.containsKey('action') || + map.containsKey('command') || + map.containsKey('type'); +} + +class _CollectionValue { + final Object? value; + final String? entryKey; + + const _CollectionValue(this.value, {this.entryKey}); +} diff --git a/lib/models/stats_summary.dart b/lib/models/stats_summary.dart new file mode 100644 index 0000000..ebac959 --- /dev/null +++ b/lib/models/stats_summary.dart @@ -0,0 +1,141 @@ +class StatsSummary { + final List groups; + + const StatsSummary({required this.groups}); + + static const empty = StatsSummary(groups: []); + + static StatsSummary fromApi(Object? data) { + return StatsSummary(groups: GroupStats.listFromApi(data)); + } +} + +class GroupStats { + final String targetId; + final String name; + final int totalCommands; + final int togglesOn; + final int togglesOff; + final double? estimatedHours; + + const GroupStats({ + required this.targetId, + required this.name, + required this.totalCommands, + required this.togglesOn, + required this.togglesOff, + this.estimatedHours, + }); + + String get formattedEstimatedHours { + final value = estimatedHours; + if (value == null) return ''; + if (value < 1) return '${(value * 60).round()} мин'; + return '${value.toStringAsFixed(1)} ч'; + } + + static GroupStats fromApi(Object? data, {String? fallbackId}) { + if (data is! Map) { + final id = data?.toString() ?? fallbackId; + if (id == null || id.isEmpty) { + throw const FormatException('group stats должен быть объектом'); + } + return GroupStats( + targetId: id, + name: id, + totalCommands: 0, + togglesOn: 0, + togglesOff: 0, + ); + } + + final map = Map.from(data); + final targetId = + _stringValue(map, const ['target_id', 'group_id', 'id', 'target']) ?? + fallbackId; + if (targetId == null || targetId.isEmpty) { + throw const FormatException('group stats не содержит id'); + } + + return GroupStats( + targetId: targetId, + name: _stringValue(map, const ['name', 'label']) ?? targetId, + totalCommands: _intValue(map['total_commands']), + togglesOn: _intValue(map['toggles_on']), + togglesOff: _intValue(map['toggles_off']), + estimatedHours: _doubleValue(map['estimated_hours']), + ); + } + + static List listFromApi(Object? data) { + final values = _collectionValues(data, const ['groups', 'data', 'items']); + return values.map((value) { + if (value.entryKey == null) return GroupStats.fromApi(value.value); + return GroupStats.fromApi(value.value, fallbackId: value.entryKey); + }).toList(); + } +} + +String? _stringValue(Map map, List 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.round(); + if (value is String) return int.tryParse(value.trim()) ?? 0; + return 0; +} + +double? _doubleValue(Object? value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value.trim()); + return null; +} + +List<_CollectionValue> _collectionValues(Object? data, List wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.from(data); + for (final wrapper in wrappers) { + final value = map[wrapper]; + if (value is List) { + return value.map((item) => _CollectionValue(item)).toList(); + } + } + + if (_looksLikeStatsItem(map)) { + return <_CollectionValue>[_CollectionValue(map)]; + } + + return map.entries + .where((entry) => entry.value is Map) + .map((entry) => _CollectionValue(entry.value, entryKey: entry.key)) + .toList(); + } + + throw const FormatException('stats summary должен быть объектом или списком'); +} + +bool _looksLikeStatsItem(Map map) { + return map.containsKey('total_commands') || + map.containsKey('toggles_on') || + map.containsKey('toggles_off') || + map.containsKey('estimated_hours'); +} + +class _CollectionValue { + final Object? value; + final String? entryKey; + + const _CollectionValue(this.value, {this.entryKey}); +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 1f8cef5..adda6e5 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -7,11 +7,13 @@ import '../app/error_message.dart'; import '../app/load_state.dart'; import '../models/api_key_info.dart'; import '../models/auth_info.dart'; +import '../models/event_log_item.dart'; import '../models/ignis_device.dart'; import '../models/ignis_group.dart'; import '../models/ignis_scene.dart'; import '../models/home_config.dart'; import '../models/schedule_task.dart'; +import '../models/stats_summary.dart'; import '../services/api_client.dart'; import '../services/settings_service.dart'; import '../services/geofence_worker.dart'; @@ -697,30 +699,23 @@ class TasksNotifier extends Notifier>> { // ─── Статистика ────────────────────────────────────────────── -final statsProvider = - NotifierProvider>>( - () => StatsNotifier(), - ); +final statsProvider = NotifierProvider>( + () => StatsNotifier(), +); -class StatsNotifier extends Notifier>> { +class StatsNotifier extends Notifier> { @override - LoadState> build() => - const LoadState.idle({}); + LoadState build() => const LoadState.idle(StatsSummary.empty); 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) { - 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); + final stats = StatsSummary.fromApi(res.data); + state = stats.groups.isEmpty + ? LoadState.empty(stats) + : LoadState.data(stats); } catch (e) { state = LoadState.error(state.data, describeLoadError(e)); } @@ -730,32 +725,21 @@ class StatsNotifier extends Notifier>> { // ─── Лог событий ───────────────────────────────────────────── final eventLogProvider = - NotifierProvider>>( + NotifierProvider>>( () => EventLogNotifier(), ); -class EventLogNotifier extends Notifier>> { +class EventLogNotifier extends Notifier>> { @override - LoadState> build() => const LoadState.idle([]); + 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) { - events = List.from(data); - } else if (data is Map) { - 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 должен быть списком событий'); - } + final events = EventLogItem.listFromApi(res.data); state = events.isEmpty ? LoadState.empty(events) : LoadState.data(events); } catch (e) { diff --git a/lib/screens/event_log_screen.dart b/lib/screens/event_log_screen.dart index 3027083..de933de 100644 --- a/lib/screens/event_log_screen.dart +++ b/lib/screens/event_log_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/load_state.dart'; +import '../models/event_log_item.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; @@ -52,8 +53,8 @@ class _EventLogScreenState extends ConsumerState { } Widget _buildContent( - LoadState> eventsState, - List events, + LoadState> eventsState, + List events, ) { if ((eventsState.isIdle || eventsState.isLoading) && events.isEmpty) { return const Center( @@ -103,10 +104,7 @@ class _EventLogScreenState extends ConsumerState { } final eventIndex = index - statusHeaderCount; - final event = events[eventIndex]; - return _EventRow( - event: event is Map ? Map.from(event) : {}, - ); + return _EventRow(event: events[eventIndex]); }, ), ); @@ -114,22 +112,12 @@ class _EventLogScreenState extends ConsumerState { } class _EventRow extends StatelessWidget { - final Map event; + final EventLogItem event; const _EventRow({required this.event}); @override Widget build(BuildContext context) { - final timestamp = - event['timestamp'] ?? event['time'] ?? event['created_at'] ?? ''; - final action = event['action'] ?? event['command'] ?? event['type'] ?? ''; - final targetId = - event['target_id'] ?? event['target'] ?? event['group_id'] ?? ''; - final params = event['params'] ?? event['details'] ?? ''; - final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? ''; - - final formattedTime = _formatTime(timestamp.toString()); - return Card( margin: const EdgeInsets.only(bottom: 4), child: Padding( @@ -141,7 +129,7 @@ class _EventRow extends StatelessWidget { SizedBox( width: 80, child: Text( - formattedTime, + event.formattedTime, style: const TextStyle( fontSize: 11, color: Colors.white38, @@ -156,15 +144,15 @@ class _EventRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$action - $targetId', + event.title, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, ), ), - if (params.toString().isNotEmpty) + if (event.paramsText.isNotEmpty) Text( - params.toString(), + event.paramsText, style: const TextStyle( fontSize: 11, color: Colors.white38, @@ -172,9 +160,9 @@ class _EventRow extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - if (actor.toString().isNotEmpty) + if (event.actor.isNotEmpty) Text( - actor.toString(), + event.actor, style: const TextStyle( fontSize: 10, color: Colors.white24, @@ -188,15 +176,4 @@ class _EventRow extends StatelessWidget { ), ); } - - String _formatTime(String iso) { - if (iso.isEmpty) return ''; - try { - final d = DateTime.parse(iso); - String pad(int n) => n.toString().padLeft(2, '0'); - return '${pad(d.day)}.${pad(d.month)} ${pad(d.hour)}:${pad(d.minute)}:${pad(d.second)}'; - } catch (_) { - return iso; - } - } } diff --git a/lib/screens/stats_screen.dart b/lib/screens/stats_screen.dart index fbb0692..bc56676 100644 --- a/lib/screens/stats_screen.dart +++ b/lib/screens/stats_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/load_state.dart'; +import '../models/stats_summary.dart'; import '../providers/providers.dart'; import '../widgets/load_error_view.dart'; @@ -30,7 +31,6 @@ class _StatsScreenState extends ConsumerState { Widget build(BuildContext context) { final statsState = ref.watch(statsProvider); final stats = statsState.data; - final groups = (stats['groups'] as List?) ?? []; return Scaffold( appBar: AppBar(title: const Text('СТАТИСТИКА')), @@ -62,15 +62,15 @@ class _StatsScreenState extends ConsumerState { ), // ─── Содержимое ─── - Expanded(child: _buildContent(statsState, groups)), + Expanded(child: _buildContent(statsState, stats.groups)), ], ), ); } Widget _buildContent( - LoadState> statsState, - List groups, + LoadState statsState, + List groups, ) { if ((statsState.isIdle || statsState.isLoading) && groups.isEmpty) { return const Center( @@ -120,8 +120,7 @@ class _StatsScreenState extends ConsumerState { } final groupIndex = index - statusHeaderCount; - final g = groups[groupIndex]; - return _StatsCard(data: g is Map ? Map.from(g) : {}); + return _StatsCard(data: groups[groupIndex]); }, ), ); @@ -130,20 +129,12 @@ class _StatsScreenState extends ConsumerState { /// Карточка статистики одной группы class _StatsCard extends StatelessWidget { - final Map data; + final GroupStats data; const _StatsCard({required this.data}); @override Widget build(BuildContext context) { - final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '') - .toString(); - final name = (data['name'] ?? targetId).toString(); - final totalCommands = data['total_commands'] ?? 0; - final togglesOn = data['toggles_on'] ?? 0; - final togglesOff = data['toggles_off'] ?? 0; - final estimatedHours = data['estimated_hours']; - return Card( margin: const EdgeInsets.only(bottom: 8), child: Padding( @@ -152,35 +143,35 @@ class _StatsCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - name, + data.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _StatRow( icon: Icons.touch_app, label: 'Всего команд', - value: totalCommands.toString(), + value: data.totalCommands.toString(), ), const SizedBox(height: 4), _StatRow( icon: Icons.power_settings_new, label: 'Включений', - value: togglesOn.toString(), + value: data.togglesOn.toString(), color: Colors.green, ), const SizedBox(height: 4), _StatRow( icon: Icons.power_off, label: 'Выключений', - value: togglesOff.toString(), + value: data.togglesOff.toString(), color: Colors.redAccent, ), - if (estimatedHours != null) ...[ + if (data.estimatedHours != null) ...[ const SizedBox(height: 4), _StatRow( icon: Icons.access_time, label: 'Примерное время работы', - value: _formatHours(estimatedHours), + value: data.formattedEstimatedHours, color: Colors.amber, ), ], @@ -189,12 +180,6 @@ class _StatsCard extends StatelessWidget { ), ); } - - String _formatHours(dynamic hours) { - final h = (hours is num) ? hours.toDouble() : 0.0; - if (h < 1) return '${(h * 60).round()} мин'; - return '${h.toStringAsFixed(1)} ч'; - } } /// Строка с иконкой, меткой и значением diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index 189d154..7a38d5e 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -205,7 +205,9 @@ void main() { final state = container.read(statsProvider); expect(state.status, LoadStatus.data); - expect(state.data['groups'], hasLength(1)); + expect(state.data.groups, hasLength(1)); + expect(state.data.groups.single.targetId, 'kitchen'); + expect(state.data.groups.single.totalCommands, 3); expect(api.requestedDays, 14); }); @@ -337,7 +339,7 @@ void main() { final state = container.read(statsProvider); expect(state.status, LoadStatus.empty); - expect(state.data['groups'], isEmpty); + expect(state.data.groups, isEmpty); }); test('event log load accepts map response and exposes data state', () async { @@ -355,6 +357,8 @@ void main() { final state = container.read(eventLogProvider); expect(state.status, LoadStatus.data); expect(state.data, hasLength(1)); + expect(state.data.single.action, 'toggle'); + expect(state.data.single.targetId, 'kitchen'); expect(api.requestedLimit, 50); }); @@ -504,7 +508,7 @@ void main() { final state = container.read(statsProvider); expect(state.status, LoadStatus.error); - expect(state.data['groups'], hasLength(1)); + expect(state.data.groups, hasLength(1)); expect(state.errorMessage, contains('Backend недоступен')); });