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>((ref) async { final r = await ref.watch(apiProvider).getSyncApi().syncStatus(); return r.data ?? const []; }); final syncRunsProvider = FutureProvider.autoDispose>((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 createState() => _HealthPageState(); } class _HealthPageState extends ConsumerState { 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 _refreshAll() async { ref.invalidate(syncStatusProvider); ref.invalidate(syncRunsProvider); ref.invalidate(dataQualityProvider); } Future _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 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 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>> 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), ), ], ), ); } } class _SourceCard extends StatelessWidget { const _SourceCard({required this.source, required this.onTrigger}); final SourceStatus source; final Future 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} с'; }