Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
379 lines
11 KiB
Dart
379 lines
11 KiB
Dart
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} с';
|
||
}
|