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
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();
|
|
|