task
This commit is contained in:
parent
1b4d0c40c6
commit
4de26d7663
@ -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": "دولة",
|
||||
|
||||
@ -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": "সোনা তালিকা",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
548
docs/android-room-music-execution-plan.md
Normal file
548
docs/android-room-music-execution-plan.md
Normal 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.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<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. 在上麦/下麦/离房流程接入播放路由同步和清理。
|
||||
|
||||
### 阶段 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 的兼容性。
|
||||
@ -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());
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
788
lib/modules/user/task/task_page.dart
Normal file
788
lib/modules/user/task/task_page.dart
Normal 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 }
|
||||
15
lib/modules/user/task/task_route.dart
Normal file
15
lib/modules/user/task/task_route.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 |
BIN
sc_images/index/sc_icon_task_game.png
Normal file
BIN
sc_images/index/sc_icon_task_game.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 B |
BIN
sc_images/index/sc_icon_task_gift.png
Normal file
BIN
sc_images/index/sc_icon_task_gift.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 377 B |
BIN
sc_images/index/sc_icon_task_mic.png
Normal file
BIN
sc_images/index/sc_icon_task_mic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 B |
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user