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:
@@ -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')}';
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user