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
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/money_text.dart';
import '../portfolio/labels.dart' show formatQty;
import 'data/income_api.dart';
import 'labels.dart';
/// One payment as a list row: what, when, on which basis, and how much. The basis chip is on
/// every row on purpose — it is what tells a declared payment from an extrapolated one.
class IncomeEntryRow extends StatelessWidget {
const IncomeEntryRow({required this.entry, super.key});
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}'),
leading: Icon(
incomeKindIcon(entry.kind),
color: basisColor(entry.basis),
size: 20,
),
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,
),
],
),
);
}
}