accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:fintracker_api/fintracker_api.dart';
|
||
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/empty_state.dart';
|
||
import '../../core/widgets/money_text.dart';
|
||
import '../accounts/providers.dart';
|
||
import '../portfolio/labels.dart' show formatQty, signColor;
|
||
import 'event_row.dart';
|
||
import 'labels.dart';
|
||
import 'providers.dart';
|
||
|
||
/// События: the broker ledger with filters and infinite scroll — the screen that answers
|
||
/// "where did this number come from" for a position or a reconciliation finding.
|
||
class EventsPage extends ConsumerStatefulWidget {
|
||
const EventsPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<EventsPage> createState() => _EventsPageState();
|
||
}
|
||
|
||
class _EventsPageState extends ConsumerState<EventsPage> {
|
||
final _searchController = TextEditingController();
|
||
final _scrollController = ScrollController();
|
||
Timer? _debounce;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scrollController.addListener(_onScroll);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_debounce?.cancel();
|
||
_searchController.dispose();
|
||
_scrollController.removeListener(_onScroll);
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onScroll() {
|
||
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
|
||
ref.read(eventsControllerProvider.notifier).loadMore();
|
||
}
|
||
}
|
||
|
||
void _onSearchChanged(String value) {
|
||
_debounce?.cancel();
|
||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||
);
|
||
});
|
||
}
|
||
|
||
Future<void> _pickDateRange() async {
|
||
final filter = ref.read(eventsControllerProvider.notifier).filter;
|
||
final now = DateTime.now();
|
||
final picked = await showDateRangePicker(
|
||
context: context,
|
||
firstDate: DateTime.utc(2015),
|
||
lastDate: now,
|
||
initialDateRange: filter.from != null && filter.to != null
|
||
? DateTimeRange(start: filter.from!, end: filter.to!)
|
||
: null,
|
||
helpText: 'Период',
|
||
);
|
||
if (picked != null) {
|
||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _clearDateRange() {
|
||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||
(f) => f.copyWith(from: () => null, to: () => null),
|
||
);
|
||
}
|
||
|
||
void _showDetail(EventOut e) {
|
||
final accountNames = ref.read(accountNamesProvider);
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(eventsControllerProvider);
|
||
final filter = ref.watch(eventsControllerProvider.notifier).filter;
|
||
final accountNames = ref.watch(accountNamesProvider);
|
||
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('События'),
|
||
actions: [
|
||
if (state.total > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Center(child: Text('${state.items.length} из ${state.total}')),
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
_Filters(
|
||
filter: filter,
|
||
accounts: accounts,
|
||
searchController: _searchController,
|
||
onSearchChanged: _onSearchChanged,
|
||
onPickDateRange: _pickDateRange,
|
||
onClearDateRange: _clearDateRange,
|
||
),
|
||
const Divider(height: 1),
|
||
Expanded(child: _buildList(state, accountNames)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildList(EventsState state, Map<int, String> accountNames) {
|
||
if (state.loading && state.items.isEmpty) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (state.error != null && state.items.isEmpty) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||
const SizedBox(height: 8),
|
||
Text('${state.error}', textAlign: TextAlign.center),
|
||
const SizedBox(height: 12),
|
||
FilledButton.tonal(
|
||
onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(),
|
||
child: const Text('Повторить'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
if (state.items.isEmpty) {
|
||
return const EmptyState(
|
||
icon: Icons.receipt_long_outlined,
|
||
message: 'Событий не найдено — измените фильтры или выполните синхронизацию брокера.',
|
||
);
|
||
}
|
||
return RefreshIndicator(
|
||
onRefresh: () => ref.read(eventsControllerProvider.notifier).refresh(),
|
||
child: ListView.builder(
|
||
controller: _scrollController,
|
||
itemCount: state.items.length + 1,
|
||
itemBuilder: (context, index) {
|
||
if (index == state.items.length) {
|
||
if (!state.hasMore) return const SizedBox(height: 24);
|
||
return Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Center(
|
||
child: state.loadingMore
|
||
? const CircularProgressIndicator()
|
||
: TextButton(
|
||
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(),
|
||
child: const Text('Ещё'),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final e = state.items[index];
|
||
return EventRow(
|
||
event: e,
|
||
accountName: accountNames[e.accountId],
|
||
onTap: () => _showDetail(e),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The filter bar. `instrument_id` is left out on purpose — there is no instrument picker
|
||
/// on this screen, and the search box already matches a ticker.
|
||
class _Filters extends ConsumerWidget {
|
||
const _Filters({
|
||
required this.filter,
|
||
required this.accounts,
|
||
required this.searchController,
|
||
required this.onSearchChanged,
|
||
required this.onPickDateRange,
|
||
required this.onClearDateRange,
|
||
});
|
||
|
||
final EventsFilter filter;
|
||
final List<AccountOut> accounts;
|
||
final TextEditingController searchController;
|
||
final ValueChanged<String> onSearchChanged;
|
||
final VoidCallback onPickDateRange;
|
||
final VoidCallback onClearDateRange;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final notifier = ref.read(eventsControllerProvider.notifier);
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
TextField(
|
||
controller: searchController,
|
||
decoration: const InputDecoration(
|
||
prefixIcon: Icon(Icons.search),
|
||
hintText: 'Поиск по описанию или тикеру',
|
||
isDense: true,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onChanged: onSearchChanged,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
InputChip(
|
||
avatar: const Icon(Icons.date_range, size: 18),
|
||
label: Text(
|
||
filter.from != null && filter.to != null
|
||
? '${ruDate(filter.from!)} – ${ruDate(filter.to!)}'
|
||
: 'Период',
|
||
),
|
||
onPressed: onPickDateRange,
|
||
onDeleted: filter.from != null ? onClearDateRange : null,
|
||
),
|
||
SizedBox(
|
||
width: 180,
|
||
child: DropdownButtonFormField<int?>(
|
||
initialValue: filter.accountId,
|
||
isDense: true,
|
||
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
|
||
items: [
|
||
const DropdownMenuItem(value: null, child: Text('Все счета')),
|
||
for (final a in accounts)
|
||
DropdownMenuItem(value: a.id, child: Text(a.name)),
|
||
],
|
||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 180,
|
||
child: DropdownButtonFormField<EventKind?>(
|
||
initialValue: filter.kind,
|
||
isDense: true,
|
||
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
|
||
items: [
|
||
const DropdownMenuItem(value: null, child: Text('Все типы')),
|
||
for (final k in filterableEventKinds)
|
||
DropdownMenuItem(value: k, child: Text(eventKindLabel(k))),
|
||
],
|
||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 180,
|
||
child: DropdownButtonFormField<EventStatus?>(
|
||
initialValue: filter.status,
|
||
isDense: true,
|
||
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
|
||
items: [
|
||
const DropdownMenuItem(value: null, child: Text('Любой статус')),
|
||
for (final s in EventStatus.values)
|
||
if (s != EventStatus.unknownDefaultOpenApi)
|
||
DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))),
|
||
],
|
||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)),
|
||
),
|
||
),
|
||
FilterChip(
|
||
label: const Text('Внешние потоки'),
|
||
tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR',
|
||
selected: filter.externalFlow == true,
|
||
onSelected: (on) =>
|
||
notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EventDetailSheet extends StatelessWidget {
|
||
const _EventDetailSheet({required this.event, required this.accountNames});
|
||
|
||
final EventOut event;
|
||
final Map<int, String> accountNames;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final e = event;
|
||
final theme = Theme.of(context);
|
||
String money(String? v, String currency) => v == null ? '—' : MoneyText.format(v, currency);
|
||
final rows = <(String, String)>[
|
||
('Дата сделки', ruDate(e.tradeDate)),
|
||
('Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
|
||
'${e.ts.minute.toString().padLeft(2, '0')}'),
|
||
('Тип', eventKindLabel(e.kind)),
|
||
('Статус', eventStatusLabel(e.status)),
|
||
('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'),
|
||
if (e.ticker != null) ('Инструмент', e.ticker!),
|
||
if (e.quantity != null) ('Количество', formatQty(e.quantity!)),
|
||
if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)),
|
||
('Сумма', money(e.amount, e.currency)),
|
||
('Сумма, ₽', money(e.amountRub, 'RUB')),
|
||
if (e.fee != null) ('Комиссия', money(e.fee, e.currency)),
|
||
if (e.tax != null) ('Налог', money(e.tax, e.currency)),
|
||
if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)),
|
||
('Внешний поток', e.externalFlow ? 'да' : 'нет'),
|
||
if (e.description != null) ('Описание', e.description!),
|
||
];
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge),
|
||
),
|
||
Text(
|
||
MoneyText.format(e.amount, e.currency),
|
||
style: theme.textTheme.titleMedium
|
||
?.copyWith(color: signColor(context, e.amount)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
for (final (label, value) in rows)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 140,
|
||
child: Text(label, style: theme.textTheme.bodySmall),
|
||
),
|
||
Expanded(child: Text(value)),
|
||
],
|
||
),
|
||
),
|
||
if (e.instrumentId != null) ...[
|
||
const SizedBox(height: 12),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: FilledButton.tonalIcon(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
context.go('/portfolio/instrument/${e.instrumentId}');
|
||
},
|
||
icon: const Icon(Icons.open_in_new, size: 18),
|
||
label: const Text('К инструменту'),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|