feat(app): календарь выплат сеткой по месяцам

Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
This commit is contained in:
Dmitry
2026-09-20 10:46:38 +03:00
parent 6891028074
commit 2645e56197
5 changed files with 755 additions and 63 deletions
+48 -63
View File
@@ -1,24 +1,65 @@
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 'calendar_grid.dart';
import 'data/income_api.dart';
import 'entry_row.dart';
import 'labels.dart';
import 'providers.dart';
/// Календарь: every expected payment, month by month, each row carrying its [BasisChip].
/// Календарь: 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 IncomeCalendarTab extends ConsumerWidget {
const IncomeCalendarTab({super.key});
class _IncomeList extends ConsumerWidget {
const _IncomeList();
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -207,64 +248,8 @@ class _MonthCard extends StatelessWidget {
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,
),
],
child: Column(
children: [for (final e in entries) IncomeEntryRow(entry: e)],
),
);
}