/// 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> asObjects(Object? v) => v is List ? v.whereType().map((e) => Map.from(e)).toList() : const []; Map? asObject(Object? v) => v is Map ? Map.from(v) : null; List asStrings(Object? v) => v is List ? v.map((e) => e.toString()).toList() : const []; /// `{"schedule": "8100.00", ...}` — a string→decimal-string map such as `by_basis`. Map 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 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); }