diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json
index f272452..e18fd57 100644
--- a/assets/l10n/intl_ar.json
+++ b/assets/l10n/intl_ar.json
@@ -42,6 +42,7 @@
"game": "لعبة",
"invite": "دعوة",
"claim": "استلام",
+ "claimed": "تم الاستلام",
"recent": "الأحدث",
"popularEvents": "الأحداث الرائجة",
"exitGameMode": "الخروج من وضع اللعبة",
@@ -534,7 +535,13 @@
"deleteAccount2": "حذف الحساب ({1}ث)",
"areYouSureYouWantToDeleteYourAccount": "هل أنت متأكد أنك تريد حذف حسابك؟",
"enterRoomConfirmTips": "هل أنت متأكد أنك تريد دخول الغرفة؟",
- "dailyTasks": "المهام اليومية",
+ "dailyTasks": "المهام اليومية",
+ "exclusiveForNewcomers": "حصري للقادمين الجدد",
+ "resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل",
+ "limitedToOneTime": "لمرة واحدة فقط",
+ "useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة",
+ "rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح",
+ "rewardClaimFailed": "فشل استلام المكافأة",
"nickName": "الاسم المستعار",
"giftSpecialEffects": "تأثيرات الهدايا الخاصة",
"country": "دولة",
diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json
index fabdef6..109e334 100644
--- a/assets/l10n/intl_bn.json
+++ b/assets/l10n/intl_bn.json
@@ -101,6 +101,7 @@
"skipCountdown": "{1}সে স্কিপ",
"coins4": "কয়েন",
"claim": "গ্রহণ",
+ "claimed": "গ্রহণ করা হয়েছে",
"complete": "সম্পূর্ণ",
"shareTo": "শেয়ার করুন",
"copyLink": "লিঙ্ক কপি করুন",
@@ -466,6 +467,12 @@
"floatingAnimationInGlobal": "গ্লোবালে ভাসমান অ্যানিমেশন",
"entryVehicleAnimation2": "VIP4 বা তার বেশি অধিকারধারী ব্যবহারকারীরা এই ফিচার ব্যবহার করে গাড়ির অ্যানিমেশন বন্ধ করতে পারেন।",
"dailyTasks": "দৈনন্দিন টাস্ক",
+ "exclusiveForNewcomers": "নতুনদের জন্য বিশেষ",
+ "resetsDailyAtMidnight": "প্রতিদিন মধ্যরাতে রিসেট হয়",
+ "limitedToOneTime": "শুধু একবারের জন্য",
+ "useMicrophoneForOneMin": "১ মিনিট মাইক্রোফোন ব্যবহার করুন",
+ "rewardClaimedSuccessfully": "পুরস্কার সফলভাবে গ্রহণ করা হয়েছে",
+ "rewardClaimFailed": "পুরস্কার গ্রহণ ব্যর্থ হয়েছে",
"enterRoomConfirmTips": "আপনি কি রুমে যেতে চান?",
"followSucc": "সফলভাবে ফলো করা হয়েছে",
"goldListort": "সোনা তালিকা",
diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json
index ff95a3f..5f1facc 100644
--- a/assets/l10n/intl_en.json
+++ b/assets/l10n/intl_en.json
@@ -437,6 +437,12 @@
"floatingAnimationInGlobal": "Floating animation in global",
"entryVehicleAnimation2": "Users with VIP4 or higher privileges can use this function to disable vehicle animations.",
"dailyTasks": "Daily Tasks",
+ "exclusiveForNewcomers": "Exclusive For Newcomers",
+ "resetsDailyAtMidnight": "Resets daily at midnight",
+ "limitedToOneTime": "Limited to one time",
+ "useMicrophoneForOneMin": "Use the microphone for 1 min",
+ "rewardClaimedSuccessfully": "Reward claimed successfully",
+ "rewardClaimFailed": "Reward claim failed",
"enterRoomConfirmTips": "Are you sure you want to enter the room?",
"followSucc": "Followed successfully",
"goldListort": "Gold List",
@@ -528,6 +534,7 @@
"guest": "Guest",
"submit": "Submit",
"claim": "Claim",
+ "claimed": "Claimed",
"complete": "Complete",
"shareTo": "Share to",
"copyLink": "Copy Link",
diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json
index c456187..fd5eb76 100644
--- a/assets/l10n/intl_tr.json
+++ b/assets/l10n/intl_tr.json
@@ -435,7 +435,13 @@
"entryVehicleAnimation": "Giriş Aracı Animasyonu",
"floatingAnimationInGlobal": "Genel Ekranda Yüzen Animasyon",
"entryVehicleAnimation2": "VIP4 veya daha yüksek haklara sahip kullanıcılar araç animasyonlarını devre dışı bırakmak için fonksiyonu kullanabilir.",
- "dailyTasks": "Günlük Görevler",
+ "dailyTasks": "Günlük Görevler",
+ "exclusiveForNewcomers": "Yeni Gelenlere Özel",
+ "resetsDailyAtMidnight": "Her gün gece yarısı sıfırlanır",
+ "limitedToOneTime": "Yalnızca bir kez",
+ "useMicrophoneForOneMin": "Mikrofonu 1 dakika kullan",
+ "rewardClaimedSuccessfully": "Ödül başarıyla alındı",
+ "rewardClaimFailed": "Ödül alınamadı",
"enterRoomConfirmTips": "Odaya girmek istediğinizden emin misiniz?",
"followSucc": "Başarıyla takip edildi",
"goldListort": "Altın Listesi",
@@ -579,6 +585,7 @@
"bdLeader": "BD Lideri",
"picture": "Resim",
"claim": "Talep Et",
+ "claimed": "Alındı",
"taskNameRoomNewMember": "Yeni Oda Üyeleri",
"taskNameRoomOwnerSendRedPacket": "Oda Sahibi bir kırmızı paket gönderiyor",
"taskNameRoomOwnerSendGiftUser": "Oda Sahibi Hediye Gönderir",
diff --git a/docs/android-room-music-execution-plan.md b/docs/android-room-music-execution-plan.md
new file mode 100644
index 0000000..8300942
--- /dev/null
+++ b/docs/android-room-music-execution-plan.md
@@ -0,0 +1,548 @@
+# Android 语言房音乐功能执行方案
+
+整理时间:2026-04-29
+适用范围:Android 单端,语言房内 MP3 背景音乐添加、管理与播放
+参考文档:`/Users/nigger/Desktop/Android音乐添加流程.md`
+
+## 1. 目标
+
+在语言房内补齐一套可上线的 Android 音乐能力:
+
+- 从房间操作面板进入音乐功能。
+- 首次为空时引导扫描手机 MP3。
+- 支持 Android 音频权限申请与本地 MP3 扫描。
+- 按文件夹展示扫描结果,支持选择音乐添加到房间音乐列表。
+- 支持已添加音乐的搜索、删除、排序、二次添加、取消已添加。
+- 支持播放器:播放、暂停、停止、上一首、下一首、播放模式、音量、进度条拖动。
+- 支持麦下仅自己听,麦上推流给房间其他用户听。
+- 离房、切房、删除当前播放歌曲时正确停止并清理播放状态。
+
+不纳入本次范围:
+
+- iOS 权限和 iOS 媒体库适配。
+- 在线音乐、歌词、专辑封面精修。
+- 后台常驻播放通知栏。
+- 服务端同步歌单。当前按用户本地持久化处理。
+
+## 2. 项目现状
+
+已有基础:
+
+- `agora_rtc_engine: ^6.5.2` 已接入,实际锁定版本为 6.5.3。
+- `lib/services/audio/rtc_manager.dart` 已有 `startAudioMixing(String localPath, int cycle)` 雏形。
+- `rtc_manager.dart` 已监听 `onAudioMixingStateChanged`,但只维护了 `isMusicPlaying`。
+- `lib/shared/data_sources/models/sc_music_mode.dart` 已有基础音乐模型。
+- `lib/shared/data_sources/sources/local/data_persistence.dart` 已有 `getUserRoomMusic()` / `setUserRoomMusic()`。
+- `sc_images/room/` 已有音乐相关图标:播放、暂停、上一首、下一首、音量、空态、播放模式、删除、添加、音乐入口等。
+- 多语言中已有 `music`、`myMusic`、`localMusic`、`add`、`delete`、`search`、`edit`、`confirm`、`cancel` 等基础文案。
+- `local_packages/on_audio_query-2.9.0` 和 `local_packages/on_audio_query_android-1.1.0` 已存在,可用于 Android MediaStore 音频扫描。
+
+需要补齐:
+
+- `pubspec.yaml` 尚未声明 `on_audio_query` 依赖。
+- AndroidManifest 缺少 Android 13+ 的 `READ_MEDIA_AUDIO`。
+- `SCMusicMode` 字段不足,缺少稳定 id、content uri、文件夹路径、排序、添加时间等。
+- 音乐入口在 `lib/ui_kit/widgets/room/room_menu_dialog.dart` 中曾经存在,但当前被注释。
+- 没有独立音乐状态管理器,播放器状态不应继续堆进已经很长的 `RtcProvider`。
+- Agora 音频混流只封装了开始播放,缺少暂停、恢复、停止、音量、进度、seek、播放完成切歌、麦上/麦下路由切换。
+
+## 3. 总体技术方案
+
+采用“扫描服务 + 歌单状态管理 + Agora 播放适配 + UI 页面”的拆分方式。
+
+```mermaid
+flowchart LR
+ A["房间菜单 音乐入口"] --> B["RoomMusicPage"]
+ B --> C["RoomMusicManager"]
+ C --> D["RoomMusicRepository"]
+ D --> E["DataPersistence"]
+ C --> F["LocalMusicScanner"]
+ F --> G["on_audio_query / MediaStore"]
+ C --> H["RoomMusicPlayer"]
+ H --> I["RtcProvider / Agora RtcEngine"]
+```
+
+推荐新增目录:
+
+- `lib/services/music/`
+- `lib/modules/room/music/`
+- `lib/ui_kit/widgets/room/music/`
+
+推荐新增核心文件:
+
+- `lib/shared/data_sources/models/sc_music_mode.dart`:扩展现有模型。
+- `lib/shared/data_sources/models/sc_music_folder_mode.dart`:新增本地扫描文件夹模型。
+- `lib/services/music/local_music_scanner.dart`:Android 本地 MP3 扫描。
+- `lib/services/music/room_music_repository.dart`:已添加歌单本地持久化。
+- `lib/services/music/room_music_manager.dart`:歌单、选择、播放状态总控。
+- `lib/modules/room/music/room_music_page.dart`:音乐列表页。
+- `lib/modules/room/music/room_music_folder_page.dart`:扫描后的文件夹列表页。
+- `lib/modules/room/music/room_music_select_page.dart`:文件夹内音乐选择页。
+- `lib/ui_kit/widgets/room/music/room_music_player_bar.dart`:播放器控制条。
+- `lib/ui_kit/widgets/room/music/room_music_list_item.dart`:音乐列表项。
+- `lib/ui_kit/widgets/room/music/room_music_empty_state.dart`:空状态。
+
+## 4. 依赖与权限
+
+### 4.1 pubspec
+
+在 `pubspec.yaml` 增加:
+
+```yaml
+on_audio_query:
+ path: ./local_packages/on_audio_query-2.9.0
+```
+
+如果代码中直接使用 `package:path/path.dart` 做文件夹路径处理,建议把 `path` 显式加入依赖,避免依赖传递包:
+
+```yaml
+path: ^1.9.0
+```
+
+### 4.2 AndroidManifest
+
+现有 Manifest 已有:
+
+- `READ_EXTERNAL_STORAGE`
+- `WRITE_EXTERNAL_STORAGE`
+
+需要补充 Android 13+ 音频权限,并建议限制旧权限版本:
+
+```xml
+
+
+
+```
+
+### 4.3 权限工具
+
+在 `lib/shared/tools/sc_permission_utils.dart` 增加 `checkAudioPermission()`:
+
+- Android 13+:请求 `Permission.audio`。
+- Android 12 及以下:请求 `Permission.storage`。
+- 需要使用项目已有 `device_info_plus` 判断 SDK 版本。
+- 权限拒绝时不进入扫描结果页,只保留在当前流程;后续可补“去设置开启”引导。
+
+## 5. 数据模型
+
+### 5.1 音乐项
+
+扩展 `SCMusicMode`,兼容旧字段读取:
+
+```dart
+class SCMusicMode {
+ final String id; // 优先用 MediaStore id;兜底用 localPath hash
+ final String title;
+ final String artist;
+ final int durationMs;
+ final String localPath; // SongModel.data
+ final String contentUri; // SongModel.uri,Agora Android 推荐优先使用
+ final String folderPath;
+ final String fileName;
+ final int size;
+ final int sortIndex;
+ final int addedAt;
+}
+```
+
+关键点:
+
+- 扫描只保留 `fileExtension == mp3` 或文件名 `.mp3` 的音频。
+- 播放时优先使用 `contentUri`,为空再用 `localPath`。
+- 持久化仍走 `DataPersistence.setUserRoomMusic(jsonEncode(list))`。
+- 读取旧数据时要兼容当前模型里的 `title`、`artist`、`duration`、`localPath`。
+
+### 5.2 文件夹模型
+
+新增 `SCMusicFolderMode`:
+
+```dart
+class SCMusicFolderMode {
+ final String id;
+ final String name;
+ final String path;
+ final int musicCount;
+ final int addedCount;
+ final List songs;
+}
+```
+
+文件夹统计规则:
+
+- 使用 `p.dirname(song.localPath)` 作为文件夹路径。
+- 单个文件夹只统计该文件夹当前层级直接包含的 MP3。
+- 子文件夹内 MP3 归入子文件夹,不累计到父文件夹。
+- 文件夹列表按名称或路径稳定排序。
+
+## 6. 播放器方案
+
+### 6.1 播放方式
+
+统一使用 Agora `startAudioMixing`,原因是麦上需要把音乐推给远端。
+
+麦下播放:
+
+```dart
+engine.startAudioMixing(
+ filePath: playPath,
+ loopback: true,
+ cycle: 1,
+ startPos: currentPositionMs,
+);
+```
+
+麦上播放:
+
+```dart
+engine.startAudioMixing(
+ filePath: playPath,
+ loopback: false,
+ cycle: 1,
+ startPos: currentPositionMs,
+);
+```
+
+这里的 `loopback` 是关键:
+
+- `true`:只本地播放,只有自己听到。
+- `false`:本地和远端都能听到。
+
+### 6.2 麦上/麦下同步
+
+当用户播放中上麦或下麦时,需要切换播放路由:
+
+1. 记录当前播放位置:`getAudioMixingCurrentPosition()`。
+2. `stopAudioMixing()`。
+3. 根据 `RtcProvider.isOnMai()` 重新 `startAudioMixing(loopback: !isOnMai, startPos: position)`。
+4. 恢复播放态和进度轮询。
+
+接入点建议:
+
+- 在 `RtcProvider` 上麦成功、下麦成功、角色切换成功后调用 `RoomMusicManager.syncPlaybackRouting(rtcProvider)`。
+- 离房清理流程调用 `RoomMusicManager.stopAndReset()`.
+
+### 6.3 控制能力
+
+`RoomMusicManager` 需要封装:
+
+- `play(SCMusicMode item)`
+- `pause()`
+- `resume()`
+- `stop()`
+- `previous()`
+- `next({bool fromAutoComplete = false})`
+- `seek(Duration position)`
+- `setVolume(int percent)`
+- `switchPlayMode()`
+- `deleteMusic(SCMusicMode item)`
+- `reorder(int oldIndex, int newIndex)`
+
+Agora 对应 API:
+
+- `startAudioMixing`
+- `pauseAudioMixing`
+- `resumeAudioMixing`
+- `stopAudioMixing`
+- `adjustAudioMixingVolume`
+- `adjustAudioMixingPlayoutVolume`
+- `adjustAudioMixingPublishVolume`
+- `getAudioMixingDuration`
+- `getAudioMixingCurrentPosition`
+- `setAudioMixingPosition`
+
+音量策略:
+
+- UI 展示 0-100%。
+- 默认同时调用 `adjustAudioMixingPlayoutVolume(volume)` 和 `adjustAudioMixingPublishVolume(volume)`。
+- 如果后续产品区分“自己听到的音量”和“房间听到的音量”,再拆成两个滑杆。
+
+### 6.4 播放模式
+
+定义:
+
+```dart
+enum RoomMusicPlayMode {
+ list,
+ shuffle,
+ single,
+}
+```
+
+规则:
+
+- 列表顺序播放:播放完成后按当前排序下一首,末尾回到第一首或停止,建议按原型做循环到第一首。
+- 随机播放:播放完成后随机选择非当前歌曲;只有一首时播放自身。
+- 单曲循环:播放完成后继续当前歌曲。
+
+切换 Toast:
+
+- `列表顺序播放`
+- `随机播放`
+- `单曲循环播放`
+
+### 6.5 播放完成
+
+监听 `onAudioMixingStateChanged`:
+
+- `audioMixingStatePlaying`:更新为播放中。
+- `audioMixingStatePaused`:更新为暂停。
+- `audioMixingStateStopped` + `audioMixingReasonAllLoopsCompleted`:按播放模式自动下一首。
+- `audioMixingStateStopped` + `audioMixingReasonStoppedByUser`:停止态。
+- `audioMixingStateFailed`:Toast 提示并停止当前播放,常见原因是文件被删除、路径无权限、格式不支持。
+
+## 7. 页面与交互落地
+
+### 7.1 房间操作面板入口
+
+修改 `lib/ui_kit/widgets/room/room_menu_dialog.dart`:
+
+- 恢复 `音乐` 入口。
+- 点击后关闭菜单,打开 `RoomMusicPage`。
+- 建议房主/管理员/普通用户都能看到入口;是否允许播放由房间配置 `allowMusic` 决定。如果产品要求强制限制,则判断 `currenRoom.roomProfile.roomSetting.allowMusic`。
+
+### 7.2 音乐列表页
+
+`RoomMusicPage` 状态:
+
+- 空列表:展示空态图标、`没有音乐`、`从手机扫描添加`。
+- 有列表:顶部 `添加`、搜索入口、编辑入口、总数、歌曲列表。
+- 播放中:当前播放项高亮,底部显示播放器条。
+- 编辑态:支持拖拽排序;也可保留左滑删除。
+
+列表能力:
+
+- 搜索按 `title` / `fileName` 包含匹配。
+- 左滑删除,弹窗确认 `确定删除音乐?`。
+- 删除当前播放歌曲时,先 `stop()`,再删除并持久化。
+- 拖拽排序后立即更新 `sortIndex` 并持久化。
+
+### 7.3 添加流程
+
+流程:
+
+```mermaid
+flowchart TD
+ A["点击 添加/从手机扫描添加"] --> B["检查音频权限"]
+ B -->|"拒绝"| C["停留当前页"]
+ B -->|"允许"| D["扫描 MediaStore MP3"]
+ D -->|"无结果"| E["Toast 未找到音乐"]
+ D -->|"有结果"| F["文件夹列表页"]
+ F --> G["选择文件夹"]
+ G --> H["音乐选择页"]
+ H --> I["勾选/取消勾选"]
+ I --> J["点击 添加"]
+ J --> K["更新已添加列表并返回音乐列表"]
+```
+
+扫描规则:
+
+- 每次进入添加流程都重新调用 `querySongs()`。
+- 过滤 MP3。
+- 过滤 `duration <= 0` 的异常项。
+- 文件不存在或 `localPath` 为空的项不进入结果。
+- 对同一个 MediaStore id 或同一路径去重。
+
+选择规则:
+
+- 已添加歌曲显示灰色勾选。
+- 本次新选歌曲显示高亮勾选。
+- 用户点击灰色勾选可取消已添加,提交后从列表移除。
+- 只有存在“新增歌曲”时,添加按钮高亮;如果只有取消已添加,建议按钮文案仍为 `添加` 但可点击,否则无法完成取消动作。这里与原型“无新增则置灰”有轻微冲突,建议和产品确认;若严格按原型,则取消已添加后也需要允许提交,否则规则无法闭环。
+
+## 8. 具体实施步骤
+
+### 阶段 1:基础依赖与数据层
+
+预计 4-6 小时。
+
+1. 修改 `pubspec.yaml`,接入 `on_audio_query`。
+2. 修改 `android/app/src/main/AndroidManifest.xml`,补 `READ_MEDIA_AUDIO`。
+3. 扩展 `SCPermissionUtils.checkAudioPermission()`。
+4. 扩展 `SCMusicMode`,新增 `SCMusicFolderMode`。
+5. 新增 `RoomMusicRepository`,封装读取、保存、去重、排序。
+6. 运行 `flutter pub get` 和基础静态检查。
+
+### 阶段 2:扫描与添加流程
+
+预计 6-8 小时。
+
+1. 新增 `LocalMusicScanner`。
+2. 使用 `OnAudioQuery.checkAndRequest()` 或项目权限工具 + `querySongs()` 扫描。
+3. 实现 MP3 过滤、文件夹分组、已添加数量统计。
+4. 实现文件夹列表页。
+5. 实现音乐选择页、全选、新增勾选、已添加灰色勾选、取消已添加。
+6. 添加成功后更新本地持久化并返回音乐列表。
+
+### 阶段 3:音乐列表与 CRUD
+
+预计 6-8 小时。
+
+1. 实现 `RoomMusicPage` 空态、有数据态。
+2. 接入搜索。
+3. 接入左滑删除和二次确认。
+4. 删除当前播放歌曲时停止播放。
+5. 接入拖拽排序并持久化。
+6. 接入口入口菜单。
+
+### 阶段 4:播放器与 Agora 混流
+
+预计 8-12 小时。
+
+1. 新增 `RoomMusicManager extends ChangeNotifier`。
+2. 在 `main.dart` 的 `MultiProvider` 中注册。
+3. 封装播放、暂停、恢复、停止、上下首。
+4. 封装进度轮询,间隔建议 500-1000ms。
+5. 封装 `seek` 与音量调节。
+6. 实现三种播放模式。
+7. 处理 `onAudioMixingStateChanged` 自动切歌与失败兜底。
+8. 在上麦/下麦/离房流程接入播放路由同步和清理。
+
+### 阶段 5:UI 细节与真机验证
+
+预计 6-10 小时。
+
+1. 调整播放器底部高度,避免遮挡列表。
+2. 接入播放中音乐 icon 旋转动画。
+3. 补充多语言 key:没有音乐、从手机扫描添加、未找到音乐、确定删除音乐、音乐已删除、总计歌曲、播放模式 Toast 等。
+4. Android 12、Android 13+ 真机验证权限。
+5. 真机验证麦上/麦下播放、闭麦不影响音乐输出。
+6. 回归语言房退出、切房、最小化、重新进入房间。
+
+## 9. 建议工时
+
+按 Android 单端、GPT-5.5 + Codex 协作速度估算:
+
+| 模块 | 预计耗时 |
+|---|---:|
+| 依赖、权限、数据模型、持久化 | 4-6 小时 |
+| MP3 扫描、文件夹分组、添加选择 | 6-8 小时 |
+| 已添加列表、搜索、删除、排序 | 6-8 小时 |
+| Agora 播放器、进度、音量、模式、上下首 | 8-12 小时 |
+| 房间入口、上麦/下麦同步、离房清理 | 4-6 小时 |
+| Android 真机测试与修复 | 6-10 小时 |
+
+总计建议预留:34-50 小时。
+
+如果先做 MVP:
+
+- 扫描 MP3
+- 添加到列表
+- 播放/暂停/停止
+- 上下首
+- 音量
+- 进度条
+- 删除
+- 麦下本地播放、麦上推流播放
+
+预计:22-32 小时。
+
+完整对齐原型并做稳定上线版本,建议按 40 小时左右排期。
+
+## 10. 风险与处理
+
+### 10.1 Android 10+ 路径访问
+
+Agora 文档建议 Android 播放本地文件优先使用 `content://` URI。`on_audio_query` 的 `SongModel.uri` 可作为 `contentUri` 保存。
+
+处理:
+
+- 播放路径优先级:`contentUri` > `localPath`。
+- 如果 `audioMixingReasonCanNotOpen`,提示文件无法播放,并可从列表移除失效文件。
+
+### 10.2 添加按钮规则冲突
+
+原型写到“无新增音乐时添加按钮置灰”,同时又写到“点击置灰已添加可取消,点击添加后移除”。如果只取消已添加但没有新增,按钮置灰会导致无法提交移除。
+
+建议:
+
+- 只要本次选择结果和原已添加状态有差异,按钮即可点击。
+- 如果产品坚持按钮必须叫 `添加`,仍然允许提交取消动作。
+
+### 10.3 播放状态跨麦位变化
+
+用户麦下播放时是 `loopback: true`,上麦后需要切到 `loopback: false`。Agora 没有直接切换 loopback 的接口,需要 stop/start。
+
+处理:
+
+- 切换前读取进度。
+- 用 `startPos` 恢复。
+- 加 500ms 节流,避免连续上麦状态变化触发频繁 start。
+
+### 10.4 当前播放歌曲被删除
+
+处理:
+
+- 删除前判断 `current?.id == target.id`。
+- 是当前歌曲则先 `stopAudioMixing()`。
+- 清空当前播放项和进度。
+- 再持久化删除。
+
+### 10.5 房间生命周期
+
+处理:
+
+- 离房、RTC disconnected 清理、App termination 释放前调用音乐停止。
+- 切房时不保留上一房间播放状态。
+- 最小化播放器仅影响 UI,不影响播放。
+
+## 11. 验收清单
+
+权限与扫描:
+
+- Android 13+ 首次添加触发音频权限弹窗。
+- Android 12 及以下可正常读取本地音乐。
+- 拒绝权限后不会进入扫描结果页。
+- 无 MP3 时 Toast `未找到音乐`。
+- 扫描结果只包含 MP3。
+- 文件夹数量符合“只统计当前层级直接音乐”的规则。
+
+添加与列表:
+
+- 空列表展示空态。
+- 添加后返回音乐列表并展示总数。
+- 二次添加能展示已添加数量。
+- 已添加歌曲灰色勾选,新选择歌曲高亮勾选。
+- 取消已添加并提交后,音乐列表移除对应歌曲。
+- 搜索能按名称过滤。
+- 左滑删除有确认弹窗。
+- 删除成功 Toast `音乐已删除`。
+- 拖拽排序后重新进入页面仍保持顺序。
+
+播放:
+
+- 点击歌曲开始播放。
+- 播放/暂停/恢复正常。
+- 上一首/下一首正常。
+- 进度条自动推进。
+- 拖动进度条能 seek。
+- 音量调节生效。
+- 列表顺序、随机、单曲循环三种模式正常。
+- 播放完成后按当前模式切歌。
+- 删除当前播放歌曲后立即停止。
+
+房间场景:
+
+- 麦下播放只有自己听到。
+- 播放中上麦后,其他用户能听到音乐。
+- 播放中下麦后,其他用户不再听到音乐,本地继续播放。
+- 闭麦不影响音乐推流。
+- 离房后音乐停止。
+- 切房后不会继续上一房间音乐。
+
+## 12. 推荐落地顺序
+
+优先顺序:
+
+1. 数据模型、持久化、权限、扫描。
+2. 文件夹页和选择页。
+3. 音乐列表页和入口。
+4. 基础播放、暂停、停止。
+5. 上下首、进度、音量、模式。
+6. 麦上/麦下同步。
+7. 删除、排序、搜索和细节回归。
+
+这样可以尽早在真机上验证最不确定的两件事:Android MediaStore 扫描结果,以及 Agora 使用 `contentUri` 播放本地 MP3 的兼容性。
diff --git a/lib/app/routes/sc_routes.dart b/lib/app/routes/sc_routes.dart
index 4f77722..c8d557d 100644
--- a/lib/app/routes/sc_routes.dart
+++ b/lib/app/routes/sc_routes.dart
@@ -7,6 +7,7 @@ import 'package:yumi/modules/chat/chat_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/index/index_page.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
+import 'package:yumi/modules/user/task/task_route.dart';
import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/modules/auth/login_route.dart';
@@ -46,6 +47,7 @@ class SCRoutes {
_listRouter.add(SettingsRoute());
_listRouter.add(VoiceRoomRoute());
_listRouter.add(SCMainRoute());
+ _listRouter.add(TaskRoute());
_listRouter.add(VipRoute());
_listRouter.add(WalletRoute());
_listRouter.add(StoreRoute());
diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart
index cf569af..d66e6a1 100644
--- a/lib/app_localizations.dart
+++ b/lib/app_localizations.dart
@@ -218,6 +218,8 @@ class SCAppLocalizations {
String get claim => translate('claim');
+ String get claimed => translate('claimed');
+
String get inviteGoRoomTips => translate('inviteGoRoomTips');
String get dailyCoinBonanzaRules => translate('dailyCoinBonanzaRules');
@@ -786,6 +788,19 @@ class SCAppLocalizations {
String get dailyTasks => translate('dailyTasks');
+ String get exclusiveForNewcomers => translate('exclusiveForNewcomers');
+
+ String get resetsDailyAtMidnight => translate('resetsDailyAtMidnight');
+
+ String get limitedToOneTime => translate('limitedToOneTime');
+
+ String get useMicrophoneForOneMin => translate('useMicrophoneForOneMin');
+
+ String get rewardClaimedSuccessfully =>
+ translate('rewardClaimedSuccessfully');
+
+ String get rewardClaimFailed => translate('rewardClaimFailed');
+
String get reject => translate('reject');
String get messageHasBeenRecalled => translate('messageHasBeenRecalled');
diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart
index 77d0c84..b1858ae 100644
--- a/lib/modules/room/seat/sc_seat_item.dart
+++ b/lib/modules/room/seat/sc_seat_item.dart
@@ -29,6 +29,11 @@ class _SCSeatItemState extends State with TickerProviderStateMixin {
static const String _seatHeartbeatValueIconAsset =
"sc_images/room/sc_icon_room_seat_heartbeat_value.png";
static const double _seatHeaddressScaleMultiplier = 1.3;
+ static const double _seatPlainAvatarScale = 0.86;
+ static const double _seatLabelBlockWidth = 64;
+ static const double _seatLabelRowHeight = 16;
+ static const double _seatLabelGap = 2;
+ static const double _seatHeartbeatRowHeight = 10;
RtcProvider? provider;
_SeatRenderSnapshot? _cachedSnapshot;
@@ -59,6 +64,8 @@ class _SCSeatItemState extends State with TickerProviderStateMixin {
: seatSnapshot.headdressSourceUrl;
final double seatBaseSize = widget.isGameModel ? 40.w : 55.w;
final bool hasHeaddress = resolvedHeaddress.isNotEmpty;
+ final double seatAvatarSize =
+ hasHeaddress ? seatBaseSize : seatBaseSize * _seatPlainAvatarScale;
return GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -86,8 +93,8 @@ class _SCSeatItemState extends State with TickerProviderStateMixin {
hasHeaddress ? _seatHeaddressScaleMultiplier : 1,
child: head(
url: seatSnapshot.userAvatar,
- width: seatBaseSize,
- height: seatBaseSize,
+ width: seatAvatarSize,
+ height: seatAvatarSize,
headdress: resolvedHeaddress,
),
)
@@ -127,75 +134,98 @@ class _SCSeatItemState extends State with TickerProviderStateMixin {
? Container()
: (seatSnapshot.hasUser
? SizedBox(
- width: 64.w,
+ width: _seatLabelBlockWidth.w,
+ height:
+ (_seatLabelRowHeight +
+ _seatLabelGap +
+ _seatHeartbeatRowHeight)
+ .w,
child: Column(
- mainAxisSize: MainAxisSize.min,
children: [
- Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.center,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- msgRoleTag(
- seatSnapshot.userRoles,
- width: 15.w,
- height: 15.w,
- ),
- Flexible(
- child: socialchatNickNameText(
- fontWeight: FontWeight.w600,
- seatSnapshot.userNickname,
- fontSize: 10.sp,
- type: seatSnapshot.userVipName,
- needScroll:
- seatSnapshot
- .userNickname
- .characters
- .length >
- 8,
- ),
- ),
- ],
- ),
- SizedBox(height: 2.w),
SizedBox(
- height: 10.w,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- mainAxisSize: MainAxisSize.min,
- children: [
- Image.asset(
- _seatHeartbeatValueIconAsset,
- width: 8.w,
- height: 8.w,
- fit: BoxFit.contain,
- ),
- SizedBox(width: 2.w),
- text(
- _heartbeatVaFormat(
- seatSnapshot.userHeartbeatValue,
+ height: _seatLabelRowHeight.w,
+ child: Center(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ msgRoleTag(
+ seatSnapshot.userRoles,
+ width: 15.w,
+ height: 15.w,
),
- fontWeight: FontWeight.w600,
- fontSize: 8.sp,
- lineHeight: 1,
- ),
- ],
+ Flexible(
+ child: socialchatNickNameText(
+ fontWeight: FontWeight.w600,
+ seatSnapshot.userNickname,
+ fontSize: 10.sp,
+ type: seatSnapshot.userVipName,
+ needScroll:
+ seatSnapshot
+ .userNickname
+ .characters
+ .length >
+ 8,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: _seatLabelGap.w),
+ SizedBox(
+ height: _seatHeartbeatRowHeight.w,
+ child: Center(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Image.asset(
+ _seatHeartbeatValueIconAsset,
+ width: 8.w,
+ height: 8.w,
+ fit: BoxFit.contain,
+ ),
+ SizedBox(width: 2.w),
+ text(
+ _heartbeatVaFormat(
+ seatSnapshot.userHeartbeatValue,
+ ),
+ fontWeight: FontWeight.w600,
+ fontSize: 8.sp,
+ lineHeight: 1,
+ ),
+ ],
+ ),
),
),
],
),
)
- : Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- SizedBox(height: 16.w),
- text(
- "NO.${widget.index}",
- fontSize: 10.sp,
- fontWeight: FontWeight.w600,
- ),
- ],
+ : SizedBox(
+ width: _seatLabelBlockWidth.w,
+ height:
+ (_seatLabelRowHeight +
+ _seatLabelGap +
+ _seatHeartbeatRowHeight)
+ .w,
+ child: Column(
+ children: [
+ SizedBox(
+ height: _seatLabelRowHeight.w,
+ child: Center(
+ child: text(
+ "NO.${widget.index}",
+ fontSize: 10.sp,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ SizedBox(height: _seatLabelGap.w),
+ SizedBox(height: _seatHeartbeatRowHeight.w),
+ ],
+ ),
)),
],
),
diff --git a/lib/modules/user/me_page2.dart b/lib/modules/user/me_page2.dart
index a4f9414..4c6d865 100644
--- a/lib/modules/user/me_page2.dart
+++ b/lib/modules/user/me_page2.dart
@@ -7,6 +7,8 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/user/settings/settings_route.dart';
+import 'package:yumi/modules/user/task/task_route.dart';
+import 'package:yumi/modules/user/vip/vip_route.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart';
@@ -106,7 +108,7 @@ class _MePage2State extends State {
SizedBox(height: 12.w),
_buildEntryRow(),
SizedBox(height: 12.w),
- _buildWalletCard(),
+ _buildWalletVipRow(),
_buildInviteActivityBanner(),
_buildMenuCard2(),
SizedBox(height: 12.w),
@@ -239,6 +241,12 @@ class _MePage2State extends State {
Widget _buildEntryRow() {
return Row(
children: [
+ _buildEntryItem(
+ title: SCAppLocalizations.of(context)!.task,
+ iconPath: 'sc_images/index/sc_icon_task.png',
+ onTap: () => SCNavigatorUtils.push(context, TaskRoute.task),
+ ),
+ SizedBox(width: 10.w),
_buildEntryItem(
title: SCAppLocalizations.of(context)!.store,
iconPath: 'sc_images/index/sc_icon_shop.png',
@@ -250,6 +258,22 @@ class _MePage2State extends State {
iconPath: 'sc_images/index/sc_icon_bag.png',
onTap: () => SCNavigatorUtils.push(context, StoreRoute.bags),
),
+ SizedBox(width: 10.w),
+ _buildEntryItem(
+ title: SCAppLocalizations.of(context)!.level,
+ iconPath: 'sc_images/index/sc_icon_level.png',
+ onTap: () => SCNavigatorUtils.push(context, SCMainRoute.levelList),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildWalletVipRow() {
+ return Row(
+ children: [
+ Expanded(child: _buildVipEntryCard()),
+ SizedBox(width: 10.w),
+ Expanded(child: _buildWalletCard()),
],
);
}
@@ -258,8 +282,7 @@ class _MePage2State extends State {
return SCDebounceWidget(
onTap: () => SCNavigatorUtils.push(context, WalletRoute.recharge),
child: Container(
- width: double.infinity,
- height: 90.w,
+ height: 68.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
image: const DecorationImage(
@@ -267,44 +290,53 @@ class _MePage2State extends State {
fit: BoxFit.fill,
),
),
- padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w),
+ padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
children: [
Text(
SCAppLocalizations.of(context)!.wallet,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
style: TextStyle(
- fontSize: 24.sp,
+ fontSize: 18.sp,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
color: Colors.white,
+ height: 1,
),
),
- SizedBox(height: 4.w),
+ SizedBox(height: 2.w),
Row(
children: [
Image.asset(
- 'sc_images/general/sc_icon_jb.png',
- width: 28.w,
- height: 28.w,
+ 'sc_images/general/sc_icon_wallet_coin.png',
+ width: 18.w,
+ height: 18.w,
),
- SizedBox(width: 6.w),
- Consumer(
- builder: (context, ref, child) {
- return Text(
- ':${_balanceText(ref.myBalance)}',
- style: TextStyle(
- color: Colors.white,
- fontSize: 24.sp,
- fontWeight: FontWeight.w700,
- fontStyle: FontStyle.italic,
- ),
- );
- },
+ SizedBox(width: 4.w),
+ Expanded(
+ child: Consumer(
+ builder: (context, ref, child) {
+ return Text(
+ ':${_balanceText(ref.myBalance)}',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 18.sp,
+ fontWeight: FontWeight.w700,
+ fontStyle: FontStyle.italic,
+ height: 1,
+ ),
+ );
+ },
+ ),
),
],
),
@@ -313,8 +345,8 @@ class _MePage2State extends State {
),
Image.asset(
'sc_images/index/sc_icon_wallet_icon.png',
- width: 68.w,
- height: 68.w,
+ width: 40.w,
+ height: 40.w,
),
],
),
@@ -322,6 +354,103 @@ class _MePage2State extends State {
);
}
+ Widget _buildVipEntryCard() {
+ return SCDebounceWidget(
+ onTap: () => SCNavigatorUtils.push(context, VipRoute.detail),
+ child: Container(
+ height: 68.w,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8.w),
+ border: Border.all(color: const Color(0xFFFFD37A), width: 1.w),
+ image: const DecorationImage(
+ image: AssetImage('sc_images/vip/sc_vip_entry_bg.png'),
+ fit: BoxFit.fill,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.18),
+ blurRadius: 10.w,
+ offset: Offset(0, 4.w),
+ ),
+ ],
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Stack(
+ children: [
+ PositionedDirectional(
+ top: 12.w,
+ start: 14.w,
+ child: Text(
+ 'VIP',
+ style: TextStyle(
+ color: Colors.white,
+ fontFamily: 'Source Han Sans SC',
+ fontSize: 16.sp,
+ fontWeight: FontWeight.w700,
+ height: 1,
+ ),
+ ),
+ ),
+ PositionedDirectional(
+ start: 13.w,
+ bottom: 10.w,
+ child: _buildVipLevelMark(5),
+ ),
+ PositionedDirectional(
+ end: 2.w,
+ top: 8.w,
+ child: Image.asset(
+ 'sc_images/vip/sc_vip_badge_5.png',
+ width: 52.w,
+ height: 52.w,
+ fit: BoxFit.contain,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildVipLevelMark(int level) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Image.asset(
+ 'sc_images/vip/sc_vip_letter_v.png',
+ width: 18.1.w,
+ height: 15.3.w,
+ fit: BoxFit.contain,
+ ),
+ Image.asset(
+ 'sc_images/vip/sc_vip_letter_i.png',
+ width: 8.3.w,
+ height: 15.3.w,
+ fit: BoxFit.contain,
+ ),
+ Image.asset(
+ 'sc_images/vip/sc_vip_letter_p.png',
+ width: 18.1.w,
+ height: 15.3.w,
+ fit: BoxFit.contain,
+ ),
+ SizedBox(width: 3.w),
+ _buildVipLevelNumber(level),
+ ],
+ );
+ }
+
+ Widget _buildVipLevelNumber(int level) {
+ return Image.asset(
+ 'sc_images/vip/sc_vip_number_${level}_filled.png',
+ height: 15.247.w,
+ fit: BoxFit.fill,
+ color: const Color(0xFFFFD155),
+ colorBlendMode: BlendMode.srcIn,
+ );
+ }
+
Widget _buildInviteActivityBanner() {
return SCDebounceWidget(
onTap: _openInviteActivity,
@@ -540,6 +669,8 @@ class _MePage2State extends State {
required String title,
required String iconPath,
required VoidCallback onTap,
+ double? iconWidth,
+ double? iconHeight,
}) {
return Expanded(
child: SCDebounceWidget(
@@ -555,10 +686,17 @@ class _MePage2State extends State {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Image.asset(iconPath, width: 54.w, height: 54.w),
+ Image.asset(
+ iconPath,
+ width: (iconWidth ?? 54).w,
+ height: (iconHeight ?? 54).w,
+ fit: BoxFit.contain,
+ ),
SizedBox(height: 4.w),
Text(
title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
diff --git a/lib/modules/user/task/task_page.dart b/lib/modules/user/task/task_page.dart
new file mode 100644
index 0000000..10d73c2
--- /dev/null
+++ b/lib/modules/user/task/task_page.dart
@@ -0,0 +1,788 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:yumi/app/constants/sc_global_config.dart';
+import 'package:yumi/app/routes/sc_fluro_navigator.dart';
+import 'package:yumi/app_localizations.dart';
+import 'package:yumi/modules/index/main_route.dart';
+import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
+import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
+
+class TaskPage extends StatefulWidget {
+ const TaskPage({super.key});
+
+ @override
+ State createState() => _TaskPageState();
+}
+
+class _TaskPageState extends State {
+ static const String _taskMicAsset = 'sc_images/index/sc_icon_task_mic.png';
+ static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png';
+ static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
+
+ final SCAccountRepository _repository = SCAccountRepository();
+ final Set _claimedTaskIds = {};
+ final Set _claimingTaskIds = {};
+ List _tasks = [];
+ bool _loading = true;
+ String? _toastMessage;
+ Timer? _toastTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadTasks();
+ }
+
+ @override
+ void dispose() {
+ _toastTimer?.cancel();
+ super.dispose();
+ }
+
+ Future _loadTasks() async {
+ try {
+ final tasks = await _repository.tasks();
+ if (!mounted) return;
+ setState(() {
+ _tasks =
+ tasks
+ ..sort((a, b) => (a.sortOrder ?? 0).compareTo(b.sortOrder ?? 0));
+ _loading = false;
+ });
+ } catch (_) {
+ if (!mounted) return;
+ setState(() {
+ _tasks = [];
+ _loading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final dailyTasks = _sectionTasks(isNewcomer: false);
+ final newcomerTasks = _sectionTasks(isNewcomer: true);
+
+ return Scaffold(
+ backgroundColor: const Color(0xFF00221D),
+ body: SafeArea(
+ bottom: false,
+ child: Stack(
+ children: [
+ Column(
+ children: [
+ _buildTopBar(),
+ Expanded(
+ child: RefreshIndicator(
+ color: const Color(0xFF16DBB2),
+ backgroundColor: const Color(0xFF06342A),
+ onRefresh: _loadTasks,
+ child: SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(
+ parent: BouncingScrollPhysics(),
+ ),
+ padding: EdgeInsets.fromLTRB(10.w, 18.w, 10.w, 28.w),
+ child: Column(
+ children: [
+ if (_loading)
+ Padding(
+ padding: EdgeInsets.only(top: 120.w),
+ child: SizedBox(
+ width: 24.w,
+ height: 24.w,
+ child: const CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Color(0xFF16DBB2),
+ ),
+ ),
+ )
+ else
+ ..._buildTaskSections(dailyTasks, newcomerTasks),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (_toastMessage != null) _buildToast(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTopBar() {
+ return SizedBox(
+ height: 44.w,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Align(
+ alignment: AlignmentDirectional.centerStart,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => SCNavigatorUtils.goBack(context),
+ child: SizedBox(
+ width: 44.w,
+ height: 44.w,
+ child: Icon(
+ SCGlobalConfig.lang == 'ar'
+ ? Icons.keyboard_arrow_right
+ : Icons.keyboard_arrow_left,
+ color: Colors.white,
+ size: 28.w,
+ ),
+ ),
+ ),
+ ),
+ Text(
+ SCAppLocalizations.of(context)!.task,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 20.sp,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildToast() {
+ return Positioned(
+ left: 50.w,
+ right: 50.w,
+ top: 316.w,
+ child: IgnorePointer(
+ child: Container(
+ height: 56.w,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: const Color(0xFF063F36),
+ borderRadius: BorderRadius.circular(12.w),
+ border: Border.all(color: const Color(0xFF18DDB5), width: 1.w),
+ ),
+ child: Text(
+ _toastMessage!,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 13.sp,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ List _buildTaskSections(
+ List<_TaskUiItem> dailyTasks,
+ List<_TaskUiItem> newcomerTasks,
+ ) {
+ final sections = [];
+ final l10n = SCAppLocalizations.of(context)!;
+ if (dailyTasks.isNotEmpty) {
+ sections.add(
+ _TaskSection(
+ title: l10n.dailyTasks,
+ subtitle: l10n.resetsDailyAtMidnight,
+ tasks: dailyTasks,
+ onAction: _handleTaskAction,
+ isClaiming: _claimingTaskIds.contains,
+ ),
+ );
+ }
+ if (newcomerTasks.isNotEmpty) {
+ if (sections.isNotEmpty) sections.add(SizedBox(height: 14.w));
+ sections.add(
+ _TaskSection(
+ title: l10n.exclusiveForNewcomers,
+ subtitle: l10n.limitedToOneTime,
+ tasks: newcomerTasks,
+ onAction: _handleTaskAction,
+ isClaiming: _claimingTaskIds.contains,
+ ),
+ );
+ }
+ return sections;
+ }
+
+ List<_TaskUiItem> _sectionTasks({required bool isNewcomer}) {
+ final source =
+ _tasks.where((task) {
+ final taskType = (task.taskType ?? 0).toInt();
+ return isNewcomer ? taskType != 0 : taskType == 0;
+ }).toList();
+ final mapped = source.map(_mapTask).toList();
+ if (_tasks.isNotEmpty) return mapped;
+ final demoTasks = _demoTasks(isNewcomer: isNewcomer);
+ return demoTasks
+ .map(
+ (task) =>
+ _claimedTaskIds.contains(task.id)
+ ? task.copyWith(status: _TaskActionStatus.claimed)
+ : task,
+ )
+ .toList();
+ }
+
+ _TaskUiItem _mapTask(SCTaskListRes task) {
+ final id = task.taskId ?? -1;
+ final currentValue = _parseNumber(task.completedValue) ?? 0;
+ final targetValue =
+ _parseNumber(task.targetValue) ??
+ _parseNumber(task.conditionValue) ??
+ 1000000;
+ final claimed =
+ _claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
+ final completedByValue = targetValue > 0 && currentValue >= targetValue;
+ final completedByStatus = (task.taskStatus ?? 0) == 1;
+ final status =
+ claimed
+ ? _TaskActionStatus.claimed
+ : (completedByStatus || completedByValue)
+ ? _TaskActionStatus.claim
+ : _TaskActionStatus.go;
+
+ return _TaskUiItem(
+ id: id,
+ title:
+ _nonEmpty(task.taskName) ??
+ SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
+ progressText:
+ '${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}',
+ rewardText: _formatCompactNumber(task.quantity ?? 1100),
+ status: status,
+ iconAsset: _iconForTask(task),
+ jumpPage: _nonEmpty(task.jumpPage),
+ fromApi: task.taskId != null,
+ );
+ }
+
+ Future _handleTaskAction(_TaskUiItem task) async {
+ if (task.status == _TaskActionStatus.claimed) return;
+ if (task.status == _TaskActionStatus.go) {
+ _openJumpPage(task.jumpPage);
+ return;
+ }
+
+ if (_claimingTaskIds.contains(task.id)) return;
+ setState(() => _claimingTaskIds.add(task.id));
+ var success = true;
+ if (task.fromApi && task.id >= 0) {
+ try {
+ success = await _repository.taskReward(task.id);
+ } catch (_) {
+ success = false;
+ }
+ }
+ if (!mounted) return;
+ setState(() {
+ _claimingTaskIds.remove(task.id);
+ if (success) _claimedTaskIds.add(task.id);
+ });
+ if (success) {
+ _showToast(SCAppLocalizations.of(context)!.rewardClaimedSuccessfully);
+ unawaited(_loadTasks());
+ } else {
+ _showToast(SCAppLocalizations.of(context)!.rewardClaimFailed);
+ }
+ }
+
+ void _openJumpPage(String? jumpPage) {
+ final target = jumpPage?.trim() ?? '';
+ if (target.isEmpty) return;
+ if (target.startsWith('http://') || target.startsWith('https://')) {
+ SCNavigatorUtils.push(
+ context,
+ '${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(target)}&showTitle=false',
+ );
+ return;
+ }
+ if (target.startsWith('/')) {
+ SCNavigatorUtils.push(context, target);
+ }
+ }
+
+ void _showToast(String message) {
+ _toastTimer?.cancel();
+ setState(() => _toastMessage = message);
+ _toastTimer = Timer(const Duration(milliseconds: 1600), () {
+ if (mounted) setState(() => _toastMessage = null);
+ });
+ }
+
+ String _iconForTask(SCTaskListRes task) {
+ final text =
+ '${task.conditionType ?? ''} ${task.taskName ?? ''} ${task.taskDesc ?? ''}'
+ .toLowerCase();
+ if (text.contains('gift')) return _taskGiftAsset;
+ if (text.contains('game')) return _taskGameAsset;
+ return _taskMicAsset;
+ }
+
+ static num? _parseNumber(Object? value) {
+ if (value is num) return value;
+ if (value is String) return num.tryParse(value);
+ return null;
+ }
+
+ static String? _nonEmpty(String? value) {
+ final text = value?.trim();
+ return text == null || text.isEmpty ? null : text;
+ }
+
+ static String _formatPlainNumber(num value) {
+ if (value % 1 == 0) return value.toInt().toString();
+ return value.toStringAsFixed(1);
+ }
+
+ static String _formatCompactNumber(num value) {
+ final absValue = value.abs();
+ if (absValue >= 1000000) {
+ return '${_trimTrailingZero(value / 1000000)}m';
+ }
+ if (absValue >= 1000) {
+ return '${_trimTrailingZero(value / 1000)}k';
+ }
+ return _formatPlainNumber(value);
+ }
+
+ static String _trimTrailingZero(num value) {
+ final text = value.toStringAsFixed(1);
+ return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
+ }
+
+ List<_TaskUiItem> _demoTasks({required bool isNewcomer}) {
+ final title = SCAppLocalizations.of(context)!.useMicrophoneForOneMin;
+ final baseId = isNewcomer ? -200 : -100;
+ return [
+ _TaskUiItem(
+ id: baseId - 1,
+ title: title,
+ progressText: '0/1000000',
+ rewardText: '1.1k',
+ status: _TaskActionStatus.go,
+ iconAsset: _taskMicAsset,
+ ),
+ _TaskUiItem(
+ id: baseId - 2,
+ title: title,
+ progressText: '0/1000000',
+ rewardText: '1.1k',
+ status: _TaskActionStatus.claim,
+ iconAsset: _taskGameAsset,
+ ),
+ _TaskUiItem(
+ id: baseId - 3,
+ title: title,
+ progressText: '0/1000000',
+ rewardText: '1.1k',
+ status: _TaskActionStatus.claimed,
+ iconAsset: _taskGiftAsset,
+ ),
+ ];
+ }
+}
+
+class _TaskSection extends StatelessWidget {
+ const _TaskSection({
+ required this.title,
+ required this.subtitle,
+ required this.tasks,
+ required this.onAction,
+ required this.isClaiming,
+ });
+
+ final String title;
+ final String subtitle;
+ final List<_TaskUiItem> tasks;
+ final ValueChanged<_TaskUiItem> onAction;
+ final bool Function(num id) isClaiming;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: double.infinity,
+ decoration: BoxDecoration(
+ color: const Color(0xFF052E25),
+ borderRadius: BorderRadius.circular(7.w),
+ border: Border.all(color: const Color(0xFFB4FCCE), width: 1.w),
+ ),
+ child: Column(
+ children: [
+ Padding(
+ padding: EdgeInsets.fromLTRB(12.w, 24.w, 12.w, 12.w),
+ child: Row(
+ children: [
+ Expanded(child: _GradientTaskTitle(title)),
+ SizedBox(width: 8.w),
+ Text(
+ subtitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 12.sp,
+ fontWeight: FontWeight.w400,
+ height: 1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ for (int i = 0; i < tasks.length; i++) ...[
+ _TaskRow(
+ task: tasks[i],
+ onAction: onAction,
+ claiming: isClaiming(tasks[i].id),
+ ),
+ if (i != tasks.length - 1)
+ Padding(
+ padding: EdgeInsetsDirectional.only(start: 62.w),
+ child: Divider(
+ height: 1.w,
+ thickness: 0.5.w,
+ color: const Color(0xFF295346).withValues(alpha: 0.8),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _GradientTaskTitle extends StatelessWidget {
+ const _GradientTaskTitle(this.text);
+
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = TextStyle(
+ fontSize: 16.sp,
+ fontWeight: FontWeight.w900,
+ height: 1,
+ );
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ return SizedBox(
+ width: constraints.maxWidth,
+ height: 23.w,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ PositionedDirectional(
+ top: 3.w,
+ start: 0,
+ end: 0,
+ child: Text(
+ text,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: style.copyWith(color: const Color(0xFF157D10)),
+ ),
+ ),
+ PositionedDirectional(
+ start: 0,
+ end: 0,
+ child: Text(
+ text,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: style.copyWith(
+ foreground:
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1.w
+ ..color = const Color(0xFF157D10),
+ ),
+ ),
+ ),
+ PositionedDirectional(
+ start: 0,
+ end: 0,
+ child: ShaderMask(
+ blendMode: BlendMode.srcIn,
+ shaderCallback:
+ (bounds) => const LinearGradient(
+ begin: Alignment.centerRight,
+ end: Alignment.centerLeft,
+ colors: [
+ Color(0xFFFFFFFF),
+ Color(0xFFBEFAE8),
+ Color(0xFFFFFFFF),
+ ],
+ stops: [0.224, 0.4907, 0.7224],
+ ).createShader(bounds),
+ child: Text(
+ text,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: style.copyWith(color: Colors.white),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _TaskRow extends StatelessWidget {
+ const _TaskRow({
+ required this.task,
+ required this.onAction,
+ required this.claiming,
+ });
+
+ final _TaskUiItem task;
+ final ValueChanged<_TaskUiItem> onAction;
+ final bool claiming;
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ height: 65.w,
+ child: Row(
+ children: [
+ SizedBox(width: 12.w),
+ Container(
+ width: 42.w,
+ height: 42.w,
+ decoration: const BoxDecoration(
+ color: Color(0x30BEF9E8),
+ shape: BoxShape.circle,
+ ),
+ alignment: Alignment.center,
+ child: Image.asset(
+ task.iconAsset,
+ width: 29.4.w,
+ height: 29.4.w,
+ fit: BoxFit.contain,
+ ),
+ ),
+ SizedBox(width: 20.w),
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ task.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 12.sp,
+ fontWeight: FontWeight.w400,
+ height: 1.15,
+ ),
+ ),
+ SizedBox(height: 3.w),
+ Text(
+ task.progressText,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.62),
+ fontSize: 10.sp,
+ fontWeight: FontWeight.w400,
+ height: 1,
+ ),
+ ),
+ SizedBox(height: 6.w),
+ Row(
+ children: [
+ Image.asset(
+ 'sc_images/general/sc_icon_jb.png',
+ width: 12.w,
+ height: 12.w,
+ ),
+ SizedBox(width: 5.w),
+ Text(
+ task.rewardText,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: const Color(0xFFFFD155),
+ fontSize: 12.sp,
+ fontWeight: FontWeight.w400,
+ height: 1,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: 10.w),
+ _TaskActionButton(
+ status: task.status,
+ claiming: claiming,
+ onTap: () => onAction(task),
+ ),
+ SizedBox(width: 12.w),
+ ],
+ ),
+ );
+ }
+}
+
+class _TaskActionButton extends StatelessWidget {
+ const _TaskActionButton({
+ required this.status,
+ required this.claiming,
+ required this.onTap,
+ });
+
+ final _TaskActionStatus status;
+ final bool claiming;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final config = _TaskButtonConfig.fromStatus(
+ status,
+ SCAppLocalizations.of(context)!,
+ );
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: claiming ? null : onTap,
+ child: Container(
+ width: config.width.w,
+ height: 20.w,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10.w),
+ border:
+ config.borderColor == null
+ ? null
+ : Border.all(color: config.borderColor!, width: 1.w),
+ gradient: config.gradient,
+ color: config.gradient == null ? config.backgroundColor : null,
+ ),
+ child:
+ claiming
+ ? SizedBox(
+ width: 10.w,
+ height: 10.w,
+ child: CircularProgressIndicator(
+ strokeWidth: 1.5.w,
+ color: config.textColor,
+ ),
+ )
+ : Text(
+ config.label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: config.textColor,
+ fontSize: 13.sp,
+ fontWeight: FontWeight.w400,
+ height: 1,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _TaskButtonConfig {
+ const _TaskButtonConfig({
+ required this.label,
+ required this.textColor,
+ required this.width,
+ this.backgroundColor,
+ this.borderColor,
+ this.gradient,
+ });
+
+ final String label;
+ final Color textColor;
+ final double width;
+ final Color? backgroundColor;
+ final Color? borderColor;
+ final Gradient? gradient;
+
+ factory _TaskButtonConfig.fromStatus(
+ _TaskActionStatus status,
+ SCAppLocalizations l10n,
+ ) {
+ switch (status) {
+ case _TaskActionStatus.claim:
+ return _TaskButtonConfig(
+ label: l10n.claim,
+ textColor: Color(0xFF2B1500),
+ width: 53,
+ gradient: const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Color(0xFFFFD981), Color(0xFFFF9E2D)],
+ ),
+ );
+ case _TaskActionStatus.claimed:
+ return _TaskButtonConfig(
+ label: l10n.claimed,
+ textColor: Colors.white,
+ width: 58,
+ backgroundColor: const Color(0xFF062E28),
+ borderColor: Colors.white.withValues(alpha: 0.56),
+ );
+ case _TaskActionStatus.go:
+ return _TaskButtonConfig(
+ label: l10n.go,
+ textColor: Colors.white,
+ width: 53,
+ backgroundColor: const Color(0xFF062E28),
+ borderColor: const Color(0xFF98E4C2),
+ );
+ }
+ }
+}
+
+class _TaskUiItem {
+ const _TaskUiItem({
+ required this.id,
+ required this.title,
+ required this.progressText,
+ required this.rewardText,
+ required this.status,
+ required this.iconAsset,
+ this.jumpPage,
+ this.fromApi = false,
+ });
+
+ final num id;
+ final String title;
+ final String progressText;
+ final String rewardText;
+ final _TaskActionStatus status;
+ final String iconAsset;
+ final String? jumpPage;
+ final bool fromApi;
+
+ _TaskUiItem copyWith({_TaskActionStatus? status}) {
+ return _TaskUiItem(
+ id: id,
+ title: title,
+ progressText: progressText,
+ rewardText: rewardText,
+ status: status ?? this.status,
+ iconAsset: iconAsset,
+ jumpPage: jumpPage,
+ fromApi: fromApi,
+ );
+ }
+}
+
+enum _TaskActionStatus { go, claim, claimed }
diff --git a/lib/modules/user/task/task_route.dart b/lib/modules/user/task/task_route.dart
new file mode 100644
index 0000000..7c357ae
--- /dev/null
+++ b/lib/modules/user/task/task_route.dart
@@ -0,0 +1,15 @@
+import 'package:fluro/fluro.dart';
+import 'package:yumi/app/routes/sc_router_init.dart';
+import 'package:yumi/modules/user/task/task_page.dart';
+
+class TaskRoute implements SCIRouterProvider {
+ static String task = '/main/me/task';
+
+ @override
+ void initRouter(FluroRouter router) {
+ router.define(
+ task,
+ handler: Handler(handlerFunc: (_, params) => const TaskPage()),
+ );
+ }
+}
diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart
index 6c328a1..2c3c0df 100644
--- a/lib/ui_kit/widgets/room/room_menu_dialog.dart
+++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart
@@ -37,7 +37,7 @@ class _RoomMenuDialogState extends State {
static const int _menuSound = 4;
static const int _menuReport = 5;
static const int _menuBackground = 6;
- static bool get _showBackgroundFeature => false;
+ static bool get _showBackgroundFeature => true;
List items1 = [];
List items2 = [];
diff --git a/sc_images/index/sc_icon_task.png b/sc_images/index/sc_icon_task.png
index a4825dc..8700ae8 100644
Binary files a/sc_images/index/sc_icon_task.png and b/sc_images/index/sc_icon_task.png differ
diff --git a/sc_images/index/sc_icon_task_game.png b/sc_images/index/sc_icon_task_game.png
new file mode 100644
index 0000000..60b25f7
Binary files /dev/null and b/sc_images/index/sc_icon_task_game.png differ
diff --git a/sc_images/index/sc_icon_task_gift.png b/sc_images/index/sc_icon_task_gift.png
new file mode 100644
index 0000000..249e88f
Binary files /dev/null and b/sc_images/index/sc_icon_task_gift.png differ
diff --git a/sc_images/index/sc_icon_task_mic.png b/sc_images/index/sc_icon_task_mic.png
new file mode 100644
index 0000000..cce6a3c
Binary files /dev/null and b/sc_images/index/sc_icon_task_mic.png differ
diff --git a/test/room_seat_widget_test.dart b/test/room_seat_widget_test.dart
index 3bc830a..06cd823 100644
--- a/test/room_seat_widget_test.dart
+++ b/test/room_seat_widget_test.dart
@@ -128,4 +128,54 @@ void main() {
expect(find.byType(SCSeatItem), findsNWidgets(10));
},
);
+
+ testWidgets('SCSeatItem keeps height stable when an empty seat gets a user', (
+ tester,
+ ) async {
+ await tester.binding.setSurfaceSize(const Size(1600, 1200));
+ addTearDown(() async {
+ await tester.binding.setSurfaceSize(null);
+ });
+
+ final rtcProvider =
+ RealTimeCommunicationManager()
+ ..currenRoom = JoinRoomRes(
+ roomProfile: RoomProfile(roomSetting: RoomSetting(mikeSize: 10)),
+ )
+ ..roomWheatMap = {0: MicRes(micIndex: 0)};
+
+ Future pumpSeat() {
+ return tester.pumpWidget(
+ ScreenUtilInit(
+ designSize: const Size(375, 812),
+ builder:
+ (_, __) =>
+ ChangeNotifierProvider.value(
+ value: rtcProvider,
+ child: const MaterialApp(
+ home: Scaffold(body: Center(child: SCSeatItem(index: 0))),
+ ),
+ ),
+ ),
+ );
+ }
+
+ await pumpSeat();
+ final emptySeatSize = tester.getSize(find.byType(SCSeatItem));
+
+ rtcProvider.roomWheatMap = {
+ 0: MicRes(
+ micIndex: 0,
+ user: SocialChatUserProfile(
+ id: 'user-1',
+ userAvatar: '',
+ userNickname: 'Alice',
+ heartbeatVal: 1234,
+ ),
+ ),
+ };
+ await pumpSeat();
+
+ expect(tester.getSize(find.byType(SCSeatItem)), emptySeatSize);
+ });
}