feat(app): календарь выплат сеткой по месяцам
Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
This commit is contained in:
@@ -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)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user