import 'dart:convert'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import '../models/memory_item.dart'; import 'network/api.dart'; /// 记忆中心(拾忆)的数据层。 /// /// 服务端是唯一真相源,本地 GetStorage 只做缓存与离线兜底。 /// /// ⚠️ **只缓存最近 [_cacheDays] 天**,不做全量镜像。GetStorage 是把整个 box 读进 /// 内存的 JSON 文件,记忆项一年三千多条,全量缓存会让每次启动越来越慢。 class MemoryService extends GetxService { static MemoryService get to => Get.find(); static const String _tag = 'MemoryService'; static const String _kItems = 'memory_items_cache'; static const String _kCacheDate = 'memory_items_cache_date'; /// 本地缓存保留的天数(以今天为中心,前后各这么多天) static const int _cacheDays = 90; final _storage = GetStorage(); /// 当前已加载的记忆项。页面 Obx 直接读它。 final RxList items = [].obs; /// 是否正在拉取(用于列表下拉刷新的转圈,不用于全屏遮罩) final RxBool loading = false.obs; /// 最近一次拉取是否失败。失败时页面显示「显示的是本地缓存」而不是空白。 final RxBool staleFromCache = false.obs; /// 待确认的周期报告 id,0 = 没有 final RxInt pendingReportId = 0.obs; final RxString pendingReportType = ''.obs; @override void onInit() { super.onInit(); _restore(); } // ===================== 本地缓存 ===================== void _restore() { try { final raw = _storage.read(_kItems); if (raw == null) return; final list = raw .map((e) => MemoryItem.fromJson(Map.from(e as Map))) .toList(); items.assignAll(list); Logger.d(_tag, '恢复本地缓存 ${list.length} 条'); } catch (e) { // 缓存结构变了(比如加了字段)就整份丢掉重拉,别让一份坏缓存把页面卡死 Logger.w(_tag, '本地缓存解析失败,已清空: $e'); items.clear(); _storage.remove(_kItems); } } void _persist() { try { final today = DateTime.now(); final from = today.subtract(const Duration(days: _cacheDays)); final to = today.add(const Duration(days: _cacheDays)); final kept = items.where((e) { final d = e.happenDateTime; return d.isAfter(from) && d.isBefore(to); }).toList(); _storage.write(_kItems, kept.map((e) => e.toJson()).toList()); _storage.write(_kCacheDate, _fmtDate(today)); } catch (e) { Logger.w(_tag, '写本地缓存失败(不影响本次使用): $e'); } } /// 登出时清空。记忆项是账号数据,换账号必须清干净。 void clear() { items.clear(); pendingReportId.value = 0; pendingReportType.value = ''; staleFromCache.value = false; _storage.remove(_kItems); _storage.remove(_kCacheDate); } // ===================== 工具 ===================== static String _fmtDate(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; /// 本机时区名。拿不到就传空串——服务端会退到它自己的本地时区, /// **不会**退成 UTC(那会让所有时刻静默偏移 8 小时)。 String _tz() { try { return DateTime.now().timeZoneName; } catch (_) { return ''; } } /// 把服务端返回的 data 取成 Map。 /// /// ⚠️ AuthInterceptor 在 code==0 且 data 为空时把 data 换成了 `{}` 而不是 null, /// 在业务错误时才把 data 置成 null 并 resolve(**不抛**)。所以: /// resp == null → 服务端明确返回了业务错误 /// resp == {} → 成功但没数据 /// 两者含义相反,绝不能合并判断。 Map? _data(dynamic resp) { if (resp == null) return null; if (resp.data == null) return null; if (resp.data is Map) return Map.from(resp.data as Map); return null; } List _parseItems(dynamic raw) { if (raw is! List) return []; final out = []; for (final e in raw) { try { out.add(MemoryItem.fromJson(Map.from(e as Map))); } catch (err) { // 单条解析失败只跳过这一条。一条脏数据不该让整页空白—— // appconfig_model 那边就是因为 non-nullable 字段让整个 fromJson 挂掉, // 表现成设备列表整体不显示。 Logger.w(_tag, '跳过一条无法解析的记忆项: $err'); } } return out; } // ===================== 读 ===================== /// 拉取日期范围内的记忆项,结果**合并**进 [items](不是整体替换)。 /// /// 合并而不是替换:日历会按需拉不同月份,整体替换会让上个月的数据在切回去时消失。 Future load({ DateTime? start, DateTime? end, List? categories, bool silent = false, }) async { final s = start ?? DateTime.now().subtract(const Duration(days: 30)); final e = end ?? DateTime.now().add(const Duration(days: 60)); if (!silent) loading.value = true; try { final resp = await Api.memoryList({ 'start_date': _fmtDate(s), 'end_date': _fmtDate(e), if (categories != null && categories.isNotEmpty) 'categories': categories, 'tz': _tz(), 'size': 1000, }); final data = _data(resp); if (data == null) { staleFromCache.value = true; Logger.w(_tag, '拉取记忆项失败(业务错误),沿用本地缓存'); return false; } final fetched = _parseItems(data['items']); _mergeRange(fetched, _fmtDate(s), _fmtDate(e)); staleFromCache.value = false; _persist(); Logger.d(_tag, '拉取 ${fetched.length} 条 (${_fmtDate(s)} ~ ${_fmtDate(e)})'); return true; } catch (err) { // 网络层异常:保留本地缓存,页面标成「离线」,绝不清空 staleFromCache.value = true; Logger.w(_tag, '拉取记忆项异常,沿用本地缓存: $err'); return false; } finally { if (!silent) loading.value = false; } } /// 用服务端返回的这一段替换掉本地同一段,段外的保持不动 void _mergeRange(List fetched, String start, String end) { final kept = items.where((e) { final d = e.happenDate; return d.compareTo(start) < 0 || d.compareTo(end) > 0; }).toList(); kept.addAll(fetched); kept.sort(_compare); items.assignAll(kept); } static int _compare(MemoryItem a, MemoryItem b) { final c = a.happenDate.compareTo(b.happenDate); if (c != 0) return c; // 无时刻的排在当天最后(与服务端 listItems 的排序口径一致) if (a.happenTime.isEmpty != b.happenTime.isEmpty) { return a.happenTime.isEmpty ? 1 : -1; } final t = a.happenTime.compareTo(b.happenTime); if (t != 0) return t; return a.id.compareTo(b.id); } /// 当天事项(每日弹窗用)。顺带带回待确认报告标记。 /// /// ⚠️ **调用方不要 await 在启动路径上**。冷启动已经串了四次网络往返 /// (网络检查 → 版本检查 → getLoginToken → getAppConfig),再挂一次会把 /// 「页面立刻可见」这条刚做过的优化又推回去。进主页后异步拉、拿到再弹。 Future> loadToday() async { try { final resp = await Api.memoryToday({ 'date': _fmtDate(DateTime.now()), 'tz': _tz(), }); final data = _data(resp); if (data == null) return _localToday(); final list = _parseItems(data['items']); pendingReportId.value = memInt(data['pending_report_id']); pendingReportType.value = (data['pending_report_type'] ?? '').toString(); _mergeRange(list, _fmtDate(DateTime.now()), _fmtDate(DateTime.now())); _persist(); return list; } catch (e) { Logger.w(_tag, 'loadToday 异常,退回本地缓存: $e'); return _localToday(); } } List _localToday() { final today = _fmtDate(DateTime.now()); return items.where((e) => e.happenDate == today).toList(); } /// 未来 N 天的提醒时刻(重复项已由服务端展开成绝对时刻) Future> loadUpcoming({int days = 7}) async { try { final resp = await Api.memoryUpcoming({'days': days, 'tz': _tz()}); final data = _data(resp); if (data == null) return []; final raw = data['slots']; if (raw is! List) return []; return raw .map((e) => MemoryRemindSlot.fromJson(Map.from(e as Map))) .toList(); } catch (e) { Logger.w(_tag, 'loadUpcoming 异常: $e'); return []; } } // ===================== 写 ===================== /// 新增。成功返回落库后的记录(含服务端分配的 id),失败返回 null。 Future add(MemoryItem item) async { try { item.tz = item.tz.isEmpty ? _tz() : item.tz; final resp = await Api.memoryAdd({'item': item.toJson()}); final data = _data(resp); if (data == null) return null; final saved = MemoryItem.fromJson( Map.from(data['item'] as Map)); // 幂等命中时服务端返回既有记录,这里按 id 去重后再插,避免列表里出现两条 items.removeWhere((e) => e.id == saved.id); items.add(saved); items.sort(_compare); items.refresh(); _persist(); return saved; } catch (e) { Logger.e(_tag, '新增失败: $e'); return null; } } /// 修改。fields 点名要改的字段(json 名),空 = 按服务端白名单全量覆盖。 Future update(MemoryItem item, {List? fields}) async { try { final resp = await Api.memoryUpdate({ 'id': item.id, 'item': item.toJson(), if (fields != null && fields.isNotEmpty) 'fields': fields, }); final data = _data(resp); if (data == null) return null; final saved = MemoryItem.fromJson( Map.from(data['item'] as Map)); final idx = items.indexWhere((e) => e.id == saved.id); if (idx >= 0) { items[idx] = saved; } else { items.add(saved); } items.sort(_compare); items.refresh(); _persist(); return saved; } catch (e) { Logger.e(_tag, '修改失败: $e'); return null; } } /// 删除 Future remove(List ids) async { if (ids.isEmpty) return true; try { final resp = await Api.memoryDel({'ids': ids}); if (_data(resp) == null) return false; items.removeWhere((e) => ids.contains(e.id)); items.refresh(); _persist(); return true; } catch (e) { Logger.e(_tag, '删除失败: $e'); return false; } } /// 标记完成 / 退回未完成。 /// /// 先改本地再发请求(乐观更新):勾一下要等一次往返才变灰太迟钝。 /// 失败时**回滚**——不回滚会让用户以为已经勾上了,下次进页面又变回来。 Future complete(List ids, bool done) async { if (ids.isEmpty) return true; final before = {}; for (final e in items) { if (ids.contains(e.id)) { before[e.id] = e.state; e.state = done ? MemoryState.done : MemoryState.pending; } } items.refresh(); try { final resp = await Api.memoryComplete({'ids': ids, 'done': done}); if (_data(resp) == null) throw Exception('业务错误'); _persist(); return true; } catch (e) { for (final it in items) { if (before.containsKey(it.id)) it.state = before[it.id]!; } items.refresh(); Logger.e(_tag, '标记完成失败,已回滚: $e'); return false; } } // ===================== 周期报告 ===================== /// 取最近一份未确认的报告。 /// /// 服务端在「生成中」时返回 MemoryReportNotReady(5205),AuthInterceptor 会把 /// data 置 null,这里拿到 null 就当「暂时没有」——**不要弹错**,报告还在生成 /// 不是故障。 Future loadPendingReport() async { try { final resp = await Api.memoryGetReport({}); final data = _data(resp); if (data == null) return null; final r = data['report']; if (r == null) return null; return MemoryReport.fromJson(Map.from(r as Map)); } catch (e) { Logger.w(_tag, 'loadPendingReport 异常: $e'); return null; } } Future confirmReport(int reportId) async { try { final resp = await Api.memoryConfirmReport({'report_id': reportId}); if (_data(resp) == null) return false; if (pendingReportId.value == reportId) { pendingReportId.value = 0; pendingReportType.value = ''; } return true; } catch (e) { Logger.e(_tag, '确认报告失败: $e'); return false; } } // ===================== 查询辅助(页面用)===================== List forDate(DateTime day, {String? category}) { final d = _fmtDate(day); return items .where((e) => e.happenDate == d && (category == null || category.isEmpty || e.category == category)) .toList(); } List forRange(DateTime from, DateTime to, {String? category}) { final f = _fmtDate(from); final t = _fmtDate(to); return items .where((e) => e.happenDate.compareTo(f) >= 0 && e.happenDate.compareTo(t) <= 0 && (category == null || category.isEmpty || e.category == category)) .toList(); } /// 范围内的花销小计,**按币种分组**。不做汇率换算。 Map expenseCentsByCurrency(DateTime from, DateTime to) { final out = {}; for (final e in forRange(from, to)) { if (!e.isExpense) continue; out[e.currency] = (out[e.currency] ?? 0) + e.amountCents; } return out; } /// 解析报告的 stat_json。坏数据返回空 Map,不抛。 static Map parseStat(String statJson) { if (statJson.isEmpty) return {}; try { final v = jsonDecode(statJson); return v is Map ? Map.from(v) : {}; } catch (_) { return {}; } } }