style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -43,7 +43,8 @@ class IncomeCalendarTab extends ConsumerWidget {
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.event_available_outlined,
|
||||
message: 'Ожидаемых выплат в этом окне нет.\n'
|
||||
message:
|
||||
'Ожидаемых выплат в этом окне нет.\n'
|
||||
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
|
||||
),
|
||||
)
|
||||
@@ -77,13 +78,15 @@ class _CalendarControls extends ConsumerWidget {
|
||||
ChoiceChip(
|
||||
label: Text('$m мес'),
|
||||
selected: months == m,
|
||||
onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = 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,
|
||||
onSelected: (v) =>
|
||||
ref.read(calendarIncludePaidProvider.notifier).state = v,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -130,7 +133,11 @@ class _Totals extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: basisColor('history')),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: basisColor('history'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -164,7 +171,11 @@ class _BasisTotal extends StatelessWidget {
|
||||
children: [
|
||||
BasisChip(basis: basis),
|
||||
const SizedBox(height: 4),
|
||||
MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall),
|
||||
MoneyText(
|
||||
amount,
|
||||
currency: 'RUB',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -240,7 +251,11 @@ class _EntryRow extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (entry.amount != null)
|
||||
MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall),
|
||||
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(
|
||||
@@ -257,7 +272,9 @@ class _EntryRow extends StatelessWidget {
|
||||
|
||||
/// 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) {
|
||||
List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(
|
||||
List<IncomeEntry> entries,
|
||||
) {
|
||||
final groups = <DateTime, List<IncomeEntry>>{};
|
||||
for (final e in entries) {
|
||||
final d = e.expectedDate;
|
||||
@@ -272,5 +289,8 @@ List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entr
|
||||
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))];
|
||||
return [
|
||||
...order.where(set.contains),
|
||||
...set.where((b) => !order.contains(b)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -56,23 +56,24 @@ class IncomeEntry {
|
||||
final String basis;
|
||||
final String? taxWithheld;
|
||||
|
||||
String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId');
|
||||
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']),
|
||||
);
|
||||
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 {
|
||||
@@ -91,12 +92,12 @@ class IncomeCalendar {
|
||||
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']),
|
||||
);
|
||||
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 {
|
||||
@@ -118,7 +119,8 @@ class IncomeHistoryRow {
|
||||
final String? taxWithheld;
|
||||
final int paymentCount;
|
||||
|
||||
static IncomeHistoryRow fromJson(Map<String, dynamic> json) => IncomeHistoryRow(
|
||||
static IncomeHistoryRow fromJson(Map<String, dynamic> json) =>
|
||||
IncomeHistoryRow(
|
||||
month: asDate(json['month']),
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
@@ -151,7 +153,11 @@ class IncomeHistory {
|
||||
}
|
||||
|
||||
class ForecastMonth {
|
||||
const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}});
|
||||
const ForecastMonth({
|
||||
required this.amountRub,
|
||||
this.month,
|
||||
this.byBasis = const {},
|
||||
});
|
||||
|
||||
final DateTime? month;
|
||||
final String amountRub;
|
||||
@@ -160,10 +166,10 @@ class ForecastMonth {
|
||||
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']),
|
||||
);
|
||||
month: asDate(json['month']),
|
||||
amountRub: asString(json['amount_rub']) ?? '0',
|
||||
byBasis: asStringMap(json['by_basis']),
|
||||
);
|
||||
}
|
||||
|
||||
class IncomeForecast {
|
||||
@@ -192,11 +198,11 @@ class IncomeForecast {
|
||||
}
|
||||
|
||||
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']),
|
||||
);
|
||||
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 {
|
||||
@@ -214,12 +220,15 @@ class IncomeApi {
|
||||
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,
|
||||
});
|
||||
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 Cached(
|
||||
IncomeCalendar.fromJson(r.data ?? const {}),
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
@@ -233,24 +242,30 @@ class IncomeApi {
|
||||
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,
|
||||
});
|
||||
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 Cached(
|
||||
IncomeHistory.fromJson(r.data ?? const {}),
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Cached<IncomeForecast>> forecast({String scope = 'all', int months = 12}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: {
|
||||
'scope': scope,
|
||||
'months': months,
|
||||
});
|
||||
Future<Cached<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 Cached(
|
||||
IncomeForecast.fromJson(r.data ?? const {}),
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
@@ -262,6 +277,6 @@ class IncomeApi {
|
||||
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')}';
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ class IncomeForecastTab extends ConsumerWidget {
|
||||
StatTile(
|
||||
label: 'Доходность к стоимости',
|
||||
// null means "нет оценки стоимости" and must not read as 0 %
|
||||
value: Text(formatPercent(data.annualYieldOnValue, signed: false)),
|
||||
value: Text(
|
||||
formatPercent(data.annualYieldOnValue, signed: false),
|
||||
),
|
||||
note: data.annualYieldOnValue == null
|
||||
? 'нет оценки текущей стоимости'
|
||||
: 'ожидаемый доход / стоимость портфеля',
|
||||
@@ -58,7 +60,10 @@ class IncomeForecastTab extends ConsumerWidget {
|
||||
for (final b in bases)
|
||||
StatTile(
|
||||
label: 'Основание: ${basisLabel(b)}',
|
||||
value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'),
|
||||
value: MoneyText(
|
||||
_basisTotal(data, b).toString(),
|
||||
currency: 'RUB',
|
||||
),
|
||||
note: basisDescription(b),
|
||||
width: 220,
|
||||
),
|
||||
@@ -91,8 +96,14 @@ class IncomeForecastTab extends ConsumerWidget {
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: (data.months.length * 52).toDouble().clamp(320, double.infinity),
|
||||
child: _ForecastChart(months: data.months, bases: bases),
|
||||
width: (data.months.length * 52).toDouble().clamp(
|
||||
320,
|
||||
double.infinity,
|
||||
),
|
||||
child: _ForecastChart(
|
||||
months: data.months,
|
||||
bases: bases,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -130,7 +141,8 @@ class _HorizonChips extends ConsumerWidget {
|
||||
ChoiceChip(
|
||||
label: Text('$m мес'),
|
||||
selected: months == m,
|
||||
onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m,
|
||||
onSelected: (_) =>
|
||||
ref.read(forecastMonthsProvider.notifier).state = m,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -191,10 +203,16 @@ class _BasisLegend extends StatelessWidget {
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: basisColor(b),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
basisLabel(b),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -235,7 +253,8 @@ class _ForecastChart extends StatelessWidget {
|
||||
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();
|
||||
if (months.length > 14 && i.isOdd)
|
||||
return const SizedBox.shrink();
|
||||
final m = months[i].month;
|
||||
return Text(
|
||||
m == null ? '—' : ruMonthYearShort(m),
|
||||
@@ -251,7 +270,8 @@ class _ForecastChart extends StatelessWidget {
|
||||
final m = months[group.x];
|
||||
final parts = [
|
||||
for (final b in bases)
|
||||
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero)
|
||||
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) >
|
||||
Decimal.zero)
|
||||
'${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}',
|
||||
];
|
||||
return BarTooltipItem(
|
||||
@@ -277,7 +297,8 @@ class _ForecastChart extends StatelessWidget {
|
||||
final stack = <BarChartRodStackItem>[];
|
||||
var from = 0.0;
|
||||
for (final b in bases) {
|
||||
final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble();
|
||||
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;
|
||||
@@ -321,14 +342,18 @@ class _ForecastTable extends StatelessWidget {
|
||||
for (final m in months)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(m.month == null ? '—' : ruMonthYearShort(m.month!))),
|
||||
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,
|
||||
)),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
m.amountRub,
|
||||
currency: 'RUB',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -77,7 +77,10 @@ class IncomeHistoryTab extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: SizedBox(
|
||||
width: (months.length * 44).toDouble().clamp(320, double.infinity),
|
||||
width: (months.length * 44).toDouble().clamp(
|
||||
320,
|
||||
double.infinity,
|
||||
),
|
||||
child: _HistoryChart(months: months),
|
||||
),
|
||||
),
|
||||
@@ -109,7 +112,8 @@ class _PeriodChips extends ConsumerWidget {
|
||||
ChoiceChip(
|
||||
label: Text(m >= 120 ? 'Всё время' : '$m мес'),
|
||||
selected: months == m,
|
||||
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m,
|
||||
onSelected: (_) =>
|
||||
ref.read(historyMonthsProvider.notifier).state = m,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -130,7 +134,9 @@ List<_MonthTotal> _byMonth(List<IncomeHistoryRow> 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);
|
||||
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]!)];
|
||||
@@ -166,8 +172,12 @@ class _HistoryChart extends StatelessWidget {
|
||||
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);
|
||||
if (months.length > 14 && i % 3 != 0)
|
||||
return const SizedBox.shrink();
|
||||
return Text(
|
||||
ruMonthYearShort(months[i].month),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -190,7 +200,9 @@ class _HistoryChart extends StatelessWidget {
|
||||
toY: months[i].amountRub.toDouble(),
|
||||
color: basisColor('paid'),
|
||||
width: 12,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -216,7 +228,10 @@ class _HistoryTable extends StatelessWidget {
|
||||
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),
|
||||
DataColumn(label: Text('Выплат', style: headerStyle), numeric: true),
|
||||
],
|
||||
@@ -224,16 +239,22 @@ class _HistoryTable extends StatelessWidget {
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(r.month == null ? '—' : ruMonthYearShort(r.month!))),
|
||||
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(
|
||||
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,7 +40,11 @@ class IncomePage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')],
|
||||
tabs: [
|
||||
Tab(text: 'Календарь'),
|
||||
Tab(text: 'История'),
|
||||
Tab(text: 'Прогноз'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@@ -52,7 +56,11 @@ class IncomePage extends ConsumerWidget {
|
||||
),
|
||||
const Expanded(
|
||||
child: TabBarView(
|
||||
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()],
|
||||
children: [
|
||||
IncomeCalendarTab(),
|
||||
IncomeHistoryTab(),
|
||||
IncomeForecastTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -26,7 +26,8 @@ const basisLabels = {
|
||||
const basisDescriptions = {
|
||||
'schedule': 'Арифметика по опубликованному графику выплат эмитента.',
|
||||
'announced': 'Объявленный эмитентом факт: размер и дата известны.',
|
||||
'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться '
|
||||
'history':
|
||||
'Экстраполяция по выплатам за последние 24 мес — может ошибаться '
|
||||
'на любую величину, в том числе выплаты может не быть вовсе.',
|
||||
'paid': 'Уже получено.',
|
||||
};
|
||||
@@ -38,12 +39,12 @@ 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,
|
||||
};
|
||||
'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 {
|
||||
@@ -58,7 +59,10 @@ class BasisChip extends StatelessWidget {
|
||||
return Tooltip(
|
||||
message: basisDescription(basis),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4),
|
||||
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)),
|
||||
|
||||
@@ -5,7 +5,9 @@ import '../../core/cache/cached.dart';
|
||||
import '../portfolio/providers.dart' show scopeProvider;
|
||||
import 'data/income_api.dart';
|
||||
|
||||
final incomeApiProvider = Provider<IncomeApi>((ref) => IncomeApi(ref.watch(apiProvider).dio));
|
||||
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);
|
||||
@@ -14,41 +16,49 @@ 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. See `docs/ai/offline-cache.md`.
|
||||
final incomeCalendarProvider = FutureProvider.autoDispose<Cached<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,
|
||||
);
|
||||
});
|
||||
final incomeCalendarProvider =
|
||||
FutureProvider.autoDispose<Cached<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<Cached<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 incomeHistoryProvider = FutureProvider.autoDispose<Cached<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<Cached<IncomeForecast>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref
|
||||
.watch(incomeApiProvider)
|
||||
.forecast(scope: scope, months: ref.watch(forecastMonthsProvider));
|
||||
});
|
||||
final incomeForecastProvider =
|
||||
FutureProvider.autoDispose<Cached<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);
|
||||
|
||||
Reference in New Issue
Block a user