diff --git a/.codebuddy/memory/2026-09-22.md b/.codebuddy/memory/2026-09-22.md new file mode 100644 index 0000000..d1357c6 --- /dev/null +++ b/.codebuddy/memory/2026-09-22.md @@ -0,0 +1,63 @@ +# 2026-09-22 + +## 排查:API 环境配置(`lib/core/network/api_env.dart`) +用户提问:`kProdBaseUrl` / `kLocalBaseUrl` 如何工作、如何保证正式包连真实后端。未改动代码,仅分析并给出改进建议。 + +### 机制(已确认) +- `baseUrl` getter 三级优先级:`--dart-define=API_BASE_URL`(`String.fromEnvironment`,编译期)> `!kDebugMode` 时 `kProdBaseUrl` > 默认 `kLocalBaseUrl`。 +- 消费方:`dio_client.dart` 的 `dioProvider` 把 `baseUrl` 塞进 `BaseOptions.baseUrl`;API 层(如 `auth_api.dart`)只用相对路径 `/auth/login`,由 dio 拼接。 +- 正式包的保证来自**编译期常量折叠**:release 下 `kDebugMode == false`,`return kLocalBaseUrl` 成为死代码被 tree-shake,本地地址字符串不会进入产物。 + +### 发现的 3 个风险点(重要,待处理) +1. **`--dart-define` 可击穿保证**:其优先级最高且 release 同样生效。`docs/Flutter打包与真机安装指南.md` 第 203 行就给出了 `flutter build apk --release --dart-define=API_BASE_URL=http://192.168.50.182:12345/api`,一旦复制粘贴或 CI 残留,会产出指向局域网的"正式包"。 +2. **profile 模式会连生产**:`kDebugMode` 在 profile 下为 false → 走 `kProdBaseUrl`。`flutter run --profile`(做性能验证时常用)直接打生产库。建议改用 `kReleaseMode` 显式判断。 +3. **配置与文档/注释不一致,且 iOS 会直接失败**: + - 代码 `kProdBaseUrl = 'http://103.36.220.231:12345/api'`(**明文 HTTP + 裸 IP + 非标端口**),但同文件注释写"生产环境 HTTPS",`docs/Flutter打包与真机安装指南.md` 第 178 行写的是 `https://accounting.aqroid.cn/api`。 + - Android 端能跑,是因为 `android/app/src/main/AndroidManifest.xml` 开了 `android:usesCleartextTraffic="true"`(而打包指南第 350 行却说"生产是 HTTPS,其实可以不开")。 + - **iOS 端 `ios/Runner/Info.plist` 中完全没有 `NSAppTransportSecurity` 配置**,请求该 http 地址会被 ATS 拦截(URLError -1022),iOS 正式包无法联网。 + - Web 端若部署在 HTTPS 页面下,浏览器会以 mixed content 拦截 http 请求(另有 CORS 问题)。 + - 硬编码裸 IP:换服务器/加域名/上 HTTPS 都必须重新打包发版。 + +### 给用户的改进方向(尚未实施) +- `baseUrl` 判断改为显式 `kReleaseMode`,并处理 profile(如新增 `kProfileBaseUrl` 或按需回退)。 +- 生产改用域名 + HTTPS,同步更新 Android 明文流量开关与 iOS ATS 例外。 +- 多环境改用 `--dart-define-from-file=config/prod.json`,避免长命令行被误复制。 +- 增加运行时自检:release 下若 `API_BASE_URL` 非空则报错/显著日志;启动时打印当前 baseUrl。 + +--- + +## 实施:App 名称与图标(已完成) + +用户提供品牌原图位置 `D:\nzy\workspace_nzy\AI-accounting-soft-uniApp\static\images\logo.png`(与项目内 `assets/images/logo.png` 同一张),要求改名 + 换图标。 + +### 关键结论:原 logo 不能作图标 +该图为 1120×928 贴纸海报(熊头 + 3 只狗 + "强宝爱记账"5 个大字),带虚化底纹。实测用洪水填充/PIL 去背景**失败**(底纹色差超出阈值,水印残留),且缩小到 48px 后文字糊成一团。已明确告知用户并改为同画风专用素材。 + +### 已完成改动 +| 内容 | 文件 | 值 | +|---|---|---| +| Android 桌面名 | `android/app/src/main/AndroidManifest.xml` | `android:label="强宝爱记账"` | +| iOS 桌面名 | `ios/Runner/Info.plist` | `CFBundleDisplayName` = `强宝爱记账` | +| Web 标签页/主屏名/描述 | `web/index.html` | ``、`apple-mobile-web-app-title` 均为 `强宝爱记账` | +| Web PWA | `web/manifest.json` | name=`强宝爱记账`、short_name=`强宝记账`、background_color=`#FFF6EC`、theme_color=`#C8956E` | +| 图标依赖 | `pubspec.yaml` | 新增 dev 依赖 `flutter_launcher_icons: ^0.14.0`(实际解析到 0.14.4) | +| 图标配置 | `pubspec.yaml` 末尾 | 新增 `flutter_launcher_icons` 段(image_path + adaptive 双层 + remove_alpha_ios + web) | +| 文档 | `docs/Flutter打包与真机安装指南.md` 7.3 节 | 重写为实际链路 | + +### 新增的文件与链路 +- `tools/make_app_icon.py`:从素材生成图标。功能 = 去右下角 AI 水印(用上方一行像素向下延展填充,适配平滑渐变底)→ 按四角平均色做背景掩码抠出主体 → 居中缩放。产出 `assets/icon/app_icon.png`(RGB 无 alpha)与 `assets/icon/app_icon_fg.png`(RGBA,主体占 60%,供自适应前景层)。支持 `--preview`(输出构图预览)、`--check`(核对色彩模式)、`--src`(换素材)。 +- `assets/icon/_source/raw_bear.png`:最终采用素材(AI 生成的 kawaii 棕熊头,奶油底 #FFF6EC)。 +- `assets/icon/_source/raw_bear_dog.png`:备选素材(熊 + 金毛),待用户选择。 +- 注意:`assets/icon/` 未在 pubspec 声明为 assets,不会打进包。 + +### 生成与校验结果 +- `flutter pub get` 成功(走 pub.flutter-io.cn 镜像);`dart run flutter_launcher_icons` 成功。 +- Android:5 套 mipmap + `mipmap-anydpi-v26/ic_launcher.xml` + 新建 `res/values/colors.xml` + `drawable-*/ic_launcher_foreground.png`。 +- iOS:`AppIcon.appiconset` 全套 22 张,1024 图确认为 **RGB 无 alpha**(合规)。 +- Web:`web/icons/*` + `web/favicon.png`。 +- `flutter analyze` → No issues found。 + +### 待用户确认 +- 是否改用备选素材 `raw_bear_dog.png`(熊 + 金毛,更贴近原 logo 的"熊+狗"组合),换法:`python tools/make_app_icon.py --src assets/icon/_source/raw_bear_dog.png && dart run flutter_launcher_icons`。 +- 需重新构建并**卸载后重装**才能看到新名称/图标(启动器有缓存)。 + diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f7e8bbf Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..aaa3545 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..161f90a Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a22cc73 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7a54886 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground> + <inset + android:drawable="@drawable/ic_launcher_foreground" + android:inset="16%" /> + </foreground> +</adaptive-icon> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..68fc055 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="ic_launcher_background">#FFF6EC</color> +</resources> \ No newline at end of file diff --git a/assets/icon/_source/raw_bear.png b/assets/icon/_source/raw_bear.png new file mode 100644 index 0000000..a6d2f13 Binary files /dev/null and b/assets/icon/_source/raw_bear.png differ diff --git a/assets/icon/_source/raw_bear_dog.png b/assets/icon/_source/raw_bear_dog.png new file mode 100644 index 0000000..6b9c6d9 Binary files /dev/null and b/assets/icon/_source/raw_bear_dog.png differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png new file mode 100644 index 0000000..ce20b9c Binary files /dev/null and b/assets/icon/app_icon.png differ diff --git a/assets/icon/app_icon_fg.png b/assets/icon/app_icon_fg.png new file mode 100644 index 0000000..cb005bb Binary files /dev/null and b/assets/icon/app_icon_fg.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png index 3ab05a1..54462d1 100644 Binary files a/assets/images/logo.png and b/assets/images/logo.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..511e8d1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..aeb5723 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..0bda030 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..d592a8f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..2f3e6e0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..122d8ba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/lib/api/note_api.dart b/lib/api/note_api.dart new file mode 100644 index 0000000..d2ef701 --- /dev/null +++ b/lib/api/note_api.dart @@ -0,0 +1,171 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/note.dart'; + +final noteApiProvider = Provider<NoteApi>((ref) => NoteApi(ref.watch(dioProvider))); + +/// 笔记接口 +class NoteApi { + final Dio _dio; + + NoteApi(this._dio); + + /// 分页列表(不含正文) + /// + /// [scope] all / uncategorized / folder;[folderId] 仅在 scope=folder 时有意义, + /// 后端会连同该目录的所有子目录一起查。 + Future<NotePage> list({ + int page = 1, + int size = 20, + String scope = 'all', + int? folderId, + String sort = 'updated', + String? keyword, + String? tag, + }) async { + try { + final res = await _dio.get('/notes', queryParameters: { + 'page': page, + 'size': size, + 'scope': scope, + 'sort': sort, + 'folderId': ?folderId, + if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + if (tag != null && tag.isNotEmpty) 'tag': tag, + }); + final data = res.data; + if (data is Map<String, dynamic>) return NotePage.fromJson(data); + return NotePage.empty; + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '笔记列表加载失败'); + } + } + + /// 目录树 + Future<FolderTree> folderTree() async { + try { + final res = await _dio.get('/notes/folders'); + final data = res.data; + if (data is Map<String, dynamic>) return FolderTree.fromJson(data); + return FolderTree.empty; + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '目录加载失败'); + } + } + + /// 把笔记移动到某个目录,[folderId] 为 null 表示移到未分类 + Future<void> assignFolder(int noteId, int? folderId) async { + try { + await _dio.put('/notes/$noteId/folder', data: {'folderId': folderId}); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '移动失败'); + } + } + + /// 详情(含正文) + Future<NoteDetail> detail(int id) async { + try { + final res = await _dio.get('/notes/$id'); + return NoteDetail.fromJson(_asMap(res.data)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '笔记加载失败'); + } + } + + /// 新建 + Future<NoteDetail> create({ + required String title, + required String content, + required String tags, + }) async { + try { + final res = await _dio.post('/notes', data: { + 'title': title, + 'content': content, + 'tags': tags, + 'isPinned': 0, + }); + return NoteDetail.fromJson(_asMap(res.data)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '笔记保存失败'); + } + } + + /// 更新 + /// + /// 后端是部分更新语义:字段传 null 表示不改动, + /// 所以这里只塞需要改的字段,不必回传全部。 + Future<NoteDetail> update( + int id, { + String? title, + String? content, + String? tags, + bool? pinned, + }) async { + try { + final res = await _dio.put('/notes/$id', data: { + // `?value` 是 Dart 的空感知元素:值为 null 时整个键值对不写入 map, + // 正好对应后端"字段为 null 表示不改动"的部分更新语义 + 'title': ?title, + 'content': ?content, + 'tags': ?tags, + if (pinned != null) 'isPinned': pinned ? 1 : 0, + }); + return NoteDetail.fromJson(_asMap(res.data)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '笔记保存失败'); + } + } + + /// 删除(后端是逻辑删除) + Future<void> delete(int id) async { + try { + await _dio.delete('/notes/$id'); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '笔记删除失败'); + } + } + + /// 标签列表,按使用次数排序 + Future<List<String>> tags() async { + try { + final res = await _dio.get('/notes/tags'); + return asStringListFrom(res.data); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '标签加载失败'); + } + } + + Map<String, dynamic> _asMap(Object? data) { + if (data is Map<String, dynamic>) return data; + if (data is Map && data['data'] is Map) { + return Map<String, dynamic>.from(data['data'] as Map); + } + return const {}; + } +} + +/// 标签接口返回的是裸数组,单独处理一下 +List<String> asStringListFrom(Object? data) { + if (data is List) { + return data.map((e) => e?.toString() ?? '').where((e) => e.isNotEmpty).toList(); + } + if (data is Map && data['data'] is List) { + return (data['data'] as List) + .map((e) => e?.toString() ?? '') + .where((e) => e.isNotEmpty) + .toList(); + } + return const []; +} diff --git a/lib/core/network/api_env.dart b/lib/core/network/api_env.dart index d6e1e05..021f2b4 100644 --- a/lib/core/network/api_env.dart +++ b/lib/core/network/api_env.dart @@ -29,3 +29,21 @@ String get baseUrl { if (!kDebugMode) return kProdBaseUrl; return kLocalBaseUrl; } + +/// 静态资源根地址(去掉 `/api` 后缀) +/// +/// 笔记附件由后端以 `/uploads/2026/09/22/xxx.png` 这种相对路径返回, +/// 客户端要拼上 host 才能加载。 +String get assetBaseUrl { + const suffix = '/api'; + return baseUrl.endsWith(suffix) + ? baseUrl.substring(0, baseUrl.length - suffix.length) + : baseUrl; +} + +/// 把后端返回的资源地址补成绝对地址(已经是 http(s) 的原样返回) +String resolveAssetUrl(String path) { + if (path.isEmpty) return path; + if (path.startsWith('http://') || path.startsWith('https://')) return path; + return path.startsWith('/') ? '$assetBaseUrl$path' : '$assetBaseUrl/$path'; +} diff --git a/lib/models/json_util.dart b/lib/models/json_util.dart index 5a0bf1e..dedd714 100644 --- a/lib/models/json_util.dart +++ b/lib/models/json_util.dart @@ -11,6 +11,18 @@ int asInt(Object? value, {int fallback = 0}) { return fallback; } +/// 取可空整数:null / 无法解析都返回 null +/// +/// 用于 folderId 这类"没有值"本身有业务含义的字段 +/// (null 表示未分类,不能退化成 0) +int? asNullableInt(Object? value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + double asDouble(Object? value, {double fallback = 0}) { if (value is double) return value; if (value is num) return value.toDouble(); @@ -34,3 +46,12 @@ List<Map<String, dynamic>> asMapList(Object? value) { if (value is! List) return const []; return value.whereType<Map<String, dynamic>>().toList(); } + +/// 取字符串数组(用于标签这类场景),非字符串元素丢弃,空串过滤掉 +List<String> asStringList(Object? value) { + if (value is! List) return const []; + return value + .map((e) => e?.toString() ?? '') + .where((e) => e.isNotEmpty) + .toList(); +} diff --git a/lib/models/note.dart b/lib/models/note.dart new file mode 100644 index 0000000..80aa460 --- /dev/null +++ b/lib/models/note.dart @@ -0,0 +1,200 @@ +import 'json_util.dart'; + +/// 笔记列表项(不含正文) +/// +/// 后端列表接口刻意不返回 content —— 一页 20 条正文可能有几 MB, +/// 而这个后端在公网服务器上,列表页只需要摘要。 +class NoteListItem { + final int id; + final int? folderId; + final String title; + final String summary; + final List<String> tags; + final bool pinned; + final String updateTime; + + const NoteListItem({ + required this.id, + required this.folderId, + required this.title, + required this.summary, + required this.tags, + required this.pinned, + required this.updateTime, + }); + + factory NoteListItem.fromJson(Map<String, dynamic> json) { + return NoteListItem( + id: asInt(json['id']), + folderId: asNullableInt(json['folderId']), + title: asNonEmpty(json['title'], '无标题笔记'), + summary: asString(json['summary']), + tags: asStringList(json['tagList']), + pinned: asInt(json['isPinned']) == 1, + updateTime: formatDateTime(json['updateTime']), + ); + } +} + +/// 笔记详情(含正文) +class NoteDetail { + final int id; + final int? folderId; + final String title; + final String content; + final List<String> tags; + final bool pinned; + final String createTime; + final String updateTime; + + const NoteDetail({ + required this.id, + required this.folderId, + required this.title, + required this.content, + required this.tags, + required this.pinned, + required this.createTime, + required this.updateTime, + }); + + factory NoteDetail.fromJson(Map<String, dynamic> json) { + return NoteDetail( + id: asInt(json['id']), + folderId: asNullableInt(json['folderId']), + title: asNonEmpty(json['title'], '无标题笔记'), + content: asString(json['content']), + tags: asStringList(json['tagList']), + pinned: asInt(json['isPinned']) == 1, + createTime: formatDateTime(json['createTime']), + updateTime: formatDateTime(json['updateTime']), + ); + } +} + +/// 分页结果 +class NotePage { + final List<NoteListItem> items; + final int total; + final bool hasMore; + + const NotePage({required this.items, required this.total, required this.hasMore}); + + static const empty = NotePage(items: [], total: 0, hasMore: false); + + factory NotePage.fromJson(Map<String, dynamic> json) { + return NotePage( + items: asMapList(json['list']).map(NoteListItem.fromJson).toList(), + total: asInt(json['total']), + hasMore: json['hasMore'] == true, + ); + } +} + +/// 目录树节点 +class FolderNode { + final int id; + final String name; + final int? parentId; + + /// 该目录下的笔记数(**含所有子目录**),和后端列表接口的口径一致 + final int noteCount; + final List<FolderNode> children; + + const FolderNode({ + required this.id, + required this.name, + required this.parentId, + required this.noteCount, + required this.children, + }); + + factory FolderNode.fromJson(Map<String, dynamic> json) { + return FolderNode( + id: asInt(json['id']), + name: asNonEmpty(json['name'], '未命名'), + parentId: asNullableInt(json['parentId']), + noteCount: asInt(json['noteCount']), + children: asMapList(json['children']).map(FolderNode.fromJson).toList(), + ); + } +} + +/// 目录树接口的完整返回 +class FolderTree { + final List<FolderNode> folders; + final int uncategorizedCount; + final int totalCount; + + const FolderTree({ + required this.folders, + required this.uncategorizedCount, + required this.totalCount, + }); + + static const empty = FolderTree(folders: [], uncategorizedCount: 0, totalCount: 0); + + factory FolderTree.fromJson(Map<String, dynamic> json) { + return FolderTree( + folders: asMapList(json['folders']).map(FolderNode.fromJson).toList(), + uncategorizedCount: asInt(json['uncategorizedCount']), + totalCount: asInt(json['totalCount']), + ); + } + + /// 按 id 找节点,用于拼当前目录的面包屑路径 + List<FolderNode>? findPath(int id) { + List<FolderNode>? walk(List<FolderNode> nodes, List<FolderNode> trail) { + for (final node in nodes) { + final next = [...trail, node]; + if (node.id == id) return next; + final found = walk(node.children, next); + if (found != null) return found; + } + return null; + } + + return walk(folders, []); + } +} + +/// 当前浏览的位置 +/// +/// 用三个 scope 对应目录树上的三种节点,而不是用 `folderId == null` +/// 来表达「全部」—— 那样就没法区分「全部」和「未分类」了。 +enum NoteScope { all, uncategorized, folder } + +class NoteLocation { + final NoteScope scope; + final int? folderId; + final String folderName; + + const NoteLocation({ + required this.scope, + this.folderId, + this.folderName = '全部', + }); + + static const all = NoteLocation(scope: NoteScope.all, folderName: '全部'); + static const uncategorized = + NoteLocation(scope: NoteScope.uncategorized, folderName: '未分类'); + + /// 传给后端 list 接口的 scope 参数 + String get apiScope { + switch (scope) { + case NoteScope.all: + return 'all'; + case NoteScope.uncategorized: + return 'uncategorized'; + case NoteScope.folder: + return 'folder'; + } + } +} + +/// 后端给的是 `yyyy-MM-dd HH:mm:ss`,这里裁成 `yyyy-MM-dd HH:mm` +/// 列表里显示到秒没有意义,还会把卡片撑宽。 +String formatDateTime(Object? value) { + final text = asString(value); + return text.length >= 16 ? text.substring(0, 16) : text; +} diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index 490f1e9..57f373f 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -238,6 +238,9 @@ class _LoginPageState extends ConsumerState<LoginPage> ), ], ), + // 圆形容器默认不裁剪子组件,而方图的对角线比圆直径长 —— + // 换成不透明底的方图会从四个角戳出来,这里裁掉。 + clipBehavior: Clip.antiAlias, padding: const EdgeInsets.all(10), child: Image.asset('assets/images/logo.png', fit: BoxFit.contain), ), diff --git a/lib/pages/mine/mine_page.dart b/lib/pages/mine/mine_page.dart index bf6adb7..0d6a021 100644 --- a/lib/pages/mine/mine_page.dart +++ b/lib/pages/mine/mine_page.dart @@ -12,6 +12,7 @@ import '../../utils/amount_input_formatter.dart'; import '../../utils/amount_util.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; +import '../note/note_list_page.dart'; /// 「我的」页面 /// @@ -50,6 +51,8 @@ class MinePage extends ConsumerWidget { _accountCard(context, ref, accountAsync), const SizedBox(height: 14), _budgetCard(context, ref, budgetAsync), + const SizedBox(height: 14), + _notesEntry(context), const SizedBox(height: 24), _logoutButton(context), const SizedBox(height: 90), @@ -366,6 +369,49 @@ class MinePage extends ConsumerWidget { ); } + // ---------------- 笔记入口 ---------------- + Widget _notesEntry(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NoteListPage()), + ), + child: Container( + padding: const EdgeInsets.fromLTRB(18, 16, 14, 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + const Text('📝', style: TextStyle(fontSize: 24)), + const SizedBox(width: 14), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '我的笔记', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 3), + Text( + '管理 Markdown 文档,随时查看和记录', + style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)), + ], + ), + ), + ); + } + // ---------------- 修改余额 ---------------- Future<void> _editBalance(BuildContext context, WidgetRef ref, AccountInfo account) async { final input = await _showAmountDialog( diff --git a/lib/pages/note/folder_picker_sheet.dart b/lib/pages/note/folder_picker_sheet.dart new file mode 100644 index 0000000..2d82e85 --- /dev/null +++ b/lib/pages/note/folder_picker_sheet.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; + +import '../../models/note.dart'; + +/// 目录选择面板 +/// +/// 从底部弹出,展示「全部 / 未分类 / 目录树」三层结构。 +/// 选中后 pop 出对应的 [NoteLocation];直接关掉则返回 null。 +class FolderPickerSheet extends StatefulWidget { + const FolderPickerSheet({ + super.key, + required this.tree, + required this.current, + this.title = '选择目录', + this.showAll = true, + }); + + final FolderTree tree; + final NoteLocation current; + final String title; + + /// 是否显示「全部」这一项。 + /// 给笔记选目标目录时要关掉 —— 「全部」不是一个能存放笔记的位置。 + final bool showAll; + + /// 弹出面板的便捷方法 + static Future<NoteLocation?> show( + BuildContext context, { + required FolderTree tree, + required NoteLocation current, + String title = '选择目录', + bool showAll = true, + }) { + return showModalBottomSheet<NoteLocation>( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => FolderPickerSheet( + tree: tree, + current: current, + title: title, + showAll: showAll, + ), + ); + } + + @override + State<FolderPickerSheet> createState() => _FolderPickerSheetState(); +} + +class _FolderPickerSheetState extends State<FolderPickerSheet> { + final Set<int> _expanded = {}; + + @override + void initState() { + super.initState(); + // 默认展开当前所在目录的所有上级,用户一眼能看到自己在哪 + if (widget.current.scope == NoteScope.folder && widget.current.folderId != null) { + final path = widget.tree.findPath(widget.current.folderId!); + if (path != null) { + for (final node in path) { + _expanded.add(node.id); + } + } + } + } + + bool _isCurrent(NoteScope scope, int? folderId) { + if (widget.current.scope != scope) return false; + if (scope != NoteScope.folder) return true; + return widget.current.folderId == folderId; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0D6CC), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(12, 4, 12, 20), + children: [ + if (widget.showAll) + _row( + icon: '📚', + name: '全部', + count: widget.tree.totalCount, + depth: 0, + selected: _isCurrent(NoteScope.all, null), + onTap: () => Navigator.pop(context, NoteLocation.all), + ), + _row( + icon: '📥', + name: '未分类', + count: widget.tree.uncategorizedCount, + depth: 0, + selected: _isCurrent(NoteScope.uncategorized, null), + onTap: () => Navigator.pop(context, NoteLocation.uncategorized), + ), + if (widget.tree.folders.isNotEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(8, 12, 8, 4), + child: Divider(height: 1, color: Color(0xFFF0E6D8)), + ), + ...widget.tree.folders.map((n) => _buildNode(n, 0)), + ], + ), + ), + ], + ), + ), + ); + } + + /// 递归渲染目录节点 + Widget _buildNode(FolderNode node, int depth) { + final hasChildren = node.children.isNotEmpty; + final open = _expanded.contains(node.id); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _row( + icon: (open && hasChildren) ? '📂' : '📁', + name: node.name, + count: node.noteCount, + depth: depth, + selected: _isCurrent(NoteScope.folder, node.id), + trailing: hasChildren + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + if (open) { + _expanded.remove(node.id); + } else { + _expanded.add(node.id); + } + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: AnimatedRotation( + turns: open ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: const Icon( + Icons.chevron_right, + size: 18, + color: Color(0xFFBCAAA4), + ), + ), + ), + ) + : null, + onTap: () => Navigator.pop( + context, + NoteLocation( + scope: NoteScope.folder, + folderId: node.id, + folderName: node.name, + ), + ), + ), + if (open && hasChildren) + ...node.children.map((c) => _buildNode(c, depth + 1)), + ], + ); + } + + Widget _row({ + required String icon, + required String name, + required int count, + required int depth, + required bool selected, + required VoidCallback onTap, + Widget? trailing, + }) { + return Padding( + padding: EdgeInsets.only(left: depth * 18.0), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + color: selected ? const Color(0xFFFFF3E6) : null, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? const Color(0xFFC8956E) : const Color(0xFF5D4037), + ), + ), + ), + Text( + '$count', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ?trailing, + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart new file mode 100644 index 0000000..1cdd8d2 --- /dev/null +++ b/lib/pages/note/note_detail_page.dart @@ -0,0 +1,401 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/note_api.dart'; +import '../../core/network/api_env.dart'; +import '../../core/network/api_exception.dart'; +import '../../models/note.dart'; +import '../../providers/note_provider.dart'; +import '../../utils/toast.dart'; +import '../../widgets/state_views.dart'; +import 'folder_picker_sheet.dart'; +import 'note_edit_page.dart'; + +/// 笔记详情页(Markdown 渲染,可编辑 / 置顶 / 删除) +class NoteDetailPage extends ConsumerWidget { + const NoteDetailPage({super.key, required this.noteId}); + + final int noteId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detailAsync = ref.watch(noteDetailProvider(noteId)); + + return Scaffold( + appBar: AppBar( + title: const Text('笔记'), + actions: [ + detailAsync.maybeWhen( + data: (note) => IconButton( + tooltip: '编辑', + icon: const Icon(Icons.edit_outlined), + onPressed: () => _openEditor(context, ref, note), + ), + orElse: () => const SizedBox.shrink(), + ), + detailAsync.maybeWhen( + data: (note) => PopupMenuButton<String>( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'pin') { + _togglePin(context, ref, note); + } else if (value == 'move') { + _moveTo(context, ref, note); + } else if (value == 'delete') { + _confirmDelete(context, ref, note); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'pin', + child: Text(note.pinned ? '取消置顶' : '置顶'), + ), + const PopupMenuItem( + value: 'move', + child: Text('移动到…'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), + ), + ], + ), + orElse: () => const SizedBox.shrink(), + ), + ], + ), + body: detailAsync.when( + loading: () => const Center(child: LoadingView(tip: '笔记加载中...')), + error: (e, _) => Center( + child: ErrorView( + message: asApiException(e).message, + onRetry: () => ref.invalidate(noteDetailProvider(noteId)), + ), + ), + data: (note) => _content(context, note), + ), + ); + } + + Widget _content(BuildContext context, NoteDetail note) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + height: 1.35, + color: Color(0xFF3E2723), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + '更新于 ${note.updateTime}', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ), + ], + ), + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: note.tags.map((t) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + t, + style: const TextStyle(fontSize: 12, color: Color(0xFFC8956E)), + ), + ); + }).toList(), + ), + ], + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF0E6D8)), + const SizedBox(height: 16), + if (note.content.trim().isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 30), + child: Center( + child: Text( + '(空笔记)', + style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)), + ), + ), + ) + else + MarkdownBody( + data: note.content, + // 允许长按选中复制 + selectable: true, + styleSheet: _styleSheet(context), + onTapLink: (text, href, title) => _showLink(context, href), + imageBuilder: (uri, title, alt) => _image(uri), + ), + ], + ), + ); + } + + MarkdownStyleSheet _styleSheet(BuildContext context) { + final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); + return base.copyWith( + p: const TextStyle(fontSize: 15, height: 1.75, color: Color(0xFF3E2723)), + h1: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + height: 1.5, + color: Color(0xFF3E2723), + ), + h2: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + height: 1.5, + color: Color(0xFF3E2723), + ), + h3: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.5, + color: Color(0xFF3E2723), + ), + a: const TextStyle( + fontSize: 15, + color: Color(0xFFC8956E), + decoration: TextDecoration.underline, + ), + code: const TextStyle( + fontSize: 13.5, + fontFamily: 'monospace', + backgroundColor: Color(0xFFF5EFE8), + color: Color(0xFFB4654A), + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFFF7F3EE), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFEDE3D8)), + ), + blockquoteDecoration: const BoxDecoration( + border: Border(left: BorderSide(color: Color(0xFFC8956E), width: 3)), + ), + blockquotePadding: const EdgeInsets.fromLTRB(12, 4, 0, 4), + listBullet: const TextStyle(fontSize: 15, color: Color(0xFFC8956E)), + tableBorder: TableBorder.all(color: const Color(0xFFEDE3D8), width: 1), + tableHead: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF5D4037)), + tableCellsPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ); + } + + /// 笔记里的图片是 `/uploads/xxx` 相对路径,要补上 host 才能加载 + Widget _image(Uri uri) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + resolveAssetUrl(uri.toString()), + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFFC8956E)), + ), + ), + ); + }, + errorBuilder: (_, _, _) => Container( + padding: const EdgeInsets.symmetric(vertical: 18), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFF7F3EE), + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + '🖼 图片加载失败', + style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)), + ), + ), + ), + ), + ); + } + + /// 没有引入 url_launcher,这里给出地址让用户自己复制, + /// 比静默无反应体验好,也不用为打开链接单独加一个依赖。 + void _showLink(BuildContext context, String? href) { + if (href == null || href.isEmpty) return; + showModalBottomSheet<void>( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + const Text('链接地址', style: TextStyle(fontSize: 15, color: Color(0xFF5D4037))), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SelectableText( + resolveAssetUrl(href), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () => Navigator.pop(sheetContext), + child: const Text('关闭', style: TextStyle(color: Color(0xFF8D6E63))), + ), + FilledButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: resolveAssetUrl(href))); + Navigator.pop(sheetContext); + showToast(context, '✅ 已复制链接'); + }, + style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)), + child: const Text('复制'), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ); + } + + // ---------------- 操作 ---------------- + Future<void> _openEditor(BuildContext context, WidgetRef ref, NoteDetail note) async { + final changed = await Navigator.of(context).push<bool>( + MaterialPageRoute(builder: (_) => NoteEditPage(noteId: note.id)), + ); + if (changed == true) { + ref.invalidate(noteDetailProvider(noteId)); + await ref.read(noteListProvider.notifier).refresh(); + ref.invalidate(noteTagsProvider); + ref.invalidate(noteFolderTreeProvider); + } + } + + /// 把笔记移动到别的目录 + Future<void> _moveTo(BuildContext context, WidgetRef ref, NoteDetail note) async { + FolderTree tree; + try { + tree = await ref.read(noteFolderTreeProvider.future); + } catch (_) { + tree = FolderTree.empty; + } + if (!context.mounted) return; + + // 当前所在位置(用于面板里高亮) + final NoteLocation current; + if (note.folderId == null) { + current = NoteLocation.uncategorized; + } else { + final path = tree.findPath(note.folderId!); + current = NoteLocation( + scope: NoteScope.folder, + folderId: note.folderId, + folderName: (path != null && path.isNotEmpty) ? path.last.name : '目录', + ); + } + + final picked = await FolderPickerSheet.show( + context, + tree: tree, + current: current, + title: '移动到', + // 「全部」不是能存放笔记的位置,给笔记选目录时不该出现 + showAll: false, + ); + if (picked == null || !context.mounted) return; + + final targetId = picked.scope == NoteScope.folder ? picked.folderId : null; + if (targetId == note.folderId) return; + + try { + await ref.read(noteApiProvider).assignFolder(note.id, targetId); + ref.invalidate(noteDetailProvider(noteId)); + await ref.read(noteListProvider.notifier).refresh(); + ref.invalidate(noteFolderTreeProvider); // 两个目录的笔记数都变了 + if (context.mounted) { + showToast(context, targetId == null ? '已移到未分类' : '已移动到「${picked.folderName}」'); + } + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } + } + + Future<void> _togglePin(BuildContext context, WidgetRef ref, NoteDetail note) async { + try { + await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned); + ref.invalidate(noteDetailProvider(noteId)); + await ref.read(noteListProvider.notifier).refresh(); + if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '📌 已置顶'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } + } + + Future<void> _confirmDelete(BuildContext context, WidgetRef ref, NoteDetail note) async { + final confirmed = await showDialog<bool>( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除笔记'), + content: Text('确定删除「${note.title}」?'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await ref.read(noteApiProvider).delete(note.id); + await ref.read(noteListProvider.notifier).refresh(); + ref.invalidate(noteTagsProvider); + ref.invalidate(noteFolderTreeProvider); + if (context.mounted) { + showToast(context, '已删除'); + Navigator.of(context).pop(); + } + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } + } +} diff --git a/lib/pages/note/note_edit_page.dart b/lib/pages/note/note_edit_page.dart new file mode 100644 index 0000000..8c64abc --- /dev/null +++ b/lib/pages/note/note_edit_page.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/note_api.dart'; +import '../../core/network/api_exception.dart'; +import '../../utils/toast.dart'; +import '../../widgets/state_views.dart'; + +/// 笔记编辑页 +/// +/// [noteId] 为 null 时是新建,否则是编辑已有笔记。 +/// 保存成功会 pop(true),上一页据此刷新数据。 +/// +/// 这里用 StatefulWidget + TextEditingController 管理表单, +/// 而不是像账单编辑页那样走 Notifier —— 后者需要额外的 revision +/// 字段来同步受控输入框,对纯文本编辑没有必要。 +class NoteEditPage extends ConsumerStatefulWidget { + const NoteEditPage({super.key, this.noteId}); + + final int? noteId; + + bool get isCreate => noteId == null; + + @override + ConsumerState<NoteEditPage> createState() => _NoteEditPageState(); +} + +class _NoteEditPageState extends ConsumerState<NoteEditPage> { + final _titleController = TextEditingController(); + final _tagsController = TextEditingController(); + final _contentController = TextEditingController(); + + bool _loading = false; + bool _saving = false; + bool _preview = false; + String? _loadError; + + /// 是否已经成功保存过——用于「返回时提示未保存」的判断 + bool _saved = false; + + @override + void initState() { + super.initState(); + if (!widget.isCreate) _load(); + } + + @override + void dispose() { + _titleController.dispose(); + _tagsController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + Future<void> _load() async { + setState(() { + _loading = true; + _loadError = null; + }); + try { + final note = await ref.read(noteApiProvider).detail(widget.noteId!); + if (!mounted) return; + _titleController.text = note.title; + _tagsController.text = note.tags.join(','); + _contentController.text = note.content; + setState(() => _loading = false); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _loadError = e.message; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _loadError = '笔记加载失败'; + }); + } + } + + bool get _dirty => + !_saved && + (_titleController.text.isNotEmpty || + _tagsController.text.isNotEmpty || + _contentController.text.isNotEmpty); + + Future<void> _save() async { + final title = _titleController.text.trim(); + final tags = _tagsController.text.trim(); + final content = _contentController.text; + + if (title.isEmpty && content.trim().isEmpty) { + showToast(context, '标题和内容不能同时为空', error: true); + return; + } + + setState(() => _saving = true); + try { + final api = ref.read(noteApiProvider); + if (widget.isCreate) { + await api.create(title: title, content: content, tags: tags); + } else { + await api.update(widget.noteId!, title: title, content: content, tags: tags); + } + if (!mounted) return; + _saved = true; + showToast(context, '✅ 已保存'); + Navigator.of(context).pop(true); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (_) { + if (mounted) showToast(context, '保存失败,请重试', error: true); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future<void> _confirmDiscard() async { + final leave = await showDialog<bool>( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('放弃编辑?'), + content: const Text('当前改动还没有保存,返回将丢失。'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('继续编辑', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('放弃', style: TextStyle(color: Color(0xFFD32F2F))), + ), + ], + ), + ); + if (leave == true && mounted) Navigator.of(context).pop(false); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !_dirty, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _confirmDiscard(); + }, + child: Scaffold( + appBar: AppBar( + title: Text(widget.isCreate ? '新建笔记' : '编辑笔记'), + actions: [ + IconButton( + tooltip: _preview ? '继续编辑' : '预览', + icon: Icon(_preview ? Icons.edit_outlined : Icons.visibility_outlined), + onPressed: _loading ? null : () => setState(() => _preview = !_preview), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton( + onPressed: (_saving || _loading) ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFFC8956E), + ), + ) + : const Text( + '保存', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFFC8956E), + ), + ), + ), + ), + ], + ), + body: _body(), + ), + ); + } + + Widget _body() { + if (_loading) return const Center(child: LoadingView(tip: '笔记加载中...')); + + if (_loadError != null) { + return Center( + child: ErrorView(message: _loadError!, onRetry: _load), + ); + } + + if (_preview) return _previewBody(); + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + children: [ + TextField( + controller: _titleController, + style: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.bold, + color: Color(0xFF3E2723), + ), + decoration: const InputDecoration( + hintText: '标题(留空则自动取正文首行)', + hintStyle: TextStyle(fontSize: 17, color: Color(0xFFD7CCC8)), + border: InputBorder.none, + ), + ), + const Divider(height: 20, color: Color(0xFFF0E6D8)), + TextField( + controller: _tagsController, + style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)), + decoration: const InputDecoration( + hintText: '标签,用逗号分隔,例如:工作,服务器', + hintStyle: TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)), + prefixIcon: Icon(Icons.label_outline, size: 18, color: Color(0xFFBCAAA4)), + prefixIconConstraints: BoxConstraints(minWidth: 34), + border: InputBorder.none, + ), + ), + const Divider(height: 20, color: Color(0xFFF0E6D8)), + TextField( + controller: _contentController, + maxLines: null, + minLines: 16, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 15, height: 1.7, color: Color(0xFF3E2723)), + decoration: const InputDecoration( + hintText: '正文,支持 Markdown…', + hintStyle: TextStyle(fontSize: 15, color: Color(0xFFD7CCC8)), + border: InputBorder.none, + ), + ), + ], + ); + } + + Widget _previewBody() { + final content = _contentController.text; + if (content.trim().isEmpty) { + return const Center( + child: EmptyView(icon: '📄', title: '还没有内容', tip: '切回编辑模式写点什么吧'), + ); + } + + final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), + child: MarkdownBody( + data: content, + selectable: true, + styleSheet: base.copyWith( + p: const TextStyle(fontSize: 15, height: 1.75, color: Color(0xFF3E2723)), + h1: const TextStyle(fontSize: 21, fontWeight: FontWeight.bold, color: Color(0xFF3E2723)), + h2: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF3E2723)), + h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF3E2723)), + code: const TextStyle( + fontSize: 13.5, + fontFamily: 'monospace', + backgroundColor: Color(0xFFF5EFE8), + color: Color(0xFFB4654A), + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFFF7F3EE), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFEDE3D8)), + ), + ), + ), + ); + } +} diff --git a/lib/pages/note/note_list_page.dart b/lib/pages/note/note_list_page.dart new file mode 100644 index 0000000..737a188 --- /dev/null +++ b/lib/pages/note/note_list_page.dart @@ -0,0 +1,588 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/note_api.dart'; +import '../../core/network/api_exception.dart'; +import '../../models/note.dart'; +import '../../providers/note_provider.dart'; +import '../../utils/toast.dart'; +import '../../widgets/state_views.dart'; +import 'folder_picker_sheet.dart'; +import 'note_detail_page.dart'; + +/// 笔记列表页 +/// +/// 列表数据在后端是分页返回的,这里用滚动到底部自动加载下一页, +/// 配合下拉刷新与 350ms 防抖的搜索框。 +class NoteListPage extends ConsumerStatefulWidget { + const NoteListPage({super.key}); + + @override + ConsumerState<NoteListPage> createState() => _NoteListPageState(); +} + +class _NoteListPageState extends ConsumerState<NoteListPage> { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + bool _searching = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + /// 距底部还有 300px 就开始加载,避免用户看到明显的"卡一下" + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 300) { + ref.read(noteListProvider.notifier).loadMore(); + } + } + + void _toggleSearch() { + setState(() { + _searching = !_searching; + if (!_searching) { + _searchController.clear(); + ref.read(noteListProvider.notifier).setKeyword(''); + } + }); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(noteListProvider); + // Riverpod 3 的 AsyncValue 只有 value(可空),没有 valueOrNull + final tree = ref.watch(noteFolderTreeProvider).value; + + return Scaffold( + appBar: AppBar( + title: _searching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: '搜索标题和正文…', + border: InputBorder.none, + hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBCAAA4)), + ), + style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)), + onChanged: (v) => ref.read(noteListProvider.notifier).setKeyword(v), + ) + : const Text('我的笔记'), + actions: [ + IconButton( + tooltip: '排序', + icon: const Icon(Icons.sort), + onPressed: _pickSort, + ), + IconButton( + tooltip: _searching ? '关闭搜索' : '搜索', + icon: Icon(_searching ? Icons.close : Icons.search), + onPressed: _toggleSearch, + ), + ], + ), + body: Column( + children: [ + _folderBar(state, tree), + _tagBar(), + Expanded(child: _body(state)), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _quickCapture, + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + icon: const Icon(Icons.edit_outlined), + label: const Text('快速记录'), + ), + ); + } + + // ---------------- 当前目录条 ---------------- + Widget _folderBar(NoteListState state, FolderTree? tree) { + return Material( + color: Colors.white, + child: InkWell( + onTap: _pickLocation, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 11, 12, 11), + child: Row( + children: [ + const Icon(Icons.folder_outlined, size: 18, color: Color(0xFFC8956E)), + const SizedBox(width: 8), + Expanded( + child: Text( + _pathText(state.location, tree), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + Text( + '${state.total} 篇', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + const Icon(Icons.expand_more, size: 18, color: Color(0xFFBCAAA4)), + ], + ), + ), + ), + ); + } + + /// 拼出「全部 › 工作 › 服务器」这样的路径 + String _pathText(NoteLocation location, FolderTree? tree) { + if (location.scope == NoteScope.all) return '全部'; + if (location.scope == NoteScope.uncategorized) return '全部 › 未分类'; + + final path = tree?.findPath(location.folderId!); + if (path == null || path.isEmpty) return '全部 › ${location.folderName}'; + return ['全部', ...path.map((e) => e.name)].join(' › '); + } + + Future<void> _pickLocation() async { + FolderTree tree; + try { + tree = await ref.read(noteFolderTreeProvider.future); + } catch (_) { + tree = FolderTree.empty; + } + if (!mounted) return; + + final current = ref.read(noteListProvider).location; + final picked = await FolderPickerSheet.show(context, tree: tree, current: current); + if (picked == null) return; + + ref.read(noteListProvider.notifier).setLocation(picked); + } + + // ---------------- 排序 ---------------- + Future<void> _pickSort() async { + final current = ref.read(noteListProvider).sort; + + final picked = await showModalBottomSheet<String>( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + const Text( + '排序方式', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 6), + _sortOption(sheetContext, 'updated', '最近更新', current), + _sortOption(sheetContext, 'created', '创建时间', current), + _sortOption(sheetContext, 'title', '标题', current), + const SizedBox(height: 16), + ], + ), + ), + ); + + if (picked == null) return; + ref.read(noteListProvider.notifier).setSort(picked); + } + + Widget _sortOption(BuildContext sheetContext, String value, String label, String current) { + final selected = value == current; + return ListTile( + dense: true, + leading: Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + size: 20, + color: selected ? const Color(0xFFC8956E) : const Color(0xFFBCAAA4), + ), + title: Text( + label, + style: TextStyle( + fontSize: 14.5, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? const Color(0xFFC8956E) : const Color(0xFF5D4037), + ), + ), + onTap: () => Navigator.pop(sheetContext, value), + ); + } + + // ---------------- 标签筛选栏 ---------------- + Widget _tagBar() { + final tagsAsync = ref.watch(noteTagsProvider); + final selected = ref.watch(noteListProvider).tag; + + return tagsAsync.maybeWhen( + // 标签加载失败或为空时不占位,避免空出一条白带 + data: (tags) { + if (tags.isEmpty) return const SizedBox.shrink(); + return Container( + height: 46, + color: Colors.white, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + children: [ + _tagChip('全部', selected.isEmpty, () { + ref.read(noteListProvider.notifier).setTag(''); + }), + ...tags.map((t) => _tagChip(t, selected == t, () { + ref.read(noteListProvider.notifier).setTag(t); + })), + ], + ), + ); + }, + orElse: () => const SizedBox.shrink(), + ); + } + + Widget _tagChip(String label, bool active, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: active ? const Color(0xFFC8956E) : const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: active ? Colors.white : const Color(0xFF8D6E63), + fontWeight: active ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ), + ); + } + + // ---------------- 主体 ---------------- + Widget _body(NoteListState state) { + if (state.loading && state.items.isEmpty) { + return const Center(child: LoadingView(tip: '笔记加载中...')); + } + + if (state.error != null && state.items.isEmpty) { + return Center( + child: ErrorView( + message: state.error!, + onRetry: () => ref.read(noteListProvider.notifier).refresh(), + ), + ); + } + + if (state.isEmpty) { + return RefreshIndicator( + color: const Color(0xFFC8956E), + onRefresh: () => ref.read(noteListProvider.notifier).refresh(), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.18), + EmptyView( + icon: state.hasFilter ? '🔍' : '📝', + title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记', + tip: state.hasFilter + ? '换个关键词或标签试试' + : '点右下角「快速记录」,\n或在电脑上打开后台管理批量导入', + ), + ], + ), + ); + } + + return RefreshIndicator( + color: const Color(0xFFC8956E), + onRefresh: () => ref.read(noteListProvider.notifier).refresh(), + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 90), + itemCount: state.items.length + 1, + itemBuilder: (context, index) { + if (index == state.items.length) return _footer(state); + return _noteCard(state.items[index]); + }, + ), + ); + } + + Widget _footer(NoteListState state) { + if (state.loadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFFC8956E)), + ), + ), + ); + } + if (!state.hasMore) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Center( + child: Text( + '共 ${state.total} 篇', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ), + ); + } + return const SizedBox(height: 18); + } + + Widget _noteCard(NoteListItem note) { + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => NoteDetailPage(noteId: note.id)), + ), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: note.pinned + ? Border.all(color: const Color(0xFFC8956E), width: 1.2) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (note.pinned) ...[ + const Text('📌', style: TextStyle(fontSize: 13)), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + note.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15.5, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + ], + ), + if (note.summary.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + note.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, height: 1.5, color: Color(0xFF8D6E63)), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + ...note.tags.take(3).map((t) => _miniTag(t)), + if (note.tags.length > 3) + Text( + '+${note.tags.length - 3}', + style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), + ), + const Spacer(), + Text( + note.updateTime, + style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _miniTag(String tag) { + return Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(6), + ), + child: Text(tag, style: const TextStyle(fontSize: 11, color: Color(0xFFC8956E))), + ); + } + + // ---------------- 快速记录 ---------------- + Future<void> _quickCapture() async { + final location = ref.read(noteListProvider).location; + final targetLabel = + location.scope == NoteScope.folder ? location.folderName : '未分类'; + + final content = await showModalBottomSheet<String>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _QuickCaptureSheet(targetLabel: targetLabel), + ); + if (content == null || content.trim().isEmpty) return; + + try { + final api = ref.read(noteApiProvider); + // 标题传空串,由后端取正文首行 —— 手机上多填一个标题会打断记录节奏 + final created = await api.create(title: '', content: content, tags: ''); + + // 记到当前所在目录里;停在「全部」或「未分类」时保持未分类 + final location = ref.read(noteListProvider).location; + if (location.scope == NoteScope.folder && location.folderId != null) { + await api.assignFolder(created.id, location.folderId); + } + + await ref.read(noteListProvider.notifier).refresh(); + ref.invalidate(noteTagsProvider); + ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了 + if (mounted) showToast(context, '✅ 已记录'); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (_) { + if (mounted) showToast(context, '保存失败,请重试', error: true); + } + } +} + +/// 快速记录面板:全屏高度的纯文本输入,不填标题 +class _QuickCaptureSheet extends StatefulWidget { + const _QuickCaptureSheet({required this.targetLabel}); + + /// 保存到哪个目录,仅用于提示文案 + final String targetLabel; + + @override + State<_QuickCaptureSheet> createState() => _QuickCaptureSheetState(); +} + +class _QuickCaptureSheetState extends State<_QuickCaptureSheet> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0D6CC), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 12, 4), + child: Row( + children: [ + const Text( + '快速记录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _controller.text), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: const Text('保存'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '第一行会作为标题,支持 Markdown,保存到「${widget.targetLabel}」', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 16), + child: TextField( + controller: _controller, + autofocus: true, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + style: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF3E2723)), + decoration: const InputDecoration( + hintText: '想到什么就写下来…', + border: InputBorder.none, + hintStyle: TextStyle(color: Color(0xFFD7CCC8)), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/providers/note_provider.dart b/lib/providers/note_provider.dart new file mode 100644 index 0000000..8e60190 --- /dev/null +++ b/lib/providers/note_provider.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/note_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/note.dart'; + +/// 笔记列表页状态 +class NoteListState { + final List<NoteListItem> items; + final bool loading; // 首屏 / 刷新 / 切筛选条件 + final bool loadingMore; // 上拉加载下一页 + final String? error; + final int page; // 已加载到第几页,0 表示还没加载 + final bool hasMore; + final int total; + final String keyword; + final String tag; + final String sort; // updated | created | title + final NoteLocation location; + + const NoteListState({ + this.items = const [], + this.loading = false, + this.loadingMore = false, + this.error, + this.page = 0, + this.hasMore = false, + this.total = 0, + this.keyword = '', + this.tag = '', + this.sort = 'updated', + this.location = NoteLocation.all, + }); + + /// 真正的一条都没有(不是加载中、也不是出错) + bool get isEmpty => items.isEmpty && !loading && error == null; + + /// 是否处于筛选态——决定空状态该显示"还没有笔记"还是"没有匹配结果" + bool get hasFilter => keyword.isNotEmpty || tag.isNotEmpty; + + NoteListState copyWith({ + List<NoteListItem>? items, + bool? loading, + bool? loadingMore, + Object? error = _sentinel, + int? page, + bool? hasMore, + int? total, + String? keyword, + String? tag, + String? sort, + NoteLocation? location, + }) { + return NoteListState( + items: items ?? this.items, + loading: loading ?? this.loading, + loadingMore: loadingMore ?? this.loadingMore, + error: error == _sentinel ? this.error : error as String?, + page: page ?? this.page, + hasMore: hasMore ?? this.hasMore, + total: total ?? this.total, + keyword: keyword ?? this.keyword, + tag: tag ?? this.tag, + sort: sort ?? this.sort, + location: location ?? this.location, + ); + } + + static const _sentinel = Object(); +} + +class NoteListNotifier extends Notifier<NoteListState> { + static const _pageSize = 20; + + Timer? _searchDebounce; + + @override + NoteListState build() { + ref.onDispose(() => _searchDebounce?.cancel()); + // build() 里不能同步读写 state,所以丢到微任务里再发起首次加载 + Future.microtask(() => _fetchFirstPage( + keyword: '', + tag: '', + sort: 'updated', + location: NoteLocation.all, + )); + return const NoteListState(loading: true); + } + + /// 取第一页。参数由调用方传入而不是读 state, + /// 是为了能在 build() 阶段安全调用。 + Future<void> _fetchFirstPage({ + required String keyword, + required String tag, + required String sort, + required NoteLocation location, + }) async { + try { + final result = await ref.read(noteApiProvider).list( + page: 1, + size: _pageSize, + scope: location.apiScope, + folderId: location.folderId, + sort: sort, + keyword: keyword, + tag: tag, + ); + if (!ref.mounted) return; + state = state.copyWith( + items: result.items, + loading: false, + error: null, + page: 1, + hasMore: result.hasMore, + total: result.total, + ); + } on ApiException catch (e) { + if (!ref.mounted) return; + state = state.copyWith(loading: false, error: e.message); + } catch (_) { + if (!ref.mounted) return; + state = state.copyWith(loading: false, error: '笔记加载失败'); + } + } + + /// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。 + Future<void> refresh() async { + state = state.copyWith(loading: true, error: null); + await _fetchFirstPage( + keyword: state.keyword, + tag: state.tag, + sort: state.sort, + location: state.location, + ); + } + + /// 上拉加载下一页 + Future<void> loadMore() async { + if (state.loadingMore || !state.hasMore || state.loading) return; + + state = state.copyWith(loadingMore: true); + try { + final next = state.page + 1; + final result = await ref.read(noteApiProvider).list( + page: next, + size: _pageSize, + scope: state.location.apiScope, + folderId: state.location.folderId, + sort: state.sort, + keyword: state.keyword, + tag: state.tag, + ); + if (!ref.mounted) return; + state = state.copyWith( + // 用 id 去重:加载过程中如果有笔记被置顶/更新, + // 它可能同时出现在两页里,直接拼接会出现重复卡片 + items: _mergeUnique(state.items, result.items), + loadingMore: false, + page: next, + hasMore: result.hasMore, + total: result.total, + ); + } catch (_) { + if (!ref.mounted) return; + state = state.copyWith(loadingMore: false); + } + } + + List<NoteListItem> _mergeUnique(List<NoteListItem> old, List<NoteListItem> added) { + final seen = old.map((e) => e.id).toSet(); + return [...old, ...added.where((e) => !seen.contains(e.id))]; + } + + /// 搜索关键词。带防抖,避免每敲一个字都打一次接口。 + void setKeyword(String keyword) { + if (keyword == state.keyword) return; + state = state.copyWith(keyword: keyword); + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 350), () { + if (!ref.mounted) return; + refresh(); + }); + } + + /// 标签筛选 + void setTag(String tag) { + if (tag == state.tag) return; + state = state.copyWith(tag: tag); + refresh(); + } + + /// 切换浏览的目录 + void setLocation(NoteLocation location) { + if (location.scope == state.location.scope && + location.folderId == state.location.folderId) { + return; + } + state = state.copyWith(location: location); + refresh(); + } + + /// 切换排序方式 + void setSort(String sort) { + if (sort == state.sort) return; + state = state.copyWith(sort: sort); + refresh(); + } +} + +/// 列表用普通 NotifierProvider(不加 autoDispose): +/// 从详情页返回时搜索关键词、标签筛选和所在目录都要还在,不能被回收重建。 +final noteListProvider = + NotifierProvider<NoteListNotifier, NoteListState>(NoteListNotifier.new); + +/// 笔记详情 +final noteDetailProvider = + FutureProvider.autoDispose.family<NoteDetail, int>((ref, id) { + return ref.watch(noteApiProvider).detail(id); +}); + +/// 标签列表 +final noteTagsProvider = FutureProvider.autoDispose<List<String>>((ref) { + return ref.watch(noteApiProvider).tags(); +}); + +/// 目录树 +final noteFolderTreeProvider = FutureProvider.autoDispose<FolderTree>((ref) { + return ref.watch(noteApiProvider).folderTree(); +}); diff --git a/pubspec.lock b/pubspec.lock index 18ee1bc..7bcda96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -214,6 +214,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.12" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -384,6 +392,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.1" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 38d6dd9..442c5d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: shared_preferences: ^2.3.0 intl: ^0.20.0 fl_chart: ^1.2.0 + flutter_markdown_plus: ^1.0.12 dev_dependencies: flutter_test: diff --git a/tools/_preview.png b/tools/_preview.png new file mode 100644 index 0000000..7c2c4f4 Binary files /dev/null and b/tools/_preview.png differ diff --git a/tools/make_app_icon.py b/tools/make_app_icon.py new file mode 100644 index 0000000..7b6cd67 --- /dev/null +++ b/tools/make_app_icon.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +""" +生成 App 图标资源(Android / iOS / Web 通用)。 + +背景: + 品牌原图 AI-accounting-soft-uniApp/static/images/logo.png 是「熊头+3只狗+5个大字」 + 的贴纸海报,带虚化底纹,不适合直接当图标(缩小后文字糊成一团、背景去不干净)。 + 因此改用同画风的专用图标素材(熊头单主体),由本脚本完成: + 1. 去除素材右下角的 AI 生成水印(用上方像素向下延展填充,适配平滑渐变底) + 2. 居中裁切、按比例缩放 + 3. 输出两种产物 + +产物: + assets/icon/app_icon.png 1024x1024,无透明通道 → iOS / Android 传统图标 / Web + assets/icon/app_icon_fg.png 1024x1024,带透明通道 → Android 自适应图标前景层 + assets/images/logo.png 512x512,带透明通道 → App 内品牌标识(登录页圆形 Logo) + + 登录页的 Logo 用透明底:外层是白色圆形,放进不透明的方图会露出四角(方图 + 对角线比圆直径长),看起来像贴了张方照片。 + +用法: + python tools/make_app_icon.py # 生成正式产物 + python tools/make_app_icon.py --preview # 只输出效果预览图 +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_SRC = ROOT / "assets" / "icon" / "_source" / "raw_bear.png" +OUT_DIR = ROOT / "assets" / "icon" +LOGO_OUT = ROOT / "assets" / "images" / "logo.png" + +CANVAS = 1024 +BG_COLOR = (255, 246, 236) # 暖奶油色,与品牌焦糖色 #C8956E 同色系 +ICON_FILL = 0.74 # 正式图标里主体的占比 +FG_FILL = 0.60 # 自适应图标前景层占比(Android 安全区为画布的 66%) +LOGO_CANVAS = 512 # 登录页最大显示 120pt,512 够 4x 屏 +LOGO_FILL = 0.86 # 品牌标识的占比,比自适应前景层饱满一些 +BG_THRESH = 70 # 判定「这是背景」的色差阈值(各通道绝对差之和) + +# 水印区域(右下角),留够边距避免碰到主体 +WM_BOX = (850, 912, 1024, 1024) + + +def remove_watermark(im: Image.Image, box: tuple[int, int, int, int]) -> Image.Image: + """把 box 区域用其上方一行的像素向下延展填充,抹掉水印。""" + x0, y0, x1, y1 = box + row = im.crop((x0, y0 - 1, x1, y0)).resize((x1 - x0, y1 - y0), Image.NEAREST) + out = im.copy() + out.paste(row, (x0, y0)) + return out + + +def background_mask(im: Image.Image, thresh: int) -> np.ndarray: + """返回「属于背景」的布尔矩阵:与四角平均色的色差小于阈值。""" + arr = np.asarray(im).astype(np.int16) + h, w, _ = arr.shape + corners = np.stack([ + arr[0, 0], arr[0, w - 1], arr[h - 1, 0], arr[h - 1, w - 1], + arr[2, 2], arr[2, w - 3], arr[h - 3, 2], arr[h - 3, w - 3], + ]).astype(np.int16) + seed = corners.mean(axis=0) + diff = np.abs(arr - seed).sum(axis=2) + return diff <= thresh + + +def to_foreground(im: Image.Image, mask: np.ndarray) -> Image.Image: + """按背景掩码抠出主体,返回带 alpha 的图。""" + rgba = im.convert("RGBA") + a = np.asarray(rgba).copy() + a[..., 3] = np.where(mask, 0, 255) + out = Image.fromarray(a, "RGBA") + bbox = out.getbbox() + return out.crop(bbox) if bbox else out + + +def fit(art: Image.Image, size: int, fill: float, bg: tuple[int, int, int, int] | None) -> Image.Image: + """等比缩放 art 至画布 size 的 fill 比例,居中合成。bg=None 表示透明底。""" + box = int(size * fill) + scale = min(box / art.width, box / art.height) + art = art.convert("RGBA").resize( + (max(1, round(art.width * scale)), max(1, round(art.height * scale))), + Image.LANCZOS, + ) + canvas = Image.new("RGBA", (size, size), bg if bg else (0, 0, 0, 0)) + canvas.alpha_composite(art, ((size - art.width) // 2, (size - art.height) // 2)) + return canvas + + +def build(src: Path): + im = Image.open(src).convert("RGB") + print(f"source: {src.name} {im.size}") + + cleaned = remove_watermark(im, WM_BOX) + mask = background_mask(cleaned, BG_THRESH) + print(f"background pixels: {mask.mean():.1%}") + + solid_subject = Image.fromarray( + np.where(mask[..., None], np.array(BG_COLOR, np.uint8), np.asarray(cleaned)).astype(np.uint8), + "RGB", + ) + bbox = background_mask(solid_subject, BG_THRESH) + ys, xs = np.nonzero(~bbox) + subject = solid_subject.crop((xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)) + fg_subject = to_foreground(cleaned, mask) + print(f"subject bbox: {subject.size} fg bbox: {fg_subject.size}") + + icon = fit(subject, CANVAS, ICON_FILL, (*BG_COLOR, 255)).convert("RGB") + fg = fit(fg_subject, CANVAS, FG_FILL, None) + logo = fit(fg_subject, LOGO_CANVAS, LOGO_FILL, None) + return icon, fg, logo + + +def checkerboard(size: int, square: int = 16) -> Image.Image: + board = Image.new("RGB", (size, size), (255, 255, 255)) + d = ImageDraw.Draw(board) + for y in range(0, size, square): + for x in range(0, size, square): + if (x // square + y // square) % 2: + d.rectangle([x, y, x + square - 1, y + square - 1], fill=(205, 205, 205)) + return board + + +def preview(icon: Image.Image, fg: Image.Image) -> None: + cell = 240 + sheet = Image.new("RGB", (cell * 4 + 50, cell + 50), (235, 235, 235)) + + sheet.paste(icon.resize((cell, cell), Image.LANCZOS), (10, 30)) + + tiny = icon.resize((48, 48), Image.LANCZOS).resize((cell, cell), Image.NEAREST) + sheet.paste(tiny, (cell + 20, 30)) + + adaptive = Image.new("RGB", (CANVAS, CANVAS), BG_COLOR) + adaptive.paste(fg, (0, 0), fg) + circ = Image.new("L", (CANVAS, CANVAS), 0) + ImageDraw.Draw(circ).ellipse([60, 60, CANVAS - 60, CANVAS - 60], fill=255) + circle_bg = Image.new("RGB", (CANVAS, CANVAS), (255, 255, 255)) + circle_bg.paste(adaptive, (0, 0), circ) + sheet.paste(circle_bg.resize((cell, cell), Image.LANCZOS), (cell * 2 + 30, 30)) + + board = checkerboard(cell) + board.paste(fg.resize((cell, cell), Image.LANCZOS), (0, 0), fg.resize((cell, cell), Image.LANCZOS)) + sheet.paste(board, (cell * 3 + 40, 30)) + + d = ImageDraw.Draw(sheet) + for i, label in enumerate(["1: icon 1024", "2: at 48px", "3: adaptive(circle)", "4: fg alpha"]): + d.text((10 + (cell + 10) * i, 10), label, fill=(0, 0, 0)) + + out = ROOT / "tools" / "_preview.png" + sheet.save(out) + print("preview ->", out) + + +CHECK_TARGETS = [ + "assets/icon/app_icon.png", + "assets/icon/app_icon_fg.png", + "assets/images/logo.png", + "ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png", + "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png", + "android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png", + "web/icons/Icon-512.png", +] + + +def check() -> None: + """核对已生成的图标:iOS 的 1024 图必须是 RGB(无 alpha),否则上架报 ITMS-90717。""" + for rel in CHECK_TARGETS: + p = ROOT / rel + if not p.exists(): + print(f"MISSING {rel}") + continue + with Image.open(p) as im: + flag = "" + if "ios" in rel and im.mode != "RGB": + flag = " <-- 必须无 alpha!" + print(f"{im.mode:<6} {str(im.size):<12} {rel}{flag}") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--src", type=Path, default=DEFAULT_SRC) + ap.add_argument("--preview", action="store_true", help="只输出效果预览图") + ap.add_argument("--check", action="store_true", help="核对已生成的图标产物") + args = ap.parse_args() + + if args.check: + check() + return + + icon, fg, logo = build(args.src) + if args.preview: + preview(icon, fg) + return + + OUT_DIR.mkdir(parents=True, exist_ok=True) + icon.save(OUT_DIR / "app_icon.png", "PNG", optimize=True) + fg.save(OUT_DIR / "app_icon_fg.png", "PNG", optimize=True) + LOGO_OUT.parent.mkdir(parents=True, exist_ok=True) + logo.save(LOGO_OUT, "PNG", optimize=True) + print("->", OUT_DIR / "app_icon.png", icon.size) + print("->", OUT_DIR / "app_icon_fg.png", fg.size) + print("->", LOGO_OUT, logo.size) + + +if __name__ == "__main__": + main()