import 'package:flutter/material.dart'; /// 记忆项分类。 /// /// ⚠️ **不要用 enum 做解析口径**:服务端的分类是字符串白名单,加新分类只改服务端 /// 常量、不发新包。客户端一旦 switch 不到就抛,服务端加一个分类就会把所有存量 /// App 的拾忆页整页打崩。所以这里保留原始字符串,展示层一律经 [MemoryCategory.of] /// 兜底成 todo。 class MemoryCategory { static const String alarm = 'alarm'; static const String todo = 'todo'; static const String idea = 'idea'; static const String expense = 'expense'; /// 本版认识的分类,按拾忆页筛选条的展示顺序 static const List known = [todo, alarm, idea, expense]; /// 认不出来的分类一律当 todo 渲染。这是「服务端可以随时加分类」的前提。 static String of(String raw) => known.contains(raw) ? raw : todo; static bool isKnown(String raw) => known.contains(raw); /// 分类配色。未知分类给中性灰,不要复用某个已有分类的颜色—— /// 那会让新分类伪装成旧分类。 static Color colorOf(String raw) { switch (raw) { case todo: return const Color(0xFF5B8DEF); case alarm: return const Color(0xFF22C1A6); case idea: return const Color(0xFFF59E0B); case expense: return const Color(0xFFEC4899); default: return const Color(0xFF94A3B8); } } /// i18n key static String labelKey(String raw) { switch (raw) { case todo: return 'memCatTodo'; case alarm: return 'memCatAlarm'; case idea: return 'memCatIdea'; case expense: return 'memCatExpense'; default: return 'memCatOther'; } } } /// 记忆项来源 class MemorySource { static const String meeting = 'meeting'; static const String assistant = 'assistant'; static const String manual = 'manual'; static const String device = 'device'; } /// 重复规则 class MemoryRepeat { static const String once = 'once'; static const String daily = 'daily'; static const String weekly = 'weekly'; static const String weekdays = 'weekdays'; static const String weekend = 'weekend'; static const String monthly = 'monthly'; static const List all = [once, daily, weekly, weekdays, weekend, monthly]; static String labelKey(String raw) { switch (raw) { case daily: return 'memRepeatDaily'; case weekly: return 'memRepeatWeekly'; case weekdays: return 'memRepeatWeekdays'; case weekend: return 'memRepeatWeekend'; case monthly: return 'memRepeatMonthly'; default: return 'memRepeatOnce'; } } } /// 状态:0 待办 / 1 完成 / 2 废弃 class MemoryState { static const int pending = 0; static const int done = 1; static const int dropped = 2; } /// 一条记忆项。手写镜像服务端 `pb.DBMemoryItem`。 /// /// ⚠️ **每个字段都必须能吃下缺失值**。服务端删一个字段、或某条老记录没有某列时, /// non-nullable 字段会让整个 fromJson 抛 TypeError,表现是拾忆页整体空白而不是 /// 少一个字段——`appconfig_model.dart` 已经为此在服务端留了兼容层,这里从一开始 /// 就用带默认值的解析,不重蹈覆辙。 class MemoryItem { final int id; final String category; final String source; final String sourceId; final String clientKey; String title; String detail; /// 归属日期 `yyyy-MM-dd`,服务端保证永不为空 String happenDate; /// `HH:mm`,无具体时间则为空串 String happenTime; /// false = 日期是推断的(比如会议待办没写截止时间,落在会议当天)。 /// 这类项要单独归到「待定日期」分组,不能和当天确定事项混排—— /// 一次会抽出 8 条全堆在同一天,那一格就没法看了。 bool dateCertain; /// 截止时间原文(「下周三前」),服务端推不出具体日期时靠它表达 String dueRaw; /// 录入时的时区(IANA 名) String tz; String repeatRule; String repeatRaw; int weekday; /// 花销金额,**单位是分**。与服务端 pay/结算模块同口径。 /// 展示时除以 100,不要在别处再引入一套「元」的表示法。 int amountCents; String currency; String owner; int state; int remindAhead; int nextRemindAt; bool userEdited; final int genRound; final String extra; final int createTime; final int updateTime; final int finishTime; MemoryItem({ this.id = 0, this.category = MemoryCategory.todo, this.source = MemorySource.manual, this.sourceId = '', this.clientKey = '', this.title = '', this.detail = '', this.happenDate = '', this.happenTime = '', this.dateCertain = true, this.dueRaw = '', this.tz = '', this.repeatRule = MemoryRepeat.once, this.repeatRaw = '', this.weekday = 0, this.amountCents = 0, this.currency = 'CNY', this.owner = '', this.state = MemoryState.pending, this.remindAhead = 5, this.nextRemindAt = 0, this.userEdited = false, this.genRound = 0, this.extra = '', this.createTime = 0, this.updateTime = 0, this.finishTime = 0, }); bool get isDone => state == MemoryState.done; bool get isExpense => category == MemoryCategory.expense; bool get isRepeating => repeatRule.isNotEmpty && repeatRule != MemoryRepeat.once; /// 金额的显示值(元)。只在展示层用,存储与传输一律用分。 double get amountYuan => amountCents / 100.0; /// 解析出的日期。服务端保证 happenDate 非空且合法,但真拿到脏数据时 /// 退回今天而不是抛——一条坏记录不该让整页渲染不出来。 DateTime get happenDateTime => DateTime.tryParse(happenDate) ?? DateTime.now(); TimeOfDay? get startTime { if (happenTime.isEmpty) return null; final parts = happenTime.split(':'); if (parts.length != 2) return null; final h = int.tryParse(parts[0]); final m = int.tryParse(parts[1]); if (h == null || m == null) return null; if (h < 0 || h > 23 || m < 0 || m > 59) return null; return TimeOfDay(hour: h, minute: m); } static int _int(dynamic v, [int fallback = 0]) => memInt(v, fallback); static bool _bool(dynamic v, [bool fallback = false]) => memBool(v, fallback); static String _str(dynamic v, [String fallback = '']) => memStr(v, fallback); factory MemoryItem.fromJson(Map json) => MemoryItem( id: _int(json['id']), category: _str(json['category'], MemoryCategory.todo), source: _str(json['source'], MemorySource.manual), sourceId: _str(json['source_id']), clientKey: _str(json['client_key']), title: _str(json['title']), detail: _str(json['detail']), happenDate: _str(json['happen_date']), happenTime: _str(json['happen_time']), dateCertain: _bool(json['date_certain'], true), dueRaw: _str(json['due_raw']), tz: _str(json['tz']), repeatRule: _str(json['repeat_rule'], MemoryRepeat.once), repeatRaw: _str(json['repeat_raw']), weekday: _int(json['weekday']), amountCents: _int(json['amount_cents']), currency: _str(json['currency'], 'CNY'), owner: _str(json['owner']), state: _int(json['state']), remindAhead: _int(json['remind_ahead'], 5), nextRemindAt: _int(json['next_remind_at']), userEdited: _bool(json['user_edited']), genRound: _int(json['gen_round']), extra: _str(json['extra']), createTime: _int(json['create_time']), updateTime: _int(json['update_time']), finishTime: _int(json['finish_time']), ); Map toJson() => { 'id': id, 'category': category, 'source': source, 'source_id': sourceId, 'client_key': clientKey, 'title': title, 'detail': detail, 'happen_date': happenDate, 'happen_time': happenTime, 'date_certain': dateCertain, 'due_raw': dueRaw, 'tz': tz, 'repeat_rule': repeatRule, 'repeat_raw': repeatRaw, 'weekday': weekday, 'amount_cents': amountCents, 'currency': currency, 'owner': owner, 'state': state, 'remind_ahead': remindAhead, 'next_remind_at': nextRemindAt, 'user_edited': userEdited, 'gen_round': genRound, 'extra': extra, 'create_time': createTime, 'update_time': updateTime, 'finish_time': finishTime, }; MemoryItem copy() => MemoryItem.fromJson(toJson()); } /// 一个展开后的提醒时刻(服务端已按项自己的时区算好,客户端直接排通知) class MemoryRemindSlot { final int itemId; final int remindAt; final int happenAt; final String title; final String detail; final String category; MemoryRemindSlot({ required this.itemId, required this.remindAt, required this.happenAt, required this.title, required this.detail, required this.category, }); factory MemoryRemindSlot.fromJson(Map json) => MemoryRemindSlot( itemId: MemoryItem._int(json['item_id']), remindAt: MemoryItem._int(json['remind_at']), happenAt: MemoryItem._int(json['happen_at']), title: MemoryItem._str(json['title']), detail: MemoryItem._str(json['detail']), category: MemoryItem._str(json['category'], MemoryCategory.todo), ); DateTime get remindDateTime => DateTime.fromMillisecondsSinceEpoch(remindAt * 1000); } /// 单币种花销小计。服务端按币种分行给出,**不做汇率换算**—— /// 把 CNY 和 JPY 加成一个数是错的。 class MemoryExpenseSum { final String currency; final int totalCents; final int count; MemoryExpenseSum({ required this.currency, required this.totalCents, required this.count, }); double get totalYuan => totalCents / 100.0; factory MemoryExpenseSum.fromJson(Map json) => MemoryExpenseSum( currency: MemoryItem._str(json['currency'], 'CNY'), totalCents: MemoryItem._int(json['total_cents']), count: MemoryItem._int(json['count']), ); } /// 周期报告 class MemoryReport { final int id; final String periodType; // week / month final String periodKey; // 2026-W35 / 2026-08 final String periodStart; final String periodEnd; final String statJson; final String summary; final int state; final String errorMsg; bool confirmed; MemoryReport({ this.id = 0, this.periodType = 'week', this.periodKey = '', this.periodStart = '', this.periodEnd = '', this.statJson = '', this.summary = '', this.state = 0, this.errorMsg = '', this.confirmed = false, }); bool get isWeek => periodType == 'week'; factory MemoryReport.fromJson(Map json) => MemoryReport( id: MemoryItem._int(json['id']), periodType: MemoryItem._str(json['period_type'], 'week'), periodKey: MemoryItem._str(json['period_key']), periodStart: MemoryItem._str(json['period_start']), periodEnd: MemoryItem._str(json['period_end']), statJson: MemoryItem._str(json['stat_json']), summary: MemoryItem._str(json['summary']), state: MemoryItem._int(json['state']), errorMsg: MemoryItem._str(json['error_msg']), confirmed: MemoryItem._bool(json['confirmed']), ); } // ===== 宽松解析器 ===== // // 全部字段都经这三个函数解析,不直接 `json['x'] as int`。 // 服务端删字段、老记录缺列、数字被序列化成字符串——任何一种都不该让整个 // fromJson 抛 TypeError。appconfig_model 那边就是因为 non-nullable 字段 // 让整份配置解析失败,表现成设备列表整体空白而不是少一个字段。 int memInt(dynamic v, [int fallback = 0]) { if (v is int) return v; if (v is num) return v.toInt(); if (v is String) return int.tryParse(v) ?? fallback; return fallback; } bool memBool(dynamic v, [bool fallback = false]) { if (v is bool) return v; if (v is num) return v != 0; if (v is String) return v == 'true' || v == '1'; return fallback; } String memStr(dynamic v, [String fallback = '']) => v == null ? fallback : v.toString();