Files
fin-tracker/app/lib/features/sync/sync_page.dart
T
Dmitry ce0966fca2 feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
2026-09-18 13:44:09 +03:00

277 lines
8.5 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/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.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);
/// Синк: 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});
@override
ConsumerState<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends ConsumerState<SyncPage> {
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);
}
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 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,
),
],
),
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 _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} с';
}