You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
428 lines
14 KiB
428 lines
14 KiB
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<MemoryService>();
|
|
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<MemoryItem> items = <MemoryItem>[].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<List>(_kItems);
|
|
if (raw == null) return;
|
|
final list = raw
|
|
.map((e) => MemoryItem.fromJson(Map<String, dynamic>.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<String, dynamic>? _data(dynamic resp) {
|
|
if (resp == null) return null;
|
|
if (resp.data == null) return null;
|
|
if (resp.data is Map) return Map<String, dynamic>.from(resp.data as Map);
|
|
return null;
|
|
}
|
|
|
|
List<MemoryItem> _parseItems(dynamic raw) {
|
|
if (raw is! List) return [];
|
|
final out = <MemoryItem>[];
|
|
for (final e in raw) {
|
|
try {
|
|
out.add(MemoryItem.fromJson(Map<String, dynamic>.from(e as Map)));
|
|
} catch (err) {
|
|
// 单条解析失败只跳过这一条。一条脏数据不该让整页空白——
|
|
// appconfig_model 那边就是因为 non-nullable 字段让整个 fromJson 挂掉,
|
|
// 表现成设备列表整体不显示。
|
|
Logger.w(_tag, '跳过一条无法解析的记忆项: $err');
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ===================== 读 =====================
|
|
|
|
/// 拉取日期范围内的记忆项,结果**合并**进 [items](不是整体替换)。
|
|
///
|
|
/// 合并而不是替换:日历会按需拉不同月份,整体替换会让上个月的数据在切回去时消失。
|
|
Future<bool> load({
|
|
DateTime? start,
|
|
DateTime? end,
|
|
List<String>? 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<MemoryItem> 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<List<MemoryItem>> 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<MemoryItem> _localToday() {
|
|
final today = _fmtDate(DateTime.now());
|
|
return items.where((e) => e.happenDate == today).toList();
|
|
}
|
|
|
|
/// 未来 N 天的提醒时刻(重复项已由服务端展开成绝对时刻)
|
|
Future<List<MemoryRemindSlot>> 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<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
} catch (e) {
|
|
Logger.w(_tag, 'loadUpcoming 异常: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ===================== 写 =====================
|
|
|
|
/// 新增。成功返回落库后的记录(含服务端分配的 id),失败返回 null。
|
|
Future<MemoryItem?> 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<String, dynamic>.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<MemoryItem?> update(MemoryItem item, {List<String>? 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<String, dynamic>.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<bool> remove(List<int> 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<bool> complete(List<int> ids, bool done) async {
|
|
if (ids.isEmpty) return true;
|
|
final before = <int, int>{};
|
|
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<MemoryReport?> 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<String, dynamic>.from(r as Map));
|
|
} catch (e) {
|
|
Logger.w(_tag, 'loadPendingReport 异常: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> 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<MemoryItem> 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<MemoryItem> 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<String, int> expenseCentsByCurrency(DateTime from, DateTime to) {
|
|
final out = <String, int>{};
|
|
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<String, dynamic> parseStat(String statJson) {
|
|
if (statJson.isEmpty) return {};
|
|
try {
|
|
final v = jsonDecode(statJson);
|
|
return v is Map ? Map<String, dynamic>.from(v) : {};
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
|