Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
282 lines
9.2 KiB
Dart
282 lines
9.2 KiB
Dart
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)),
|
||
];
|
||
}
|