accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
159 lines
5.2 KiB
Dart
159 lines
5.2 KiB
Dart
/// Hand-written client for `/api/v1/goals`.
|
|
///
|
|
/// **Temporary.** The goal routes are not in `openapi/openapi.json` yet, so
|
|
/// `app/packages/api_client` has no generated models or methods for them. Everything here
|
|
/// follows `docs/ai/phase4-contract.md` §4 literally and is meant to be **replaced by the
|
|
/// generated client** once the routes land in the spec and `just gen-client` runs.
|
|
///
|
|
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot
|
|
/// refresh on 401 are already wired there.
|
|
library;
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
import '../../../core/cache/cached.dart';
|
|
import '../../../core/utils/json.dart';
|
|
|
|
class Goal {
|
|
const Goal({
|
|
required this.id,
|
|
required this.name,
|
|
required this.scope,
|
|
required this.targetAmount,
|
|
required this.currency,
|
|
this.targetDate,
|
|
this.monthlyContribution,
|
|
this.note,
|
|
this.archived = false,
|
|
});
|
|
|
|
final int id;
|
|
final String name;
|
|
final String scope;
|
|
final String targetAmount;
|
|
final String currency;
|
|
final DateTime? targetDate;
|
|
final String? monthlyContribution;
|
|
final String? note;
|
|
final bool archived;
|
|
|
|
/// The create/patch body. `id` is never sent; `null` fields are omitted so a PATCH that
|
|
/// touches one field does not blank the rest.
|
|
Map<String, dynamic> toJson() => {
|
|
'name': name,
|
|
'scope': scope,
|
|
'target_amount': targetAmount,
|
|
'currency': currency,
|
|
'target_date': ?_isoDate(targetDate),
|
|
'monthly_contribution': ?monthlyContribution,
|
|
'note': ?note,
|
|
'archived': archived,
|
|
};
|
|
|
|
static Goal fromJson(Map<String, dynamic> json) => Goal(
|
|
id: asInt(json['id']) ?? 0,
|
|
name: asString(json['name']) ?? 'Без названия',
|
|
scope: asString(json['scope']) ?? 'all',
|
|
targetAmount: asString(json['target_amount']) ?? '0',
|
|
currency: asString(json['currency']) ?? 'RUB',
|
|
targetDate: asDate(json['target_date']),
|
|
monthlyContribution: asString(json['monthly_contribution']),
|
|
note: asString(json['note']),
|
|
archived: asBool(json['archived']),
|
|
);
|
|
|
|
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')}';
|
|
}
|
|
|
|
class GoalProgress {
|
|
const GoalProgress({
|
|
required this.goalId,
|
|
required this.basis,
|
|
required this.onTrack,
|
|
this.asOf,
|
|
this.currentValueRub,
|
|
this.targetAmountRub,
|
|
this.progress,
|
|
this.projectedDate,
|
|
this.assumedRate,
|
|
this.monthlyNeededRub,
|
|
});
|
|
|
|
final int goalId;
|
|
final DateTime? asOf;
|
|
final String? currentValueRub;
|
|
final String? targetAmountRub;
|
|
|
|
/// A share: `"0.4128"` is 41,28 %.
|
|
final String? progress;
|
|
|
|
/// **Null means the goal is not reached at the current trend** — an honest answer, and the
|
|
/// contract forbids substituting a far-off date for it. The UI must say so in words.
|
|
final DateTime? projectedDate;
|
|
|
|
/// `xirr | contribution | none` — what the projection is built on.
|
|
final String basis;
|
|
final String? assumedRate;
|
|
final String? monthlyNeededRub;
|
|
final bool onTrack;
|
|
|
|
/// The projection exists only when a date came back. `basis == 'none'` means there was
|
|
/// nothing to project from in the first place.
|
|
bool get isUnreachable => projectedDate == null && basis != 'none';
|
|
|
|
static GoalProgress fromJson(Map<String, dynamic> json) => GoalProgress(
|
|
goalId: asInt(json['goal_id']) ?? 0,
|
|
asOf: asDate(json['as_of']),
|
|
currentValueRub: asString(json['current_value_rub']),
|
|
targetAmountRub: asString(json['target_amount_rub']),
|
|
progress: asString(json['progress']),
|
|
projectedDate: asDate(json['projected_date']),
|
|
basis: asString(json['basis']) ?? 'none',
|
|
assumedRate: asString(json['assumed_rate']),
|
|
monthlyNeededRub: asString(json['monthly_needed_rub']),
|
|
onTrack: asBool(json['on_track']),
|
|
);
|
|
}
|
|
|
|
class GoalsApi {
|
|
const GoalsApi(this._dio);
|
|
|
|
final Dio _dio;
|
|
|
|
static const _base = '/api/v1/goals';
|
|
|
|
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
|
|
/// (there is no generated `Response<T>` for `GoalsPage` to unwrap `r.cached` from).
|
|
Future<Cached<List<Goal>>> list() async {
|
|
final r = await _dio.get<List<dynamic>>(_base);
|
|
final goals = (r.data ?? const [])
|
|
.map((e) => Goal.fromJson(Map<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
return Cached(goals, fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
|
}
|
|
|
|
Future<Goal> create(Goal goal) async {
|
|
final r = await _dio.post<Map<String, dynamic>>(_base, data: goal.toJson());
|
|
return Goal.fromJson(r.data ?? const {});
|
|
}
|
|
|
|
Future<Goal> patch(int id, Map<String, dynamic> changes) async {
|
|
final r = await _dio.patch<Map<String, dynamic>>('$_base/$id', data: changes);
|
|
return Goal.fromJson(r.data ?? const {});
|
|
}
|
|
|
|
Future<void> delete(int id) => _dio.delete<void>('$_base/$id');
|
|
|
|
Future<Cached<GoalProgress>> progress(int id) async {
|
|
final r = await _dio.get<Map<String, dynamic>>('$_base/$id/progress');
|
|
return Cached(
|
|
GoalProgress.fromJson(r.data ?? const {}),
|
|
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
|
);
|
|
}
|
|
}
|