style(app): остальные экраны под новый визуальный язык и форматирование

Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
Dmitry
2026-09-19 22:14:27 +03:00
parent 62d36aa3e8
commit 322c60a359
55 changed files with 2158 additions and 1080 deletions
+28 -8
View File
@@ -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)),
];
}
+67 -52
View File
@@ -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')}';
}
+41 -16
View File
@@ -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,
),
),
],
),
],
+35 -14
View File
@@ -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}')),
],
),
+10 -2
View File
@@ -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(),
],
),
),
],
+12 -8
View File
@@ -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)),
+39 -29
View File
@@ -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);