feat: make core pages local-first offline
This commit is contained in:
+10
@@ -33,6 +33,8 @@ frontend/android/app/release/
|
|||||||
release/
|
release/
|
||||||
backups/
|
backups/
|
||||||
filing/
|
filing/
|
||||||
|
.backup-before-update-*/
|
||||||
|
.deploy/
|
||||||
*.apk
|
*.apk
|
||||||
*.sql
|
*.sql
|
||||||
*.pem
|
*.pem
|
||||||
@@ -48,6 +50,11 @@ frontend/android/app/libs/push/*.aar
|
|||||||
# Local tool metadata
|
# Local tool metadata
|
||||||
.agents/
|
.agents/
|
||||||
.codex/
|
.codex/
|
||||||
|
.codex-build/
|
||||||
|
.codex-*-a/
|
||||||
|
.codex-*-b/
|
||||||
|
.codex-*.patch
|
||||||
|
backend/.codex-tools/
|
||||||
.git-local/
|
.git-local/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -55,3 +62,6 @@ frontend/android/app/libs/push/*.aar
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Generated Admin Web static assets
|
||||||
|
backend/MiaoJiZhang.Api/wwwroot/assets/*
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# 喵记账 · AI 记账 APP 设计文档
|
# 喵记账 · AI 记账 APP 设计文档
|
||||||
|
|
||||||
> 状态:UI/UX 设计阶段 · 更新于 2026-07-18
|
> 状态:设计规范已落地,持续跟随实现迭代 · 更新于 2026-08-21
|
||||||
|
|
||||||
|
实现进度、已完成模块和当前风险见 [`STATUS.md`](STATUS.md)。本文保留产品决策、视觉规范和后台边界。
|
||||||
|
|
||||||
## 1. 产品概述
|
## 1. 产品概述
|
||||||
|
|
||||||
|
|||||||
+12
-13
@@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
### 后端版本号
|
### 后端版本号
|
||||||
- 文件:`backend/MiaoJiZhang.Api/Program.cs`
|
- 文件:`backend/MiaoJiZhang.Api/Program.cs`
|
||||||
- 变量:`apiVersion = "20260718-1600"`(修改此行即可)
|
- 配置:`Build:Version`(未注入时默认为 `dev`)
|
||||||
- 显示位置:
|
- 显示位置:
|
||||||
- `GET /api/ping` 返回 JSON `{ "version": "...", "built": "..." }`
|
- `GET /api/ping` 返回 JSON `{ "version": "...", "built": "..." }`
|
||||||
- `GET /api/version` 同上
|
- `GET /api/version` 同上
|
||||||
- 更新方法:修改 `apiVersion` 字符串,重启后端
|
- 更新方法:通过环境变量或部署配置注入 `Build__Version`,重启后端
|
||||||
|
|
||||||
### Flutter App 版本号
|
### Flutter App 版本号
|
||||||
- 文件:`frontend/lib/shared/version.dart`
|
- 文件:`frontend/lib/shared/version.dart`
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
- 登录页底部
|
- 登录页底部
|
||||||
- 「我的」页底部
|
- 「我的」页底部
|
||||||
- 构建命令:
|
- 构建命令:
|
||||||
```bash
|
```powershell
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
|
flutter build apk --release --dart-define=APP_VERSION=20260821-135
|
||||||
```
|
```
|
||||||
⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`**
|
⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`**
|
||||||
|
|
||||||
@@ -33,8 +33,7 @@
|
|||||||
- 更新方法:重新构建并完整替换后端静态资源目录。
|
- 更新方法:重新构建并完整替换后端静态资源目录。
|
||||||
```bash
|
```bash
|
||||||
cd admin-web && npm run build
|
cd admin-web && npm run build
|
||||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
|
||||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -58,11 +57,10 @@ dotnet run --project MiaoJiZhang.Api
|
|||||||
临时设置 `Admin__CookieSecure=false`。
|
临时设置 `Admin__CookieSecure=false`。
|
||||||
|
|
||||||
### 2. Admin Web
|
### 2. Admin Web
|
||||||
```bash
|
```powershell
|
||||||
cd admin-web
|
cd admin-web
|
||||||
npm run build
|
npm run build
|
||||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
|
||||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
|
||||||
```
|
```
|
||||||
浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。
|
浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
|||||||
cd frontend
|
cd frontend
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
|
flutter build apk --release --dart-define=APP_VERSION=20260821-135
|
||||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -111,10 +109,10 @@ jizhang/
|
|||||||
│ └── features/ # 业务页面
|
│ └── features/ # 业务页面
|
||||||
├── admin-web/ # Vue3 + Ant Design 后台
|
├── admin-web/ # Vue3 + Ant Design 后台
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── App.vue # ★ 侧边栏版本号
|
│ ├── App.vue # Admin 壳与导航
|
||||||
│ └── views/ # 管理页面
|
│ └── views/ # 管理页面
|
||||||
├── docs/ # 项目说明、设计规范、开发说明
|
├── design/ # UI 稿、UX 原型、配色探索
|
||||||
└── design/ # UI 稿、UX 原型、配色探索
|
└── docs/ # 项目状态、设计、开发与客户端对接文档
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -136,5 +134,6 @@ jizhang/
|
|||||||
| 修改了代码但 App 不变 | 没有 clean 构建,或没加 dart-define | `flutter clean && flutter build apk --release --dart-define=APP_VERSION=...` |
|
| 修改了代码但 App 不变 | 没有 clean 构建,或没加 dart-define | `flutter clean && flutter build apk --release --dart-define=APP_VERSION=...` |
|
||||||
| 修改了代码但后台不变 | 后端还在跑旧进程 | 先 `Stop-Process -Name dotnet -Force` 再 `dotnet run` |
|
| 修改了代码但后台不变 | 后端还在跑旧进程 | 先 `Stop-Process -Name dotnet -Force` 再 `dotnet run` |
|
||||||
| Admin Web 界面不变 | 浏览器缓存 | Ctrl+Shift+R 强制刷新,或用无痕模式打开 |
|
| Admin Web 界面不变 | 浏览器缓存 | Ctrl+Shift+R 强制刷新,或用无痕模式打开 |
|
||||||
|
| 数据库结构不一致 | 未执行最新 EF Migration | `dotnet ef database update` 后再启动服务 |
|
||||||
| Windows 文件路径错误 | 中文路径编码 | 用 python3 读文件时加 `encoding='utf-8'` |
|
| Windows 文件路径错误 | 中文路径编码 | 用 python3 读文件时加 `encoding='utf-8'` |
|
||||||
| sed 破坏代码 | git-bash 的 sed 不兼容 | 禁止用 sed 改 dart/vue 源码,用 Write/Edit 工具 |
|
| sed 破坏代码 | git-bash 的 sed 不兼容 | 禁止用 sed 改 dart/vue 源码,用 Write/Edit 工具 |
|
||||||
|
|||||||
+13
-8
@@ -1,6 +1,8 @@
|
|||||||
# 喵记账 · 项目骨架
|
# 喵记账 · 项目骨架
|
||||||
|
|
||||||
> Flutter 客户端 + .NET 9 后端 · 搭建于 2026-07-18
|
> Flutter 客户端 + .NET 9 后端 · 当前状态更新于 2026-08-21
|
||||||
|
|
||||||
|
当前实现状态与待办以 [`STATUS.md`](STATUS.md) 为准;本文保留项目入口、运行方式和接口索引。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ jizhang/
|
|||||||
2. 改 `backend/MiaoJiZhang.Api/appsettings.json` 里的连接串(`your_password`)
|
2. 改 `backend/MiaoJiZhang.Api/appsettings.json` 里的连接串(`your_password`)
|
||||||
3. 改 `Jwt:Secret` 为 ≥32 字符的随机串
|
3. 改 `Jwt:Secret` 为 ≥32 字符的随机串
|
||||||
4. `cd backend && dotnet run --project MiaoJiZhang.Api`
|
4. `cd backend && dotnet run --project MiaoJiZhang.Api`
|
||||||
- 开发期 `Program.cs` 会 `EnsureCreated` 建表 + 跑种子数据
|
- 数据库结构通过 `MiaoJiZhang.Infrastructure/Persistence/Migrations` 维护;本地首次启动前执行 `dotnet ef database update`
|
||||||
- OpenAPI 文档:`http://localhost:5000/openapi/v1.json`
|
- OpenAPI 文档:`http://localhost:5000/openapi/v1.json`
|
||||||
5. 测试注册:
|
5. 测试注册:
|
||||||
```bash
|
```bash
|
||||||
@@ -93,15 +95,18 @@ flutter run
|
|||||||
9. ~~性格设置页~~ ✅(PUT /api/users/me/companion + CompanionPage,形象/性格/三滑杆)
|
9. ~~性格设置页~~ ✅(PUT /api/users/me/companion + CompanionPage,形象/性格/三滑杆)
|
||||||
10. ~~分类管理~~ ✅(POST/DELETE /api/categories + CategoryManagePage,自定义分类增删)
|
10. ~~分类管理~~ ✅(POST/DELETE /api/categories + CategoryManagePage,自定义分类增删)
|
||||||
11. ~~语音/OCR 流程~~ ✅(POST /api/parse + ParseSheet 确认入账;ASR/OCR SDK 接入后自动填充文本)
|
11. ~~语音/OCR 流程~~ ✅(POST /api/parse + ParseSheet 确认入账;ASR/OCR SDK 接入后自动填充文本)
|
||||||
12. ~~LLM 接入层~~ ✅(ILlmClient 抽象 + NullLlmClient 回退规则版;对接真实 LLM 时实现该接口即可)
|
12. ~~LLM 接入层~~ ✅(已有客户端抽象、权限/额度和回退路径;仍需真实环境联调)
|
||||||
|
|
||||||
|
13. ~~Admin Web 后台~~ ✅(配置、分类、AI 形象/性格、表情包、用户、推送、审计与管理员账号页面已存在)
|
||||||
|
14. ~~EF Core Migration~~ ✅(已有多次迁移;发布前仍需执行全新库和升级库验证)
|
||||||
|
|
||||||
## 遗留(需要外部资源/发布前处理)
|
## 遗留(需要外部资源/发布前处理)
|
||||||
|
|
||||||
- ASR(语音转文字)与拍照 OCR 的 SDK 选型接入(ParseSheet 已留好入口,接入后把识别文本传入即可)
|
- ASR(语音转文字)与拍照 OCR 的 SDK 选型接入(ParseSheet 已留好入口,接入后把识别文本传入即可)
|
||||||
- 真实 LLM 对接(实现 ILlmClient;性格 Prompt 已在 AiPersona 表可后台调)
|
- 真实 LLM 对接(实现 ILlmClient;性格 Prompt 已在 AiPersona 表可后台调)
|
||||||
- SVG 图标(category_icon.dart / stickerStyle 集中管理,直接替换映射即可)
|
- SVG 图标(category_icon.dart / stickerStyle 集中管理,直接替换映射即可)
|
||||||
- Admin Web 后台(`docs/DESIGN.md` 第 8 节;实体 AppConfig/AiPersona/AiAvatar/Sticker 已建好)
|
- 真实 LLM、OCR/ASR、截图识别和推送供应商的生产配置与联调
|
||||||
- EF Migration 替代 EnsureCreated;Jwt Secret 换生产值;baseUrl 环境化
|
- JWT Secret、管理员引导变量、数据库连接串、API Base URL 和更新下载地址环境化
|
||||||
|
|
||||||
## 已完成的接口清单
|
## 已完成的接口清单
|
||||||
|
|
||||||
@@ -129,6 +134,6 @@ flutter run
|
|||||||
|
|
||||||
## 技术债 / 注意
|
## 技术债 / 注意
|
||||||
|
|
||||||
- 后端用 `EnsureCreated` 跳过 Migration,便于快速起盘;正式环境前要切到 `dotnet ef migrations add Init`
|
- `Build:Version` 未注入时后端显示 `dev`;发布构建必须显式注入版本。
|
||||||
- `appsettings.json` 的 `Jwt:Secret` 是占位值,**生产必须换**
|
- `appsettings.json` 的 JWT、数据库和外部服务配置不能直接用于生产。
|
||||||
- 前端 `main_shell.dart` 的 5 个 branch 暂用 `Placeholder()`,下一步逐个填真实页面
|
- Admin Web 的 `wwwroot/assets` 是构建生成物;本地构建后不要把临时 hashed 文件混入提交。
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 记之 · 当前项目状态
|
||||||
|
|
||||||
|
> 更新时间:2026-08-21
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
项目已经从 UI/接口骨架进入「客户端、后台和服务端能力联调」阶段。当前主干包含 Flutter 客户端、.NET 9 API、Vue 3 管理后台、数据库迁移和自动化测试;`main` 与 `origin/main` 当前指向同一提交 `8828851`。
|
||||||
|
|
||||||
|
## 已完成模块
|
||||||
|
|
||||||
|
### 后端 API
|
||||||
|
|
||||||
|
- 用户注册、登录、JWT、用户资料、引导和双模式切换。
|
||||||
|
- 账本、系统/自定义分类、手动记账、月账单、统计、搜索和软删除回收站。
|
||||||
|
- AI 聊天、意图识别、AI 直接入账、来源追溯、预算、月报、表情包和 AI 伙伴设置。
|
||||||
|
- 文本解析入口、截图识别相关接口、识别幂等和 AI 权限/额度控制。
|
||||||
|
- 管理后台 API:品牌配置、系统分类、AI 形象/性格、表情包、用户、推送、管理员账号、审计和密码变更。
|
||||||
|
- 账户注销清理、数据导出/恢复、同步冲突和访客数据合并相关服务。
|
||||||
|
- EF Core/MySQL 迁移已存在,覆盖初始模型、账户注销、分类颜色、AI 权限、额度、推送基础设施和转账/后台安全等变更。
|
||||||
|
|
||||||
|
### Flutter 客户端
|
||||||
|
|
||||||
|
- 启动、登录/注册、引导选模式和 AI 伙伴。
|
||||||
|
- 普通模式首页、账本切换、手动记账、账单详情/编辑、聊天和全 AI 模式。
|
||||||
|
- 统计、AI 月报、预算、搜索、分类管理、伙伴设置、回收站、数据管理和推送设置。
|
||||||
|
- 语音/OCR 确认页、截图识别设置/批量导入、识别诊断、离线提示、同步冲突和本地数据导出。
|
||||||
|
- 客户端更新检查、推送注册和本地数据库/会话恢复。
|
||||||
|
|
||||||
|
当前 `frontend/pubspec.yaml` 版本为 `1.2.5+135`;发布时仍需使用正式构建参数和发布说明。
|
||||||
|
|
||||||
|
### Admin Web
|
||||||
|
|
||||||
|
已具备登录、强制改密、仪表盘、系统设置、品牌配置、系统分类、AI 性格、AI 形象、表情包、用户、推送、管理员账号和审计页面。管理员角色权限由路由和后端共同控制。
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 后端:API 集成测试、推送集成测试、推送供应商测试。
|
||||||
|
- Flutter:模型、主题/时间、本地导出、本地转移数据库、更新流程、视觉契约和 Widget 测试。
|
||||||
|
|
||||||
|
## 当前待办
|
||||||
|
|
||||||
|
### P0:联调与发布阻塞项
|
||||||
|
|
||||||
|
- 在真实环境验证 LLM、OCR/ASR、截图识别和推送供应商配置。
|
||||||
|
- 验证最新 EF Migration 在全新库、升级库和回滚/重试场景下的行为。
|
||||||
|
- 将数据库连接串、JWT Secret、管理员引导变量、API Base URL 和更新下载地址全部改为部署环境注入。
|
||||||
|
- 完成 Android 内部构建的截图权限、后台识别、证书和网络安全回归。
|
||||||
|
|
||||||
|
### P1:质量与体验
|
||||||
|
|
||||||
|
- 对核心流程做端到端回归:注册 → 引导 → 记账 → AI 解析 → 撤销 → 月报/预算。
|
||||||
|
- 完善错误提示、离线待同步、同步冲突和外部服务失败时的降级行为。
|
||||||
|
- 检查 Admin Web 发布资源生成策略,避免把本地 hashed 静态文件和构建目录带入工作区。
|
||||||
|
|
||||||
|
### P2:后续演进
|
||||||
|
|
||||||
|
- 完成正式 SVG/插画资源和更多运营内容配置。
|
||||||
|
- 补充后台数据看板、脱敏意图失败样本和内容版本回滚。
|
||||||
|
- 完成各平台正式签名、应用商店元数据和更新渠道配置。
|
||||||
|
|
||||||
|
## 工作区清理记录
|
||||||
|
|
||||||
|
2026-08-21 已清理未跟踪的完整备份、历史部署、`.codex-tools` 缓存、Codex 临时构建/补丁目录,以及后端 `wwwroot/assets` 的未跟踪生成资源。源码、`docs/`、设计原型、APK、Android 新代码和测试文件未删除。
|
||||||
|
|
||||||
|
## 建议验证顺序
|
||||||
|
|
||||||
|
1. 启动 MySQL,执行 `dotnet ef database update`。
|
||||||
|
2. 配置后端外部服务和管理员引导变量,运行 API 集成测试。
|
||||||
|
3. 构建 Admin Web,验证登录、权限、配置和发布资源。
|
||||||
|
4. 运行 Flutter 测试,再构建 `1.2.5+135` 内部包。
|
||||||
|
5. 用真实设备验证截图识别、推送、离线同步和更新检查。
|
||||||
@@ -183,6 +183,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _resumeServices() async {
|
Future<void> _resumeServices() async {
|
||||||
|
unawaited(ApiClient.instance.probe());
|
||||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
await _runSafely(RecognitionImportService.configureNativeContext);
|
||||||
await _runSafely(RecognitionImportService.importAutomatic);
|
await _runSafely(RecognitionImportService.importAutomatic);
|
||||||
await _runSafely(PushService.instance.refresh);
|
await _runSafely(PushService.instance.refresh);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
@@ -7,6 +9,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
|||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
|
|
||||||
class AddPage extends StatefulWidget {
|
class AddPage extends StatefulWidget {
|
||||||
@@ -36,6 +39,7 @@ class _AddPageState extends State<AddPage> {
|
|||||||
DateTime _occurredAt = ShanghaiTime.now;
|
DateTime _occurredAt = ShanghaiTime.now;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _loadingCategories = true;
|
bool _loadingCategories = true;
|
||||||
|
int _loadRevision = 0;
|
||||||
String get _categoryType => _tab == 'transfer'
|
String get _categoryType => _tab == 'transfer'
|
||||||
? _transferDirection == 'in'
|
? _transferDirection == 'in'
|
||||||
? 'income'
|
? 'income'
|
||||||
@@ -60,7 +64,7 @@ class _AddPageState extends State<AddPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadCategories();
|
unawaited(_loadCategories());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -71,38 +75,60 @@ class _AddPageState extends State<AddPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCategories() async {
|
Future<void> _loadCategories() async {
|
||||||
setState(() => _loadingCategories = true);
|
final revision = ++_loadRevision;
|
||||||
try {
|
final expense = TxApi.categoriesLocal('expense');
|
||||||
final results = await Future.wait([
|
final income = TxApi.categoriesLocal('income');
|
||||||
TxApi.categories('expense'),
|
if (mounted) {
|
||||||
TxApi.categories('income'),
|
|
||||||
]);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_categoriesByType['expense'] = results[0];
|
_applyCategories(expense, income);
|
||||||
_categoriesByType['income'] = results[1];
|
_loadingCategories = false;
|
||||||
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
|
|
||||||
_selectedByType['expense'] = results[0].first;
|
|
||||||
}
|
|
||||||
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
|
|
||||||
_selectedByType['income'] = results[1].first;
|
|
||||||
}
|
|
||||||
_selectedByType['transfer_out'] ??= results[0].isEmpty
|
|
||||||
? null
|
|
||||||
: results[0].first;
|
|
||||||
_selectedByType['transfer_in'] ??= results[1].isEmpty
|
|
||||||
? null
|
|
||||||
: results[1].first;
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loadingCategories = false);
|
|
||||||
}
|
}
|
||||||
|
await Future.wait([
|
||||||
|
TxApi.categoriesRemote('expense').then<void>((values) {
|
||||||
|
if (!mounted || revision != _loadRevision) return;
|
||||||
|
setState(() {
|
||||||
|
_applyCategories(values, _categoriesByType['income'] ?? const []);
|
||||||
|
});
|
||||||
|
}, onError: (_) {}),
|
||||||
|
TxApi.categoriesRemote('income').then<void>((values) {
|
||||||
|
if (!mounted || revision != _loadRevision) return;
|
||||||
|
setState(() {
|
||||||
|
_applyCategories(_categoriesByType['expense'] ?? const [], values);
|
||||||
|
});
|
||||||
|
}, onError: (_) {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyCategories(List<CategoryItem> expense, List<CategoryItem> income) {
|
||||||
|
_categoriesByType['expense'] = expense;
|
||||||
|
_categoriesByType['income'] = income;
|
||||||
|
_selectedByType['expense'] = _preserveSelection(
|
||||||
|
_selectedByType['expense'],
|
||||||
|
expense,
|
||||||
|
);
|
||||||
|
_selectedByType['income'] = _preserveSelection(
|
||||||
|
_selectedByType['income'],
|
||||||
|
income,
|
||||||
|
);
|
||||||
|
_selectedByType['transfer_out'] = _preserveSelection(
|
||||||
|
_selectedByType['transfer_out'],
|
||||||
|
expense,
|
||||||
|
);
|
||||||
|
_selectedByType['transfer_in'] = _preserveSelection(
|
||||||
|
_selectedByType['transfer_in'],
|
||||||
|
income,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryItem? _preserveSelection(
|
||||||
|
CategoryItem? selected,
|
||||||
|
List<CategoryItem> values,
|
||||||
|
) {
|
||||||
|
if (values.isEmpty) return null;
|
||||||
|
if (selected == null) return values.first;
|
||||||
|
return values.where((item) => item.id == selected.id).firstOrNull ??
|
||||||
|
values.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _switchTab(String tab) {
|
void _switchTab(String tab) {
|
||||||
@@ -239,6 +265,7 @@ class _AddPageState extends State<AddPage> {
|
|||||||
),
|
),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
|
actions: [BackendStatusIcon(onRetry: _loadCategories)],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
@@ -10,6 +12,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
|||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
|
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
|
||||||
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
|
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
@@ -28,6 +31,7 @@ class HomePageState extends State<HomePage> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
late DateTime _month;
|
late DateTime _month;
|
||||||
|
int _loadRevision = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -36,7 +40,7 @@ class HomePageState extends State<HomePage> {
|
|||||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||||
TransactionEvents.revision.addListener(_refreshTransactions);
|
TransactionEvents.revision.addListener(_refreshTransactions);
|
||||||
CurrentLedgerStore.instance.addListener(_refreshLedger);
|
CurrentLedgerStore.instance.addListener(_refreshLedger);
|
||||||
refresh();
|
unawaited(refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshCompanion() {
|
void _refreshCompanion() {
|
||||||
@@ -60,33 +64,56 @@ class HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
final revision = ++_loadRevision;
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await CurrentLedgerStore.instance.ensureLoaded();
|
await CurrentLedgerStore.instance.loadCached();
|
||||||
final results = await Future.wait<dynamic>([
|
final localSummary = TxApi.monthLocal(_month.year, _month.month);
|
||||||
TxApi.month(_month.year, _month.month),
|
final localBudgets = BudgetApi.getLocal(_month.year, _month.month);
|
||||||
BudgetApi.get(_month.year, _month.month),
|
if (mounted && revision == _loadRevision) {
|
||||||
]);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_summary = results[0] as MonthSummary;
|
_summary = localSummary;
|
||||||
_budgets = results[1] as BudgetsData;
|
_budgets = localBudgets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
if (mounted && revision == _loadRevision) {
|
||||||
final summary = await TxApi.month(_month.year, _month.month);
|
setState(() {
|
||||||
if (mounted) setState(() => _summary = summary);
|
_error = apiErrorMessage(error);
|
||||||
} catch (fallbackError) {
|
_loading = false;
|
||||||
if (mounted) {
|
});
|
||||||
setState(() => _error = apiErrorMessage(fallbackError));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
return;
|
||||||
if (mounted) setState(() => _loading = false);
|
}
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
await CurrentLedgerStore.instance.refreshRemote();
|
||||||
|
} catch (_) {}
|
||||||
|
}(),
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
final summary = await TxApi.monthRemote(_month.year, _month.month);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _summary = summary);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}(),
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
final budgets = await BudgetApi.getRemote(_month.year, _month.month);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _budgets = budgets);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}(),
|
||||||
|
]);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +202,7 @@ class HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
|
BackendStatusIcon(onRetry: refresh),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: AppIcons.icon(
|
icon: AppIcons.icon(
|
||||||
AppIcons.search,
|
AppIcons.search,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:miaoji_zhang/shared/api/business_api.dart';
|
|||||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
|
|
||||||
class CategoryManagePage extends StatefulWidget {
|
class CategoryManagePage extends StatefulWidget {
|
||||||
@@ -20,6 +21,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
bool _reordering = false;
|
bool _reordering = false;
|
||||||
bool _savingOrder = false;
|
bool _savingOrder = false;
|
||||||
bool _orderDirty = false;
|
bool _orderDirty = false;
|
||||||
|
int _loadRevision = 0;
|
||||||
|
|
||||||
List<CategoryItem> get _custom =>
|
List<CategoryItem> get _custom =>
|
||||||
_cats.where((category) => category.isCustom).toList();
|
_cats.where((category) => category.isCustom).toList();
|
||||||
@@ -31,19 +33,31 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
final revision = ++_loadRevision;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final cats = await TxApi.categories(_type);
|
final type = _type;
|
||||||
if (mounted) {
|
final cached = TxApi.categoriesLocal(type);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cats = cats;
|
_cats = cached;
|
||||||
_orderDirty = false;
|
_orderDirty = false;
|
||||||
|
_loading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
final remote = await TxApi.categoriesRemote(type);
|
||||||
|
if (mounted && revision == _loadRevision && type == _type) {
|
||||||
|
setState(() => _cats = remote);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) _showError(error);
|
if (mounted && revision == _loadRevision && _loading) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_showError(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted && revision == _loadRevision && _loading) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +438,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('分类管理'),
|
title: Text('分类管理'),
|
||||||
actions: [
|
actions: [
|
||||||
|
BackendStatusIcon(onRetry: _load),
|
||||||
if (_custom.length > 1)
|
if (_custom.length > 1)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _savingOrder ? null : _toggleReorder,
|
onPressed: _savingOrder ? null : _toggleReorder,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/config_api.dart';
|
|||||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
|
|
||||||
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
|
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
|
||||||
class CompanionPage extends StatefulWidget {
|
class CompanionPage extends StatefulWidget {
|
||||||
@@ -16,7 +17,9 @@ class CompanionPage extends StatefulWidget {
|
|||||||
class _CompanionPageState extends State<CompanionPage> {
|
class _CompanionPageState extends State<CompanionPage> {
|
||||||
String _avatar = 'cat', _persona = 'sassy_cat';
|
String _avatar = 'cat', _persona = 'sassy_cat';
|
||||||
double _roast = 60, _sticker = 70, _proactive = 40;
|
double _roast = 60, _sticker = 70, _proactive = 40;
|
||||||
bool _saving = false, _loaded = false;
|
bool _saving = false;
|
||||||
|
bool _refreshing = false;
|
||||||
|
int _loadRevision = 0;
|
||||||
List<AvatarItem> _avatars = [];
|
List<AvatarItem> _avatars = [];
|
||||||
List<PersonaItem> _personas = [];
|
List<PersonaItem> _personas = [];
|
||||||
|
|
||||||
@@ -40,27 +43,52 @@ class _CompanionPageState extends State<CompanionPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
final revision = ++_loadRevision;
|
||||||
final p = await AuthApi.me();
|
setState(() => _refreshing = true);
|
||||||
if (mounted && p.aiCompanion != null)
|
|
||||||
setState(() {
|
final cached = await Future.wait<Object?>([
|
||||||
_avatar = p.aiCompanion!.avatarKey;
|
AuthApi.cachedCompanion(),
|
||||||
_persona = p.aiCompanion!.personaKey;
|
PublicConfigApi.cachedAvatars(),
|
||||||
_roast = p.aiCompanion!.roastLevel.toDouble();
|
PublicConfigApi.cachedPersonas(),
|
||||||
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
|
]);
|
||||||
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
|
if (!mounted || revision != _loadRevision) return;
|
||||||
});
|
_applyCompanion(cached[0] as AiCompanion?);
|
||||||
} catch (_) {}
|
setState(() {
|
||||||
try {
|
_avatars = cached[1] as List<AvatarItem>;
|
||||||
final av = await PublicConfigApi.avatars();
|
_personas = cached[2] as List<PersonaItem>;
|
||||||
final ps = await PublicConfigApi.personas();
|
});
|
||||||
if (mounted)
|
|
||||||
setState(() {
|
await Future.wait([
|
||||||
_avatars = av;
|
AuthApi.me(forceRemote: true).then<void>((profile) {
|
||||||
_personas = ps;
|
if (mounted && revision == _loadRevision) {
|
||||||
});
|
_applyCompanion(profile.aiCompanion);
|
||||||
} catch (_) {}
|
}
|
||||||
if (mounted) setState(() => _loaded = true);
|
}, onError: (_) {}),
|
||||||
|
PublicConfigApi.avatars().then<void>((values) {
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _avatars = values);
|
||||||
|
}
|
||||||
|
}, onError: (_) {}),
|
||||||
|
PublicConfigApi.personas().then<void>((values) {
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _personas = values);
|
||||||
|
}
|
||||||
|
}, onError: (_) {}),
|
||||||
|
]);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _refreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyCompanion(AiCompanion? companion) {
|
||||||
|
if (companion == null || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_avatar = companion.avatarKey;
|
||||||
|
_persona = companion.personaKey;
|
||||||
|
_roast = companion.roastLevel.toDouble();
|
||||||
|
_sticker = companion.stickerFrequency.toDouble();
|
||||||
|
_proactive = companion.proactiveLevel.toDouble();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@@ -80,26 +108,20 @@ class _CompanionPageState extends State<CompanionPage> {
|
|||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('已保存')));
|
).showSnackBar(const SnackBar(content: Text('已保存')));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted)
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) {
|
||||||
|
setState(() => _saving = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_loaded)
|
|
||||||
return const Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: AppTheme.primary,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final avatars = _avatars.isNotEmpty
|
final avatars = _avatars.isNotEmpty
|
||||||
? _avatars
|
? _avatars
|
||||||
.map(
|
.map(
|
||||||
@@ -122,7 +144,16 @@ class _CompanionPageState extends State<CompanionPage> {
|
|||||||
: _fallbackPersonas;
|
: _fallbackPersonas;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text('AI 性格设置')),
|
appBar: AppBar(
|
||||||
|
title: Text('AI 性格设置'),
|
||||||
|
actions: [BackendStatusIcon(onRetry: _load)],
|
||||||
|
bottom: _refreshing
|
||||||
|
? const PreferredSize(
|
||||||
|
preferredSize: Size.fromHeight(2),
|
||||||
|
child: LinearProgressIndicator(minHeight: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@@ -236,19 +267,24 @@ class _CompanionPageState extends State<CompanionPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ValueListenableBuilder<BackendAvailability>(
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
valueListenable: ApiClient.availability,
|
||||||
onPressed: _saving ? null : _save,
|
builder: (context, availability, _) => ElevatedButton(
|
||||||
child: _saving
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||||
? SizedBox(
|
onPressed: availability == BackendAvailability.online && !_saving
|
||||||
width: 20,
|
? _save
|
||||||
height: 20,
|
: null,
|
||||||
child: CircularProgressIndicator(
|
child: _saving
|
||||||
strokeWidth: 2,
|
? SizedBox(
|
||||||
color: Colors.white,
|
width: 20,
|
||||||
),
|
height: 20,
|
||||||
)
|
child: CircularProgressIndicator(
|
||||||
: Text('保存设置'),
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text('保存设置'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
|||||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
|
|
||||||
class RecycleBinPage extends StatefulWidget {
|
class RecycleBinPage extends StatefulWidget {
|
||||||
const RecycleBinPage({super.key});
|
const RecycleBinPage({super.key});
|
||||||
@@ -17,6 +18,7 @@ class RecycleBinPage extends StatefulWidget {
|
|||||||
class _RecycleBinPageState extends State<RecycleBinPage> {
|
class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||||
List<TxItem> _items = const [];
|
List<TxItem> _items = const [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
int _loadRevision = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -25,15 +27,30 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
final revision = ++_loadRevision;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
await CurrentLedgerStore.instance.ensureLoaded();
|
await CurrentLedgerStore.instance.loadCached();
|
||||||
final items = await TxApi.recycleBin();
|
final cached = TxApi.recycleBinLocal();
|
||||||
if (mounted) setState(() => _items = items);
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() {
|
||||||
|
_items = cached;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final remote = await TxApi.recycleBinRemote();
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _items = remote);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) _showError(error);
|
if (mounted && revision == _loadRevision && _loading) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_showError(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted && revision == _loadRevision && _loading) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +65,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _permanentDelete(TxItem item) async {
|
Future<void> _permanentDelete(TxItem item) async {
|
||||||
|
if (ApiClient.availability.value != BackendAvailability.online) return;
|
||||||
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
|
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +77,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clear() async {
|
Future<void> _clear() async {
|
||||||
if (_items.isEmpty) return;
|
if (_items.isEmpty ||
|
||||||
|
ApiClient.availability.value != BackendAvailability.online) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
|
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
@@ -79,12 +100,14 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _showActions(TxItem item) async {
|
Future<void> _showActions(TxItem item) async {
|
||||||
|
final online = ApiClient.availability.value == BackendAvailability.online;
|
||||||
final action = await showJzOptionSheet<String>(
|
final action = await showJzOptionSheet<String>(
|
||||||
context,
|
context,
|
||||||
title: item.note ?? item.categoryName,
|
title: item.note ?? item.categoryName,
|
||||||
options: const [
|
options: [
|
||||||
JzOption(value: 'restore', label: '恢复账单'),
|
const JzOption(value: 'restore', label: '恢复账单'),
|
||||||
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
|
if (online)
|
||||||
|
const JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (action == 'restore') await _restore(item);
|
if (action == 'restore') await _restore(item);
|
||||||
@@ -103,9 +126,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
|
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
BackendStatusIcon(onRetry: _load),
|
||||||
onPressed: _items.isEmpty ? null : _clear,
|
ValueListenableBuilder<BackendAvailability>(
|
||||||
child: Text('清空'),
|
valueListenable: ApiClient.availability,
|
||||||
|
builder: (context, availability, _) => TextButton(
|
||||||
|
onPressed:
|
||||||
|
_items.isEmpty || availability != BackendAvailability.online
|
||||||
|
? null
|
||||||
|
: _clear,
|
||||||
|
child: Text('清空'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||||
@@ -6,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
|||||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
@@ -25,13 +28,14 @@ class _ReportPageState extends State<ReportPage> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
DateTime _anchor = ShanghaiTime.now;
|
DateTime _anchor = ShanghaiTime.now;
|
||||||
_ReportKind _kind = _ReportKind.monthly;
|
_ReportKind _kind = _ReportKind.monthly;
|
||||||
|
int _loadRevision = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||||
CurrentLedgerStore.instance.addListener(_load);
|
CurrentLedgerStore.instance.addListener(_load);
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,24 +50,33 @@ class _ReportPageState extends State<ReportPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
final revision = ++_loadRevision;
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final report = switch (_kind) {
|
final period = switch (_kind) {
|
||||||
_ReportKind.weekly => await ReportApi.weekly(_anchor),
|
_ReportKind.weekly => 'week',
|
||||||
_ReportKind.monthly => await ReportApi.monthlyPeriod(
|
_ReportKind.monthly => 'month',
|
||||||
_anchor.year,
|
_ReportKind.yearly => 'year',
|
||||||
_anchor.month,
|
|
||||||
),
|
|
||||||
_ReportKind.yearly => await ReportApi.yearly(_anchor.year),
|
|
||||||
};
|
};
|
||||||
if (mounted) setState(() => _report = report);
|
final local = ReportApi.local(period, _anchor);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _report = local);
|
||||||
|
}
|
||||||
|
final remote = await ReportApi.remote(period, _anchor);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _report = remote);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
if (mounted && revision == _loadRevision && _report == null) {
|
||||||
|
setState(() => _error = apiErrorMessage(error));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +121,7 @@ class _ReportPageState extends State<ReportPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
|
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
|
||||||
|
actions: [BackendStatusIcon(onRetry: _load)],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:async';
|
||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
|||||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||||
|
|
||||||
class StatsPage extends StatefulWidget {
|
class StatsPage extends StatefulWidget {
|
||||||
@@ -23,12 +25,13 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
PeriodStats? _stats;
|
PeriodStats? _stats;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
int _loadRevision = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
CurrentLedgerStore.instance.addListener(_load);
|
CurrentLedgerStore.instance.addListener(_load);
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,6 +41,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
final revision = ++_loadRevision;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@@ -45,12 +49,25 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final stats = await TxApi.periodStats(_period, _anchor);
|
final local = TxApi.periodStatsLocal(_period, _anchor);
|
||||||
if (mounted) setState(() => _stats = stats);
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _stats = local);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _error = apiErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final remote = await TxApi.periodStatsRemote(_period, _anchor);
|
||||||
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _stats = remote);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted && revision == _loadRevision) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +110,10 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final stats = _stats;
|
final stats = _stats;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text('统计')),
|
appBar: AppBar(
|
||||||
|
title: Text('统计'),
|
||||||
|
actions: [BackendStatusIcon(onRetry: _load)],
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -105,7 +125,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
JzOption(value: 'month', label: '月'),
|
JzOption(value: 'month', label: '月'),
|
||||||
JzOption(value: 'year', label: '年'),
|
JzOption(value: 'year', label: '年'),
|
||||||
],
|
],
|
||||||
onChanged: _loading ? null : _selectPeriod,
|
onChanged: _selectPeriod,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -171,7 +191,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '上一个周期',
|
tooltip: '上一个周期',
|
||||||
onPressed: _loading ? null : () => _shift(-1),
|
onPressed: () => _shift(-1),
|
||||||
icon: Icon(Icons.chevron_left_rounded),
|
icon: Icon(Icons.chevron_left_rounded),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -184,9 +204,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '下一个周期',
|
tooltip: '下一个周期',
|
||||||
onPressed: _loading || _isAfterCurrentPeriod(nextAnchor)
|
onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1),
|
||||||
? null
|
|
||||||
: () => _shift(1),
|
|
||||||
icon: Icon(Icons.chevron_right_rounded),
|
icon: Icon(Icons.chevron_right_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
|
|
||||||
|
enum BackendAvailability { unknown, online, offline }
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
ApiClient._();
|
ApiClient._();
|
||||||
static final ApiClient instance = ApiClient._();
|
static final ApiClient instance = ApiClient._();
|
||||||
@@ -12,6 +14,9 @@ class ApiClient {
|
|||||||
static const _legacyTokenKey = 'auth_token';
|
static const _legacyTokenKey = 'auth_token';
|
||||||
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
|
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
|
||||||
static final sessionExpired = ValueNotifier<int>(0);
|
static final sessionExpired = ValueNotifier<int>(0);
|
||||||
|
static final availability = ValueNotifier<BackendAvailability>(
|
||||||
|
BackendAvailability.unknown,
|
||||||
|
);
|
||||||
static bool _handlingUnauthorized = false;
|
static bool _handlingUnauthorized = false;
|
||||||
|
|
||||||
static const String baseUrl = BackendIdentity.baseUrl;
|
static const String baseUrl = BackendIdentity.baseUrl;
|
||||||
@@ -31,6 +36,10 @@ class ApiClient {
|
|||||||
)
|
)
|
||||||
..interceptors.add(
|
..interceptors.add(
|
||||||
InterceptorsWrapper(
|
InterceptorsWrapper(
|
||||||
|
onResponse: (response, handler) {
|
||||||
|
availability.value = BackendAvailability.online;
|
||||||
|
handler.next(response);
|
||||||
|
},
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) async {
|
||||||
final token = await _storage.read(key: _tokenKey);
|
final token = await _storage.read(key: _tokenKey);
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
@@ -39,6 +48,11 @@ class ApiClient {
|
|||||||
handler.next(options);
|
handler.next(options);
|
||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
|
if (isConnectivityError(error)) {
|
||||||
|
availability.value = BackendAvailability.offline;
|
||||||
|
} else if (error.response != null) {
|
||||||
|
availability.value = BackendAvailability.online;
|
||||||
|
}
|
||||||
final unauthorized = error.response?.statusCode == 401;
|
final unauthorized = error.response?.statusCode == 401;
|
||||||
final data = error.response?.data;
|
final data = error.response?.data;
|
||||||
final aiDenied =
|
final aiDenied =
|
||||||
@@ -74,6 +88,18 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||||
|
|
||||||
|
Future<void> probe() async {
|
||||||
|
try {
|
||||||
|
await dio.get<void>(
|
||||||
|
'/api/public/brand',
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 10)),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// The interceptor owns the reachability state transition.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearToken() async {
|
Future<void> clearToken() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
await _storage.delete(key: _tokenKey);
|
||||||
await _storage.delete(key: _legacyTokenKey);
|
await _storage.delete(key: _legacyTokenKey);
|
||||||
@@ -91,8 +117,9 @@ String apiErrorMessage(Object e) {
|
|||||||
if (e is StateError) return e.message;
|
if (e is StateError) return e.message;
|
||||||
if (e is DioException) {
|
if (e is DioException) {
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
if (data is Map && data['message'] != null)
|
if (data is Map && data['message'] != null) {
|
||||||
return data['message'] as String;
|
return data['message'] as String;
|
||||||
|
}
|
||||||
if (e.type == DioExceptionType.connectionTimeout ||
|
if (e.type == DioExceptionType.connectionTimeout ||
|
||||||
e.type == DioExceptionType.connectionError ||
|
e.type == DioExceptionType.connectionError ||
|
||||||
e.type == DioExceptionType.receiveTimeout) {
|
e.type == DioExceptionType.receiveTimeout) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
@@ -19,6 +23,15 @@ class AiCompanion {
|
|||||||
roastLevel = json['roastLevel'] as int,
|
roastLevel = json['roastLevel'] as int,
|
||||||
stickerFrequency = json['stickerFrequency'] as int,
|
stickerFrequency = json['stickerFrequency'] as int,
|
||||||
proactiveLevel = json['proactiveLevel'] as int;
|
proactiveLevel = json['proactiveLevel'] as int;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'avatarKey': avatarKey,
|
||||||
|
'personaKey': personaKey,
|
||||||
|
'customName': customName,
|
||||||
|
'roastLevel': roastLevel,
|
||||||
|
'stickerFrequency': stickerFrequency,
|
||||||
|
'proactiveLevel': proactiveLevel,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserProfile {
|
class UserProfile {
|
||||||
@@ -49,8 +62,9 @@ class UserProfile {
|
|||||||
required this.nickname,
|
required this.nickname,
|
||||||
required this.appMode,
|
required this.appMode,
|
||||||
required this.onboardingDone,
|
required this.onboardingDone,
|
||||||
|
this.aiCompanion,
|
||||||
this.aiEnabled = false,
|
this.aiEnabled = false,
|
||||||
}) : aiCompanion = null;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthLoginResult {
|
class AuthLoginResult {
|
||||||
@@ -120,6 +134,20 @@ class AuthApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<AiCompanion?> cachedCompanion() async {
|
||||||
|
final userId = SessionStore.instance.userId;
|
||||||
|
if (userId == null) return null;
|
||||||
|
final value = (await SharedPreferences.getInstance()).getString(
|
||||||
|
_companionCacheKey(userId),
|
||||||
|
);
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return AiCompanion.fromJson(jsonDecode(value) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<UserProfile> completeOnboarding({
|
static Future<UserProfile> completeOnboarding({
|
||||||
required String appMode,
|
required String appMode,
|
||||||
required String avatarKey,
|
required String avatarKey,
|
||||||
@@ -272,13 +300,24 @@ class AuthApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _cacheProfile(UserProfile profile) =>
|
static Future<void> _cacheProfile(UserProfile profile) async {
|
||||||
SessionStore.instance.activateAccount(
|
await SessionStore.instance.activateAccount(
|
||||||
userId: profile.userId,
|
userId: profile.userId,
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
nickname: profile.nickname,
|
nickname: profile.nickname,
|
||||||
appMode: profile.appMode,
|
appMode: profile.appMode,
|
||||||
onboardingDone: profile.onboardingDone,
|
onboardingDone: profile.onboardingDone,
|
||||||
aiEnabled: profile.aiEnabled,
|
aiEnabled: profile.aiEnabled,
|
||||||
|
);
|
||||||
|
final companion = profile.aiCompanion;
|
||||||
|
if (companion != null) {
|
||||||
|
await (await SharedPreferences.getInstance()).setString(
|
||||||
|
_companionCacheKey(profile.userId),
|
||||||
|
jsonEncode(companion.toJson()),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _companionCacheKey(int userId) =>
|
||||||
|
'ai_companion_${BackendIdentity.scope}_$userId';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,20 @@ class TxApi {
|
|||||||
static bool get _queueOfflineChanges =>
|
static bool get _queueOfflineChanges =>
|
||||||
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
|
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
|
||||||
|
|
||||||
|
static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
|
||||||
|
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<MonthSummary> monthRemote(int year, int month) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/transactions/month',
|
||||||
|
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||||
|
);
|
||||||
|
final json = response.data as Map<String, dynamic>;
|
||||||
|
LocalDatabase.instance.cacheMonthSummary(json);
|
||||||
|
return MonthSummary.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<MonthSummary> month(int year, int month) async {
|
static Future<MonthSummary> month(int year, int month) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly) {
|
||||||
@@ -330,6 +344,29 @@ class TxApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
|
||||||
|
PeriodStats.fromJson(
|
||||||
|
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<PeriodStats> periodStatsRemote(
|
||||||
|
String period,
|
||||||
|
DateTime anchor,
|
||||||
|
) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/transactions/stats/period',
|
||||||
|
queryParameters: {
|
||||||
|
'period': period,
|
||||||
|
'anchor':
|
||||||
|
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${anchor.day.toString().padLeft(2, '0')}',
|
||||||
|
'ledgerId': _ledgerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<CategoryItem>> categories(String type) async {
|
static Future<List<CategoryItem>> categories(String type) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly) {
|
||||||
@@ -357,6 +394,24 @@ class TxApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase
|
||||||
|
.instance
|
||||||
|
.categories(type)
|
||||||
|
.map(CategoryItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
static Future<List<CategoryItem>> categoriesRemote(String type) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/categories',
|
||||||
|
queryParameters: {'type': type},
|
||||||
|
);
|
||||||
|
final values = response.data as List;
|
||||||
|
LocalDatabase.instance.cacheCategories(values, replaceType: type);
|
||||||
|
return values
|
||||||
|
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<TxItem> create({
|
static Future<TxItem> create({
|
||||||
required int categoryId,
|
required int categoryId,
|
||||||
required String type,
|
required String type,
|
||||||
@@ -532,7 +587,9 @@ class TxApi {
|
|||||||
includeDeleted: true,
|
includeDeleted: true,
|
||||||
)?['updatedAt'];
|
)?['updatedAt'];
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly || id < 0) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
|
id < 0) {
|
||||||
LocalDatabase.instance.softDeleteTransaction(id);
|
LocalDatabase.instance.softDeleteTransaction(id);
|
||||||
if (_queueOfflineChanges) {
|
if (_queueOfflineChanges) {
|
||||||
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
|
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
|
||||||
@@ -613,7 +670,8 @@ class TxApi {
|
|||||||
|
|
||||||
static Future<List<TxItem>> recycleBin() async {
|
static Future<List<TxItem>> recycleBin() async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline) {
|
||||||
return LocalDatabase.instance
|
return LocalDatabase.instance
|
||||||
.recycleBin(_ledgerId)
|
.recycleBin(_ledgerId)
|
||||||
.map(TxItem.fromJson)
|
.map(TxItem.fromJson)
|
||||||
@@ -638,13 +696,32 @@ class TxApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
|
||||||
|
.recycleBin(_ledgerId)
|
||||||
|
.map(TxItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
static Future<List<TxItem>> recycleBinRemote() async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/transactions/recycle-bin',
|
||||||
|
queryParameters: {'ledgerId': _ledgerId},
|
||||||
|
);
|
||||||
|
final values = response.data as List;
|
||||||
|
LocalDatabase.instance.cacheTransactions(values);
|
||||||
|
return values
|
||||||
|
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<TxItem> restore(int id) async {
|
static Future<TxItem> restore(int id) async {
|
||||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||||
id,
|
id,
|
||||||
includeDeleted: true,
|
includeDeleted: true,
|
||||||
)?['updatedAt'];
|
)?['updatedAt'];
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly || id < 0) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
|
id < 0) {
|
||||||
final local = LocalDatabase.instance.restoreTransaction(id);
|
final local = LocalDatabase.instance.restoreTransaction(id);
|
||||||
if (_queueOfflineChanges) {
|
if (_queueOfflineChanges) {
|
||||||
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
|
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
|
||||||
@@ -984,6 +1061,18 @@ class BudgetApi {
|
|||||||
static final _dio = ApiClient.instance.dio;
|
static final _dio = ApiClient.instance.dio;
|
||||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||||
|
|
||||||
|
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
|
||||||
|
LocalDatabase.instance.budgets(year, month, _ledgerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<BudgetsData> getRemote(int year, int month) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/budgets',
|
||||||
|
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||||
|
);
|
||||||
|
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<BudgetsData> get(int year, int month) async {
|
static Future<BudgetsData> get(int year, int month) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly) {
|
||||||
@@ -1294,6 +1383,29 @@ class ReportApi {
|
|||||||
static final _dio = ApiClient.instance.dio;
|
static final _dio = ApiClient.instance.dio;
|
||||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||||
|
|
||||||
|
static PeriodReport local(String period, DateTime anchor) =>
|
||||||
|
_localPeriod(period, anchor);
|
||||||
|
|
||||||
|
static Future<PeriodReport> remote(String period, DateTime anchor) async {
|
||||||
|
if (period == 'month') {
|
||||||
|
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
|
||||||
|
}
|
||||||
|
final path = period == 'week'
|
||||||
|
? '/api/reports/weekly'
|
||||||
|
: '/api/reports/yearly';
|
||||||
|
final query = period == 'week'
|
||||||
|
? {
|
||||||
|
'date':
|
||||||
|
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${anchor.day.toString().padLeft(2, '0')}',
|
||||||
|
'ledgerId': _ledgerId,
|
||||||
|
}
|
||||||
|
: {'year': anchor.year, 'ledgerId': _ledgerId};
|
||||||
|
final response = await _dio.get(path, queryParameters: query);
|
||||||
|
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<PeriodReport> weekly(DateTime date) async {
|
static Future<PeriodReport> weekly(DateTime date) async {
|
||||||
if (SessionStore.instance.shouldUseLocalOnly) {
|
if (SessionStore.instance.shouldUseLocalOnly) {
|
||||||
return _localPeriod('week', date);
|
return _localPeriod('week', date);
|
||||||
@@ -1595,7 +1707,8 @@ class CategoryApi {
|
|||||||
'type': type,
|
'type': type,
|
||||||
};
|
};
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline) {
|
||||||
final local = LocalDatabase.instance.createCategory(
|
final local = LocalDatabase.instance.createCategory(
|
||||||
name,
|
name,
|
||||||
iconKey,
|
iconKey,
|
||||||
@@ -1649,7 +1762,9 @@ class CategoryApi {
|
|||||||
'sortOrder': sortOrder,
|
'sortOrder': sortOrder,
|
||||||
};
|
};
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly || id < 0) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
|
id < 0) {
|
||||||
final local = LocalDatabase.instance.updateCategory(
|
final local = LocalDatabase.instance.updateCategory(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -1662,16 +1777,31 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
return CategoryItem.fromJson(local);
|
return CategoryItem.fromJson(local);
|
||||||
}
|
}
|
||||||
final response = await _dio.put('/api/categories/$id', data: payload);
|
try {
|
||||||
final json = response.data as Map<String, dynamic>;
|
final response = await _dio.put('/api/categories/$id', data: payload);
|
||||||
LocalDatabase.instance.cacheCategories([json]);
|
final json = response.data as Map<String, dynamic>;
|
||||||
return CategoryItem.fromJson(json);
|
LocalDatabase.instance.cacheCategories([json]);
|
||||||
|
return CategoryItem.fromJson(json);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||||
|
final local = LocalDatabase.instance.updateCategory(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
iconKey,
|
||||||
|
colorKey,
|
||||||
|
sortOrder,
|
||||||
|
);
|
||||||
|
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
||||||
|
return CategoryItem.fromJson(local);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> reorder(String type, List<int> categoryIds) async {
|
static Future<void> reorder(String type, List<int> categoryIds) async {
|
||||||
LocalDatabase.instance.reorderCategories(type, categoryIds);
|
LocalDatabase.instance.reorderCategories(type, categoryIds);
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
|
categoryIds.any((id) => id < 0)) {
|
||||||
if (session.isAccount && session.cloudSyncEnabled) {
|
if (session.isAccount && session.cloudSyncEnabled) {
|
||||||
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||||
'type': type,
|
'type': type,
|
||||||
@@ -1680,15 +1810,25 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _dio.put(
|
try {
|
||||||
'/api/categories/reorder',
|
await _dio.put(
|
||||||
data: {'type': type, 'categoryIds': categoryIds},
|
'/api/categories/reorder',
|
||||||
);
|
data: {'type': type, 'categoryIds': categoryIds},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||||
|
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||||
|
'type': type,
|
||||||
|
'categoryIds': categoryIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> delete(int id) async {
|
static Future<void> delete(int id) async {
|
||||||
final session = SessionStore.instance;
|
final session = SessionStore.instance;
|
||||||
if (session.shouldUseLocalOnly || id < 0) {
|
if (session.shouldUseLocalOnly ||
|
||||||
|
ApiClient.availability.value == BackendAvailability.offline ||
|
||||||
|
id < 0) {
|
||||||
LocalDatabase.instance.deleteCategory(id);
|
LocalDatabase.instance.deleteCategory(id);
|
||||||
if (session.isAccount && session.cloudSyncEnabled) {
|
if (session.isAccount && session.cloudSyncEnabled) {
|
||||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
||||||
@@ -1697,7 +1837,13 @@ class CategoryApi {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _dio.delete('/api/categories/$id');
|
try {
|
||||||
LocalDatabase.instance.deleteCategory(id);
|
await _dio.delete('/api/categories/$id');
|
||||||
|
LocalDatabase.instance.deleteCategory(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||||
|
LocalDatabase.instance.deleteCategory(id);
|
||||||
|
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
|
|
||||||
class BrandConfig {
|
class BrandConfig {
|
||||||
@@ -41,6 +45,13 @@ class AvatarItem {
|
|||||||
defaultName = j['defaultName'] as String,
|
defaultName = j['defaultName'] as String,
|
||||||
speechTic = j['speechTic'] as String? ?? '',
|
speechTic = j['speechTic'] as String? ?? '',
|
||||||
imageUrl = j['imageUrl'] as String?;
|
imageUrl = j['imageUrl'] as String?;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'key': key,
|
||||||
|
'defaultName': defaultName,
|
||||||
|
'speechTic': speechTic,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class PersonaItem {
|
class PersonaItem {
|
||||||
@@ -50,6 +61,13 @@ class PersonaItem {
|
|||||||
name = j['name'] as String,
|
name = j['name'] as String,
|
||||||
description = j['description'] as String? ?? '',
|
description = j['description'] as String? ?? '',
|
||||||
sampleLine = j['sampleLine'] as String? ?? '';
|
sampleLine = j['sampleLine'] as String? ?? '';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'key': key,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'sampleLine': sampleLine,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class CompanionDisplay {
|
class CompanionDisplay {
|
||||||
@@ -145,16 +163,59 @@ class PublicConfigApi {
|
|||||||
|
|
||||||
static Future<List<AvatarItem>> avatars() async {
|
static Future<List<AvatarItem>> avatars() async {
|
||||||
final res = await _dio.get('/api/public/avatars');
|
final res = await _dio.get('/api/public/avatars');
|
||||||
return (res.data as List)
|
final values = (res.data as List)
|
||||||
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
await _cacheCatalog(
|
||||||
|
_avatarCacheKey,
|
||||||
|
values.map((e) => e.toJson()).toList(),
|
||||||
|
);
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<PersonaItem>> personas() async {
|
static Future<List<PersonaItem>> personas() async {
|
||||||
final res = await _dio.get('/api/public/personas');
|
final res = await _dio.get('/api/public/personas');
|
||||||
return (res.data as List)
|
final values = (res.data as List)
|
||||||
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
await _cacheCatalog(
|
||||||
|
_personaCacheKey,
|
||||||
|
values.map((e) => e.toJson()).toList(),
|
||||||
|
);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<AvatarItem>> cachedAvatars() async =>
|
||||||
|
(await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList();
|
||||||
|
|
||||||
|
static Future<List<PersonaItem>> cachedPersonas() async =>
|
||||||
|
(await _cachedCatalog(
|
||||||
|
_personaCacheKey,
|
||||||
|
)).map(PersonaItem.fromJson).toList();
|
||||||
|
|
||||||
|
static String get _avatarCacheKey =>
|
||||||
|
'public_avatars_${BackendIdentity.scope}';
|
||||||
|
static String get _personaCacheKey =>
|
||||||
|
'public_personas_${BackendIdentity.scope}';
|
||||||
|
|
||||||
|
static Future<void> _cacheCatalog(
|
||||||
|
String key,
|
||||||
|
List<Map<String, dynamic>> values,
|
||||||
|
) async {
|
||||||
|
await (await SharedPreferences.getInstance()).setString(
|
||||||
|
key,
|
||||||
|
jsonEncode(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> _cachedCatalog(String key) async {
|
||||||
|
final value = (await SharedPreferences.getInstance()).getString(key);
|
||||||
|
if (value == null) return const [];
|
||||||
|
try {
|
||||||
|
return (jsonDecode(value) as List).cast<Map<String, dynamic>>().toList();
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _normalizeName(String? value) {
|
static String? _normalizeName(String? value) {
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ class CurrentLedgerStore extends ChangeNotifier {
|
|||||||
return _loading ??= _loadCached().whenComplete(() => _loading = null);
|
return _loading ??= _loadCached().whenComplete(() => _loading = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshRemote() async {
|
||||||
|
if (!SessionStore.instance.isAccount ||
|
||||||
|
SessionStore.instance.shouldUseLocalOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final response = await _dio.get('/api/ledgers');
|
||||||
|
final values = response.data as List;
|
||||||
|
LocalDatabase.instance.cacheLedgers(values);
|
||||||
|
_apply(values);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCached() async {
|
Future<void> _loadCached() async {
|
||||||
_apply(LocalDatabase.instance.ledgers());
|
_apply(LocalDatabase.instance.ledgers());
|
||||||
}
|
}
|
||||||
@@ -78,11 +89,24 @@ class CurrentLedgerStore extends ChangeNotifier {
|
|||||||
final items = values
|
final items = values
|
||||||
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
|
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
_ledgers = items;
|
final current =
|
||||||
_current =
|
|
||||||
items.where((item) => item.isDefault).firstOrNull ??
|
items.where((item) => item.isDefault).firstOrNull ??
|
||||||
(items.isEmpty ? null : items.first);
|
(items.isEmpty ? null : items.first);
|
||||||
notifyListeners();
|
final changed =
|
||||||
|
_current?.id != current?.id ||
|
||||||
|
_ledgers.length != items.length ||
|
||||||
|
Iterable<int>.generate(items.length).any((index) {
|
||||||
|
final before = _ledgers[index];
|
||||||
|
final after = items[index];
|
||||||
|
return before.id != after.id ||
|
||||||
|
before.name != after.name ||
|
||||||
|
before.iconKey != after.iconKey ||
|
||||||
|
before.isDefault != after.isDefault ||
|
||||||
|
before.transactionCount != after.transactionCount;
|
||||||
|
});
|
||||||
|
_ledgers = items;
|
||||||
|
_current = current;
|
||||||
|
if (changed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> select(int id) async {
|
Future<void> select(int id) async {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
|
||||||
|
class BackendStatusIcon extends StatelessWidget {
|
||||||
|
final Future<void> Function() onRetry;
|
||||||
|
|
||||||
|
const BackendStatusIcon({super.key, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<BackendAvailability>(
|
||||||
|
valueListenable: ApiClient.availability,
|
||||||
|
builder: (context, availability, _) {
|
||||||
|
if (availability != BackendAvailability.offline) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return IconButton(
|
||||||
|
tooltip: '当前显示本地数据,点击重试',
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: AppIcons.icon(
|
||||||
|
AppIcons.offline,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/api/auth_api.dart';
|
||||||
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
tearDown(() {
|
||||||
|
ApiClient.availability.value = BackendAvailability.unknown;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async {
|
||||||
|
var retries = 0;
|
||||||
|
ApiClient.availability.value = BackendAvailability.offline;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
actions: [
|
||||||
|
BackendStatusIcon(
|
||||||
|
onRetry: () async {
|
||||||
|
retries++;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final retry = find.byTooltip('当前显示本地数据,点击重试');
|
||||||
|
expect(retry, findsOneWidget);
|
||||||
|
await tester.tap(retry);
|
||||||
|
await tester.pump();
|
||||||
|
expect(retries, 1);
|
||||||
|
|
||||||
|
ApiClient.availability.value = BackendAvailability.online;
|
||||||
|
await tester.pump();
|
||||||
|
expect(retry, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('连接、发送和接收失败都会被识别为后端不可达', () {
|
||||||
|
DioException failure(DioExceptionType type) => DioException(
|
||||||
|
requestOptions: RequestOptions(path: '/test'),
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isConnectivityError(failure(DioExceptionType.connectionError)),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isConnectivityError(failure(DioExceptionType.connectionTimeout)),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue);
|
||||||
|
expect(
|
||||||
|
isConnectivityError(failure(DioExceptionType.receiveTimeout)),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 伙伴缓存 DTO 可无损往返', () {
|
||||||
|
final source = {
|
||||||
|
'avatarKey': 'cat',
|
||||||
|
'personaKey': 'gentle',
|
||||||
|
'customName': '小记',
|
||||||
|
'roastLevel': 20,
|
||||||
|
'stickerFrequency': 40,
|
||||||
|
'proactiveLevel': 60,
|
||||||
|
};
|
||||||
|
final companion = AiCompanion.fromJson(source);
|
||||||
|
|
||||||
|
expect(companion.toJson(), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
|
||||||
|
final contracts = <String, List<String>>{
|
||||||
|
'lib/features/home/pages/home_page.dart': [
|
||||||
|
'monthLocal',
|
||||||
|
'monthRemote',
|
||||||
|
'BudgetApi.getLocal',
|
||||||
|
'BudgetApi.getRemote',
|
||||||
|
'refreshRemote',
|
||||||
|
],
|
||||||
|
'lib/features/add/add_page.dart': [
|
||||||
|
"categoriesLocal('expense')",
|
||||||
|
"categoriesRemote('expense')",
|
||||||
|
"categoriesRemote('income')",
|
||||||
|
],
|
||||||
|
'lib/features/stats/stats_page.dart': [
|
||||||
|
'periodStatsLocal',
|
||||||
|
'periodStatsRemote',
|
||||||
|
],
|
||||||
|
'lib/features/stats/report_page.dart': [
|
||||||
|
'ReportApi.local',
|
||||||
|
'ReportApi.remote',
|
||||||
|
],
|
||||||
|
'lib/features/settings/category_manage_page.dart': [
|
||||||
|
'categoriesLocal',
|
||||||
|
'categoriesRemote',
|
||||||
|
],
|
||||||
|
'lib/features/settings/recycle_bin_page.dart': [
|
||||||
|
'recycleBinLocal',
|
||||||
|
'recycleBinRemote',
|
||||||
|
],
|
||||||
|
'lib/features/settings/companion_page.dart': [
|
||||||
|
'cachedCompanion',
|
||||||
|
'cachedAvatars',
|
||||||
|
'cachedPersonas',
|
||||||
|
'forceRemote: true',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final entry in contracts.entries) {
|
||||||
|
final source = File(entry.key).readAsStringSync();
|
||||||
|
expect(source, contains('_loadRevision'), reason: entry.key);
|
||||||
|
expect(source, contains('BackendStatusIcon'), reason: entry.key);
|
||||||
|
for (final token in entry.value) {
|
||||||
|
expect(source, contains(token), reason: '${entry.key}: $token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final companion = File(
|
||||||
|
'lib/features/settings/companion_page.dart',
|
||||||
|
).readAsStringSync();
|
||||||
|
expect(companion, isNot(contains('if (!_loaded)')));
|
||||||
|
expect(companion, contains('BackendAvailability.online'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () {
|
||||||
|
final auth = File('lib/shared/api/auth_api.dart').readAsStringSync();
|
||||||
|
final config = File('lib/shared/api/config_api.dart').readAsStringSync();
|
||||||
|
final recycle = File(
|
||||||
|
'lib/features/settings/recycle_bin_page.dart',
|
||||||
|
).readAsStringSync();
|
||||||
|
|
||||||
|
expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'"));
|
||||||
|
expect(config, contains("'public_avatars_\${BackendIdentity.scope}'"));
|
||||||
|
expect(config, contains("'public_personas_\${BackendIdentity.scope}'"));
|
||||||
|
expect(recycle, contains('availability != BackendAvailability.online'));
|
||||||
|
expect(recycle, contains('if (online)'));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user