From 2645e56197d2542990031c181ada0bf2c72e2e9b Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 20 Sep 2026 10:46:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=80=D1=8C=20=D0=B2=D1=8B=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=20=D1=81=D0=B5=D1=82=D0=BA=D0=BE=D0=B9=20=D0=BF=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=8F=D1=86=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом. --- app/lib/features/income/calendar_grid.dart | 595 +++++++++++++++++++++ app/lib/features/income/calendar_tab.dart | 111 ++-- app/lib/features/income/entry_row.dart | 73 +++ app/lib/features/income/labels.dart | 10 + app/lib/features/income/providers.dart | 29 + 5 files changed, 755 insertions(+), 63 deletions(-) create mode 100644 app/lib/features/income/calendar_grid.dart create mode 100644 app/lib/features/income/entry_row.dart diff --git a/app/lib/features/income/calendar_grid.dart b/app/lib/features/income/calendar_grid.dart new file mode 100644 index 0000000..be36359 --- /dev/null +++ b/app/lib/features/income/calendar_grid.dart @@ -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 createState() => _IncomeMonthViewState(); +} + +class _IncomeMonthViewState extends ConsumerState { + 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 entries; + final int? selectedDay; + final ValueChanged onSelect; + final Future 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 = >{}; + for (final e in inMonth) { + byDay.putIfAbsent(e.expectedDate!.day, () => []).add(e); + } + final shown = selectedDay == null + ? inMonth + : byDay[selectedDay] ?? const []; + + 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 entries; + final Future 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 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 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> byDay; + final int? selectedDay; + final ValueChanged 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 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 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 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), + ], + ); + } +} diff --git a/app/lib/features/income/calendar_tab.dart b/app/lib/features/income/calendar_tab.dart index af4fa4f..515aa18 100644 --- a/app/lib/features/income/calendar_tab.dart +++ b/app/lib/features/income/calendar_tab.dart @@ -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( + 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)], ), ); } diff --git a/app/lib/features/income/entry_row.dart b/app/lib/features/income/entry_row.dart new file mode 100644 index 0000000..4909300 --- /dev/null +++ b/app/lib/features/income/entry_row.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/app/lib/features/income/labels.dart b/app/lib/features/income/labels.dart index 9144a4f..67c212a 100644 --- a/app/lib/features/income/labels.dart +++ b/app/lib/features/income/labels.dart @@ -13,6 +13,16 @@ const incomeKindLabels = { String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind; +/// One glyph per kind, so a day cell on the calendar says what is paid without a word. The +/// colour of the glyph is left to the caller: on the calendar it is the basis, not the kind. +IconData incomeKindIcon(String kind) => switch (kind) { + 'dividend' => Icons.paid_outlined, + 'coupon' => Icons.receipt_long_outlined, + 'amortization' => Icons.trending_down, + 'repayment' => Icons.flag_outlined, + _ => Icons.circle_outlined, +}; + const basisLabels = { 'schedule': 'по графику', 'announced': 'объявлено', diff --git a/app/lib/features/income/providers.dart b/app/lib/features/income/providers.dart index 4e1cca9..3008955 100644 --- a/app/lib/features/income/providers.dart +++ b/app/lib/features/income/providers.dart @@ -32,6 +32,34 @@ final incomeCalendarProvider = ); }); +enum CalendarView { month, list } + +/// The calendar opens as a month grid; the list of the coming payments is the other view. +final calendarViewProvider = StateProvider( + (ref) => CalendarView.month, +); + +/// The month the grid shows, as the first day of it. +final calendarMonthProvider = StateProvider((ref) { + final now = DateTime.now(); + return DateTime(now.year, now.month); +}); + +/// Every payment of one month, the paid ones included: a calendar of a month is the record of +/// it as well as the plan. Keyed by the first day of the month. +final incomeMonthProvider = FutureProvider.autoDispose + .family, DateTime>((ref, month) async { + final scope = ref.watch(scopeProvider); + return ref + .watch(incomeApiProvider) + .calendar( + scope: scope, + dateFrom: DateTime(month.year, month.month, 1), + dateTo: DateTime(month.year, month.month + 1, 0), + includePaid: true, + ); + }); + /// How far the history goes back, in months. final historyMonthsProvider = StateProvider((ref) => 24); @@ -62,6 +90,7 @@ final incomeForecastProvider = void invalidateIncomeProviders(WidgetRef ref) { ref.invalidate(incomeCalendarProvider); + ref.invalidate(incomeMonthProvider); ref.invalidate(incomeHistoryProvider); ref.invalidate(incomeForecastProvider); }