Files
Dmitry 2645e56197 feat(app): календарь выплат сеткой по месяцам
Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
2026-09-20 10:46:38 +03:00

282 lines
9.2 KiB
Dart
Raw Permalink 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 '../../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 'calendar_grid.dart';
import 'data/income_api.dart';
import 'entry_row.dart';
import 'labels.dart';
import 'providers.dart';
/// Календарь: the payments as a month grid (default) or as a list running forward.
class IncomeCalendarTab extends ConsumerWidget {
const IncomeCalendarTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final view = ref.watch(calendarViewProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SegmentedButton<CalendarView>(
showSelectedIcon: false,
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: const [
ButtonSegment(
value: CalendarView.month,
icon: Icon(Icons.calendar_month_outlined),
label: Text('Календарь'),
),
ButtonSegment(
value: CalendarView.list,
icon: Icon(Icons.view_list_outlined),
label: Text('Список'),
),
],
selected: {view},
onSelectionChanged: (s) =>
ref.read(calendarViewProvider.notifier).state = s.first,
),
),
Expanded(
child: view == CalendarView.month
? const IncomeMonthView()
: const _IncomeList(),
),
],
);
}
}
/// 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 _IncomeList extends ConsumerWidget {
const _IncomeList();
@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) IncomeEntryRow(entry: e)],
),
);
}
}
/// 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)),
];
}