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