feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+273
View File
@@ -0,0 +1,273 @@
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 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// Календарь: 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});
@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: (data) => 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) _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,
),
],
),
);
}
}
/// 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))];
}
@@ -0,0 +1,255 @@
/// Hand-written client for `/api/v1/income`.
///
/// **Temporary.** The income routes are not in `openapi/openapi.json` yet, so the generated
/// package `app/packages/api_client` knows nothing about them. Models and calls here follow
/// `docs/ai/phase4-contract.md` §1 literally and are meant to be **replaced by the
/// generated client** as soon as the routes land in the spec and `just gen-client` runs.
///
/// Raw Dio comes from `ref.read(apiProvider).dio`, which already carries the base URL, the
/// bearer header and the single transparent refresh on 401.
library;
import 'package:dio/dio.dart';
import '../../../core/utils/json.dart';
/// One expected (or already paid) payment.
///
/// [basis] is not decoration: `schedule` is arithmetic over a published schedule,
/// `announced` is a fact the issuer declared, `history` is an extrapolation that can be
/// wrong by any amount, and `paid` already happened. The screen must show it on every row.
class IncomeEntry {
const IncomeEntry({
required this.kind,
required this.basis,
required this.currency,
this.instrumentId,
this.ticker,
this.name,
this.expectedDate,
this.recordDate,
this.qty,
this.perUnit,
this.amount,
this.amountRub,
this.taxWithheld,
});
final int? instrumentId;
final String? ticker;
final String? name;
/// `dividend | coupon | amortization | repayment` — a plain string, like every stable key.
final String kind;
final DateTime? expectedDate;
final DateTime? recordDate;
final String? qty;
final String? perUnit;
final String? amount;
final String currency;
/// Null when there is no FX rate for the date — not zero.
final String? amountRub;
/// `schedule | announced | history | paid`.
final String basis;
final String? taxWithheld;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry(
instrumentId: asInt(json['instrument_id']),
ticker: asString(json['ticker']),
name: asString(json['name']),
kind: asString(json['kind']) ?? 'dividend',
expectedDate: asDate(json['expected_date']),
recordDate: asDate(json['record_date']),
qty: asString(json['qty']),
perUnit: asString(json['per_unit']),
amount: asString(json['amount']),
currency: asString(json['currency']) ?? 'RUB',
amountRub: asString(json['amount_rub']),
basis: asString(json['basis']) ?? 'history',
taxWithheld: asString(json['tax_withheld']),
);
}
class IncomeCalendar {
const IncomeCalendar({
required this.totalExpectedRub,
required this.currency,
this.asOf,
this.entries = const [],
this.byBasis = const {},
});
final DateTime? asOf;
final String currency;
final String totalExpectedRub;
final List<IncomeEntry> entries;
final Map<String, String> byBasis;
static IncomeCalendar fromJson(Map<String, dynamic> json) => IncomeCalendar(
asOf: asDate(json['as_of']),
currency: asString(json['currency']) ?? 'RUB',
totalExpectedRub: asString(json['total_expected_rub']) ?? '0',
entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(),
byBasis: asStringMap(json['by_basis']),
);
}
class IncomeHistoryRow {
const IncomeHistoryRow({
required this.kind,
required this.currency,
required this.amount,
this.month,
this.amountRub,
this.taxWithheld,
this.paymentCount = 0,
});
final DateTime? month;
final String kind;
final String currency;
final String amount;
final String? amountRub;
final String? taxWithheld;
final int paymentCount;
static IncomeHistoryRow fromJson(Map<String, dynamic> json) => IncomeHistoryRow(
month: asDate(json['month']),
kind: asString(json['kind']) ?? 'other',
currency: asString(json['currency']) ?? 'RUB',
amount: asString(json['amount']) ?? '0',
amountRub: asString(json['amount_rub']),
taxWithheld: asString(json['tax_withheld']),
paymentCount: asInt(json['payment_count']) ?? 0,
);
}
class IncomeHistory {
const IncomeHistory({
this.rows = const [],
this.totalRub = '0',
this.taxWithheldRub = '0',
});
final List<IncomeHistoryRow> rows;
final String totalRub;
final String taxWithheldRub;
static IncomeHistory fromJson(Map<String, dynamic> json) {
final totals = asObject(json['totals']) ?? const {};
return IncomeHistory(
rows: asObjects(json['rows']).map(IncomeHistoryRow.fromJson).toList(),
totalRub: asString(totals['amount_rub']) ?? '0',
taxWithheldRub: asString(totals['tax_withheld_rub']) ?? '0',
);
}
}
class ForecastMonth {
const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}});
final DateTime? month;
final String amountRub;
/// The split the total must never hide: a month made of `history` alone is a guess.
final Map<String, String> byBasis;
static ForecastMonth fromJson(Map<String, dynamic> json) => ForecastMonth(
month: asDate(json['month']),
amountRub: asString(json['amount_rub']) ?? '0',
byBasis: asStringMap(json['by_basis']),
);
}
class IncomeForecast {
const IncomeForecast({
required this.totalRub,
this.months = const [],
this.annualYieldOnValue,
this.warnings = const [],
});
final List<ForecastMonth> months;
final String totalRub;
/// Null when the current value is unknown — shown as an em dash, never as 0 %.
final String? annualYieldOnValue;
final List<String> warnings;
/// Every basis present anywhere in the forecast, in contract order.
List<String> get bases {
const order = ['schedule', 'announced', 'history', 'paid'];
final seen = {for (final m in months) ...m.byBasis.keys};
return [
...order.where(seen.contains),
...seen.where((b) => !order.contains(b)),
];
}
static IncomeForecast fromJson(Map<String, dynamic> json) => IncomeForecast(
months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(),
totalRub: asString(json['total_rub']) ?? '0',
annualYieldOnValue: asString(json['annual_yield_on_value']),
warnings: asStrings(json['warnings']),
);
}
class IncomeApi {
const IncomeApi(this._dio);
final Dio _dio;
static const _base = '/api/v1/income';
Future<IncomeCalendar> calendar({
String scope = 'all',
DateTime? dateFrom,
DateTime? dateTo,
bool includePaid = false,
}) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/calendar', queryParameters: {
'scope': scope,
'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo),
'include_paid': includePaid,
});
return IncomeCalendar.fromJson(r.data ?? const {});
}
Future<IncomeHistory> history({
String scope = 'all',
String group = 'month',
DateTime? dateFrom,
DateTime? dateTo,
String? kind,
}) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/history', queryParameters: {
'scope': scope,
'group': group,
'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo),
'kind': ?kind,
});
return IncomeHistory.fromJson(r.data ?? const {});
}
Future<IncomeForecast> forecast({String scope = 'all', int months = 12}) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: {
'scope': scope,
'months': months,
});
return IncomeForecast.fromJson(r.data ?? const {});
}
/// `format: date` on the wire. `DateQueryInterceptor` does this for the generated client;
/// this layer builds its query maps itself, so it truncates here.
static String? _isoDate(DateTime? d) => d == null
? null
: '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
}
+337
View File
@@ -0,0 +1,337 @@
import 'package:decimal/decimal.dart';
import 'package:fl_chart/fl_chart.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 '../portfolio/labels.dart' show formatPercent;
import 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// Прогноз: expected income for the next N months, **always split by basis**.
///
/// The stacked bar and the table both carry the split rather than the total alone: a month
/// built entirely out of `history` is an extrapolation, and a month built out of `announced`
/// is nearly a fact. One number cannot say which.
class IncomeForecastTab extends ConsumerWidget {
const IncomeForecastTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final forecast = ref.watch(incomeForecastProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeForecastProvider),
child: AsyncValueView(
value: forecast,
onRetry: () => ref.invalidate(incomeForecastProvider),
data: (data) {
final bases = data.bases;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _HorizonChips(),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Ожидается всего',
value: MoneyText(data.totalRub, currency: 'RUB'),
note: 'за ${data.months.length} мес, до налога',
),
StatTile(
label: 'Доходность к стоимости',
// null means "нет оценки стоимости" and must not read as 0 %
value: Text(formatPercent(data.annualYieldOnValue, signed: false)),
note: data.annualYieldOnValue == null
? 'нет оценки текущей стоимости'
: 'ожидаемый доход / стоимость портфеля',
),
for (final b in bases)
StatTile(
label: 'Основание: ${basisLabel(b)}',
value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'),
note: basisDescription(b),
width: 220,
),
],
),
if (data.warnings.isNotEmpty) ...[
const SizedBox(height: 16),
_Warnings(warnings: data.warnings),
],
const SizedBox(height: 16),
if (data.months.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.insights_outlined,
message: 'Прогнозировать нечего: ни объявленных выплат, ни истории.',
),
)
else ...[
SectionCard(
title: 'По месяцам',
subtitle: 'цвет столбца — основание прогноза',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BasisLegend(bases: bases),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: (data.months.length * 52).toDouble().clamp(320, double.infinity),
child: _ForecastChart(months: data.months, bases: bases),
),
),
),
],
),
),
const SizedBox(height: 16),
SectionCard(
title: 'Разбивка по основанию',
child: _ForecastTable(months: data.months, bases: bases),
),
],
],
);
},
),
);
}
}
Decimal _basisTotal(IncomeForecast data, String basis) => data.months
.map((m) => Decimal.tryParse(m.byBasis[basis] ?? '0') ?? Decimal.zero)
.fold(Decimal.zero, (a, b) => a + b);
class _HorizonChips extends ConsumerWidget {
const _HorizonChips();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(forecastMonthsProvider);
return Wrap(
spacing: 8,
children: [
for (final m in const [6, 12, 24, 36])
ChoiceChip(
label: Text('$m мес'),
selected: months == m,
onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m,
),
],
);
}
}
class _Warnings extends StatelessWidget {
const _Warnings({required this.warnings});
final List<String> warnings;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final w in warnings)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_outlined, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(w)),
],
),
),
],
),
),
);
}
}
class _BasisLegend extends StatelessWidget {
const _BasisLegend({required this.bases});
final List<String> bases;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 6,
children: [
for (final b in bases)
Tooltip(
message: basisDescription(b),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall),
],
),
),
],
);
}
}
class _ForecastChart extends StatelessWidget {
const _ForecastChart({required this.months, required this.bases});
final List<ForecastMonth> months;
final List<String> bases;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final maxY = months.fold<double>(0, (m, r) {
final v = (Decimal.tryParse(r.amountRub) ?? Decimal.zero).toDouble();
return v > m ? v : m;
});
return BarChart(
BarChartData(
maxY: maxY == 0 ? 1 : maxY * 1.15,
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 56),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i.isOdd) return const SizedBox.shrink();
final m = months[i].month;
return Text(
m == null ? '' : ruMonthYearShort(m),
style: theme.textTheme.bodySmall,
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, _, rod, _) {
final m = months[group.x];
final parts = [
for (final b in bases)
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero)
'${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}',
];
return BarTooltipItem(
'${m.month == null ? '' : ruMonthYearShort(m.month!)}\n'
'${MoneyText.format(m.amountRub, 'RUB')}'
'${parts.isEmpty ? '' : '\n${parts.join('\n')}'}',
theme.textTheme.bodySmall ?? const TextStyle(),
);
},
),
),
barGroups: [
for (var i = 0; i < months.length; i++)
BarChartGroupData(x: i, barRods: [_stackedRod(months[i])]),
],
),
);
}
/// One rod per month, stacked by basis: the height is the month's total, the segments are
/// where that total came from.
BarChartRodData _stackedRod(ForecastMonth m) {
final stack = <BarChartRodStackItem>[];
var from = 0.0;
for (final b in bases) {
final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble();
if (v <= 0) continue;
stack.add(BarChartRodStackItem(from, from + v, basisColor(b)));
from += v;
}
final total = (Decimal.tryParse(m.amountRub) ?? Decimal.zero).toDouble();
return BarChartRodData(
toY: from > 0 ? from : total,
rodStackItems: stack,
color: Colors.transparent,
width: 16,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
);
}
}
class _ForecastTable extends StatelessWidget {
const _ForecastTable({required this.months, required this.bases});
final List<ForecastMonth> months;
final List<String> bases;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Месяц', style: headerStyle)),
for (final b in bases)
DataColumn(
label: Tooltip(
message: basisDescription(b),
child: Text(basisLabel(b), style: headerStyle),
),
numeric: true,
),
DataColumn(label: Text('Итого', style: headerStyle), numeric: true),
],
rows: [
for (final m in months)
DataRow(
cells: [
DataCell(Text(m.month == null ? '' : ruMonthYearShort(m.month!))),
for (final b in bases)
DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')),
DataCell(MoneyText(
m.amountRub,
currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall,
)),
],
),
],
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:decimal/decimal.dart';
import 'package:fl_chart/fl_chart.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 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// История: what was actually paid, by month and kind. Facts only — no basis column here,
/// because every row is `paid` by construction.
class IncomeHistoryTab extends ConsumerWidget {
const IncomeHistoryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(incomeHistoryProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeHistoryProvider),
child: AsyncValueView(
value: history,
onRetry: () => ref.invalidate(incomeHistoryProvider),
data: (data) {
if (data.rows.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
_PeriodChips(),
SizedBox(height: 48),
EmptyState(
icon: Icons.history,
message: 'Выплат за период не было.',
),
],
);
}
final months = _byMonth(data.rows);
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _PeriodChips(),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Получено',
value: MoneyText(data.totalRub, currency: 'RUB'),
note: 'за выбранный период, до налога',
),
StatTile(
label: 'Удержано налога',
value: MoneyText(data.taxWithheldRub, currency: 'RUB'),
note: 'по данным брокера',
),
StatTile(
label: 'Месяцев с выплатами',
value: Text('${months.length}'),
note: 'строк в таблице: ${data.rows.length}',
),
],
),
const SizedBox(height: 16),
SectionCard(
title: 'Выплаты по месяцам',
child: SizedBox(
height: 220,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: SizedBox(
width: (months.length * 44).toDouble().clamp(320, double.infinity),
child: _HistoryChart(months: months),
),
),
),
),
const SizedBox(height: 16),
SectionCard(
title: 'Помесячно',
child: _HistoryTable(rows: data.rows),
),
],
);
},
),
);
}
}
class _PeriodChips extends ConsumerWidget {
const _PeriodChips();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(historyMonthsProvider);
return Wrap(
spacing: 8,
children: [
for (final m in const [12, 24, 36, 120])
ChoiceChip(
label: Text(m >= 120 ? 'Всё время' : '$m мес'),
selected: months == m,
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m,
),
],
);
}
}
/// One bar per month, in rubles. Rows of several kinds inside a month are summed for the
/// chart; the breakdown by kind stays in the table below.
class _MonthTotal {
_MonthTotal(this.month, this.amountRub);
final DateTime month;
final Decimal amountRub;
}
List<_MonthTotal> _byMonth(List<IncomeHistoryRow> rows) {
final sums = <DateTime, Decimal>{};
for (final r in rows) {
final m = r.month;
if (m == null) continue;
final key = DateTime.utc(m.year, m.month);
sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero);
}
final keys = sums.keys.toList()..sort();
return [for (final k in keys) _MonthTotal(k, sums[k]!)];
}
class _HistoryChart extends StatelessWidget {
const _HistoryChart({required this.months});
final List<_MonthTotal> months;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final maxY = months.fold<double>(0, (m, r) {
final v = r.amountRub.toDouble();
return v > m ? v : m;
});
return BarChart(
BarChartData(
maxY: maxY == 0 ? 1 : maxY * 1.15,
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 56),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink();
return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, _, rod, _) => BarTooltipItem(
'${ruMonthYearShort(months[group.x].month)}\n'
'${MoneyText.format(months[group.x].amountRub.toString(), 'RUB')}',
theme.textTheme.bodySmall ?? const TextStyle(),
),
),
),
barGroups: [
for (var i = 0; i < months.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: months[i].amountRub.toDouble(),
color: basisColor('paid'),
width: 12,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
),
],
),
],
),
);
}
}
class _HistoryTable extends StatelessWidget {
const _HistoryTable({required this.rows});
final List<IncomeHistoryRow> rows;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Месяц', style: headerStyle)),
DataColumn(label: Text('Тип', style: headerStyle)),
DataColumn(label: Text('Валюта', style: headerStyle)),
DataColumn(label: Text('Сумма', style: headerStyle), numeric: true),
DataColumn(label: Text('В рублях', style: headerStyle), numeric: true),
DataColumn(label: Text('Налог', style: headerStyle), numeric: true),
DataColumn(label: Text('Выплат', style: headerStyle), numeric: true),
],
rows: [
for (final r in rows)
DataRow(
cells: [
DataCell(Text(r.month == null ? '' : ruMonthYearShort(r.month!))),
DataCell(Text(incomeKindLabel(r.kind))),
DataCell(Text(r.currency)),
DataCell(MoneyText(r.amount, currency: r.currency)),
DataCell(r.amountRub == null
? const Text('')
: MoneyText(r.amountRub!, currency: 'RUB')),
DataCell(r.taxWithheld == null
? const Text('')
: MoneyText(r.taxWithheld!, currency: r.currency)),
DataCell(Text('${r.paymentCount}')),
],
),
],
),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/scope_selector.dart';
import 'calendar_tab.dart';
import 'forecast_tab.dart';
import 'history_tab.dart';
import 'providers.dart';
/// Доходы: the dividend/coupon calendar, the paid history and the forecast — three views of
/// one question, so three tabs of one screen rather than three navigation destinations.
class IncomePage extends ConsumerWidget {
const IncomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Доходы'),
actions: [
const ScopeSelector(),
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => invalidateIncomeProviders(ref),
),
],
bottom: const TabBar(
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')],
),
),
body: const TabBarView(
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()],
),
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../core/theme/chart_colors.dart';
/// Russian labels for the stable keys the income routes send.
const incomeKindLabels = {
'dividend': 'Дивиденд',
'coupon': 'Купон',
'amortization': 'Амортизация',
'repayment': 'Погашение',
};
String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind;
const basisLabels = {
'schedule': 'по графику',
'announced': 'объявлено',
'history': 'по истории',
'paid': 'выплачено',
};
/// What each basis actually promises. Shown as a tooltip and spelled out in the legend,
/// because the difference between «объявлено» and «по истории» is the difference between a
/// fact and a guess.
const basisDescriptions = {
'schedule': 'Арифметика по опубликованному графику выплат эмитента.',
'announced': 'Объявленный эмитентом факт: размер и дата известны.',
'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться '
'на любую величину, в том числе выплаты может не быть вовсе.',
'paid': 'Уже получено.',
};
String basisLabel(String basis) => basisLabels[basis] ?? basis;
String basisDescription(String basis) => basisDescriptions[basis] ?? basis;
/// Fixed slot per basis — never cycled, so the same basis is the same colour on the
/// calendar, in the forecast legend and in the stacked bars.
Color basisColor(String basis) => switch (basis) {
'schedule' => ChartColors.slot1Blue,
'announced' => ChartColors.slot3Aqua,
'history' => ChartColors.slot4Yellow,
'paid' => ChartColors.slot5Magenta,
_ => ChartColors.slot2Orange,
};
/// The basis chip that has to sit on every calendar and forecast row.
class BasisChip extends StatelessWidget {
const BasisChip({required this.basis, super.key, this.dense = true});
final String basis;
final bool dense;
@override
Widget build(BuildContext context) {
final color = basisColor(basis);
return Tooltip(
message: basisDescription(basis),
child: Container(
padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
border: Border.all(color: color.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(6),
),
child: Text(
basisLabel(basis),
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
),
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../portfolio/providers.dart' show scopeProvider;
import 'data/income_api.dart';
final incomeApiProvider = Provider<IncomeApi>((ref) => IncomeApi(ref.watch(apiProvider).dio));
/// How far the calendar looks ahead, in months. 12 is the contract default.
final calendarMonthsProvider = StateProvider<int>((ref) => 12);
final calendarIncludePaidProvider = StateProvider<bool>((ref) => false);
/// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must
/// not leave the income calendar showing a different portfolio.
final incomeCalendarProvider = FutureProvider.autoDispose<IncomeCalendar>((ref) async {
final scope = ref.watch(scopeProvider);
final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider);
final now = DateTime.now();
return ref.watch(incomeApiProvider).calendar(
scope: scope,
dateFrom: DateTime(now.year, now.month, now.day),
dateTo: DateTime(now.year, now.month + months, now.day),
includePaid: includePaid,
);
});
/// How far the history goes back, in months.
final historyMonthsProvider = StateProvider<int>((ref) => 24);
final incomeHistoryProvider = FutureProvider.autoDispose<IncomeHistory>((ref) async {
final scope = ref.watch(scopeProvider);
final months = ref.watch(historyMonthsProvider);
final now = DateTime.now();
return ref.watch(incomeApiProvider).history(
scope: scope,
dateFrom: DateTime(now.year, now.month - months + 1, 1),
dateTo: DateTime(now.year, now.month + 1, 0),
);
});
final forecastMonthsProvider = StateProvider<int>((ref) => 12);
final incomeForecastProvider = FutureProvider.autoDispose<IncomeForecast>((ref) async {
final scope = ref.watch(scopeProvider);
return ref
.watch(incomeApiProvider)
.forecast(scope: scope, months: ref.watch(forecastMonthsProvider));
});
void invalidateIncomeProviders(WidgetRef ref) {
ref.invalidate(incomeCalendarProvider);
ref.invalidate(incomeHistoryProvider);
ref.invalidate(incomeForecastProvider);
}