feat(app): календарь выплат сеткой по месяцам
Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
This commit is contained in:
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,65 @@
|
|||||||
import 'package:decimal/decimal.dart';
|
import 'package:decimal/decimal.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/utils/ru_date.dart';
|
import '../../core/utils/ru_date.dart';
|
||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/money_text.dart';
|
import '../../core/widgets/money_text.dart';
|
||||||
import '../../core/widgets/section_card.dart';
|
import '../../core/widgets/section_card.dart';
|
||||||
import '../portfolio/labels.dart' show formatQty;
|
import 'calendar_grid.dart';
|
||||||
import 'data/income_api.dart';
|
import 'data/income_api.dart';
|
||||||
|
import 'entry_row.dart';
|
||||||
import 'labels.dart';
|
import 'labels.dart';
|
||||||
import 'providers.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 «по
|
/// The total is deliberately paired with the by-basis split: adding «объявлено» and «по
|
||||||
/// истории» into one number turns a guess into a promise.
|
/// истории» into one number turns a guess into a promise.
|
||||||
class IncomeCalendarTab extends ConsumerWidget {
|
class _IncomeList extends ConsumerWidget {
|
||||||
const IncomeCalendarTab({super.key});
|
const _IncomeList();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -207,64 +248,8 @@ class _MonthCard extends StatelessWidget {
|
|||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
child: Column(children: [for (final e in entries) _EntryRow(entry: e)]),
|
child: Column(
|
||||||
);
|
children: [for (final e in entries) IncomeEntryRow(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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,16 @@ const incomeKindLabels = {
|
|||||||
|
|
||||||
String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind;
|
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 = {
|
const basisLabels = {
|
||||||
'schedule': 'по графику',
|
'schedule': 'по графику',
|
||||||
'announced': 'объявлено',
|
'announced': 'объявлено',
|
||||||
|
|||||||
@@ -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<CalendarView>(
|
||||||
|
(ref) => CalendarView.month,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The month the grid shows, as the first day of it.
|
||||||
|
final calendarMonthProvider = StateProvider<DateTime>((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<Cached<IncomeCalendar>, 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.
|
/// How far the history goes back, in months.
|
||||||
final historyMonthsProvider = StateProvider<int>((ref) => 24);
|
final historyMonthsProvider = StateProvider<int>((ref) => 24);
|
||||||
|
|
||||||
@@ -62,6 +90,7 @@ final incomeForecastProvider =
|
|||||||
|
|
||||||
void invalidateIncomeProviders(WidgetRef ref) {
|
void invalidateIncomeProviders(WidgetRef ref) {
|
||||||
ref.invalidate(incomeCalendarProvider);
|
ref.invalidate(incomeCalendarProvider);
|
||||||
|
ref.invalidate(incomeMonthProvider);
|
||||||
ref.invalidate(incomeHistoryProvider);
|
ref.invalidate(incomeHistoryProvider);
|
||||||
ref.invalidate(incomeForecastProvider);
|
ref.invalidate(incomeForecastProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user