Files
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

283 lines
8.3 KiB
Dart

/// 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/cache/cached.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';
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<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 Cached(
IncomeCalendar.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
Future<Cached<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 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},
);
return Cached(
IncomeForecast.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
/// `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')}';
}