This commit is contained in:
roxy 2026-04-29 14:09:34 +08:00
parent 1b4d0c40c6
commit 4de26d7663
17 changed files with 1704 additions and 90 deletions

View File

@ -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": "دولة",

View File

@ -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": "সোনা তালিকা",

View File

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

View File

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

View File

@ -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
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
```
### 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.uriAgora 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<SCMusicMode> 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. 在上麦/下麦/离房流程接入播放路由同步和清理。
### 阶段 5UI 细节与真机验证
预计 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 的兼容性。

View File

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

View File

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

View File

@ -29,6 +29,11 @@ class _SCSeatItemState extends State<SCSeatItem> 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<SCSeatItem> 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<SCSeatItem> 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<SCSeatItem> 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),
],
),
)),
],
),

View File

@ -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<MePage2> {
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<MePage2> {
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<MePage2> {
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<MePage2> {
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<MePage2> {
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<SocialChatUserProfileManager>(
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<SocialChatUserProfileManager>(
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<MePage2> {
),
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<MePage2> {
);
}
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<MePage2> {
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<MePage2> {
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,

View File

@ -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<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
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<num> _claimedTaskIds = {};
final Set<num> _claimingTaskIds = {};
List<SCTaskListRes> _tasks = [];
bool _loading = true;
String? _toastMessage;
Timer? _toastTimer;
@override
void initState() {
super.initState();
_loadTasks();
}
@override
void dispose() {
_toastTimer?.cancel();
super.dispose();
}
Future<void> _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<Widget> _buildTaskSections(
List<_TaskUiItem> dailyTasks,
List<_TaskUiItem> newcomerTasks,
) {
final sections = <Widget>[];
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<void> _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 }

View File

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

View File

@ -37,7 +37,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
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<RoomMenu> items1 = [];
List<RoomMenu> items2 = [];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

View File

@ -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<void> pumpSeat() {
return tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder:
(_, __) =>
ChangeNotifierProvider<RealTimeCommunicationManager>.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);
});
}