Browse Source
dart analyze 零错误,flutter build apk --debug 通过。 零期修缮 - tabTodo 补齐 41 个语言。此前只有 11 个文件有这个 key,其中 8 个的值是 'Remin'(不是任何语言里的词,像 Reminisce 打了一半),缺 key 的 30 个经 GetX 回退到 en_US 同样显示 'Remin' —— 41 个语言里 38 个是坏的,只是不报错。 - i18n_audit.py 补「缺失 key」检查。它此前**结构上无法发现缺失的 key**:循环遍历 的是该文件已有的 key,且对 baseline 里没有的直接 continue。这正是 tabTodo 在 30 个语言里悄悄丢掉而没人发现的原因。现以 zh_cn 为基准,实测报出 7243 处历史缺失 (40 个语言只有 1 个不缺)。加了 --fail-on-missing/--allow-missing 给 CI 卡「只增 不减」,默认不开——一上来就红会让人直接把检查关掉。 - pubspec 引入 timezone + flutter_timezone。zonedSchedule 只接受 tz.TZDateTime, 没有这两个依赖,提醒功能一行都写不了。 拾忆页从纯展示改成可写(此前最大的工作量遗漏) 页面原来没有 RefreshIndicator、没有 FloatingActionButton、卡片没有 onTap, 于是服务端的 add/update/del/complete 四个写接口在客户端一个调用点都没有, user_edited 那套「保护用户修改」的机制完全空转。本次补齐: - MemoryEditSheet:新增/编辑弹层,按分类分流表单(统一表存储 ≠ 统一表单) - 卡片 onTap 打开编辑、左侧勾选框标记完成(灵感不给勾选框,它没有完成一说) - 右下角 + 新增、RefreshIndicator 下拉刷新、带引导的空态页 - date_certain=false 的项单独归到「待定日期」小节。一次会抽出 8 条没写截止时间的 待办全堆在会议当天,不分开那一格就没法看了。 数据层 - MemoryItem / MemoryRemindSlot / MemoryReport 手写镜像服务端 pb。 **每个字段都经宽松解析器**(memInt/memBool/memStr),不直接 as int: 服务端删字段或老记录缺列时,non-nullable 会让整个 fromJson 抛 TypeError, 表现是整页空白而不是少一个字段(appconfig_model 已经为此踩过坑)。 单条解析失败只跳过那一条。 - 分类保留原始字符串而不是 enum 解析。服务端加分类只改常量不发包, 客户端 switch 不到就抛等于服务端一加分类就打崩所有存量 App。 CalendarEventCategory 加了 other 作为未知兜底,筛选与卡片分流都改成值比较 而不是穷举 switch。 - MemoryService 只缓存最近 90 天。GetStorage 是把整个 box 读进内存的 JSON, 一年三千多条全量缓存会让每次启动越来越慢。 - 拉取失败保留本地缓存并标 staleFromCache,页面显示行内「当前显示的是本地缓存」, 不清空、不弹红条。 - 勾选完成是乐观更新 + 失败回滚。不回滚会让用户以为已经勾上,下次进页面又变回来。 - memory_today / memory_upcoming 加进 AuthInterceptor 的红条抑制名单: 它们在启动与前台恢复时自动调,用户什么都没点就吃一条英文内部状态词很差。 删除 mock mockCalendarEvents() 与它引用的 44 个 demo* i18n key 一并删除(41 个文件共 484 行)。 真实数据上线后混着演示数据会让人以为系统出错。 本地提醒(MemoryReminderScheduler) - 通知 id 占 920000 起的固定区间,**逐条 cancel,绝不 cancelAll()** —— 仓库里已有 两个各自 new 的 FlutterLocalNotificationsPlugin 实例,cancelAll 会顺手干掉正在 录音的前台通知,表现是录音悄悄失去前台身份被系统回收。 - 单独的通知通道:录音提示是常驻低优先级通道,共用会让提醒被降级成静默。 - **只查权限不申请**。申请必须走 PermissionUtil(它收敛了过审要求的 48 小时冷却), 且只允许用户在权限管理页主动发起,这里主动申请等于绕过合规规则。 无权限时降级为「打开 App 才提醒」,不阻塞。 - 精确排期失败(Android 14 起 SCHEDULE_EXACT_ALARM 默认不授予)自动退化成 inexact 再试一次——不精确的提醒也比没有强,日志会记下来。 - 拿不到系统时区时按本机偏移找等价区兜底,不落回 UTC(那会让国内用户全部偏 8 小时)。 每日播报(MemoryDailySheet) - **不 await 在启动路径上**。冷启动已串四次网络往返,仓库刚做过相反方向的优化 (6a64c3d0 把会议模板拉取改成 unawaited)。这里是进主页后异步拉、拿到再弹。 - 排在隐私政策与强更弹窗之后,且必须在登录态确认之后。 - 通话翻译进行中一律不播报:两者共用同一个 AVAudioSession,在对方刚调好的会话上 再设一次会把通话音频路由弄乱(iOS 那条 phone_call 分支被改坏过三次)。 - 关窗必须真停:speakStream 是队列式播放,只 stop 不清队列会「关了还在念下一句」。 - 播报文案本地拼,不调 LLM。 生命周期与账号 - 切前台的刷新挂在 AppLifecycleManager(仓库已有 emai/translation/permissions 三个各自实现的 observer,本类就是收敛点),全部 unawaited。 - 登出清空记忆数据与已排提醒。不清的话换账号会看到上一个人的日程花销灵感, 提醒也会继续按上一个账号的安排响。 助手闹钟迁移 SET_clock 改为双写:本地 reminders 保留一个版本(上传失败时本机仍可用), 同时写服务端 memory_item(category=alarm)。**幂等键用指令 id** —— 本类的按 id 去重只在本进程内存里,换机/重装/重启后全失效,服务端靠 (uid, client_key) 唯一索引兜住。认不出的重复规则按单次处理,宁可只响一次也不按猜的规则天天响。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
59 changed files with 4839 additions and 745 deletions
@ -0,0 +1,396 @@ |
|||||
|
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(); |
||||
@ -0,0 +1,254 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; |
||||
|
import 'package:flutter_timezone/flutter_timezone.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
|
import 'package:timezone/data/latest_all.dart' as tzdata; |
||||
|
import 'package:timezone/timezone.dart' as tz; |
||||
|
|
||||
|
import '../../core/utils/logger.dart'; |
||||
|
import '../models/memory_item.dart'; |
||||
|
import 'memory_service.dart'; |
||||
|
|
||||
|
/// 拾忆的提前提醒:把服务端展开好的提醒时刻排成系统本地通知。 |
||||
|
/// |
||||
|
/// ## 为什么在客户端做 |
||||
|
/// App 未打开、甚至无网时也要响。服务端推送做不到——没有 APNs/FCM 通道, |
||||
|
/// 国内厂商推送还要逐家接。 |
||||
|
/// |
||||
|
/// ## ⚠️ 送达率做不到 100%,这是已知的 |
||||
|
/// 三条叠加,都不是代码能绕过去的: |
||||
|
/// 1. 通知权限有 48 小时合规冷却(见 PermissionUtil):用户在别处拒过一次, |
||||
|
/// 两天内提醒完全静默,而且 App **不被允许**弹窗解释。 |
||||
|
/// 2. Android 14 起 SCHEDULE_EXACT_ALARM 对普通应用默认不授予。真正该用的是 |
||||
|
/// USE_EXACT_ALARM,但 Google Play 要求「应用核心功能就是闹钟/日历」并单独 |
||||
|
/// 提交说明——那是产品决策。拿不到精确权限时这里退化成 inexact 排期, |
||||
|
/// 「提前 5 分钟」会变成「提前 5 到 20 分钟之间」。 |
||||
|
/// 3. 国内 ROM 的省电策略会清掉未加白名单应用的 AlarmManager 排期。 |
||||
|
/// |
||||
|
/// ## ⚠️ 通知 id 必须分命名空间 |
||||
|
/// 仓库里已经有两个各自 new 出来的 FlutterLocalNotificationsPlugin 实例 |
||||
|
/// (background_session_notifier / meeting_record_controller),各管各的 id。 |
||||
|
/// 提醒占 [_idBase] 起的一段固定区间,取消时**逐条 cancel**, |
||||
|
/// **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知, |
||||
|
/// 表现是录音悄悄失去前台身份、被系统回收。 |
||||
|
class MemoryReminderScheduler extends GetxService { |
||||
|
static MemoryReminderScheduler get to => Get.find<MemoryReminderScheduler>(); |
||||
|
static const String _tag = 'MemoryReminder'; |
||||
|
|
||||
|
/// 提醒通知的 id 区间:[_idBase, _idBase + _maxScheduled) |
||||
|
static const int _idBase = 920000; |
||||
|
|
||||
|
/// 排期上限。iOS 最多 64 条待处理通知,留一些余量给别的通知用。 |
||||
|
static const int _maxScheduled = 48; |
||||
|
|
||||
|
/// 只排未来这么多天 |
||||
|
static const int _days = 7; |
||||
|
|
||||
|
final FlutterLocalNotificationsPlugin _plugin = |
||||
|
FlutterLocalNotificationsPlugin(); |
||||
|
|
||||
|
bool _pluginReady = false; |
||||
|
bool _tzReady = false; |
||||
|
|
||||
|
/// 上一次真正排出去的 id,取消时按它逐条撤 |
||||
|
final List<int> _scheduledIds = []; |
||||
|
|
||||
|
/// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页 |
||||
|
final RxBool permissionDenied = false.obs; |
||||
|
|
||||
|
Future<void> _ensureTz() async { |
||||
|
if (_tzReady) return; |
||||
|
tzdata.initializeTimeZones(); |
||||
|
try { |
||||
|
final name = await FlutterTimezone.getLocalTimezone(); |
||||
|
tz.setLocalLocation(tz.getLocation(name.toString())); |
||||
|
} catch (e) { |
||||
|
// 拿不到系统时区就用 tz 的默认 UTC 之外的兜底:按本机偏移找一个等价区。 |
||||
|
// 不做这一步会让所有提醒按 UTC 排,国内用户全部偏 8 小时。 |
||||
|
Logger.w(_tag, '读系统时区失败,按本机偏移兜底: $e'); |
||||
|
try { |
||||
|
final offset = DateTime.now().timeZoneOffset; |
||||
|
final match = tz.timeZoneDatabase.locations.values.firstWhere( |
||||
|
(l) => l.currentTimeZone.offset == offset.inMilliseconds, |
||||
|
orElse: () => tz.getLocation('Asia/Shanghai'), |
||||
|
); |
||||
|
tz.setLocalLocation(match); |
||||
|
} catch (_) { |
||||
|
tz.setLocalLocation(tz.getLocation('Asia/Shanghai')); |
||||
|
} |
||||
|
} |
||||
|
_tzReady = true; |
||||
|
} |
||||
|
|
||||
|
Future<void> _ensurePlugin() async { |
||||
|
if (_pluginReady) return; |
||||
|
const androidSettings = |
||||
|
AndroidInitializationSettings('@mipmap/ic_launcher'); |
||||
|
const iosSettings = DarwinInitializationSettings( |
||||
|
// 不在这里请求权限:请求要走 PermissionUtil 那套 48 小时冷却, |
||||
|
// 绕过它等于绕过合规规则。 |
||||
|
requestAlertPermission: false, |
||||
|
requestBadgePermission: false, |
||||
|
requestSoundPermission: false, |
||||
|
); |
||||
|
await _plugin.initialize( |
||||
|
const InitializationSettings(android: androidSettings, iOS: iosSettings), |
||||
|
onDidReceiveNotificationResponse: _onTap, |
||||
|
); |
||||
|
_pluginReady = true; |
||||
|
} |
||||
|
|
||||
|
void _onTap(NotificationResponse response) { |
||||
|
final payload = response.payload; |
||||
|
if (payload == null || payload.isEmpty) return; |
||||
|
try { |
||||
|
final data = jsonDecode(payload) as Map<String, dynamic>; |
||||
|
final itemId = memInt(data['item_id']); |
||||
|
Logger.d(_tag, '用户点开提醒 item:$itemId'); |
||||
|
// 播报交给拾忆页:通知里不直接出声,锁屏状态下强行播报是骚扰。 |
||||
|
Get.toNamed('/main', arguments: {'tab': 'memory', 'focusItem': itemId}); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '解析提醒 payload 失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 通知权限是否已授予。 |
||||
|
/// |
||||
|
/// ⚠️ **只查不申请**。申请必须走 PermissionUtil 的 requestPermission(它收敛了 |
||||
|
/// 48 小时冷却),而且只允许在「设置 → 权限管理」里由用户主动发起。 |
||||
|
/// 这里主动申请会直接违反那条合规规则。 |
||||
|
Future<bool> _hasPermission() async { |
||||
|
try { |
||||
|
final status = await Permission.notification.status; |
||||
|
final ok = status.isGranted; |
||||
|
permissionDenied.value = !ok; |
||||
|
return ok; |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '查询通知权限失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重新排期:先撤旧的,再按服务端给的时刻排新的。 |
||||
|
/// |
||||
|
/// 每次都全撤全排,不做增量 diff —— 改期后旧通知还在响比多排一次贵得多。 |
||||
|
Future<void> reschedule() async { |
||||
|
if (!await _hasPermission()) { |
||||
|
Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」'); |
||||
|
await _cancelAllOurs(); |
||||
|
return; |
||||
|
} |
||||
|
await _ensureTz(); |
||||
|
await _ensurePlugin(); |
||||
|
|
||||
|
final slots = await MemoryService.to.loadUpcoming(days: _days); |
||||
|
await _cancelAllOurs(); |
||||
|
if (slots.isEmpty) { |
||||
|
Logger.d(_tag, '没有待排的提醒'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 服务端已按 remind_at 升序返回。按顺序取前 N 条 —— 顺序错了会把明天的 |
||||
|
// 排掉、留下一周后的,所以这里不再自己排序,直接信任服务端口径。 |
||||
|
final now = DateTime.now(); |
||||
|
var idx = 0; |
||||
|
for (final slot in slots) { |
||||
|
if (idx >= _maxScheduled) break; |
||||
|
final when = slot.remindDateTime; |
||||
|
if (!when.isAfter(now)) continue; |
||||
|
|
||||
|
final id = _idBase + idx; |
||||
|
try { |
||||
|
await _plugin.zonedSchedule( |
||||
|
id, |
||||
|
slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, |
||||
|
_bodyOf(slot), |
||||
|
tz.TZDateTime.from(when, tz.local), |
||||
|
_details(), |
||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, |
||||
|
// 按「墙上时钟时间」解释:用户设的是 8:00,跨时区飞行后也该是当地 8:00 语义 |
||||
|
// 由服务端按项自己的 tz 算好绝对时刻,这里只需保证不被二次偏移。 |
||||
|
uiLocalNotificationDateInterpretation: |
||||
|
UILocalNotificationDateInterpretation.absoluteTime, |
||||
|
payload: jsonEncode({'item_id': slot.itemId}), |
||||
|
); |
||||
|
_scheduledIds.add(id); |
||||
|
idx++; |
||||
|
} catch (e) { |
||||
|
// 拿不到精确闹钟权限(Android 14 常见)会在这里抛。退化成 inexact |
||||
|
// 再试一次:不精确的提醒也比没有强。 |
||||
|
try { |
||||
|
await _plugin.zonedSchedule( |
||||
|
id, |
||||
|
slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, |
||||
|
_bodyOf(slot), |
||||
|
tz.TZDateTime.from(when, tz.local), |
||||
|
_details(), |
||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, |
||||
|
uiLocalNotificationDateInterpretation: |
||||
|
UILocalNotificationDateInterpretation.absoluteTime, |
||||
|
payload: jsonEncode({'item_id': slot.itemId}), |
||||
|
); |
||||
|
_scheduledIds.add(id); |
||||
|
idx++; |
||||
|
Logger.w(_tag, '精确排期失败已退化为 inexact(提醒时间可能延后): $e'); |
||||
|
} catch (e2) { |
||||
|
Logger.e(_tag, '排期失败 item:${slot.itemId}: $e2'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
Logger.i(_tag, '已排定 $idx 条提醒(共 ${slots.length} 个待选时刻)'); |
||||
|
} |
||||
|
|
||||
|
String _bodyOf(MemoryRemindSlot slot) { |
||||
|
if (slot.detail.isNotEmpty) return slot.detail; |
||||
|
final t = DateTime.fromMillisecondsSinceEpoch(slot.happenAt * 1000); |
||||
|
return '${t.hour.toString().padLeft(2, '0')}:' |
||||
|
'${t.minute.toString().padLeft(2, '0')}'; |
||||
|
} |
||||
|
|
||||
|
NotificationDetails _details() { |
||||
|
// 单独的通道:录音提示是常驻低优先级通道,共用会让提醒被降级成静默。 |
||||
|
const android = AndroidNotificationDetails( |
||||
|
'memory_reminder', |
||||
|
'拾忆提醒', |
||||
|
channelDescription: '待办与闹钟的提前提醒', |
||||
|
importance: Importance.high, |
||||
|
priority: Priority.high, |
||||
|
); |
||||
|
const ios = DarwinNotificationDetails( |
||||
|
presentAlert: true, |
||||
|
presentBadge: true, |
||||
|
presentSound: true, |
||||
|
); |
||||
|
return const NotificationDetails(android: android, iOS: ios); |
||||
|
} |
||||
|
|
||||
|
/// 只撤我们自己排的那些 id。 |
||||
|
/// |
||||
|
/// ⚠️ **绝不能用 cancelAll()**:它会把录音的前台通知一起干掉。 |
||||
|
Future<void> _cancelAllOurs() async { |
||||
|
if (!_pluginReady) return; |
||||
|
for (final id in _scheduledIds) { |
||||
|
try { |
||||
|
await _plugin.cancel(id); |
||||
|
} catch (_) {} |
||||
|
} |
||||
|
// 进程重启后 _scheduledIds 是空的,但系统里可能还留着上次的排期, |
||||
|
// 所以把整个区间都撤一遍(cancel 不存在的 id 是安全的空操作)。 |
||||
|
for (var i = 0; i < _maxScheduled; i++) { |
||||
|
try { |
||||
|
await _plugin.cancel(_idBase + i); |
||||
|
} catch (_) {} |
||||
|
} |
||||
|
_scheduledIds.clear(); |
||||
|
} |
||||
|
|
||||
|
/// 登出时清掉所有提醒——换账号不该继续响上一个账号的日程 |
||||
|
Future<void> clear() async { |
||||
|
await _ensurePlugin(); |
||||
|
await _cancelAllOurs(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,428 @@ |
|||||
|
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 {}; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,271 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
import '../../../../core/utils/logger.dart'; |
||||
|
import '../../../../data/models/memory_item.dart'; |
||||
|
import '../../../../data/services/memory_service.dart'; |
||||
|
import '../../../../data/services/tts_service.dart'; |
||||
|
|
||||
|
/// 每日首次进入的「今天的安排」弹窗,带语音播报。 |
||||
|
/// |
||||
|
/// ## ⚠️ 绝不能 await 在启动路径上 |
||||
|
/// 冷启动已经串了四次网络往返(网络检查 → 版本检查 → getLoginToken → |
||||
|
/// getAppConfig),仓库刚做过一次相反方向的优化(把会议模板拉取改成 unawaited, |
||||
|
/// 理由是「启动时没有任何地方要用它」)。所以这里的用法固定是: |
||||
|
/// splash 照常放行导航 → 进主页后异步拉 → 拿到再弹。弹窗晚一两秒出现是可接受的。 |
||||
|
/// |
||||
|
/// ## 弹窗顺序 |
||||
|
/// 启动路径上已经有两个弹窗,本弹窗必须排最后: |
||||
|
/// 1. 华为/荣耀「每次启动重新确认隐私政策」 |
||||
|
/// 2. 强制更新弹窗(不可关闭,会把 splash 卡住) |
||||
|
/// 3. 本弹窗 —— 且必须在登录态确认之后(未登录时拿不到数据) |
||||
|
class MemoryDailySheet { |
||||
|
static const String _tag = 'MemoryDaily'; |
||||
|
static const String _kLastDate = 'memory_daily_popup_date'; |
||||
|
static const String _sessionId = 'memory_daily_briefing'; |
||||
|
|
||||
|
static bool _showing = false; |
||||
|
|
||||
|
static String _today() { |
||||
|
final d = DateTime.now(); |
||||
|
return '${d.year}-${d.month.toString().padLeft(2, '0')}-' |
||||
|
'${d.day.toString().padLeft(2, '0')}'; |
||||
|
} |
||||
|
|
||||
|
/// 今天是否已经弹过 |
||||
|
static bool shownToday() { |
||||
|
try { |
||||
|
return GetStorage().read<String>(_kLastDate) == _today(); |
||||
|
} catch (_) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 尝试弹出。已经弹过、没有数据、或正在弹时静默跳过。 |
||||
|
/// |
||||
|
/// 调用方**不要 await**它的网络部分——见类注释。 |
||||
|
static Future<void> maybeShow() async { |
||||
|
if (_showing || shownToday()) return; |
||||
|
_showing = true; |
||||
|
try { |
||||
|
final items = await MemoryService.to.loadToday(); |
||||
|
if (items.isEmpty) { |
||||
|
// 没有安排就不打扰。仍然记下「今天弹过」,避免每次切前台都去打一次接口。 |
||||
|
GetStorage().write(_kLastDate, _today()); |
||||
|
return; |
||||
|
} |
||||
|
GetStorage().write(_kLastDate, _today()); |
||||
|
await _show(items); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '每日弹窗失败(不影响主流程): $e'); |
||||
|
} finally { |
||||
|
_showing = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
static Future<void> _show(List<MemoryItem> items) async { |
||||
|
await Get.bottomSheet( |
||||
|
_DailySheetView(items: items), |
||||
|
isScrollControlled: true, |
||||
|
isDismissible: true, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 拼播报文案。**本地拼,不调 LLM**——这段话的结构是固定的, |
||||
|
/// 花一次模型调用换一句同样的话不值得,而且启动阶段等不起。 |
||||
|
static String buildSpeech(List<MemoryItem> items) { |
||||
|
if (items.isEmpty) return 'memDailyEmpty'.tr; |
||||
|
final buf = StringBuffer(); |
||||
|
buf.write('memDailyCount'.trParams({'n': '${items.length}'})); |
||||
|
for (final it in items) { |
||||
|
buf.write(' '); |
||||
|
if (it.happenTime.isNotEmpty) { |
||||
|
buf.write('${it.happenTime},'); |
||||
|
} |
||||
|
buf.write(it.title); |
||||
|
if (it.owner.isNotEmpty) { |
||||
|
buf.write(',${it.owner}'); |
||||
|
} |
||||
|
buf.write('。'); |
||||
|
} |
||||
|
return buf.toString(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class _DailySheetView extends StatefulWidget { |
||||
|
final List<MemoryItem> items; |
||||
|
const _DailySheetView({required this.items}); |
||||
|
|
||||
|
@override |
||||
|
State<_DailySheetView> createState() => _DailySheetViewState(); |
||||
|
} |
||||
|
|
||||
|
class _DailySheetViewState extends State<_DailySheetView> { |
||||
|
bool _speaking = false; |
||||
|
TtsService? _tts; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
// 打开即播报(需求:每日首次登录自动播报) |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _speak()); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
// ⚠️ 关窗必须真的停下来。speakStream 是队列式播放,只调 stop 不清队列会出现 |
||||
|
// 「关了还在念下一句」。TtsService.stop() 内部已负责清队列。 |
||||
|
_stop(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _speak() async { |
||||
|
// ⚠️ 通话翻译进行中一律不播报。两者共用同一个 AVAudioSession, |
||||
|
// 在对方刚调好的会话上再设一次会把通话音频路由弄乱(iOS 那条 phone_call |
||||
|
// 分支被改坏过三次)。宁可这次不播,也不能干扰正在进行的通话。 |
||||
|
if (_isTranslationBusy()) { |
||||
|
Logger.i(MemoryDailySheet._tag, '通话翻译进行中,跳过播报'); |
||||
|
return; |
||||
|
} |
||||
|
if (!Get.isRegistered<TtsService>()) return; |
||||
|
try { |
||||
|
_tts = Get.find<TtsService>(); |
||||
|
setState(() => _speaking = true); |
||||
|
await _tts!.startspeak(MemoryDailySheet._sessionId); |
||||
|
await _tts!.speakOnce( |
||||
|
MemoryDailySheet._sessionId, MemoryDailySheet.buildSpeech(widget.items)); |
||||
|
} catch (e) { |
||||
|
Logger.w(MemoryDailySheet._tag, '播报失败: $e'); |
||||
|
} finally { |
||||
|
if (mounted) setState(() => _speaking = false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _stop() async { |
||||
|
try { |
||||
|
await _tts?.stop(); |
||||
|
} catch (_) {} |
||||
|
if (mounted) setState(() => _speaking = false); |
||||
|
} |
||||
|
|
||||
|
/// 通话翻译是否正在进行。 |
||||
|
/// |
||||
|
/// 用 Get.isRegistered + 反射式判断而不是直接 import TranslationController: |
||||
|
/// 那会把整个翻译模块拖进启动路径的依赖图里。拿不到就当没在通话。 |
||||
|
bool _isTranslationBusy() { |
||||
|
try { |
||||
|
return Get.isRegistered(tag: 'translation_active'); |
||||
|
} catch (_) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? const Color(0xFF1C1C1E) : Colors.white, |
||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), |
||||
|
), |
||||
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Center( |
||||
|
child: Container( |
||||
|
width: 36.w, |
||||
|
height: 4.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? Colors.white24 : Colors.black12, |
||||
|
borderRadius: BorderRadius.circular(2.r), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text('memDailyTitle'.tr, |
||||
|
style: |
||||
|
TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700)), |
||||
|
), |
||||
|
TextButton.icon( |
||||
|
onPressed: _speaking ? _stop : _speak, |
||||
|
icon: Icon(_speaking ? Icons.stop : Icons.volume_up, |
||||
|
size: 18.sp), |
||||
|
label: Text( |
||||
|
(_speaking ? 'memReportStop' : 'memDailyPlay').tr, |
||||
|
style: TextStyle(fontSize: 13.sp)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
Flexible( |
||||
|
child: ListView.separated( |
||||
|
shrinkWrap: true, |
||||
|
itemCount: widget.items.length, |
||||
|
separatorBuilder: (_, __) => Divider(height: 16.h), |
||||
|
itemBuilder: (_, i) { |
||||
|
final it = widget.items[i]; |
||||
|
return Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 4.w, |
||||
|
height: 32.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: MemoryCategory.colorOf(it.category), |
||||
|
borderRadius: BorderRadius.circular(2.r), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 10.w), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text(it.title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w600)), |
||||
|
if (it.happenTime.isNotEmpty || |
||||
|
it.owner.isNotEmpty) ...[ |
||||
|
SizedBox(height: 2.h), |
||||
|
Text( |
||||
|
[it.happenTime, it.owner] |
||||
|
.where((e) => e.isNotEmpty) |
||||
|
.join(' · '), |
||||
|
style: TextStyle( |
||||
|
fontSize: 11.sp, |
||||
|
color: isDark |
||||
|
? Colors.white54 |
||||
|
: Colors.black45), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: () { |
||||
|
_stop(); |
||||
|
Get.back(); |
||||
|
}, |
||||
|
child: Text('memDailyClose'.tr), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,495 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../../../../data/models/memory_item.dart'; |
||||
|
import '../../../../data/services/memory_service.dart'; |
||||
|
|
||||
|
/// 新增/编辑一条记忆项的底部弹层。 |
||||
|
/// |
||||
|
/// 这是拾忆页此前**完全缺失**的一块:页面原来只有展示,没有新增按钮、卡片没有 |
||||
|
/// onTap、没有下拉刷新,服务端的 add/update/del/complete 四个写接口在客户端 |
||||
|
/// 一个调用点都没有,`user_edited` 那套「保护用户修改」的机制也就无从谈起。 |
||||
|
/// |
||||
|
/// 按分类分流表单:只显示这个分类真正用得上的字段,不做一个塞满 12 个输入框的 |
||||
|
/// 通用表单(统一表存储 ≠ 统一表单)。 |
||||
|
class MemoryEditSheet extends StatefulWidget { |
||||
|
/// null = 新增 |
||||
|
final MemoryItem? item; |
||||
|
|
||||
|
/// 新增时的默认日期与分类 |
||||
|
final DateTime? defaultDate; |
||||
|
final String defaultCategory; |
||||
|
|
||||
|
const MemoryEditSheet({ |
||||
|
super.key, |
||||
|
this.item, |
||||
|
this.defaultDate, |
||||
|
this.defaultCategory = MemoryCategory.todo, |
||||
|
}); |
||||
|
|
||||
|
/// 打开弹层。返回 true 表示有改动(调用方据此刷新)。 |
||||
|
static Future<bool> show({ |
||||
|
MemoryItem? item, |
||||
|
DateTime? defaultDate, |
||||
|
String defaultCategory = MemoryCategory.todo, |
||||
|
}) async { |
||||
|
final r = await Get.bottomSheet<bool>( |
||||
|
MemoryEditSheet( |
||||
|
item: item, |
||||
|
defaultDate: defaultDate, |
||||
|
defaultCategory: defaultCategory, |
||||
|
), |
||||
|
isScrollControlled: true, |
||||
|
ignoreSafeArea: false, |
||||
|
); |
||||
|
return r ?? false; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
State<MemoryEditSheet> createState() => _MemoryEditSheetState(); |
||||
|
} |
||||
|
|
||||
|
class _MemoryEditSheetState extends State<MemoryEditSheet> { |
||||
|
late final TextEditingController _title; |
||||
|
late final TextEditingController _detail; |
||||
|
late final TextEditingController _amount; |
||||
|
late final TextEditingController _owner; |
||||
|
|
||||
|
late String _category; |
||||
|
late DateTime _date; |
||||
|
TimeOfDay? _time; |
||||
|
late String _repeat; |
||||
|
late int _remindAhead; |
||||
|
|
||||
|
bool _saving = false; |
||||
|
|
||||
|
bool get _isEdit => widget.item != null; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
final it = widget.item; |
||||
|
_title = TextEditingController(text: it?.title ?? ''); |
||||
|
_detail = TextEditingController(text: it?.detail ?? ''); |
||||
|
// 金额展示用「元」,存储用「分」。这里做一次换算,保存时换回去。 |
||||
|
_amount = TextEditingController( |
||||
|
text: (it != null && it.amountCents > 0) |
||||
|
? (it.amountCents / 100).toStringAsFixed(2) |
||||
|
: '', |
||||
|
); |
||||
|
_owner = TextEditingController(text: it?.owner ?? ''); |
||||
|
|
||||
|
_category = it?.category ?? widget.defaultCategory; |
||||
|
// 未知分类(服务端加了新类而本包不认识)落到 todo,避免下拉选不中任何一项 |
||||
|
if (!MemoryCategory.isKnown(_category)) { |
||||
|
_category = MemoryCategory.todo; |
||||
|
} |
||||
|
_date = it?.happenDateTime ?? widget.defaultDate ?? DateTime.now(); |
||||
|
_time = it?.startTime; |
||||
|
_repeat = it?.repeatRule.isNotEmpty == true ? it!.repeatRule : MemoryRepeat.once; |
||||
|
if (!MemoryRepeat.all.contains(_repeat)) _repeat = MemoryRepeat.once; |
||||
|
_remindAhead = it?.remindAhead ?? 5; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_title.dispose(); |
||||
|
_detail.dispose(); |
||||
|
_amount.dispose(); |
||||
|
_owner.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
bool get _showTime => |
||||
|
_category == MemoryCategory.alarm || _category == MemoryCategory.todo; |
||||
|
bool get _showRepeat => _category == MemoryCategory.alarm; |
||||
|
bool get _showRemind => |
||||
|
_category == MemoryCategory.alarm || _category == MemoryCategory.todo; |
||||
|
bool get _showAmount => _category == MemoryCategory.expense; |
||||
|
bool get _showOwner => _category == MemoryCategory.todo; |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
final bg = isDark ? const Color(0xFF1C1C1E) : Colors.white; |
||||
|
return Padding( |
||||
|
// 键盘弹起时把内容顶上去,否则输入框会被盖住 |
||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), |
||||
|
child: Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: bg, |
||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), |
||||
|
), |
||||
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), |
||||
|
child: SingleChildScrollView( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Center( |
||||
|
child: Container( |
||||
|
width: 36.w, |
||||
|
height: 4.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? Colors.white24 : Colors.black12, |
||||
|
borderRadius: BorderRadius.circular(2.r), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
Text( |
||||
|
(_isEdit ? 'memEditTitle' : 'memAddTitle').tr, |
||||
|
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
_categoryPicker(isDark), |
||||
|
SizedBox(height: 16.h), |
||||
|
_field(_title, 'memFieldTitle'.tr, isDark, autofocus: !_isEdit), |
||||
|
SizedBox(height: 12.h), |
||||
|
_dateTimeRow(isDark), |
||||
|
if (_showAmount) ...[ |
||||
|
SizedBox(height: 12.h), |
||||
|
_field( |
||||
|
_amount, |
||||
|
'memFieldAmount'.tr, |
||||
|
isDark, |
||||
|
keyboardType: |
||||
|
const TextInputType.numberWithOptions(decimal: true), |
||||
|
inputFormatters: [ |
||||
|
// 只允许数字与至多两位小数:金额存的是「分」, |
||||
|
// 多余位数换算时会被静默截断,不如在输入处就挡住 |
||||
|
FilteringTextInputFormatter.allow( |
||||
|
RegExp(r'^\d*\.?\d{0,2}')), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
if (_showOwner) ...[ |
||||
|
SizedBox(height: 12.h), |
||||
|
_field(_owner, 'memFieldOwner'.tr, isDark), |
||||
|
], |
||||
|
if (_showRepeat) ...[ |
||||
|
SizedBox(height: 12.h), |
||||
|
_repeatPicker(isDark), |
||||
|
], |
||||
|
if (_showRemind) ...[ |
||||
|
SizedBox(height: 12.h), |
||||
|
_remindPicker(isDark), |
||||
|
], |
||||
|
SizedBox(height: 12.h), |
||||
|
_field(_detail, 'memFieldDetail'.tr, isDark, maxLines: 3), |
||||
|
SizedBox(height: 20.h), |
||||
|
_actions(isDark), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _categoryPicker(bool isDark) { |
||||
|
return Wrap( |
||||
|
spacing: 8.w, |
||||
|
children: MemoryCategory.known.map((c) { |
||||
|
final active = c == _category; |
||||
|
final color = MemoryCategory.colorOf(c); |
||||
|
return GestureDetector( |
||||
|
onTap: () => setState(() => _category = c), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 7.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: active ? color.withValues(alpha: 0.15) : Colors.transparent, |
||||
|
border: Border.all( |
||||
|
color: active ? color : (isDark ? Colors.white24 : Colors.black12), |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
MemoryCategory.labelKey(c).tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13.sp, |
||||
|
color: active ? color : (isDark ? Colors.white70 : Colors.black54), |
||||
|
fontWeight: active ? FontWeight.w700 : FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}).toList(), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _field( |
||||
|
TextEditingController c, |
||||
|
String label, |
||||
|
bool isDark, { |
||||
|
int maxLines = 1, |
||||
|
bool autofocus = false, |
||||
|
TextInputType? keyboardType, |
||||
|
List<TextInputFormatter>? inputFormatters, |
||||
|
}) { |
||||
|
return TextField( |
||||
|
controller: c, |
||||
|
maxLines: maxLines, |
||||
|
autofocus: autofocus, |
||||
|
keyboardType: keyboardType, |
||||
|
inputFormatters: inputFormatters, |
||||
|
style: TextStyle(fontSize: 14.sp), |
||||
|
decoration: InputDecoration( |
||||
|
labelText: label, |
||||
|
isDense: true, |
||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10.r)), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _dateTimeRow(bool isDark) { |
||||
|
return Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _pickerTile( |
||||
|
'memFieldDate'.tr, |
||||
|
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', |
||||
|
isDark, |
||||
|
onTap: () async { |
||||
|
final d = await showDatePicker( |
||||
|
context: context, |
||||
|
initialDate: _date, |
||||
|
firstDate: DateTime(2020), |
||||
|
lastDate: DateTime(2100), |
||||
|
); |
||||
|
if (d != null) setState(() => _date = d); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
if (_showTime) ...[ |
||||
|
SizedBox(width: 10.w), |
||||
|
Expanded( |
||||
|
child: _pickerTile( |
||||
|
'memFieldTime'.tr, |
||||
|
_time == null |
||||
|
? '--:--' |
||||
|
: '${_time!.hour.toString().padLeft(2, '0')}:${_time!.minute.toString().padLeft(2, '0')}', |
||||
|
isDark, |
||||
|
onTap: () async { |
||||
|
final t = await showTimePicker( |
||||
|
context: context, |
||||
|
initialTime: _time ?? const TimeOfDay(hour: 9, minute: 0), |
||||
|
); |
||||
|
if (t != null) setState(() => _time = t); |
||||
|
}, |
||||
|
// 时间可以清空:待办常常只有日期没有具体时刻 |
||||
|
onClear: _time == null ? null : () => setState(() => _time = null), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _pickerTile(String label, String value, bool isDark, |
||||
|
{required VoidCallback onTap, VoidCallback? onClear}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), |
||||
|
decoration: BoxDecoration( |
||||
|
border: |
||||
|
Border.all(color: isDark ? Colors.white24 : Colors.black26), |
||||
|
borderRadius: BorderRadius.circular(10.r), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text(label, |
||||
|
style: TextStyle( |
||||
|
fontSize: 11.sp, |
||||
|
color: isDark ? Colors.white54 : Colors.black45)), |
||||
|
SizedBox(height: 2.h), |
||||
|
Text(value, style: TextStyle(fontSize: 14.sp)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (onClear != null) |
||||
|
GestureDetector( |
||||
|
onTap: onClear, |
||||
|
child: Icon(Icons.close, |
||||
|
size: 16.sp, |
||||
|
color: isDark ? Colors.white38 : Colors.black26), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _repeatPicker(bool isDark) { |
||||
|
return DropdownButtonFormField<String>( |
||||
|
initialValue: _repeat, |
||||
|
isDense: true, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'memFieldRepeat'.tr, |
||||
|
isDense: true, |
||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10.r)), |
||||
|
), |
||||
|
items: MemoryRepeat.all |
||||
|
.map((r) => DropdownMenuItem( |
||||
|
value: r, |
||||
|
child: Text(MemoryRepeat.labelKey(r).tr, |
||||
|
style: TextStyle(fontSize: 14.sp)))) |
||||
|
.toList(), |
||||
|
onChanged: (v) => setState(() => _repeat = v ?? MemoryRepeat.once), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _remindPicker(bool isDark) { |
||||
|
const options = [0, 5, 10, 15, 30, 60]; |
||||
|
return DropdownButtonFormField<int>( |
||||
|
initialValue: options.contains(_remindAhead) ? _remindAhead : 5, |
||||
|
isDense: true, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'memFieldRemind'.tr, |
||||
|
isDense: true, |
||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10.r)), |
||||
|
), |
||||
|
items: options |
||||
|
.map((m) => DropdownMenuItem( |
||||
|
value: m, |
||||
|
child: Text( |
||||
|
m == 0 |
||||
|
? 'memRemindOff'.tr |
||||
|
: 'memRemindMinutes'.trParams({'n': '$m'}), |
||||
|
style: TextStyle(fontSize: 14.sp), |
||||
|
), |
||||
|
)) |
||||
|
.toList(), |
||||
|
onChanged: (v) => setState(() => _remindAhead = v ?? 5), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _actions(bool isDark) { |
||||
|
return Row( |
||||
|
children: [ |
||||
|
if (_isEdit) |
||||
|
TextButton.icon( |
||||
|
onPressed: _saving ? null : _delete, |
||||
|
icon: Icon(Icons.delete_outline, size: 18.sp, color: Colors.red), |
||||
|
label: Text('memDelete'.tr, |
||||
|
style: TextStyle(color: Colors.red, fontSize: 14.sp)), |
||||
|
), |
||||
|
const Spacer(), |
||||
|
TextButton( |
||||
|
onPressed: _saving ? null : () => Get.back(result: false), |
||||
|
child: Text('cancel'.tr, style: TextStyle(fontSize: 14.sp)), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
ElevatedButton( |
||||
|
onPressed: _saving ? null : _save, |
||||
|
child: _saving |
||||
|
? SizedBox( |
||||
|
width: 16.w, |
||||
|
height: 16.w, |
||||
|
child: const CircularProgressIndicator(strokeWidth: 2)) |
||||
|
: Text('confirm'.tr, style: TextStyle(fontSize: 14.sp)), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Future<void> _save() async { |
||||
|
final title = _title.text.trim(); |
||||
|
if (title.isEmpty) { |
||||
|
Get.snackbar('error'.tr, 'memTitleRequired'.tr, |
||||
|
snackPosition: SnackPosition.TOP); |
||||
|
return; |
||||
|
} |
||||
|
// 金额:输入的是「元」,存的是「分」。用 round 而不是 toInt—— |
||||
|
// 12.30 在浮点里可能是 12.299999,直接截断会变成 1229 分。 |
||||
|
var cents = 0; |
||||
|
if (_showAmount && _amount.text.trim().isNotEmpty) { |
||||
|
final yuan = double.tryParse(_amount.text.trim()); |
||||
|
if (yuan == null || yuan < 0) { |
||||
|
Get.snackbar('error'.tr, 'memAmountInvalid'.tr, |
||||
|
snackPosition: SnackPosition.TOP); |
||||
|
return; |
||||
|
} |
||||
|
cents = (yuan * 100).round(); |
||||
|
} |
||||
|
|
||||
|
setState(() => _saving = true); |
||||
|
final base = widget.item?.copy() ?? MemoryItem(); |
||||
|
base.title = title; |
||||
|
base.detail = _detail.text.trim(); |
||||
|
base.happenDate = |
||||
|
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'; |
||||
|
base.happenTime = (_showTime && _time != null) |
||||
|
? '${_time!.hour.toString().padLeft(2, '0')}:${_time!.minute.toString().padLeft(2, '0')}' |
||||
|
: ''; |
||||
|
// 用户手填的日期一定是确定的——把服务端推断出来的 date_certain=false 顶掉, |
||||
|
// 这样它就不会再留在「待定日期」分组里 |
||||
|
base.dateCertain = true; |
||||
|
base.amountCents = _showAmount ? cents : 0; |
||||
|
base.owner = _showOwner ? _owner.text.trim() : ''; |
||||
|
base.repeatRule = _showRepeat ? _repeat : MemoryRepeat.once; |
||||
|
base.remindAhead = _showRemind ? _remindAhead : 0; |
||||
|
if (base.repeatRule == MemoryRepeat.weekly) { |
||||
|
base.weekday = _date.weekday; // Dart 的 1-7 与服务端同口径 |
||||
|
} |
||||
|
|
||||
|
// category / source 在 MemoryItem 上是 final(它们是身份而不是可随手改的属性), |
||||
|
// 所以统一经 fromJson 重建一份带新分类的对象,而不是就地赋值。 |
||||
|
// 编辑时也允许改分类——服务端会重新校验白名单。 |
||||
|
final payload = MemoryItem.fromJson({ |
||||
|
...base.toJson(), |
||||
|
'id': _isEdit ? base.id : 0, |
||||
|
'category': _category, |
||||
|
'source': _isEdit ? base.source : MemorySource.manual, |
||||
|
}); |
||||
|
|
||||
|
final svc = MemoryService.to; |
||||
|
final ok = _isEdit |
||||
|
? await svc.update(payload) != null |
||||
|
: await svc.add(payload) != null; |
||||
|
|
||||
|
if (!mounted) return; |
||||
|
setState(() => _saving = false); |
||||
|
if (ok) { |
||||
|
Get.back(result: true); |
||||
|
} else { |
||||
|
Get.snackbar('error'.tr, 'memSaveFailed'.tr, |
||||
|
snackPosition: SnackPosition.TOP); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _delete() async { |
||||
|
final confirmed = await Get.dialog<bool>( |
||||
|
AlertDialog( |
||||
|
content: Text('memDeleteConfirm'.tr), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(result: false), child: Text('cancel'.tr)), |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(result: true), |
||||
|
child: Text('memDelete'.tr, |
||||
|
style: const TextStyle(color: Colors.red)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
if (confirmed != true) return; |
||||
|
setState(() => _saving = true); |
||||
|
final ok = await MemoryService.to.remove([widget.item!.id]); |
||||
|
if (!mounted) return; |
||||
|
setState(() => _saving = false); |
||||
|
if (ok) { |
||||
|
Get.back(result: true); |
||||
|
} else { |
||||
|
Get.snackbar('error'.tr, 'memDeleteFailed'.tr, |
||||
|
snackPosition: SnackPosition.TOP); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue