Files
fin-tracker/app/lib/features/income/calendar_tab.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
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 отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

277 lines
9.6 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:decimal/decimal.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/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatQty;
import 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// Календарь: every expected payment, month by month, each row carrying its [BasisChip].
///
/// The total is deliberately paired with the by-basis split: adding «объявлено» and «по
/// истории» into one number turns a guess into a promise.
class IncomeCalendarTab extends ConsumerWidget {
const IncomeCalendarTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final calendar = ref.watch(incomeCalendarProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeCalendarProvider),
child: AsyncValueView(
value: calendar,
onRetry: () => ref.invalidate(incomeCalendarProvider),
data: (cached) {
final data = cached.data;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _CalendarControls(),
const SizedBox(height: 12),
_Totals(data: data),
const SizedBox(height: 16),
if (data.entries.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
),
)
else
for (final group in _groupByMonth(data.entries))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MonthCard(month: group.key, entries: group.value),
),
],
);
},
),
);
}
}
class _CalendarControls extends ConsumerWidget {
const _CalendarControls();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final m in const [3, 6, 12, 24])
ChoiceChip(
label: Text('$m мес'),
selected: months == m,
onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m,
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Показать выплаченные'),
selected: includePaid,
onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v,
),
],
);
}
}
class _Totals extends StatelessWidget {
const _Totals({required this.data});
final IncomeCalendar data;
@override
Widget build(BuildContext context) {
final byBasis = data.byBasis;
final guess = Decimal.tryParse(byBasis['history'] ?? '0') ?? Decimal.zero;
return SectionCard(
title: 'Ожидается',
subtitle: data.asOf == null ? null : 'на ${ruDate(data.asOf!)}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MoneyText(
data.totalExpectedRub,
currency: data.currency,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
if (byBasis.isEmpty)
Text(
'Сервер не прислал разбивку по основанию — сумму нельзя трактовать как прогноз.',
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 12,
runSpacing: 8,
children: [
for (final e in _orderedBases(byBasis.keys))
_BasisTotal(basis: e, amount: byBasis[e] ?? '0'),
],
),
if (guess > Decimal.zero) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 16, color: basisColor('history')),
const SizedBox(width: 6),
Expanded(
child: Text(
'Из них ${MoneyText.format(byBasis['history']!, data.currency)} — '
'экстраполяция по истории выплат: этих выплат может не быть.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
],
),
);
}
}
class _BasisTotal extends StatelessWidget {
const _BasisTotal({required this.basis, required this.amount});
final String basis;
final String amount;
@override
Widget build(BuildContext context) {
return Tooltip(
message: basisDescription(basis),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BasisChip(basis: basis),
const SizedBox(height: 4),
MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall),
],
),
);
}
}
class _MonthCard extends StatelessWidget {
const _MonthCard({required this.month, required this.entries});
final DateTime month;
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final total = entries
.map((e) => Decimal.tryParse(e.amountRub ?? '') ?? Decimal.zero)
.fold(Decimal.zero, (a, b) => a + b);
final unconverted = entries.where((e) => e.amountRub == null).length;
return SectionCard(
// `_groupByMonth` parks dateless entries under a sentinel year rather than dropping
// them: money with an unknown date is still money.
title: month.year == 9999 ? 'Дата неизвестна' : ruMonthYear(month),
subtitle: unconverted == 0
? null
: 'у $unconverted выплат нет курса на дату — в сумму месяца не вошли',
trailing: MoneyText(
total.toString(),
currency: 'RUB',
style: Theme.of(context).textTheme.titleMedium,
),
child: Column(children: [for (final e in entries) _EntryRow(entry: e)]),
);
}
}
class _EntryRow extends StatelessWidget {
const _EntryRow({required this.entry});
final IncomeEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final details = [
incomeKindLabel(entry.kind),
if (entry.qty != null && entry.perUnit != null)
'${formatQty(entry.qty!)} × ${MoneyText.format(entry.perUnit!, entry.currency)}',
if (entry.recordDate != null) 'отсечка ${ruDate(entry.recordDate!)}',
if (entry.taxWithheld != null)
'налог ${MoneyText.format(entry.taxWithheld!, entry.currency)}',
].join(' · ');
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
onTap: entry.instrumentId == null
? null
: () => context.push('/portfolio/instrument/${entry.instrumentId}'),
title: Row(
children: [
Flexible(child: Text(entry.title, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
BasisChip(basis: entry.basis),
],
),
subtitle: Text(
'${entry.expectedDate == null ? 'дата неизвестна' : ruDate(entry.expectedDate!)} · $details',
style: theme.textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.amount != null)
MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall),
// no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽.
if (entry.currency != 'RUB')
Text(
entry.amountRub == null
? '— ₽ (нет курса)'
: MoneyText.format(entry.amountRub!, 'RUB'),
style: theme.textTheme.bodySmall,
),
],
),
);
}
}
/// Groups entries by month, preserving the server's order inside each month. Entries with
/// no date go last, under their own heading — they are still expected money.
List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entries) {
final groups = <DateTime, List<IncomeEntry>>{};
for (final e in entries) {
final d = e.expectedDate;
final key = d == null ? DateTime.utc(9999) : DateTime.utc(d.year, d.month);
groups.putIfAbsent(key, () => []).add(e);
}
final keys = groups.keys.toList()..sort();
return [for (final k in keys) MapEntry(k, groups[k]!)];
}
/// Contract order first, anything unexpected after it.
List<String> _orderedBases(Iterable<String> bases) {
const order = ['schedule', 'announced', 'history', 'paid'];
final set = bases.toSet();
return [...order.where(set.contains), ...set.where((b) => !order.contains(b))];
}