Files
fin-tracker/app/lib/features/income/calendar_grid.dart
T
Dmitry 2645e56197 feat(app): календарь выплат сеткой по месяцам
Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
2026-09-20 10:46:38 +03:00

596 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
],
);
}
}