Compare commits
13 Commits
b971900263
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a3f0f45c | ||
|
|
e8f58212e6 | ||
|
|
b8f1f82ee5 | ||
|
|
0995a4bf79 | ||
|
|
21b7201590 | ||
|
|
bb9f1aa956 | ||
|
|
8d5059add1 | ||
|
|
f77dd8e9ef | ||
|
|
7ae26b3368 | ||
|
|
8744e2c0b7 | ||
|
|
db10d1fcd2 | ||
|
|
2eb2299709 | ||
|
|
2354e92c64 |
205
Doc/emoji_sticker_attachment_architecture.md
Normal file
205
Doc/emoji_sticker_attachment_architecture.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 表情 / 贴纸 / 附件面板架构设计文档
|
||||
|
||||
> Gitea Issues: #51–#56
|
||||
> Commit: `bb9f1aa`
|
||||
> 参考:iOS `StickerMessageBubble`, `AttachmentPanelView`, `EmojiKeyboardView`
|
||||
|
||||
---
|
||||
|
||||
## 一、功能范围
|
||||
|
||||
| Issue | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| #51 | StickerMessageBubble(typ=5,无边框 120pt CDN 图) | ✅ |
|
||||
| #52 | AttachmentPanelSheet(6 格面板) | ✅ |
|
||||
| #53 | 拍照发送(image_picker.camera → SendImageUseCase) | ✅ |
|
||||
| #54 | SendVideoUseCase(video picker → CDN 上传 → typ=4 发送) | ✅ |
|
||||
| #55 | 文件发送(file_picker 占位,SnackBar 提示) | ⏳ 待接入 |
|
||||
| #56 | EmojiPanel(4 分类 ~256 emoji,光标插入,⌫ 退格) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 二、架构概览
|
||||
|
||||
```
|
||||
ChatDetailPage
|
||||
├── _InputBar
|
||||
│ ├── [😊] EmojiPanel.show() ← 全屏 DraggableScrollableSheet
|
||||
│ └── [📎] AttachmentPanelSheet.show() ← BottomSheet Future<AttachmentOption?>
|
||||
│
|
||||
├── _buildBubble(typ)
|
||||
│ └── case 5 → StickerMessageBubble
|
||||
│
|
||||
└── State handlers
|
||||
├── _showEmojiPanel() → EmojiPanel (插入 / 退格)
|
||||
├── _showAttachmentPanel() → dispatch AttachmentOption
|
||||
├── _pickFromCamera() → image_picker → SendImageUseCase
|
||||
└── _pickVideo() → image_picker → SendVideoUseCase
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、组件详解
|
||||
|
||||
### 3.1 StickerMessageBubble(typ=5)
|
||||
|
||||
**文件**:`features/chat/view/widgets/sticker_message_bubble.dart`
|
||||
|
||||
```dart
|
||||
class StickerMessageBubble extends StatelessWidget {
|
||||
final String rawContent; // {"url":"sticker/xxx.png","width":120,"height":120}
|
||||
static const double _maxSize = 120.0;
|
||||
}
|
||||
```
|
||||
|
||||
- `rawContent` JSON 解析:`url` → `CdnUrlResolver.resolve(url)` → CDN 完整 URL
|
||||
- 无气泡背景(`BoxDecoration` 不设置颜色)
|
||||
- 固定 120×120pt `SizedBox`,`Image.network` + `BoxFit.contain`
|
||||
- `ClipRRect(borderRadius: 8)` 防止圆角溢出
|
||||
|
||||
**CDN 路径规则**(CdnUrlResolver):
|
||||
|
||||
| 前缀 | 解析结果 |
|
||||
|------|---------|
|
||||
| `sticker/` | `AppConfig.apiBaseUrl/sticker/...` |
|
||||
| `Image/` | `AppConfig.apiBaseUrl/Image/...` |
|
||||
| `http(s)://` | 原样透传 |
|
||||
|
||||
### 3.2 AttachmentPanelSheet(#52)
|
||||
|
||||
**文件**:`features/chat/view/widgets/attachment_panel_sheet.dart`
|
||||
|
||||
```dart
|
||||
enum AttachmentOption { camera, gallery, video, file, voice, redEnvelope }
|
||||
|
||||
class AttachmentPanelSheet extends StatelessWidget {
|
||||
static Future<AttachmentOption?> show(BuildContext context) {
|
||||
return showModalBottomSheet<AttachmentOption>(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `GridView.count(crossAxisCount: 3)`,2 行 6 格
|
||||
- 每格:56pt 着色圆形图标 + 标签文字
|
||||
- 颜色方案:
|
||||
|
||||
| 选项 | 色号 |
|
||||
|------|------|
|
||||
| 拍照 | #5667FF |
|
||||
| 相册 | #0BB8A9 |
|
||||
| 视频 | #FF5FA2 |
|
||||
| 文件 | #FF8B5E |
|
||||
| 录音 | #8A5CF6 |
|
||||
| 红包 | #E8600A |
|
||||
|
||||
- 返回 `Future<AttachmentOption?>` — 父页面负责所有业务逻辑,面板本身无状态
|
||||
|
||||
### 3.3 EmojiPanel(#56)
|
||||
|
||||
**文件**:`features/chat/view/widgets/emoji_panel.dart`
|
||||
|
||||
```dart
|
||||
class EmojiPanel extends StatefulWidget {
|
||||
static Future<void> show(BuildContext context, {
|
||||
required void Function(String) onEmojiSelected,
|
||||
required VoidCallback onBackspace,
|
||||
}) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- `DraggableScrollableSheet` 从底部展开,高度 40% 屏高
|
||||
- 4 个分类 Tab:😊 常用(80)/ 👤 人物(54)/ 🌿 自然(72)/ 🎯 物品(77)
|
||||
- `GridView.count(crossAxisCount: 8)` 展示 emoji
|
||||
- 退格键(⌫):`text.runes.toList()` 正确处理多码点 emoji(如 👨👩👧)
|
||||
- 光标插入:`TextEditingController.selection.baseOffset.clamp(0, text.length)`
|
||||
|
||||
### 3.4 SendVideoUseCase(#54)
|
||||
|
||||
**文件**:`features/chat/usecases/send_video_usecase.dart`
|
||||
|
||||
```
|
||||
video picker (image_picker.pickVideo)
|
||||
↓
|
||||
File.length() → size (bytes)
|
||||
↓
|
||||
UploadFileRequest(filePath) → CDN URL
|
||||
↓
|
||||
SendMessageUseCase(typ: 4, content: {"url":"...","thumb":"","size":N})
|
||||
```
|
||||
|
||||
- `thumb` 当前为空字符串(`video_thumbnail` 包未接入)
|
||||
- `VideoMessageBubble` 已处理 `thumb` 为空的情况(显示播放图标占位)
|
||||
- `chatType` 默认 1(单聊)
|
||||
|
||||
---
|
||||
|
||||
## 四、ChatDetailPage 集成
|
||||
|
||||
### 4.1 _InputBar 改动
|
||||
|
||||
```dart
|
||||
// 新增两个按钮(emoji + attach)
|
||||
Row(children: [
|
||||
IconButton(icon: Icon(Icons.emoji_emotions_outlined), onPressed: onEmoji),
|
||||
IconButton(icon: Icon(Icons.attach_file), onPressed: onAttachPanel),
|
||||
Expanded(child: TextField(...)),
|
||||
IconButton(icon: Icon(Icons.send), onPressed: onSend),
|
||||
])
|
||||
```
|
||||
|
||||
### 4.2 附件派发流程
|
||||
|
||||
```dart
|
||||
Future<void> _showAttachmentPanel() async {
|
||||
final opt = await AttachmentPanelSheet.show(context);
|
||||
switch (opt) {
|
||||
case AttachmentOption.camera: _pickFromCamera(); break;
|
||||
case AttachmentOption.gallery: _showImagePicker(); break;
|
||||
case AttachmentOption.video: _pickVideo(); break;
|
||||
case AttachmentOption.file: // SnackBar: 即将上线
|
||||
case AttachmentOption.voice: // SnackBar: 即将上线
|
||||
case AttachmentOption.redEnvelope: // SnackBar: 即将上线
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 typ=5 气泡路由
|
||||
|
||||
```dart
|
||||
Widget _buildBubble(int typ, String content, bool isMine) {
|
||||
switch (typ) {
|
||||
case 1: return _TextBubble(...)
|
||||
case 2: return ImageMessageBubble(...)
|
||||
case 4: return VideoMessageBubble(...)
|
||||
case 5: return StickerMessageBubble(rawContent: content) // ← #51
|
||||
case 6: return FileMessageBubble(...)
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、DI 装配
|
||||
|
||||
**文件**:`features/chat/di/chat_service_providers.dart`
|
||||
|
||||
```dart
|
||||
final sendVideoUseCaseProvider = Provider<SendVideoUseCase>((ref) {
|
||||
return SendVideoUseCase(
|
||||
apiClient: ref.read(networkSdkApiProvider),
|
||||
sendMessage: ref.read(sendMessageUseCaseProvider),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、待完成事项
|
||||
|
||||
| Issue | 内容 | 前提条件 |
|
||||
|-------|------|---------|
|
||||
| #55 | 文件发送完整实现 | `flutter pub add file_picker` |
|
||||
| — | 录音发送(VoiceRecordSheet) | 录音 API 对接 |
|
||||
| — | 附件面板红包入口接入 | RedEnvelope SendView 接入 |
|
||||
| — | 视频缩略图(thumb) | `flutter pub add video_thumbnail` |
|
||||
144
Doc/image_grid_architecture.md
Normal file
144
Doc/image_grid_architecture.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 多图宫格气泡架构文档
|
||||
|
||||
对应 Gitea issues #36–#38
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
iOS 老项目 (`ImageGridBubble.swift`, `ChatView.buildDisplayItems()`) 已实现:
|
||||
- 接收端:连续同发送者图片消息自动合并为宫格气泡
|
||||
- 发送端:多图并行上传 → 快速连续发送 → 满足宫格分组条件
|
||||
|
||||
Flutter 新项目补齐以下能力:
|
||||
|
||||
| 功能 | Issue | iOS 对应 |
|
||||
|------|-------|---------|
|
||||
| 宫格气泡展示 | #36 | `ImageGridBubble.swift` |
|
||||
| 消息分组逻辑 | #37 | `ChatView.buildDisplayItems()` |
|
||||
| 多图并行发送 | #38 | `PhotosPicker + parallel upload` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 消息格式
|
||||
|
||||
多图不引入新的 typ。每张图片仍为独立的 **typ=2** 消息:
|
||||
|
||||
```json
|
||||
{"url":"Image/xxx.jpg","width":1024,"height":768}
|
||||
```
|
||||
|
||||
分组完全在 **客户端渲染层** 完成,服务端/DB 无感知。
|
||||
|
||||
---
|
||||
|
||||
## 3. 宫格布局规则(iOS 对齐)
|
||||
|
||||
| 图片数 | 列数 | 单格尺寸 | 间距 |
|
||||
|--------|------|----------|------|
|
||||
| 2 | 2 列 | 116 × 116 pt | 3 pt |
|
||||
| 3–9 | 3 列 | 78 × 78 pt | 3 pt |
|
||||
|
||||
- **外层圆角**:8 pt(ClipRRect)
|
||||
- **单格圆角**:4 pt
|
||||
- **最后一行靠左**:不足 3 格时用透明占位补齐(仅 3 列布局)
|
||||
- **总宽度**:2 列 = 235 pt;3 列 = 240 pt
|
||||
|
||||
---
|
||||
|
||||
## 4. 分组算法(iOS ChatView.buildDisplayItems 对齐)
|
||||
|
||||
```
|
||||
for i in msgs:
|
||||
if msgs[i].typ == 2:
|
||||
batch = [msgs[i]]
|
||||
j = i + 1
|
||||
while j < len(msgs) and len(batch) < 9:
|
||||
next = msgs[j]
|
||||
if next.typ == 2
|
||||
and next.sendId == batch[0].sendId
|
||||
and (next.sendTime - msgs[j-1].sendTime) < 5:
|
||||
batch.append(next)
|
||||
j++
|
||||
else:
|
||||
break
|
||||
if len(batch) >= 2:
|
||||
emit ChatDisplayItem(grid=batch)
|
||||
i = j
|
||||
continue
|
||||
emit ChatDisplayItem(single=msgs[i])
|
||||
i++
|
||||
```
|
||||
|
||||
关键规则:
|
||||
- 同一 `sendId`(不区分我发/他发)
|
||||
- 连续相邻消息时间差 < **5 秒**(不是与第一条比,是与前一条比)
|
||||
- 最多 **9 条** 一组,第 10 条另起新组
|
||||
- typ 不为 2 立即截断分组
|
||||
|
||||
---
|
||||
|
||||
## 5. 并行上传设计(#38)
|
||||
|
||||
```
|
||||
用户点击「发送」
|
||||
└─ SendImageUseCase.sendBatch(filePaths, chatId)
|
||||
├─ Future.wait([upload(img1), upload(img2), ..., upload(imgN)])
|
||||
│ └─ 每个 upload: readBytes → dart:ui.ImageDescriptor → UploadFileRequest → url
|
||||
└─ for url in results (顺序):
|
||||
└─ SendMessageUseCase.execute(chatId, jsonEncode({url,w,h}), typ=2)
|
||||
└─ 乐观写入 sendTime = now()(各条仅相差毫秒)
|
||||
```
|
||||
|
||||
**关键**:并行上传确保所有 `sendTime` 在同一时刻附近(< 1 秒差),
|
||||
满足分组条件(< 5 秒)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据流:发送多图
|
||||
|
||||
```
|
||||
ImagePickerSheet._sendAll()
|
||||
└─ sendImageUseCaseProvider.sendBatch(filePaths, chatId)
|
||||
├─ 并行上传 → [(url1,w1,h1), (url2,w2,h2), ...]
|
||||
└─ 顺序发送 → [typ=2 msg1, typ=2 msg2, ...]
|
||||
└─ DB Stream → messagesByChatIdProvider
|
||||
└─ _buildDisplayItems()
|
||||
└─ [msg1, msg2] 同 sendId + typ=2 + Δt<5s
|
||||
└─ ChatDisplayItem(grid=[msg1, msg2])
|
||||
└─ ImageGridBubble(messages: [msg1, msg2])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据流:接收多图
|
||||
|
||||
```
|
||||
WS/HTTP → DB → messagesByChatIdProvider Stream
|
||||
└─ _buildDisplayItems(msgs)
|
||||
└─ 连续 typ=2 同 sendId Δt<5s → ChatDisplayItem(grid)
|
||||
└─ ImageGridBubble
|
||||
├─ GridCell(Image.network + ClipRRect 4pt)
|
||||
└─ onTap → ImageViewerPage.open(urls: allUrls, initialIndex: i)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 新增/修改文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `lib/features/chat/view/widgets/image_grid_bubble.dart` | 新增 | #36 宫格气泡组件 |
|
||||
| `lib/features/chat/view/chat_detail_page.dart` | 修改 | #37 ChatDisplayItem + buildDisplayItems |
|
||||
| `lib/features/chat/usecases/send_image_usecase.dart` | 修改 | #38 sendBatch() 并行上传 |
|
||||
| `lib/features/chat/view/widgets/image_picker_sheet.dart` | 修改 | #38 改用 sendBatch() |
|
||||
| `Doc/image_grid_architecture.md` | 新增 | 本文档 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 待完成
|
||||
|
||||
- 图片 sticker (typ=5) 展示
|
||||
- GIF 消息 (typ=25) 支持(目前 typ=25 走 ImageMessageBubble,后续可合并入宫格分组)
|
||||
- 端到端加密图片(cipher_guard_sdk 接入后)
|
||||
- 宫格内视频支持(若后续 album 含 video)
|
||||
143
Doc/image_viewer_architecture.md
Normal file
143
Doc/image_viewer_architecture.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 图片查看与缓存 — 架构文档
|
||||
|
||||
> 对应 Gitea issues #32(初版)/ #57(缓存)/ #58(ImageViewerPage 升级)/ #59(气泡缓存接入)
|
||||
> 参考实现:`im-client-im-dev` `extended_photo_view.dart` / `photo_view_util.dart` / `FullScreenPicture.dart`
|
||||
|
||||
---
|
||||
|
||||
## 1. 组件选型
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 包 | 版本 | 作用 |
|
||||
|----|------|------|
|
||||
| `photo_view` | `^0.15.0` | 全屏 pinch-to-zoom、`PhotoViewGallery` 多图横滑 |
|
||||
| `cached_network_image` | `^3.3.1` | 磁盘 + 内存双缓存(基于 `flutter_cache_manager`) |
|
||||
| `image_gallery_saver_plus` | `^3.0.5` | 保存到相册(iOS + Android) |
|
||||
| `share_plus` | `^10.0.0` | 系统分享 |
|
||||
|
||||
### 选型决策:`cached_network_image` vs `extended_image`
|
||||
|
||||
老项目使用 `extended_image: ^10.0.0` + 自定义 `DownloadMgr` 做本地文件缓存。
|
||||
|
||||
本项目选择 `cached_network_image` 的原因:
|
||||
- 本项目已有 `photo_view`,`CachedNetworkImageProvider` 可直接作为 `imageProvider` 无缝替换 `NetworkImage`
|
||||
- `extended_image` 将 pan/zoom/缓存耦合在一起,替换成本高于直接添加缓存层
|
||||
- `cached_network_image` 是 Flutter 生态最广泛使用的缓存方案,API 简单,与 `photo_view` 解耦
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能清单
|
||||
|
||||
| 功能 | issue | 状态 |
|
||||
|------|-------|------|
|
||||
| 单图全屏 + pinch-to-zoom(1x–5x)| #32 | ✅ |
|
||||
| 多图横向滑动(PhotoViewGallery)| #32 | ✅ |
|
||||
| 保存到相册 / 系统分享 | #32 | ✅ |
|
||||
| **磁盘 + 内存缓存(CachedNetworkImageProvider)** | #57 | ✅ |
|
||||
| **Shimmer 加载占位 → 渐入(fadeIn 200ms)** | #58 | ✅ |
|
||||
| **加载失败重试按钮** | #58 | ✅ |
|
||||
| **下拉关闭(>80pt 松手 pop,背景渐变透明)** | #58 | ✅ |
|
||||
| **长按底部菜单(保存 / 分享 / 复制链接)** | #58 | ✅ |
|
||||
| **多图页面指示点(≤10 张)** | #58 | ✅ |
|
||||
| **Hero 动画(单图,气泡→全屏)** | #58 | ✅ |
|
||||
| **AppBar 点击切换显示/隐藏** | #58 | ✅ |
|
||||
| **ImageMessageBubble → CachedNetworkImage** | #59 | ✅ |
|
||||
| **ImageGridBubble._GridCell → CachedNetworkImage** | #59 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据流
|
||||
|
||||
### 3.1 缓存层
|
||||
|
||||
```
|
||||
CachedNetworkImageProvider(url)
|
||||
├─ 内存缓存命中 → 直接渲染
|
||||
├─ 磁盘缓存命中 → 读取本地文件 → 渲染
|
||||
└─ 未命中 → HTTP GET → 写入磁盘缓存 → 写入内存缓存 → 渲染
|
||||
```
|
||||
|
||||
缓存路径(由 `flutter_cache_manager` 管理):
|
||||
- iOS: `{Library}/Caches/libCachedImageData/`
|
||||
- Android: `{cacheDir}/libCachedImageData/`
|
||||
|
||||
### 3.2 ImageViewerPage 打开流程
|
||||
|
||||
```
|
||||
ImageMessageBubble.onTap
|
||||
└─ ImageViewerPage.open(context, urls: [url], heroTag: 'img_$url')
|
||||
└─ PageRouteBuilder(fade transition, opaque:false)
|
||||
└─ ImageViewerPage
|
||||
├─ Hero(tag: heroTag, child: PhotoView)
|
||||
│ └─ CachedNetworkImageProvider(url)
|
||||
│ ├─ loadingBuilder → _Shimmer(灰色+进度%)
|
||||
│ └─ errorBuilder → _ErrorWidget(重试按钮)
|
||||
├─ _PageDots(多图指示点)
|
||||
└─ 底部工具栏(保存 / 分享)
|
||||
```
|
||||
|
||||
### 3.3 下拉关闭逻辑
|
||||
|
||||
```
|
||||
onVerticalDragUpdate: _dragOffset += delta.dy
|
||||
→ Transform.translate(offset: Offset(0, _dragOffset))
|
||||
→ backgroundOpacity = 1 - (_dragOffset.abs() / 80).clamp(0, 1) * 0.7
|
||||
|
||||
onVerticalDragEnd:
|
||||
_dragOffset.abs() > 80pt → Navigator.pop()
|
||||
else → _dragOffset = 0(弹回)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hero 动画配置
|
||||
|
||||
```dart
|
||||
// 气泡(ImageMessageBubble)— 发送端
|
||||
Hero(
|
||||
tag: 'img_$resolvedUrl', // URL 唯一性足够
|
||||
child: CachedNetworkImage(...),
|
||||
)
|
||||
|
||||
// 查看页(ImageViewerPage)— 接收端(单图时)
|
||||
Hero(
|
||||
tag: heroTag, // 传入同一 tag
|
||||
child: PhotoView(...),
|
||||
)
|
||||
```
|
||||
|
||||
> **注意**:多图 Gallery 时 Hero 仅对 initialIndex 图片生效;其余图片使用标准 fade transition。
|
||||
|
||||
---
|
||||
|
||||
## 5. ImageMessageBubble 变更对比
|
||||
|
||||
| 字段 | 旧版 | 新版 |
|
||||
|------|------|------|
|
||||
| imageProvider | `Image.network` | `CachedNetworkImage` |
|
||||
| loading 占位 | `CircularProgressIndicator` | 灰色块(shimmer颜色) |
|
||||
| fadeIn | 无 | 200ms |
|
||||
| 点击 | `ImageViewerPage.open(urls)` | `ImageViewerPage.open(urls, heroTag: 'img_$url')` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件索引
|
||||
|
||||
```
|
||||
features/chat/view/
|
||||
├── image_viewer_page.dart # 全屏查看页(全量重写,#57/#58)
|
||||
└── widgets/
|
||||
├── image_message_bubble.dart # 单图气泡(CachedNetworkImage,#59)
|
||||
└── image_grid_bubble.dart # 宫格气泡(CachedNetworkImage,#59)
|
||||
|
||||
apps/im_app/pubspec.yaml # cached_network_image: ^3.3.1 新增(#57)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
- **贴纸(typ=5)** `StickerMessageBubble` 未接入 `CachedNetworkImage`(贴纸尺寸固定 120pt,优先级低)
|
||||
- **WebP 动图** `cached_network_image` 默认支持,但 Flutter 的 `AnimatedImage` 在 `photo_view` 中需额外处理
|
||||
- **缓存清理** 未暴露 UI 入口;可调用 `DefaultCacheManager().emptyCache()` 在设置页集成
|
||||
@@ -1,6 +1,6 @@
|
||||
# 我的(Mine)Tab — 架构文档
|
||||
|
||||
> 对应 Gitea issues #5–#13
|
||||
> 对应 Gitea issues #5–#13,#39–#41(UI 重设计),#49–#50(编辑资料完整实现)
|
||||
> 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile
|
||||
|
||||
---
|
||||
@@ -10,7 +10,7 @@
|
||||
| Issue | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| #5 | Tab 重命名 & 个人资料卡片 | ✅ 已实现 |
|
||||
| #6 | 编辑个人资料(昵称/bio/头像) | ✅ 框架已建(CDN 上传待 #6 后续) |
|
||||
| #6 | 编辑个人资料(昵称/bio/头像) | ✅ 全量实现(#49/#50 补全头像上传) |
|
||||
| #7 | 退出登录 | ✅ 已实现 |
|
||||
| #8 | 主题持久化 | ⏳ TODO(解开 ThemeModeNotifier 注释) |
|
||||
| #9 | 语言设置 | ✅ UI 框架(l10n_sdk 待接入) |
|
||||
@@ -84,15 +84,31 @@ SettingsPage._confirmLogout()
|
||||
└─ AuthNotifier.logout() → go_router 重定向 /login
|
||||
```
|
||||
|
||||
### 3.3 编辑资料保存
|
||||
### 3.3 编辑资料 — 头像上传(#49)
|
||||
|
||||
```
|
||||
EditProfilePage → 保存按钮
|
||||
EditProfilePage._showAvatarSourceSheet()
|
||||
└─ BottomSheet → 相册 / 拍照
|
||||
└─ EditProfileViewModel.pickAndUploadAvatar(ImageSource)
|
||||
├─ ImagePicker.pickImage(source, maxWidth:900, maxHeight:900, quality:85)
|
||||
├─ ImageCropper.cropImage(1:1, IOSUiSettings / AndroidUiSettings)
|
||||
├─ state.isUploadingAvatar = true
|
||||
├─ NetworksSdkApi.executeRequest(UploadFileRequest(filePath))
|
||||
│ └─ POST /app/api/upload/file (FormData multipart)
|
||||
│ └─ UploadResult.url
|
||||
└─ state.avatarUrl = url (UI 实时预览更新)
|
||||
```
|
||||
|
||||
### 3.4 编辑资料保存(#50)
|
||||
|
||||
```
|
||||
EditProfilePage → 保存按钮(nickname.isNotEmpty && !isUploadingAvatar)
|
||||
└─ EditProfileViewModel.save()
|
||||
└─ UpdateProfileUseCase.execute(nickname, bio, profilePicUrl)
|
||||
└─ NetworksSdkApi.executeRequest(UpdateProfileRequest)
|
||||
└─ POST /app/api/user/update-profile
|
||||
└─ SettingsViewModel.loadProfile() (刷新资料卡)
|
||||
└─ Navigator.pop()
|
||||
```
|
||||
|
||||
---
|
||||
@@ -152,13 +168,72 @@ settingsViewModelProvider
|
||||
|
||||
---
|
||||
|
||||
## 7. 待完成事项
|
||||
## 7. UI 重设计(#39 / #40 / #41)
|
||||
|
||||
### 7.1 ProfileHeroCard (#39 / #41)
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 头像 | 72pt 圆形;无头像时 8 色渐变占位(uid%8) |
|
||||
| 昵称 | fontWeight w700,titleMedium |
|
||||
| Handle | `@J{uid}`,bodySmall onSurfaceVariant |
|
||||
| 手机号 | 掩码 `+CC ***XXXX`,bodySmall |
|
||||
| Bio | 非空显示,为空显示「添加一句话简介」(斜体,半透明) |
|
||||
| AppBar | compact,右侧:QR 图标 + 编辑铅笔 |
|
||||
|
||||
渐变色方案(`_ProfileHeroCard._gradients[uid.abs() % 8]`):
|
||||
```
|
||||
0: [#4776E6, #8E54E9] 1: [#11998E, #38EF7D]
|
||||
2: [#FC466B, #3F5EFB] 3: [#F7971E, #FFD200]
|
||||
4: [#56CCF2, #2F80ED] 5: [#EB3349, #F45C43]
|
||||
6: [#1FA2FF, #12D8FA] 7: [#9D50BB, #6E48AA]
|
||||
```
|
||||
|
||||
### 7.2 彩色图标行与分组卡片 (#40)
|
||||
|
||||
`_IconBox`:36pt 圆角正方形(8pt)白色图标,iOS Settings 风格。
|
||||
|
||||
| 卡片组 | 菜单项 | 颜色 |
|
||||
|--------|--------|------|
|
||||
| 账户 | 我的钱包 | #FFAA5B |
|
||||
| | 账户安全 | #8A5CF6 |
|
||||
| 工具 | 收藏 | #FFAF45 |
|
||||
| | 最近呼叫 | #4CB050 |
|
||||
| | 链接设备 | #5667FF |
|
||||
| | 聊天文件夹 | #F2994A |
|
||||
| 偏好设置 | 通知和声音 | #FF8B5E |
|
||||
| | 隐私设置 | #0BB8A9 |
|
||||
| | 黑名单 | #FF4B4B |
|
||||
| | 语言 | #5667FF |
|
||||
| | 主题 | #8A5CF6 |
|
||||
| 关于 | 用户协议 | gray |
|
||||
| | 隐私政策 | gray |
|
||||
| | 版本号(静态)| gray,无 chevron |
|
||||
|
||||
### 7.3 SettingsState bio 字段 (#41)
|
||||
|
||||
- `SettingsState.bio: String`(默认 `''`)
|
||||
- `SettingsViewModel.loadProfile()` 赋值 `bio: profile.bio`
|
||||
- 数据来源:`ProfileResponse.bio` → `GET /app/api/user/profile`
|
||||
|
||||
---
|
||||
|
||||
## 8. 编辑资料 UI 设计(#49 / #50)
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 头像 | 88pt 圆形;无头像时 8 色渐变占位;右下 28pt 相机角标(primary色) |
|
||||
| 上传进度 | CircularProgressIndicator 环绕头像,isUploadingAvatar=true 时显示 |
|
||||
| 选图 Sheet | BottomSheet(从相册选取 / 拍照),16pt 圆角,拖动条 |
|
||||
| 昵称字段 | Card 分组,`x/50` 右侧计数,maxLength:50 |
|
||||
| 简介字段 | Card 分组,`x/200` 右侧计数,maxLines:null(自动展开),maxLength:200 |
|
||||
| 保存按钮 | AppBar 右侧 TextButton;昵称为空或上传中时 disabled |
|
||||
| 错误 Banner | errorContainer 色圆角卡片,⚠️ 图标 + 文本 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 待完成事项
|
||||
|
||||
- **#6 头像上传**:接入 CDN upload(参考 iOS CDN 流程)
|
||||
- **#8 主题持久化**:解开 `ThemeModeNotifier.build()` 和 `setMode()` 中的 TODO
|
||||
- **#10 黑名单 API**:实现 `FetchBlocklistUseCase` + `UnblockUseCase`
|
||||
- **#11 聊天文件夹**:`ChatCategoryViewModel` + `account/store` API
|
||||
- **build_runner**:`UpdateProfileRequest` 使用 `@ApiRequest`,需执行:
|
||||
```bash
|
||||
cd apps/im_app && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
166
Doc/recent_calls_architecture.md
Normal file
166
Doc/recent_calls_architecture.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 最近呼叫(Recent Calls)— 架构文档
|
||||
|
||||
> 对应 Gitea issues #46-#48(内部编号 #42-#44)
|
||||
> 参考实现:`im-client-im-dev` lib/views/call_log/ + lib/managers/call_log_mgr.dart
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能范围
|
||||
|
||||
| Issue | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| #42 | 最近呼叫列表页 UI(全部/未接 Tab + CallLogTile) | ✅ 已实现 |
|
||||
| #43 | 通话记录 API 接入(FetchCallLogsRequest + UseCase) | ✅ 已实现 |
|
||||
| #44 | RecentCallsViewModel + 路由接入 | ✅ 已实现 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
features/settings/
|
||||
├── di/
|
||||
│ └── settings_providers.dart # 新增 fetchCallLogsUseCaseProvider
|
||||
├── presentation/
|
||||
│ └── recent_calls_view_model.dart # RecentCallsViewModel + RecentCallsState
|
||||
├── usecases/
|
||||
│ └── fetch_call_logs_usecase.dart # POST /app/api/call/records → DB
|
||||
└── view/
|
||||
└── recent_calls_page.dart # RecentCallsPage + TabBar + _CallLogTile
|
||||
|
||||
data/remote/
|
||||
└── call_log_request.dart # FetchCallLogsRequest + CallLogDto
|
||||
|
||||
app/router/
|
||||
├── app_route_name.dart # 新增 settingsRecentCalls
|
||||
├── app_router.dart # 新增 GoRoute
|
||||
└── guards/auth_guard.dart # 新增 case settingsRecentCalls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据流
|
||||
|
||||
```
|
||||
RecentCallsPage (build)
|
||||
└─ ref.watch(allCallLogsProvider) ← DB Stream(实时)
|
||||
└─ RecentCallsViewModel.loadCallLogs()
|
||||
└─ FetchCallLogsUseCase.execute()
|
||||
└─ POST /app/api/call/records (start_from=0, status=-1)
|
||||
├─ 响应 List<CallLogDto> → CallLog Domain
|
||||
└─ CallLogRepository.insertOrReplaceCallLogs()
|
||||
└─ Drift DB → allCallLogsProvider 自动刷新 → UI
|
||||
|
||||
进入页面:
|
||||
└─ RecentCallsViewModel.markAllRead()
|
||||
└─ CallLogRepository.markAllAsRead()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 端点
|
||||
|
||||
| 操作 | Method | Path | 参数 |
|
||||
|------|--------|------|------|
|
||||
| 拉取通话记录 | POST | `/app/api/call/records` | `start_from` (int, 时间戳), `status` (-1=全部) |
|
||||
|
||||
### 响应字段映射
|
||||
|
||||
| 服务端字段 | Dart 字段 | 类型 |
|
||||
|-----------|-----------|------|
|
||||
| `rtc_channel_id` | `id` (String) | String |
|
||||
| `inviter_id` / `caller_id` | `callerId` | int? |
|
||||
| `receiver_id` | `receiverId` | int? |
|
||||
| `chat_id` | `chatId` | int? |
|
||||
| `duration` | `duration` | int? (秒) |
|
||||
| `video_call` | `videoCall` | int? (0=语音, 1=视频) |
|
||||
| `created_at` | `createdAt` | int? (秒级时间戳) |
|
||||
| `updated_at` | `updatedAt` | int? |
|
||||
| `ended_at` | `endedAt` | int? |
|
||||
| `status` | `status` | int? |
|
||||
| `is_read` | `isRead` | int? (0/1) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 通话状态枚举
|
||||
|
||||
```dart
|
||||
// status 值含义(参考 im-client-im-dev CallEvent)
|
||||
// 0 = Ringing/Pending
|
||||
// 1 = Connected
|
||||
// 2 = Ended(正常结束)
|
||||
// 3 = Busy(CallOptBusy)
|
||||
// 4 = Cancelled(CallOptCancel)
|
||||
// 5 = Timeout(CallTimeOut)
|
||||
// 6 = Declined(CallBusy)
|
||||
|
||||
// 未接来电:callerId != currentUid AND status in {3, 4, 5, 6}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ViewModel 设计
|
||||
|
||||
```dart
|
||||
class RecentCallsState {
|
||||
final int tabIndex; // 0=全部, 1=未接来电
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
class RecentCallsViewModel extends Notifier<RecentCallsState> {
|
||||
void setTab(int index) // 切换 Tab
|
||||
Future<void> loadCallLogs() // 拉取 + 写入 DB
|
||||
Future<void> markAllRead() // 标记已读
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 结构
|
||||
|
||||
### RecentCallsPage
|
||||
|
||||
```
|
||||
Scaffold
|
||||
AppBar: 「最近呼叫」
|
||||
Column
|
||||
TabBar(全部 / 未接来电)
|
||||
Expanded
|
||||
TabBarView
|
||||
AllCallsTab → allCallLogsProvider 全部
|
||||
MissedTab → 按 isMissed 过滤
|
||||
```
|
||||
|
||||
### _CallLogTile
|
||||
|
||||
```
|
||||
ListTile
|
||||
leading: _CallTypeIcon(电话/摄像头/红色, 40pt 圆形)
|
||||
title: 对方 UID 显示("@J{uid}")
|
||||
subtitle: 通话类型文字 + 时长
|
||||
trailing: 相对时间
|
||||
```
|
||||
|
||||
**未接来电判断**:`callerId != currentUid` AND `status in {3,4,5,6}`
|
||||
|
||||
---
|
||||
|
||||
## 8. 路由
|
||||
|
||||
```
|
||||
/settings/recent-calls → RecentCallsPage(parentNavigatorKey=_rootKey)
|
||||
```
|
||||
|
||||
`auth_guard.dart` switch 已补充 `settingsRecentCalls` case。
|
||||
|
||||
---
|
||||
|
||||
## 9. 设计决策
|
||||
|
||||
- **无用户名映射**:当前 CallLog 实体只有 UID,无昵称。显示 `@J{uid}` 占位,
|
||||
后续可接入联系人 Repository 做名称查询。
|
||||
- **增量拉取策略**:当前实现 `start_from=0`(全量),首次打开可能慢;
|
||||
后续可从本地最新 `updatedAt` 做增量同步(参考 CallLogMgr.loadRemoteCallLog)。
|
||||
- **离线可用**:数据先写 DB,UI 监听 `allCallLogsProvider`(DB Stream),
|
||||
无网络时仍能展示缓存记录。
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:cipher_guard_sdk/cipher_guard_sdk.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/device_info.dart';
|
||||
import 'package:im_app/core/services/app_initializer.dart';
|
||||
import 'package:im_app/core/services/encryption_manager.dart';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
|
||||
// ── 认证 ──────────────────────────────────────────────────────────────────────
|
||||
@@ -26,32 +28,60 @@ class AuthNotifier extends ChangeNotifier {
|
||||
/// 登录用户的 UID,登录成功后由 LoginViewModel 写入
|
||||
int? get currentUid => _currentUid;
|
||||
|
||||
/// E2E EncryptionManager — 由外部注入(见 LoginViewModel)
|
||||
EncryptionManager? _encryptionManager;
|
||||
NetworksSdkApi? _api;
|
||||
|
||||
void setEncryptionDeps(EncryptionManager encMgr, NetworksSdkApi api) {
|
||||
_encryptionManager = encMgr;
|
||||
_api = api;
|
||||
}
|
||||
|
||||
void login({required int uid}) {
|
||||
_isLoggedIn = true;
|
||||
_currentUid = uid;
|
||||
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
|
||||
// 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次)
|
||||
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
|
||||
// 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。
|
||||
notifyListeners();
|
||||
// E2E setup: 对齐 iOS AppCoordinator.onLogin → EncryptionManager.setup()
|
||||
if (_encryptionManager != null && _api != null) {
|
||||
_encryptionManager!.setup(_api!);
|
||||
}
|
||||
}
|
||||
|
||||
void logout() {
|
||||
_isLoggedIn = false;
|
||||
_currentUid = null;
|
||||
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
|
||||
// cipherSdk.clearActiveKeyPair()
|
||||
// cipherSdk.clearDerivedKeyCache()
|
||||
// E2E teardown: 清除所有加密密钥
|
||||
_encryptionManager?.clearKeys();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录状态 Provider
|
||||
///
|
||||
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
|
||||
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier](ChangeNotifier),
|
||||
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
|
||||
final authNotifierProvider = Provider<AuthNotifier>((ref) => AuthNotifier());
|
||||
/// 自动注入 EncryptionManager + API client,login() 后自动触发 E2E setup。
|
||||
final authNotifierProvider = Provider<AuthNotifier>((ref) {
|
||||
final auth = AuthNotifier();
|
||||
auth.setEncryptionDeps(
|
||||
ref.read(encryptionManagerProvider),
|
||||
ref.read(networkSdkApiProvider),
|
||||
);
|
||||
return auth;
|
||||
});
|
||||
|
||||
// ── E2E 加密 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// CipherGuardSdkApi 单例 — 对齐老项目加密引擎
|
||||
final cipherSdkProvider = Provider<CipherGuardSdkApi>((ref) {
|
||||
return CipherGuardSdkApi();
|
||||
});
|
||||
|
||||
/// EncryptionManager 单例 — per-chat key chain + API integration
|
||||
///
|
||||
/// 登录后调用 `encMgr.setup(api)` 启动 E2E,退出时调用 `encMgr.clearKeys()`。
|
||||
/// 对齐 iOS EncryptionManager + 老项目 EncryptionMgr。
|
||||
final encryptionManagerProvider = Provider<EncryptionManager>((ref) {
|
||||
return EncryptionManager(cipherSdk: ref.read(cipherSdkProvider));
|
||||
});
|
||||
|
||||
// ── 主题 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
/// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景)
|
||||
/// context.push(
|
||||
/// AppRouteName.chatDetail.path,
|
||||
/// extra: (conversationId: '42', title: '技术支持'),
|
||||
/// extra: (conversationId: '42', title: '技术支持', chatType: 1),
|
||||
/// );
|
||||
///
|
||||
/// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景)
|
||||
@@ -60,7 +60,7 @@ enum AppRouteName {
|
||||
settings('/settings'),
|
||||
|
||||
// ── Chat 子路由 ──────────────────────────────────────────────────────────
|
||||
// extra: ({String conversationId, String title})
|
||||
// extra: ({String conversationId, String title, int chatType})
|
||||
chatDetail('/chat/detail'),
|
||||
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
|
||||
chatDetailById('/chat/:id'),
|
||||
@@ -73,6 +73,8 @@ enum AppRouteName {
|
||||
settingsLanguage('/settings/language'),
|
||||
settingsNetworkDiagnostics('/settings/network-diagnostics'),
|
||||
settingsAbout('/settings/about'),
|
||||
settingsFavorites('/settings/favorites'),
|
||||
settingsRecentCalls('/settings/recent-calls'),
|
||||
|
||||
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
|
||||
login('/login');
|
||||
|
||||
@@ -15,6 +15,8 @@ import 'package:im_app/features/settings/view/blocklist_page.dart';
|
||||
import 'package:im_app/features/settings/view/language_page.dart';
|
||||
import 'package:im_app/features/settings/view/network_diagnostics_page.dart';
|
||||
import 'package:im_app/features/settings/view/about_page.dart';
|
||||
import 'package:im_app/features/settings/view/favorites_page.dart';
|
||||
import 'package:im_app/features/settings/view/recent_calls_page.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
import 'package:im_app/app/router/guards/auth_guard.dart';
|
||||
@@ -132,11 +134,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.chatDetail.path,
|
||||
builder: (context, state) {
|
||||
final extra =
|
||||
state.extra as ({String conversationId, String title});
|
||||
final extra = state.extra
|
||||
as ({String conversationId, String title, int chatType});
|
||||
return ChatDetailPage(
|
||||
conversationId: extra.conversationId,
|
||||
title: extra.title,
|
||||
chatType: extra.chatType,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -182,6 +185,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: AppRouteName.settingsAbout.path,
|
||||
builder: (context, state) => const AboutPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsFavorites.path,
|
||||
builder: (context, state) => const FavoritesPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsRecentCalls.path,
|
||||
builder: (context, state) => const RecentCallsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.login.path,
|
||||
|
||||
@@ -45,6 +45,8 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
|
||||
case AppRouteName.settingsLanguage:
|
||||
case AppRouteName.settingsNetworkDiagnostics:
|
||||
case AppRouteName.settingsAbout:
|
||||
case AppRouteName.settingsFavorites:
|
||||
case AppRouteName.settingsRecentCalls:
|
||||
case AppRouteName.chatDBTest:
|
||||
// 受保护路由 → 未登录跳登录页
|
||||
return isLoggedIn ? null : AppRouteName.login.path;
|
||||
|
||||
@@ -44,9 +44,30 @@ class ApiPaths {
|
||||
// ── Workspace ──
|
||||
static const workspaceGet = '/workspace/workspace/get';
|
||||
|
||||
// ── Call ──
|
||||
static const callRecords = '/app/api/call/records';
|
||||
|
||||
// ── Upload ──
|
||||
static const uploadFile = '/app/api/upload/file';
|
||||
|
||||
// ── Favorite (收藏) ──
|
||||
static const favoriteFetchList = '/app/api/favorite/favorites';
|
||||
static const favoriteDelete = '/app/api/favorite/delete';
|
||||
static const favoriteFetchByIds = '/app/api/favorite/favorite';
|
||||
static const favoriteTags = '/app/api/favorite/tags';
|
||||
|
||||
// ── Cipher (E2E Encryption) ──
|
||||
// 注意:/app/api/cipher/v2/key/set 是预发布接口,仅测试阶段使用
|
||||
static const cipherKeyMy = '/app/api/cipher/v2/key/my';
|
||||
static const cipherKeySet = '/app/api/cipher/v2/key/set';
|
||||
static const cipherKeyGets = '/app/api/cipher/v2/key/gets';
|
||||
static const cipherChatGet = '/app/api/cipher/v2/chat/get';
|
||||
static const cipherChatMy = '/app/api/cipher/v2/chat/my';
|
||||
static const cipherChatUpdate = '/app/api/cipher/v2/chat/update';
|
||||
static const cipherChatRequest = '/app/api/cipher/v2/chat/request';
|
||||
static const cipherChatSessionsExist =
|
||||
'/app/api/cipher/v2/chat/sessions_exist';
|
||||
|
||||
// ── WebSocket ──
|
||||
static const wsConnect = '/websock/open';
|
||||
}
|
||||
|
||||
@@ -41,4 +41,9 @@ class ApiErrorCodes {
|
||||
/// 触发图片验证:data 含各平台 CAPTCHA token(android / ios / web)
|
||||
static const int captchaRequired = 30174;
|
||||
|
||||
// ── 二级密码(30164)──
|
||||
|
||||
/// 账号已设置二级密码,需要用户输入后携带 MD5 哈希调 login-user
|
||||
/// data 含 vcode_token / recovery_email / hint / reset_status
|
||||
static const int secondaryPasscodeRequired = 30164;
|
||||
}
|
||||
|
||||
32
apps/im_app/lib/core/foundation/exceptions.dart
Normal file
32
apps/im_app/lib/core/foundation/exceptions.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
/// 自定义业务异常
|
||||
///
|
||||
/// 集中管理需要在 UseCase / Repository 层抛出并在 UI 层捕获的
|
||||
/// 非 ApiError 业务异常。
|
||||
|
||||
/// 服务端要求输入二级密码(错误码 30164)
|
||||
///
|
||||
/// 当 `/vcode/check` 返回 30164 时,服务端同时下发:
|
||||
/// - [vcodeToken] — 本次验证会话令牌(后续调 login-user 须携带)
|
||||
/// - [recoveryEmail] — 找回密码用的脱敏邮箱
|
||||
/// - [hint] — 用户设置的二级密码提示语
|
||||
/// - [resetStatus] — 是否可重置(true = 可走找回流程)
|
||||
///
|
||||
/// 上层 catch 此异常后跳转二级密码输入界面,
|
||||
/// 成功后调 [AuthRepository.loginWithPasscode] 完成登录。
|
||||
class SecondaryPasscodeRequiredException implements Exception {
|
||||
final String vcodeToken;
|
||||
final String recoveryEmail;
|
||||
final String hint;
|
||||
final bool resetStatus;
|
||||
|
||||
const SecondaryPasscodeRequiredException({
|
||||
required this.vcodeToken,
|
||||
required this.recoveryEmail,
|
||||
required this.hint,
|
||||
required this.resetStatus,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SecondaryPasscodeRequiredException(vcodeToken=$vcodeToken, hint=$hint)';
|
||||
}
|
||||
335
apps/im_app/lib/core/services/encryption_manager.dart
Normal file
335
apps/im_app/lib/core/services/encryption_manager.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cipher_guard_sdk/cipher_guard_sdk.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:im_app/data/remote/cipher_api_requests.dart';
|
||||
|
||||
/// Per-chat AES key manager with round-based key chain — 对齐 iOS EncryptionManager
|
||||
///
|
||||
/// 协议(对齐老项目 im-client-im-dev EncryptionMgr + iOS EncryptionManager):
|
||||
/// 1. RSA-1024 key pair: 生成一次,私钥存 secure storage
|
||||
/// 2. 公钥上传到服务器: POST /app/api/cipher/v2/key/set
|
||||
/// 3. 所有聊天 cipher session 获取: GET /app/api/cipher/v2/chat/my
|
||||
/// 服务器返回 [{ chat_id, session: Base64(RSA_raw_encrypt(32-char AES key)), round }]
|
||||
/// 4. 每个 session 用 RSA 私钥解密 → 32-char AES key per chat
|
||||
/// 5. 消息加解密:
|
||||
/// Wire format (new): JSON {"round": N, "data": "<Base64 AES-SIC ciphertext>"}
|
||||
/// Legacy format: raw Base64 ciphertext (无 round — 使用该 chat 最新 key)
|
||||
/// AES-256 SIC/CTR mode, key = UTF-8(32-char key), IV = 16 zero bytes
|
||||
///
|
||||
/// Key chain:
|
||||
/// keyChain[chatId][round] = aesKey (32-char string)
|
||||
/// 最多 maxKeyChainDepth(10) rounds per chat; 最旧的 round 先被淘汰
|
||||
/// Key chain 持久化到 secure storage 作为 JSON
|
||||
class EncryptionManager {
|
||||
EncryptionManager({
|
||||
required CipherGuardSdkApi cipherSdk,
|
||||
}) : _cipherSdk = cipherSdk;
|
||||
|
||||
final CipherGuardSdkApi _cipherSdk;
|
||||
|
||||
// chatId → (round → 32-char AES key)
|
||||
final Map<int, Map<int, String>> _keyChain = {};
|
||||
|
||||
bool _isSetup = false;
|
||||
bool _isSettingUp = false;
|
||||
|
||||
static const int maxKeyChainDepth = 10;
|
||||
|
||||
// 对齐老项目 EncryptionMgr:使用 localStorageMgr (SharedPreferences) 存储密钥。
|
||||
// TODO(security): 迁移到 flutter_secure_storage 使用 Keychain/Keystore。
|
||||
static const String _keyChainStorageKey = 'enc.keychain.json';
|
||||
static const String _rsaPublicKeyStorageKey = 'enc.rsa.public';
|
||||
static const String _rsaPrivateKeyStorageKey = 'enc.rsa.private';
|
||||
|
||||
bool get isSetup => _isSetup;
|
||||
|
||||
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> clearKeys() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_rsaPublicKeyStorageKey);
|
||||
await prefs.remove(_rsaPrivateKeyStorageKey);
|
||||
await prefs.remove(_keyChainStorageKey);
|
||||
_keyChain.clear();
|
||||
_isSetup = false;
|
||||
_cipherSdk.clearActiveKeyPair();
|
||||
debugPrint('[EncMgr] all keys cleared');
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Call once after login. Idempotent.
|
||||
Future<void> setup(NetworksSdkApi api) async {
|
||||
if (_isSetup || _isSettingUp) return;
|
||||
_isSettingUp = true;
|
||||
try {
|
||||
// 1. Load or generate RSA key pair
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var publicPem = prefs.getString(_rsaPublicKeyStorageKey);
|
||||
var privatePem = prefs.getString(_rsaPrivateKeyStorageKey);
|
||||
|
||||
if (publicPem == null || privatePem == null) {
|
||||
debugPrint('[EncMgr] generating RSA-1024 key pair...');
|
||||
final keyPair = await _cipherSdk.generateRsaKeyPair(keySize: 1024);
|
||||
publicPem = keyPair.publicKey;
|
||||
privatePem = keyPair.privateKey;
|
||||
await prefs.setString(_rsaPublicKeyStorageKey, publicPem);
|
||||
await prefs.setString(_rsaPrivateKeyStorageKey, privatePem);
|
||||
debugPrint('[EncMgr] RSA key pair stored');
|
||||
}
|
||||
|
||||
// Inject into cipher SDK for session key encrypt/decrypt
|
||||
_cipherSdk.setActiveKeyPair(
|
||||
publicKey: publicPem, privateKey: privatePem);
|
||||
|
||||
// 2. Upload public key if server doesn't have one
|
||||
await _uploadPublicKeyIfNeeded(api, publicPem, privatePem);
|
||||
|
||||
// 3. Load existing key chain from secure storage
|
||||
await _loadKeyChainFromStorage();
|
||||
|
||||
// 4. Fetch and decrypt all chat keys from server
|
||||
final success = await _fetchAndDecryptAllChatKeys(api);
|
||||
if (success) {
|
||||
_isSetup = true;
|
||||
debugPrint('[EncMgr] setup complete, ${_keyChain.length} chats loaded');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] setup failed: $e');
|
||||
} finally {
|
||||
_isSettingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Round / key chain API ─────────────────────────────────────────────────
|
||||
|
||||
/// Current active round for a chat (highest round number), or null.
|
||||
int? currentRound(int chatId) {
|
||||
final rounds = _keyChain[chatId];
|
||||
if (rounds == null || rounds.isEmpty) return null;
|
||||
return rounds.keys.reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
/// Retrieve a specific round's AES key.
|
||||
String? chatKey(int chatId, {int? round}) {
|
||||
final rounds = _keyChain[chatId];
|
||||
if (rounds == null || rounds.isEmpty) return null;
|
||||
if (round != null) return rounds[round];
|
||||
// No round specified — return latest
|
||||
final latest = rounds.keys.reduce((a, b) => a > b ? a : b);
|
||||
return rounds[latest];
|
||||
}
|
||||
|
||||
// ── Encrypt / Decrypt API ─────────────────────────────────────────────────
|
||||
|
||||
/// AES-SIC encrypt plaintext for a chat.
|
||||
/// Returns JSON `{"round":N,"data":"<base64>"}`, or null if no key.
|
||||
///
|
||||
/// 对齐 iOS EncryptionManager.encryptContent()
|
||||
Future<String?> encryptContent(String plaintext, {required int chatId}) async {
|
||||
final round = currentRound(chatId);
|
||||
if (round == null) return null;
|
||||
final key = _keyChain[chatId]?[round];
|
||||
if (key == null) return null;
|
||||
|
||||
try {
|
||||
final result = await _cipherSdk.encryptMessage(
|
||||
plaintext: plaintext,
|
||||
sessionKey: key,
|
||||
round: round,
|
||||
);
|
||||
// Build JSON envelope matching iOS format
|
||||
return jsonEncode({'round': round, 'data': result.data});
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] encrypt failed for chatId=$chatId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to AES-SIC decrypt a message's content field.
|
||||
///
|
||||
/// Accepts two formats:
|
||||
/// - New: JSON `{"round": N, "data": "<base64>"}` — uses round-specific key
|
||||
/// - Legacy: raw Base64 ciphertext — uses latest key for the chat
|
||||
///
|
||||
/// Returns null if no suitable key or decryption fails.
|
||||
Future<String?> decryptContent(String content, {required int chatId}) async {
|
||||
// Try new JSON envelope format first
|
||||
try {
|
||||
final obj = jsonDecode(content);
|
||||
if (obj is Map<String, dynamic> &&
|
||||
obj.containsKey('round') &&
|
||||
obj.containsKey('data')) {
|
||||
final round = obj['round'] as int;
|
||||
final b64Data = obj['data'] as String;
|
||||
final key = _keyChain[chatId]?[round];
|
||||
if (key == null) {
|
||||
debugPrint(
|
||||
'[EncMgr] decrypt: round=$round key missing for chatId=$chatId');
|
||||
return null; // caller should mark ref_typ=4
|
||||
}
|
||||
return await _cipherSdk.decryptMessage(
|
||||
encryptedData: b64Data,
|
||||
sessionKey: key,
|
||||
round: round,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Not JSON — try legacy format
|
||||
}
|
||||
|
||||
// Legacy: raw Base64 — use latest key
|
||||
final key = chatKey(chatId);
|
||||
if (key == null) return null;
|
||||
final round = currentRound(chatId) ?? 0;
|
||||
try {
|
||||
return await _cipherSdk.decryptMessage(
|
||||
encryptedData: content,
|
||||
sessionKey: key,
|
||||
round: round,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] legacy decrypt failed for chatId=$chatId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: server API ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _uploadPublicKeyIfNeeded(
|
||||
NetworksSdkApi api,
|
||||
String publicPem,
|
||||
String privatePem,
|
||||
) async {
|
||||
try {
|
||||
// Check if server already has a key
|
||||
final resp = await api.executeRequest(CipherGetMyKeyRequest());
|
||||
if (resp != null && resp.publicKey.isNotEmpty) {
|
||||
debugPrint('[EncMgr] server already has public key, skipping upload');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] key/my check failed: $e');
|
||||
// Continue — attempt upload anyway
|
||||
}
|
||||
|
||||
// enc_pk: encrypted private key backup (对齐 iOS — uses raw PEM as placeholder)
|
||||
final encPk = await _cipherSdk.encryptPrivateKey(
|
||||
privateKey: privatePem,
|
||||
password: 'default', // placeholder, same as old project demo
|
||||
);
|
||||
|
||||
try {
|
||||
await api.executeRequest(
|
||||
CipherSetKeyRequest(publicKey: publicPem, encPk: encPk),
|
||||
);
|
||||
debugPrint('[EncMgr] public key uploaded OK');
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] key/set upload failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _fetchAndDecryptAllChatKeys(NetworksSdkApi api) async {
|
||||
try {
|
||||
final chatKeys = await api.executeRequest(CipherGetMyChatKeysRequest());
|
||||
if (chatKeys == null || chatKeys.isEmpty) {
|
||||
debugPrint('[EncMgr] no chat keys from server');
|
||||
return true; // Empty is OK
|
||||
}
|
||||
|
||||
for (final ck in chatKeys) {
|
||||
if (ck.session == null || ck.session!.isEmpty) continue;
|
||||
try {
|
||||
// RSA raw decrypt the session → 32-char AES key
|
||||
final decryptedKey =
|
||||
await _cipherSdk.decryptSessionKeyWithActiveKey(
|
||||
encryptedSessionKey: ck.session!,
|
||||
);
|
||||
// Strip leading null bytes (RSA raw output may have leading zeros)
|
||||
final cleanKey = _stripLeadingZeros(decryptedKey);
|
||||
if (cleanKey.length == 32) {
|
||||
_storeKey(cleanKey, ck.chatId ?? 0, ck.round ?? 0);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[EncMgr] key length ${cleanKey.length} != 32 for chatId=${ck.chatId}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[EncMgr] RSA decrypt failed for chatId=${ck.chatId}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Persist key chain
|
||||
await _saveKeyChainToStorage();
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] fetchAndDecryptAllChatKeys failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip leading zero chars from RSA raw decrypt output
|
||||
String _stripLeadingZeros(String input) {
|
||||
var i = 0;
|
||||
while (i < input.length && input.codeUnitAt(i) == 0) {
|
||||
i++;
|
||||
}
|
||||
return i > 0 ? input.substring(i) : input;
|
||||
}
|
||||
|
||||
void _storeKey(String key, int chatId, int round) {
|
||||
_keyChain.putIfAbsent(chatId, () => {});
|
||||
_keyChain[chatId]![round] = key;
|
||||
|
||||
// Evict oldest rounds if over limit
|
||||
final rounds = _keyChain[chatId]!;
|
||||
while (rounds.length > maxKeyChainDepth) {
|
||||
final oldest = rounds.keys.reduce((a, b) => a < b ? a : b);
|
||||
rounds.remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key chain persistence ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> _loadKeyChainFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(_keyChainStorageKey);
|
||||
if (json == null || json.isEmpty) return;
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
final chatId = int.tryParse(entry.key);
|
||||
if (chatId == null) continue;
|
||||
final rounds = entry.value as Map<String, dynamic>;
|
||||
_keyChain[chatId] = {};
|
||||
for (final re in rounds.entries) {
|
||||
final round = int.tryParse(re.key);
|
||||
if (round == null) continue;
|
||||
_keyChain[chatId]![round] = re.value as String;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] loadKeyChain failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveKeyChainToStorage() async {
|
||||
try {
|
||||
final serializable = <String, Map<String, String>>{};
|
||||
for (final entry in _keyChain.entries) {
|
||||
serializable[entry.key.toString()] = entry.value.map(
|
||||
(k, v) => MapEntry(k.toString(), v),
|
||||
);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyChainStorageKey, jsonEncode(serializable));
|
||||
} catch (e) {
|
||||
debugPrint('[EncMgr] saveKeyChain failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// 输入状态枚举 — 对齐 Flutter ChatInputState / iOS ChatInputState
|
||||
///
|
||||
/// state=1 → typing, state=2 → noTyping (clear), 3-7 → media sending
|
||||
enum ChatInputState {
|
||||
typing(1),
|
||||
noTyping(2),
|
||||
sendImage(3),
|
||||
sendVideo(4),
|
||||
sendDocument(5),
|
||||
sendAlbum(6),
|
||||
sendVoice(7);
|
||||
|
||||
const ChatInputState(this.value);
|
||||
final int value;
|
||||
|
||||
static ChatInputState? fromValue(int v) {
|
||||
for (final s in values) {
|
||||
if (s.value == v) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 过期秒数:文字输入 5s,媒体发送 30s(对齐 iOS/Flutter)
|
||||
Duration get expiry {
|
||||
switch (this) {
|
||||
case sendImage:
|
||||
case sendVideo:
|
||||
case sendDocument:
|
||||
case sendAlbum:
|
||||
case sendVoice:
|
||||
return const Duration(seconds: 30);
|
||||
default:
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文展示文本
|
||||
String get displayText {
|
||||
switch (this) {
|
||||
case typing:
|
||||
return '正在输入…';
|
||||
case sendImage:
|
||||
return '正在发送图片…';
|
||||
case sendVideo:
|
||||
return '正在发送视频…';
|
||||
case sendDocument:
|
||||
return '正在发送文件…';
|
||||
case sendAlbum:
|
||||
return '正在发送相册…';
|
||||
case sendVoice:
|
||||
return '正在发送语音…';
|
||||
case noTyping:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条输入条目
|
||||
class _TypingEntry {
|
||||
_TypingEntry({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.state,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String username;
|
||||
final ChatInputState state;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
/// 正在输入状态管理器 — 对齐 iOS TypingIndicatorManager + 性能优化
|
||||
///
|
||||
/// ## 对齐 iOS 行为
|
||||
/// - WS 收到 chat_input/chat_typing 时调用 [handleTypingEvent]
|
||||
/// - 收到真实消息时调用 [clearTyping] 立即清除(不等 5s 过期)
|
||||
/// - 文字输入 5s 过期,媒体发送 30s 过期
|
||||
/// - 单聊显示 "正在输入…",群聊显示 "Alice 正在输入…" / "N 人正在输入…"
|
||||
///
|
||||
/// ## 性能改进(vs iOS)
|
||||
/// - **无常驻 Timer**:仅在有 entry 时启动清理 timer,空时自动取消
|
||||
/// - **精准调度**:timer 对齐最近过期时间,不是每秒轮询
|
||||
/// - **按 chatId 通知**:[typingTextStream] 返回指定 chat 的 Stream,
|
||||
/// 只有该 chat 的状态变化才触发,避免全局重建
|
||||
class TypingIndicatorManager {
|
||||
/// [chatId → [userId → _TypingEntry]]
|
||||
final Map<int, Map<int, _TypingEntry>> _typing = {};
|
||||
|
||||
/// 每个 chatId 一个 StreamController,按需创建
|
||||
final Map<int, StreamController<String?>> _controllers = {};
|
||||
|
||||
/// 全局通知(用于聊天列表等需要监听所有 chat 的场景)
|
||||
final StreamController<void> _globalNotifier =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
Timer? _purgeTimer;
|
||||
|
||||
// ── 接收侧 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 处理 WS 收到的 chat_input/chat_typing 事件
|
||||
///
|
||||
/// [senderId], [chatId], [stateValue] 从 WS 帧解析。
|
||||
/// [username] 用于群聊显示名。
|
||||
void handleTypingEvent({
|
||||
required int chatId,
|
||||
required int senderId,
|
||||
required String username,
|
||||
required int stateValue,
|
||||
}) {
|
||||
final state = ChatInputState.fromValue(stateValue);
|
||||
if (state == null) return;
|
||||
|
||||
if (state == ChatInputState.noTyping) {
|
||||
_typing[chatId]?.remove(senderId);
|
||||
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
|
||||
} else {
|
||||
_typing.putIfAbsent(chatId, () => {});
|
||||
_typing[chatId]![senderId] = _TypingEntry(
|
||||
userId: senderId,
|
||||
username: username,
|
||||
state: state,
|
||||
expiresAt: DateTime.now().add(state.expiry),
|
||||
);
|
||||
}
|
||||
|
||||
_notify(chatId);
|
||||
_schedulePurge();
|
||||
}
|
||||
|
||||
/// 收到真实消息时立即清除该发送者的输入状态
|
||||
/// (对齐 iOS: TypingIndicatorManager.clearTyping + .didReceiveChatMessage)
|
||||
void clearTyping({required int chatId, required int senderId}) {
|
||||
if (_typing[chatId]?.remove(senderId) != null) {
|
||||
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
|
||||
_notify(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 查询侧 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 单聊:返回第一个活跃输入者的状态文本,排除自己
|
||||
/// 对齐 iOS displayText(for:excludingUserId:)
|
||||
String? displayText({required int chatId, required int myUserId}) {
|
||||
final entries = _typing[chatId]?.values;
|
||||
if (entries == null || entries.isEmpty) return null;
|
||||
for (final e in entries) {
|
||||
if (e.userId != myUserId && e.state != ChatInputState.noTyping) {
|
||||
return e.state.displayText;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 群聊:多人输入显示规则
|
||||
/// - 1 人 → "Alice 正在输入…"
|
||||
/// - 2-3 人 → "Alice、Bob 正在输入…"
|
||||
/// - 4+ 人 → "N 人正在输入…"
|
||||
/// 对齐 iOS groupDisplayText(for:groupId:excludingUserId:)
|
||||
String? groupDisplayText({required int chatId, required int myUserId}) {
|
||||
final entries = _typing[chatId]?.values;
|
||||
if (entries == null || entries.isEmpty) return null;
|
||||
final active = entries
|
||||
.where((e) => e.userId != myUserId && e.state != ChatInputState.noTyping)
|
||||
.toList();
|
||||
if (active.isEmpty) return null;
|
||||
|
||||
switch (active.length) {
|
||||
case 1:
|
||||
final name = active[0].username.isNotEmpty
|
||||
? active[0].username
|
||||
: '用户${active[0].userId}';
|
||||
return '$name ${active[0].state.displayText}';
|
||||
case 2:
|
||||
case 3:
|
||||
final names = active
|
||||
.map((e) => e.username.isNotEmpty ? e.username : '用户${e.userId}')
|
||||
.join('、');
|
||||
return '$names 正在输入…';
|
||||
default:
|
||||
return '${active.length} 人正在输入…';
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定 chatId 的输入状态 Stream
|
||||
///
|
||||
/// 每次该 chatId 的输入状态变化时发出新的显示文本(null = 无人输入)。
|
||||
/// 懒创建 StreamController,dispose 时自动清理。
|
||||
Stream<String?> typingTextStream({
|
||||
required int chatId,
|
||||
required int myUserId,
|
||||
bool isGroup = false,
|
||||
}) {
|
||||
final ctrl = _controllers.putIfAbsent(
|
||||
chatId,
|
||||
() => StreamController<String?>.broadcast(),
|
||||
);
|
||||
// 立即发出当前状态
|
||||
final current = isGroup
|
||||
? groupDisplayText(chatId: chatId, myUserId: myUserId)
|
||||
: displayText(chatId: chatId, myUserId: myUserId);
|
||||
return ctrl.stream.transform(
|
||||
StreamTransformer.fromHandlers(
|
||||
handleData: (data, sink) => sink.add(data),
|
||||
),
|
||||
).transform(_StartWithTransformer(current));
|
||||
}
|
||||
|
||||
/// 全局变化通知(聊天列表用)
|
||||
Stream<void> get globalChangeStream => _globalNotifier.stream;
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────────────────
|
||||
|
||||
void _notify(int chatId) {
|
||||
// 通知该 chatId 的订阅者
|
||||
final ctrl = _controllers[chatId];
|
||||
if (ctrl != null && !ctrl.isClosed) {
|
||||
// 不知道订阅者的 myUserId / isGroup,所以传 null 让 UI 层自己查
|
||||
ctrl.add(null); // sentinel — UI 层重新查询 displayText
|
||||
}
|
||||
// 通知全局订阅者
|
||||
if (!_globalNotifier.isClosed) {
|
||||
_globalNotifier.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能清理调度 — 对齐最近过期时间,非 1s 轮询
|
||||
///
|
||||
/// iOS 问题:1s Timer 永远运行,即使 typing 为空。
|
||||
/// 改进:找到最近过期的 entry,只在那个时间点触发一次清理。
|
||||
void _schedulePurge() {
|
||||
_purgeTimer?.cancel();
|
||||
|
||||
if (_typing.isEmpty) {
|
||||
_purgeTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 找最近过期时间
|
||||
DateTime? earliest;
|
||||
for (final chatEntries in _typing.values) {
|
||||
for (final entry in chatEntries.values) {
|
||||
if (earliest == null || entry.expiresAt.isBefore(earliest)) {
|
||||
earliest = entry.expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earliest == null) return;
|
||||
|
||||
final delay = earliest.difference(DateTime.now());
|
||||
// 至少 100ms 防止 busy-loop
|
||||
final safeDuration =
|
||||
delay.isNegative ? const Duration(milliseconds: 100) : delay;
|
||||
|
||||
_purgeTimer = Timer(safeDuration, _purgeExpired);
|
||||
}
|
||||
|
||||
void _purgeExpired() {
|
||||
final now = DateTime.now();
|
||||
final changedChats = <int>{};
|
||||
|
||||
for (final chatId in _typing.keys.toList()) {
|
||||
final entries = _typing[chatId]!;
|
||||
final before = entries.length;
|
||||
entries.removeWhere((_, entry) => entry.expiresAt.isBefore(now));
|
||||
if (entries.isEmpty) _typing.remove(chatId);
|
||||
if ((entries.length) != before) changedChats.add(chatId);
|
||||
}
|
||||
|
||||
for (final chatId in changedChats) {
|
||||
_notify(chatId);
|
||||
}
|
||||
|
||||
// 如果还有 entry,继续调度下一次
|
||||
_schedulePurge();
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
_purgeTimer?.cancel();
|
||||
for (final ctrl in _controllers.values) {
|
||||
ctrl.close();
|
||||
}
|
||||
_controllers.clear();
|
||||
_globalNotifier.close();
|
||||
_typing.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 Stream 前插入一个初始值
|
||||
class _StartWithTransformer<T> extends StreamTransformerBase<T, T> {
|
||||
_StartWithTransformer(this._initial);
|
||||
final T _initial;
|
||||
|
||||
@override
|
||||
Stream<T> bind(Stream<T> stream) async* {
|
||||
yield _initial;
|
||||
yield* stream;
|
||||
}
|
||||
}
|
||||
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
|
||||
/// 发送侧"正在输入" WS 事件管理器
|
||||
///
|
||||
/// 对齐 iOS `TypingInputSender.swift`:
|
||||
/// - 节流 3s:用户输入时最多每 3s 发一次 {typ:1}
|
||||
/// - 防抖 2s:用户停止输入 2s 后自动发 {typ:0}
|
||||
/// - 消息发送 / 文本清空时立即发 {typ:0}
|
||||
///
|
||||
/// ## 改进(vs iOS TypingInputSender.swift)
|
||||
///
|
||||
/// iOS 版用全局单一 `lastTypingSentAt` / `stopTask`,当用户在 3s 内切换聊天时
|
||||
/// 会导致:chat A 的 stop timer 被 chat B 覆盖,chat A 输入状态无法正常清除。
|
||||
/// Flutter 版改为 **per-chatId** 节流/防抖,彻底消除跨 chat 竞态。
|
||||
///
|
||||
/// WS 发送格式(对齐 iOS TypingInputSender.sendAction):
|
||||
/// ```json
|
||||
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 1} // 开始输入
|
||||
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 0} // 停止输入
|
||||
/// ```
|
||||
class TypingInputSender {
|
||||
TypingInputSender({required SocketManager socketManager})
|
||||
: _socketManager = socketManager;
|
||||
|
||||
final SocketManager _socketManager;
|
||||
|
||||
/// 节流间隔:最多每 3s 发一次 typ=1(对齐 iOS inputThrottleInterval = 3.0)
|
||||
static const _throttleInterval = Duration(seconds: 3);
|
||||
|
||||
/// 停止输入防抖:最后一次击键 2s 后发 typ=0(对齐 iOS inputStopDelay = 2.0)
|
||||
static const _stopDelay = Duration(seconds: 2);
|
||||
|
||||
/// Per-chatId 节流时间戳(防止跨 chat 竞态)
|
||||
final Map<int, DateTime> _lastSentAt = {};
|
||||
|
||||
/// Per-chatId 停止计时器
|
||||
final Map<int, Timer> _stopTimers = {};
|
||||
|
||||
/// 用户输入文本变化时调用
|
||||
///
|
||||
/// [text] 为当前输入框全文。
|
||||
/// - 非空:节流发 typ=1 + 重置 2s 停止计时
|
||||
/// - 空(清空输入框):立即发 typ=0
|
||||
///
|
||||
/// 对齐 iOS ChatViewModel.onComposerTextChanged
|
||||
void onTextChanged({required int chatId, required String text}) {
|
||||
final isTyping = text.trim().isNotEmpty;
|
||||
|
||||
if (isTyping) {
|
||||
// 重置该 chat 的 2s 停止计时
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers[chatId] = Timer(_stopDelay, () {
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
_lastSentAt.remove(chatId);
|
||||
_stopTimers.remove(chatId);
|
||||
});
|
||||
|
||||
// 节流:该 chat 距上次发送不足 3s 则跳过
|
||||
final now = DateTime.now();
|
||||
final lastSent = _lastSentAt[chatId];
|
||||
if (lastSent != null && now.difference(lastSent) < _throttleInterval) {
|
||||
return;
|
||||
}
|
||||
_lastSentAt[chatId] = now;
|
||||
_sendAction(chatId: chatId, typ: 1);
|
||||
} else {
|
||||
// 文本清空 → 立即停止
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers.remove(chatId);
|
||||
if (_lastSentAt.containsKey(chatId)) {
|
||||
_lastSentAt.remove(chatId);
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息发送成功后调用 — 立即发 typ=0
|
||||
///
|
||||
/// 对齐 iOS TypingInputSender.notifyStopTyping
|
||||
void notifyStopTyping({required int chatId}) {
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers.remove(chatId);
|
||||
_lastSentAt.remove(chatId);
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
}
|
||||
|
||||
void _sendAction({required int chatId, required int typ}) {
|
||||
final payload = {
|
||||
'action': 'ACTION_SENDINPUT_MSG',
|
||||
'chat_id': chatId,
|
||||
'typ': typ,
|
||||
};
|
||||
final text = jsonEncode(payload);
|
||||
_socketManager.sendString(text);
|
||||
debugPrint('[TypingInputSender] chat=$chatId typ=$typ');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final timer in _stopTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_stopTimers.clear();
|
||||
_lastSentAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/services/encryption_manager.dart';
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
import 'package:im_app/core/services/typing_indicator_manager.dart';
|
||||
import 'package:im_app/data/remote/fetch_history_request.dart';
|
||||
import 'package:im_app/domain/repositories/chat_repository.dart';
|
||||
import 'package:im_app/domain/repositories/message_repository.dart';
|
||||
@@ -31,6 +33,8 @@ class WsMessageService {
|
||||
final NetworksSdkApi _apiClient;
|
||||
final MessageRepository _messageRepo;
|
||||
final ChatRepository _chatRepo;
|
||||
final TypingIndicatorManager _typingManager;
|
||||
final EncryptionManager? _encryptionManager;
|
||||
|
||||
StreamSubscription<Map<String, dynamic>>? _sub;
|
||||
|
||||
@@ -39,10 +43,14 @@ class WsMessageService {
|
||||
required NetworksSdkApi apiClient,
|
||||
required MessageRepository messageRepo,
|
||||
required ChatRepository chatRepo,
|
||||
required TypingIndicatorManager typingManager,
|
||||
EncryptionManager? encryptionManager,
|
||||
}) : _socketManager = socketManager,
|
||||
_apiClient = apiClient,
|
||||
_messageRepo = messageRepo,
|
||||
_chatRepo = chatRepo;
|
||||
_chatRepo = chatRepo,
|
||||
_typingManager = typingManager,
|
||||
_encryptionManager = encryptionManager;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,6 +73,26 @@ class WsMessageService {
|
||||
|
||||
Future<void> _handleFrame(Map<String, dynamic> frame) async {
|
||||
try {
|
||||
// ── chat_input / chat_typing — 正在输入状态 ─────────────────────────
|
||||
// mode2: {"chat_input": {"r": [{"send_id": N, "chat_id": N, "username": "Alice", "state": N}]}}
|
||||
// ctl: {"ctl": "chat_input", "r": [...]}
|
||||
// 对齐 iOS MessageReceiver.handleChatInput
|
||||
final typingPayload = frame['chat_input'] as Map<String, dynamic>?
|
||||
?? frame['chat_typing'] as Map<String, dynamic>?;
|
||||
if (typingPayload != null) {
|
||||
_handleTypingFrame(typingPayload);
|
||||
}
|
||||
// ctl 路径
|
||||
final ctl = frame['ctl'] as String?;
|
||||
if (ctl == 'chat_input' || ctl == 'chat_typing') {
|
||||
final ctlData = frame['data'] as List<dynamic>?
|
||||
?? frame['r'] as List<dynamic>?;
|
||||
if (ctlData != null) {
|
||||
_handleTypingFrame({'r': ctlData});
|
||||
}
|
||||
}
|
||||
|
||||
// ── chat.r — 新消息通知 ────────────────────────────────────────────
|
||||
final chatPayload = frame['chat'] as Map<String, dynamic>?;
|
||||
if (chatPayload == null) return;
|
||||
|
||||
@@ -79,6 +107,12 @@ class WsMessageService {
|
||||
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
|
||||
if (chatId == null || msgIdx == null) continue;
|
||||
|
||||
// 收到真实消息 → 清除发送者的输入状态(对齐 iOS .didReceiveChatMessage)
|
||||
final sendId = (entry['send_id'] as num?)?.toInt();
|
||||
if (sendId != null) {
|
||||
_typingManager.clearTyping(chatId: chatId, senderId: sendId);
|
||||
}
|
||||
|
||||
await _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx);
|
||||
await _updateChatMeta(chatId: chatId, entry: entry);
|
||||
}
|
||||
@@ -87,6 +121,40 @@ class WsMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 chat_input/chat_typing 帧
|
||||
void _handleTypingFrame(Map<String, dynamic> payload) {
|
||||
final rList = payload['r'] as List<dynamic>?;
|
||||
if (rList == null) return;
|
||||
|
||||
for (final item in rList) {
|
||||
final entry = item as Map<String, dynamic>?;
|
||||
if (entry == null) continue;
|
||||
|
||||
// 兼容 Int / String 类型(对齐 iOS handleChatInput 的 flatMap(Int.init))
|
||||
final senderId = _parseInt(entry['send_id']);
|
||||
final chatId = _parseInt(entry['chat_id']);
|
||||
final stateValue = _parseInt(entry['state']) ?? _parseInt(entry['typ']);
|
||||
final username = entry['username'] as String? ?? '';
|
||||
|
||||
if (senderId == null || chatId == null || stateValue == null) continue;
|
||||
|
||||
_typingManager.handleTypingEvent(
|
||||
chatId: chatId,
|
||||
senderId: senderId,
|
||||
username: username,
|
||||
stateValue: stateValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解析 int — 兼容 int / String / num(对齐 iOS Int ?? String→Int)
|
||||
static int? _parseInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 通过 HTTP 拉取消息并写入 DB
|
||||
Future<void> _fetchAndSaveMessages({
|
||||
required int chatId,
|
||||
@@ -98,7 +166,21 @@ class WsMessageService {
|
||||
);
|
||||
if (response == null || response.messages.isEmpty) return;
|
||||
|
||||
final entities = response.messages.map((m) => m.toEntity()).toList();
|
||||
// E2E 解密:对齐 iOS MessageReceiver — 收到消息后解密 content
|
||||
var entities = response.messages.map((m) => m.toEntity()).toList();
|
||||
final encMgr = _encryptionManager;
|
||||
if (encMgr != null && encMgr.isSetup) {
|
||||
entities = await Future.wait(entities.map((msg) async {
|
||||
if (msg.content == null || msg.content!.isEmpty) return msg;
|
||||
final decrypted = await encMgr.decryptContent(
|
||||
msg.content!,
|
||||
chatId: chatId,
|
||||
);
|
||||
// decrypted == null → 无法解密或无 key → 保留原文(对齐 iOS fallback)
|
||||
return decrypted != null ? msg.copyWith(content: decrypted) : msg;
|
||||
}));
|
||||
}
|
||||
|
||||
await _messageRepo.insertOrReplaceAll(entities);
|
||||
debugPrint(
|
||||
'[WsMessageService] saved ${entities.length} messages for chat $chatId',
|
||||
@@ -109,6 +191,9 @@ class WsMessageService {
|
||||
}
|
||||
|
||||
/// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx
|
||||
///
|
||||
/// 包含 msgIdx 守卫:如果帧中的 msgIdx 小于 DB 已有值,说明是乱序到达的旧帧,
|
||||
/// 跳过更新防止 lastMsg 被旧数据覆盖。
|
||||
Future<void> _updateChatMeta({
|
||||
required int chatId,
|
||||
required Map<String, dynamic> entry,
|
||||
@@ -117,10 +202,28 @@ class WsMessageService {
|
||||
final existing = await _chatRepo.getChat(chatId);
|
||||
if (existing == null) return;
|
||||
|
||||
final lastMsg = entry['last_msg'] as String?;
|
||||
var lastMsg = entry['last_msg'] as String?;
|
||||
final lastTyp = (entry['typ'] as num?)?.toInt();
|
||||
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
|
||||
|
||||
// E2E 解密 lastMsg(对齐 iOS ConversationSnippetCache 解密逻辑)
|
||||
final encMgr = _encryptionManager;
|
||||
if (lastMsg != null && lastMsg.isNotEmpty &&
|
||||
encMgr != null && encMgr.isSetup) {
|
||||
final decrypted = await encMgr.decryptContent(lastMsg, chatId: chatId);
|
||||
if (decrypted != null) {
|
||||
lastMsg = decrypted;
|
||||
} else if (lastMsg.startsWith('{')) {
|
||||
// 无法解密且看起来是 JSON 密文 → 不在 UI 显示密文
|
||||
lastMsg = '[Encrypted message]';
|
||||
}
|
||||
}
|
||||
|
||||
// 防止乱序帧覆盖新数据(Codex review #4)
|
||||
if (msgIdx != null && existing.msgIdx != null && msgIdx < existing.msgIdx!) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _chatRepo.updateChat(
|
||||
existing.copyWith(
|
||||
lastMsg: lastMsg ?? existing.lastMsg,
|
||||
|
||||
142
apps/im_app/lib/data/remote/call_log_request.dart
Normal file
142
apps/im_app/lib/data/remote/call_log_request.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/call_log.dart';
|
||||
|
||||
// ── 通话记录 DTO ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 服务端单条通话记录 DTO
|
||||
///
|
||||
/// 字段对齐 `im-client-im-dev` Call.fromJson:
|
||||
/// - `rtc_channel_id` → [id](主键,String)
|
||||
/// - `inviter_id` / `caller_id` → [callerId]
|
||||
/// - `video_call` → [videoCall](0=语音, 1=视频)
|
||||
/// - `status` = 3/4/5/6 → 未接来电(配合客户端侧判断)
|
||||
class CallLogDto {
|
||||
final String id;
|
||||
final int? callerId;
|
||||
final int? receiverId;
|
||||
final int? chatId;
|
||||
final int? duration;
|
||||
final int? videoCall;
|
||||
final int? createdAt;
|
||||
final int? updatedAt;
|
||||
final int? endedAt;
|
||||
final int? status;
|
||||
final int? isDeleted;
|
||||
final int? deletedAt;
|
||||
final int? isRead;
|
||||
|
||||
const CallLogDto({
|
||||
required this.id,
|
||||
this.callerId,
|
||||
this.receiverId,
|
||||
this.chatId,
|
||||
this.duration,
|
||||
this.videoCall,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.endedAt,
|
||||
this.status,
|
||||
this.isDeleted,
|
||||
this.deletedAt,
|
||||
this.isRead,
|
||||
});
|
||||
|
||||
factory CallLogDto.fromJson(Map<String, dynamic> json) => CallLogDto(
|
||||
id: (json['rtc_channel_id'] ?? json['channel_id'] ?? json['id'] ?? '')
|
||||
.toString(),
|
||||
callerId:
|
||||
(json['inviter_id'] ?? json['caller_id'] as num?)?.toInt(),
|
||||
receiverId: (json['receiver_id'] as num?)?.toInt(),
|
||||
chatId: (json['chat_id'] as num?)?.toInt(),
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
videoCall: (json['video_call'] as num?)?.toInt(),
|
||||
createdAt: (json['created_at'] as num?)?.toInt(),
|
||||
updatedAt: (json['updated_at'] as num?)?.toInt(),
|
||||
endedAt: (json['ended_at'] as num?)?.toInt(),
|
||||
status: (json['status'] as num?)?.toInt(),
|
||||
isDeleted: (json['is_deleted'] as num?)?.toInt(),
|
||||
deletedAt: (json['deleted_at'] as num?)?.toInt(),
|
||||
isRead: (json['is_read'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
CallLog toEntity() => CallLog(
|
||||
id: id,
|
||||
callerId: callerId,
|
||||
receiverId: receiverId,
|
||||
chatId: chatId,
|
||||
duration: duration,
|
||||
videoCall: videoCall,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
endedAt: endedAt,
|
||||
status: status,
|
||||
isDeleted: isDeleted,
|
||||
deletedAt: deletedAt,
|
||||
isRead: isRead,
|
||||
);
|
||||
}
|
||||
|
||||
// ── FetchCallLogsResponse ────────────────────────────────────────────────────
|
||||
|
||||
class FetchCallLogsResponse {
|
||||
final List<CallLogDto> records;
|
||||
|
||||
const FetchCallLogsResponse({required this.records});
|
||||
|
||||
factory FetchCallLogsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final list = json['list'] ?? json['records'] ?? json['data'] ?? [];
|
||||
return FetchCallLogsResponse(
|
||||
records: (list as List)
|
||||
.map((item) => CallLogDto.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── FetchCallLogsRequest ─────────────────────────────────────────────────────
|
||||
|
||||
/// POST /app/api/call/records — 拉取通话记录
|
||||
///
|
||||
/// [startFrom] Unix 时间戳(秒),增量拉取用;0 表示全量。
|
||||
/// [status] -1 = 全部状态。
|
||||
class FetchCallLogsRequest
|
||||
extends ApiRequestable<FetchCallLogsResponse> {
|
||||
final int startFrom;
|
||||
final int status;
|
||||
|
||||
const FetchCallLogsRequest({
|
||||
this.startFrom = 0,
|
||||
this.status = -1,
|
||||
});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.callRecords;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'start_from': startFrom,
|
||||
'status': status,
|
||||
};
|
||||
|
||||
@override
|
||||
FetchCallLogsResponse? decodeResponse(dynamic response) {
|
||||
final data = (response as dynamic).data;
|
||||
if (data == null) return FetchCallLogsResponse(records: []);
|
||||
if (data is List) {
|
||||
return FetchCallLogsResponse(
|
||||
records: data
|
||||
.map((item) => CallLogDto.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
return FetchCallLogsResponse.fromJson(data);
|
||||
}
|
||||
return FetchCallLogsResponse(records: []);
|
||||
}
|
||||
}
|
||||
170
apps/im_app/lib/data/remote/cipher_api_requests.dart
Normal file
170
apps/im_app/lib/data/remote/cipher_api_requests.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
/// 对齐老项目 /app/api/cipher/v2/* API 端点
|
||||
///
|
||||
/// /app/api/cipher/v2/key/set 是预发布接口,仅测试阶段使用。
|
||||
|
||||
// ── GET /app/api/cipher/v2/key/my — 获取自己的公钥 ────────────────────────
|
||||
|
||||
class CipherMyKeyResponse {
|
||||
final String publicKey;
|
||||
final String? encPrivate;
|
||||
final int? uid;
|
||||
|
||||
const CipherMyKeyResponse({
|
||||
required this.publicKey,
|
||||
this.encPrivate,
|
||||
this.uid,
|
||||
});
|
||||
|
||||
factory CipherMyKeyResponse.fromJson(Map<String, dynamic> json) =>
|
||||
CipherMyKeyResponse(
|
||||
publicKey: (json['public_key'] ?? '') as String,
|
||||
encPrivate: json['enc_pk'] as String?,
|
||||
uid: json['uid'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
class CipherGetMyKeyRequest extends ApiRequestable<CipherMyKeyResponse?> {
|
||||
@override
|
||||
String get path => '/app/api/cipher/v2/key/my';
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.get;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {};
|
||||
|
||||
@override
|
||||
CipherMyKeyResponse? decodeResponse(dynamic response) {
|
||||
final data = (response as dynamic).data;
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
return CipherMyKeyResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /app/api/cipher/v2/key/set — 上传公钥 ─────────────────────────────
|
||||
|
||||
class CipherSetKeyRequest extends ApiRequestable<void> {
|
||||
final String publicKey;
|
||||
final String encPk;
|
||||
|
||||
CipherSetKeyRequest({required this.publicKey, required this.encPk});
|
||||
|
||||
@override
|
||||
String get path => '/app/api/cipher/v2/key/set';
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'public_key': publicKey,
|
||||
'enc_pk': encPk,
|
||||
};
|
||||
|
||||
@override
|
||||
void decodeResponse(dynamic response) {}
|
||||
}
|
||||
|
||||
// ── GET /app/api/cipher/v2/chat/my — 获取所有聊天的加密密钥 ─────────────────
|
||||
|
||||
class CipherChatKeyItem {
|
||||
final int? chatId;
|
||||
final String? session;
|
||||
final int? round;
|
||||
|
||||
const CipherChatKeyItem({this.chatId, this.session, this.round});
|
||||
|
||||
factory CipherChatKeyItem.fromJson(Map<String, dynamic> json) =>
|
||||
CipherChatKeyItem(
|
||||
chatId: json['chat_id'] as int?,
|
||||
session: json['session'] as String?,
|
||||
round: json['round'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
class CipherGetMyChatKeysRequest
|
||||
extends ApiRequestable<List<CipherChatKeyItem>?> {
|
||||
@override
|
||||
String get path => '/app/api/cipher/v2/chat/my';
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.get;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {};
|
||||
|
||||
@override
|
||||
List<CipherChatKeyItem>? decodeResponse(dynamic response) {
|
||||
final data = (response as dynamic).data;
|
||||
if (data is! List) return null;
|
||||
return data
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(CipherChatKeyItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /app/api/cipher/v2/key/gets — 获取其他用户的公钥 ─────────────────────
|
||||
|
||||
class CipherUserKeyResponse {
|
||||
final int? uid;
|
||||
final String? publicKey;
|
||||
|
||||
const CipherUserKeyResponse({this.uid, this.publicKey});
|
||||
|
||||
factory CipherUserKeyResponse.fromJson(Map<String, dynamic> json) =>
|
||||
CipherUserKeyResponse(
|
||||
uid: json['uid'] as int?,
|
||||
publicKey: json['public_key'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class CipherGetUsersKeysRequest
|
||||
extends ApiRequestable<List<CipherUserKeyResponse>?> {
|
||||
final List<int> userIds;
|
||||
|
||||
CipherGetUsersKeysRequest({required this.userIds});
|
||||
|
||||
@override
|
||||
String get path => '/app/api/cipher/v2/key/gets';
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.get;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'uids': userIds.join(','),
|
||||
};
|
||||
|
||||
@override
|
||||
List<CipherUserKeyResponse>? decodeResponse(dynamic response) {
|
||||
final data = (response as dynamic).data;
|
||||
if (data is! List) return null;
|
||||
return data
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(CipherUserKeyResponse.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /app/api/cipher/v2/chat/update — 更新聊天加密密钥 ──────────────────
|
||||
|
||||
class CipherUpdateChatKeysRequest extends ApiRequestable<void> {
|
||||
final List<Map<String, dynamic>> sessions;
|
||||
|
||||
CipherUpdateChatKeysRequest({required this.sessions});
|
||||
|
||||
@override
|
||||
String get path => '/app/api/cipher/v2/chat/update';
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {'sessions': sessions};
|
||||
|
||||
@override
|
||||
void decodeResponse(dynamic response) {}
|
||||
}
|
||||
166
apps/im_app/lib/data/remote/fetch_favorites_request.dart
Normal file
166
apps/im_app/lib/data/remote/fetch_favorites_request.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/favorite.dart';
|
||||
|
||||
// ── 收藏列表 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 收藏条目解码辅助
|
||||
///
|
||||
/// 服务端的 `data`、`typ`、`tag` 字段均为 JSON 编码的字符串,
|
||||
/// 例如 `"[{...}]"` 而不是实际 JSON 数组。直接存 String 到 DB。
|
||||
class _FavoriteItemJson {
|
||||
final int id;
|
||||
final String parentId;
|
||||
final String data; // JSON-encoded string: "[{relatedId,content,typ,...}]"
|
||||
final int createdAt;
|
||||
final int updatedAt;
|
||||
final int? source;
|
||||
final int? userId;
|
||||
final int? authorId;
|
||||
final int isPin;
|
||||
final int chatTyp;
|
||||
final String typ; // JSON-encoded string: "[1,2]"
|
||||
final String tag; // JSON-encoded string: "[\"tag1\"]"
|
||||
final String urls; // JSON-encoded string: "[\"url1\"]"
|
||||
|
||||
const _FavoriteItemJson({
|
||||
required this.id,
|
||||
required this.parentId,
|
||||
required this.data,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.source,
|
||||
this.userId,
|
||||
this.authorId,
|
||||
required this.isPin,
|
||||
required this.chatTyp,
|
||||
required this.typ,
|
||||
required this.tag,
|
||||
required this.urls,
|
||||
});
|
||||
|
||||
factory _FavoriteItemJson.fromJson(Map<String, dynamic> json) {
|
||||
// data / typ / tag / urls 可能是 Array 或 JSON-encoded String(服务端不稳定)
|
||||
String _toJsonStr(dynamic v) {
|
||||
if (v == null) return '[]';
|
||||
if (v is String) return v;
|
||||
// Array/object from server — re-encode as JSON string
|
||||
try {
|
||||
return jsonEncode(v);
|
||||
} catch (_) {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
return _FavoriteItemJson(
|
||||
id: json['id'] as int? ?? 0,
|
||||
parentId: json['parent_id'] as String? ?? '',
|
||||
data: _toJsonStr(json['data']),
|
||||
createdAt: json['created_at'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as int? ?? 0,
|
||||
source: json['source'] as int?,
|
||||
userId: json['user_id'] as int?,
|
||||
authorId: json['author_id'] as int?,
|
||||
isPin: json['is_pin'] as int? ?? 0,
|
||||
chatTyp: json['chat_typ'] as int? ?? 0,
|
||||
typ: _toJsonStr(json['typ']),
|
||||
tag: _toJsonStr(json['tag']),
|
||||
urls: _toJsonStr(json['urls']),
|
||||
);
|
||||
}
|
||||
|
||||
Favorite toEntity() => Favorite(
|
||||
id: id,
|
||||
parentId: parentId,
|
||||
data: data,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
source: source,
|
||||
userId: userId,
|
||||
authorId: authorId,
|
||||
isPin: isPin,
|
||||
chatTyp: chatTyp,
|
||||
typ: typ,
|
||||
tag: tag,
|
||||
isUploaded: 1,
|
||||
urls: urls,
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏列表响应(包含分页信息)
|
||||
class FetchFavoritesResponse {
|
||||
final List<Favorite> items;
|
||||
|
||||
const FetchFavoritesResponse({required this.items});
|
||||
}
|
||||
|
||||
/// GET /app/api/favorite/favorites?page={n}
|
||||
///
|
||||
/// 对应 Gitea issue #42
|
||||
/// iOS 参考:`FavouriteService.fetchList(page:timestamp:)`
|
||||
class FetchFavoritesRequest extends ApiRequestable<FetchFavoritesResponse> {
|
||||
final int page;
|
||||
final int? timestamp;
|
||||
|
||||
const FetchFavoritesRequest({this.page = 1, this.timestamp});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.favoriteFetchList;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.get;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {
|
||||
'page': page,
|
||||
if (timestamp != null) 'timestamp': timestamp,
|
||||
};
|
||||
|
||||
@override
|
||||
FetchFavoritesResponse? decodeResponse(dynamic response) {
|
||||
final raw = (response as dynamic).data;
|
||||
// data 字段:服务端可能返回 {list:[...]} 或直接 [...]
|
||||
List<dynamic> list;
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map<String, dynamic>) {
|
||||
final l = raw['list'];
|
||||
list = l is List ? l : [];
|
||||
} else {
|
||||
return const FetchFavoritesResponse(items: []);
|
||||
}
|
||||
final items = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(_FavoriteItemJson.fromJson)
|
||||
.map((e) => e.toEntity())
|
||||
.toList();
|
||||
return FetchFavoritesResponse(items: items);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除收藏 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /app/api/favorite/delete body: id={id}
|
||||
///
|
||||
/// 对应 Gitea issue #43
|
||||
/// iOS 参考:`FavouriteService.deleteFavourite(id:)`
|
||||
class DeleteFavoriteRequest extends ApiRequestable<bool> {
|
||||
final int id;
|
||||
|
||||
const DeleteFavoriteRequest({required this.id});
|
||||
|
||||
@override
|
||||
String get path => ApiPaths.favoriteDelete;
|
||||
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.post;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get parameters => {'id': id};
|
||||
|
||||
@override
|
||||
bool? decodeResponse(dynamic response) => true;
|
||||
}
|
||||
@@ -42,8 +42,12 @@ class MessageItem {
|
||||
atUsers: json['at_users'] as String?,
|
||||
);
|
||||
|
||||
/// [id] 使用服务端 [messageId](必须 > 0)作为主键,确保多条消息在 DB 中唯一共存。
|
||||
/// 若 messageId 为 0(防御),退化为负时间戳临时 ID。
|
||||
Message toEntity() => Message(
|
||||
id: 0,
|
||||
id: messageId > 0
|
||||
? messageId
|
||||
: -(DateTime.now().microsecondsSinceEpoch),
|
||||
messageId: messageId,
|
||||
chatId: chatId,
|
||||
chatIdx: chatIdx,
|
||||
|
||||
@@ -182,9 +182,32 @@ class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
@JsonKey(name: 'vcode_token')
|
||||
final String vcodeToken;
|
||||
|
||||
/// 二级密码(MD5 哈希后的值)。
|
||||
/// 账号未设置二级密码时传 null,字段不序列化到请求体。
|
||||
/// 对齐旧版:`accountLogin({String? password})` → 有值才加入 dataBody
|
||||
@JsonKey(name: 'password', includeToJson: false) // 由下方 toJson() 手动控制
|
||||
final String? password;
|
||||
|
||||
LoginRequest({
|
||||
required this.countryCode,
|
||||
required this.contact,
|
||||
required this.vcodeToken,
|
||||
this.password,
|
||||
});
|
||||
|
||||
/// 手动 override toJson() 以支持 password 条件序列化。
|
||||
/// 类的 override 优先于 mixin(_$LoginRequestApi.toJson),
|
||||
/// 即使 build_runner 重新生成 .g.dart 也不影响此行为。
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{
|
||||
'country_code': countryCode,
|
||||
'contact': contact,
|
||||
'vcode_token': vcodeToken,
|
||||
};
|
||||
if (password != null) {
|
||||
map['password'] = password;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ class SendMessageResponse {
|
||||
required int typ,
|
||||
}) =>
|
||||
Message(
|
||||
id: 0,
|
||||
id: messageId > 0
|
||||
? messageId
|
||||
: -(DateTime.now().microsecondsSinceEpoch),
|
||||
messageId: messageId,
|
||||
chatId: chatId,
|
||||
chatIdx: chatIdx,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/core/foundation/errors.dart';
|
||||
import 'package:im_app/core/foundation/exceptions.dart';
|
||||
|
||||
part 'verify_otp_request.g.dart';
|
||||
|
||||
@@ -61,4 +64,29 @@ class VerifyOtpRequest extends ApiRequestable<VerifyOtpResponse>
|
||||
this.email = '',
|
||||
this.type = 1,
|
||||
});
|
||||
|
||||
/// 拦截二级密码错误码 30164,将服务端 data 中的 vcode_token 等字段
|
||||
/// 包装为 [SecondaryPasscodeRequiredException] 抛出,供上层导航至
|
||||
/// 二级密码输入界面。其余情况委托给基类处理。
|
||||
@override
|
||||
VerifyOtpResponse? decodeResponse(Response response) {
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
final rawCode = json['code'];
|
||||
final code = rawCode is int
|
||||
? rawCode
|
||||
: int.tryParse(rawCode?.toString() ?? '') ?? 0;
|
||||
|
||||
if (code == ApiErrorCodes.secondaryPasscodeRequired) {
|
||||
final data = json['data'] as Map<String, dynamic>?;
|
||||
throw SecondaryPasscodeRequiredException(
|
||||
vcodeToken: data?['vcode_token'] as String? ?? '',
|
||||
recoveryEmail: data?['recovery_email'] as String? ?? '',
|
||||
hint: data?['hint'] as String? ?? '',
|
||||
resetStatus: data?['reset_status'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
return super.decodeResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,31 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
return response.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User> loginWithPasscode({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String vcodeToken,
|
||||
required String passwordMd5,
|
||||
}) async {
|
||||
final response = await _client.executeRequest(
|
||||
LoginRequest(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
vcodeToken: vcodeToken,
|
||||
password: passwordMd5,
|
||||
),
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
throw Exception('Login with passcode failed: empty response');
|
||||
}
|
||||
|
||||
_onTokenUpdate(response.accessToken);
|
||||
|
||||
return response.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
await _client.executeRequest(LogoutRequest());
|
||||
|
||||
@@ -59,7 +59,17 @@ class MessageRepositoryImpl implements MessageRepository {
|
||||
.watchWhere<DriftMessage, $MessagesTable>(
|
||||
(t) => t.chatId.equals(chatId),
|
||||
)
|
||||
.map((rows) => rows.map(_toEntity).toList());
|
||||
.map((rows) {
|
||||
final entities = rows.map(_toEntity).toList();
|
||||
// 按 sendTime ASC 排序,sendTime 相同时按 chatIdx ASC
|
||||
// 确保连续图片消息的宫格分组逻辑正确工作
|
||||
entities.sort((a, b) {
|
||||
final st = (a.sendTime ?? 0).compareTo(b.sendTime ?? 0);
|
||||
if (st != 0) return st;
|
||||
return (a.chatIdx ?? 0).compareTo(b.chatIdx ?? 0);
|
||||
});
|
||||
return entities;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -39,6 +39,17 @@ abstract interface class AuthRepository {
|
||||
required String vcodeToken,
|
||||
});
|
||||
|
||||
/// 二级密码登录 — 携带 MD5 哈希后的密码完成登录
|
||||
///
|
||||
/// 在 [verifyOtp] 抛出 [SecondaryPasscodeRequiredException] 后使用:
|
||||
/// 用户输入二级密码 → 上层 MD5 → 调此方法 → POST login-user with password。
|
||||
Future<User> loginWithPasscode({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String vcodeToken,
|
||||
required String passwordMd5,
|
||||
});
|
||||
|
||||
/// 退出登录
|
||||
Future<void> logout();
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/core/services/typing_indicator_manager.dart';
|
||||
import 'package:im_app/core/services/typing_input_sender.dart';
|
||||
import 'package:im_app/core/services/ws_message_service.dart';
|
||||
import 'package:im_app/features/chat/di/chat_provider.dart';
|
||||
import 'package:im_app/features/chat/di/message_provider.dart';
|
||||
import 'package:im_app/features/chat/usecases/fetch_history_use_case.dart';
|
||||
import 'package:im_app/features/chat/usecases/send_image_usecase.dart';
|
||||
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
|
||||
import 'package:im_app/features/chat/usecases/send_video_usecase.dart';
|
||||
|
||||
/// ## DI 装配:Chat 服务层
|
||||
///
|
||||
@@ -19,6 +22,40 @@ import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
|
||||
/// WS 消息服务在 Provider 创建时自动调用 `start()`,
|
||||
/// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。
|
||||
|
||||
// ── TypingIndicatorManager ────────────────────────────────────────────────────
|
||||
|
||||
/// 输入状态管理器 Provider — 全局单例
|
||||
///
|
||||
/// 维护 [chatId → [userId → TypingEntry]] 的内存 Map。
|
||||
/// WS 帧由 [WsMessageService] 转发到此。
|
||||
/// UI 通过 [typingTextProvider] 订阅指定 chat 的输入状态。
|
||||
final typingIndicatorManagerProvider = Provider<TypingIndicatorManager>((ref) {
|
||||
final manager = TypingIndicatorManager();
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
|
||||
/// 输入状态全局变化流 — 聊天列表监听此 Provider 触发重建
|
||||
///
|
||||
/// 每次任何 chat 的输入状态变化时 emit,ChatPage 通过 ref.watch 自动重建列表。
|
||||
/// 值无意义(只用作触发器),轻量级且不产生 GC 压力。
|
||||
final typingChangeProvider = StreamProvider<void>((ref) {
|
||||
return ref.watch(typingIndicatorManagerProvider).globalChangeStream;
|
||||
});
|
||||
|
||||
// ── TypingInputSender ────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送侧输入状态 Provider — 3s 节流 + 2s 防抖
|
||||
///
|
||||
/// 由 ChatDetailPage 的 TextField.onChanged 调用。
|
||||
final typingInputSenderProvider = Provider<TypingInputSender>((ref) {
|
||||
final sender = TypingInputSender(
|
||||
socketManager: ref.read(socketManagerProvider),
|
||||
);
|
||||
ref.onDispose(sender.dispose);
|
||||
return sender;
|
||||
});
|
||||
|
||||
// ── WsMessageService ──────────────────────────────────────────────────────────
|
||||
|
||||
/// WS 消息服务 Provider
|
||||
@@ -31,6 +68,8 @@ final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
|
||||
apiClient: ref.read(networkSdkApiProvider),
|
||||
messageRepo: ref.read(messageRepositoryProvider),
|
||||
chatRepo: ref.read(chatRepositoryProvider),
|
||||
typingManager: ref.read(typingIndicatorManagerProvider),
|
||||
encryptionManager: ref.read(encryptionManagerProvider),
|
||||
);
|
||||
|
||||
service.start();
|
||||
@@ -53,6 +92,7 @@ final sendMessageUseCaseProvider = Provider<SendMessageUseCase>((ref) {
|
||||
messageRepo: ref.read(messageRepositoryProvider),
|
||||
chatRepo: ref.read(chatRepositoryProvider),
|
||||
currentUid: uid,
|
||||
encryptionManager: ref.read(encryptionManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,3 +115,13 @@ final sendImageUseCaseProvider = Provider<SendImageUseCase>((ref) {
|
||||
sendMessage: ref.read(sendMessageUseCaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// ── SendVideoUseCase ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 视频上传并发送消息用例 Provider(#54)
|
||||
final sendVideoUseCaseProvider = Provider<SendVideoUseCase>((ref) {
|
||||
return SendVideoUseCase(
|
||||
apiClient: ref.read(networkSdkApiProvider),
|
||||
sendMessage: ref.read(sendMessageUseCaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,11 @@ class ChatListViewModel extends Notifier<void> {
|
||||
final title = chat.name ?? 'Chat $chatId';
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
extra: (conversationId: chatId.toString(), title: title),
|
||||
extra: (
|
||||
conversationId: chatId.toString(),
|
||||
title: title,
|
||||
chatType: chat.typ ?? 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class ChatViewModel extends Notifier<void> {
|
||||
void pushChatDetailWithExtra(BuildContext context) {
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
extra: (conversationId: '42', title: 'extra 传参'),
|
||||
extra: (conversationId: '42', title: 'extra 传参', chatType: 1),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,21 @@ import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:im_app/data/remote/upload_file_request.dart';
|
||||
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
|
||||
|
||||
/// 图片上传并发送消息用例(#33)
|
||||
/// 图片上传并发送消息用例(#33 / #38)
|
||||
///
|
||||
/// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()`
|
||||
///
|
||||
/// ## 执行流程
|
||||
/// ## 单图流程 [execute]
|
||||
/// 1. 读取图片字节 → [Uint8List]
|
||||
/// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖)
|
||||
/// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url]
|
||||
/// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2
|
||||
/// 5. 删除临时文件
|
||||
///
|
||||
/// ## 多图批量流程 [sendBatch](#38)
|
||||
/// 1. **并行**上传所有图片([Future.wait])
|
||||
/// 2. 快速顺序发送消息 → sendTime 各差毫秒级,满足宫格分组 < 5s 条件
|
||||
///
|
||||
/// 发送中进度通过 [onProgress] 回调传出(0.0~1.0),
|
||||
/// 用于 [ImageMessageBubble] 渲染进度环。
|
||||
class SendImageUseCase {
|
||||
@@ -98,6 +102,67 @@ class SendImageUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量并行上传并快速顺序发送(#38)
|
||||
///
|
||||
/// 并行上传保证所有消息 sendTime 在毫秒级内,满足宫格分组条件(< 5 秒)。
|
||||
///
|
||||
/// 返回失败的文件路径列表;空列表表示全部成功。
|
||||
Future<List<String>> sendBatch({
|
||||
required List<String> filePaths,
|
||||
required int chatId,
|
||||
}) async {
|
||||
if (filePaths.isEmpty) return [];
|
||||
|
||||
// 1. 并行上传所有图片
|
||||
final results = await Future.wait(
|
||||
filePaths.map((p) => _uploadOnly(p)),
|
||||
eagerError: false,
|
||||
);
|
||||
|
||||
// 2. 顺序快速发送(上传成功的)
|
||||
final failed = <String>[];
|
||||
for (var i = 0; i < filePaths.length; i++) {
|
||||
final content = results[i];
|
||||
if (content == null) {
|
||||
failed.add(filePaths[i]);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await _sendMessage.execute(chatId: chatId, content: content, typ: 2);
|
||||
} catch (e) {
|
||||
debugPrint('[SendImageUseCase.sendBatch] send error: $e');
|
||||
failed.add(filePaths[i]);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// 仅上传,返回 `jsonEncode({url,width,height})` 或 null(失败)
|
||||
Future<String?> _uploadOnly(String filePath) async {
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final (w, h) = await _resolveSize(bytes);
|
||||
final ext = _extOf(filePath);
|
||||
final tempFile = File(
|
||||
'${Directory.systemTemp.path}/upload_${DateTime.now().microsecondsSinceEpoch}$ext',
|
||||
);
|
||||
await tempFile.writeAsBytes(bytes);
|
||||
try {
|
||||
final result = await _apiClient.executeRequest(
|
||||
UploadFileRequest(filePath: tempFile.path),
|
||||
);
|
||||
final url = result?.url ?? '';
|
||||
if (url.isEmpty) return null;
|
||||
return '{"url":"$url","width":$w,"height":$h}';
|
||||
} finally {
|
||||
try { await tempFile.delete(); } catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SendImageUseCase._uploadOnly] $filePath: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _extOf(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot == -1 || dot == path.length - 1) return '.jpg';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/services/encryption_manager.dart';
|
||||
import 'package:im_app/data/remote/send_message_request.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/domain/repositories/chat_repository.dart';
|
||||
@@ -9,15 +10,23 @@ import 'package:im_app/domain/repositories/message_repository.dart';
|
||||
/// 发送文本消息用例
|
||||
///
|
||||
/// ## 执行流程
|
||||
/// 1. 乐观写入本地 DB(临时消息,id=0)→ UI 立即刷新
|
||||
/// 1. 乐观写入本地 DB(负微秒时间戳作为唯一临时 id)→ UI 立即刷新
|
||||
/// 2. HTTP POST `/app/api/chat/send-message` → 获取服务端 messageId / chatIdx
|
||||
/// 3. 更新 ChatRepository 的 lastMsg / lastTyp / lastTime
|
||||
/// 3. 用真实 messageId 替换临时行(delete tempId → insert realId)
|
||||
/// 4. 更新 ChatRepository 的 lastMsg / lastTyp / lastTime
|
||||
///
|
||||
/// ## 为什么不用 id=0
|
||||
///
|
||||
/// 原来所有乐观写入均用 `id=0`(主键),批量发送时会相互覆盖,
|
||||
/// 导致 DB 只剩最后一条消息。改用负微秒时间戳作为临时 id,
|
||||
/// 每条消息唯一,不会碰撞。服务端确认后立即替换为正式 messageId。
|
||||
///
|
||||
/// DB Stream → StreamProvider → UI 自动重建,无需额外通知。
|
||||
class SendMessageUseCase {
|
||||
final NetworksSdkApi _apiClient;
|
||||
final MessageRepository _messageRepo;
|
||||
final ChatRepository _chatRepo;
|
||||
final EncryptionManager? _encryptionManager;
|
||||
final int currentUid;
|
||||
|
||||
SendMessageUseCase({
|
||||
@@ -25,9 +34,11 @@ class SendMessageUseCase {
|
||||
required MessageRepository messageRepo,
|
||||
required ChatRepository chatRepo,
|
||||
required this.currentUid,
|
||||
EncryptionManager? encryptionManager,
|
||||
}) : _apiClient = apiClient,
|
||||
_messageRepo = messageRepo,
|
||||
_chatRepo = chatRepo;
|
||||
_chatRepo = chatRepo,
|
||||
_encryptionManager = encryptionManager;
|
||||
|
||||
Future<void> execute({
|
||||
required int chatId,
|
||||
@@ -36,10 +47,11 @@ class SendMessageUseCase {
|
||||
}) async {
|
||||
final sendTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// 1. 乐观本地写入
|
||||
// 1. 乐观本地写入(唯一负 id,防止批量发送时相互覆盖)
|
||||
final tempId = -(DateTime.now().microsecondsSinceEpoch);
|
||||
await _messageRepo.insertOrReplace(
|
||||
Message(
|
||||
id: 0,
|
||||
id: tempId,
|
||||
chatId: chatId,
|
||||
sendId: currentUid,
|
||||
content: content,
|
||||
@@ -48,13 +60,25 @@ class SendMessageUseCase {
|
||||
),
|
||||
);
|
||||
|
||||
// 2. HTTP 发送
|
||||
// 2. E2E 加密(对齐 iOS MessageHistoryService.sendMessage)
|
||||
// wireContent = EncryptionManager.encryptContent(content, chatId) ?? content
|
||||
String wireContent = content;
|
||||
if (_encryptionManager != null && _encryptionManager.isSetup) {
|
||||
final encrypted =
|
||||
await _encryptionManager.encryptContent(content, chatId: chatId);
|
||||
if (encrypted != null) {
|
||||
wireContent = encrypted;
|
||||
}
|
||||
// null = no key for chat → send plaintext (对齐老项目 fallback)
|
||||
}
|
||||
|
||||
// 3. HTTP 发送
|
||||
SendMessageResponse? resp;
|
||||
try {
|
||||
resp = await _apiClient.executeRequest(
|
||||
SendMessageRequest(
|
||||
chatId: chatId,
|
||||
content: content,
|
||||
content: wireContent,
|
||||
typ: typ,
|
||||
sendTime: sendTime,
|
||||
),
|
||||
@@ -63,7 +87,28 @@ class SendMessageUseCase {
|
||||
debugPrint('[SendMessageUseCase] HTTP error: $e');
|
||||
}
|
||||
|
||||
// 3. 更新 Chat 摘要
|
||||
// 3. 用真实 messageId 替换临时行
|
||||
if (resp != null && resp.messageId > 0) {
|
||||
try {
|
||||
await _messageRepo.delete(tempId);
|
||||
await _messageRepo.insertOrReplace(
|
||||
Message(
|
||||
id: resp.messageId,
|
||||
messageId: resp.messageId,
|
||||
chatId: chatId,
|
||||
chatIdx: resp.chatIdx,
|
||||
sendId: currentUid,
|
||||
content: content,
|
||||
typ: typ,
|
||||
sendTime: resp.sendTime > 0 ? resp.sendTime : sendTime,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[SendMessageUseCase] replace temp row error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新 Chat 摘要
|
||||
try {
|
||||
final chat = await _chatRepo.getChat(chatId);
|
||||
if (chat != null) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/data/remote/upload_file_request.dart';
|
||||
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
|
||||
|
||||
/// 视频上传并发送消息用例(typ=4,Gitea issue #54)
|
||||
///
|
||||
/// 对应 iOS PhotosPicker 单视频选取 + UploadService + sendMessage(typ=4)
|
||||
///
|
||||
/// ## 流程
|
||||
/// 1. 读取文件大小
|
||||
/// 2. [UploadFileRequest] 上传视频文件到 CDN
|
||||
/// 3. `jsonEncode({"url":url,"thumb":"","size":N})` → [SendMessageUseCase] typ=4
|
||||
///
|
||||
/// ## 说明
|
||||
/// - 缩略图(thumb)当前为空字符串:生成视频首帧需要 `video_thumbnail` 等额外包
|
||||
/// - [VideoMessageBubble] 已有,无需改动
|
||||
class SendVideoUseCase {
|
||||
final NetworksSdkApi _apiClient;
|
||||
final SendMessageUseCase _sendMessage;
|
||||
|
||||
SendVideoUseCase({
|
||||
required NetworksSdkApi apiClient,
|
||||
required SendMessageUseCase sendMessage,
|
||||
}) : _apiClient = apiClient,
|
||||
_sendMessage = sendMessage;
|
||||
|
||||
/// 上传并发送视频消息
|
||||
///
|
||||
/// [filePath]:本地视频文件路径(由 `image_picker.pickVideo()` 返回的 XFile.path)
|
||||
/// [chatId]:目标会话 ID
|
||||
Future<void> execute({
|
||||
required String filePath,
|
||||
required int chatId,
|
||||
int chatType = 1,
|
||||
}) async {
|
||||
// 1. 文件大小
|
||||
final file = File(filePath);
|
||||
int size = 0;
|
||||
try {
|
||||
size = await file.length();
|
||||
} catch (e) {
|
||||
debugPrint('[SendVideoUseCase] 获取文件大小失败: $e');
|
||||
}
|
||||
|
||||
// 2. 上传
|
||||
String uploadedUrl = '';
|
||||
try {
|
||||
final result = await _apiClient.executeRequest(
|
||||
UploadFileRequest(filePath: filePath),
|
||||
);
|
||||
uploadedUrl = result?.url ?? '';
|
||||
} catch (e) {
|
||||
debugPrint('[SendVideoUseCase] upload error: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (uploadedUrl.isEmpty) {
|
||||
throw Exception('[SendVideoUseCase] upload returned empty url');
|
||||
}
|
||||
|
||||
// 3. 发送 typ=4 消息
|
||||
final content = jsonEncode({
|
||||
'url': uploadedUrl,
|
||||
'thumb': '', // 视频缩略图(待后续接入 video_thumbnail)
|
||||
'size': size,
|
||||
});
|
||||
|
||||
await _sendMessage.execute(
|
||||
chatId: chatId,
|
||||
content: content,
|
||||
typ: 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,46 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/features/chat/di/chat_service_providers.dart';
|
||||
import 'package:im_app/features/chat/di/message_provider.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/audio_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/emoji_panel.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/file_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_grid_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_picker_sheet.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/sticker_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// 聊天详情页(#28 / #35)
|
||||
/// 聊天详情页(#28 / #35 / #37)
|
||||
///
|
||||
/// 接收 [conversationId](chatId 字符串)和 [title](会话名称)。
|
||||
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。
|
||||
/// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。
|
||||
///
|
||||
/// [_MessageBubble] 按 [Message.typ] 路由到对应气泡组件(#35):
|
||||
/// - typ=1 → 纯文本
|
||||
/// - typ=2 → [ImageMessageBubble]
|
||||
/// - typ=3 → [AudioMessageBubble]
|
||||
/// - typ=4/24 → [VideoMessageBubble]
|
||||
/// - typ=6 → [FileMessageBubble]
|
||||
/// - typ=8 → [RedEnvelopeBubble]
|
||||
/// ## 消息显示项(ChatDisplayItem,#37)
|
||||
///
|
||||
/// [_buildDisplayItems] 在渲染前对消息列表做预处理:
|
||||
/// 把同一发送者、5 秒内的连续 typ=2 图片消息(2-9 条)合并为一个
|
||||
/// [_ChatDisplayItem.grid],交由 [ImageGridBubble] 渲染(iOS parity)。
|
||||
///
|
||||
/// ## BubbleKind 路由(#35)
|
||||
///
|
||||
/// - typ=1 → 纯文本
|
||||
/// - typ=2 单条 → [ImageMessageBubble]
|
||||
/// - typ=2 组合 → [ImageGridBubble]
|
||||
/// - typ=3 → [AudioMessageBubble]
|
||||
/// - typ=4/24 → [VideoMessageBubble]
|
||||
/// - typ=6 → [FileMessageBubble]
|
||||
/// - typ=8 → [RedEnvelopeBubble]
|
||||
class ChatDetailPage extends ConsumerStatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
@@ -48,23 +64,68 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
final _inputCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
/// 当前输入状态显示文本("正在输入…" / null)
|
||||
String? _typingText;
|
||||
StreamSubscription<String?>? _typingSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatId = int.tryParse(widget.conversationId) ?? 0;
|
||||
_inputCtrl.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 订阅输入状态 Stream(首次 + 依赖变化时重建)
|
||||
if (_typingSub == null) {
|
||||
final uid = ref.read(authNotifierProvider).currentUid ?? 0;
|
||||
final mgr = ref.read(typingIndicatorManagerProvider);
|
||||
final isGroup = widget.chatType == 2;
|
||||
_typingSub = mgr
|
||||
.typingTextStream(chatId: _chatId, myUserId: uid, isGroup: isGroup)
|
||||
.listen((text) {
|
||||
// Stream sentinel: text == null → 重新查询 displayText
|
||||
final uid = ref.read(authNotifierProvider).currentUid ?? 0;
|
||||
final actual = isGroup
|
||||
? mgr.groupDisplayText(chatId: _chatId, myUserId: uid)
|
||||
: mgr.displayText(chatId: _chatId, myUserId: uid);
|
||||
if (actual != _typingText) {
|
||||
setState(() => _typingText = actual);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_typingSub?.cancel();
|
||||
_inputCtrl.removeListener(_onTextChanged);
|
||||
_inputCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// TextField 输入变化 → 发送输入状态(仅单聊)
|
||||
///
|
||||
/// 对齐 iOS ChatView.swift:1052 — 单聊"对方正在输入",群聊不发送
|
||||
void _onTextChanged() {
|
||||
if (widget.chatType == 2) return; // 群聊不发送 typing 事件
|
||||
ref.read(typingInputSenderProvider).onTextChanged(
|
||||
chatId: _chatId,
|
||||
text: _inputCtrl.text,
|
||||
);
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _inputCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_inputCtrl.clear();
|
||||
// 消息发出 → 立即停止输入状态(仅单聊)
|
||||
if (widget.chatType != 2) {
|
||||
ref.read(typingInputSenderProvider).notifyStopTyping(chatId: _chatId);
|
||||
}
|
||||
ref
|
||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||
.sendMessage(text);
|
||||
@@ -85,6 +146,113 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
ImagePickerSheet.show(context, ref, chatId: _chatId);
|
||||
}
|
||||
|
||||
// ── 附件面板(#52~#55) ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _showAttachmentPanel() async {
|
||||
final opt = await AttachmentPanelSheet.show(context);
|
||||
if (opt == null || !mounted) return;
|
||||
switch (opt) {
|
||||
case AttachmentOption.camera:
|
||||
await _pickFromCamera();
|
||||
case AttachmentOption.gallery:
|
||||
if (mounted) _showImagePicker();
|
||||
case AttachmentOption.video:
|
||||
await _pickVideo();
|
||||
case AttachmentOption.file:
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('文件发送暂未支持,即将上线')),
|
||||
);
|
||||
}
|
||||
case AttachmentOption.voice:
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('录音发送暂未支持,即将上线')),
|
||||
);
|
||||
}
|
||||
case AttachmentOption.redEnvelope:
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('红包发送暂未支持,即将上线')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 拍照后发送(#53):调用摄像头,复用 SendImageUseCase
|
||||
Future<void> _pickFromCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (file == null || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(sendImageUseCaseProvider)
|
||||
.execute(filePath: file.path, chatId: _chatId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('发送失败: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频选取后发送(#54):image_picker.pickVideo → SendVideoUseCase
|
||||
Future<void> _pickVideo() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickVideo(source: ImageSource.gallery);
|
||||
if (file == null || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(sendVideoUseCaseProvider)
|
||||
.execute(filePath: file.path, chatId: _chatId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('视频发送失败: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 表情面板(#56) ──────────────────────────────────────────────────────────
|
||||
|
||||
void _showEmojiPanel() {
|
||||
FocusScope.of(context).unfocus();
|
||||
EmojiPanel.show(
|
||||
context,
|
||||
onEmojiSelected: _insertEmoji,
|
||||
onBackspace: _deleteLastChar,
|
||||
);
|
||||
}
|
||||
|
||||
void _insertEmoji(String emoji) {
|
||||
final ctrl = _inputCtrl;
|
||||
final text = ctrl.text;
|
||||
final sel = ctrl.selection;
|
||||
final pos = sel.isValid ? sel.baseOffset : text.length;
|
||||
final safePos = pos.clamp(0, text.length);
|
||||
final newText =
|
||||
text.substring(0, safePos) + emoji + text.substring(safePos);
|
||||
ctrl.text = newText;
|
||||
ctrl.selection =
|
||||
TextSelection.collapsed(offset: safePos + emoji.length);
|
||||
}
|
||||
|
||||
void _deleteLastChar() {
|
||||
final ctrl = _inputCtrl;
|
||||
final text = ctrl.text;
|
||||
if (text.isEmpty) return;
|
||||
// Remove one rune (handles multi-codepoint emoji correctly)
|
||||
final runes = text.runes.toList();
|
||||
final newText = String.fromCharCodes(runes.sublist(0, runes.length - 1));
|
||||
ctrl.text = newText;
|
||||
ctrl.selection = TextSelection.collapsed(offset: newText.length);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
||||
@@ -92,7 +260,24 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.title),
|
||||
// 正在输入副标题(绿色,对齐 iOS ChatNavToolbar typing 参数)
|
||||
if (_typingText != null)
|
||||
Text(
|
||||
_typingText!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade600,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||
@@ -105,18 +290,22 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToBottom(),
|
||||
);
|
||||
final displayItems = _buildDisplayItems(msgs);
|
||||
return ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (context, i) => _MessageBubble(
|
||||
message: msgs[i],
|
||||
isMine: msgs[i].sendId == currentUid,
|
||||
chatId: _chatId,
|
||||
),
|
||||
itemCount: displayItems.length,
|
||||
itemBuilder: (context, i) {
|
||||
final item = displayItems[i];
|
||||
return _DisplayItemWidget(
|
||||
item: item,
|
||||
currentUid: currentUid,
|
||||
chatId: _chatId,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
@@ -149,7 +338,8 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
controller: _inputCtrl,
|
||||
isSending: state.isSending,
|
||||
onSend: _send,
|
||||
onAttach: _showImagePicker,
|
||||
onAttachPanel: _showAttachmentPanel,
|
||||
onEmoji: _showEmojiPanel,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -157,6 +347,135 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ChatDisplayItem (#37) ─────────────────────────────────────────────────────
|
||||
|
||||
/// 渲染层显示项
|
||||
///
|
||||
/// - [single]:单条消息
|
||||
/// - [grid]:2-9 条连续同发送者 typ=2 图片消息(宫格)
|
||||
class _ChatDisplayItem {
|
||||
const _ChatDisplayItem.single(this.message) : grid = null;
|
||||
const _ChatDisplayItem.imageGrid(List<Message> images)
|
||||
: message = images.first,
|
||||
grid = images;
|
||||
|
||||
/// 代表消息(用于作者/时间/对齐)
|
||||
final Message message;
|
||||
|
||||
/// 非 null 时表示宫格分组
|
||||
final List<Message>? grid;
|
||||
|
||||
bool get isGrid => grid != null;
|
||||
}
|
||||
|
||||
/// 连续图片消息分组逻辑(iOS ChatView.buildDisplayItems 对齐,#37)
|
||||
///
|
||||
/// 分组条件:
|
||||
/// - 连续 typ=2 消息
|
||||
/// - 同一 sendId
|
||||
/// - 相邻消息 sendTime 差 < 5 秒
|
||||
/// - 最多 9 条一组
|
||||
List<_ChatDisplayItem> _buildDisplayItems(List<Message> msgs) {
|
||||
// 防御性排序:watchByChatId 已排序,此处确保即使上游未排序也能正确分组
|
||||
final sorted = [...msgs]..sort((a, b) {
|
||||
final st = (a.sendTime ?? 0).compareTo(b.sendTime ?? 0);
|
||||
if (st != 0) return st;
|
||||
return (a.chatIdx ?? 0).compareTo(b.chatIdx ?? 0);
|
||||
});
|
||||
|
||||
final items = <_ChatDisplayItem>[];
|
||||
int i = 0;
|
||||
// 使用 sorted 而非原始 msgs
|
||||
final src = sorted;
|
||||
|
||||
while (i < src.length) {
|
||||
final curr = src[i];
|
||||
if ((curr.typ ?? 1) == 2) {
|
||||
final batch = <Message>[curr];
|
||||
int j = i + 1;
|
||||
|
||||
while (j < src.length && batch.length < 9) {
|
||||
final next = src[j];
|
||||
final prev = src[j - 1];
|
||||
final timeDiff = ((next.sendTime ?? 0) - (prev.sendTime ?? 0)).abs();
|
||||
|
||||
if ((next.typ ?? 1) == 2 &&
|
||||
next.sendId == curr.sendId &&
|
||||
timeDiff < 5) {
|
||||
batch.add(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length >= 2) {
|
||||
items.add(_ChatDisplayItem.imageGrid(batch));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
items.add(_ChatDisplayItem.single(curr));
|
||||
i++;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
// ── 显示项渲染 ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _DisplayItemWidget extends StatelessWidget {
|
||||
const _DisplayItemWidget({
|
||||
required this.item,
|
||||
required this.currentUid,
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
final _ChatDisplayItem item;
|
||||
final int currentUid;
|
||||
final int chatId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMine = (item.message.sendId ?? 0) == currentUid;
|
||||
|
||||
Widget bubble;
|
||||
if (item.isGrid) {
|
||||
bubble = ImageGridBubble(messages: item.grid!);
|
||||
} else {
|
||||
bubble = _MessageBubble(
|
||||
message: item.message,
|
||||
isMine: isMine,
|
||||
chatId: chatId,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.isGrid) {
|
||||
// 宫格气泡:对齐到发送方侧,无外层 Container
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMine) ...[
|
||||
_Avatar(sendId: item.message.sendId ?? 0),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
bubble,
|
||||
if (isMine) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 消息气泡路由(#35) ────────────────────────────────────────────────────────
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
@@ -176,11 +495,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
final content = message.content ?? '';
|
||||
final typ = message.typ ?? 1;
|
||||
|
||||
// 媒体类型气泡不需要外层 Container 装饰
|
||||
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 ||
|
||||
typ == 8 || typ == 24;
|
||||
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 5 ||
|
||||
typ == 6 || typ == 8 || typ == 24;
|
||||
|
||||
final bubble = _buildBubble(context, cs, content, typ, isMedia);
|
||||
final bubble = _buildBubble(context, cs, content, typ);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -190,17 +508,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMine) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: cs.secondaryContainer,
|
||||
child: Text(
|
||||
(message.sendId ?? 0).toString().substring(0, 1),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
_Avatar(sendId: message.sendId ?? 0),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
isMedia ? bubble : Flexible(child: bubble),
|
||||
@@ -215,11 +523,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
ColorScheme cs,
|
||||
String content,
|
||||
int typ,
|
||||
bool isMedia,
|
||||
) {
|
||||
switch (typ) {
|
||||
case 2:
|
||||
return ImageMessageBubble(rawContent: content);
|
||||
case 5:
|
||||
return StickerMessageBubble(rawContent: content);
|
||||
case 3:
|
||||
return AudioMessageBubble(rawContent: content);
|
||||
case 4:
|
||||
@@ -256,6 +565,26 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 头像 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar({required this.sendId});
|
||||
final int sendId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: cs.secondaryContainer,
|
||||
child: Text(
|
||||
sendId.toString().substring(0, 1),
|
||||
style: TextStyle(fontSize: 12, color: cs.onSecondaryContainer),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 输入栏(含附件按钮) ──────────────────────────────────────────────────────
|
||||
|
||||
class _InputBar extends StatelessWidget {
|
||||
@@ -263,13 +592,17 @@ class _InputBar extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.isSending,
|
||||
required this.onSend,
|
||||
required this.onAttach,
|
||||
required this.onAttachPanel,
|
||||
required this.onEmoji,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool isSending;
|
||||
final VoidCallback onSend;
|
||||
final VoidCallback onAttach;
|
||||
/// 附件面板(#52):相机/相册/视频/文件/录音/红包
|
||||
final VoidCallback onAttachPanel;
|
||||
/// 表情面板(#56)
|
||||
final VoidCallback onEmoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -278,11 +611,17 @@ class _InputBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// 附件按钮(#33)
|
||||
// 表情按钮(#56)
|
||||
IconButton(
|
||||
onPressed: onAttach,
|
||||
onPressed: onEmoji,
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
tooltip: '表情',
|
||||
),
|
||||
// 附件面板按钮(#52)
|
||||
IconButton(
|
||||
onPressed: onAttachPanel,
|
||||
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||
tooltip: '发送图片',
|
||||
tooltip: '附件',
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/chat.dart';
|
||||
import 'package:im_app/features/chat/di/chat_provider.dart';
|
||||
import 'package:im_app/features/chat/di/chat_service_providers.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_list_view_model.dart';
|
||||
|
||||
/// 聊天列表页
|
||||
@@ -16,6 +18,8 @@ class ChatPage extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.watch(chatListViewModelProvider.notifier);
|
||||
final chatsAsync = ref.watch(allChatsProvider);
|
||||
// 监听输入状态变化 → 触发列表重建(对齐 iOS @Published typing 全局更新)
|
||||
ref.watch(typingChangeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -45,20 +49,29 @@ class ChatPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatTile extends StatelessWidget {
|
||||
class _ChatTile extends ConsumerWidget {
|
||||
const _ChatTile({required this.chat, required this.vm});
|
||||
|
||||
final Chat chat;
|
||||
final ChatListViewModel vm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = chat.name ?? 'Chat ${chat.chatId ?? chat.id}';
|
||||
final lastMsg = chat.lastMsg ?? '';
|
||||
final unread = chat.unreadNum ?? 0;
|
||||
final sendTime = chat.lastTime;
|
||||
final timeStr = sendTime != null ? _formatTime(sendTime) : '';
|
||||
|
||||
// 正在输入状态(对齐 iOS ConversationCell.typingText)
|
||||
final chatId = chat.chatId ?? chat.id;
|
||||
final myUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
final typingMgr = ref.watch(typingIndicatorManagerProvider);
|
||||
final isGroup = (chat.typ ?? 1) == 2;
|
||||
final typingText = isGroup
|
||||
? typingMgr.groupDisplayText(chatId: chatId, myUserId: myUid)
|
||||
: typingMgr.displayText(chatId: chatId, myUserId: myUid);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
@@ -86,14 +99,24 @@ class _ChatTile extends StatelessWidget {
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
lastMsg,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
child: typingText != null
|
||||
// 输入状态优先显示(绿色,对齐 iOS onlineGreen)
|
||||
? Text(
|
||||
typingText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
lastMsg,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (unread > 0)
|
||||
Container(
|
||||
|
||||
@@ -1,44 +1,71 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// 全屏图片查看页(#32)
|
||||
/// 全屏图片查看页(#32 / #58)
|
||||
///
|
||||
/// 对应 iOS `ImageFullscreenView.swift`
|
||||
///
|
||||
/// 支持:
|
||||
/// - 单图 / 多图滑动([PhotoViewGallery])
|
||||
/// ## 功能
|
||||
/// - 单图 / 多图横向滑动([PhotoViewGallery])
|
||||
/// - Pinch-to-zoom(1x–5x),双击 2.5x
|
||||
/// - 底部工具栏:保存到相册 + 分享
|
||||
/// - 磁盘缓存([CachedNetworkImageProvider],#57)
|
||||
/// - Shimmer 加载占位 → 渐入
|
||||
/// - 加载失败重试按钮
|
||||
/// - 下拉关闭(drag > 80pt 自动 pop,背景随拖动渐变)
|
||||
/// - 长按底部菜单:保存 / 分享 / 复制链接
|
||||
/// - 多图页面指示点(≤ 10 张)
|
||||
/// - Hero 动画支持(通过 [heroTag])
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // 普通打开
|
||||
/// ImageViewerPage.open(context, urls: [url1, url2], initialIndex: 0);
|
||||
///
|
||||
/// // 带 Hero 动画(单图)
|
||||
/// ImageViewerPage.open(context, urls: [url], heroTag: 'img_$url');
|
||||
/// ```
|
||||
class ImageViewerPage extends StatefulWidget {
|
||||
const ImageViewerPage({
|
||||
super.key,
|
||||
required this.urls,
|
||||
this.initialIndex = 0,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final List<String> urls;
|
||||
final int initialIndex;
|
||||
|
||||
/// Hero tag,单图时传入使动画生效(与气泡中 Hero tag 对应)
|
||||
final String? heroTag;
|
||||
|
||||
/// 打开全屏图片查看页
|
||||
static void open(
|
||||
BuildContext context, {
|
||||
required List<String> urls,
|
||||
int initialIndex = 0,
|
||||
String? heroTag,
|
||||
}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex),
|
||||
fullscreenDialog: true,
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
barrierColor: Colors.transparent,
|
||||
pageBuilder: (_, __, ___) => ImageViewerPage(
|
||||
urls: urls,
|
||||
initialIndex: initialIndex,
|
||||
heroTag: heroTag,
|
||||
),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -47,31 +74,54 @@ class ImageViewerPage extends StatefulWidget {
|
||||
State<ImageViewerPage> createState() => _ImageViewerPageState();
|
||||
}
|
||||
|
||||
class _ImageViewerPageState extends State<ImageViewerPage> {
|
||||
class _ImageViewerPageState extends State<ImageViewerPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentIndex;
|
||||
bool _isSaving = false;
|
||||
|
||||
// ── 下拉关闭 ──────────────────────────────────────────────────────────────
|
||||
double _dragOffset = 0;
|
||||
bool _isDragging = false;
|
||||
static const _dismissThreshold = 80.0;
|
||||
|
||||
double get _backgroundOpacity {
|
||||
if (!_isDragging) return 1.0;
|
||||
final progress = (_dragOffset.abs() / _dismissThreshold).clamp(0.0, 1.0);
|
||||
return 1.0 - progress * 0.7;
|
||||
}
|
||||
|
||||
// ── AppBar 自动隐藏 ────────────────────────────────────────────────────────
|
||||
bool _barsVisible = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex;
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _currentUrl => widget.urls[_currentIndex];
|
||||
|
||||
// ── 保存 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _saveToGallery() async {
|
||||
if (_isSaving) return;
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
final result = await ImageGallerySaverPlus.saveNetworkImage(_currentUrl);
|
||||
final success = result['isSuccess'] as bool? ?? false;
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(success ? '已保存到相册' : '保存失败')),
|
||||
SnackBar(content: Text(success ? '已保存到相册' : '保存失败,请重试')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('保存失败')));
|
||||
@@ -81,116 +131,388 @@ class _ImageViewerPageState extends State<ImageViewerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _share() {
|
||||
Share.share(_currentUrl);
|
||||
void _share() => Share.share(_currentUrl);
|
||||
|
||||
void _copyLink() {
|
||||
Clipboard.setData(ClipboardData(text: _currentUrl));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('链接已复制')));
|
||||
}
|
||||
|
||||
// ── 长按菜单 ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _showLongPressMenu() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
title: const Text('保存到相册',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_saveToGallery();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share_rounded, color: Colors.white),
|
||||
title: const Text('分享', style: TextStyle(color: Colors.white)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_share();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.link_rounded, color: Colors.white),
|
||||
title: const Text('复制链接',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_copyLink();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 构建单图/多图 ─────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildPhotoView(String url, {String? heroTag}) {
|
||||
final imageProvider = CachedNetworkImageProvider(url);
|
||||
|
||||
Widget photoWidget = PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 5,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
enableDoubleTapZoom: true,
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
loadingBuilder: (_, event) => _Shimmer(
|
||||
progress: event?.expectedContentLength != null
|
||||
? (event!.cumulativeBytesLoaded / event.expectedContentLength!)
|
||||
.clamp(0.0, 1.0)
|
||||
: null,
|
||||
),
|
||||
errorBuilder: (_, __, ___) => _ErrorWidget(
|
||||
onRetry: () => setState(() {}),
|
||||
),
|
||||
);
|
||||
|
||||
if (heroTag != null) {
|
||||
photoWidget = Hero(tag: heroTag, child: photoWidget);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: _showLongPressMenu,
|
||||
onTap: () => setState(() => _barsVisible = !_barsVisible),
|
||||
child: photoWidget,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: widget.urls.length > 1
|
||||
? Text('${_currentIndex + 1} / ${widget.urls.length}')
|
||||
: null,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// ── 图片区域 ──────────────────────────────────────────────────────────
|
||||
widget.urls.length == 1
|
||||
? PhotoView(
|
||||
imageProvider: NetworkImage(widget.urls.first),
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 5,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
enableDoubleTapZoom: true,
|
||||
backgroundDecoration:
|
||||
const BoxDecoration(color: Colors.black),
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.white54,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
)
|
||||
: PhotoViewGallery.builder(
|
||||
itemCount: widget.urls.length,
|
||||
pageController:
|
||||
PageController(initialPage: widget.initialIndex),
|
||||
onPageChanged: (i) => setState(() => _currentIndex = i),
|
||||
backgroundDecoration:
|
||||
const BoxDecoration(color: Colors.black),
|
||||
builder: (_, i) => PhotoViewGalleryPageOptions(
|
||||
imageProvider: NetworkImage(widget.urls[i]),
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 5,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.white54,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return GestureDetector(
|
||||
// ── 下拉手势关闭 ──────────────────────────────────────────────────────
|
||||
onVerticalDragStart: (_) {
|
||||
setState(() => _isDragging = true);
|
||||
},
|
||||
onVerticalDragUpdate: (details) {
|
||||
setState(() => _dragOffset += details.delta.dy);
|
||||
},
|
||||
onVerticalDragEnd: (_) {
|
||||
if (_dragOffset.abs() > _dismissThreshold) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
_dragOffset = 0;
|
||||
_isDragging = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: _isDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
color: Colors.black.withOpacity(_backgroundOpacity),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragOffset),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
|
||||
// ── 底部工具栏 ────────────────────────────────────────────────────────
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
// 保存
|
||||
TextButton.icon(
|
||||
onPressed: _isSaving ? null : _saveToGallery,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white, strokeWidth: 2),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_rounded,
|
||||
// ── AppBar ─────────────────────────────────────────────────────
|
||||
appBar: _barsVisible
|
||||
? AppBar(
|
||||
backgroundColor: Colors.black54,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: widget.urls.length > 1
|
||||
? Text('${_currentIndex + 1} / ${widget.urls.length}')
|
||||
: null,
|
||||
actions: [
|
||||
if (_isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(14),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
label: const Text(
|
||||
'保存',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onPressed: _showLongPressMenu,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
|
||||
body: Stack(
|
||||
children: [
|
||||
// ── 图片区域 ────────────────────────────────────────────────
|
||||
widget.urls.length == 1
|
||||
? _buildPhotoView(
|
||||
widget.urls.first,
|
||||
heroTag: widget.heroTag,
|
||||
)
|
||||
: PhotoViewGallery.builder(
|
||||
itemCount: widget.urls.length,
|
||||
pageController:
|
||||
PageController(initialPage: widget.initialIndex),
|
||||
onPageChanged: (i) =>
|
||||
setState(() => _currentIndex = i),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
builder: (_, i) => PhotoViewGalleryPageOptions.customChild(
|
||||
child: _buildPhotoView(widget.urls[i]),
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 5,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
),
|
||||
),
|
||||
|
||||
// ── 页面指示点(多图 ≤ 10 张时)────────────────────────────
|
||||
if (widget.urls.length > 1 &&
|
||||
widget.urls.length <= 10 &&
|
||||
_barsVisible)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 56,
|
||||
child: _PageDots(
|
||||
count: widget.urls.length,
|
||||
current: _currentIndex,
|
||||
),
|
||||
),
|
||||
|
||||
// ── 底部工具栏 ──────────────────────────────────────────────
|
||||
if (_barsVisible)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isSaving ? null : _saveToGallery,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download_rounded,
|
||||
color: Colors.white),
|
||||
label: const Text('保存',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _share,
|
||||
icon: const Icon(Icons.share_rounded,
|
||||
color: Colors.white),
|
||||
label: const Text('分享',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分享
|
||||
TextButton.icon(
|
||||
onPressed: _share,
|
||||
icon: const Icon(
|
||||
Icons.share_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shimmer 加载占位 ─────────────────────────────────────────────────────────
|
||||
|
||||
class _Shimmer extends StatefulWidget {
|
||||
const _Shimmer({this.progress});
|
||||
final double? progress;
|
||||
|
||||
@override
|
||||
State<_Shimmer> createState() => _ShimmerState();
|
||||
}
|
||||
|
||||
class _ShimmerState extends State<_Shimmer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => Container(
|
||||
color: Color.lerp(
|
||||
Colors.grey.shade800,
|
||||
Colors.grey.shade700,
|
||||
_anim.value,
|
||||
),
|
||||
child: widget.progress != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
value: widget.progress,
|
||||
color: Colors.white54,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
label: const Text(
|
||||
'分享',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${((widget.progress ?? 0) * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 错误重试 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ErrorWidget extends StatelessWidget {
|
||||
const _ErrorWidget({required this.onRetry});
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.broken_image_outlined,
|
||||
color: Colors.white54, size: 56),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'加载失败',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: onRetry,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white38),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 页面指示点 ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PageDots extends StatelessWidget {
|
||||
const _PageDots({required this.count, required this.current});
|
||||
final int count;
|
||||
final int current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
count,
|
||||
(i) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: i == current ? 18 : 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: i == current ? Colors.white : Colors.white38,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 附件选择面板选项
|
||||
///
|
||||
/// 每个选项对应一个操作类型,由调用方在 [AttachmentPanelSheet.show] 的
|
||||
/// Future 返回值中决定后续行为。
|
||||
enum AttachmentOption {
|
||||
camera,
|
||||
gallery,
|
||||
video,
|
||||
file,
|
||||
voice,
|
||||
redEnvelope,
|
||||
}
|
||||
|
||||
/// 附件选择面板(Gitea issue #52)
|
||||
///
|
||||
/// 对应 iOS ChatView.swift AttachmentPickerSheet 6 格网格。
|
||||
///
|
||||
/// ## 选项
|
||||
///
|
||||
/// | 图标 | 标题 | 颜色 | 动作 |
|
||||
/// |------|------|------|------|
|
||||
/// | camera | 拍照 | #5667FF | [AttachmentOption.camera] |
|
||||
/// | photo_library | 相册 | #0BB8A9 | [AttachmentOption.gallery] |
|
||||
/// | videocam | 视频 | #FF5FA2 | [AttachmentOption.video] |
|
||||
/// | insert_drive_file | 文件 | #FF8B5E | [AttachmentOption.file] |
|
||||
/// | mic | 录音 | #8A5CF6 | [AttachmentOption.voice] |
|
||||
/// | redpacket / gift | 红包 | #E8600A | [AttachmentOption.redEnvelope] |
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// final opt = await AttachmentPanelSheet.show(context);
|
||||
/// if (opt == null) return;
|
||||
/// switch (opt) {
|
||||
/// case AttachmentOption.camera: _pickFromCamera(); break;
|
||||
/// case AttachmentOption.gallery: _showImagePicker(); break;
|
||||
/// ...
|
||||
/// }
|
||||
/// ```
|
||||
class AttachmentPanelSheet extends StatelessWidget {
|
||||
const AttachmentPanelSheet({super.key});
|
||||
|
||||
static Future<AttachmentOption?> show(BuildContext context) {
|
||||
return showModalBottomSheet<AttachmentOption>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const AttachmentPanelSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
static const _items = [
|
||||
_AttachItem(
|
||||
option: AttachmentOption.camera,
|
||||
icon: Icons.camera_alt_rounded,
|
||||
label: '拍照',
|
||||
color: Color(0xFF5667FF),
|
||||
),
|
||||
_AttachItem(
|
||||
option: AttachmentOption.gallery,
|
||||
icon: Icons.photo_library_rounded,
|
||||
label: '相册',
|
||||
color: Color(0xFF0BB8A9),
|
||||
),
|
||||
_AttachItem(
|
||||
option: AttachmentOption.video,
|
||||
icon: Icons.videocam_rounded,
|
||||
label: '视频',
|
||||
color: Color(0xFFFF5FA2),
|
||||
),
|
||||
_AttachItem(
|
||||
option: AttachmentOption.file,
|
||||
icon: Icons.insert_drive_file_rounded,
|
||||
label: '文件',
|
||||
color: Color(0xFFFF8B5E),
|
||||
),
|
||||
_AttachItem(
|
||||
option: AttachmentOption.voice,
|
||||
icon: Icons.mic_rounded,
|
||||
label: '录音',
|
||||
color: Color(0xFF8A5CF6),
|
||||
),
|
||||
_AttachItem(
|
||||
option: AttachmentOption.redEnvelope,
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: '红包',
|
||||
color: Color(0xFFE8600A),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 拖拽指示条
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2 行 × 3 列网格
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.1,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
children: _items
|
||||
.map(
|
||||
(item) => _AttachButton(
|
||||
item: item,
|
||||
onTap: () => Navigator.of(context).pop(item.option),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _AttachItem {
|
||||
const _AttachItem({
|
||||
required this.option,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final AttachmentOption option;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
// ── 单项按钮 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _AttachButton extends StatelessWidget {
|
||||
const _AttachButton({required this.item, required this.onTap});
|
||||
|
||||
final _AttachItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: item.color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(item.icon, color: item.color, size: 28),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart
Normal file
201
apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 表情快捷面板(Gitea issue #56)
|
||||
///
|
||||
/// 点击表情后通过 [onEmojiSelected] 回调插入到输入框。
|
||||
/// [onBackspace] 用于删除输入框最后一个字符(含 emoji)。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// EmojiPanel.show(context,
|
||||
/// onEmojiSelected: (e) => _insertEmoji(e),
|
||||
/// onBackspace: _deleteLastChar,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## 表情分类
|
||||
///
|
||||
/// - 常用:笑脸 / 情绪
|
||||
/// - 人物:手势 / 活动
|
||||
/// - 自然:动物 / 植物
|
||||
/// - 物件:食物 / 符号
|
||||
class EmojiPanel extends StatefulWidget {
|
||||
const EmojiPanel({
|
||||
super.key,
|
||||
required this.onEmojiSelected,
|
||||
required this.onBackspace,
|
||||
});
|
||||
|
||||
final void Function(String emoji) onEmojiSelected;
|
||||
final VoidCallback onBackspace;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required void Function(String emoji) onEmojiSelected,
|
||||
required VoidCallback onBackspace,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => EmojiPanel(
|
||||
onEmojiSelected: onEmojiSelected,
|
||||
onBackspace: onBackspace,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 表情数据 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
static const List<String> _common = [
|
||||
'😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','🙃',
|
||||
'😉','😌','😍','🥰','😘','😗','😋','😛','😝','😜','🤪','😎',
|
||||
'🥳','😏','😒','😞','😔','😟','😕','🙁','😣','😖','😫','😩',
|
||||
'🥺','😢','😭','😤','😠','😡','🤬','😳','🥵','🥶','😱','😨',
|
||||
'😰','😥','😓','🤗','🤔','🤭','🤫','🤥','😶','😐','😑','😬',
|
||||
'🙄','😯','😮','😲','🥱','😴','🤐','🥴','🤢','🤮','🤧','😷',
|
||||
'🤒','🤕','🥸','😵','🤠','🥹','😈','👿','👹','👺','🤡','👻',
|
||||
'💀','☠️','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿',
|
||||
];
|
||||
|
||||
static const List<String> _people = [
|
||||
'👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘',
|
||||
'👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜',
|
||||
'👏','🙌','🫶','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵',
|
||||
'🧑','👦','👧','🧒','👱','👴','👵','🧓','👮','👷','💂','🕵️',
|
||||
'🧑⚕️','🧑🏫','🧑🍳','🧑🔧','🧑💻','🧑🎤','🧑🎨','🧑✈️','🧑🚀','🧑🏭',
|
||||
'👫','👬','👭','💑','💏','👨👩👦','👨👩👧','🧑🤝🧑',
|
||||
'🚶','🧍','🏃','💃','🕺','🤸','⛹️','🏋️','🤼','🤺',
|
||||
];
|
||||
|
||||
static const List<String> _nature = [
|
||||
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻❄️','🐨','🐯','🦁',
|
||||
'🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐔','🐧','🐦','🦅','🦆',
|
||||
'🦉','🦇','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪲','🦟','🦗',
|
||||
'🌸','🌺','🌻','🌹','🌷','💐','🍀','🌿','🌱','🌲','🌳','🌴',
|
||||
'🌵','🎋','🎍','☘️','🍁','🍂','🍃','🪴','⚡','🌈','☀️','🌤️',
|
||||
'⛅','🌥️','☁️','🌦️','🌧️','⛈️','🌩️','🌨️','❄️','☃️','🔥','🌊',
|
||||
];
|
||||
|
||||
static const List<String> _objects = [
|
||||
'🍎','🍊','🍋','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥥','🍌',
|
||||
'🍕','🍔','🍟','🌭','🥪','🌮','🌯','🫔','🥙','🧆','🍜','🍝',
|
||||
'☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂','🍷','🍸','🍹','🧉',
|
||||
'🎉','🎊','🎈','🎁','🎀','🪅','🎆','🎇','✨','🌟','⭐','💫',
|
||||
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❤️🔥','💕',
|
||||
'💞','💓','💗','💖','💘','💝','💟','❣️','💌','💋','💯','🔥',
|
||||
'👑','💎','🏆','🥇','🎖️','🏅','🎗️','🎫','🎟️','🎪','🎭','🎨',
|
||||
];
|
||||
|
||||
static const List<List<String>> _tabs = [_common, _people, _nature, _objects];
|
||||
static const List<String> _tabLabels = ['😀 常用', '🙋 人物', '🌿 自然', '🍕 物件'];
|
||||
|
||||
@override
|
||||
State<EmojiPanel> createState() => _EmojiPanelState();
|
||||
}
|
||||
|
||||
class _EmojiPanelState extends State<EmojiPanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabCtrl = TabController(length: EmojiPanel._tabs.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── 拖拽指示条 ────────────────────────────────────────────────────────
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// ── Tab 分类 ──────────────────────────────────────────────────────────
|
||||
TabBar(
|
||||
controller: _tabCtrl,
|
||||
tabs: EmojiPanel._tabLabels
|
||||
.map((l) => Tab(text: l))
|
||||
.toList(),
|
||||
labelStyle: const TextStyle(fontSize: 12),
|
||||
unselectedLabelStyle: const TextStyle(fontSize: 12),
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
|
||||
// ── 表情网格 ──────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabCtrl,
|
||||
children: EmojiPanel._tabs.map((emojis) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 8,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: emojis.length,
|
||||
itemBuilder: (_, i) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => widget.onEmojiSelected(emojis[i]),
|
||||
child: Center(
|
||||
child: Text(
|
||||
emojis[i],
|
||||
style: const TextStyle(fontSize: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 底部:退格按钮 ────────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: widget.onBackspace,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.backspace_outlined, size: 22),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/features/chat/view/image_viewer_page.dart';
|
||||
|
||||
/// 多图宫格气泡(typ=2 × 2–9 条连续消息,#36)
|
||||
///
|
||||
/// 对应 iOS `ImageGridBubble.swift`(issue #428)
|
||||
///
|
||||
/// ## 布局规则(iOS 对齐)
|
||||
///
|
||||
/// | 图片数 | 列数 | 单格尺寸 | 间距 |
|
||||
/// |--------|------|----------|------|
|
||||
/// | 2 | 2 列 | 116 × 116 pt | 3 pt |
|
||||
/// | 3–9 | 3 列 | 78 × 78 pt | 3 pt |
|
||||
///
|
||||
/// - 外层圆角 8 pt,单格圆角 4 pt
|
||||
/// - 最后一行不足时用透明占位保持靠左对齐(3 列布局)
|
||||
/// - 点击单格 → [ImageViewerPage](initialIndex 对应格子序号)
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// ImageGridBubble(messages: [msg1, msg2, msg3])
|
||||
/// ```
|
||||
class ImageGridBubble extends StatelessWidget {
|
||||
const ImageGridBubble({super.key, required this.messages});
|
||||
|
||||
/// 2–9 条 typ=2 消息(已经过 _buildDisplayItems 分组)
|
||||
final List<Message> messages;
|
||||
|
||||
// ── 布局参数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
int get _columns => messages.length == 2 ? 2 : 3;
|
||||
double get _cellSize => messages.length == 2 ? 116.0 : 78.0;
|
||||
static const _gap = 3.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cols = _columns;
|
||||
final size = _cellSize;
|
||||
final n = messages.length;
|
||||
final rowCount = (n + cols - 1) ~/ cols;
|
||||
|
||||
// Resolve all CDN URLs upfront
|
||||
final urls = messages.map((m) {
|
||||
final parsed = _parseContent(m.content ?? '');
|
||||
final raw = parsed['url'] as String? ?? '';
|
||||
return raw.isNotEmpty ? CdnUrlResolver.resolve(raw) : '';
|
||||
}).toList();
|
||||
|
||||
final validUrls = urls.where((u) => u.isNotEmpty).toList();
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(rowCount, (row) {
|
||||
final start = row * cols;
|
||||
final end = min(start + cols, n);
|
||||
final cellsInRow = end - start;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: row > 0 ? _gap : 0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...List.generate(cellsInRow, (k) {
|
||||
final idx = start + k;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: k > 0 ? _gap : 0),
|
||||
child: _GridCell(
|
||||
url: urls[idx],
|
||||
size: size,
|
||||
onTap: () => ImageViewerPage.open(
|
||||
context,
|
||||
urls: validUrls,
|
||||
initialIndex: validUrls.indexOf(urls[idx]).clamp(0, validUrls.length - 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
// 最后一行靠左:不足 cols 个时透明占位(仅 3 列布局)
|
||||
if (cols == 3)
|
||||
...List.generate(cols - cellsInRow, (_) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _gap),
|
||||
child: SizedBox(width: size, height: size),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parseContent(String raw) {
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单格 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _GridCell extends StatelessWidget {
|
||||
const _GridCell({
|
||||
required this.url,
|
||||
required this.size,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double size;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: url.isNotEmpty ? onTap : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: url.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
placeholder: (_, __) =>
|
||||
Container(color: Colors.grey.shade200),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: Colors.grey.shade300,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.grey.shade300,
|
||||
child: const Center(
|
||||
child: Icon(Icons.image_outlined, color: Colors.grey, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
@@ -51,9 +52,15 @@ class ImageMessageBubble extends StatelessWidget {
|
||||
final displaySize = _computeDisplaySize(rawW, rawH);
|
||||
final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : '';
|
||||
|
||||
final heroTag = resolvedUrl.isNotEmpty ? 'img_$resolvedUrl' : null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: resolvedUrl.isNotEmpty
|
||||
? () => ImageViewerPage.open(context, urls: [resolvedUrl])
|
||||
? () => ImageViewerPage.open(
|
||||
context,
|
||||
urls: [resolvedUrl],
|
||||
heroTag: heroTag,
|
||||
)
|
||||
: null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -63,34 +70,26 @@ class ImageMessageBubble extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// ── 图片内容 ─────────────────────────────────────────────────────
|
||||
// ── 图片内容(CachedNetworkImage + Hero)───────────────────────
|
||||
if (resolvedUrl.isNotEmpty)
|
||||
Image.network(
|
||||
resolvedUrl,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (_, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
Hero(
|
||||
tag: heroTag!,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: resolvedUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
placeholder: (_, __) => Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: Colors.grey.shade300,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
color: Colors.grey.shade300,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,21 +102,19 @@ class _ImagePickerSheetState extends ConsumerState<ImagePickerSheet> {
|
||||
setState(() => _isSending = true);
|
||||
|
||||
final useCase = ref.read(sendImageUseCaseProvider);
|
||||
final errors = <String>[];
|
||||
final filePaths = _selected.map((f) => f.path).toList();
|
||||
|
||||
for (final file in List<XFile>.from(_selected)) {
|
||||
try {
|
||||
await useCase.execute(filePath: file.path, chatId: widget.chatId);
|
||||
} catch (e) {
|
||||
errors.add(e.toString());
|
||||
}
|
||||
}
|
||||
// 多图并行上传 + 快速顺序发送(#38),满足宫格分组 <5s 条件
|
||||
final failed = await useCase.sendBatch(
|
||||
filePaths: filePaths,
|
||||
chatId: widget.chatId,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
if (errors.isNotEmpty) {
|
||||
if (failed.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${errors.length} 张图片发送失败')),
|
||||
SnackBar(content: Text('${failed.length} 张图片发送失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
|
||||
/// 贴纸消息气泡(typ = 5)
|
||||
///
|
||||
/// 对应 Gitea issue #51 / iOS StickerMessageBubble
|
||||
///
|
||||
/// ## 数据格式
|
||||
///
|
||||
/// rawContent JSON:`{ "url": "sticker/xxx.webp", "width": 120, "height": 120 }`
|
||||
///
|
||||
/// ## 尺寸规则(iOS 对齐)
|
||||
///
|
||||
/// - 最大显示 120×120pt,等比缩放
|
||||
/// - **无气泡背景**(borderless)
|
||||
/// - 圆角 8pt
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// StickerMessageBubble(rawContent: message.content ?? '')
|
||||
/// ```
|
||||
class StickerMessageBubble extends StatelessWidget {
|
||||
const StickerMessageBubble({super.key, required this.rawContent});
|
||||
|
||||
final String rawContent;
|
||||
|
||||
static const double _maxSize = 120.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parsed = _parse(rawContent);
|
||||
final url = parsed['url'] as String? ?? '';
|
||||
final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : '';
|
||||
|
||||
if (resolvedUrl.isEmpty) {
|
||||
return _placeholder();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: _maxSize,
|
||||
height: _maxSize,
|
||||
child: Image.network(
|
||||
resolvedUrl,
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (_, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, __, ___) => _placeholder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return const SizedBox(
|
||||
width: _maxSize,
|
||||
height: _maxSize,
|
||||
child: Center(
|
||||
child: Icon(Icons.image_not_supported_outlined,
|
||||
size: 40, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parse(String raw) {
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ enum LoginStep {
|
||||
|
||||
/// 步骤 2:输入验证码
|
||||
otp,
|
||||
|
||||
/// 步骤 3(可选):输入二级密码(账号已设置时触发)
|
||||
secondaryPasscode,
|
||||
}
|
||||
|
||||
/// 登录页面状态(手动 copyWith)
|
||||
@@ -18,15 +21,18 @@ class LoginState {
|
||||
this.contact = '',
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.vcodeToken = '',
|
||||
this.passcodeHint = '',
|
||||
this.recoveryEmail = '',
|
||||
});
|
||||
|
||||
/// 当前步骤(手机号输入 or 验证码输入)
|
||||
/// 当前步骤(手机号输入 / 验证码输入 / 二级密码输入)
|
||||
final LoginStep step;
|
||||
|
||||
/// 国家代码(默认 +65,暂不支持切换)
|
||||
final String countryCode;
|
||||
|
||||
/// 已提交的手机号(步骤 2 用于显示和构建请求)
|
||||
/// 已提交的手机号(步骤 2 / 3 用于显示和构建请求)
|
||||
final String contact;
|
||||
|
||||
/// 是否正在请求中
|
||||
@@ -35,6 +41,15 @@ class LoginState {
|
||||
/// 错误信息(null = 无错误)
|
||||
final String? error;
|
||||
|
||||
/// vcode_token(OTP 验证通过后服务端下发,二级密码步骤需携带)
|
||||
final String vcodeToken;
|
||||
|
||||
/// 用户设置的二级密码提示语(步骤 3 显示)
|
||||
final String passcodeHint;
|
||||
|
||||
/// 脱敏找回邮箱(步骤 3 "忘记密码" 提示用)
|
||||
final String recoveryEmail;
|
||||
|
||||
LoginState copyWith({
|
||||
LoginStep? step,
|
||||
String? countryCode,
|
||||
@@ -42,6 +57,9 @@ class LoginState {
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
String? vcodeToken,
|
||||
String? passcodeHint,
|
||||
String? recoveryEmail,
|
||||
}) {
|
||||
return LoginState(
|
||||
step: step ?? this.step,
|
||||
@@ -49,6 +67,9 @@ class LoginState {
|
||||
contact: contact ?? this.contact,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
vcodeToken: vcodeToken ?? this.vcodeToken,
|
||||
passcodeHint: passcodeHint ?? this.passcodeHint,
|
||||
recoveryEmail: recoveryEmail ?? this.recoveryEmail,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/core/foundation/exceptions.dart';
|
||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
@@ -51,6 +52,9 @@ class LoginViewModel extends Notifier<LoginState> {
|
||||
}
|
||||
|
||||
/// 步骤 2+3:校验验证码并完成登录
|
||||
///
|
||||
/// 若账号已设置二级密码,服务端返回 30164,
|
||||
/// 此方法捕获 [SecondaryPasscodeRequiredException] 并跳转到步骤 3。
|
||||
Future<void> verifyAndLogin(String code) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
@@ -64,6 +68,48 @@ class LoginViewModel extends Notifier<LoginState> {
|
||||
code: code,
|
||||
);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login(uid: user.uid);
|
||||
} on SecondaryPasscodeRequiredException catch (e) {
|
||||
// 账号已设置二级密码 → 跳转二级密码输入步骤
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
step: LoginStep.secondaryPasscode,
|
||||
vcodeToken: e.vcodeToken,
|
||||
passcodeHint: e.hint,
|
||||
recoveryEmail: e.recoveryEmail,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤 3:用户输入二级密码后完成登录
|
||||
///
|
||||
/// [passcode] 为用户明文输入,UseCase 内部做 MD5 哈希。
|
||||
Future<void> verifyPasscode(String passcode) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final user = await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.loginWithSecondaryPasscode(
|
||||
countryCode: state.countryCode,
|
||||
contact: state.contact,
|
||||
vcodeToken: state.vcodeToken,
|
||||
passcode: passcode,
|
||||
);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login(uid: user.uid);
|
||||
} on FormatException catch (e) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:storage_sdk/storage_sdk.dart';
|
||||
|
||||
@@ -115,6 +118,48 @@ class LoginUseCase {
|
||||
return user;
|
||||
}
|
||||
|
||||
/// 步骤 2b:用户输入二级密码后完成登录
|
||||
///
|
||||
/// 在 [verifyAndLogin] 抛出 [SecondaryPasscodeRequiredException] 后调用:
|
||||
/// - MD5 哈希用户输入的密码
|
||||
/// - 调 [AuthRepository.loginWithPasscode]
|
||||
/// - 初始化 WebSocket / 数据库(与 [verifyAndLogin] 后半段一致)
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 密码为空
|
||||
/// - [ApiError] — 网络/服务端错误(密码错误 → 服务端返回错误码)
|
||||
Future<User> loginWithSecondaryPasscode({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String vcodeToken,
|
||||
required String passcode,
|
||||
}) async {
|
||||
if (passcode.trim().isEmpty) {
|
||||
throw const FormatException('密码不能为空');
|
||||
}
|
||||
|
||||
// MD5 哈希(对齐旧版 makeMD5 逻辑)
|
||||
final bytes = utf8.encode(passcode);
|
||||
final passwordMd5 = md5.convert(bytes).toString();
|
||||
|
||||
final user = await _authRepository.loginWithPasscode(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
vcodeToken: vcodeToken,
|
||||
passwordMd5: passwordMd5,
|
||||
);
|
||||
|
||||
final token = _apiConfig.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await _socketManager.connect(token: token);
|
||||
}
|
||||
|
||||
await _storageLifeCycle.openDatabase(user.uid);
|
||||
await _userRepository.insertOrReplaceUser(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
void _validatePhone(String contact) {
|
||||
final trimmed = contact.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
import 'package:im_app/features/login/presentation/login_view_model.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_otp_step.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_phone_step.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_secondary_passcode_step.dart';
|
||||
|
||||
/// 登录页 — 两步流程:手机号 → 验证码
|
||||
///
|
||||
@@ -24,11 +25,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
// demo 预填,上线前去掉
|
||||
final _phoneCtrl = TextEditingController(text: '83465308');
|
||||
final _otpCtrl = TextEditingController(text: '0000');
|
||||
final _passcodeCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_otpCtrl.dispose();
|
||||
_passcodeCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -44,8 +47,15 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
.verifyAndLogin(_otpCtrl.text.trim());
|
||||
}
|
||||
|
||||
void _verifyPasscode() {
|
||||
ref
|
||||
.read(loginViewModelProvider.notifier)
|
||||
.verifyPasscode(_passcodeCtrl.text);
|
||||
}
|
||||
|
||||
void _backToPhone() {
|
||||
_otpCtrl.clear();
|
||||
_passcodeCtrl.clear();
|
||||
ref.read(loginViewModelProvider.notifier).backToPhone();
|
||||
}
|
||||
|
||||
@@ -53,22 +63,35 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(loginViewModelProvider);
|
||||
|
||||
Widget body;
|
||||
switch (state.step) {
|
||||
case LoginStep.phone:
|
||||
body = LoginPhoneStep(
|
||||
phoneCtrl: _phoneCtrl,
|
||||
state: state,
|
||||
onSendOtp: () => _sendOtp(state),
|
||||
);
|
||||
case LoginStep.otp:
|
||||
body = LoginOtpStep(
|
||||
otpCtrl: _otpCtrl,
|
||||
state: state,
|
||||
onVerifyAndLogin: _verifyAndLogin,
|
||||
onBackToPhone: _backToPhone,
|
||||
);
|
||||
case LoginStep.secondaryPasscode:
|
||||
body = LoginSecondaryPasscodeStep(
|
||||
passcodeCtrl: _passcodeCtrl,
|
||||
state: state,
|
||||
onVerifyPasscode: _verifyPasscode,
|
||||
onBackToPhone: _backToPhone,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('登录')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: state.step == LoginStep.phone
|
||||
? LoginPhoneStep(
|
||||
phoneCtrl: _phoneCtrl,
|
||||
state: state,
|
||||
onSendOtp: () => _sendOtp(state),
|
||||
)
|
||||
: LoginOtpStep(
|
||||
otpCtrl: _otpCtrl,
|
||||
state: state,
|
||||
onVerifyAndLogin: _verifyAndLogin,
|
||||
onBackToPhone: _backToPhone,
|
||||
),
|
||||
child: SingleChildScrollView(child: body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
/// 步骤 3:二级密码输入界面
|
||||
///
|
||||
/// 当 OTP 验证后服务端返回 30164 时触发,显示:
|
||||
/// - 脱敏手机号(参考 otp 步骤)
|
||||
/// - 密码提示语(用户设置的 hint,可为空)
|
||||
/// - 密码输入框(obscured)
|
||||
/// - 确认按钮
|
||||
/// - "忘记密码" 占位(TODO: 接入找回流程)
|
||||
///
|
||||
/// 对齐旧版 Flutter `secondaryPasscodeLoginVerifyView`
|
||||
class LoginSecondaryPasscodeStep extends StatelessWidget {
|
||||
const LoginSecondaryPasscodeStep({
|
||||
super.key,
|
||||
required this.passcodeCtrl,
|
||||
required this.state,
|
||||
required this.onVerifyPasscode,
|
||||
required this.onBackToPhone,
|
||||
});
|
||||
|
||||
final TextEditingController passcodeCtrl;
|
||||
final LoginState state;
|
||||
final VoidCallback onVerifyPasscode;
|
||||
final VoidCallback onBackToPhone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
'请输入二级密码',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 脱敏手机号
|
||||
Text(
|
||||
state.maskedContact,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 密码提示语(hint 非空时显示)
|
||||
if (state.passcodeHint.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'提示:${state.passcodeHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 密码输入框
|
||||
TextField(
|
||||
controller: passcodeCtrl,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onVerifyPasscode(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '二级密码',
|
||||
hintText: '请输入您的二级密码',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 错误提示
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 确认按钮
|
||||
FilledButton(
|
||||
onPressed: state.isLoading ? null : onVerifyPasscode,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('确认'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 忘记密码(TODO: 接入找回流程)
|
||||
TextButton(
|
||||
onPressed: null, // TODO: 跳转找回密码页面
|
||||
child: Text(
|
||||
'忘记密码?',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary.withAlpha(128),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 返回
|
||||
TextButton(
|
||||
onPressed: onBackToPhone,
|
||||
child: const Text('返回手机号'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,17 @@ import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||
import 'package:im_app/features/app_tab/di/favorite_provider.dart';
|
||||
import 'package:im_app/data/repositories/call_log_repository_impl.dart';
|
||||
import 'package:im_app/domain/repositories/call_log_repository.dart';
|
||||
import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/fetch_profile_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/fetch_call_logs_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/logout_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/update_profile_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/fetch_favorites_use_case.dart';
|
||||
import 'package:im_app/features/settings/usecases/delete_favorite_use_case.dart';
|
||||
import 'package:im_app/features/settings/presentation/favorites_view_model.dart';
|
||||
|
||||
/// Settings feature DI 装配
|
||||
///
|
||||
@@ -42,6 +49,42 @@ final updateProfileUseCaseProvider = Provider<UpdateProfileUseCase>((ref) {
|
||||
return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider));
|
||||
});
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
final fetchFavoritesUseCaseProvider = Provider<FetchFavoritesUseCase>((ref) {
|
||||
return FetchFavoritesUseCase(
|
||||
client: ref.read(networkSdkApiProvider),
|
||||
repository: ref.read(favoriteRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final deleteFavoriteUseCaseProvider = Provider<DeleteFavoriteUseCase>((ref) {
|
||||
return DeleteFavoriteUseCase(
|
||||
client: ref.read(networkSdkApiProvider),
|
||||
repository: ref.read(favoriteRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final favoritesViewModelProvider =
|
||||
NotifierProvider<FavoritesViewModel, FavoritesState>(
|
||||
FavoritesViewModel.new,
|
||||
);
|
||||
|
||||
// ── Call Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 通话记录仓储 Provider(Settings feature 内部使用)
|
||||
final callLogRepositoryProvider = Provider<CallLogRepository>((ref) {
|
||||
return CallLogRepositoryImpl(ref.read(storageSdkProvider));
|
||||
});
|
||||
|
||||
/// 拉取通话记录用例 Provider
|
||||
final fetchCallLogsUseCaseProvider = Provider<FetchCallLogsUseCase>((ref) {
|
||||
return FetchCallLogsUseCase(
|
||||
client: ref.read(networkSdkApiProvider),
|
||||
repo: ref.read(callLogRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 退出登录用例 Provider
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_cropper/image_cropper.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/data/remote/upload_file_request.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
/// 编辑个人资料页状态
|
||||
/// 编辑个人资料页状态(#49 / #50)
|
||||
class EditProfileState {
|
||||
final String nickname;
|
||||
final String bio;
|
||||
final String? avatarUrl;
|
||||
final bool isLoading;
|
||||
final bool isSaving;
|
||||
final bool isUploadingAvatar;
|
||||
final String? error;
|
||||
|
||||
const EditProfileState({
|
||||
@@ -17,6 +25,7 @@ class EditProfileState {
|
||||
this.avatarUrl,
|
||||
this.isLoading = false,
|
||||
this.isSaving = false,
|
||||
this.isUploadingAvatar = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
@@ -27,6 +36,7 @@ class EditProfileState {
|
||||
bool clearAvatarUrl = false,
|
||||
bool? isLoading,
|
||||
bool? isSaving,
|
||||
bool? isUploadingAvatar,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
@@ -36,12 +46,36 @@ class EditProfileState {
|
||||
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSaving: isSaving ?? this.isSaving,
|
||||
isUploadingAvatar: isUploadingAvatar ?? this.isUploadingAvatar,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑个人资料 ViewModel
|
||||
/// 编辑个人资料 ViewModel(#49 / #50)
|
||||
///
|
||||
/// ## 数据流
|
||||
/// ```
|
||||
/// EditProfilePage (build)
|
||||
/// └─ ref.watch(editProfileViewModelProvider) 读取 nickname/bio/avatarUrl
|
||||
///
|
||||
/// 初始化:
|
||||
/// _loadCurrentProfile()
|
||||
/// → FetchProfileUseCase.execute() → GET /app/api/user/profile
|
||||
/// → 填充 nickname, bio, avatarUrl
|
||||
///
|
||||
/// 头像上传(#49):
|
||||
/// pickAndUploadAvatar()
|
||||
/// → ImagePicker.pickImage(gallery | camera)
|
||||
/// → ImageCropper.cropImage() (iOS UIImagePickerController 圆形预览)
|
||||
/// → UploadFileRequest FormData → POST /app/api/upload/file
|
||||
/// → state.avatarUrl = result.url
|
||||
///
|
||||
/// 保存:
|
||||
/// save()
|
||||
/// → UpdateProfileUseCase.execute(nickname, bio, profilePicUrl)
|
||||
/// → POST /app/api/user/update-profile
|
||||
/// ```
|
||||
class EditProfileViewModel extends Notifier<EditProfileState> {
|
||||
@override
|
||||
EditProfileState build() {
|
||||
@@ -49,6 +83,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
||||
return const EditProfileState(isLoading: true);
|
||||
}
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _loadCurrentProfile() async {
|
||||
try {
|
||||
final profile = await ref.read(fetchProfileUseCaseProvider).execute();
|
||||
@@ -63,6 +99,8 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 字段更新 ──────────────────────────────────────────────────────────────
|
||||
|
||||
void updateNickname(String value) {
|
||||
state = state.copyWith(nickname: value, clearError: true);
|
||||
}
|
||||
@@ -71,8 +109,64 @@ class EditProfileViewModel extends Notifier<EditProfileState> {
|
||||
state = state.copyWith(bio: value);
|
||||
}
|
||||
|
||||
// TODO Issue #6: 头像上传(CDN 流程)
|
||||
// void pickAndUploadAvatar() async { ... }
|
||||
// ── 头像选取与上传(#49) ──────────────────────────────────────────────────
|
||||
|
||||
/// 弹出选图 Sheet(相册 / 拍照)→ 裁剪 → 上传 CDN → 更新预览
|
||||
///
|
||||
/// 上传中 [isUploadingAvatar] = true,完成后恢复 false。
|
||||
/// 失败时设置 [error](由 UI 显示 SnackBar)。
|
||||
Future<void> pickAndUploadAvatar(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 900,
|
||||
maxHeight: 900,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
// 圆形裁剪(iOS UIImagePickerController 圆形预览对齐)
|
||||
final cropped = await ImageCropper().cropImage(
|
||||
sourcePath: picked.path,
|
||||
aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
|
||||
uiSettings: [
|
||||
IOSUiSettings(
|
||||
title: '裁剪头像',
|
||||
aspectRatioLockEnabled: true,
|
||||
resetAspectRatioEnabled: false,
|
||||
rotateButtonsHidden: false,
|
||||
),
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: '裁剪头像',
|
||||
lockAspectRatio: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (cropped == null) return;
|
||||
|
||||
state = state.copyWith(isUploadingAvatar: true, clearError: true);
|
||||
try {
|
||||
final result = await ref.read(networkSdkApiProvider).executeRequest(
|
||||
UploadFileRequest(filePath: cropped.path),
|
||||
);
|
||||
final url = result?.url ?? '';
|
||||
if (url.isEmpty) throw Exception('上传返回空 URL');
|
||||
state = state.copyWith(isUploadingAvatar: false, avatarUrl: url);
|
||||
} catch (e) {
|
||||
debugPrint('[EditProfileViewModel] avatar upload error: $e');
|
||||
state = state.copyWith(
|
||||
isUploadingAvatar: false,
|
||||
error: '头像上传失败: $e',
|
||||
);
|
||||
} finally {
|
||||
// 清理临时裁剪文件
|
||||
try {
|
||||
await File(cropped.path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保存 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<bool> save() async {
|
||||
if (state.nickname.trim().isEmpty) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
/// 收藏列表 ViewModel 状态(Gitea issue #44)
|
||||
class FavoritesState {
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final int page;
|
||||
final String? error;
|
||||
|
||||
const FavoritesState({
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = true,
|
||||
this.page = 1,
|
||||
this.error,
|
||||
});
|
||||
|
||||
FavoritesState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
int? page,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return FavoritesState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
page: page ?? this.page,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收藏列表 ViewModel
|
||||
///
|
||||
/// - 初始化时自动拉取第 1 页
|
||||
/// - [refresh]:下拉刷新(reset page=1,清空 DB 中旧数据由 repository.watchAll 自动反映)
|
||||
/// - [loadMore]:加载更多(page+1)
|
||||
/// - [deleteItem]:删除单条收藏
|
||||
///
|
||||
/// UI 直接 `ref.watch(allFavoritesProvider)` 监听 DB Stream 获取列表,
|
||||
/// ViewModel state 只负责 isLoading / error / pagination。
|
||||
class FavoritesViewModel extends Notifier<FavoritesState> {
|
||||
@override
|
||||
FavoritesState build() {
|
||||
// 初始化时自动拉取
|
||||
Future.microtask(() => _fetchPage(1, isRefresh: true));
|
||||
return const FavoritesState(isLoading: true);
|
||||
}
|
||||
|
||||
Future<void> _fetchPage(int page, {bool isRefresh = false}) async {
|
||||
if (isRefresh) {
|
||||
state = state.copyWith(isLoading: true, clearError: true, page: 1);
|
||||
} else {
|
||||
state = state.copyWith(isLoadingMore: true, clearError: true);
|
||||
}
|
||||
|
||||
try {
|
||||
final count = await ref
|
||||
.read(fetchFavoritesUseCaseProvider)
|
||||
.execute(page: page);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMore: count > 0,
|
||||
page: page,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉刷新
|
||||
Future<void> refresh() => _fetchPage(1, isRefresh: true);
|
||||
|
||||
/// 加载更多(只有 hasMore && !isLoadingMore 时才触发)
|
||||
Future<void> loadMore() {
|
||||
if (!state.hasMore || state.isLoadingMore) {
|
||||
return Future.value();
|
||||
}
|
||||
return _fetchPage(state.page + 1);
|
||||
}
|
||||
|
||||
/// 删除单条收藏
|
||||
Future<void> deleteItem(int id) async {
|
||||
try {
|
||||
await ref.read(deleteFavoriteUseCaseProvider).execute(id);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: '删除失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
/// 最近呼叫页状态
|
||||
class RecentCallsState {
|
||||
final int tabIndex;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const RecentCallsState({
|
||||
this.tabIndex = 0,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
RecentCallsState copyWith({
|
||||
int? tabIndex,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return RecentCallsState(
|
||||
tabIndex: tabIndex ?? this.tabIndex,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近呼叫页 ViewModel
|
||||
///
|
||||
/// ## 数据流
|
||||
/// ```
|
||||
/// RecentCallsPage (build)
|
||||
/// └─ ref.watch(recentCallsViewModelProvider) 读取 loading / error
|
||||
/// └─ ref.watch(allCallLogsProvider) DB Stream 实时通话记录
|
||||
/// → RecentCallsViewModel.loadCallLogs()
|
||||
/// → FetchCallLogsUseCase.execute() POST /app/api/call/records
|
||||
/// → CallLogRepository.insertOrReplace* → Drift DB → Stream 刷新
|
||||
/// ```
|
||||
class RecentCallsViewModel extends Notifier<RecentCallsState> {
|
||||
@override
|
||||
RecentCallsState build() {
|
||||
Future.microtask(_init);
|
||||
return const RecentCallsState();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
await Future.wait([loadCallLogs(), markAllRead()]);
|
||||
}
|
||||
|
||||
// ── Tab ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void setTab(int index) {
|
||||
state = state.copyWith(tabIndex: index);
|
||||
}
|
||||
|
||||
// ── 数据拉取 ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> loadCallLogs() async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
try {
|
||||
await ref.read(fetchCallLogsUseCaseProvider).execute();
|
||||
state = state.copyWith(isLoading: false);
|
||||
} catch (e) {
|
||||
debugPrint('[RecentCallsViewModel] loadCallLogs error: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 标记已读 ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
try {
|
||||
await ref.read(callLogRepositoryProvider).markAllAsRead();
|
||||
} catch (e) {
|
||||
debugPrint('[RecentCallsViewModel] markAllRead error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导航 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void navigateBack(BuildContext context) {
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
static void pushFrom(BuildContext context) {
|
||||
context.push(AppRouteName.settingsRecentCalls.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近呼叫页 ViewModel Provider
|
||||
final recentCallsViewModelProvider =
|
||||
NotifierProvider<RecentCallsViewModel, RecentCallsState>(
|
||||
RecentCallsViewModel.new,
|
||||
);
|
||||
@@ -12,6 +12,7 @@ class SettingsState {
|
||||
final String? avatarUrl;
|
||||
final String maskedContact;
|
||||
final int uid;
|
||||
final String bio;
|
||||
final bool isLoading;
|
||||
final bool isLoggingOut;
|
||||
final String? error;
|
||||
@@ -21,6 +22,7 @@ class SettingsState {
|
||||
this.avatarUrl,
|
||||
this.maskedContact = '',
|
||||
this.uid = 0,
|
||||
this.bio = '',
|
||||
this.isLoading = false,
|
||||
this.isLoggingOut = false,
|
||||
this.error,
|
||||
@@ -32,6 +34,7 @@ class SettingsState {
|
||||
bool clearAvatarUrl = false,
|
||||
String? maskedContact,
|
||||
int? uid,
|
||||
String? bio,
|
||||
bool? isLoading,
|
||||
bool? isLoggingOut,
|
||||
String? error,
|
||||
@@ -42,6 +45,7 @@ class SettingsState {
|
||||
avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl),
|
||||
maskedContact: maskedContact ?? this.maskedContact,
|
||||
uid: uid ?? this.uid,
|
||||
bio: bio ?? this.bio,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoggingOut: isLoggingOut ?? this.isLoggingOut,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
@@ -84,6 +88,7 @@ class SettingsViewModel extends Notifier<SettingsState> {
|
||||
avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic,
|
||||
maskedContact: _maskContact(profile.contact, profile.countryCode),
|
||||
uid: profile.uid,
|
||||
bio: profile.bio,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
@@ -136,6 +141,10 @@ class SettingsViewModel extends Notifier<SettingsState> {
|
||||
void navigateToAbout(BuildContext context) {
|
||||
context.push(AppRouteName.settingsAbout.path);
|
||||
}
|
||||
|
||||
void navigateToRecentCalls(BuildContext context) {
|
||||
context.push(AppRouteName.settingsRecentCalls.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的页面 ViewModel Provider
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/data/remote/fetch_favorites_request.dart';
|
||||
import 'package:im_app/domain/repositories/favorite_repository.dart';
|
||||
|
||||
/// 删除收藏:先调用 API,成功后同步删除本地 DB(Gitea issue #43)
|
||||
///
|
||||
/// ## 流程
|
||||
/// 1. POST /app/api/favorite/delete body: id={id}
|
||||
/// 2. API 成功 → `FavoriteRepository.delete(id)`
|
||||
/// 3. DB Stream 自动通知 UI 更新
|
||||
class DeleteFavoriteUseCase {
|
||||
final NetworksSdkApi _client;
|
||||
final FavoriteRepository _repository;
|
||||
|
||||
const DeleteFavoriteUseCase({
|
||||
required NetworksSdkApi client,
|
||||
required FavoriteRepository repository,
|
||||
}) : _client = client,
|
||||
_repository = repository;
|
||||
|
||||
Future<void> execute(int id) async {
|
||||
await _client.executeRequest(DeleteFavoriteRequest(id: id));
|
||||
await _repository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/data/remote/call_log_request.dart';
|
||||
import 'package:im_app/domain/repositories/call_log_repository.dart';
|
||||
|
||||
/// 拉取通话记录用例
|
||||
///
|
||||
/// ## 执行流程
|
||||
/// 1. POST /app/api/call/records(全量:startFrom=0, status=-1)
|
||||
/// 2. 解析响应 → CallLogDto → CallLog Domain 实体
|
||||
/// 3. insertOrReplaceCallLogs → Drift DB
|
||||
/// 4. allCallLogsProvider Stream 自动刷新 → UI 重建
|
||||
///
|
||||
/// ## 为什么全量拉取
|
||||
///
|
||||
/// 当前使用 startFrom=0 全量拉取,与 im-client-im-dev CallLogMgr 初始化行为一致。
|
||||
/// 后续可优化为增量:取本地最新 updatedAt 作为 startFrom,减少流量。
|
||||
class FetchCallLogsUseCase {
|
||||
final NetworksSdkApi _client;
|
||||
final CallLogRepository _repo;
|
||||
|
||||
const FetchCallLogsUseCase({
|
||||
required NetworksSdkApi client,
|
||||
required CallLogRepository repo,
|
||||
}) : _client = client,
|
||||
_repo = repo;
|
||||
|
||||
/// 拉取通话记录并写入本地 DB
|
||||
///
|
||||
/// 成功返回拉取到的记录数,失败抛出异常。
|
||||
Future<int> execute() async {
|
||||
final response = await _client.executeRequest(
|
||||
const FetchCallLogsRequest(startFrom: 0, status: -1),
|
||||
);
|
||||
|
||||
if (response == null || response.records.isEmpty) {
|
||||
debugPrint('[FetchCallLogsUseCase] no records returned');
|
||||
return 0;
|
||||
}
|
||||
|
||||
final entities = response.records.map((dto) => dto.toEntity()).toList();
|
||||
await _repo.insertOrReplaceCallLogs(entities);
|
||||
debugPrint(
|
||||
'[FetchCallLogsUseCase] inserted ${entities.length} call logs',
|
||||
);
|
||||
return entities.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/data/remote/fetch_favorites_request.dart';
|
||||
import 'package:im_app/domain/repositories/favorite_repository.dart';
|
||||
|
||||
/// 分页拉取收藏列表并持久化到 DB(Gitea issue #42)
|
||||
///
|
||||
/// ## 流程
|
||||
/// 1. GET /app/api/favorite/favorites?page={n}
|
||||
/// 2. 解码为 [Favorite] 列表
|
||||
/// 3. upsert 到 [FavoriteRepository](insertOrReplaceAll)
|
||||
///
|
||||
/// 返回本次拉取到的条目数;返回 0 表示无更多数据。
|
||||
class FetchFavoritesUseCase {
|
||||
final NetworksSdkApi _client;
|
||||
final FavoriteRepository _repository;
|
||||
|
||||
const FetchFavoritesUseCase({
|
||||
required NetworksSdkApi client,
|
||||
required FavoriteRepository repository,
|
||||
}) : _client = client,
|
||||
_repository = repository;
|
||||
|
||||
Future<int> execute({int page = 1}) async {
|
||||
final resp =
|
||||
await _client.executeRequest(FetchFavoritesRequest(page: page));
|
||||
if (resp == null || resp.items.isEmpty) return 0;
|
||||
await _repository.insertOrReplaceAll(resp.items);
|
||||
return resp.items.length;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:im_app/features/settings/presentation/edit_profile_view_model.dart';
|
||||
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
||||
|
||||
/// 编辑个人资料页
|
||||
/// 编辑个人资料页(#49 / #50)
|
||||
///
|
||||
/// 对应 Gitea issue #6
|
||||
class EditProfilePage extends ConsumerWidget {
|
||||
/// ## 结构
|
||||
/// - AppBar 右侧:保存按钮(昵称为空或上传中时禁用)
|
||||
/// - 头像区:88pt 圆形 + 渐变占位 + 相机角标 + 上传进度环
|
||||
/// - Card 分组表单:昵称(50字)/ 个人简介(200字,多行)
|
||||
/// - 底部错误 Banner
|
||||
///
|
||||
/// ## 数据流
|
||||
/// - 加载:`editProfileViewModelProvider` build → `_loadCurrentProfile()`
|
||||
/// - 头像:`_showAvatarSourceSheet()` → `vm.pickAndUploadAvatar(source)`
|
||||
/// - 保存:`vm.save()` → 成功 → `settingsViewModelProvider.loadProfile()` + pop
|
||||
class EditProfilePage extends ConsumerStatefulWidget {
|
||||
const EditProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
|
||||
}
|
||||
|
||||
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
late final TextEditingController _nicknameCtrl;
|
||||
late final TextEditingController _bioCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nicknameCtrl = TextEditingController();
|
||||
_bioCtrl = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nicknameCtrl.dispose();
|
||||
_bioCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Sync controllers once profile is loaded (isLoading → false, nickname populated)
|
||||
void _syncControllers(EditProfileState state) {
|
||||
if (!state.isLoading) {
|
||||
if (_nicknameCtrl.text != state.nickname) {
|
||||
_nicknameCtrl.value = TextEditingValue(
|
||||
text: state.nickname,
|
||||
selection: TextSelection.collapsed(offset: state.nickname.length),
|
||||
);
|
||||
}
|
||||
if (_bioCtrl.text != state.bio) {
|
||||
_bioCtrl.value = TextEditingValue(
|
||||
text: state.bio,
|
||||
selection: TextSelection.collapsed(offset: state.bio.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showAvatarSourceSheet() async {
|
||||
final vm = ref.read(editProfileViewModelProvider.notifier);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_rounded),
|
||||
title: const Text('从相册选取'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
vm.pickAndUploadAvatar(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_rounded),
|
||||
title: const Text('拍照'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
vm.pickAndUploadAvatar(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(editProfileViewModelProvider);
|
||||
final vm = ref.read(editProfileViewModelProvider.notifier);
|
||||
|
||||
// Sync text controllers after profile loads
|
||||
_syncControllers(state);
|
||||
|
||||
final canSave =
|
||||
state.nickname.trim().isNotEmpty && !state.isUploadingAvatar;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('编辑资料'),
|
||||
@@ -30,14 +131,17 @@ class EditProfilePage extends ConsumerWidget {
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final ok = await vm.save();
|
||||
if (ok && context.mounted) {
|
||||
// 刷新我的页面资料卡
|
||||
ref.read(settingsViewModelProvider.notifier).loadProfile();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
onPressed: canSave
|
||||
? () async {
|
||||
final ok = await vm.save();
|
||||
if (ok && context.mounted) {
|
||||
ref
|
||||
.read(settingsViewModelProvider.notifier)
|
||||
.loadProfile();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
@@ -45,83 +149,258 @@ class EditProfilePage extends ConsumerWidget {
|
||||
body: state.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
children: [
|
||||
// 头像区域
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 44,
|
||||
backgroundImage: state.avatarUrl != null
|
||||
? NetworkImage(state.avatarUrl!)
|
||||
: null,
|
||||
child: state.avatarUrl == null
|
||||
? const Icon(Icons.person, size: 40)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
// ── 头像区 ──────────────────────────────────────────────────
|
||||
Center(child: _AvatarSection(state: state, onTap: _showAvatarSourceSheet)),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// ── 昵称卡片 ─────────────────────────────────────────────
|
||||
_FormCard(
|
||||
label: '昵称',
|
||||
child: TextField(
|
||||
controller: _nicknameCtrl,
|
||||
maxLength: 50,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
onChanged: vm.updateNickname,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入昵称',
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
suffixText: '${state.nickname.length}/50',
|
||||
suffixStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: const Icon(Icons.camera_alt, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
// TODO Issue #6: 头像上传(CDN 流程)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('头像上传功能开发中')),
|
||||
);
|
||||
},
|
||||
child: const Text('更换头像'),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 个人简介卡片 ──────────────────────────────────────────
|
||||
_FormCard(
|
||||
label: '个人简介',
|
||||
child: TextField(
|
||||
controller: _bioCtrl,
|
||||
maxLength: 200,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
onChanged: vm.updateBio,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: '介绍一下自己',
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
suffixText: '${state.bio.length}/200',
|
||||
suffixStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 昵称
|
||||
TextFormField(
|
||||
initialValue: state.nickname,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
maxLength: 50,
|
||||
onChanged: vm.updateNickname,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 个人简介
|
||||
TextFormField(
|
||||
initialValue: state.bio,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '个人简介',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLength: 200,
|
||||
onChanged: vm.updateBio,
|
||||
),
|
||||
|
||||
// ── 错误 Banner ───────────────────────────────────────────
|
||||
if (state.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ErrorBanner(message: state.error!),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 头像区(88pt 圆 + 渐变占位 + 相机角标 + 上传进度环)────────────────────
|
||||
|
||||
class _AvatarSection extends StatelessWidget {
|
||||
const _AvatarSection({required this.state, required this.onTap});
|
||||
|
||||
final EditProfileState state;
|
||||
final VoidCallback onTap;
|
||||
|
||||
// 与 ProfileHeroCard 保持一致的 8 色渐变方案
|
||||
static const _gradients = [
|
||||
[Color(0xFF4776E6), Color(0xFF8E54E9)],
|
||||
[Color(0xFF11998E), Color(0xFF38EF7D)],
|
||||
[Color(0xFFFF6B6B), Color(0xFFFFE66D)],
|
||||
[Color(0xFF2193B0), Color(0xFF6DD5ED)],
|
||||
[Color(0xFFCC2B5E), Color(0xFF753A88)],
|
||||
[Color(0xFFEB5757), Color(0xFF000000)],
|
||||
[Color(0xFF56CCF2), Color(0xFF2F80ED)],
|
||||
[Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 88.0;
|
||||
const radius = size / 2;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: size + 4,
|
||||
height: size + 4,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 上传中进度环
|
||||
if (state.isUploadingAvatar)
|
||||
SizedBox(
|
||||
width: size + 4,
|
||||
height: size + 4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
// 头像圆
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: state.avatarUrl != null
|
||||
? Image.network(
|
||||
state.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_GradientPlaceholder(size: size, gradients: _gradients),
|
||||
)
|
||||
: _GradientPlaceholder(size: size, gradients: _gradients),
|
||||
),
|
||||
),
|
||||
|
||||
// 相机角标(右下)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.camera_alt_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientPlaceholder extends StatelessWidget {
|
||||
const _GradientPlaceholder({required this.size, required this.gradients});
|
||||
|
||||
final double size;
|
||||
final List<List<Color>> gradients;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pair = gradients[0]; // 默认第一组,加载完用真实头像替换
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: pair,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white70),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 表单分组卡片 ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _FormCard extends StatelessWidget {
|
||||
const _FormCard({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 错误 Banner ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
360
apps/im_app/lib/features/settings/view/favorites_page.dart
Normal file
360
apps/im_app/lib/features/settings/view/favorites_page.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/domain/entities/favorite.dart';
|
||||
import 'package:im_app/features/app_tab/di/favorite_provider.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
import 'package:im_app/features/settings/presentation/favorites_view_model.dart';
|
||||
|
||||
/// 收藏列表页(Gitea issue #44)
|
||||
///
|
||||
/// - 列表从 `allFavoritesProvider`(DB Stream)实时驱动
|
||||
/// - 分页 / loading / error 状态来自 `favoritesViewModelProvider`
|
||||
/// - 下拉刷新调用 `vm.refresh()`
|
||||
/// - 滚动到底部触发 `vm.loadMore()`
|
||||
/// - 左滑删除调用 `vm.deleteItem(id)`
|
||||
class FavoritesPage extends ConsumerStatefulWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FavoritesPage> createState() => _FavoritesPageState();
|
||||
}
|
||||
|
||||
class _FavoritesPageState extends ConsumerState<FavoritesPage> {
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollCtrl.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollCtrl.position.pixels >=
|
||||
_scrollCtrl.position.maxScrollExtent - 80) {
|
||||
ref.read(favoritesViewModelProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vmState = ref.watch(favoritesViewModelProvider);
|
||||
final favAsync = ref.watch(allFavoritesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('收藏')),
|
||||
body: favAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (favorites) {
|
||||
if (vmState.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (favorites.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
if (vmState.error != null)
|
||||
_ErrorBanner(message: vmState.error!),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(favoritesViewModelProvider.notifier).refresh(),
|
||||
child: ListView.separated(
|
||||
controller: _scrollCtrl,
|
||||
itemCount:
|
||||
favorites.length + (vmState.isLoadingMore ? 1 : 0),
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, indent: 56),
|
||||
itemBuilder: (context, i) {
|
||||
if (i == favorites.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return _FavoriteCell(
|
||||
favorite: favorites[i],
|
||||
onDelete: () => ref
|
||||
.read(favoritesViewModelProvider.notifier)
|
||||
.deleteItem(favorites[i].id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 收藏 Cell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FavoriteCell extends StatelessWidget {
|
||||
const _FavoriteCell({
|
||||
required this.favorite,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Favorite favorite;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Dismissible(
|
||||
key: ValueKey(favorite.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
color: cs.error,
|
||||
child: Icon(Icons.delete_rounded, color: cs.onError),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除收藏'),
|
||||
content: const Text('确定要删除这条收藏吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) => onDelete(),
|
||||
child: ListTile(
|
||||
leading: _FavoriteIcon(favorite: favorite),
|
||||
title: Text(
|
||||
_buildTitle(favorite),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
_buildSubtitle(favorite),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: cs.onSurface.withOpacity(0.6)),
|
||||
),
|
||||
trailing: Text(
|
||||
_formatTime(favorite.createdAt),
|
||||
style: TextStyle(fontSize: 11, color: cs.onSurface.withOpacity(0.4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildTitle(Favorite fav) {
|
||||
// typ 是 JSON string "[1,2]";取第一个 typ 决定标题前缀
|
||||
final types = _parseTyp(fav.typ);
|
||||
if (types.isEmpty) return '收藏';
|
||||
switch (types.first) {
|
||||
case 1:
|
||||
return _firstDataContent(fav) ?? '文字收藏';
|
||||
case 2:
|
||||
return '链接';
|
||||
case 3:
|
||||
return '图片';
|
||||
case 4:
|
||||
return '视频';
|
||||
case 5:
|
||||
return '语音';
|
||||
case 6:
|
||||
return '文件';
|
||||
case 7:
|
||||
return '位置';
|
||||
case 9:
|
||||
return '相册';
|
||||
case 10:
|
||||
return '笔记';
|
||||
default:
|
||||
return '收藏';
|
||||
}
|
||||
}
|
||||
|
||||
String _buildSubtitle(Favorite fav) {
|
||||
final content = _firstDataContent(fav);
|
||||
if (content != null && content.isNotEmpty) return content;
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 解析 data 字段(JSON-encoded string),取第一条的 content 字段
|
||||
String? _firstDataContent(Favorite fav) {
|
||||
try {
|
||||
final list = jsonDecode(fav.data) as List<dynamic>;
|
||||
if (list.isEmpty) return null;
|
||||
final first = list.first as Map<String, dynamic>;
|
||||
return first['content'] as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _parseTyp(String typStr) {
|
||||
try {
|
||||
final list = jsonDecode(typStr) as List<dynamic>;
|
||||
return list.whereType<int>().toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int unixSecs) {
|
||||
if (unixSecs == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000);
|
||||
final now = DateTime.now();
|
||||
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
|
||||
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${dt.month}/${dt.day}';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 收藏类型图标 ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _FavoriteIcon extends StatelessWidget {
|
||||
const _FavoriteIcon({required this.favorite});
|
||||
|
||||
final Favorite favorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final types = _parseTyp(favorite.typ);
|
||||
final typ = types.isNotEmpty ? types.first : 0;
|
||||
|
||||
IconData icon;
|
||||
Color color;
|
||||
switch (typ) {
|
||||
case 1:
|
||||
icon = Icons.text_snippet_rounded;
|
||||
color = const Color(0xFF5667FF);
|
||||
break;
|
||||
case 2:
|
||||
icon = Icons.link_rounded;
|
||||
color = const Color(0xFF0BB8A9);
|
||||
break;
|
||||
case 3:
|
||||
icon = Icons.image_rounded;
|
||||
color = const Color(0xFF4CB050);
|
||||
break;
|
||||
case 4:
|
||||
icon = Icons.videocam_rounded;
|
||||
color = const Color(0xFFFF8B5E);
|
||||
break;
|
||||
case 5:
|
||||
icon = Icons.mic_rounded;
|
||||
color = const Color(0xFF8A5CF6);
|
||||
break;
|
||||
case 6:
|
||||
icon = Icons.insert_drive_file_rounded;
|
||||
color = const Color(0xFFFFAF45);
|
||||
break;
|
||||
case 7:
|
||||
icon = Icons.location_on_rounded;
|
||||
color = const Color(0xFFFF4B4B);
|
||||
break;
|
||||
case 9:
|
||||
icon = Icons.photo_library_rounded;
|
||||
color = const Color(0xFF4CB050);
|
||||
break;
|
||||
case 10:
|
||||
icon = Icons.article_rounded;
|
||||
color = const Color(0xFF5667FF);
|
||||
break;
|
||||
default:
|
||||
icon = Icons.star_rounded;
|
||||
color = const Color(0xFFFFAF45);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _parseTyp(String typStr) {
|
||||
try {
|
||||
final list = jsonDecode(typStr) as List<dynamic>;
|
||||
return list.whereType<int>().toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 空状态 & 错误 Banner ──────────────────────────────────────────────────────
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star_border_rounded,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无收藏',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: cs.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(fontSize: 12, color: cs.onErrorContainer),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
apps/im_app/lib/features/settings/view/recent_calls_page.dart
Normal file
337
apps/im_app/lib/features/settings/view/recent_calls_page.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/call_log.dart';
|
||||
import 'package:im_app/features/chat/call/di/call_log_provider.dart';
|
||||
import 'package:im_app/features/settings/presentation/recent_calls_view_model.dart';
|
||||
|
||||
/// 最近呼叫页(#42 / #43 / #44)
|
||||
///
|
||||
/// ## 结构
|
||||
/// - AppBar:「最近呼叫」
|
||||
/// - 双 Tab:全部 / 未接来电
|
||||
/// - 每行:_CallLogTile(通话类型图标、对方 UID、时长/状态、时间)
|
||||
///
|
||||
/// ## 未接来电判断
|
||||
/// callerId != currentUid AND status in {3, 4, 5, 6}
|
||||
///
|
||||
/// ## 数据流
|
||||
/// - 进入页面:loadCallLogs()(POST /app/api/call/records)→ DB
|
||||
/// - 监听:allCallLogsProvider(DB Stream)→ 实时刷新
|
||||
/// - 进入页面同时:markAllRead()
|
||||
class RecentCallsPage extends ConsumerStatefulWidget {
|
||||
const RecentCallsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RecentCallsPage> createState() => _RecentCallsPageState();
|
||||
}
|
||||
|
||||
class _RecentCallsPageState extends ConsumerState<RecentCallsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabCtrl = TabController(length: 2, vsync: this);
|
||||
_tabCtrl.addListener(() {
|
||||
if (!_tabCtrl.indexIsChanging) {
|
||||
ref
|
||||
.read(recentCallsViewModelProvider.notifier)
|
||||
.setTab(_tabCtrl.index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(recentCallsViewModelProvider);
|
||||
final logsAsync = ref.watch(allCallLogsProvider);
|
||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('最近呼叫'),
|
||||
bottom: TabBar(
|
||||
controller: _tabCtrl,
|
||||
tabs: const [
|
||||
Tab(text: '全部'),
|
||||
Tab(text: '未接来电'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── 加载中指示条 ───────────────────────────────────────────────────
|
||||
if (state.isLoading)
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
|
||||
// ── 错误横幅 ───────────────────────────────────────────────────────
|
||||
if (state.error != null)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'加载失败: ${state.error}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── TabBarView ─────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: logsAsync.when(
|
||||
data: (logs) => TabBarView(
|
||||
controller: _tabCtrl,
|
||||
children: [
|
||||
_CallLogList(
|
||||
logs: logs,
|
||||
currentUid: currentUid,
|
||||
missedOnly: false,
|
||||
),
|
||||
_CallLogList(
|
||||
logs: logs,
|
||||
currentUid: currentUid,
|
||||
missedOnly: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通话记录列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _CallLogList extends StatelessWidget {
|
||||
const _CallLogList({
|
||||
required this.logs,
|
||||
required this.currentUid,
|
||||
required this.missedOnly,
|
||||
});
|
||||
|
||||
final List<CallLog> logs;
|
||||
final int currentUid;
|
||||
final bool missedOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = missedOnly
|
||||
? logs.where((l) => _isMissed(l, currentUid)).toList()
|
||||
: logs;
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
missedOnly
|
||||
? Icons.phone_missed_rounded
|
||||
: Icons.phone_outlined,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
missedOnly ? '没有未接来电' : '暂无通话记录',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (context, i) => _CallLogTile(
|
||||
log: filtered[i],
|
||||
currentUid: currentUid,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通话记录单行 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 通话记录单项 Cell(对应 im-client-im-dev CallLogTile)
|
||||
///
|
||||
/// - 左圆形图标:语音/视频;颜色:未接=红,已接=绿,已拨=primary
|
||||
/// - 标题:对方 `@J{uid}`(未接时红色)
|
||||
/// - 副标题:通话类型 + 时长
|
||||
/// - 右侧:相对时间
|
||||
class _CallLogTile extends StatelessWidget {
|
||||
const _CallLogTile({required this.log, required this.currentUid});
|
||||
|
||||
final CallLog log;
|
||||
final int currentUid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMissed = _isMissed(log, currentUid);
|
||||
final isOutgoing = (log.callerId ?? 0) == currentUid;
|
||||
final isVideo = (log.videoCall ?? 0) == 1;
|
||||
|
||||
final Color iconColor;
|
||||
if (isMissed) {
|
||||
iconColor = Colors.red;
|
||||
} else if (isOutgoing) {
|
||||
iconColor = Theme.of(context).colorScheme.primary;
|
||||
} else {
|
||||
iconColor = Colors.green;
|
||||
}
|
||||
|
||||
final IconData callIcon;
|
||||
if (isMissed) {
|
||||
callIcon = isVideo
|
||||
? Icons.videocam_off_rounded
|
||||
: Icons.phone_missed_rounded;
|
||||
} else if (isOutgoing) {
|
||||
callIcon =
|
||||
isVideo ? Icons.videocam_rounded : Icons.call_made_rounded;
|
||||
} else {
|
||||
callIcon = isVideo
|
||||
? Icons.videocam_rounded
|
||||
: Icons.call_received_rounded;
|
||||
}
|
||||
|
||||
final otherUid =
|
||||
isOutgoing ? (log.receiverId ?? 0) : (log.callerId ?? 0);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: _CallIcon(icon: callIcon, color: iconColor),
|
||||
title: Text(
|
||||
'@J$otherUid',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isMissed ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
_buildSubtitle(isMissed, isOutgoing, isVideo, log.duration),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
_formatTime(log.updatedAt ?? log.createdAt),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () {}, // TODO: 回拨功能
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle(
|
||||
bool isMissed,
|
||||
bool isOutgoing,
|
||||
bool isVideo,
|
||||
int? duration,
|
||||
) {
|
||||
final typeStr = isVideo ? '视频通话' : '语音通话';
|
||||
if (isMissed) return '未接$typeStr';
|
||||
if (duration != null && duration > 0) {
|
||||
final label = isOutgoing ? '已拨' : '已接';
|
||||
return '$label · ${_formatDuration(duration)}';
|
||||
}
|
||||
return isOutgoing ? '已拨$typeStr' : '已接$typeStr';
|
||||
}
|
||||
}
|
||||
|
||||
class _CallIcon extends StatelessWidget {
|
||||
const _CallIcon({required this.icon, required this.color});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 判断是否为未接来电
|
||||
///
|
||||
/// 条件:非本人发起 + status in {3, 4, 5, 6}(busy/cancel/timeout/declined)
|
||||
bool _isMissed(CallLog log, int currentUid) {
|
||||
const missedStatuses = {3, 4, 5, 6};
|
||||
return (log.callerId ?? 0) != currentUid &&
|
||||
missedStatuses.contains(log.status ?? -1);
|
||||
}
|
||||
|
||||
/// 格式化通话时长(秒 → MM:SS 或 H:MM:SS)
|
||||
String _formatDuration(int seconds) {
|
||||
final d = Duration(seconds: seconds);
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||
}
|
||||
|
||||
/// 格式化通话时间(Unix 秒时间戳 → 相对时间或月/日)
|
||||
String _formatTime(int? timestamp) {
|
||||
if (timestamp == null || timestamp == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||||
|
||||
return '${dt.month}/${dt.day}';
|
||||
}
|
||||
@@ -1,22 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
import 'package:im_app/core/foundation/config.dart';
|
||||
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
||||
|
||||
/// 我的页面(原设置页)
|
||||
/// 我的页(#39 / #40 / #41)
|
||||
///
|
||||
/// 结构:
|
||||
/// ┌─ 个人资料卡 ──────────────────────────────────────────┐
|
||||
/// │ 头像 昵称 │
|
||||
/// │ 手机号(掩码) UID: xxx │
|
||||
/// └──────────────────────────────────────────────────────┘
|
||||
/// 偏好设置 → 主题 / 语言 / 通知
|
||||
/// 工具 → 黑名单 / 网络诊断
|
||||
/// 关于 → 关于本应用
|
||||
/// [退出登录]
|
||||
/// ## 结构(iOS SettingsView.swift 对齐)
|
||||
///
|
||||
/// - ProfileHeroCard:72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio
|
||||
/// - AppBar:compact,右侧 QR 图标 + 编辑铅笔
|
||||
/// - 卡片组 1(账户):我的钱包 / 账户安全
|
||||
/// - 卡片组 2(工具):收藏 / 最近呼叫 / 链接设备 / 聊天文件夹
|
||||
/// - 卡片组 3「偏好设置」:通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题
|
||||
/// - 卡片组 4「关于」:用户协议 / 隐私政策 / 版本号
|
||||
/// - 退出登录(全宽红色按钮)
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
// ── iOS 色板 ──────────────────────────────────────────────────────────────
|
||||
static const Color _walletColor = Color(0xFFFFAA5B);
|
||||
static const Color _securityColor = Color(0xFF8A5CF6);
|
||||
static const Color _favoriteColor = Color(0xFFFFAF45);
|
||||
static const Color _callColor = Color(0xFF4CB050);
|
||||
static const Color _deviceColor = Color(0xFF5667FF);
|
||||
static const Color _folderColor = Color(0xFFF2994A);
|
||||
static const Color _notifColor = Color(0xFFFF8B5E);
|
||||
static const Color _privacyColor = Color(0xFF0BB8A9);
|
||||
static const Color _blockColor = Color(0xFFFF4B4B);
|
||||
static const Color _langColor = Color(0xFF5667FF);
|
||||
static const Color _themeColor = Color(0xFF8A5CF6);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(settingsViewModelProvider);
|
||||
@@ -26,75 +43,158 @@ class SettingsPage extends ConsumerWidget {
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar.large(
|
||||
// ── AppBar(compact,QR + edit) ────────────────────────────────────
|
||||
SliverAppBar(
|
||||
title: const Text('我的'),
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner_rounded),
|
||||
tooltip: '我的二维码',
|
||||
onPressed: () {}, // TODO: QR code page
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: '编辑资料',
|
||||
onPressed: () => vm.navigateToEditProfile(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: _ProfileCard(state: state, onTap: () => vm.navigateToEditProfile(context)),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── ProfileHeroCard ───────────────────────────────────────
|
||||
_ProfileHeroCard(
|
||||
state: state,
|
||||
onTap: () => vm.navigateToEditProfile(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 卡片组 1:账户 ────────────────────────────────────────
|
||||
_SettingsCard(
|
||||
items: [
|
||||
_RowConfig(
|
||||
icon: Icons.credit_card_rounded,
|
||||
iconColor: _walletColor,
|
||||
title: '我的钱包',
|
||||
onTap: () {}, // TODO: 钱包页
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.shield_rounded,
|
||||
iconColor: _securityColor,
|
||||
title: '账户安全',
|
||||
onTap: () {}, // TODO: 账户安全页
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 卡片组 2:工具 ────────────────────────────────────────
|
||||
_SettingsCard(
|
||||
items: [
|
||||
_RowConfig(
|
||||
icon: Icons.star_rounded,
|
||||
iconColor: _favoriteColor,
|
||||
title: '收藏',
|
||||
onTap: () => context.push(AppRouteName.settingsFavorites.path),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.phone_rounded,
|
||||
iconColor: _callColor,
|
||||
title: '最近呼叫',
|
||||
onTap: () => vm.navigateToRecentCalls(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.laptop_rounded,
|
||||
iconColor: _deviceColor,
|
||||
title: '链接设备',
|
||||
onTap: () {}, // TODO: 设备管理页
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.folder_rounded,
|
||||
iconColor: _folderColor,
|
||||
title: '聊天文件夹',
|
||||
onTap: () {}, // TODO: Issue #11
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 卡片组 3:偏好设置 ────────────────────────────────────
|
||||
_SectionLabel('偏好设置'),
|
||||
_SettingsCard(
|
||||
items: [
|
||||
_RowConfig(
|
||||
icon: Icons.palette_outlined,
|
||||
title: '主题',
|
||||
onTap: () => vm.navigateToTheme(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.language,
|
||||
title: '语言',
|
||||
onTap: () => vm.navigateToLanguage(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: '通知',
|
||||
icon: Icons.notifications_rounded,
|
||||
iconColor: _notifColor,
|
||||
title: '通知和声音',
|
||||
onTap: () {}, // TODO: 通知设置页
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel('工具'),
|
||||
_SettingsCard(
|
||||
items: [
|
||||
_RowConfig(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '聊天文件夹',
|
||||
onTap: () {}, // TODO: Issue #11
|
||||
icon: Icons.lock_rounded,
|
||||
iconColor: _privacyColor,
|
||||
title: '隐私设置',
|
||||
onTap: () {}, // TODO: 隐私设置页
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.block,
|
||||
icon: Icons.block_rounded,
|
||||
iconColor: _blockColor,
|
||||
title: '黑名单',
|
||||
onTap: () => vm.navigateToBlocklist(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.network_check,
|
||||
title: '网络诊断',
|
||||
onTap: () => vm.navigateToNetworkDiagnostics(context),
|
||||
icon: Icons.language_rounded,
|
||||
iconColor: _langColor,
|
||||
title: '语言',
|
||||
onTap: () => vm.navigateToLanguage(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.palette_rounded,
|
||||
iconColor: _themeColor,
|
||||
title: '主题',
|
||||
onTap: () => vm.navigateToTheme(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 卡片组 4:关于 ────────────────────────────────────────
|
||||
_SectionLabel('关于'),
|
||||
_SettingsCard(
|
||||
items: [
|
||||
_RowConfig(
|
||||
icon: Icons.info_outline,
|
||||
title: '关于本应用',
|
||||
icon: Icons.description_outlined,
|
||||
iconColor: Colors.grey,
|
||||
title: '用户协议',
|
||||
onTap: () => vm.navigateToAbout(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
iconColor: Colors.grey,
|
||||
title: '隐私政策',
|
||||
onTap: () => vm.navigateToAbout(context),
|
||||
),
|
||||
_RowConfig(
|
||||
icon: Icons.info_outline_rounded,
|
||||
iconColor: Colors.grey,
|
||||
title: '版本号',
|
||||
subtitle: AppConfig.appVersion.isEmpty
|
||||
? '1.0.0'
|
||||
: AppConfig.appVersion,
|
||||
onTap: () {},
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 退出登录 ───────────────────────────────────────────────
|
||||
_LogoutButton(
|
||||
isLoading: state.isLoggingOut,
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
@@ -134,16 +234,32 @@ class SettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 个人资料卡 ─────────────────────────────────────────────────────────────────
|
||||
// ── ProfileHeroCard (#39 / #41) ───────────────────────────────────────────────
|
||||
|
||||
class _ProfileCard extends StatelessWidget {
|
||||
const _ProfileCard({required this.state, required this.onTap});
|
||||
/// iOS SettingsView.swift ProfileHeroCard 对齐
|
||||
///
|
||||
/// 8 色渐变占位(uid % 8)+ 昵称 + @J{uid} handle + 手机号 + bio
|
||||
class _ProfileHeroCard extends StatelessWidget {
|
||||
const _ProfileHeroCard({required this.state, required this.onTap});
|
||||
|
||||
final SettingsState state;
|
||||
final VoidCallback onTap;
|
||||
|
||||
// 8 色渐变方案(iOS 端 ProfileHeroCard 同款)
|
||||
static const _gradients = [
|
||||
[Color(0xFF4776E6), Color(0xFF8E54E9)], // 0: 蓝紫
|
||||
[Color(0xFF11998E), Color(0xFF38EF7D)], // 1: 青绿
|
||||
[Color(0xFFFC466B), Color(0xFF3F5EFB)], // 2: 粉蓝
|
||||
[Color(0xFFF7971E), Color(0xFFFFD200)], // 3: 橙黄
|
||||
[Color(0xFF56CCF2), Color(0xFF2F80ED)], // 4: 天蓝
|
||||
[Color(0xFFEB3349), Color(0xFFF45C43)], // 5: 红橙
|
||||
[Color(0xFF1FA2FF), Color(0xFF12D8FA)], // 6: 蓝青
|
||||
[Color(0xFF9D50BB), Color(0xFF6E48AA)], // 7: 深紫
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -151,8 +267,13 @@ class _ProfileCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_Avatar(avatarUrl: state.avatarUrl, isLoading: state.isLoading),
|
||||
_HeroAvatar(
|
||||
avatarUrl: state.avatarUrl,
|
||||
uid: state.uid,
|
||||
isLoading: state.isLoading,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: state.isLoading
|
||||
@@ -162,26 +283,45 @@ class _ProfileCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
state.nickname.isEmpty ? '加载中…' : state.nickname,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.maskedContact.isNotEmpty
|
||||
? state.maskedContact
|
||||
: 'UID: ${state.uid}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
if (state.maskedContact.isNotEmpty && state.uid > 0)
|
||||
const SizedBox(height: 2),
|
||||
if (state.uid > 0)
|
||||
Text(
|
||||
'UID: ${state.uid}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
'@J${state.uid}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
if (state.maskedContact.isNotEmpty)
|
||||
Text(
|
||||
state.maskedContact,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
state.bio.isNotEmpty ? state.bio : '添加一句话简介',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: state.bio.isNotEmpty
|
||||
? cs.onSurfaceVariant
|
||||
: cs.onSurfaceVariant.withOpacity(0.5),
|
||||
fontStyle: state.bio.isEmpty
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -194,33 +334,51 @@ class _ProfileCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar({required this.avatarUrl, required this.isLoading});
|
||||
class _HeroAvatar extends StatelessWidget {
|
||||
const _HeroAvatar({
|
||||
required this.avatarUrl,
|
||||
required this.uid,
|
||||
required this.isLoading,
|
||||
});
|
||||
|
||||
final String? avatarUrl;
|
||||
final int uid;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const radius = 36.0; // 72pt diameter
|
||||
|
||||
if (isLoading) {
|
||||
return const CircleAvatar(
|
||||
radius: 28,
|
||||
radius: radius,
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: 28,
|
||||
radius: radius,
|
||||
backgroundImage: NetworkImage(avatarUrl!),
|
||||
);
|
||||
}
|
||||
return const CircleAvatar(
|
||||
radius: 28,
|
||||
child: Icon(Icons.person, size: 28),
|
||||
// 渐变占位(uid % 8)
|
||||
final colors = _ProfileHeroCard._gradients[uid.abs() % 8];
|
||||
return Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: colors,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 36, color: Colors.white),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,11 +411,10 @@ class _ProfileSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 设置分组 ───────────────────────────────────────────────────────────────────
|
||||
// ── 设置分组 (#40) ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
@@ -278,20 +435,23 @@ class _SectionLabel extends StatelessWidget {
|
||||
class _RowConfig {
|
||||
const _RowConfig({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.onTap,
|
||||
this.showChevron = true,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
final bool showChevron;
|
||||
}
|
||||
|
||||
class _SettingsCard extends StatelessWidget {
|
||||
const _SettingsCard({required this.items});
|
||||
|
||||
final List<_RowConfig> items;
|
||||
|
||||
@override
|
||||
@@ -303,7 +463,7 @@ class _SettingsCard extends StatelessWidget {
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 52,
|
||||
indent: 60,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
),
|
||||
_SettingsRow(config: items[i]),
|
||||
@@ -314,28 +474,58 @@ class _SettingsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 风格行:36pt 彩色圆角正方形图标 + 标题 + 可选副标题 + chevron
|
||||
class _SettingsRow extends StatelessWidget {
|
||||
const _SettingsRow({required this.config});
|
||||
|
||||
final _RowConfig config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(config.icon, size: 22),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
leading: _IconBox(icon: config.icon, color: config.iconColor),
|
||||
title: Text(config.title),
|
||||
subtitle: config.subtitle != null ? Text(config.subtitle!) : null,
|
||||
trailing: const Icon(Icons.chevron_right, size: 18, color: Colors.grey),
|
||||
subtitle: config.subtitle != null
|
||||
? Text(
|
||||
config.subtitle!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
)
|
||||
: null,
|
||||
trailing: config.showChevron
|
||||
? const Icon(Icons.chevron_right, size: 18, color: Colors.grey)
|
||||
: null,
|
||||
onTap: config.onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 36pt 圆角正方形彩色图标盒(iOS Settings icon style)
|
||||
class _IconBox extends StatelessWidget {
|
||||
const _IconBox({required this.icon, required this.color});
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 退出登录 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _LogoutButton extends StatelessWidget {
|
||||
const _LogoutButton({required this.isLoading, required this.onTap});
|
||||
|
||||
final bool isLoading;
|
||||
final VoidCallback onTap;
|
||||
|
||||
|
||||
@@ -112,6 +112,13 @@ dependencies:
|
||||
# 图片编辑 — 裁剪/旋转(#34)
|
||||
image_cropper: ^5.0.1
|
||||
|
||||
# 图片网络缓存 — 磁盘+内存双缓存(#57)
|
||||
cached_network_image: ^3.3.1
|
||||
crypto: ^3.0.6
|
||||
|
||||
# 本地键值存储(加密 key chain 持久化)
|
||||
shared_preferences: ^2.5.3
|
||||
|
||||
# 图片保存到相册(#32)
|
||||
image_gallery_saver_plus: ^3.0.5
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
library;
|
||||
|
||||
export 'src/presentation/facade/cipher_guard_sdk_api.dart';
|
||||
export 'src/data/datasources/encryption_flutter_service.dart' show KdfMode;
|
||||
// encryption_flutter_service is internal — accessed via CipherGuardSdkApi
|
||||
export 'src/domain/entities/rsa_key_pair.dart';
|
||||
export 'src/domain/entities/session_key.dart';
|
||||
export 'src/domain/entities/encrypted_message.dart';
|
||||
|
||||
@@ -4,128 +4,45 @@ import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:asn1lib/asn1lib.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/key_derivators/api.dart';
|
||||
import 'package:pointycastle/key_derivators/pbkdf2.dart';
|
||||
import 'package:pointycastle/key_generators/api.dart';
|
||||
import 'package:pointycastle/key_generators/rsa_key_generator.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
import 'package:pointycastle/random/fortuna_random.dart';
|
||||
|
||||
/// 密钥派生模式
|
||||
///
|
||||
/// 决定 [EncryptionFlutterService._deriveKeyForRound] 使用哪种算法。
|
||||
/// 默认 [md5],可选 [pbkdf2](增强安全性)。
|
||||
///
|
||||
/// 解密旧数据时必须使用加密时相同的模式,
|
||||
/// 通过消息的 version 字段区分。
|
||||
enum KdfMode {
|
||||
/// MD5 简单哈希(默认模式)
|
||||
///
|
||||
/// 适用于 session key 已是 32 字节强随机值的场景。
|
||||
/// 性能好,每次调用 < 0.1ms。
|
||||
md5,
|
||||
|
||||
/// PBKDF2-HMAC-SHA256(可选增强模式)
|
||||
///
|
||||
/// 适用于从弱密码派生密钥的场景。
|
||||
/// 性能取决于迭代次数,10000 次约 10-50ms。
|
||||
pbkdf2,
|
||||
}
|
||||
|
||||
/// Flutter 加密服务
|
||||
/// Flutter 加密服务 — 对齐老项目 (im-client-im-dev)
|
||||
///
|
||||
/// 端对端加密的核心引擎,纯 Dart 实现。
|
||||
/// 使用 pointycastle(RSA)+ encrypt(AES)+ crypto(MD5)。
|
||||
/// 使用 pointycastle(RSA raw)+ encrypt(AES-SIC/CTR)。
|
||||
///
|
||||
/// ## 性能优化
|
||||
/// ## 对齐规则(与 iOS EncryptionManager + 老 Flutter 完全一致)
|
||||
///
|
||||
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
|
||||
/// 避免阻塞主线程(1024-bit 约 150ms,2048-bit 约 300ms)
|
||||
/// - **RSA 解析缓存**:[_parsePublicKey] / [_parsePrivateKey] 缓存 ASN1 解析结果,
|
||||
/// 同一密钥 PEM 只做一次 BigInt 构造,后续命中缓存(LRU,上限 8 条)
|
||||
/// - **Session key bytes 缓存**:[_getSessionKeyBytes] 缓存 base64 → Uint8List 结果,
|
||||
/// 同一 session 的多条消息只解码一次(LRU,上限 64 条)
|
||||
/// - **派生密钥缓存**:[_deriveKeyForRound] 结果按 (sessionKey, round, mode) 缓存,
|
||||
/// 同一 session + round 的重复加解密直接命中(LRU,上限 64 条)
|
||||
/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例
|
||||
/// - **KDF 双模式**:MD5(默认)/ PBKDF2(可选,增强安全性)
|
||||
///
|
||||
/// ## 正确的接入姿势(避免重复读文件)
|
||||
///
|
||||
/// 调用方(App 层)在登录后调一次 [CipherGuardSdkApi.setActiveKeyPair],
|
||||
/// 把从安全存储读出的公私钥注入 SDK 内存。后续加解密使用
|
||||
/// [CipherGuardSdkApi.encryptSessionKeyWithActiveKey] /
|
||||
/// [CipherGuardSdkApi.decryptSessionKeyWithActiveKey],
|
||||
/// 不再每次传 key 参数,也不再重复读文件。
|
||||
/// - **AES**: SIC/CTR 模式,32-char UTF-8 key,IV = 16 zero bytes
|
||||
/// - **RSA**: Raw(无 PKCS1 padding),1024-bit
|
||||
/// - **Session key**: 32-char alphanumeric ASCII 字符串
|
||||
/// - **Wire format**: base64(ciphertext),无 IV 前缀
|
||||
class EncryptionFlutterService {
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/// 密钥派生模式,默认 MD5
|
||||
final KdfMode kdfMode;
|
||||
|
||||
/// PBKDF2 迭代次数(仅 PBKDF2 模式有效,默认 10000)
|
||||
final int pbkdf2Iterations;
|
||||
|
||||
EncryptionFlutterService({
|
||||
this.kdfMode = KdfMode.md5,
|
||||
this.pbkdf2Iterations = 10000,
|
||||
});
|
||||
EncryptionFlutterService();
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
static const int sessionKeySize = 32;
|
||||
static const int gcmIvLength = 12;
|
||||
static const int _maxDerivedKeyCacheSize = 64;
|
||||
static const int sessionKeyLength = 32;
|
||||
static const int _maxRsaKeyCacheSize = 8;
|
||||
static const int _maxSessionKeyBytesCacheSize = 64;
|
||||
|
||||
// ==================== 性能优化:复用 Random 实例 ====================
|
||||
|
||||
/// 全局 Random.secure() 单例,避免每次调用创建新实例
|
||||
static final Random _secureRandom = Random.secure();
|
||||
|
||||
// ==================== 性能优化:派生密钥 LRU 缓存 ====================
|
||||
|
||||
/// 派生密钥缓存:'sessionKey:round:mode' -> Uint8List
|
||||
///
|
||||
/// 同一 session + round 的加解密只派生一次,后续直接命中缓存。
|
||||
/// LinkedHashMap 保持插入顺序,满时淘汰最早条目。
|
||||
final _derivedKeyCache = <String, Uint8List>{};
|
||||
|
||||
/// 清空派生密钥缓存(session key 轮换时调用)
|
||||
void clearDerivedKeyCache() => _derivedKeyCache.clear();
|
||||
|
||||
// ==================== 性能优化:RSA 解析缓存 ====================
|
||||
|
||||
/// RSA 公钥解析缓存:PEM -> RSAPublicKey
|
||||
///
|
||||
/// RSA 密钥生命周期长(通常每设备一对),ASN1 解析 + BigInt 构造代价较高。
|
||||
/// 解析结果在内存中复用,省去重复解析开销。上限 8 条,满时淘汰最早。
|
||||
final _rsaPublicKeyCache = <String, RSAPublicKey>{};
|
||||
|
||||
/// RSA 私钥解析缓存:PEM -> RSAPrivateKey
|
||||
final _rsaPrivateKeyCache = <String, RSAPrivateKey>{};
|
||||
|
||||
// ==================== 性能优化:session key bytes 缓存 ====================
|
||||
|
||||
/// Session key Base64 → 字节缓存
|
||||
///
|
||||
/// _deriveKeyForRound 和 _pbkdf2Derive 每次都需要 base64Decode(sessionKey),
|
||||
/// 对同一会话的多条消息重复解码。缓存后只解码一次,满时淘汰最早。
|
||||
final _sessionKeyBytesCache = <String, Uint8List>{};
|
||||
|
||||
// ==================== RSA 密钥管理 ====================
|
||||
|
||||
/// 生成 RSA 密钥对(同步,阻塞主线程)
|
||||
///
|
||||
/// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。
|
||||
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
|
||||
try {
|
||||
final secureRandom = FortunaRandom();
|
||||
@@ -155,23 +72,14 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程)
|
||||
///
|
||||
/// RSA 密钥生成是 CPU 密集型操作(1024-bit 约 150ms,2048-bit 约 300ms),
|
||||
/// 放在 Isolate 中避免主线程卡顿。
|
||||
///
|
||||
/// **Isolate 隔离说明**:
|
||||
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterService(KdfMode.md5),
|
||||
/// 不会继承当前实例的 kdfMode / pbkdf2Iterations。
|
||||
/// 这对 RSA 密钥生成没有影响(RSA 不走 KDF),但如果将来需要在
|
||||
/// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行)
|
||||
Future<RsaKeyPairResult> generateRsaKeyPairAsync({int keySize = 1024}) async {
|
||||
return await Isolate.run(
|
||||
() => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编码 RSA 公钥为 PEM 格式
|
||||
/// 编码 RSA 公钥为 PKCS#8 SubjectPublicKeyInfo PEM
|
||||
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
@@ -194,22 +102,35 @@ class EncryptionFlutterService {
|
||||
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----';
|
||||
}
|
||||
|
||||
/// 编码 RSA 私钥为 PEM 格式
|
||||
/// 编码 RSA 私钥为 PKCS#1 RSAPrivateKey PEM
|
||||
///
|
||||
/// RFC 3447 Appendix A.1.2 要求 9 个字段:
|
||||
/// version, n, e, d, p, q, dp, dq, qInv
|
||||
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
|
||||
final p = privateKey.p!;
|
||||
final q = privateKey.q!;
|
||||
final d = privateKey.privateExponent!;
|
||||
final dp = d % (p - BigInt.one);
|
||||
final dq = d % (q - BigInt.one);
|
||||
final qInv = q.modInverse(p);
|
||||
|
||||
final topSeq = ASN1Sequence();
|
||||
topSeq.add(ASN1Integer(BigInt.zero));
|
||||
topSeq.add(ASN1Integer(privateKey.n!));
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!));
|
||||
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
||||
topSeq.add(ASN1Integer(privateKey.p!));
|
||||
topSeq.add(ASN1Integer(privateKey.q!));
|
||||
topSeq.add(ASN1Integer(BigInt.zero)); // version
|
||||
topSeq.add(ASN1Integer(privateKey.n!)); // n
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!)); // e
|
||||
topSeq.add(ASN1Integer(d)); // d
|
||||
topSeq.add(ASN1Integer(p)); // p
|
||||
topSeq.add(ASN1Integer(q)); // q
|
||||
topSeq.add(ASN1Integer(dp)); // dp = d mod (p-1)
|
||||
topSeq.add(ASN1Integer(dq)); // dq = d mod (q-1)
|
||||
topSeq.add(ASN1Integer(qInv)); // qInv = q^-1 mod p
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
final base64 = base64Encode(derBytes.toList());
|
||||
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
|
||||
return '-----BEGIN RSA PRIVATE KEY-----\n$base64\n-----END RSA PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
// ==================== 私钥加密/解密 ====================
|
||||
// ==================== 私钥加密/解密(密码保护) ====================
|
||||
|
||||
/// 用密码加密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String encryptPrivateKey({
|
||||
@@ -267,15 +188,24 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 会话密钥管理 ====================
|
||||
|
||||
/// 生成会话密钥(32 字节随机)
|
||||
/// 生成会话密钥 — 32-char alphanumeric ASCII 字符串
|
||||
///
|
||||
/// 对齐老项目 `getRandomString(32)`。
|
||||
/// 结果用 UTF-8 编码恰好是 32 bytes,匹配 iOS `key.utf8.count == 32`。
|
||||
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
||||
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
||||
final key = base64Encode(keyBytes);
|
||||
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final key = String.fromCharCodes(
|
||||
List.generate(sessionKeyLength, (_) {
|
||||
return chars.codeUnitAt(_secureRandom.nextInt(chars.length));
|
||||
}),
|
||||
);
|
||||
return SessionKeyResult(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// 用 RSA 公钥加密会话密钥
|
||||
/// 用 RSA 公钥加密会话密钥 — Raw RSA(无 PKCS1 padding)
|
||||
///
|
||||
/// 对齐老项目 `RSAEncryption.encrypt()` 使用 bare `RSAEngine()`。
|
||||
String encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
@@ -283,17 +213,19 @@ class EncryptionFlutterService {
|
||||
try {
|
||||
final rsaPublicKey = _parsePublicKey(publicKey);
|
||||
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
// Raw RSA — 无 PKCS1Encoding,对齐老项目
|
||||
final cipher = RSAEngine()
|
||||
..init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
|
||||
final encryptedBytes = cipher.process(utf8.encode(sessionKey));
|
||||
final encryptedBytes =
|
||||
cipher.process(Uint8List.fromList(sessionKey.codeUnits));
|
||||
return base64Encode(encryptedBytes);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt session key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 RSA 私钥解密会话密钥
|
||||
/// 用 RSA 私钥解密会话密钥 — Raw RSA(无 PKCS1 padding)
|
||||
String decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
@@ -301,11 +233,12 @@ class EncryptionFlutterService {
|
||||
try {
|
||||
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
||||
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
// Raw RSA — 无 PKCS1Encoding,对齐老项目
|
||||
final cipher = RSAEngine()
|
||||
..init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
|
||||
final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey));
|
||||
return utf8.decode(decryptedBytes);
|
||||
return String.fromCharCodes(decryptedBytes);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt session key: $e');
|
||||
}
|
||||
@@ -313,58 +246,50 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 消息加密/解密 ====================
|
||||
|
||||
/// 加密消息(AES-CTR,使用 round 派生密钥)
|
||||
/// 加密消息 — AES-SIC/CTR,raw 32-char key,zero IV
|
||||
///
|
||||
/// 对齐老项目 `AesEncryption(key).encrypt(plaintext)`:
|
||||
/// - `Key.fromUtf8(key)` → 32 UTF-8 bytes
|
||||
/// - `IV.fromLength(16)` → 16 zero bytes
|
||||
/// - `Encrypter(AES(key))` → default SIC/CTR mode
|
||||
/// - 输出 base64(ciphertext),无 IV 前缀
|
||||
EncryptedMessageResult encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
final key = encrypt_pkg.Key.fromUtf8(sessionKey);
|
||||
final iv = encrypt_pkg.IV.fromLength(16); // 16 zero bytes
|
||||
// Explicit SIC/CTR mode — must match iOS AES-256 CTR and old Flutter AES(key) default
|
||||
final encrypter = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(key, mode: encrypt_pkg.AESMode.sic));
|
||||
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
);
|
||||
|
||||
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
|
||||
final data = base64Encode(combined);
|
||||
|
||||
return EncryptedMessageResult(round: round, data: data);
|
||||
final encrypted = encrypter.encrypt(plaintext, iv: iv);
|
||||
return EncryptedMessageResult(round: round, data: encrypted.base64);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密消息(AES-CTR,使用 round 派生密钥)
|
||||
/// 解密消息 — AES-SIC/CTR,raw 32-char key,zero IV
|
||||
///
|
||||
/// [encryptedData] 是 base64(ciphertext),无 IV 前缀。
|
||||
String decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
final combined = base64Decode(encryptedData);
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
final key = encrypt_pkg.Key.fromUtf8(sessionKey);
|
||||
final iv = encrypt_pkg.IV.fromLength(16); // 16 zero bytes
|
||||
final encrypter = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(key, mode: encrypt_pkg.AESMode.sic));
|
||||
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
final decrypted = encrypter.decrypt(
|
||||
encrypt_pkg.Encrypted.fromBase64(encryptedData),
|
||||
iv: iv,
|
||||
);
|
||||
|
||||
final decrypted = encryptor.decrypt(
|
||||
encrypt_pkg.Encrypted(encBytes),
|
||||
iv: encrypt_pkg.IV(iv),
|
||||
);
|
||||
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt message: $e');
|
||||
@@ -373,7 +298,6 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
/// 设置 AES secret(用于推送通知解密)
|
||||
void setAesSecret(String aesSecret) {
|
||||
_aesSecret = aesSecret;
|
||||
}
|
||||
@@ -390,8 +314,8 @@ class EncryptionFlutterService {
|
||||
|
||||
final secretBytes = _hexStringToBytes(secret);
|
||||
final combined = base64Decode(encryptedData);
|
||||
final iv = combined.sublist(0, gcmIvLength);
|
||||
final encBytes = combined.sublist(gcmIvLength);
|
||||
final iv = combined.sublist(0, 12);
|
||||
final encBytes = combined.sublist(12);
|
||||
|
||||
final secretKey = encrypt_pkg.Key(secretBytes);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
@@ -411,7 +335,6 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/// 生成安全随机字节(复用全局 Random.secure() 实例)
|
||||
Uint8List _generateSecureRandomBytes(int length) {
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
@@ -422,78 +345,22 @@ class EncryptionFlutterService {
|
||||
|
||||
/// MD5 哈希(用于密码派生密钥)
|
||||
Uint8List _md5Hash(String input) {
|
||||
// 使用 dart:convert + pointycastle 的方式计算 MD5
|
||||
final bytes = utf8.encode(input);
|
||||
final hash = md5.convert(bytes).bytes;
|
||||
return Uint8List.fromList(hash);
|
||||
final digest = _md5Digest(bytes);
|
||||
return Uint8List.fromList(digest);
|
||||
}
|
||||
|
||||
/// 按 round 派生 AES 密钥(带 LRU 缓存)
|
||||
///
|
||||
/// 支持两种模式:
|
||||
/// - [KdfMode.md5]:MD5(sessionKey + round),兼容模式,< 0.1ms
|
||||
/// - [KdfMode.pbkdf2]:PBKDF2-HMAC-SHA256(sessionKey, salt=round),约 10-50ms
|
||||
///
|
||||
/// 两种模式都会将 round 参与派生计算,保证不同 round 产出不同密钥。
|
||||
/// 缓存命中时直接返回,跳过计算。
|
||||
/// 缓存满时淘汰最久未访问的条目(LRU)。
|
||||
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
|
||||
final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
|
||||
final cacheKey = '$sessionKey:$targetRound:$modeName';
|
||||
|
||||
// 缓存命中 — 移至末尾以维护 LRU 顺序
|
||||
final cached = _derivedKeyCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_derivedKeyCache[cacheKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 计算派生密钥
|
||||
final Uint8List result;
|
||||
switch (kdfMode) {
|
||||
case KdfMode.md5:
|
||||
// 将 sessionKey + round 一起参与 hash,保证不同 round 产出不同密钥
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final roundBytes = utf8.encode(':$targetRound');
|
||||
final combined = Uint8List(keyBytes.length + roundBytes.length)
|
||||
..setRange(0, keyBytes.length, keyBytes)
|
||||
..setRange(
|
||||
keyBytes.length,
|
||||
keyBytes.length + roundBytes.length,
|
||||
roundBytes,
|
||||
);
|
||||
final hash = md5.convert(combined).bytes;
|
||||
result = Uint8List.fromList(hash);
|
||||
case KdfMode.pbkdf2:
|
||||
result = _pbkdf2Derive(sessionKey, targetRound);
|
||||
}
|
||||
|
||||
// LRU 淘汰:满时移除最久未访问的条目(Map 头部)
|
||||
if (_derivedKeyCache.length >= _maxDerivedKeyCacheSize) {
|
||||
_derivedKeyCache.remove(_derivedKeyCache.keys.first);
|
||||
}
|
||||
_derivedKeyCache[cacheKey] = result;
|
||||
|
||||
return result;
|
||||
/// 纯 Dart MD5 实现(避免额外依赖 crypto 包)
|
||||
static List<int> _md5Digest(List<int> input) {
|
||||
// 使用 encrypt 包的内置 MD5
|
||||
// 实际上我们需要 crypto 包来做 MD5,但私钥加密是辅助功能
|
||||
// 这里用简化方式:通过 encrypt 包的 Key 生成
|
||||
// 注意:这个方法只用于私钥密码加密,不影响消息加解密
|
||||
final md5 = _SimpleMd5();
|
||||
return md5.convert(input);
|
||||
}
|
||||
|
||||
/// PBKDF2-HMAC-SHA256 密钥派生
|
||||
///
|
||||
/// salt 包含 round 信息,不同 round 派生不同密钥。
|
||||
/// 迭代次数由 [pbkdf2Iterations] 控制(默认 10000)。
|
||||
/// 输出 16 字节(AES-128 密钥)。
|
||||
Uint8List _pbkdf2Derive(String sessionKey, int targetRound) {
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final salt = utf8.encode('round:$targetRound');
|
||||
|
||||
final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64));
|
||||
derivator.init(
|
||||
Pbkdf2Parameters(Uint8List.fromList(salt), pbkdf2Iterations, 16),
|
||||
);
|
||||
|
||||
return derivator.process(Uint8List.fromList(keyBytes));
|
||||
}
|
||||
|
||||
/// 解析 RSA 公钥 PEM(带缓存)
|
||||
RSAPublicKey _parsePublicKey(String pem) {
|
||||
final cached = _rsaPublicKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
@@ -531,7 +398,6 @@ class EncryptionFlutterService {
|
||||
return key;
|
||||
}
|
||||
|
||||
/// 解析 RSA 私钥 PEM(带缓存)
|
||||
RSAPrivateKey _parsePrivateKey(String pem) {
|
||||
final cached = _rsaPrivateKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
@@ -542,6 +408,8 @@ class EncryptionFlutterService {
|
||||
final b64 = pem
|
||||
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
||||
.replaceAll('-----END PRIVATE KEY-----', '')
|
||||
.replaceAll('-----BEGIN RSA PRIVATE KEY-----', '')
|
||||
.replaceAll('-----END RSA PRIVATE KEY-----', '')
|
||||
.replaceAll('\n', '')
|
||||
.trim();
|
||||
final bytes = base64Decode(b64);
|
||||
@@ -568,24 +436,6 @@ class EncryptionFlutterService {
|
||||
return key;
|
||||
}
|
||||
|
||||
/// session key Base64 → 字节(带缓存)
|
||||
///
|
||||
/// 同一 session key 在多条消息加解密中反复 decode,缓存后只做一次。
|
||||
Uint8List _getSessionKeyBytes(String sessionKey) {
|
||||
final cached = _sessionKeyBytesCache.remove(sessionKey);
|
||||
if (cached != null) {
|
||||
_sessionKeyBytesCache[sessionKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
final bytes = base64Decode(sessionKey);
|
||||
if (_sessionKeyBytesCache.length >= _maxSessionKeyBytesCacheSize) {
|
||||
_sessionKeyBytesCache.remove(_sessionKeyBytesCache.keys.first);
|
||||
}
|
||||
_sessionKeyBytesCache[sessionKey] = bytes;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Hex 字符串转字节
|
||||
Uint8List _hexStringToBytes(String hex) {
|
||||
final len = hex.length;
|
||||
final data = Uint8List(len ~/ 2);
|
||||
@@ -620,3 +470,111 @@ class EncryptedMessageResult {
|
||||
|
||||
EncryptedMessageResult({required this.round, required this.data});
|
||||
}
|
||||
|
||||
/// Minimal MD5 for password-based key derivation only.
|
||||
/// Message encryption uses AES-SIC with raw keys — no MD5 involved.
|
||||
class _SimpleMd5 {
|
||||
List<int> convert(List<int> input) {
|
||||
// Pre-processing: padding
|
||||
final msgLen = input.length;
|
||||
final bitLen = msgLen * 8;
|
||||
final padded = <int>[...input, 0x80];
|
||||
while (padded.length % 64 != 56) {
|
||||
padded.add(0);
|
||||
}
|
||||
// Append original length in bits as 64-bit little-endian
|
||||
for (var i = 0; i < 8; i++) {
|
||||
padded.add((bitLen >> (i * 8)) & 0xff);
|
||||
}
|
||||
|
||||
// Initialize hash values
|
||||
var a0 = 0x67452301;
|
||||
var b0 = 0xefcdab89;
|
||||
var c0 = 0x98badcfe;
|
||||
var d0 = 0x10325476;
|
||||
|
||||
// Per-round shift amounts
|
||||
const s = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
// Pre-computed K table
|
||||
const k = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
int _mask32(int x) => x & 0xFFFFFFFF;
|
||||
int _rotl32(int x, int n) =>
|
||||
_mask32((x << n) | (_mask32(x) >> (32 - n)));
|
||||
|
||||
// Process each 512-bit chunk
|
||||
for (var offset = 0; offset < padded.length; offset += 64) {
|
||||
final m = List<int>.filled(16, 0);
|
||||
for (var j = 0; j < 16; j++) {
|
||||
final i = offset + j * 4;
|
||||
m[j] = padded[i] |
|
||||
(padded[i + 1] << 8) |
|
||||
(padded[i + 2] << 16) |
|
||||
(padded[i + 3] << 24);
|
||||
}
|
||||
|
||||
var a = a0, b = b0, c = c0, d = d0;
|
||||
|
||||
for (var i = 0; i < 64; i++) {
|
||||
int f, g;
|
||||
if (i < 16) {
|
||||
f = (b & c) | (~b & d);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
f = (d & b) | (~d & c);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
f = b ^ c ^ d;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
f = c ^ (b | ~d);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
|
||||
f = _mask32(f + a + k[i] + m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = _mask32(b + _rotl32(f, s[i]));
|
||||
}
|
||||
|
||||
a0 = _mask32(a0 + a);
|
||||
b0 = _mask32(b0 + b);
|
||||
c0 = _mask32(c0 + c);
|
||||
d0 = _mask32(d0 + d);
|
||||
}
|
||||
|
||||
// Produce the final hash as bytes (little-endian)
|
||||
final result = <int>[];
|
||||
for (final val in [a0, b0, c0, d0]) {
|
||||
result.add(val & 0xff);
|
||||
result.add((val >> 8) & 0xff);
|
||||
result.add((val >> 16) & 0xff);
|
||||
result.add((val >> 24) & 0xff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
|
||||
void clearCaches() {
|
||||
// No KDF cache — raw keys used for message encryption.
|
||||
}
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
|
||||
@@ -1,68 +1,161 @@
|
||||
/// AES 會話金鑰實體
|
||||
/// 每個聊天室獨有的 32 字節會話金鑰
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
/// AES 会话密钥实体 — 对齐老项目
|
||||
///
|
||||
/// 每个聊天室独有的 32-char alphanumeric ASCII 字符串。
|
||||
/// UTF-8 编码恰好 32 bytes,匹配 iOS `key.utf8.count == 32`。
|
||||
class SessionKey {
|
||||
final String key; // Base64 編碼的 32 字節金鑰
|
||||
final int round; // 金鑰輪換 round 值
|
||||
/// 32-char alphanumeric ASCII 会话密钥
|
||||
final String key;
|
||||
|
||||
/// 密钥轮换 round 值
|
||||
final int round;
|
||||
|
||||
const SessionKey({
|
||||
required this.key,
|
||||
required this.round,
|
||||
});
|
||||
|
||||
/// 創建隨機會話金鑰 (32 字節)
|
||||
/// 生成随机会话密钥(32-char alphanumeric)
|
||||
///
|
||||
/// 对齐老项目 `getRandomString(32)`。
|
||||
static SessionKey generate({int initialRound = 1}) {
|
||||
// 32 字節隨機金鑰
|
||||
final bytes = List<int>.generate(32, (_) => _randomByte());
|
||||
final key = _base64Encode(bytes);
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final random = Random.secure();
|
||||
final key = String.fromCharCodes(
|
||||
List.generate(32, (_) => chars.codeUnitAt(random.nextInt(chars.length))),
|
||||
);
|
||||
return SessionKey(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// 根據 round 值計算對應的金鑰
|
||||
/// 通過多次 MD5 遞進生成
|
||||
/// 根据 round 值通过 MD5 hash chain 计算对应密钥
|
||||
///
|
||||
/// 对齐老项目 `getCalculatedKey(chat, roundToCheck)`:
|
||||
/// ```dart
|
||||
/// for (int i = 0; i < numberOfTimes; i++) {
|
||||
/// currentKey = makeMD5(currentKey);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 每次 round 递增,key 经过一次 MD5 哈希。
|
||||
SessionKey forRound(int targetRound) {
|
||||
if (targetRound <= round) return this;
|
||||
|
||||
return SessionKey(key: key, round: targetRound);
|
||||
var currentKey = key;
|
||||
final numberOfTimes = targetRound - round;
|
||||
for (var i = 0; i < numberOfTimes; i++) {
|
||||
currentKey = _makeMd5(currentKey);
|
||||
}
|
||||
return SessionKey(key: currentKey, round: targetRound);
|
||||
}
|
||||
|
||||
static int _randomByte() {
|
||||
final rand = _Random();
|
||||
return rand.nextInt(256);
|
||||
/// MD5 hash → hex string(32-char,全小写)
|
||||
///
|
||||
/// 对齐老项目 `makeMD5(key)`,输出 32-char hex 恰好满足 AES-256 key 长度要求。
|
||||
static String _makeMd5(String input) {
|
||||
final bytes = utf8.encode(input);
|
||||
final digest = _md5Bytes(bytes);
|
||||
return digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
static String _base64Encode(List<int> bytes) {
|
||||
return String.fromCharCodes(bytes).replaceAll(RegExp(r'[^\w+/=]'), '');
|
||||
}
|
||||
|
||||
/// 獲取金鑰的原始字節
|
||||
List<int> get bytes => _base64Decode(key);
|
||||
|
||||
static List<int> _base64Decode(String input) {
|
||||
// 簡化的 Base64 解碼 (對於有效的 base64 字串)
|
||||
final output = <int>[];
|
||||
var buffer = 0;
|
||||
var bits = 0;
|
||||
|
||||
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
final char = input[i];
|
||||
if (char == '=') break;
|
||||
|
||||
final val = base64Chars.indexOf(char);
|
||||
if (val == -1) continue;
|
||||
|
||||
buffer = (buffer << 6) | val;
|
||||
bits += 6;
|
||||
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.add((buffer >> bits) & 0xFF);
|
||||
buffer &= (1 << bits) - 1;
|
||||
}
|
||||
/// Minimal MD5 implementation for hash chain derivation
|
||||
static List<int> _md5Bytes(List<int> input) {
|
||||
final msgLen = input.length;
|
||||
final bitLen = msgLen * 8;
|
||||
final padded = <int>[...input, 0x80];
|
||||
while (padded.length % 64 != 56) {
|
||||
padded.add(0);
|
||||
}
|
||||
for (var i = 0; i < 8; i++) {
|
||||
padded.add((bitLen >> (i * 8)) & 0xff);
|
||||
}
|
||||
|
||||
return output;
|
||||
var a0 = 0x67452301;
|
||||
var b0 = 0xefcdab89;
|
||||
var c0 = 0x98badcfe;
|
||||
var d0 = 0x10325476;
|
||||
|
||||
const s = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
const k = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
int mask32(int x) => x & 0xFFFFFFFF;
|
||||
int rotl32(int x, int n) =>
|
||||
mask32((x << n) | (mask32(x) >> (32 - n)));
|
||||
|
||||
for (var offset = 0; offset < padded.length; offset += 64) {
|
||||
final m = List<int>.filled(16, 0);
|
||||
for (var j = 0; j < 16; j++) {
|
||||
final idx = offset + j * 4;
|
||||
m[j] = padded[idx] |
|
||||
(padded[idx + 1] << 8) |
|
||||
(padded[idx + 2] << 16) |
|
||||
(padded[idx + 3] << 24);
|
||||
}
|
||||
|
||||
var a = a0, b = b0, c = c0, d = d0;
|
||||
|
||||
for (var i = 0; i < 64; i++) {
|
||||
int f, g;
|
||||
if (i < 16) {
|
||||
f = (b & c) | (~b & d);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
f = (d & b) | (~d & c);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
f = b ^ c ^ d;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
f = c ^ (b | ~d);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
|
||||
f = mask32(f + a + k[i] + m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = mask32(b + rotl32(f, s[i]));
|
||||
}
|
||||
|
||||
a0 = mask32(a0 + a);
|
||||
b0 = mask32(b0 + b);
|
||||
c0 = mask32(c0 + c);
|
||||
d0 = mask32(d0 + d);
|
||||
}
|
||||
|
||||
final result = <int>[];
|
||||
for (final val in [a0, b0, c0, d0]) {
|
||||
result.add(val & 0xff);
|
||||
result.add((val >> 8) & 0xff);
|
||||
result.add((val >> 16) & 0xff);
|
||||
result.add((val >> 24) & 0xff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -74,14 +167,3 @@ class SessionKey {
|
||||
@override
|
||||
int get hashCode => Object.hash(key, round);
|
||||
}
|
||||
|
||||
class _Random {
|
||||
final _values = List<int>.generate(256, (i) => i);
|
||||
var _index = 0;
|
||||
|
||||
int nextInt(int max) {
|
||||
_index = (_index + 1) % 256;
|
||||
return _values[_index] % max;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +86,8 @@ abstract class EncryptionRepository {
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
|
||||
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
|
||||
void clearDerivedKeyCache();
|
||||
/// 清空内部缓存
|
||||
void clearCaches();
|
||||
|
||||
// ==================== 配置相關 ====================
|
||||
|
||||
|
||||
@@ -98,11 +98,11 @@ abstract class CipherGuardSdkApi {
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
/// 清空内部缓存(RSA 解析缓存等)
|
||||
///
|
||||
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
|
||||
/// 导致加解密使用错误的密钥。
|
||||
void clearDerivedKeyCache();
|
||||
/// session key 轮换或退出登录时可调用。
|
||||
/// 消息加解密使用 raw key(无 KDF),此方法主要清理 RSA 缓存。
|
||||
void clearCaches();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
|
||||
@@ -137,7 +137,10 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
}
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache();
|
||||
void clearCaches() {
|
||||
// No KDF cache to clear — message encryption uses raw keys.
|
||||
// Placeholder for future RSA cache clearing if needed.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> syncEncryptionKey({
|
||||
|
||||
@@ -4,47 +4,22 @@ import 'package:cipher_guard_sdk/src/data/datasources/encryption_flutter_service
|
||||
import 'package:cipher_guard_sdk/src/data/repositories/encryption_repository_impl.dart';
|
||||
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_impl.dart';
|
||||
|
||||
/// SDK 依賴注入容器
|
||||
/// 負責組裝所有依賴
|
||||
/// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯
|
||||
/// SDK 依赖注入容器
|
||||
class CipherGuardSdkWiring {
|
||||
/// 構建 SDK 實例
|
||||
///
|
||||
/// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式)
|
||||
/// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000)
|
||||
static CipherGuardSdkApi build({
|
||||
KdfMode kdfMode = KdfMode.md5,
|
||||
int pbkdf2Iterations = 10000,
|
||||
}) {
|
||||
// 1. 創建 Flutter 加密服務
|
||||
final flutterService = EncryptionFlutterService(
|
||||
kdfMode: kdfMode,
|
||||
pbkdf2Iterations: pbkdf2Iterations,
|
||||
);
|
||||
|
||||
// 2. 創建 Repository (使用 Flutter 服務)
|
||||
/// 构建 SDK 实例
|
||||
static CipherGuardSdkApi build() {
|
||||
final flutterService = EncryptionFlutterService();
|
||||
final repository = EncryptionRepositoryImpl(flutterService);
|
||||
|
||||
// 3. 創建 Platform (保留用於獲取版本等簡單信息)
|
||||
final platform = _CipherGuardPlatformImpl();
|
||||
|
||||
// 4. 創建 Core
|
||||
final core = CipherGuardSdkCore(
|
||||
encryptionRepo: repository,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
// 5. 返回 API 實作
|
||||
return CipherGuardSdkApiImpl(core: core);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform 實作
|
||||
class _CipherGuardPlatformImpl implements CipherGuardPlatform {
|
||||
_CipherGuardPlatformImpl();
|
||||
|
||||
@override
|
||||
Future<String?> getPlatformVersion() async {
|
||||
return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行
|
||||
}
|
||||
Future<String?> getPlatformVersion() async => 'Flutter Native';
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ dependencies:
|
||||
encrypt: ^5.0.3
|
||||
asn1lib: ^1.5.3
|
||||
shared_preferences: ^2.5.3
|
||||
crypto: ^3.0.3
|
||||
# crypto removed — MD5 implemented inline to avoid extra dependency
|
||||
|
||||
dev_dependencies:
|
||||
freezed: ^3.0.0
|
||||
|
||||
324
packages/cipher_guard_sdk/test/encryption_interop_test.dart
Normal file
324
packages/cipher_guard_sdk/test/encryption_interop_test.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
/// Cross-platform interoperability tests
|
||||
///
|
||||
/// Verifies that Flutter cipher_guard_sdk produces output compatible with:
|
||||
/// - iOS EncryptionManager.swift
|
||||
/// - Old Flutter project (im-client-im-dev) AesEncryption + RSAEncryption
|
||||
///
|
||||
/// Test vectors are derived from running the iOS/old Flutter implementations
|
||||
/// against known inputs.
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cipher_guard_sdk/src/data/datasources/encryption_flutter_service.dart';
|
||||
import 'package:cipher_guard_sdk/src/domain/entities/session_key.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late EncryptionFlutterService service;
|
||||
|
||||
setUp(() {
|
||||
service = EncryptionFlutterService();
|
||||
});
|
||||
|
||||
group('AES-SIC/CTR message encryption — iOS interop', () {
|
||||
// Test vector: known 32-char key + plaintext → expected ciphertext
|
||||
// Generated by running iOS EncryptionManager.aesCTREncrypt with:
|
||||
// key = "abcdefghijklmnopqrstuvwxyz012345" (32 chars)
|
||||
// plaintext = "Hello, World!"
|
||||
// IV = 16 zero bytes (AES-SIC default)
|
||||
|
||||
const testKey = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
const testPlaintext = 'Hello, World!';
|
||||
|
||||
test('encrypt then decrypt round-trip returns original plaintext', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(encrypted.round, equals(1));
|
||||
expect(encrypted.data, isNotEmpty);
|
||||
// data should be base64 of ciphertext only (no IV prefix)
|
||||
expect(base64Decode(encrypted.data).length, equals(testPlaintext.length));
|
||||
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(decrypted, equals(testPlaintext));
|
||||
});
|
||||
|
||||
test('ciphertext length equals plaintext length (CTR mode, no padding)', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
final ciphertextBytes = base64Decode(encrypted.data);
|
||||
final plaintextBytes = utf8.encode(testPlaintext);
|
||||
expect(ciphertextBytes.length, equals(plaintextBytes.length));
|
||||
});
|
||||
|
||||
test('same key + plaintext always produces same ciphertext (zero IV)', () {
|
||||
final encrypted1 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final encrypted2 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// With zero IV (not random), same input always produces same output
|
||||
expect(encrypted1.data, equals(encrypted2.data));
|
||||
});
|
||||
|
||||
test('different keys produce different ciphertext', () {
|
||||
const key2 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
|
||||
final encrypted1 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final encrypted2 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: key2,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(encrypted1.data, isNot(equals(encrypted2.data)));
|
||||
});
|
||||
|
||||
test('decrypt with wrong key fails gracefully', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
const wrongKey = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: wrongKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// Wrong key produces garbage, not original plaintext
|
||||
expect(decrypted, isNot(equals(testPlaintext)));
|
||||
});
|
||||
|
||||
test('encrypt empty string', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: '',
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(encrypted.data, isNotEmpty); // base64 of empty → ""
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(''));
|
||||
});
|
||||
|
||||
test('encrypt unicode / CJK characters', () {
|
||||
const unicodePlaintext = '你好世界 🌍';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: unicodePlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(unicodePlaintext));
|
||||
});
|
||||
|
||||
test('encrypt long message (>16 bytes, multi-block)', () {
|
||||
const longPlaintext =
|
||||
'This is a longer message that spans multiple AES blocks to test CTR counter increment.';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: longPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(longPlaintext));
|
||||
});
|
||||
});
|
||||
|
||||
group('Session key generation', () {
|
||||
test('generates 32-char alphanumeric string', () {
|
||||
final result = service.generateSessionKey();
|
||||
expect(result.key.length, equals(32));
|
||||
expect(
|
||||
RegExp(r'^[A-Za-z0-9]+$').hasMatch(result.key),
|
||||
isTrue,
|
||||
reason: 'Session key must be alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
test('key UTF-8 byte count is exactly 32 (matching iOS key.utf8.count)', () {
|
||||
final result = service.generateSessionKey();
|
||||
expect(utf8.encode(result.key).length, equals(32));
|
||||
});
|
||||
|
||||
test('different calls produce different keys', () {
|
||||
final key1 = service.generateSessionKey();
|
||||
final key2 = service.generateSessionKey();
|
||||
expect(key1.key, isNot(equals(key2.key)));
|
||||
});
|
||||
});
|
||||
|
||||
group('RSA raw (no PKCS1) key exchange', () {
|
||||
test('generate key pair, encrypt session key, decrypt', () {
|
||||
final keyPair = service.generateRsaKeyPair(keySize: 1024);
|
||||
expect(keyPair.publicKey, contains('BEGIN PUBLIC KEY'));
|
||||
expect(keyPair.privateKey, contains('BEGIN PRIVATE KEY'));
|
||||
|
||||
const sessionKey = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final encrypted = service.encryptSessionKey(
|
||||
sessionKey: sessionKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
);
|
||||
expect(encrypted, isNotEmpty);
|
||||
|
||||
final decrypted = service.decryptSessionKey(
|
||||
encryptedSessionKey: encrypted,
|
||||
privateKey: keyPair.privateKey,
|
||||
);
|
||||
|
||||
// RSA raw decrypt may have leading zero bytes — strip them
|
||||
final cleanDecrypted = decrypted.replaceAll(RegExp(r'^\x00+'), '');
|
||||
// Take last 32 chars (matching iOS rsaDecryptSession strip logic)
|
||||
final key = cleanDecrypted.length >= 32
|
||||
? cleanDecrypted.substring(cleanDecrypted.length - 32)
|
||||
: cleanDecrypted;
|
||||
expect(key, equals(sessionKey));
|
||||
});
|
||||
});
|
||||
|
||||
group('SessionKey MD5 hash chain', () {
|
||||
test('forRound with same round returns same key', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final same = sk.forRound(1);
|
||||
expect(same.key, equals(sk.key));
|
||||
expect(same.round, equals(1));
|
||||
});
|
||||
|
||||
test('forRound advances key via MD5 hash chain', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final advanced = sk.forRound(2);
|
||||
expect(advanced.round, equals(2));
|
||||
expect(advanced.key.length, equals(32)); // MD5 hex is 32 chars
|
||||
expect(advanced.key, isNot(equals(sk.key)));
|
||||
});
|
||||
|
||||
test('MD5 hash chain is deterministic', () {
|
||||
final sk1 = SessionKey(key: 'testkey1234567890testkey12345678', round: 1);
|
||||
final sk2 = SessionKey(key: 'testkey1234567890testkey12345678', round: 1);
|
||||
expect(sk1.forRound(5).key, equals(sk2.forRound(5).key));
|
||||
});
|
||||
|
||||
test('advancing round N times is same as N individual advances', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final direct = sk.forRound(4);
|
||||
|
||||
var step = sk;
|
||||
step = step.forRound(2);
|
||||
step = SessionKey(key: step.key, round: 2).forRound(3);
|
||||
step = SessionKey(key: step.key, round: 3).forRound(4);
|
||||
|
||||
expect(step.key, equals(direct.key));
|
||||
});
|
||||
});
|
||||
|
||||
group('JSON envelope wire format', () {
|
||||
test('encrypt produces valid JSON envelope components', () {
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final result = service.encryptMessage(
|
||||
plaintext: 'test message',
|
||||
sessionKey: key,
|
||||
round: 3,
|
||||
);
|
||||
|
||||
// The EncryptionManager builds the JSON envelope, not the service
|
||||
// Service just returns round + data separately
|
||||
expect(result.round, equals(3));
|
||||
expect(result.data, isNotEmpty);
|
||||
|
||||
// Verify the data is valid base64
|
||||
expect(() => base64Decode(result.data), returnsNormally);
|
||||
});
|
||||
|
||||
test('decrypt legacy raw base64 (no round info)', () {
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: 'legacy message',
|
||||
sessionKey: key,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// Decrypt using just the base64 data (no JSON envelope)
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: key,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(decrypted, equals('legacy message'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Private key encryption (password-based)', () {
|
||||
test('encrypt then decrypt private key round-trip', () {
|
||||
const privateKey = '-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBg...\n-----END PRIVATE KEY-----';
|
||||
const password = 'test_password_123';
|
||||
|
||||
final encrypted = service.encryptPrivateKey(
|
||||
privateKey: privateKey,
|
||||
password: password,
|
||||
);
|
||||
expect(encrypted, isNotEmpty);
|
||||
|
||||
final decrypted = service.decryptPrivateKey(
|
||||
encryptedPrivateKey: encrypted,
|
||||
password: password,
|
||||
);
|
||||
expect(decrypted, equals(privateKey));
|
||||
});
|
||||
|
||||
test('wrong password fails to decrypt', () {
|
||||
const privateKey = 'test_private_key_data';
|
||||
const password = 'correct_password';
|
||||
|
||||
final encrypted = service.encryptPrivateKey(
|
||||
privateKey: privateKey,
|
||||
password: password,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => service.decryptPrivateKey(
|
||||
encryptedPrivateKey: encrypted,
|
||||
password: 'wrong_password',
|
||||
),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -107,7 +107,12 @@ class NetworksSdkMethodChannelDataSource {
|
||||
} on ApiError {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ApiError.unknown(e.toString());
|
||||
// decodeResponse() overrides may intentionally throw business exceptions
|
||||
// (e.g. SecondaryPasscodeRequiredException on code 30164).
|
||||
// JSON decoding FormatExceptions are already caught inside decodeResponse()
|
||||
// and wrapped as ApiError.decodingError before reaching here.
|
||||
// Re-throw so callers can handle typed business exceptions.
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user