Files
fin-tracker/app/lib/features/health/health_page.dart
T
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

379 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} с';
}