feat(app): страница здоровья вместо разрозненных /sync и data quality

/sync и блок data quality на дашборде сведены в один /health с двумя
вкладками (Источники, Качество данных); дашборд ссылается на неё вместо
собственного bottom sheet. Список находок вынесен в общий виджет
DataQualityList, чтобы не дублировать рендер.
This commit is contained in:
Dmitry
2026-09-19 12:59:27 +03:00
parent ffc5ed959a
commit 68e1e53535
6 changed files with 288 additions and 108 deletions
@@ -0,0 +1,73 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import '../../core/widgets/empty_state.dart';
/// Maps a `/metrics/data-quality` row's severity string to a color. Shared by
/// the Здоровье page's findings list and the dashboard's summary chip so the
/// two surfaces never disagree on what a severity looks like.
Color severityColor(String severity) {
final s = severity.toLowerCase();
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
if (s.contains('warn')) return const Color(0xFFFAB219);
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
return const Color(0xFFEC835A);
}
/// Renders `DataQualityRow`s as a colored-dot list: one implementation used
/// by both the Здоровье page and (if reused) the dashboard's quick peek.
class DataQualityList extends StatelessWidget {
const DataQualityList({required this.rows, super.key});
final List<DataQualityRow> rows;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
return const EmptyState(
icon: Icons.check_circle_outline,
message: 'Проблем не найдено.',
);
}
return Column(
children: [for (final row in rows) _DataQualityTile(row: row)],
);
}
}
class _DataQualityTile extends StatelessWidget {
const _DataQualityTile({required this.row});
final DataQualityRow row;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 4, right: 10),
width: 10,
height: 10,
decoration: BoxDecoration(color: severityColor(row.severity), shape: BoxShape.circle),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${row.checkName} (${row.count})',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 2),
Text(row.detail),
],
),
),
],
),
),
);
}
}
+346
View File
@@ -0,0 +1,346 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../home/providers.dart' show dataQualityProvider;
import 'data_quality_list.dart';
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async {
final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
return r.data ?? const [];
});
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async {
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
return r.data ?? const [];
});
const _pollInterval = Duration(seconds: 10);
/// Здоровье: the one screen that answers "is my data OK right now" — source
/// sync status with a manual trigger per source, a log of the last 20 runs,
/// and the data-quality findings, as two tabs of one page. Polls
/// `/sync/status` every 10 s while this page is mounted, regardless of which
/// tab is showing.
class HealthPage extends ConsumerStatefulWidget {
const HealthPage({this.initialTab = 0, super.key});
/// 0 = «Источники», 1 = «Качество данных». Lets the dashboard's data-quality
/// chip deep-link straight to the findings tab instead of always opening on
/// sources.
final int initialTab;
@override
ConsumerState<HealthPage> createState() => _HealthPageState();
}
class _HealthPageState extends ConsumerState<HealthPage> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(_pollInterval, (_) {
if (!mounted) return;
ref.invalidate(syncStatusProvider);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _refreshAll() async {
ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider);
ref.invalidate(dataQualityProvider);
}
Future<void> _trigger(String source) async {
try {
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider);
}
}
@override
Widget build(BuildContext context) {
final dataQuality = ref.watch(dataQualityProvider);
final issueCount = dataQuality.valueOrNull?.data.length;
return DefaultTabController(
length: 2,
initialIndex: widget.initialTab,
child: Scaffold(
appBar: AppBar(
title: const Text('Здоровье'),
actions: [
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: _refreshAll,
),
],
bottom: TabBar(
tabs: [
const Tab(icon: Icon(Icons.cable_outlined), text: 'Источники'),
Tab(
icon: const Icon(Icons.fact_check_outlined),
text: issueCount == null || issueCount == 0
? 'Качество данных'
: 'Качество данных ($issueCount)',
),
],
),
),
body: TabBarView(
children: [
_SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger),
_QualityTab(dataQuality: dataQuality, onRefresh: () async => ref.invalidate(dataQualityProvider)),
],
),
),
);
}
}
class _SourcesTab extends ConsumerWidget {
const _SourcesTab({required this.onRefresh, required this.onTrigger});
final Future<void> Function() onRefresh;
final Future<void> Function(String source) onTrigger;
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(syncStatusProvider);
final runs = ref.watch(syncRunsProvider);
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Источники', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
AsyncValueView(
value: status,
onRetry: () => ref.invalidate(syncStatusProvider),
data: (rows) => rows.isEmpty
? const EmptyState(
icon: Icons.cable_outlined,
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
)
: Column(
children: [for (final s in rows) _SourceCard(source: s, onTrigger: onTrigger)],
),
),
const SizedBox(height: 24),
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
AsyncValueView(
value: runs,
onRetry: () => ref.invalidate(syncRunsProvider),
data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.')
: Column(children: [for (final r in rows) _RunTile(run: r)]),
),
],
),
);
}
}
class _QualityTab extends StatelessWidget {
const _QualityTab({required this.dataQuality, required this.onRefresh});
final AsyncValue<Cached<List<DataQualityRow>>> dataQuality;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
AsyncValueView(
value: dataQuality,
data: (cached) => DataQualityList(rows: cached.data),
),
],
),
);
}
}
class _SourceCard extends StatelessWidget {
const _SourceCard({required this.source, required this.onTrigger});
final SourceStatus source;
final Future<void> Function(String source) onTrigger;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Row(
children: [
Text(source.source_, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(width: 8),
_statusChip(context, source.lastRunStatus),
if (source.queued) ...[
const SizedBox(width: 8),
const Chip(
visualDensity: VisualDensity.compact,
label: Text('в очереди'),
),
],
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text([
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}',
].join(' · ')),
),
trailing: IconButton(
tooltip: 'Запустить синхронизацию',
icon: const Icon(Icons.sync),
onPressed: source.queued ? null : () => onTrigger(source.source_),
),
),
);
}
}
class _RunTile extends StatelessWidget {
const _RunTile({required this.run});
final SyncRunOut run;
@override
Widget build(BuildContext context) {
final duration = run.finishedAt?.difference(run.startedAt);
final subtitle = [
_dateFmt.format(run.startedAt.toLocal()),
duration != null ? _formatDuration(duration) : 'выполняется',
].join(' · ');
final counts = run.counts;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (counts != null && counts.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final e in counts.entries)
Chip(
visualDensity: VisualDensity.compact,
label: Text('${e.key}=${e.value}'),
),
],
),
],
);
if (run.error != null && run.error!.isNotEmpty) {
return Card(
child: ExpansionTile(
title: _runTitle(context),
subtitle: Text(subtitle),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
content,
const SizedBox(height: 8),
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
),
),
],
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_runTitle(context),
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
),
content,
],
),
),
);
}
Widget _runTitle(BuildContext context) {
return Row(
children: [
Text(run.source_, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(width: 8),
_statusChip(context, run.status),
],
);
}
}
Widget _statusChip(BuildContext context, RunStatus? status) {
if (status == null) {
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных'));
}
final scheme = Theme.of(context).colorScheme;
final (label, color) = switch (status) {
RunStatus.ok => ('ok', Colors.green),
RunStatus.error => ('error', scheme.error),
RunStatus.running => ('running', Colors.amber.shade700),
RunStatus.unknownDefaultOpenApi => ('неизвестно', scheme.outline),
};
return Chip(
visualDensity: VisualDensity.compact,
label: Text(label),
backgroundColor: color.withValues(alpha: 0.15),
labelStyle: TextStyle(color: color),
side: BorderSide(color: color.withValues(alpha: 0.4)),
);
}
String _shortCursor(String cursor) {
if (cursor.length <= 20) return cursor;
return '${cursor.substring(0, 10)}${cursor.substring(cursor.length - 6)}';
}
String _formatDuration(Duration d) {
if (d.inMinutes >= 1) return '${d.inMinutes} мин ${d.inSeconds % 60} с';
return '${d.inSeconds} с';
}