From 68e1e53535e4236761db674d8d20e4f6b837f201 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 12:59:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=B7=D0=B4=D0=BE=D1=80=D0=BE=D0=B2=D1=8C?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=BE=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?/sync=20=D0=B8=20data=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sync и блок data quality на дашборде сведены в один /health с двумя вкладками (Источники, Качество данных); дашборд ссылается на неё вместо собственного bottom sheet. Список находок вынесен в общий виджет DataQualityList, чтобы не дублировать рендер. --- .../features/health/data_quality_list.dart | 73 ++++++++ .../health_page.dart} | 160 +++++++++++++----- app/lib/features/home/home_page.dart | 68 +------- app/lib/features/shell/app_shell.dart | 2 +- app/lib/router.dart | 9 +- app/test/health_page_test.dart | 84 +++++++++ 6 files changed, 288 insertions(+), 108 deletions(-) create mode 100644 app/lib/features/health/data_quality_list.dart rename app/lib/features/{sync/sync_page.dart => health/health_page.dart} (60%) create mode 100644 app/test/health_page_test.dart diff --git a/app/lib/features/health/data_quality_list.dart b/app/lib/features/health/data_quality_list.dart new file mode 100644 index 0000000..45666f1 --- /dev/null +++ b/app/lib/features/health/data_quality_list.dart @@ -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 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), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/sync/sync_page.dart b/app/lib/features/health/health_page.dart similarity index 60% rename from app/lib/features/sync/sync_page.dart rename to app/lib/features/health/health_page.dart index e5eca1e..3d2b25d 100644 --- a/app/lib/features/sync/sync_page.dart +++ b/app/lib/features/health/health_page.dart @@ -8,8 +8,11 @@ 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'); @@ -25,16 +28,24 @@ final syncRunsProvider = FutureProvider.autoDispose>((ref) asyn const _pollInterval = Duration(seconds: 10); -/// Синк: source statuses with a manual trigger per source, and a log of the -/// last 20 runs. Polls `/sync/status` every 10 s while this page is mounted. -class SyncPage extends ConsumerStatefulWidget { - const SyncPage({super.key}); +/// Здоровье: 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 createState() => _SyncPageState(); + ConsumerState createState() => _HealthPageState(); } -class _SyncPageState extends ConsumerState { +class _HealthPageState extends ConsumerState { Timer? _timer; @override @@ -55,6 +66,7 @@ class _SyncPageState extends ConsumerState { Future _refreshAll() async { ref.invalidate(syncStatusProvider); ref.invalidate(syncRunsProvider); + ref.invalidate(dataQualityProvider); } Future _trigger(String source) async { @@ -71,51 +83,109 @@ class _SyncPageState extends ConsumerState { @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 Function() onRefresh; + final Future Function(String source) onTrigger; + + @override + Widget build(BuildContext context, WidgetRef ref) { final status = ref.watch(syncStatusProvider); final runs = ref.watch(syncRunsProvider); - return Scaffold( - appBar: AppBar( - title: const Text('Синк'), - actions: [ - IconButton( - tooltip: 'Обновить', - icon: const Icon(Icons.refresh), - onPressed: _refreshAll, + 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)]), ), ], ), - body: RefreshIndicator( - onRefresh: _refreshAll, - 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: _trigger)], - ), - ), - 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>> dataQuality; + final Future 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), + ), + ], ), ); } diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart index 2dbc34e..9a49aa1 100644 --- a/app/lib/features/home/home_page.dart +++ b/app/lib/features/home/home_page.dart @@ -15,18 +15,11 @@ import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/stale_banner.dart'; import '../../core/api/api_client.dart'; +import '../health/data_quality_list.dart' show severityColor; import 'providers.dart'; double _d(String s) => Decimal.parse(s).toDouble(); -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); -} - /// Обзор: the dashboard landing page — net worth, this month's cashflow, /// runway, a net worth line chart, a 12-month income/expense bar chart, and /// a data-quality summary linking to the findings. @@ -53,53 +46,6 @@ class _HomePageState extends ConsumerState { } } - void _showDataQuality(List rows) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Качество данных', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), - if (rows.isEmpty) const Text('Проблем не найдено.'), - for (final row in rows) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 4, right: 8), - 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), - Text(row.detail), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final breakdown = ref.watch(netWorthBreakdownProvider); @@ -166,10 +112,12 @@ class _HomePageState extends ConsumerState { avatar: Icon( rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined, size: 18, - color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity), + color: rows.isEmpty ? Colors.green : severityColor(rows.first.severity), ), label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'), - onPressed: rows.isEmpty ? null : () => _showDataQuality(rows), + // Health page renders the same findings via DataQualityList — no + // second implementation of this list here. + onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'), ); }, ), @@ -249,9 +197,9 @@ class _HomePageState extends ConsumerState { Align( alignment: Alignment.centerRight, child: TextButton.icon( - onPressed: () => context.go('/sync'), - icon: const Icon(Icons.sync, size: 16), - label: const Text('Синхронизация'), + onPressed: () => context.go('/health'), + icon: const Icon(Icons.monitor_heart_outlined, size: 16), + label: const Text('Здоровье'), ), ), ], diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index 326bd17..ef1c659 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -47,7 +47,7 @@ const _destinations = [ _Destination( '/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'), _Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'), - _Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'), + _Destination('/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье'), _Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'), ]; diff --git a/app/lib/router.dart b/app/lib/router.dart index f07acf8..ce334c6 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -8,6 +8,7 @@ import 'features/cashflow/cashflow_page.dart'; import 'features/categories/categories_page.dart'; import 'features/events/events_page.dart'; import 'features/goals/goals_page.dart'; +import 'features/health/health_page.dart'; import 'features/home/home_page.dart'; import 'features/income/income_page.dart'; import 'features/imports/import_preview_page.dart'; @@ -21,7 +22,6 @@ import 'features/rules/rules_page.dart'; import 'features/settings/settings_page.dart'; import 'features/shell/analytics_page.dart'; import 'features/shell/app_shell.dart'; -import 'features/sync/sync_page.dart'; import 'features/tax/tax_page.dart'; import 'features/transactions/transactions_page.dart'; @@ -82,7 +82,12 @@ final routerProvider = Provider((ref) { ), GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()), GoRoute(path: '/rules', builder: (_, _) => const RulesPage()), - GoRoute(path: '/sync', builder: (_, _) => const SyncPage()), + GoRoute( + path: '/health', + builder: (_, state) => HealthPage( + initialTab: state.uri.queryParameters['tab'] == 'quality' ? 1 : 0, + ), + ), GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), ], ), diff --git a/app/test/health_page_test.dart b/app/test/health_page_test.dart new file mode 100644 index 0000000..20e9d8f --- /dev/null +++ b/app/test/health_page_test.dart @@ -0,0 +1,84 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/features/health/health_page.dart'; +import 'package:fintracker_app/features/home/providers.dart' show dataQualityProvider; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +SourceStatus _source(String name) => SourceStatus( + source_: name, + lastRunStatus: RunStatus.ok, + lastRunAt: DateTime(2026, 9, 18, 10), + lastSuccessAt: DateTime(2026, 9, 18, 10), + cursor: null, + queued: false, + ); + +DataQualityRow _issue() => DataQualityRow( + id: 1, + ref: null, + severity: 'warn', + checkName: 'missing_fx', + count: 3, + detail: 'Курс не найден для 3 операций.', + computedAt: DateTime(2026, 9, 18, 10), + ); + +void main() { + testWidgets('shows sources on the first tab and findings on the second', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + syncStatusProvider.overrideWith((ref) async => [_source('zenmoney')]), + syncRunsProvider.overrideWith((ref) async => const []), + dataQualityProvider.overrideWith((ref) => Cached([_issue()])), + ], + child: const MaterialApp(home: HealthPage()), + ), + ); + await tester.pumpAndSettle(); + + // Starts on «Источники»: the source card is visible, the findings text isn't. + expect(find.text('zenmoney'), findsOneWidget); + expect(find.textContaining('missing_fx'), findsNothing); + + await tester.tap(find.text('Качество данных (1)')); + await tester.pumpAndSettle(); + + expect(find.textContaining('missing_fx'), findsOneWidget); + expect(find.text('Курс не найден для 3 операций.'), findsOneWidget); + }); + + testWidgets('opens directly on the findings tab when initialTab is 1', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + syncStatusProvider.overrideWith((ref) async => const []), + syncRunsProvider.overrideWith((ref) async => const []), + dataQualityProvider.overrideWith((ref) => Cached([_issue()])), + ], + child: const MaterialApp(home: HealthPage(initialTab: 1)), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('missing_fx'), findsOneWidget); + }); + + testWidgets('empty findings show the no-issues empty state', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + syncStatusProvider.overrideWith((ref) async => const []), + syncRunsProvider.overrideWith((ref) async => const []), + dataQualityProvider.overrideWith((ref) => const Cached([])), + ], + child: const MaterialApp(home: HealthPage(initialTab: 1)), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Проблем не найдено.'), findsOneWidget); + }); +}