Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
364 lines
12 KiB
Dart
364 lines
12 KiB
Dart
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: (cached) {
|
|
final data = cached.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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|