feat: type stats and event log models
This commit is contained in:
116
lib/models/event_log_item.dart
Normal file
116
lib/models/event_log_item.dart
Normal file
@@ -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<String, dynamic>.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<EventLogItem> 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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic> 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});
|
||||||
|
}
|
||||||
141
lib/models/stats_summary.dart
Normal file
141
lib/models/stats_summary.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
class StatsSummary {
|
||||||
|
final List<GroupStats> groups;
|
||||||
|
|
||||||
|
const StatsSummary({required this.groups});
|
||||||
|
|
||||||
|
static const empty = StatsSummary(groups: <GroupStats>[]);
|
||||||
|
|
||||||
|
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<String, dynamic>.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<GroupStats> 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<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.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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic> 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});
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import '../app/error_message.dart';
|
|||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
import '../models/api_key_info.dart';
|
import '../models/api_key_info.dart';
|
||||||
import '../models/auth_info.dart';
|
import '../models/auth_info.dart';
|
||||||
|
import '../models/event_log_item.dart';
|
||||||
import '../models/ignis_device.dart';
|
import '../models/ignis_device.dart';
|
||||||
import '../models/ignis_group.dart';
|
import '../models/ignis_group.dart';
|
||||||
import '../models/ignis_scene.dart';
|
import '../models/ignis_scene.dart';
|
||||||
import '../models/home_config.dart';
|
import '../models/home_config.dart';
|
||||||
import '../models/schedule_task.dart';
|
import '../models/schedule_task.dart';
|
||||||
|
import '../models/stats_summary.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/geofence_worker.dart';
|
import '../services/geofence_worker.dart';
|
||||||
@@ -697,30 +699,23 @@ class TasksNotifier extends Notifier<LoadState<List<ScheduleTask>>> {
|
|||||||
|
|
||||||
// ─── Статистика ──────────────────────────────────────────────
|
// ─── Статистика ──────────────────────────────────────────────
|
||||||
|
|
||||||
final statsProvider =
|
final statsProvider = NotifierProvider<StatsNotifier, LoadState<StatsSummary>>(
|
||||||
NotifierProvider<StatsNotifier, LoadState<Map<String, dynamic>>>(
|
|
||||||
() => StatsNotifier(),
|
() => StatsNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class StatsNotifier extends Notifier<LoadState<Map<String, dynamic>>> {
|
class StatsNotifier extends Notifier<LoadState<StatsSummary>> {
|
||||||
@override
|
@override
|
||||||
LoadState<Map<String, dynamic>> build() =>
|
LoadState<StatsSummary> build() => const LoadState.idle(StatsSummary.empty);
|
||||||
const LoadState.idle(<String, dynamic>{});
|
|
||||||
|
|
||||||
Future<void> load({int days = 7}) async {
|
Future<void> load({int days = 7}) 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.getStatsSummary(days: days);
|
final res = await api.getStatsSummary(days: days);
|
||||||
final data = res.data;
|
final stats = StatsSummary.fromApi(res.data);
|
||||||
if (data is! Map) {
|
state = stats.groups.isEmpty
|
||||||
throw FormatException('stats summary должен быть объектом');
|
? LoadState.empty(stats)
|
||||||
}
|
: LoadState.data(stats);
|
||||||
|
|
||||||
final stats = Map<String, dynamic>.from(data);
|
|
||||||
final groups = stats['groups'];
|
|
||||||
final hasGroups = groups is List && groups.isNotEmpty;
|
|
||||||
state = hasGroups ? LoadState.data(stats) : LoadState.empty(stats);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = LoadState.error(state.data, describeLoadError(e));
|
state = LoadState.error(state.data, describeLoadError(e));
|
||||||
}
|
}
|
||||||
@@ -730,32 +725,21 @@ class StatsNotifier extends Notifier<LoadState<Map<String, dynamic>>> {
|
|||||||
// ─── Лог событий ─────────────────────────────────────────────
|
// ─── Лог событий ─────────────────────────────────────────────
|
||||||
|
|
||||||
final eventLogProvider =
|
final eventLogProvider =
|
||||||
NotifierProvider<EventLogNotifier, LoadState<List<dynamic>>>(
|
NotifierProvider<EventLogNotifier, LoadState<List<EventLogItem>>>(
|
||||||
() => EventLogNotifier(),
|
() => EventLogNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
|
class EventLogNotifier extends Notifier<LoadState<List<EventLogItem>>> {
|
||||||
@override
|
@override
|
||||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
LoadState<List<EventLogItem>> build() =>
|
||||||
|
const LoadState.idle(<EventLogItem>[]);
|
||||||
|
|
||||||
Future<void> load({int limit = 100}) async {
|
Future<void> load({int limit = 100}) 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.getStatsLog(limit: limit);
|
final res = await api.getStatsLog(limit: limit);
|
||||||
final data = res.data;
|
final events = EventLogItem.listFromApi(res.data);
|
||||||
late final List<dynamic> events;
|
|
||||||
if (data is List) {
|
|
||||||
events = List<dynamic>.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<dynamic>.from(value);
|
|
||||||
} else {
|
|
||||||
throw FormatException('stats log должен быть списком событий');
|
|
||||||
}
|
|
||||||
|
|
||||||
state = events.isEmpty ? LoadState.empty(events) : LoadState.data(events);
|
state = events.isEmpty ? LoadState.empty(events) : LoadState.data(events);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../models/event_log_item.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
@@ -52,8 +53,8 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(
|
Widget _buildContent(
|
||||||
LoadState<List<dynamic>> eventsState,
|
LoadState<List<EventLogItem>> eventsState,
|
||||||
List<dynamic> events,
|
List<EventLogItem> events,
|
||||||
) {
|
) {
|
||||||
if ((eventsState.isIdle || eventsState.isLoading) && events.isEmpty) {
|
if ((eventsState.isIdle || eventsState.isLoading) && events.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -103,10 +104,7 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final eventIndex = index - statusHeaderCount;
|
final eventIndex = index - statusHeaderCount;
|
||||||
final event = events[eventIndex];
|
return _EventRow(event: events[eventIndex]);
|
||||||
return _EventRow(
|
|
||||||
event: event is Map ? Map<String, dynamic>.from(event) : {},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -114,22 +112,12 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EventRow extends StatelessWidget {
|
class _EventRow extends StatelessWidget {
|
||||||
final Map<String, dynamic> event;
|
final EventLogItem event;
|
||||||
|
|
||||||
const _EventRow({required this.event});
|
const _EventRow({required this.event});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -141,7 +129,7 @@ class _EventRow extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 80,
|
width: 80,
|
||||||
child: Text(
|
child: Text(
|
||||||
formattedTime,
|
event.formattedTime,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.white38,
|
color: Colors.white38,
|
||||||
@@ -156,15 +144,15 @@ class _EventRow extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$action - $targetId',
|
event.title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (params.toString().isNotEmpty)
|
if (event.paramsText.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
params.toString(),
|
event.paramsText,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.white38,
|
color: Colors.white38,
|
||||||
@@ -172,9 +160,9 @@ class _EventRow extends StatelessWidget {
|
|||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (actor.toString().isNotEmpty)
|
if (event.actor.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
actor.toString(),
|
event.actor,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Colors.white24,
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../models/stats_summary.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final statsState = ref.watch(statsProvider);
|
final statsState = ref.watch(statsProvider);
|
||||||
final stats = statsState.data;
|
final stats = statsState.data;
|
||||||
final groups = (stats['groups'] as List<dynamic>?) ?? [];
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('СТАТИСТИКА')),
|
appBar: AppBar(title: const Text('СТАТИСТИКА')),
|
||||||
@@ -62,15 +62,15 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ─── Содержимое ───
|
// ─── Содержимое ───
|
||||||
Expanded(child: _buildContent(statsState, groups)),
|
Expanded(child: _buildContent(statsState, stats.groups)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(
|
Widget _buildContent(
|
||||||
LoadState<Map<String, dynamic>> statsState,
|
LoadState<StatsSummary> statsState,
|
||||||
List<dynamic> groups,
|
List<GroupStats> groups,
|
||||||
) {
|
) {
|
||||||
if ((statsState.isIdle || statsState.isLoading) && groups.isEmpty) {
|
if ((statsState.isIdle || statsState.isLoading) && groups.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -120,8 +120,7 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final groupIndex = index - statusHeaderCount;
|
final groupIndex = index - statusHeaderCount;
|
||||||
final g = groups[groupIndex];
|
return _StatsCard(data: groups[groupIndex]);
|
||||||
return _StatsCard(data: g is Map ? Map<String, dynamic>.from(g) : {});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -130,20 +129,12 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
|||||||
|
|
||||||
/// Карточка статистики одной группы
|
/// Карточка статистики одной группы
|
||||||
class _StatsCard extends StatelessWidget {
|
class _StatsCard extends StatelessWidget {
|
||||||
final Map<String, dynamic> data;
|
final GroupStats data;
|
||||||
|
|
||||||
const _StatsCard({required this.data});
|
const _StatsCard({required this.data});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -152,35 +143,35 @@ class _StatsCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
data.name,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatRow(
|
_StatRow(
|
||||||
icon: Icons.touch_app,
|
icon: Icons.touch_app,
|
||||||
label: 'Всего команд',
|
label: 'Всего команд',
|
||||||
value: totalCommands.toString(),
|
value: data.totalCommands.toString(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_StatRow(
|
_StatRow(
|
||||||
icon: Icons.power_settings_new,
|
icon: Icons.power_settings_new,
|
||||||
label: 'Включений',
|
label: 'Включений',
|
||||||
value: togglesOn.toString(),
|
value: data.togglesOn.toString(),
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_StatRow(
|
_StatRow(
|
||||||
icon: Icons.power_off,
|
icon: Icons.power_off,
|
||||||
label: 'Выключений',
|
label: 'Выключений',
|
||||||
value: togglesOff.toString(),
|
value: data.togglesOff.toString(),
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
),
|
),
|
||||||
if (estimatedHours != null) ...[
|
if (data.estimatedHours != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_StatRow(
|
_StatRow(
|
||||||
icon: Icons.access_time,
|
icon: Icons.access_time,
|
||||||
label: 'Примерное время работы',
|
label: 'Примерное время работы',
|
||||||
value: _formatHours(estimatedHours),
|
value: data.formattedEstimatedHours,
|
||||||
color: Colors.amber,
|
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)} ч';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Строка с иконкой, меткой и значением
|
/// Строка с иконкой, меткой и значением
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ void main() {
|
|||||||
|
|
||||||
final state = container.read(statsProvider);
|
final state = container.read(statsProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
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);
|
expect(api.requestedDays, 14);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,7 +339,7 @@ void main() {
|
|||||||
|
|
||||||
final state = container.read(statsProvider);
|
final state = container.read(statsProvider);
|
||||||
expect(state.status, LoadStatus.empty);
|
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 {
|
test('event log load accepts map response and exposes data state', () async {
|
||||||
@@ -355,6 +357,8 @@ void main() {
|
|||||||
final state = container.read(eventLogProvider);
|
final state = container.read(eventLogProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
expect(state.status, LoadStatus.data);
|
||||||
expect(state.data, hasLength(1));
|
expect(state.data, hasLength(1));
|
||||||
|
expect(state.data.single.action, 'toggle');
|
||||||
|
expect(state.data.single.targetId, 'kitchen');
|
||||||
expect(api.requestedLimit, 50);
|
expect(api.requestedLimit, 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -504,7 +508,7 @@ void main() {
|
|||||||
|
|
||||||
final state = container.read(statsProvider);
|
final state = container.read(statsProvider);
|
||||||
expect(state.status, LoadStatus.error);
|
expect(state.status, LoadStatus.error);
|
||||||
expect(state.data['groups'], hasLength(1));
|
expect(state.data.groups, hasLength(1));
|
||||||
expect(state.errorMessage, contains('Backend недоступен'));
|
expect(state.errorMessage, contains('Backend недоступен'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user