From 4de26d766301f32eff9aeec23725565e76f43882 Mon Sep 17 00:00:00 2001 From: roxy Date: Wed, 29 Apr 2026 14:09:34 +0800 Subject: [PATCH] task --- assets/l10n/intl_ar.json | 9 +- assets/l10n/intl_bn.json | 7 + assets/l10n/intl_en.json | 7 + assets/l10n/intl_tr.json | 9 +- docs/android-room-music-execution-plan.md | 548 ++++++++++++ lib/app/routes/sc_routes.dart | 2 + lib/app_localizations.dart | 15 + lib/modules/room/seat/sc_seat_item.dart | 154 ++-- lib/modules/user/me_page2.dart | 188 ++++- lib/modules/user/task/task_page.dart | 788 ++++++++++++++++++ lib/modules/user/task/task_route.dart | 15 + lib/ui_kit/widgets/room/room_menu_dialog.dart | 2 +- sc_images/index/sc_icon_task.png | Bin 3815 -> 8793 bytes sc_images/index/sc_icon_task_game.png | Bin 0 -> 466 bytes sc_images/index/sc_icon_task_gift.png | Bin 0 -> 377 bytes sc_images/index/sc_icon_task_mic.png | Bin 0 -> 422 bytes test/room_seat_widget_test.dart | 50 ++ 17 files changed, 1704 insertions(+), 90 deletions(-) create mode 100644 docs/android-room-music-execution-plan.md create mode 100644 lib/modules/user/task/task_page.dart create mode 100644 lib/modules/user/task/task_route.dart create mode 100644 sc_images/index/sc_icon_task_game.png create mode 100644 sc_images/index/sc_icon_task_gift.png create mode 100644 sc_images/index/sc_icon_task_mic.png 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 a4825dc6e5bb7306f928f237c1e78e89bf826e94..8700ae89c302d5a8e74ddb58ebc6d63936e359ce 100644 GIT binary patch literal 8793 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 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 0000000000000000000000000000000000000000..60b25f72e488a495803ea7408e4ef46f76e469fb GIT binary patch literal 466 zcmV;@0WJQCP)r{MgWKHYHL z(Dkn<_9|91bRS&RS-I=iuB%*-pA2c7xXQD6O}UmbBzm<|0)^}Q?&j6C%N*)1ANb6+oKrWqZ!o0RvU#YrATJ{KLAX6Jn28{VABD2EHKwaRJu$V*QwOkwCFnCvpq8r;fS<$nTU9LOFh9-cxMT=q@^pY?Tr<)ZEyK8 ztl5g$u8s8)Y%T6TMc2HG*HO5!xTf|x;$ep!_IFm6V6|-il(qRj=Hl)u{^yDtjsChX Xnz>5MyQ!8O00000NkvXXu0mjf|In^x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cce6a3c0e05198981187523dac17069f74195668 GIT binary patch literal 422 zcmV;X0a^ZuP)<_RbF;emQ#QD~p?U=^%yy~^V!rcTXNhCZNKu}juUiR!%$B0?3;8a@F)mURC+2Gt(71-+aVbjF zVlyDCnX>BT3%=^DdE=q$rv1a7bj{`d6W5(~cf8=1K{pC+?z@po$NHo$+(LBev_?fN z`g^iTai7qvC3w4!hRIwo+V2PQz%rrp5?HRZ&9(H<>$nE_Hxyno5r_2?49g?y*PkGJ zEX(v&-klacsUHA?UJ^@mY6u{3cM1^fje>XkdAal4LI?O9Syu#g7EtfT2QbMoK(~Cd Qvj6}907*qoM6N<$f?_wmga7~l literal 0 HcmV?d00001 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); + }); }