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.
 
 
 
 
 
 

396 lines
12 KiB

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<String> 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<String> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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();