DateTime.tryParse("2026-09-18") в Dart возвращает локальное время, не UTC —
на MSK (UTC+3) это трёхчасовой сдвиг относительно того, что сервер имел
в виду датой без времени. asDate() перетэговывает те же значения полей как
UTC вместо доверия tryParse; настоящий таймстемп с "Z"/смещением проходит
насквозь без изменений.
86 lines
3.2 KiB
Dart
86 lines
3.2 KiB
Dart
/// JSON coercion helpers shared by the hand-written phase-4 data layers
|
|
/// (`features/{income,rebalance,goals,tax}/data`, `features/portfolio/data`).
|
|
///
|
|
/// **Temporary, like the layers that use them.** Once the phase-4 routes land in
|
|
/// `openapi/openapi.json` and `just gen-client` runs, the generated models take over the
|
|
/// parsing and this file loses most of its callers.
|
|
///
|
|
/// Nothing here ever produces a `double`: money, quantities and rates arrive as strings and
|
|
/// stay strings until the point of display, where [Decimal] parses them. A numeric JSON
|
|
/// value (should the server ever send one) is kept in its lossless string form.
|
|
library;
|
|
|
|
import 'package:decimal/decimal.dart';
|
|
import 'package:dio/dio.dart';
|
|
|
|
import '../auth/auth_controller.dart' show problemMessage;
|
|
|
|
String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString());
|
|
|
|
int? asInt(Object? v) => switch (v) {
|
|
null => null,
|
|
final int i => i,
|
|
final String s => int.tryParse(s),
|
|
_ => null,
|
|
};
|
|
|
|
bool asBool(Object? v) => v == true;
|
|
|
|
DateTime? asDate(Object? v) {
|
|
final s = asString(v);
|
|
if (s == null || s.isEmpty) return null;
|
|
final parsed = DateTime.tryParse(s);
|
|
if (parsed == null) return null;
|
|
// A plain date ("2026-09-18") has no timezone of its own; `DateTime.tryParse` still
|
|
// tags it local, which silently shifts it by the machine's offset. Re-tag the same
|
|
// wall-clock fields as UTC so the date this API meant is the date callers get, on any
|
|
// machine — a full timestamp (already carrying "Z"/an offset) is left untouched.
|
|
if (parsed.isUtc) return parsed;
|
|
return DateTime.utc(
|
|
parsed.year,
|
|
parsed.month,
|
|
parsed.day,
|
|
parsed.hour,
|
|
parsed.minute,
|
|
parsed.second,
|
|
parsed.millisecond,
|
|
parsed.microsecond,
|
|
);
|
|
}
|
|
|
|
/// A list of JSON objects; anything else (including null) becomes an empty list.
|
|
List<Map<String, dynamic>> asObjects(Object? v) => v is List
|
|
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
|
: const [];
|
|
|
|
Map<String, dynamic>? asObject(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
|
|
|
List<String> asStrings(Object? v) =>
|
|
v is List ? v.map((e) => e.toString()).toList() : const <String>[];
|
|
|
|
/// `{"schedule": "8100.00", ...}` — a string→decimal-string map such as `by_basis`.
|
|
Map<String, String> asStringMap(Object? v) => v is Map
|
|
? {for (final e in v.entries) e.key.toString(): e.value?.toString() ?? '0'}
|
|
: const {};
|
|
|
|
/// Parses a decimal string, returning null for null/empty/garbage rather than zero:
|
|
/// "no value" and "zero" are different answers and must not be merged.
|
|
Decimal? asDecimal(Object? v) {
|
|
final s = asString(v);
|
|
if (s == null || s.isEmpty) return null;
|
|
return Decimal.tryParse(s);
|
|
}
|
|
|
|
/// Sum of decimal strings, exact — used for weight sums, where 0.1 + 0.2 in `double`
|
|
/// would make a valid set look invalid.
|
|
Decimal sumDecimals(Iterable<String> values) => values.fold(
|
|
Decimal.zero,
|
|
(acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero),
|
|
);
|
|
|
|
/// RFC 7807 `detail` first, then a readable fallback. Never surfaces a raw [DioException].
|
|
String apiErrorMessage(Object error) {
|
|
if (error is! DioException) return error.toString();
|
|
return problemMessage(error);
|
|
}
|