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
+595
View File
@@ -0,0 +1,595 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/compact_number.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import 'data/income_api.dart';
import 'entry_row.dart';
import 'labels.dart';
import 'providers.dart';
const _weekdays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
/// Kinds in the order their glyphs are laid out inside a day cell.
const _kindOrder = ['dividend', 'coupon', 'amortization', 'repayment'];
/// When several payments of one kind share a day, the cell shows the least certain basis:
/// a guess must not be hidden behind a fact that happens to fall on the same day.
const _basisOrder = ['history', 'schedule', 'announced', 'paid'];
/// The month grid: every dividend, coupon, amortisation and redemption on the day it falls,
/// paid ones beside the expected ones. A day opens its payments beside or below the grid.
///
/// The screen itself does not scroll: the grid takes the height it is given, and only the
/// list of payments — the one part that can be any length — scrolls inside its own panel.
class IncomeMonthView extends ConsumerStatefulWidget {
const IncomeMonthView({super.key});
@override
ConsumerState<IncomeMonthView> createState() => _IncomeMonthViewState();
}
class _IncomeMonthViewState extends ConsumerState<IncomeMonthView> {
int? _selectedDay;
void _setMonth(DateTime month) {
ref.read(calendarMonthProvider.notifier).state = month;
setState(() => _selectedDay = null);
}
@override
Widget build(BuildContext context) {
final month = ref.watch(calendarMonthProvider);
final data = ref.watch(incomeMonthProvider(month));
final now = DateTime.now();
final isCurrent = month.year == now.year && month.month == now.month;
const dense = VisualDensity.compact;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
visualDensity: dense,
tooltip: 'Предыдущий месяц',
icon: const Icon(Icons.chevron_left),
onPressed: () =>
_setMonth(DateTime(month.year, month.month - 1)),
),
Expanded(
child: Text(
ruMonthYear(month),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
visualDensity: dense,
tooltip: 'Следующий месяц',
icon: const Icon(Icons.chevron_right),
onPressed: () =>
_setMonth(DateTime(month.year, month.month + 1)),
),
TextButton(
style: TextButton.styleFrom(visualDensity: dense),
onPressed: isCurrent
? null
: () => _setMonth(DateTime(now.year, now.month)),
child: const Text('Сегодня'),
),
],
),
Expanded(
child: AsyncValueView(
value: data,
onRetry: () => ref.invalidate(incomeMonthProvider(month)),
data: (cached) => _MonthBody(
month: month,
entries: cached.data.entries,
selectedDay: _selectedDay,
onSelect: (day) => setState(
() => _selectedDay = day == _selectedDay ? null : day,
),
onRefresh: () async =>
ref.invalidate(incomeMonthProvider(month)),
),
),
),
],
),
);
}
}
/// Width from which the payments sit beside the grid instead of under it.
const _wideFrom = 840.0;
/// Room the parts around the grid take, for sizing its rows from the height that is left.
const _weekdayRow = 24.0;
const _legendRow = 32.0;
/// On a narrow screen the legend wraps onto several lines.
const _legendRowNarrow = 64.0;
const _detailsMin = 150.0;
class _MonthBody extends StatelessWidget {
const _MonthBody({
required this.month,
required this.entries,
required this.selectedDay,
required this.onSelect,
required this.onRefresh,
});
final DateTime month;
final List<IncomeEntry> entries;
final int? selectedDay;
final ValueChanged<int> onSelect;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final inMonth = [
for (final e in entries)
if (e.expectedDate != null &&
e.expectedDate!.year == month.year &&
e.expectedDate!.month == month.month)
e,
];
final byDay = <int, List<IncomeEntry>>{};
for (final e in inMonth) {
byDay.putIfAbsent(e.expectedDate!.day, () => []).add(e);
}
final shown = selectedDay == null
? inMonth
: byDay[selectedDay] ?? const <IncomeEntry>[];
final lead = DateTime(month.year, month.month).weekday - 1;
final days = DateTime(month.year, month.month + 1, 0).day;
final weeks = ((lead + days) / 7).ceil();
Widget grid(double cellHeight) => _Grid(
month: month,
byDay: byDay,
selectedDay: selectedDay,
onSelect: onSelect,
lead: lead,
days: days,
weeks: weeks,
cellHeight: cellHeight,
);
final legend = _Legend(entries: inMonth);
final details = _Details(
title: selectedDay == null
? 'Выплаты за месяц'
: ruDate(DateTime(month.year, month.month, selectedDay!)),
entries: shown,
onRefresh: onRefresh,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Summary(entries: inMonth),
const SizedBox(height: 8),
Expanded(
child: LayoutBuilder(
builder: (context, c) {
if (c.maxWidth >= _wideFrom) {
// grid on the left fills the height; payments on the right
final cell = ((c.maxHeight - _weekdayRow - _legendRow) / weeks)
.clamp(48.0, 104.0);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
children: [
grid(cell),
const SizedBox(height: 8),
legend,
],
),
),
const SizedBox(width: 16),
Expanded(flex: 2, child: details),
],
);
}
// narrow: rows shrink to leave the payments room underneath
final cell =
((c.maxHeight -
_weekdayRow -
_legendRowNarrow -
_detailsMin) /
weeks)
.clamp(40.0, 60.0);
return Column(
children: [
grid(cell),
const SizedBox(height: 8),
legend,
const SizedBox(height: 8),
Expanded(child: details),
],
);
},
),
),
],
);
}
}
/// The payments of the chosen day (or of the month), scrolling inside their own panel.
class _Details extends StatelessWidget {
const _Details({
required this.title,
required this.entries,
required this.onRefresh,
});
final String title;
final List<IncomeEntry> entries;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: EdgeInsets.zero,
children: [
SectionCard(
title: title,
child: entries.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Выплат нет.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
),
)
: Column(
children: [
for (final e in entries) IncomeEntryRow(entry: e),
],
),
),
],
),
);
}
}
class _Sum {
const _Sum(this.total, this.unconverted);
final Decimal total;
/// Payments with no rouble figure (no FX rate for the day): left out of [total], not zeroed.
final int unconverted;
}
_Sum _sum(Iterable<IncomeEntry> entries) {
var total = Decimal.zero;
var unconverted = 0;
for (final e in entries) {
final v = Decimal.tryParse(e.amountRub ?? '');
if (v == null) {
unconverted++;
} else {
total += v;
}
}
return _Sum(total, unconverted);
}
/// One line: what came in, what is still expected. What qualifies the numbers — a share that
/// is only an extrapolation, payments with no rouble figure — sits behind the info icon.
class _Summary extends StatelessWidget {
const _Summary({required this.entries});
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final paid = _sum(entries.where((e) => e.basis == 'paid'));
final expected = _sum(entries.where((e) => e.basis != 'paid'));
final guess = _sum(entries.where((e) => e.basis == 'history')).total;
final unconverted = paid.unconverted + expected.unconverted;
Widget stat(String label, Color color, Decimal value) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
MoneyText(
value.toString(),
currency: 'RUB',
style: theme.textTheme.titleSmall?.copyWith(color: color),
),
],
);
final notes = [
if (guess > Decimal.zero)
'Из ожидаемого ${MoneyText.format(guess.toString(), 'RUB')}'
'экстраполяция по истории: этих выплат может не быть.',
if (unconverted > 0)
'У $unconverted выплат нет курса на дату — в суммы они не вошли.',
];
return Wrap(
spacing: 20,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
stat('Получено', basisColor('paid'), paid.total),
stat('Ожидается', basisColor('schedule'), expected.total),
if (notes.isNotEmpty)
Tooltip(
message: notes.join('\n'),
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 6),
child: Icon(
Icons.info_outline,
size: 18,
color: basisColor('history'),
),
),
],
);
}
}
class _Grid extends StatelessWidget {
const _Grid({
required this.month,
required this.byDay,
required this.selectedDay,
required this.onSelect,
required this.lead,
required this.days,
required this.weeks,
required this.cellHeight,
});
final DateTime month;
final Map<int, List<IncomeEntry>> byDay;
final int? selectedDay;
final ValueChanged<int> onSelect;
final int lead;
final int days;
final int weeks;
final double cellHeight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
return Column(
children: [
SizedBox(
height: _weekdayRow,
child: Row(
children: [
for (final w in _weekdays)
Expanded(
child: Center(
child: Text(
w,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.hintColor,
),
),
),
),
],
),
),
for (var w = 0; w < weeks; w++)
Row(
children: [
for (var c = 0; c < 7; c++)
Expanded(
child: Builder(
builder: (_) {
final day = w * 7 + c - lead + 1;
if (day < 1 || day > days) {
return SizedBox(height: cellHeight);
}
return _DayCell(
day: day,
height: cellHeight,
entries: byDay[day] ?? const [],
isToday:
month.year == now.year &&
month.month == now.month &&
day == now.day,
selected: day == selectedDay,
onTap: () => onSelect(day),
);
},
),
),
],
),
],
);
}
}
class _DayCell extends StatelessWidget {
const _DayCell({
required this.day,
required this.height,
required this.entries,
required this.isToday,
required this.selected,
required this.onTap,
});
final int day;
final double height;
final List<IncomeEntry> entries;
final bool isToday;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
final kinds = [
for (final k in _kindOrder)
if (entries.any((e) => e.kind == k)) k,
// a kind the server invents later still gets a glyph rather than vanishing
for (final k in {for (final e in entries) e.kind})
if (!_kindOrder.contains(k)) k,
];
final sum = _sum(entries);
final hasAmount = entries.isNotEmpty && sum.unconverted < entries.length;
return LayoutBuilder(
builder: (context, c) {
final narrow = c.maxWidth < 72;
// a wide but short cell keeps the day and the glyphs on one line
final oneLine = !narrow && height < 64;
final showAmount = !narrow && height >= 64 && hasAmount;
final iconSize = narrow || height < 64 ? 12.0 : 15.0;
final dayText = Text(
'$day',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: isToday ? FontWeight.w700 : null,
color: isToday ? primary : null,
),
);
final glyphs = Wrap(
spacing: 2,
runSpacing: 2,
alignment: oneLine ? WrapAlignment.end : WrapAlignment.start,
children: [
for (final k in kinds)
Icon(
incomeKindIcon(k),
size: iconSize,
color: basisColor(
_leastCertain(entries.where((e) => e.kind == k)),
),
),
],
);
return Padding(
padding: const EdgeInsets.all(1.5),
child: Material(
color: selected
? primary.withValues(alpha: 0.16)
: entries.isEmpty
? Colors.transparent
: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isToday
? BorderSide(color: primary)
: selected
? BorderSide(color: primary.withValues(alpha: 0.5))
: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.4),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: SizedBox(
height: height - 3,
child: Padding(
padding: EdgeInsets.all(narrow ? 4 : 6),
child: oneLine
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [dayText, const Spacer(), glyphs],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
dayText,
glyphs,
if (showAmount)
Text(
'${sum.unconverted > 0 ? '' : ''}${compactNumber(sum.total.toDouble())}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall,
),
],
),
),
),
),
),
);
},
);
}
}
String _leastCertain(Iterable<IncomeEntry> entries) {
final bases = entries.map((e) => e.basis).toSet();
for (final b in _basisOrder) {
if (bases.contains(b)) return b;
}
return bases.first;
}
/// What the glyphs and colours mean, and only for what is on this month's grid.
class _Legend extends StatelessWidget {
const _Legend({required this.entries});
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bases = {for (final e in entries) e.basis};
return Wrap(
spacing: 16,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final k in _kindOrder)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(incomeKindIcon(k), size: 15, color: theme.hintColor),
const SizedBox(width: 4),
Text(incomeKindLabel(k), style: theme.textTheme.bodySmall),
],
),
for (final b in _basisOrder.reversed)
if (bases.contains(b)) BasisChip(basis: b),
],
);
}
}