Compare commits

13 Commits

Author SHA1 Message Date
lobster-wenshu-bot
f50667969b docs(agent-hook): install dev role hooks (CLAUDE.md, AGENTS.md) — 文殊 batch 2026-04-27 2026-04-27 13:41:23 +09:00
lobster-wenshu-bot
2551f1d5b3 docs(claude-hook): install dev role hook (CLAUDE.md) — 文殊 batch 2026-04-27 2026-04-27 13:34:52 +09:00
pp-bot
b8f1f82ee5 feat(login): 二级密码登录支持(STATUS_SECONDARY_PASSCODE_ERROR #1)
Some checks failed
CI / Lint (push) Has been cancelled
## 问题
旧版 Flutter 项目在 /vcode/check 返回 30164 时展示二级密码输入界面;
新版完全缺失此路径,有二级密码的账号无法登录。

## 改动

### networks_sdk
- `networks_sdk_method_channel_datasource.dart`:executeRequest 的
  generic catch 改为 rethrow,允许 decodeResponse override 抛出
  自定义业务异常(原为 ApiError.unknown 包裹导致数据丢失)

### 数据层
- `errors.dart`:新增 `secondaryPasscodeRequired = 30164`
- `exceptions.dart`(新增):`SecondaryPasscodeRequiredException`
  携带 vcodeToken / recoveryEmail / hint / resetStatus
- `verify_otp_request.dart`:override decodeResponse,拦截 30164,
  从响应 data 提取字段,throw SecondaryPasscodeRequiredException
- `login_request.dart`:新增可选 password 字段 + toJson override
  (条件序列化,null 时不带 password 字段)
- `auth_repository.dart`:新增 loginWithPasscode() 接口
- `auth_repository_impl.dart`:实现 loginWithPasscode()

### 业务层
- `login_usecase.dart`:新增 loginWithSecondaryPasscode()
  (MD5 哈希 passcode → 调 AuthRepository.loginWithPasscode)
- `pubspec.yaml`:新增 crypto: ^3.0.6(用于 MD5)

### UI 层
- `login_state.dart`:新增 LoginStep.secondaryPasscode
  + vcodeToken / passcodeHint / recoveryEmail 字段
- `login_view_model.dart`:verifyAndLogin 捕获 SecondaryPasscodeRequiredException
  跳转步骤 3;新增 verifyPasscode()
- `login_secondary_passcode_step.dart`(新增):密码输入 UI(hint 显示、
  obscured 输入框、错误提示、忘记密码占位)
- `login_page.dart`:switch 路由接入 LoginStep.secondaryPasscode
2026-03-31 15:36:54 +09:00
pp-bot
0995a4bf79 feat(image): 图片查看全量升级 — 缓存/Hero/下拉关闭/长按菜单(#57~#59)
#57 cached_network_image 接入
- pubspec 新增 cached_network_image: ^3.3.1
- CachedNetworkImageProvider 替换 PhotoView 中的 NetworkImage
- 磁盘+内存双缓存,同 URL 第二次加载无网络请求

#58 ImageViewerPage 完整重写
- Shimmer 加载占位(灰色渐变动画 + 进度百分比)
- 加载失败重试按钮(_ErrorWidget)
- 下拉关闭:>80pt 松手 pop,背景随拖动渐变透明
- 长按底部菜单:保存 / 分享 / 复制链接
- AppBar 右上角"⋮"快捷菜单
- 多图页面指示点(≤10张,活跃项宽度扩展为18pt)
- Hero 动画(单图,heroTag: 'img_$url')
- 点击切换 AppBar/工具栏显示/隐藏(沉浸式)
- 全屏沉浸模式(SystemUiMode.immersiveSticky)

#59 气泡接入 CachedNetworkImage
- ImageMessageBubble: Image.network → CachedNetworkImage + Hero tag
- ImageGridBubble._GridCell: Image.network → CachedNetworkImage
- 灰色 placeholder + 200ms fadeIn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:03:08 +09:00
pp-bot
21b7201590 docs(chat): 表情/贴纸/附件面板架构文档(#51~#56)
StickerMessageBubble/AttachmentPanelSheet/EmojiPanel/SendVideoUseCase
完整架构说明:组件设计、CDN路径规则、DI装配、待完成事项。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:18:36 +09:00
pp-bot
bb9f1aa956 feat(chat): 表情/贴纸/附件面板全量实现(#51~#56)
## 贴纸(#51)
- `StickerMessageBubble`:typ=5,CDN 图片,120×120pt max,无气泡背景,圆角 8pt
- `_buildBubble()` switch 新增 `case 5:`,isMedia 添加 typ=5

## 附件面板(#52~#55)
- `AttachmentPanelSheet`:6 格 BottomSheet(拍照/相册/视频/文件/录音/红包)
- 返回 `AttachmentOption` enum,由 `_ChatDetailPageState` 分发后续行为
- 拍照(#53):`image_picker.pickImage(camera)` → `SendImageUseCase`
- 视频(#54):`image_picker.pickVideo()` → `SendVideoUseCase`(新建)
- 文件/录音/红包:SnackBar 占位「暂未支持」
- `SendVideoUseCase`:上传 + `jsonEncode({url,thumb,size})` → typ=4

## 表情面板(#56)
- `EmojiPanel`:4 分类(常用/人物/自然/物件),每类 ~64 个 Unicode emoji
- 点击插入到 `TextEditingController` 当前光标位置(支持多码点 emoji)
- ⌫ 退格按钮按 rune 删除(正确处理多码点 emoji)
- `_InputBar` 新增表情按钮(😊)和附件按钮,原 `onAttach` 拆分为 `onAttachPanel` + `onEmoji`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:06:24 +09:00
pp-bot
8d5059add1 docs(mine-tab): 更新架构文档 — 编辑资料完整实现(#49/#50)
- §3 补充头像上传(3.3)和保存(3.4)数据流
- §8 新增编辑资料 UI 规格表
- §9 更新待完成事项(#6 已关闭)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:53:25 +09:00
pp-bot
f77dd8e9ef feat(settings): 编辑个人资料头像上传全量实现(#49 / #50)
- EditProfileState: 新增 isUploadingAvatar 字段
- EditProfileViewModel: pickAndUploadAvatar()(ImagePicker→裁剪→CDN→state)
- EditProfilePage: 完整重写
  - 88pt 圆形头像 + 8色渐变占位 + 相机角标 + 上传进度环
  - _showAvatarSourceSheet()(相册 / 拍照)
  - Card 分组表单:昵称(50字计数)/ 个人简介(200字多行)
  - 保存按钮(昵称空或上传中禁用)
  - 错误 Banner
  - 保存成功 → 刷新 SettingsViewModel + pop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:49:58 +09:00
pp-bot
7ae26b3368 docs(recent-calls): 架构文档补充(#42~#44)
新增 Doc/recent_calls_architecture.md:
- 功能范围、目录结构、数据流图
- API 端点 /app/api/call/records 字段映射表
- 通话状态枚举(0-6)与未接来电判断逻辑
- ViewModel 设计、UI 结构、路由说明
- 设计决策(无用户名映射、全量拉取、离线可用)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:32:27 +09:00
pp-bot
8744e2c0b7 feat(settings): 收藏列表 + 最近呼叫全量实现(#42~#45)
## 收藏(Gitea #42~#45)
- `FetchFavoritesRequest` / `DeleteFavoriteRequest`:ApiRequestable,对齐 iOS FavouriteService
- `FetchFavoritesUseCase`:GET 分页拉取 → upsert FavoriteRepository
- `DeleteFavoriteUseCase`:POST delete → 同步删本地 DB
- `FavoritesViewModel`:分页/刷新/加载更多/删除,DB Stream 驱动
- `FavoritesPage`:列表 + RefreshIndicator + Dismissible 左滑删除 + 类型图标 + 空状态
- `AppRouteName.settingsFavorites` + 路由注册 + auth guard
- `settings_page.dart` 收藏行 onTap 接入导航

## 最近呼叫(框架,API 对接待续)
- `CallLogRequest` / `FetchCallLogsUseCase` / `RecentCallsViewModel`
- `RecentCallsPage`:双 Tab(全部/未接)+ _CallLogTile(图标/时长/时间)
- `AppRouteName.settingsRecentCalls` + 路由注册

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:30:56 +09:00
pp-bot
db10d1fcd2 feat(mine): 我的 Tab UI 重设计(#39~#41)
- ProfileHeroCard:72pt 渐变头像(8色 uid%8 主题)、@J{uid} handle、bio 简介、掩码手机号
- AppBar:compact,右侧 QR 图标 + 编辑铅笔
- 彩色图标行(_IconBox 36pt 圆角)+ 4 卡片组对齐 iOS SettingsView
  - 账户:我的钱包 / 账户安全
  - 工具:收藏 / 最近呼叫 / 链接设备 / 聊天文件夹
  - 偏好设置:通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题
  - 关于:用户协议 / 隐私政策 / 版本号
- SettingsState 增加 bio 字段(#41),loadProfile 同步赋值
- Doc/mine_tab_architecture.md 补充 UI 重设计章节(§7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:14:34 +09:00
pp-bot
2eb2299709 fix: 修复多图消息无法显示的三个根因
根因 1 — MessageItem.toEntity() id=0 主键碰撞:
  WS 拉取的每条消息均用 id=0 insertOrReplace,批量消息相互覆盖,
  DB 中只留最后一条。改为 id=messageId(服务端唯一 ID)。

根因 2 — SendMessageUseCase 乐观写入 id=0 碰撞:
  批量图片发送时所有乐观行共享 id=0,逐条覆盖。
  改用负微秒时间戳作为临时唯一 id,HTTP 确认后用真实 messageId 替换。

根因 3 — watchByChatId 无 ORDER BY:
  DB 消息顺序不确定,宫格分组算法依赖时间升序失败。
  在 MessageRepositoryImpl.watchByChatId 及 _buildDisplayItems 中
  分别按 sendTime ASC + chatIdx ASC 排序。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:45:41 +09:00
pp-bot
2354e92c64 feat: 多图宫格气泡全量实现 (#36~#38)
- #36 ImageGridBubble: 2 列×116pt / 3 列×78pt, 3pt 间距, 8pt 外层/4pt 单格圆角, tap→ImageViewerPage
- #37 ChatDisplayItem + _buildDisplayItems: 连续 typ=2 同 sendId Δt<5s 分组为宫格,iOS ChatView.buildDisplayItems parity
- #38 SendImageUseCase.sendBatch: Future.wait 并行上传 → 顺序快速发送,ImagePickerSheet 改用 sendBatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:09:27 +09:00
53 changed files with 5089 additions and 379 deletions

67
AGENTS.md Normal file
View File

@@ -0,0 +1,67 @@
<!-- agents-md-role: dev -->
# AGENTS.md — dev role
> Open-standard agent contract per [agents.md](https://agents.md).
> Companion to `CLAUDE.md` (Claude-specific) — this file works for any
> AI coding agent (Cursor, Aider, Codex, OpenHands, etc.).
## Dev environment tips
- Default branch convention: `main` = prod, `dev` = rolling integration,
`release/*` = release candidates, `feat/*` = ephemeral feature branches.
If this repo currently uses something else (`im-dev`, `bundle`,
`4411-1`, `release`, `20260203`), see
`dasheng-repos/.omc/notes/packing-machine-arch-2026-04-27.md` for the
migration plan.
- For Vue 3 + Vite repos: `npm run build-only` (not `npm run build`)
to bypass legacy `tsc` errors that block clean builds.
- For Flutter repos: `flutter pub get && flutter test` before any change.
- For Swift repos: `xcodebuild -workspace ... -scheme ... test` from the
workspace root.
## Testing instructions
- TDD-first. Diff that changes behavior MUST add or update a test that
fails on the parent commit and passes on this branch.
- Find existing tests by language:
- `tests/`, `core/tests/`, `__tests__/` (Python / JS)
- `test/`, `integration_test/` (Flutter / Dart)
- `*Tests/` (Swift)
- Run with the lightest scope that proves the change:
`pytest core/tests/test_<module>.py` / `vitest run <pattern>` /
`flutter test test/<file>` / `xcodebuild test -only-testing:<TestClass>`.
- Pre-existing failures unrelated to your change are NOT in scope to fix
unless explicitly requested. Note them in your PR body.
## PR instructions
- Title: `<type>(<scope>): <subject>` lowercase, no trailing period.
- Body: `Why:` + `How:` + `Refs:` + `Co-Authored-By:` trailer.
- Default branch is read at runtime — never hardcode `"main"` in
PR-create / merge calls. See `wen_shu_hub.auto_pr_botdev` for the
canonical pattern.
- Idempotency: any artifact (issue, PR, comment) the agent creates MUST
carry an idempotency key in body so re-runs dedupe. Pattern:
`<!-- idem:<sha16> -->`.
## Hard rules (production-incident-driven)
1. **Fail-closed defaults** when uncertain about routing / permissions /
platforms. Default to the safer outcome (no fan-out, no auto-promote,
no auto-merge). See `_MODULE_PLATFORMS` in
`claude-worker-universe/core/wen_shu_hub.py` for the canonical pattern.
2. **Portable links only.** Never write `/Users/...` in commits, issues,
comments, or test artifacts. Use Gitea web URL with per-segment UTF-8
percent-encoding. Helper: `_qa_spec_gitea_url`.
3. **No inline PAT in remote URL.** If you see
`https://oauth2:<token>@host/...` in a remote, that's an existing
smell — don't propagate.
## References
- Master design: `claude-worker-universe/.omc/notes/master-design.md`
- Build machine review: `dasheng-repos/.omc/notes/packing-machine-arch-2026-04-27.md`
- Big-co patterns reused: Stripe Idempotency, Linear AI agents,
GitHub Copilot for Issues, Datadog rolling-3-window WARN, AWS IAM
fail-closed.

95
CLAUDE.md Normal file
View File

@@ -0,0 +1,95 @@
<!-- claude-md-role: dev -->
# CLAUDE.md — dev repo hook (autoinstalled by lobster-wenshu-bot)
> This file is the project's CLAUDE.md hook for the **dev** role.
> When Claude Code opens this repo, this is the first context it reads.
> Author: 文殊菩萨 / `lobster-wenshu-bot`. Last updated: 2026-04-27.
## Identity
This repo is a **product / dev** repo. Code that ships to users.
Bot owners: `lobster-worker-bot*` (iOS), `xurishu_bot` / `kevins-studio-bot` /
`jarvis_theone_bot` / `andyjanebot` (H5). PR review: 八戒 (`lobster-qa-bot`)
or human gate. Deploy: `mini-build-all` orchestrator (see also
`packing-machine-arch-2026-04-27.md` in claude-worker-universe `.omc/notes/`).
## Top rules
1. **TDD-first.** Before changing behavior, the diff must include a new or
updated test that *fails* on `main` and passes on this branch. No
exceptions for "trivial" fixes — historically the trivial ones are the
ones that come back as P0 production bugs (idem-label collapse, label
string→int64 422, bold-colon regex, fail-closed iOS routing — all
shipped without enough tests).
2. **Fail-closed defaults.** When a routing / permission / platform table
is unsure, default to the *safer* answer (no fan-out, no auto-promote,
no auto-merge). See `_MODULE_PLATFORMS` and `_PLATFORM_OWNER_BOTS` in
`claude-worker-universe/core/wen_shu_hub.py` for the canonical pattern.
3. **Idempotency markers in the body.** Any artifact this repo creates
(issues, PRs, comments, files) should carry an idempotency key in its
body (e.g. `<!-- idem:<sha16> -->`) so re-runs / retries de-dupe. The
*labels* field on Gitea issues is **not** authoritative — it requires
the bot account to be a repo collaborator (most aren't). Cross-ref
commit `3a1013b` in claude-worker-universe.
4. **Portable links only.** Never write a per-machine path
(`/Users/pp-bot/...`) into a commit, issue, or comment. Use the Gitea
web URL (`<host>/<org>/<repo>/src/branch/<branch>/<path>`) with
per-segment UTF-8 percent-encoding. See `_qa_spec_gitea_url` for the
canonical helper.
5. **Branch convention** (target end state): `main` is prod, `dev` is
rolling integration, `release/*` is tagged. If this repo currently
uses something else (`im-dev`, `bundle`, `4411-1`, `release`, `20260203`),
read the latest `packing-machine-arch-2026-04-27.md` for the migration
plan and **don't unilaterally rename**.
6. **Default branch is read at runtime** — never hardcode `"main"` in
PR-create / merge calls. `auto_pr_botdev` got bitten by this; the fix
reads `RepoState.default_branch` dynamically.
## Big-co patterns reused in this codebase
- **Stripe Idempotency-Key** → `_idempotency_key()` (sha16 hash; body
marker is the source of truth, not the label).
- **Linear AI agents / GitHub Copilot for Issues** → @-mention
`_PLATFORM_OWNER_BOTS` in body; assignees are best-effort with 422
fallback because most bots aren't repo collaborators.
- **Datadog rolling-window WARN** → `distribute.summary` JSON +
`distribute.idem_storm` after 3 consecutive ticks at idem_rate > 0.9.
- **AWS IAM fail-closed default-deny** → `_MODULE_PLATFORMS` opt-in for
iOS routing; default H5-only.
- **GitHub Actions/Vercel preview-smoke contract** → `headless-smoke`
CLI command (Playwright + Chromium against built dist/, emits PASS/FAIL
+ screenshot + exit code).
## Agent tier guidance for this repo
- Trivial lookups → Haiku
- Standard implementation → Sonnet
- Architecture, race conditions, security review → Opus
For multi-step work in this repo, route via the wenshu CLI:
`python3 .../core/wen_shu_hub.py <subcommand>``gen` / `distribute` /
`poll` / `auto-pr` / `state` / `daemon` / `headless-smoke` /
`align-bot-dev` / `figma`.
## Run commands
If `package.json` exists: `npm install && npm run build-only` (skip
type-check until repo's TS errors are clean — see lessons_learned).
If `pyproject.toml` exists: `python3 -m pytest core/tests/`.
If `pubspec.yaml` exists: this is a Flutter repo — see
`packing-machine-arch-2026-04-27.md` for build hooks (worker-knowledge,
bajie merge).
## Commit style
`<type>(<scope>): <subject in lowercase, no trailing period>`
Body: `Why:` + `How:` + `Refs:`. End every commit with the `Co-Authored-By`
trailer for the bot that authored it.
## What this hook deliberately does NOT do
- It does NOT pin a Python / Node / Flutter version (varies per repo).
- It does NOT enforce a license / formatter — repos diverge.
- It does NOT bypass review — `bot-dev → main` PRs still need human or
八戒 sign-off (master-design D2 / §6.2).

View File

@@ -0,0 +1,205 @@
# 表情 / 贴纸 / 附件面板架构设计文档
> Gitea Issues: #51#56
> Commit: `bb9f1aa`
> 参考iOS `StickerMessageBubble`, `AttachmentPanelView`, `EmojiKeyboardView`
---
## 一、功能范围
| Issue | 功能 | 状态 |
|-------|------|------|
| #51 | StickerMessageBubbletyp=5无边框 120pt CDN 图) | ✅ |
| #52 | AttachmentPanelSheet6 格面板) | ✅ |
| #53 | 拍照发送image_picker.camera → SendImageUseCase | ✅ |
| #54 | SendVideoUseCasevideo picker → CDN 上传 → typ=4 发送) | ✅ |
| #55 | 文件发送file_picker 占位SnackBar 提示) | ⏳ 待接入 |
| #56 | EmojiPanel4 分类 ~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 StickerMessageBubbletyp=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` |

View 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 |
| 39 | 3 列 | 78 × 78 pt | 3 pt |
- **外层圆角**8 ptClipRRect
- **单格圆角**4 pt
- **最后一行靠左**:不足 3 格时用透明占位补齐(仅 3 列布局)
- **总宽度**2 列 = 235 pt3 列 = 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

View File

@@ -0,0 +1,143 @@
# 图片查看与缓存 — 架构文档
> 对应 Gitea issues #32初版/ #57缓存/ #58ImageViewerPage 升级)/ #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-zoom1x5x| #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')
└─ PageRouteBuilderfade 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()` 在设置页集成

View File

@@ -1,6 +1,6 @@
# 我的MineTab — 架构文档
> 对应 Gitea issues #5#13
> 对应 Gitea issues #5#13#39#41UI 重设计),#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 w700titleMedium |
| 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
```

View 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 = BusyCallOptBusy
// 4 = CancelledCallOptCancel
// 5 = TimeoutCallTimeOut
// 6 = DeclinedCallBusy
// 未接来电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 → RecentCallsPageparentNavigatorKey=_rootKey
```
`auth_guard.dart` switch 已补充 `settingsRecentCalls` case。
---
## 9. 设计决策
- **无用户名映射**:当前 CallLog 实体只有 UID无昵称。显示 `@J{uid}` 占位,
后续可接入联系人 Repository 做名称查询。
- **增量拉取策略**:当前实现 `start_from=0`(全量),首次打开可能慢;
后续可从本地最新 `updatedAt` 做增量同步(参考 CallLogMgr.loadRemoteCallLog
- **离线可用**:数据先写 DBUI 监听 `allCallLogsProvider`DB Stream
无网络时仍能展示缓存记录。

View File

@@ -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');

View File

@@ -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';
@@ -182,6 +184,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,

View File

@@ -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;

View File

@@ -44,9 +44,18 @@ 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';
// ── WebSocket ──
static const wsConnect = '/websock/open';
}

View File

@@ -41,4 +41,9 @@ class ApiErrorCodes {
/// 触发图片验证data 含各平台 CAPTCHA tokenandroid / ios / web
static const int captchaRequired = 30174;
// ── 二级密码30164──
/// 账号已设置二级密码,需要用户输入后携带 MD5 哈希调 login-user
/// data 含 vcode_token / recovery_email / hint / reset_status
static const int secondaryPasscodeRequired = 30164;
}

View 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)';
}

View 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: []);
}
}

View 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;
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

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

View File

@@ -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

View File

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

View File

@@ -8,6 +8,7 @@ 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 服务层
///
@@ -75,3 +76,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),
);
});

View File

@@ -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';

View File

@@ -9,9 +9,16 @@ 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 {
@@ -36,10 +43,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,
@@ -63,7 +71,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) {

View File

@@ -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=4Gitea 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,
);
}
}

View File

@@ -7,24 +7,39 @@ 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/audio_message_bubble.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:image_picker/image_picker.dart';
import 'package:im_app/features/chat/di/chat_service_providers.dart';
import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart';
import 'package:im_app/features/chat/view/widgets/emoji_panel.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';
/// 聊天详情页(#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,
@@ -85,6 +100,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')));
}
}
}
/// 视频选取后发送(#54image_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));
@@ -105,18 +227,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 +275,8 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
controller: _inputCtrl,
isSending: state.isSending,
onSend: _send,
onAttach: _showImagePicker,
onAttachPanel: _showAttachmentPanel,
onEmoji: _showEmojiPanel,
),
],
),
@@ -157,6 +284,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 +432,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 +445,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 +460,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 +502,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 +529,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 +548,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(

View File

@@ -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-zoom1x5x双击 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),
),
),
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View 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),
),
),
],
),
),
],
),
);
}
}

View File

@@ -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 × 29 条连续消息,#36
///
/// 对应 iOS `ImageGridBubble.swift`issue #428
///
/// ## 布局规则iOS 对齐)
///
/// | 图片数 | 列数 | 单格尺寸 | 间距 |
/// |--------|------|----------|------|
/// | 2 | 2 列 | 116 × 116 pt | 3 pt |
/// | 39 | 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});
/// 29 条 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),
),
),
),
),
);
}
}

View File

@@ -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,
),
),
),
)

View File

@@ -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} 张图片发送失败')),
);
}
}

View File

@@ -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 {};
}
}
}

View File

@@ -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_tokenOTP 验证通过后服务端下发,二级密码步骤需携带)
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,
);
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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),
),
);
}

View File

@@ -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('返回手机号'),
),
],
);
}
}

View File

@@ -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 ─────────────────────────────────────────────────────────────────
/// 通话记录仓储 ProviderSettings 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

View File

@@ -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) {

View File

@@ -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');
}
}
}

View File

@@ -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,
);

View File

@@ -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

View File

@@ -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成功后同步删除本地 DBGitea 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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';
/// 分页拉取收藏列表并持久化到 DBGitea 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;
}
}

View File

@@ -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,
),
),
),
],
),
),
);
}
}

View 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),
),
),
);
}
}

View 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
/// - 监听allCallLogsProviderDB 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}';
}

View File

@@ -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 对齐)
///
/// - ProfileHeroCard72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio
/// - AppBarcompact右侧 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(
// ── AppBarcompactQR + 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;

View File

@@ -112,6 +112,10 @@ dependencies:
# 图片编辑 — 裁剪/旋转(#34
image_cropper: ^5.0.1
# 图片网络缓存 — 磁盘+内存双缓存(#57
cached_network_image: ^3.3.1
crypto: ^3.0.6
# 图片保存到相册(#32
image_gallery_saver_plus: ^3.0.5

View File

@@ -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;
}
}