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

234 lines
7.8 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../pending/providers.dart';
import 'data/imports_api.dart';
import 'data/report_picker.dart';
import 'labels.dart';
import 'providers.dart';
/// Импорт: the list of uploaded broker reports plus the upload button.
///
/// Uploading never writes to the ledger — it parses the file and opens the preview, where
/// the numbers are checked against the ledger before anything is committed.
class ImportsPage extends ConsumerStatefulWidget {
const ImportsPage({super.key});
@override
ConsumerState<ImportsPage> createState() => _ImportsPageState();
}
class _ImportsPageState extends ConsumerState<ImportsPage> {
bool _uploading = false;
Future<void> _upload() async {
final picked = await ref.read(reportPickerProvider).pick();
if (picked == null || !mounted) return;
setState(() => _uploading = true);
try {
final preview = await ref.read(importsApiProvider).upload(picked);
if (!mounted) return;
ref.invalidate(importsListProvider);
if (preview.duplicateOfId != null) {
_snack(
'Этот файл уже загружали — открыт существующий импорт №${preview.id}',
);
}
context.go('/imports/${preview.id}');
} catch (e) {
if (!mounted) return;
_snack(importErrorMessage(e));
} finally {
if (mounted) setState(() => _uploading = false);
}
}
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
@override
Widget build(BuildContext context) {
final imports = ref.watch(importsListProvider);
final pendingCount = ref.watch(pendingCountProvider).valueOrNull ?? 0;
return Scaffold(
appBar: AppBar(
title: const Text('Импорт отчётов'),
actions: [
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () {
ref.invalidate(importsListProvider);
ref.invalidate(pendingCountProvider);
},
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _uploading ? null : _upload,
icon: _uploading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(importsListProvider);
ref.invalidate(pendingCountProvider);
},
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [
if (pendingCount > 0)
Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile(
leading: const Icon(Icons.help_outline),
title: Text('Нераспознанных инструментов: $pendingCount'),
subtitle: const Text(
'События по ним ждут в статусе pending и не попадают в аналитику.',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/instruments/pending'),
),
),
const _StatusFilter(),
const SizedBox(height: 8),
AsyncValueView(
value: imports,
onRetry: () => ref.invalidate(importsListProvider),
data: (rows) => rows.isEmpty
? const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.upload_file_outlined,
message:
'Отчёты ещё не загружались.\n'
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
),
)
: Column(
children: [
for (final row in rows) _ImportCard(item: row),
],
),
),
],
),
),
);
}
}
class _StatusFilter extends ConsumerWidget {
const _StatusFilter();
static const _options = <String?, String>{
null: 'Все',
'uploaded': 'Загружены',
'parsed': 'Разобраны',
'committed': 'Импортированы',
'failed': 'С ошибкой',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(importsStatusFilterProvider);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(
spacing: 8,
children: [
for (final e in _options.entries)
ChoiceChip(
label: Text(e.value),
selected: current == e.key,
onSelected: (_) =>
ref.read(importsStatusFilterProvider.notifier).state = e.key,
),
],
),
);
}
}
class _ImportCard extends StatelessWidget {
const _ImportCard({required this.item});
final ImportPreview item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final period = item.periodFrom != null && item.periodTo != null
? '${ruDate(item.periodFrom!)} – ${ruDate(item.periodTo!)}'
: 'период не определён';
final subtitle = [
period,
item.accountName ??
(item.accountId != null
? 'счёт #${item.accountId}'
: 'счёт не найден'),
if (item.uploadedAt != null)
'загружен ${ruDate(item.uploadedAt!.toLocal())}',
if (item.sizeBytes != null) formatBytes(item.sizeBytes),
].join(' · ');
return Card(
child: ListTile(
onTap: () => context.go('/imports/${item.id}'),
title: Row(
children: [
Flexible(
child: Text(item.filename, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
parseStatusChip(context, item.parseStatus),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${brokerLabel(item.broker)} · $subtitle'),
if (item.counts.eventsTotal > 0)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'событий ${item.counts.eventsTotal}'
' · новых ${item.counts.eventsNew}'
' · дубликатов ${item.counts.eventsDuplicate}',
style: theme.textTheme.bodySmall,
),
),
if (item.isFailed && item.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
item.error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
trailing: const Icon(Icons.chevron_right),
),
);
}
}