Compare commits

...

17 Commits

Author SHA1 Message Date
roxy
36ba0f3573 1.2.6 2026-04-30 10:11:30 +08:00
roxy
fd445ccdaa vip接口 以及房间兜底 2026-04-29 20:37:03 +08:00
roxy
9f4df82c42 vip 接口 游戏及 重新安装等问题 2026-04-29 19:23:51 +08:00
roxy
7d12417116 bug fix 2026-04-29 19:04:14 +08:00
roxy
62d6405aa0 bgm播放器 进厨房bug fix 2026-04-29 18:28:39 +08:00
roxy
b0d68d2756 bgm播放器 第二版 2026-04-29 17:32:00 +08:00
roxy
ddb378f5b4 音乐播放 2026-04-29 14:52:09 +08:00
roxy
4de26d7663 task 2026-04-29 14:09:34 +08:00
roxy
1b4d0c40c6 麦位bug 2026-04-29 09:56:01 +08:00
roxy
78c3a95160 信息类型增加_邀请奖励 2026-04-28 10:07:29 +08:00
roxy
24c4a4b70c 注册邀请码 2026-04-27 19:30:20 +08:00
roxy
ddd50a65dc 邀请活动 2026-04-27 14:59:22 +08:00
roxy
a3f8db991d h5补token传参 2026-04-27 14:56:56 +08:00
roxy
4cf895b11e 只做flutter侧兜底 2026-04-27 14:11:26 +08:00
roxy
83fb6465f9 banner参数 非全面屏适配 语言房进入逻辑修改 2026-04-27 13:50:43 +08:00
roxy
39464469ba 上下麦/表情 bug 2026-04-27 11:17:02 +08:00
roxy
6db3aa5ae8 表情包 svga特效同步 语音房bug修复 VIP权益说明补充 背景 邀请码等 2026-04-24 18:51:11 +08:00
199 changed files with 15292 additions and 3309 deletions

View File

@ -85,13 +85,14 @@ android {
}
}
}
dependencies {
implementation("androidx.multidex:multidex:2.0.1")
// implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
// implementation("com.google.firebase:firebase-auth")
// implementation("com.google.firebase:firebase-core:16.0.8")
// implementation("com.google.android.gms:play-services-auth:21.1.1")
}
dependencies {
implementation("androidx.multidex:multidex:2.0.1")
implementation("com.android.installreferrer:installreferrer:2.2")
// implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
// implementation("com.google.firebase:firebase-auth")
// implementation("com.google.firebase:firebase-core:16.0.8")
// implementation("com.google.android.gms:play-services-auth:21.1.1")
}
flutter {

View File

@ -3,14 +3,19 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"
tools:node="remove" />
@ -33,11 +38,15 @@
</intent>
<package android:name="com.snapchat.android" />
</queries>
<application
android:name="${applicationName}"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true">
<application
android:name="${applicationName}"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="${appIcon}"
android:label="${appName}"
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -1,10 +1,83 @@
package com.org.yumiparty
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private var installReferrerClient: InstallReferrerClient? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) // 这行必须存在!
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
INSTALL_REFERRER_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"getInstallReferrer" -> getInstallReferrer(result)
else -> result.notImplemented()
}
}
}
override fun onDestroy() {
installReferrerClient?.endConnection()
installReferrerClient = null
super.onDestroy()
}
private fun getInstallReferrer(result: MethodChannel.Result) {
val client = InstallReferrerClient.newBuilder(this).build()
installReferrerClient = client
var hasResponded = false
fun respondOnce(value: String?) {
if (hasResponded) return
hasResponded = true
result.success(value)
client.endConnection()
if (installReferrerClient === client) {
installReferrerClient = null
}
}
fun respondError(errorCode: String, message: String?) {
if (hasResponded) return
hasResponded = true
result.error(errorCode, message, null)
client.endConnection()
if (installReferrerClient === client) {
installReferrerClient = null
}
}
try {
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
when (responseCode) {
InstallReferrerClient.InstallReferrerResponse.OK -> {
try {
respondOnce(client.installReferrer.installReferrer)
} catch (e: Exception) {
respondError("INSTALL_REFERRER_ERROR", e.message)
}
}
else -> respondOnce(null)
}
}
override fun onInstallReferrerServiceDisconnected() {
respondOnce(null)
}
})
} catch (e: Exception) {
respondError("INSTALL_REFERRER_CONNECTION_ERROR", e.message)
}
}
companion object {
private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer"
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include
domain="sharedpref"
path="." />
</full-backup-content>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include
domain="sharedpref"
path="." />
</cloud-backup>
<device-transfer>
<include
domain="sharedpref"
path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -32,7 +32,7 @@
"pleaseEnterContent": "يرجى إدخال المحتوى",
"profilePhoto": "صورة الملف الشخصي",
"noHistoricalRecordsAvailable": "لا توجد سجلات تاريخية متاحة.",
"inviteNewUsersToEarnCoins": "ادعُ مستخدمين جدد لكسب العملات",
"inviteNewUsersToEarnCoins": "ادعُ مستخدمين جدد لكسب العملات",
"crateMyRoom": "أنشئ غرفتك الخاصة.",
"casualInteraction": "التفاعل العفوي",
"haveGamePlayingTips": "لديك لعبة جارية، يرجى الخروج من اللعبة الحالية أولاً، هل أنت متأكد من الخروج؟",
@ -42,6 +42,7 @@
"game": "لعبة",
"invite": "دعوة",
"claim": "استلام",
"claimed": "تم الاستلام",
"recent": "الأحدث",
"popularEvents": "الأحداث الرائجة",
"exitGameMode": "الخروج من وضع اللعبة",
@ -308,6 +309,7 @@
"ownerSendTheRedEnvelope": "أرسل مالك الغرفة عملات المكافأة.",
"rewardCoins": "عملات المكافأة:{1} عملة",
"signInRewardReceived": "تم تسجيل الدخول بنجاح. المكافأة: {1}",
"registerRewardReceived": "تم استلام مكافأة التسجيل: {1}",
"lastWeekProgress": "تقدم الأسبوع الماضي",
"currentProgress": "التقدم الحالي",
"coins2": "{1} عملات",
@ -533,7 +535,13 @@
"deleteAccount2": "حذف الحساب ({1}ث)",
"areYouSureYouWantToDeleteYourAccount": "هل أنت متأكد أنك تريد حذف حسابك؟",
"enterRoomConfirmTips": "هل أنت متأكد أنك تريد دخول الغرفة؟",
"dailyTasks": "المهام اليومية",
"dailyTasks": "المهام اليومية",
"exclusiveForNewcomers": "حصري للقادمين الجدد",
"resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل",
"limitedToOneTime": "لمرة واحدة فقط",
"useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة",
"rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح",
"rewardClaimFailed": "فشل استلام المكافأة",
"nickName": "الاسم المستعار",
"giftSpecialEffects": "تأثيرات الهدايا الخاصة",
"country": "دولة",
@ -603,6 +611,8 @@
"custom": "مخصص",
"customBackground": "خلفية مخصصة",
"example": "مثال",
"endPreview": "إنهاء المعاينة",
"selectAgain": "اختر مرة أخرى",
"store": "المتجر",
"viewFrame": "عرض الإطار",
"headdress": "إطارات",
@ -647,8 +657,22 @@
"lightMode": "الوضع الفاتح",
"systemDefault": "النظام الافتراضي",
"pleaseGetOnTheMicFirst": "يرجى الصعود إلى الميكروفون أولاً.",
"roomMusicAddMusic": "إضافة موسيقى",
"roomMusicSelectAll": "تحديد الكل",
"roomMusicAddedCount": "تمت إضافة {1} أغنية",
"roomMusicEmpty": "لا توجد موسيقى",
"roomMusicScanFromPhone": "المسح من الهاتف",
"roomMusicTotalSongs": "الإجمالي: {1} أغنية",
"roomMusicNotFound": "لم يتم العثور على موسيقى",
"roomMusicDeleteConfirm": "هل تريد حذف هذه الموسيقى؟",
"roomMusicDeleted": "تم حذف الموسيقى",
"roomMusicListMode": "تشغيل بالترتيب",
"roomMusicShuffleMode": "تشغيل عشوائي",
"roomMusicSingleMode": "تكرار أغنية واحدة",
"welcomeMessage": "مرحبًا بك في تطبيقنا، {name}!",
"operationFail": "فشلت العملية.",
"enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",
"voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.",
"doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟",
"duration2": "المدة:{1}"
}

View File

@ -101,6 +101,7 @@
"skipCountdown": "{1}সে স্কিপ",
"coins4": "কয়েন",
"claim": "গ্রহণ",
"claimed": "গ্রহণ করা হয়েছে",
"complete": "সম্পূর্ণ",
"shareTo": "শেয়ার করুন",
"copyLink": "লিঙ্ক কপি করুন",
@ -162,6 +163,7 @@
"ownerSendTheRedEnvelope": "মালিক পুরস্কার কয়েন পাঠিয়েছেন।",
"rewardCoins": "পুরস্কার কয়েন:{1} কয়েন",
"signInRewardReceived": "সাইন ইন সফল হয়েছে। পুরস্কার: {1}",
"registerRewardReceived": "রেজিস্ট্রেশন পুরস্কার পাওয়া গেছে: {1}",
"lastWeekProgress": "গত সপ্তাহের অগ্রগতি",
"redEnvelopeTips2": "*লাল খাম সময়সীমার মধ্যে দাবি না করলে, বাকি কয়েন প্রেরক ব্যবহারকারীকে ফেরত দেওয়া হবে।",
"goToRecharge": "রিচার্জ করতে যান",
@ -268,6 +270,8 @@
"dice": "ডাইস",
"rps": "পাথর-কাগজ-কাঁচি",
"operationFail": "অপারেশন ব্যর্থ হয়েছে।",
"enterRoomFailedRetry": "রুমে প্রবেশ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"voiceConnectionFailedRetry": "ভয়েস সংযোগ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"likedYourComment": "আপনার মন্তব্য পছন্দ করেছেন।",
"doYouWantToKeepTheDraft": "আপনি কি ড্রাফট রাখতে চান?",
"operationsAreTooFrequent": "অপারেশন খুব ঘন ঘন",
@ -463,6 +467,12 @@
"floatingAnimationInGlobal": "গ্লোবালে ভাসমান অ্যানিমেশন",
"entryVehicleAnimation2": "VIP4 বা তার বেশি অধিকারধারী ব্যবহারকারীরা এই ফিচার ব্যবহার করে গাড়ির অ্যানিমেশন বন্ধ করতে পারেন।",
"dailyTasks": "দৈনন্দিন টাস্ক",
"exclusiveForNewcomers": "নতুনদের জন্য বিশেষ",
"resetsDailyAtMidnight": "প্রতিদিন মধ্যরাতে রিসেট হয়",
"limitedToOneTime": "শুধু একবারের জন্য",
"useMicrophoneForOneMin": "১ মিনিট মাইক্রোফোন ব্যবহার করুন",
"rewardClaimedSuccessfully": "পুরস্কার সফলভাবে গ্রহণ করা হয়েছে",
"rewardClaimFailed": "পুরস্কার গ্রহণ ব্যর্থ হয়েছে",
"enterRoomConfirmTips": "আপনি কি রুমে যেতে চান?",
"followSucc": "সফলভাবে ফলো করা হয়েছে",
"goldListort": "সোনা তালিকা",
@ -620,6 +630,8 @@
"custom": "কাস্টম",
"customBackground": "কাস্টম ব্যাকগ্রাউন্ড",
"example": "উদাহরণ",
"endPreview": "প্রিভিউ শেষ করুন",
"selectAgain": "আবার নির্বাচন করুন",
"myItems": "আমার আইটেম",
"use": "ব্যবহার করুন",
"unUse": "ব্যবহার না করুন",
@ -650,5 +662,17 @@
"lightMode": "লাইট মোড",
"systemDefault": "সিস্টেম ডিফল্ট",
"pleaseGetOnTheMicFirst": "অনুগ্রহ করে আগে মাইকে উঠুন।",
"roomMusicAddMusic": "মিউজিক যোগ করুন",
"roomMusicSelectAll": "সব নির্বাচন করুন",
"roomMusicAddedCount": "{1}টি গান যোগ হয়েছে",
"roomMusicEmpty": "কোনো মিউজিক নেই",
"roomMusicScanFromPhone": "ফোন থেকে স্ক্যান করুন",
"roomMusicTotalSongs": "মোট: {1}টি গান",
"roomMusicNotFound": "কোনো মিউজিক পাওয়া যায়নি",
"roomMusicDeleteConfirm": "এই মিউজিক মুছে ফেলবেন?",
"roomMusicDeleted": "মিউজিক মুছে ফেলা হয়েছে",
"roomMusicListMode": "ক্রম অনুসারে চালান",
"roomMusicShuffleMode": "শাফল চালান",
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
"duration2": "সময়:{1}"
}

View File

@ -134,6 +134,7 @@
"ownerSendTheRedEnvelope": "The owner sent reward coins.",
"rewardCoins": "Reward coins:{1} coins",
"signInRewardReceived": "Signed in successfully. Reward: {1}",
"registerRewardReceived": "Registration reward received: {1}",
"lastWeekProgress": "Last week's progress",
"redEnvelopeTips2": "*If the red envelope is not claimed within the time limit, the remaining coins will be returned to the user who sent the red envelope.",
"goToRecharge": "Go to recharge",
@ -239,6 +240,8 @@
"dice": "Dice",
"rps": "RPS",
"operationFail": "The operation failed.",
"enterRoomFailedRetry": "Failed to enter the room. Please try again.",
"voiceConnectionFailedRetry": "Voice connection failed. Please try again.",
"likedYourComment": "Liked your comment.",
"doYouWantToKeepTheDraft": "Do you want to keep the draft?",
"operationsAreTooFrequent": "Operations are too frequent",
@ -434,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",
@ -525,6 +534,7 @@
"guest": "Guest",
"submit": "Submit",
"claim": "Claim",
"claimed": "Claimed",
"complete": "Complete",
"shareTo": "Share to",
"copyLink": "Copy Link",
@ -620,6 +630,8 @@
"custom": "Custom",
"customBackground": "Custom Background",
"example": "Example",
"endPreview": "End Preview",
"selectAgain": "Select again",
"myItems": "My items",
"use": "Use",
"unUse": "Unequip",
@ -650,5 +662,17 @@
"lightMode": "Light Mode",
"systemDefault": "System Default",
"pleaseGetOnTheMicFirst": "Please get on the mic first.",
"roomMusicAddMusic": "Add Music",
"roomMusicSelectAll": "Select all",
"roomMusicAddedCount": "Added {1} songs",
"roomMusicEmpty": "No music",
"roomMusicScanFromPhone": "Scan from phone",
"roomMusicTotalSongs": "Total: {1} songs",
"roomMusicNotFound": "No music found",
"roomMusicDeleteConfirm": "Delete this music?",
"roomMusicDeleted": "Music deleted",
"roomMusicListMode": "Play in order",
"roomMusicShuffleMode": "Shuffle play",
"roomMusicSingleMode": "Single loop",
"duration2": "Duration:{1}"
}

View File

@ -30,7 +30,7 @@
"background": "Arka Plan",
"roomPassword": "Oda Şifresi",
"noHistoricalRecordsAvailable": "Geçmiş kayıt bulunamadı.",
"inviteNewUsersToEarnCoins": "Yeni kullanıcıları davet ederek coin kazanın",
"inviteNewUsersToEarnCoins": "Yeni kullanıcıları davet ederek coin kazanın",
"crateMyRoom": "KENDİ ODANIZI OLUŞTURUN.",
"event": "Etkinlik",
"clearCacheSuccessfully": "Önbellek başarıyla temizlendi",
@ -123,6 +123,7 @@
"ownerSendTheRedEnvelope": "Sahip ödül jettonlarını gönderdi.",
"rewardCoins": "Ödül jettonları:{1} jetton",
"signInRewardReceived": "Giriş başarılı. Ödül: {1}",
"registerRewardReceived": "Kayıt ödülü alındı: {1}",
"lastWeekProgress": "Geçen Haftanın İlerlemesi",
"redEnvelopeTips2": "*Kırmızı zarf zaman sınırı içinde talep edilmezse, kalan jettonlar gönderen kullanıcıya iade edilecektir.",
"goToRecharge": "Yüklemeye Git",
@ -237,8 +238,10 @@
"win": "Kazanan",
"dice": "Zar",
"rps": "Taş-Kağıt-Makas",
"operationFail": "İşlem başarısız oldu.",
"likedYourComment": "Yorumunuzu beğendi.",
"operationFail": "İşlem başarısız oldu.",
"enterRoomFailedRetry": "Odaya girilemedi. Lütfen tekrar deneyin.",
"voiceConnectionFailedRetry": "Ses bağlantısı başarısız oldu. Lütfen tekrar deneyin.",
"likedYourComment": "Yorumunuzu beğendi.",
"doYouWantToKeepTheDraft": "Taslağı saklamak ister misiniz?",
"operationsAreTooFrequent": "İşlemler çok sık",
"luckNumber": "Şans Numarası",
@ -432,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",
@ -576,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",
@ -620,7 +630,9 @@
"custom": "Özel",
"customBackground": "Özel Arka Plan",
"example": "Örnek",
"myItems": "Eşyalarım",
"endPreview": "Önizlemeyi Bitir",
"selectAgain": "Tekrar seç",
"myItems": "Eşyalarım",
"use": "Kullan",
"unUse": "Kullanmama",
"renewal": "Yenileme",
@ -649,6 +661,18 @@
"darkMode": "Karanlık Mod",
"lightMode": "Açık Mod",
"systemDefault": "Sistem Varsayılanı",
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"duration2": "Süre:{1}"
}
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"roomMusicAddMusic": "Müzik Ekle",
"roomMusicSelectAll": "Tümünü seç",
"roomMusicAddedCount": "{1} şarkı eklendi",
"roomMusicEmpty": "Müzik yok",
"roomMusicScanFromPhone": "Telefondan tara",
"roomMusicTotalSongs": "Toplam: {1} şarkı",
"roomMusicNotFound": "Müzik bulunamadı",
"roomMusicDeleteConfirm": "Bu müziği silmek istiyor musunuz?",
"roomMusicDeleted": "Müzik silindi",
"roomMusicListMode": "Sırayla çal",
"roomMusicShuffleMode": "Karışık çal",
"roomMusicSingleMode": "Tek şarkı döngüsü",
"duration2": "Süre:{1}"
}

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

@ -209,6 +209,9 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- on_audio_query_ios (0.0.1):
- Flutter
- SwiftyBeaver
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@ -231,6 +234,7 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyBeaver (1.9.5)
- tancent_vap (0.0.1):
- Flutter
- tencent_cloud_chat_sdk (8.0.0):
@ -269,6 +273,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- on_audio_query_ios (from `.symlinks/plugins/on_audio_query_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@ -314,6 +319,7 @@ SPEC REPOS:
- RecaptchaInterop
- SDWebImage
- SDWebImageWebPCoder
- SwiftyBeaver
- TOCropViewController
- TXIMSDK_Plus_iOS_XCFramework
@ -348,6 +354,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
on_audio_query_ios:
:path: ".symlinks/plugins/on_audio_query_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@ -414,6 +422,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@ -424,6 +433,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
tancent_vap: fa8ad93814a9f950514a7074c662d2c937084c68
tencent_cloud_chat_sdk: 55e5fffe20f6b7937a26a674ccccb639563a9790
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863

View File

@ -502,7 +502,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = S9X2AJ2US9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -511,7 +511,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -693,7 +693,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -702,7 +702,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -731,7 +731,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -1,13 +1,91 @@
import Flutter
import UIKit
import Flutter
import Security
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
registerDurableAuthStorageChannel()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func registerDurableAuthStorageChannel() {
guard let controller = window?.rootViewController as? FlutterViewController else {
return
}
let channel = FlutterMethodChannel(
name: "com.org.yumiparty/durable_auth_storage",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
guard
let self,
let args = call.arguments as? [String: Any],
let key = args["key"] as? String
else {
result(FlutterError(code: "bad_args", message: "Missing key", details: nil))
return
}
switch call.method {
case "write":
guard let value = args["value"] as? String else {
result(FlutterError(code: "bad_args", message: "Missing value", details: nil))
return
}
self.writeKeychainValue(value, key: key)
result(nil)
case "read":
result(self.readKeychainValue(key: key))
case "delete":
self.deleteKeychainValue(key: key)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func keychainQuery(key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.org.yumiparty.durable_auth_storage",
kSecAttrAccount as String: key
]
}
private func writeKeychainValue(_ value: String, key: String) {
deleteKeychainValue(key: key)
guard let data = value.data(using: .utf8) else {
return
}
var query = keychainQuery(key: key)
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(query as CFDictionary, nil)
}
private func readKeychainValue(key: String) -> String? {
var query = keychainQuery(key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard
status == errSecSuccess,
let data = result as? Data
else {
return nil
}
return String(data: data, encoding: .utf8)
}
private func deleteKeychainValue(key: String) {
SecItemDelete(keychainQuery(key: key) as CFDictionary)
}
}

View File

@ -9,24 +9,6 @@ import 'package:yumi/app/routes/sc_lk_application.dart';
///
const Duration kAndroidTransitionDuration = Duration(milliseconds: 375);
///
const Duration kOpenRoomTransitionDuration = Duration(milliseconds: 325);
///
SlideTransition kOpenRoomTransitionBuilder( BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: const Offset(0.0, 0.0),
).animate(CurvedAnimation(parent: animation, curve: Curves.ease)),
child: child,
);
}
/// fluro的路由跳转工具类
class SCNavigatorUtils {
static bool inChatPage = false;

View File

@ -7,6 +7,8 @@ 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';
import 'package:yumi/modules/room/voice_room_route.dart';
@ -15,17 +17,25 @@ import 'package:yumi/app/routes/sc_404_page.dart';
class SCRoutes {
static String home = '/';
static List<SCIRouterProvider> _listRouter = [];
static final List<SCIRouterProvider> _listRouter = [];
static void configureRoutes(fluro.FluroRouter router) {
///
router.notFoundHandler = fluro.Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
debugPrint('未找到目标页');
return SCWidgetNotFound();
});
router.notFoundHandler = fluro.Handler(
handlerFunc: (BuildContext? context, Map<String, List<String>> params) {
debugPrint('未找到目标页');
return SCWidgetNotFound();
},
);
router.define(home,
handler: fluro.Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> params) => SCIndexPage()));
router.define(
home,
handler: fluro.Handler(
handlerFunc:
(BuildContext? context, Map<String, List<String>> params) =>
SCIndexPage(),
),
);
_listRouter.clear();
@ -37,6 +47,8 @@ class SCRoutes {
_listRouter.add(SettingsRoute());
_listRouter.add(VoiceRoomRoute());
_listRouter.add(SCMainRoute());
_listRouter.add(TaskRoute());
_listRouter.add(VipRoute());
_listRouter.add(WalletRoute());
_listRouter.add(StoreRoute());
_listRouter.add(SCChatRouter());

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');
@ -239,6 +241,20 @@ class SCAppLocalizations {
String get operationFail => translate('operationFail');
String get enterRoomFailedRetry {
final value = translate('enterRoomFailedRetry');
return value == 'enterRoomFailedRetry'
? 'Failed to enter the room. Please try again.'
: value;
}
String get voiceConnectionFailedRetry {
final value = translate('voiceConnectionFailedRetry');
return value == 'voiceConnectionFailedRetry'
? 'Voice connection failed. Please try again.'
: value;
}
String get forMoreRewardsPleaseCheckTheTaskCenter =>
translate('forMoreRewardsPleaseCheckTheTaskCenter');
@ -772,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');
@ -1326,6 +1355,10 @@ class SCAppLocalizations {
String get example => translate('example');
String get endPreview => translate('endPreview');
String get selectAgain => translate('selectAgain');
String get touristsCannotSendMessages =>
translate('touristsCannotSendMessages');
@ -1594,6 +1627,9 @@ class SCAppLocalizations {
String signInRewardReceived(String name) =>
translate('signInRewardReceived').replaceAll('{1}', name);
String registerRewardReceived(String name) =>
translate('registerRewardReceived').replaceAll('{1}', name);
String deleteAccount2(String name) =>
translate('deleteAccount2').replaceAll('{1}', name);

View File

@ -27,6 +27,7 @@ import 'app/routes/sc_routes.dart';
import 'app/routes/sc_lk_application.dart';
import 'shared/tools/sc_deep_link_handler.dart';
import 'shared/tools/sc_deviceId_utils.dart';
import 'shared/data_sources/sources/local/user_manager.dart';
import 'modules/splash/splash_page.dart';
import 'services/general/sc_app_general_manager.dart';
import 'services/payment/apple_payment_manager.dart';
@ -35,6 +36,7 @@ import 'services/gift/gift_animation_manager.dart';
import 'services/gift/gift_system_manager.dart';
import 'services/payment/google_payment_manager.dart';
import 'services/localization/localization_manager.dart';
import 'services/music/room_music_manager.dart';
import 'services/room/rc_room_manager.dart';
import 'services/audio/rtc_manager.dart';
import 'services/audio/rtm_manager.dart';
@ -93,6 +95,7 @@ Future<void> _prepareAppShell() async {
SCKeybordUtil.init();
// 使
await _initStore();
await AccountStorage().restoreDurableSessionIfNeeded();
await SCDeviceIdUtils.initDeviceinfo();
_configureImageCache();
}
@ -235,6 +238,10 @@ class RootAppWithProviders extends StatelessWidget {
lazy: true,
create: (context) => RealTimeMessagingManager(),
),
ChangeNotifierProvider<RoomMusicManager>(
lazy: true,
create: (context) => RoomMusicManager(),
),
// Provider -
ChangeNotifierProvider<SocialChatRoomManager>(

View File

@ -23,6 +23,7 @@ import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_install_referrer_utils.dart';
import '../../../shared/business_logic/usecases/sc_custom_filtering_textinput_formatter.dart';
import '../../country/country_route.dart';
@ -53,6 +54,7 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
);
Provider.of<SCAppGeneralManager>(context, listen: false).selectCountryInfo =
null;
_fillInvitationCodeFromInstallReferrer();
}
@override
@ -529,6 +531,22 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
return eighteenYearsAgo;
}
Future<void> _fillInvitationCodeFromInstallReferrer() async {
final inviteCode = await SCInstallReferrerUtils.getInviteCode();
if (!mounted ||
inviteCode.isEmpty ||
invitationCodeController.text.trim().isNotEmpty) {
return;
}
final normalizedInviteCode =
inviteCode.length > 10 ? inviteCode.substring(0, 10) : inviteCode;
invitationCodeController.value = TextEditingValue(
text: normalizedInviteCode,
selection: TextSelection.collapsed(offset: normalizedInviteCode.length),
);
}
String? _preferNonEmpty(String? primary, String? fallback) {
if (primary != null && primary.isNotEmpty) {
return primary;
@ -604,13 +622,12 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
context,
listen: false,
).uid;
// TODO(register): When the backend registration API supports invitation
// codes, pass `invitationCodeController.text.trim()` here as an optional
// argument. Empty string should remain allowed.
final invitationCode = invitationCodeController.text.trim();
SocialChatLoginRes user = await SCAccountRepository().regist(
authType,
idToken,
userProvider!.editUser!,
invitationCode: invitationCode,
);
final submittedProfile = userProvider?.editUser;
if (submittedProfile != null) {

View File

@ -584,6 +584,13 @@ class _MessageItem extends StatelessWidget {
) {
_showMsgItemMenu(ct, content);
}, context);
} else if (_isRegisterRewardGrantedType(type)) {
return SCSystemMessageUtils.buildRegisterRewardGrantedMessage(data, (
ct,
content,
) {
_showMsgItemMenu(ct, content);
}, context);
} else if (type == SCSysytemMessageType.USER_SPECIAL_AUDIT_RESULTS.name) {
if (data["content"] != null) {
return Builder(
@ -883,6 +890,11 @@ class _MessageItem extends StatelessWidget {
);
}
bool _isRegisterRewardGrantedType(String type) {
return type.trim().toUpperCase().replaceAll(' ', '_') ==
SCSysytemMessageType.REGISTER_REWARD_GRANTED.name;
}
void _showMsgItemMenu(BuildContext ct, String content) {
SmartDialog.showAttach(
tag: "showMsgItemMenu",

View File

@ -68,7 +68,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
"LUCKY_GIFT",
"CP",
"MAGIC",
"CUSTOMIZED",
"NSCIONAL_FLAG",
];
@ -212,7 +211,10 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
void initState() {
super.initState();
rtcProvider = Provider.of<RtcProvider>(context, listen: false);
Provider.of<SCAppGeneralManager>(context, listen: false).giftList();
Provider.of<SCAppGeneralManager>(
context,
listen: false,
).giftList(includeCustomized: false);
Provider.of<SCAppGeneralManager>(context, listen: false).giftActivityList();
// Provider.of<GeneralProvider>(context, listen: false).giftBackpack();
Provider.of<SocialChatUserProfileManager>(context, listen: false).balance();
@ -263,6 +265,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
final availableTypes =
ref.giftByTab.entries
.where((entry) => entry.value.isNotEmpty)
.where((entry) => entry.key != "CUSTOMIZED")
.map((entry) => entry.key)
.toList();
final orderedTypes = <String>[];

View File

@ -14,8 +14,6 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/config/business_logic_strategy.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import '../../shared/data_sources/models/enum/sc_gift_type.dart';
enum _GiftGridPageStatus { idle, loading, ready }
const Duration _kGiftPageSkeletonMinDuration = Duration(milliseconds: 420);
@ -405,113 +403,63 @@ class _GiftTabPageState extends State<GiftTabPage>
},
)
: GestureDetector(
child: Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w),
color: Colors.white10,
border: Border.all(
color:
checkedIndex ==
ref.giftByTab[widget.type]!.indexOf(gift)
? SocialChatTheme.primaryLight
: Colors.transparent,
width: 1.w,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w),
color: Colors.white10,
border: Border.all(
color:
checkedIndex == ref.giftByTab[widget.type]!.indexOf(gift)
? SocialChatTheme.primaryLight
: Colors.transparent,
width: 1.w,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
netImage(
url: gift.giftPhoto ?? "",
fit: BoxFit.cover,
width: 48.w,
height: 48.w,
loadingWidget: _buildGiftCoverLoading(),
errorWidget: _buildGiftCoverNoData(),
),
SizedBox(height: 5.w),
Container(
height: 23.w,
margin: EdgeInsets.symmetric(horizontal: 3.w),
alignment: Alignment.center,
child: text(
gift.giftName ?? "",
maxLines: 2,
fontSize: 10.sp,
letterSpacing: 0.1,
lineHeight: 1,
textAlign: TextAlign.center,
fontWeight: FontWeight.w600,
textColor: widget.isDark ? Colors.white : Colors.black,
),
),
child: Column(
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
netImage(
url: gift.giftPhoto ?? "",
fit: BoxFit.cover,
width: 48.w,
height: 48.w,
loadingWidget: _buildGiftCoverLoading(),
errorWidget: _buildGiftCoverNoData(),
Image.asset(
_strategy.getGiftPageGoldCoinIcon(),
width: 14.w,
height: 14.w,
),
SizedBox(height: 5.w),
Container(
height: 23.w,
margin: EdgeInsets.symmetric(horizontal: 3.w),
alignment: Alignment.center,
child: text(
gift.giftName ?? "",
maxLines: 2,
fontSize: 10.sp,
letterSpacing: 0.1,
lineHeight: 1,
textAlign: TextAlign.center,
fontWeight: FontWeight.w600,
textColor: widget.isDark ? Colors.white : Colors.black,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
_strategy.getGiftPageGoldCoinIcon(),
width: 14.w,
height: 14.w,
),
SizedBox(width: 3.w),
text(
"${gift.giftCandy}",
fontSize: 10.sp,
textColor:
widget.isDark ? Colors.white : Colors.black,
),
],
SizedBox(width: 3.w),
text(
"${gift.giftCandy}",
fontSize: 10.sp,
textColor: widget.isDark ? Colors.white : Colors.black,
),
],
),
),
Positioned(
right: 3.w,
child: Column(
spacing: 3.w,
children: [
SizedBox(height: 3.w),
// widget
if (scGiftHasFullScreenEffect(gift.special))
Image.asset(
_strategy.getGiftPageGiftEffectIcon(
SCGiftType.ANIMSCION.name,
),
width: 16.w,
height: 16.w,
fit: BoxFit.fill,
),
if (gift.special!.contains(SCGiftType.MUSIC.name))
Image.asset(
_strategy.getGiftPageGiftMusicIcon(
SCGiftType.MUSIC.name,
),
width: 16.w,
height: 16.w,
fit: BoxFit.fill,
),
if (gift.giftTab == (SCGiftType.LUCKY_GIFT.name))
Image.asset(
_strategy.getGiftPageGiftLuckIcon(
SCGiftType.LUCKY_GIFT.name,
),
width: 16.w,
height: 16.w,
fit: BoxFit.fill,
),
if (gift.giftTab == (SCGiftType.CP.name))
Image.asset(
_strategy.getGiftPageGiftCpIcon(SCGiftType.CP.name),
width: 16.w,
height: 16.w,
fit: BoxFit.fill,
),
],
),
),
],
],
),
),
onTap: () {
setState(() {

View File

@ -25,13 +25,17 @@ class SCRoomFollowPage extends SCPageList {
class _RoomFollowPageState
extends SCPageListState<FollowRoomRes, SCRoomFollowPage>
with WidgetsBindingObserver {
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId;
Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
@ -71,11 +75,12 @@ class _RoomFollowPageState
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
!_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer(
builder: _buildFollowSkeletonContent,
)
@ -264,10 +269,11 @@ class _RoomFollowPageState
),
),
onTap: () {
Provider.of<RtcProvider>(
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
listen: false,
).joinVoiceRoomSession(context, roomRes.roomProfile?.id ?? "");
roomRes.roomProfile?.id ?? "",
previewData: RoomEntryPreviewData.fromFollowRoom(roomRes),
);
},
);
}
@ -322,8 +328,14 @@ class _RoomFollowPageState
if (roomList.isNotEmpty) {
lastId = roomList.last.id;
}
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList);
} catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) {
onErr();
}
@ -380,9 +392,8 @@ class _RoomFollowPageState
}
bool changed = false;
final replaceCount = latestRooms.length < items.length
? latestRooms.length
: items.length;
final replaceCount =
latestRooms.length < items.length ? latestRooms.length : items.length;
for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i];

View File

@ -25,13 +25,17 @@ class SCRoomHistoryPage extends SCPageList {
class _SCRoomHistoryPageState
extends SCPageListState<FollowRoomRes, SCRoomHistoryPage>
with WidgetsBindingObserver {
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
String? lastId;
Timer? _roomListRefreshTimer;
bool _isSilentRefreshingRooms = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialLoad = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
@ -71,11 +75,12 @@ class _SCRoomHistoryPageState
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
!_hasCompletedInitialLoad && items.isEmpty && isLoading
? const SCHomeSkeletonShimmer(
builder: _buildHistorySkeletonContent,
)
@ -264,10 +269,11 @@ class _SCRoomHistoryPageState
),
),
onTap: () {
Provider.of<RtcProvider>(
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
listen: false,
).joinVoiceRoomSession(context, roomRes.roomProfile?.id ?? "");
roomRes.roomProfile?.id ?? "",
previewData: RoomEntryPreviewData.fromFollowRoom(roomRes),
);
},
);
}
@ -322,8 +328,14 @@ class _SCRoomHistoryPageState
if (roomList.isNotEmpty) {
lastId = roomList.last.id;
}
if (page == 1) {
_hasCompletedInitialLoad = true;
}
onSuccess(roomList);
} catch (e) {
if (page == 1) {
_hasCompletedInitialLoad = true;
}
if (onErr != null) {
onErr();
}
@ -380,9 +392,8 @@ class _SCRoomHistoryPageState
}
bool changed = false;
final replaceCount = latestRooms.length < items.length
? latestRooms.length
: items.length;
final replaceCount =
latestRooms.length < items.length ? latestRooms.length : items.length;
for (int i = 0; i < replaceCount; i++) {
if (!_sameRoom(items[i], latestRooms[i])) {
items[i] = latestRooms[i];

View File

@ -22,11 +22,14 @@ class SCHomeMinePage extends StatefulWidget {
}
class _HomeMinePageState extends State<SCHomeMinePage>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late TabController _tabController;
final List<Widget> _pages = const [SCRoomHistoryPage(), SCRoomFollowPage()];
final List<Widget> _tabs = [];
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
@ -45,6 +48,7 @@ class _HomeMinePageState extends State<SCHomeMinePage>
@override
Widget build(BuildContext context) {
super.build(context);
_tabs.clear();
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.recent));
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.followed));
@ -234,16 +238,27 @@ class _HomeMinePageState extends State<SCHomeMinePage>
],
),
onTap: () {
String roomId =
Provider.of<SocialChatRoomManager>(
context,
listen: false,
).myRoom?.id ??
"";
final roomManager = Provider.of<SocialChatRoomManager>(
context,
listen: false,
);
final myRoom = roomManager.myRoom;
final roomId = myRoom?.id ?? "";
Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
).joinVoiceRoomSession(context, roomId);
).joinVoiceRoomSession(
context,
roomId,
previewData:
myRoom == null
? null
: RoomEntryPreviewData.fromMyRoom(
myRoom,
ownerProfile:
AccountStorage().getCurrentUser()?.userProfile,
),
);
},
)
: GestureDetector(

View File

@ -15,6 +15,7 @@ import '../../../../shared/business_logic/models/res/follow_room_res.dart';
import '../../../../shared/business_logic/models/res/room_res.dart';
import '../../../../services/audio/rtc_manager.dart';
import '../../../../services/general/sc_app_general_manager.dart';
import '../../../../services/home/home_room_preload_manager.dart';
import '../../../../ui_kit/components/sc_compontent.dart';
import '../../../../ui_kit/components/text/sc_text.dart';
import '../../../../ui_kit/widgets/room/room_live_audio_indicator.dart';
@ -36,7 +37,10 @@ class SCHomePartyPage extends StatefulWidget {
}
class _HomePartyPageState extends State<SCHomePartyPage>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
with
SingleTickerProviderStateMixin,
WidgetsBindingObserver,
AutomaticKeepAliveClientMixin {
static const Duration _roomListRefreshInterval = Duration(seconds: 15);
static const Duration _roomCounterHydrationMinGap = Duration(seconds: 20);
static const int _roomCounterHydrationLimit = 6;
@ -61,8 +65,12 @@ class _HomePartyPageState extends State<SCHomePartyPage>
bool _isSilentRefreshingRooms = false;
bool _isHydratingVisibleRoomCounters = false;
bool _wasTickerModeEnabled = false;
bool _hasCompletedInitialRoomLoad = false;
DateTime? _lastRoomCounterHydrationAt;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
@ -101,9 +109,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
_wasTickerModeEnabled = isTickerModeEnabled;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: Stack(
@ -114,7 +123,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
enablePullUp: false,
controller: _refreshController,
onRefresh: () {
loadData();
loadData(forceRefresh: true);
},
onLoading: () {},
child: SingleChildScrollView(
@ -601,10 +610,10 @@ class _HomePartyPageState extends State<SCHomePartyPage>
);
}
Widget _buildRoomsSection() {
if (isLoading && rooms.isEmpty) {
return _buildRoomGridSkeleton();
}
Widget _buildRoomsSection() {
if (!_hasCompletedInitialRoomLoad && isLoading && rooms.isEmpty) {
return _buildRoomGridSkeleton();
}
if (rooms.isEmpty) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
@ -960,23 +969,24 @@ class _HomePartyPageState extends State<SCHomePartyPage>
);
}
loadData() {
final generalManager = Provider.of<SCAppGeneralManager>(
context,
listen: false,
);
setState(() {
isLoading = true;
_isBannerLoading =
generalManager.exploreBanners.isEmpty &&
generalManager.homeBanners.isEmpty;
_isLeaderboardLoading = generalManager.appLeaderResult == null;
});
SCChatRoomRepository()
.discovery(allRegion: true)
loadData({bool forceRefresh = false}) {
final generalManager = Provider.of<SCAppGeneralManager>(
context,
listen: false,
);
setState(() {
isLoading = true;
_isBannerLoading =
generalManager.exploreBanners.isEmpty &&
generalManager.homeBanners.isEmpty;
_isLeaderboardLoading = generalManager.appLeaderResult == null;
});
SCHomeRoomPreloadManager.instance
.loadPartyRooms(forceRefresh: forceRefresh)
.then((values) {
rooms = _mergeLatestRoomsWithCurrentCounters(values);
isLoading = false;
_hasCompletedInitialRoomLoad = true;
_refreshController.refreshCompleted();
_refreshController.loadComplete();
if (mounted) {
@ -986,10 +996,11 @@ class _HomePartyPageState extends State<SCHomePartyPage>
})
.catchError((e) {
_refreshController.loadNoData();
_refreshController.refreshCompleted();
isLoading = false;
if (mounted) setState(() {});
});
_refreshController.refreshCompleted();
isLoading = false;
_hasCompletedInitialRoomLoad = true;
if (mounted) setState(() {});
});
generalManager.loadMainBanner().whenComplete(() {
if (!mounted) {
return;
@ -1010,7 +1021,9 @@ class _HomePartyPageState extends State<SCHomePartyPage>
_buildItem(SocialChatRoomRes res, int index) {
final rankBorderAsset =
index < _topRankBorderAssets.length ? _topRankBorderAssets[index] : null;
index < _topRankBorderAssets.length
? _topRankBorderAssets[index]
: null;
final rankBorderOverflow = 12.w;
final rankInfoHorizontalInset = rankBorderAsset != null ? 10.w : 0.w;
final rankInfoBottomInset = rankBorderAsset != null ? 9.w : 0.w;
@ -1164,11 +1177,12 @@ class _HomePartyPageState extends State<SCHomePartyPage>
),
),
onTap: () {
Provider.of<RtcProvider>(
context,
listen: false,
).joinVoiceRoomSession(context, res.id ?? "");
},
);
}
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
res.id ?? "",
previewData: RoomEntryPreviewData.fromSocialChatRoom(res),
);
},
);
}
}

View File

@ -39,6 +39,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
int _currentIndex = 0;
final List<Widget> _pages = [];
final Set<int> _builtPageIndexes = <int>{0};
final List<BottomNavigationBarItem> _bottomItems = [];
SCAppGeneralManager? generalProvider;
Locale? _lastLocale;
@ -61,6 +62,14 @@ class _SCIndexPageState extends State<SCIndexPage> {
context,
listen: false,
).initializeRealTimeCommunicationManager(context);
unawaited(
Provider.of<RtcProvider>(
context,
listen: false,
).prewarmRtcEngine().catchError((error, stackTrace) {
debugPrint('[Agora] prewarm on index failed: $error');
}),
);
Provider.of<RtmProvider>(context, listen: false).init(context);
Provider.of<SocialChatUserProfileManager>(
context,
@ -122,7 +131,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
body: _pages[_currentIndex],
body: _buildCachedBody(),
bottomNavigationBar: Container(
height: 85.w,
decoration: BoxDecoration(
@ -159,7 +168,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
showSelectedLabels: true,
items: _bottomItems,
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
onTap: _switchBottomTab,
),
),
),
@ -197,6 +206,31 @@ class _SCIndexPageState extends State<SCIndexPage> {
_pages.add(MePage2());
}
Widget _buildCachedBody() {
return IndexedStack(
index: _currentIndex,
children: List<Widget>.generate(_pages.length, (index) {
if (!_builtPageIndexes.contains(index)) {
return const SizedBox.shrink();
}
return TickerMode(
enabled: index == _currentIndex,
child: _pages[index],
);
}),
);
}
void _switchBottomTab(int index) {
if (index == _currentIndex) {
return;
}
setState(() {
_currentIndex = index;
_builtPageIndexes.add(index);
});
}
void _rebuildBottomItemsIfNeeded() {
final locale = Localizations.localeOf(context);
if (_lastLocale == locale && _bottomItems.isNotEmpty) {

View File

@ -0,0 +1,434 @@
import 'dart:io';
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/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
class RoomBackgroundPreviewPage extends StatelessWidget {
const RoomBackgroundPreviewPage({super.key, this.backgroundPath});
final String? backgroundPath;
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final localizations = SCAppLocalizations.of(context)!;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _PreviewBackground(path: backgroundPath)),
Positioned.fill(child: _PreviewRoomLayer()),
Positioned(
left: 24.w,
right: 24.w,
bottom: 148.w + bottomPadding,
child: _PreviewPrimaryButton(
text: localizations.endPreview,
onTap: () => SCNavigatorUtils.goBackWithParams(context, true),
),
),
Positioned(
left: 24.w,
right: 24.w,
bottom: 90.w + bottomPadding,
child: _PreviewSecondaryButton(
text: localizations.selectAgain,
onTap: () => SCNavigatorUtils.goBackWithParams(context, false),
),
),
],
),
);
}
}
class _PreviewBackground extends StatelessWidget {
const _PreviewBackground({this.path});
final String? path;
@override
Widget build(BuildContext context) {
final value = (path ?? "").trim();
if (value.startsWith("http://") || value.startsWith("https://")) {
return Image.network(
value,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
if (value.startsWith("assets/") || value.startsWith("sc_images/")) {
return Image.asset(
value,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
if (value.isNotEmpty) {
final file = File(value);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _DefaultPreviewBackground(),
);
}
}
return const _DefaultPreviewBackground();
}
}
class _DefaultPreviewBackground extends StatelessWidget {
const _DefaultPreviewBackground();
@override
Widget build(BuildContext context) {
return Image.asset(
SCGlobalConfig.businessLogicStrategy.getVoiceRoomDefaultBackgroundImage(),
fit: BoxFit.cover,
);
}
}
class _PreviewRoomLayer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: ScreenUtil().setWidth(42)),
IgnorePointer(child: RoomHeadWidget()),
SizedBox(height: 5.w),
const IgnorePointer(child: RoomOnlineUserWidget()),
const IgnorePointer(child: _PreviewSeat10()),
Expanded(child: _PreviewChatArea()),
const IgnorePointer(child: RoomBottomWidget()),
],
);
}
}
class _PreviewSeat10 extends StatelessWidget {
const _PreviewSeat10();
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: List.generate(
5,
(index) => Expanded(
child: _PreviewSeatItem(index: index, locked: index == 1),
),
),
),
Row(
children: List.generate(5, (index) {
final seatIndex = index + 5;
return Expanded(
child: _PreviewSeatItem(
index: seatIndex,
locked: seatIndex == 6,
),
);
}),
),
],
),
);
}
}
class _PreviewSeatItem extends StatelessWidget {
const _PreviewSeatItem({required this.index, required this.locked});
final int index;
final bool locked;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 55.w,
height: 55.w,
child: Image.asset(
locked
? "sc_images/room/sc_icon_seat_lock.png"
: "sc_images/room/sc_icon_seat_open.png",
width: 52.w,
height: 52.w,
),
),
SizedBox(
height: 16.w,
child: Center(
child: Text(
"NO.${index + 1}",
style: TextStyle(
color: Colors.white,
fontSize: 10.sp,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
}
class _PreviewChatArea extends StatefulWidget {
@override
State<_PreviewChatArea> createState() => _PreviewChatAreaState();
}
class _PreviewChatAreaState extends State<_PreviewChatArea> {
int _selectedTabIndex = 0;
void _selectTab(int index) {
if (_selectedTabIndex == index) {
return;
}
setState(() {
_selectedTabIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: _PreviewChatTabs(
selectedIndex: _selectedTabIndex,
onTap: _selectTab,
),
),
Positioned(
left: 24.w,
right: 24.w,
bottom: 166.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_PreviewChatBubble(text: "Hello My Friends!"),
SizedBox(height: 12.w),
_PreviewGiftBubble(),
],
),
),
],
);
}
}
class _PreviewChatTabs extends StatelessWidget {
const _PreviewChatTabs({required this.selectedIndex, required this.onTap});
final int selectedIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32.w,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 8.w),
itemCount: 3,
itemBuilder: (context, index) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(index),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Center(
child: Image.asset(
_assetFor(index, selectedIndex == index),
height: 26.w,
fit: BoxFit.contain,
),
),
),
);
},
),
);
}
String _assetFor(int index, bool selected) {
switch (index) {
case 0:
return selected
? "sc_images/room/sc_icon_room_chat_tab_all_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_all_unselected.png";
case 1:
return selected
? "sc_images/room/sc_icon_room_chat_tab_chat_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_chat_unselected.png";
case 2:
default:
return selected
? "sc_images/room/sc_icon_room_chat_tab_gift_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_gift_unselected.png";
}
}
}
class _PreviewChatBubble extends StatelessWidget {
const _PreviewChatBubble({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 276.w),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(10.w),
),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _PreviewGiftBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 300.w),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.44),
borderRadius: BorderRadius.circular(10.w),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
"Send To User Name2",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 10.w),
ClipOval(
child: Image.asset(
"sc_images/general/sc_icon_avar_defalt.png",
width: 32.w,
height: 32.w,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.w),
Text(
"*1",
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _PreviewPrimaryButton extends StatelessWidget {
const _PreviewPrimaryButton({required this.text, required this.onTap});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
gradient: LinearGradient(
colors: [SocialChatTheme.primaryLight, const Color(0xff8BF2D0)],
),
),
child: Text(
text,
style: TextStyle(
color: const Color(0xff0B2823),
fontSize: 18.sp,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _PreviewSecondaryButton extends StatelessWidget {
const _PreviewSecondaryButton({required this.text, required this.onTap});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xff06372F).withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(999.w),
border: Border.all(color: SocialChatTheme.primaryLight, width: 1.w),
),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w700,
),
),
),
);
}
}

View File

@ -82,6 +82,17 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
});
}
Future<void> _previewItem(List<_RoomBackgroundItem> items, int index) async {
final shouldUse = await VoiceRoomRoute.openRoomBackgroundPreview<bool>(
context,
backgroundPath: items[index].localImagePath,
);
if (!mounted || shouldUse != true) {
return;
}
_selectItem(items, index);
}
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
@ -114,12 +125,12 @@ class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
_RoomBackgroundGrid(
items: _officialItems,
onTap:
(index) => _selectItem(_officialItems, index),
(index) => _previewItem(_officialItems, index),
bottomPadding: 24.w,
),
_RoomBackgroundGrid(
items: _mineItems,
onTap: (index) => _selectItem(_mineItems, index),
onTap: (index) => _previewItem(_mineItems, index),
bottomPadding: 100.w,
),
],

View File

@ -591,6 +591,24 @@ class _RoomEditPageState extends State<RoomEditPage> {
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ??
"",
clearRoomData: true,
previewData: RoomEntryPreviewData(
roomId:
mergedRoomInfo.id ??
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id,
roomAccount:
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomAccount,
userId: rtcProvider?.currenRoom?.roomProfile?.roomProfile?.userId,
roomCover: mergedRoomInfo.roomCover,
roomName: mergedRoomInfo.roomName ?? submittedRoomName,
roomDesc: mergedRoomInfo.roomDesc ?? submittedRoomDesc,
roomSetting: rtcProvider?.currenRoom?.roomProfile?.roomSetting,
ownerProfile: rtcProvider?.currenRoom?.roomProfile?.userProfile,
roomAdminCount:
rtcProvider?.currenRoom?.roomProfile?.roomCounter?.adminCount,
roomMemberCount:
rtcProvider?.currenRoom?.roomProfile?.roomCounter?.memberCount,
entrantsRole: rtcProvider?.currenRoom?.entrants?.roles,
),
);
} else {
SCLoadingManager.hide();

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/modules/room/music/room_music_select_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
class RoomMusicFolderPage extends StatelessWidget {
const RoomMusicFolderPage({super.key, required this.folders});
final List<SCMusicFolderMode> folders;
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
appBar: AppBar(
backgroundColor: const Color(0xFF071F1B),
elevation: 0,
title: Text(
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
),
),
body: ListView.separated(
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 20.w),
itemCount: folders.length,
separatorBuilder:
(_, __) => Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 1,
),
itemBuilder: (context, index) {
final folder = folders[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final changed = await Navigator.of(
context,
).push<bool>(_musicRoute(RoomMusicSelectPage(folder: folder)));
if (changed == true && context.mounted) {
Navigator.pop(context, true);
}
},
child: SizedBox(
height: 82.w,
child: Row(
children: [
Image.asset(
"sc_images/room/sc_music_material_folder.png",
width: 44.w,
height: 44.w,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
folder.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
if (folder.addedCount > 0)
Text(
RoomMusicTexts.t(
context,
"roomMusicAddedCount",
"已添加 ${folder.addedCount}",
).replaceAll(
"{1}",
folder.addedCount.toString(),
),
style: TextStyle(
color: const Color(0xFF33E6A2),
fontSize: 11.sp,
),
),
],
),
SizedBox(height: 6.w),
Text(
"${folder.musicCount} Music | ${folder.path}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
SizedBox(width: 8.w),
Icon(
Icons.chevron_right,
color: Colors.white.withValues(alpha: 0.55),
),
],
),
),
);
},
),
),
);
}
}
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}

View File

@ -0,0 +1,629 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_folder_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/local_music_scanner.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_player_bar.dart';
class RoomMusicPage extends StatefulWidget {
const RoomMusicPage({super.key});
@override
State<RoomMusicPage> createState() => _RoomMusicPageState();
}
class _RoomMusicPageState extends State<RoomMusicPage> {
final TextEditingController _searchController = TextEditingController();
final LocalMusicScanner _scanner = LocalMusicScanner();
bool _isScanning = false;
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final manager = context.read<RoomMusicManager>();
manager.attachRtcProvider(context.read<RtcProvider>());
manager.reload();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
body: SafeArea(
child: Stack(
children: [
Column(
children: [
_buildHeader(context),
Expanded(
child: Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final songs = _filteredSongs(manager.playlist);
if (manager.playlist.isEmpty) {
return _buildEmptyState(context);
}
return Column(
children: [
_buildSearchField(),
_buildTotal(context, manager.playlist.length),
Expanded(
child:
_isEditing && _searchController.text.isEmpty
? _buildReorderableList(manager, songs)
: _buildSongList(manager, songs),
),
],
);
},
),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: const RoomMusicPlayerBar(),
),
if (_isScanning)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: CircularProgressIndicator(
color: Color(0xFF33E6A2),
),
),
),
),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 54.w,
child: Row(
children: [
_PlainIconButton(
onPressed: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 24.w,
),
),
Expanded(
child: Text(
RoomMusicTexts.t(context, "music", "音乐"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
),
),
),
_PlainIconButton(
onPressed: _startAddFlow,
child: Image.asset(
"sc_images/room/sc_icon_room_music_add.png",
width: 24.w,
height: 24.w,
),
),
],
),
);
}
Widget _buildSearchField() {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 14.w),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
style: TextStyle(color: Colors.white, fontSize: 13.sp),
decoration: InputDecoration(
isDense: true,
hintText: RoomMusicTexts.t(context, "search", "搜索"),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.42),
fontSize: 13.sp,
),
prefixIcon: Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.42),
size: 20.w,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.08),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(17.w),
borderSide: BorderSide.none,
),
),
),
);
}
Widget _buildTotal(BuildContext context, int count) {
final template = RoomMusicTexts.t(
context,
"roomMusicTotalSongs",
"总计:{1} 首歌曲",
);
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 8.w),
child: Row(
children: [
Expanded(
child: Text(
template.replaceAll("{1}", count.toString()),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.90),
fontSize: 14.sp,
),
),
),
_PlainIconButton(
onPressed:
_searchController.text.trim().isEmpty
? () {
setState(() {
_isEditing = !_isEditing;
});
}
: null,
child: Image.asset(
"sc_images/room/sc_music_material_edit.png",
width: 24.w,
height: 24.w,
color:
_isEditing
? const Color(0xFF33E6A2)
: Colors.white.withValues(
alpha: _searchController.text.trim().isEmpty ? 1 : 0.36,
),
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.only(bottom: 120.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
"sc_images/room/sc_icon_room_music_empty.png",
width: 86.w,
height: 86.w,
),
SizedBox(height: 12.w),
Text(
RoomMusicTexts.t(context, "roomMusicEmpty", "没有音乐"),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 18.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _startAddFlow,
child: Container(
width: 164.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF33E6A2),
borderRadius: BorderRadius.circular(22.w),
),
child: Text(
RoomMusicTexts.t(
context,
"roomMusicScanFromPhone",
"从手机扫描添加",
),
style: TextStyle(
color: const Color(0xFF06251F),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
Widget _buildSongList(RoomMusicManager manager, List<SCMusicMode> songs) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
itemCount: songs.length,
itemBuilder: (context, index) {
final item = songs[index];
return Padding(
padding: EdgeInsets.only(bottom: 8.w),
child: _SwipeDeleteTile(
key: ValueKey("music-${item.id}"),
onDelete: () {
_confirmDelete(item);
},
child: _SongTile(
item: item,
selected: manager.current?.id == item.id,
playing: manager.current?.id == item.id && manager.isPlaying,
onTap: () => manager.play(item),
),
),
);
},
);
}
Widget _buildReorderableList(
RoomMusicManager manager,
List<SCMusicMode> songs,
) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ReorderableListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
buildDefaultDragHandles: false,
itemCount: songs.length,
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: child,
);
},
onReorder: (oldIndex, newIndex) {
manager.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = songs[index];
return Padding(
key: ValueKey("reorder-${item.id}"),
padding: EdgeInsets.only(bottom: 8.w),
child: _SongTile(
item: item,
selected: manager.current?.id == item.id,
playing: manager.current?.id == item.id && manager.isPlaying,
onTap: () => manager.play(item),
trailing: ReorderableDragStartListener(
index: index,
child: Image.asset(
"sc_images/room/sc_music_material_more.png",
width: 24.w,
height: 24.w,
),
),
),
);
},
);
}
Future<void> _startAddFlow() async {
if (_isScanning) {
return;
}
setState(() {
_isScanning = true;
});
final manager = context.read<RoomMusicManager>();
final folders = await _scanner.scanMp3Folders(addedSongs: manager.playlist);
if (!mounted) {
return;
}
setState(() {
_isScanning = false;
});
final songCount = folders.fold<int>(
0,
(sum, item) => sum + item.musicCount,
);
if (songCount == 0) {
SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
return;
}
final changed = await Navigator.of(
context,
).push<bool>(_musicRoute(RoomMusicFolderPage(folders: folders)));
if (changed == true && mounted) {
await manager.reload();
}
}
Future<bool> _confirmDelete(SCMusicMode item) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: const Color(0xFF10352E),
title: Text(
RoomMusicTexts.t(context, "roomMusicDeleteConfirm", "确定删除音乐?"),
style: const TextStyle(color: Colors.white),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(RoomMusicTexts.t(context, "cancel", "取消")),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(RoomMusicTexts.t(context, "confirm", "确认")),
),
],
);
},
);
if (result == true && mounted) {
final deletedText = RoomMusicTexts.t(
context,
"roomMusicDeleted",
"音乐已删除",
);
final manager = context.read<RoomMusicManager>();
await manager.deleteMusic(item);
SCTts.show(deletedText);
}
return false;
}
List<SCMusicMode> _filteredSongs(List<SCMusicMode> songs) {
final keyword = _searchController.text.trim().toLowerCase();
if (keyword.isEmpty) {
return songs;
}
return songs.where((item) {
return item.title.toLowerCase().contains(keyword) ||
item.fileName.toLowerCase().contains(keyword);
}).toList();
}
}
class _SongTile extends StatelessWidget {
const _SongTile({
required this.item,
required this.selected,
required this.playing,
required this.onTap,
this.trailing,
});
final SCMusicMode item;
final bool selected;
final bool playing;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB2FBCC), Color(0x00B2FBCC)],
stops: [0, 1],
),
borderRadius: BorderRadius.circular(8.w),
),
child: Container(
height: 54.w,
margin: const EdgeInsets.all(1),
padding: EdgeInsetsDirectional.fromSTEB(12.w, 0, 12.w, 0),
decoration: BoxDecoration(
color: const Color(0xFF08251E),
borderRadius: BorderRadius.circular(7.w),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
selected ? const Color(0xFF33E6A2) : Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 5.w),
Text(
_formatDuration(item.durationMs),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
if (trailing != null) ...[
SizedBox(width: 10.w),
trailing!,
] else
Image.asset(
playing
? "sc_images/room/sc_music_material_pause.png"
: "sc_images/room/sc_music_material_play.png",
width: 26.w,
height: 26.w,
),
],
),
),
),
);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}
class _SwipeDeleteTile extends StatefulWidget {
const _SwipeDeleteTile({
super.key,
required this.child,
required this.onDelete,
});
final Widget child;
final VoidCallback onDelete;
@override
State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> {
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
final actionWidth = 60.w;
return LayoutBuilder(
builder: (context, constraints) {
return ClipRect(
child: SizedBox(
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
setState(() {
_dragOffset = (_dragOffset + details.delta.dx).clamp(
-actionWidth,
0,
);
});
},
onHorizontalDragEnd: (_) {
setState(() {
_dragOffset =
_dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: Row(
children: [
SizedBox(width: constraints.maxWidth, child: widget.child),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
),
),
),
),
],
),
),
),
),
);
},
);
}
}
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({required this.child, required this.onPressed});
final Widget child;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(width: 48.w, height: 48.w, child: Center(child: child)),
);
}
}

View File

@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class RoomMusicSelectPage extends StatefulWidget {
const RoomMusicSelectPage({super.key, required this.folder});
final SCMusicFolderMode folder;
@override
State<RoomMusicSelectPage> createState() => _RoomMusicSelectPageState();
}
class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
late Set<String> _initialSelectedIds;
late Set<String> _selectedIds;
bool get _hasChanged => !_setEquals(_initialSelectedIds, _selectedIds);
bool get _allSelected => _selectedIds.length == widget.folder.songs.length;
@override
void initState() {
super.initState();
final currentIds =
context
.read<RoomMusicManager>()
.playlist
.map((item) => item.id)
.toSet();
_initialSelectedIds =
widget.folder.songs
.where((item) => currentIds.contains(item.id))
.map((item) => item.id)
.toSet();
_selectedIds = {..._initialSelectedIds};
}
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
appBar: AppBar(
backgroundColor: const Color(0xFF071F1B),
elevation: 0,
title: Text(
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
),
),
body: Column(
children: [
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 16.w),
itemCount: widget.folder.songs.length,
separatorBuilder:
(_, __) => Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 1,
),
itemBuilder: (context, index) {
return _buildSongItem(widget.folder.songs[index]);
},
),
),
_buildBottomBar(context),
],
),
),
);
}
Widget _buildSongItem(SCMusicMode item) {
final selected = _selectedIds.contains(item.id);
final alreadyAdded = _initialSelectedIds.contains(item.id);
final checkColor =
!selected
? Colors.transparent
: alreadyAdded
? Colors.white.withValues(alpha: 0.34)
: const Color(0xFF33E6A2);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
if (selected) {
_selectedIds.remove(item.id);
} else {
_selectedIds.add(item.id);
}
});
},
child: SizedBox(
height: 64.w,
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 5.w),
Text(
_formatDuration(item.durationMs),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
Container(
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: checkColor,
border: Border.all(
color:
selected
? checkColor
: Colors.white.withValues(alpha: 0.28),
),
),
child:
selected
? Icon(Icons.check, size: 15.w, color: Colors.white)
: null,
),
],
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xFF082820),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
),
child: Row(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
if (_allSelected) {
_selectedIds.clear();
} else {
_selectedIds =
widget.folder.songs.map((item) => item.id).toSet();
}
});
},
child: Row(
children: [
Icon(
_allSelected
? Icons.check_circle
: Icons.radio_button_unchecked,
color:
_allSelected
? const Color(0xFF33E6A2)
: Colors.white.withValues(alpha: 0.55),
size: 22.w,
),
SizedBox(width: 8.w),
Text(
RoomMusicTexts.t(context, "roomMusicSelectAll", "全选"),
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
const Spacer(),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _hasChanged ? _submit : null,
child: Container(
width: 104.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color:
_hasChanged
? const Color(0xFF33E6A2)
: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20.w),
),
child: Text(
RoomMusicTexts.t(context, "add", "添加"),
style: TextStyle(
color:
_hasChanged
? const Color(0xFF06251F)
: Colors.white.withValues(alpha: 0.38),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
Future<void> _submit() async {
await context.read<RoomMusicManager>().applyFolderSelection(
folder: widget.folder,
selectedIds: _selectedIds,
);
if (!mounted) {
return;
}
Navigator.pop(context, true);
}
bool _setEquals(Set<String> a, Set<String> b) {
return a.length == b.length && a.containsAll(b);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/widgets.dart';
import 'package:yumi/app_localizations.dart';
class RoomMusicTexts {
const RoomMusicTexts._();
static String t(BuildContext context, String key, String fallback) {
final value = SCAppLocalizations.of(context)?.translate(key) ?? key;
return value == key ? fallback : value;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,8 @@ import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
@ -12,6 +14,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
@ -27,6 +30,7 @@ import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_room_player.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
@ -60,6 +64,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
RoomGiftSeatFlightController();
final Map<String, _LuckyGiftComboSession> _luckyGiftComboSessions =
<String, _LuckyGiftComboSession>{};
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
@override
void initState() {
@ -67,7 +74,6 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_tabController = TabController(length: _pages.length, vsync: this);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,
) {
@ -86,11 +92,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rtcProvider = context.read<RtcProvider>();
if (!identical(_rtcProvider, rtcProvider)) {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = rtcProvider;
rtcProvider.addListener(_handleRoomProviderChanged);
}
_ensureRoomVisualEffectsEnabled();
}
@override
void dispose() {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = null;
_roomProviderRebuildScheduled = false;
_suspendRoomVisualEffects();
_tabController.removeListener(_handleTabChange);
_tabController.dispose(); //
@ -105,6 +120,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
setState(() {});
}
void _handleRoomProviderChanged() {
if (!mounted || _roomProviderRebuildScheduled) {
return;
}
_roomProviderRebuildScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomProviderRebuildScheduled = false;
if (!mounted) {
return;
}
setState(() {});
});
}
void _enableRoomVisualEffects() {
Provider.of<RtcProvider>(
context,
@ -144,6 +173,75 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
}
Widget _buildRoomStartupLayer() {
return Selector<RtcProvider, _RoomStartupSnapshot>(
selector:
(context, provider) => _RoomStartupSnapshot(
status: provider.roomStartupStatus,
failureType: provider.roomStartupFailureType,
failureToken: provider.roomStartupFailureToken,
),
builder: (context, snapshot, child) {
if (snapshot.status == RoomStartupStatus.failed) {
_showRoomStartupFailureDialog(snapshot);
return const SizedBox.shrink();
}
if (snapshot.status != RoomStartupStatus.loading) {
return const SizedBox.shrink();
}
return const SizedBox.shrink();
},
);
}
void _showRoomStartupFailureDialog(_RoomStartupSnapshot snapshot) {
if (_shownRoomStartupFailureToken == snapshot.failureToken) {
return;
}
_shownRoomStartupFailureToken = snapshot.failureToken;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
SmartDialog.dismiss(tag: "roomStartupFailureDialog");
SmartDialog.show(
tag: "roomStartupFailureDialog",
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
backType: SmartBackType.block,
clickMaskDismiss: false,
builder: (_) {
return _RoomStartupFailureDialog(
title: SCAppLocalizations.of(context)!.tips,
message: _roomStartupFailureMessage(snapshot.failureType),
btnText: SCAppLocalizations.of(context)!.confirm,
onEnsure: () {
SmartDialog.dismiss(tag: "roomStartupFailureDialog");
context.read<RtcProvider>().confirmRoomStartupFailureAndLeave(
navigationContext: context,
);
},
);
},
);
});
}
String _roomStartupFailureMessage(RoomStartupFailureType failureType) {
switch (failureType) {
case RoomStartupFailureType.im:
return SCAppLocalizations.of(context)!.enterRoomFailedRetry;
case RoomStartupFailureType.rtc:
return SCAppLocalizations.of(context)!.voiceConnectionFailedRetry;
case RoomStartupFailureType.entry:
case RoomStartupFailureType.none:
return SCAppLocalizations.of(context)!.operationFail;
}
}
String? _resolveRoomThemeBackground(JoinRoomRes? room) {
@ -164,6 +262,11 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (!didPop) {
final rtcProvider = context.read<RtcProvider>();
if (rtcProvider.roomStartupStatus == RoomStartupStatus.loading ||
rtcProvider.roomStartupStatus == RoomStartupStatus.failed) {
return;
}
_suspendRoomVisualEffects();
SCFloatIchart().show();
SCNavigatorUtils.goBack(context);
@ -223,6 +326,13 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
),
],
),
_buildRoomStartupLayer(),
const PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: RoomMusicRoomPlayer(),
),
// _buildPlayViews(),
///
LuckGiftNomorAnimWidget(),
@ -719,3 +829,110 @@ class _LuckyGiftComboSession {
clearQueueTimer?.cancel();
}
}
class _RoomStartupSnapshot {
const _RoomStartupSnapshot({
required this.status,
required this.failureType,
required this.failureToken,
});
final RoomStartupStatus status;
final RoomStartupFailureType failureType;
final int failureToken;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomStartupSnapshot &&
other.status == status &&
other.failureType == failureType &&
other.failureToken == failureToken;
}
@override
int get hashCode => Object.hash(status, failureType, failureToken);
}
class _RoomStartupFailureDialog extends StatelessWidget {
const _RoomStartupFailureDialog({
required this.title,
required this.message,
required this.btnText,
required this.onEnsure,
});
final String title;
final String message;
final String btnText;
final VoidCallback onEnsure;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 320.w),
margin: EdgeInsets.symmetric(horizontal: 22.w),
padding: EdgeInsets.fromLTRB(18.w, 18.w, 18.w, 30.w),
decoration: BoxDecoration(
color: const Color(0xff09372E),
borderRadius: BorderRadius.circular(12.w),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
SizedBox(height: 14.w),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
height: 1.25,
decoration: TextDecoration.none,
),
),
SizedBox(height: 18.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onEnsure,
child: Container(
width: 160.w,
height: 38.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xff18F2B1),
borderRadius: BorderRadius.circular(12.w),
),
child: Text(
btnText,
style: TextStyle(
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
),
),
],
),
),
),
);
}
}

View File

@ -3,9 +3,11 @@ import 'dart:io';
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:yumi/modules/room/background/room_background_preview_page.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart';
import 'package:yumi/modules/room/music/room_music_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
@ -15,13 +17,66 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomEdit = '/room/roomEdit';
static String roomTheme = '/room/roomTheme';
static String roomBackgroundSelect = '/room/background/select';
static String roomBackgroundPreview = '/room/background/preview';
static String roomBackgroundUpload = '/room/background/upload';
static String roomMusic = '/room/music';
static int _voiceRoomRouteDepth = 0;
static Route<T> _buildRoute<T>(Widget page) {
static bool get isVoiceRoomOpen => _voiceRoomRouteDepth > 0;
static Route<T> _buildRoute<T>(Widget page, {RouteSettings? settings}) {
if (Platform.isIOS) {
return CupertinoPageRoute<T>(builder: (_) => page);
return CupertinoPageRoute<T>(builder: (_) => page, settings: settings);
}
return MaterialPageRoute<T>(builder: (_) => page);
return MaterialPageRoute<T>(builder: (_) => page, settings: settings);
}
static Route<T> _buildDarkRoute<T>(Widget page, {RouteSettings? settings}) {
return PageRouteBuilder<T>(
settings: settings,
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
static Future<T?> openVoiceRoom<T>(
BuildContext context, {
bool replace = false,
bool rootNavigator = false,
}) {
final navigator = Navigator.of(context, rootNavigator: rootNavigator);
if (!replace && isVoiceRoomOpen) {
debugPrint('[RoomRoute] skip duplicate voice room route');
return Future<T?>.value();
}
final route = _buildRoute<T>(
const VoiceRoomPage(),
settings: RouteSettings(name: voiceRoom),
);
_voiceRoomRouteDepth = replace ? 1 : _voiceRoomRouteDepth + 1;
Future<T?> routeFuture;
if (replace) {
routeFuture = navigator.pushReplacement<T, T>(route);
} else {
routeFuture = navigator.push<T>(route);
}
return routeFuture.whenComplete(() {
if (_voiceRoomRouteDepth > 0) {
_voiceRoomRouteDepth -= 1;
}
});
}
static Future<T?> openRoomEdit<T>(
@ -52,20 +107,49 @@ class VoiceRoomRoute implements SCIRouterProvider {
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundSelectPage()));
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomBackgroundSelectPage(),
settings: RouteSettings(name: roomBackgroundSelect),
),
);
}
static Future<T?> openRoomBackgroundUpload<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundUploadPage()));
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomBackgroundUploadPage(),
settings: RouteSettings(name: roomBackgroundUpload),
),
);
}
static Future<T?> openRoomBackgroundPreview<T>(
BuildContext context, {
String? backgroundPath,
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
RoomBackgroundPreviewPage(backgroundPath: backgroundPath),
settings: RouteSettings(name: roomBackgroundPreview),
),
);
}
static Future<T?> openRoomMusic<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
const RoomMusicPage(),
settings: RouteSettings(name: roomMusic),
),
);
}
@override
@ -84,12 +168,22 @@ class VoiceRoomRoute implements SCIRouterProvider {
handlerFunc: (_, params) => const RoomBackgroundSelectPage(),
),
);
router.define(
roomBackgroundPreview,
handler: Handler(
handlerFunc: (_, params) => const RoomBackgroundPreviewPage(),
),
);
router.define(
roomBackgroundUpload,
handler: Handler(
handlerFunc: (_, params) => const RoomBackgroundUploadPage(),
),
);
router.define(
roomMusic,
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
);
router.define(
roomEdit,
handler: Handler(

View File

@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
const double roomGameDesignWidth = 750;
double resolveRoomGameAvailableHeight(
BuildContext context, {
double reservedTop = 12,
}) {
final mediaQuery = MediaQuery.maybeOf(context);
if (mediaQuery == null) {
return 0;
}
final bottomInset =
mediaQuery.viewInsets.bottom > mediaQuery.padding.bottom
? mediaQuery.viewInsets.bottom
: mediaQuery.padding.bottom;
final availableHeight =
mediaQuery.size.height -
mediaQuery.padding.top -
bottomInset -
reservedTop;
if (!availableHeight.isFinite || availableHeight <= 0) {
return mediaQuery.size.height;
}
return availableHeight.clamp(0.0, mediaQuery.size.height).toDouble();
}
double clampRoomGameHeight(
double value,
double maxHeight, {
double minHeight = 0,
}) {
if (!value.isFinite || value <= 0) {
return maxHeight > 0 ? maxHeight : 0;
}
if (!maxHeight.isFinite || maxHeight <= 0) {
return value;
}
if (minHeight > maxHeight) {
return maxHeight;
}
return value.clamp(minHeight, maxHeight).toDouble();
}
String resolveRoomGameScreenMode(
Map<String, dynamic> launchParams, {
required bool fullScreen,
}) {
final candidates = <String>[
launchParams['screenMode']?.toString() ?? '',
launchParams['screen_mode']?.toString() ?? '',
launchParams['displayMode']?.toString() ?? '',
launchParams['gameScreenMode']?.toString() ?? '',
];
for (final candidate in candidates) {
final trimmed = candidate.trim();
if (trimmed.isNotEmpty) {
return trimmed.toLowerCase();
}
}
return fullScreen ? 'full' : 'half';
}
double resolveRoomGameCompatibilityBottomHeight(BuildContext context) {
final mediaQuery = MediaQuery.maybeOf(context);
if (mediaQuery == null) {
return 0;
}
final isClassicScreen = mediaQuery.padding.bottom <= 0.5;
if (!isClassicScreen) {
return 0;
}
return (mediaQuery.size.width * 0.19).clamp(56.0, 88.0).toDouble();
}
class RoomGameCompatibilityBottomFrame extends StatelessWidget {
const RoomGameCompatibilityBottomFrame({super.key, required this.height});
final double height;
@override
Widget build(BuildContext context) {
if (height <= 0) {
return const SizedBox.shrink();
}
return SizedBox(
height: height,
width: double.infinity,
child: CustomPaint(painter: _RoomGameBottomFramePainter()),
);
}
}
class _RoomGameBottomFramePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final radius = Radius.circular(size.height * 0.18);
final frameRect = RRect.fromRectAndCorners(
Offset.zero & size,
topLeft: radius,
topRight: radius,
);
final backgroundPaint =
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF0E392E), Color(0xFF082A23)],
).createShader(Offset.zero & size);
canvas.drawRRect(frameRect, backgroundPaint);
canvas.save();
canvas.clipRRect(frameRect);
final patternPaint =
Paint()
..color = const Color(0xFF2C5B4A).withValues(alpha: 0.20)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final motifWidth = size.width / 8;
final motifHeight = size.height / 2.4;
for (double x = -motifWidth; x < size.width + motifWidth; x += motifWidth) {
for (
double y = -motifHeight * 0.2;
y < size.height + motifHeight;
y += motifHeight * 0.72
) {
final rect = Rect.fromCenter(
center: Offset(x + motifWidth * 0.52, y + motifHeight * 0.55),
width: motifWidth * 0.9,
height: motifHeight,
);
canvas.drawArc(rect, -0.35, 4.5, false, patternPaint);
canvas.drawArc(
rect.translate(motifWidth * 0.34, 0),
2.75,
3.6,
false,
patternPaint,
);
}
}
canvas.restore();
final borderPaint =
Paint()
..color = const Color(0xFFE9D58A)
..style = PaintingStyle.stroke
..strokeWidth = 3;
final innerBorderPaint =
Paint()
..color = const Color(0xFF8F7136).withValues(alpha: 0.75)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRRect(frameRect.deflate(1.5), borderPaint);
canvas.drawRRect(frameRect.deflate(5), innerBorderPaint);
final highlightPaint =
Paint()
..shader = const LinearGradient(
colors: <Color>[
Color(0x00F6E8A7),
Color(0x99F6E8A7),
Color(0x00F6E8A7),
],
).createShader(Rect.fromLTWH(0, 0, size.width, 3));
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, 3), highlightPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@ -11,8 +11,10 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/baishun_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/utils/room_game_viewport.dart';
import 'package:yumi/modules/room_game/views/baishun_loading_view.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/shared/tools/sc_h5_url_utils.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class BaishunGamePage extends StatefulWidget {
@ -33,7 +35,6 @@ class BaishunGamePage extends StatefulWidget {
class _BaishunGamePageState extends State<BaishunGamePage> {
static const String _logPrefix = '[BaishunGame]';
static const double _designWidth = 750;
final RoomGameRepository _repository = RoomGameRepository();
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
@ -215,7 +216,8 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return;
}
final uri = Uri.tryParse(entryUrl);
final h5Url = SCH5UrlUtils.appendToken(entryUrl);
final uri = Uri.tryParse(h5Url);
if (uri == null) {
throw Exception('Invalid game entry url: $entryUrl');
}
@ -610,27 +612,33 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
}
double _calculateWebViewHeight(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final safePadding = MediaQuery.paddingOf(context);
final preferredHeight = _calculatePreferredWebViewHeight(context);
final maxHeight = resolveRoomGameAvailableHeight(
context,
reservedTop: 12.w,
);
return clampRoomGameHeight(preferredHeight, maxHeight);
}
double _calculatePreferredWebViewHeight(BuildContext context) {
final mediaQuery = MediaQuery.maybeOf(context);
final screenSize = mediaQuery?.size ?? Size.zero;
final safeHeight = _resolveSafeHeight();
final fallbackHeight = screenSize.height * 0.5;
if (safeHeight <= 0 || screenSize.width <= 0) {
return fallbackHeight;
}
final ratio = _designWidth / safeHeight;
final webViewHeight = screenSize.width / ratio;
final maxHeight = screenSize.height - safePadding.top - 12.w;
if (maxHeight <= 0) {
return fallbackHeight;
}
return webViewHeight.clamp(0.0, maxHeight).toDouble();
final ratio = roomGameDesignWidth / safeHeight;
return screenSize.width / ratio;
}
@override
Widget build(BuildContext context) {
final topCrop = 28.w;
final webViewHeight = _calculateWebViewHeight(context);
final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context);
final dialogHeight = webViewHeight + bottomFrameHeight;
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
@ -650,7 +658,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
),
child: Container(
width: ScreenUtil().screenWidth,
height: webViewHeight,
height: dialogHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
@ -658,12 +666,22 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
top: -topCrop,
left: 0,
right: 0,
bottom: 0,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
),
),
if (bottomFrameHeight > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomFrameHeight,
child: RoomGameCompatibilityBottomFrame(
height: bottomFrameHeight,
),
),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const IgnorePointer(

View File

@ -10,8 +10,10 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/utils/room_game_viewport.dart';
import 'package:yumi/modules/room_game/views/baishun_loading_view.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/shared/tools/sc_h5_url_utils.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class LeaderGamePage extends StatefulWidget {
@ -32,7 +34,6 @@ class LeaderGamePage extends StatefulWidget {
class _LeaderGamePageState extends State<LeaderGamePage> {
static const String _logPrefix = '[LeaderGame]';
static const double _designWidth = 750;
static const double _hdDesignHeight = 1044;
final RoomGameRepository _repository = RoomGameRepository();
@ -189,7 +190,8 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
await _controller.loadHtmlString(_buildMockHtml());
return;
}
final uri = Uri.tryParse(entryUrl);
final h5Url = SCH5UrlUtils.appendToken(entryUrl);
final uri = Uri.tryParse(h5Url);
if (uri == null) {
throw Exception('Invalid game entry url: $entryUrl');
}
@ -328,23 +330,10 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
}
String _resolveScreenMode() {
final launchParams = widget.game.launchParams;
final candidates = <String>[
launchParams['screenMode']?.toString() ?? '',
launchParams['screen_mode']?.toString() ?? '',
launchParams['displayMode']?.toString() ?? '',
launchParams['gameScreenMode']?.toString() ?? '',
];
for (final candidate in candidates) {
final trimmed = candidate.trim();
if (trimmed.isNotEmpty) {
return trimmed.toLowerCase();
}
}
if (widget.game.fullScreen) {
return 'full';
}
return 'half';
return resolveRoomGameScreenMode(
widget.game.launchParams,
fullScreen: widget.game.fullScreen,
);
}
int _resolveSafeHeight() {
@ -358,32 +347,42 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
}
double _calculateBodyHeight(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final safePadding = MediaQuery.paddingOf(context);
final screenMode = _resolveScreenMode();
final safeHeight = _resolveSafeHeight();
final fallback = screenSize.width;
final maxHeight = screenSize.height - safePadding.top - 24.w;
if (maxHeight <= 0) {
return fallback;
final maxDialogHeight = resolveRoomGameAvailableHeight(
context,
reservedTop: 12.w,
);
if (maxDialogHeight <= 0) {
return 0;
}
if (screenMode == 'full') {
return maxHeight;
return maxDialogHeight;
}
return clampRoomGameHeight(
_calculatePreferredBodyHeight(context),
maxDialogHeight,
);
}
double _calculatePreferredBodyHeight(BuildContext context) {
final mediaQuery = MediaQuery.maybeOf(context);
final screenSize = mediaQuery?.size ?? Size.zero;
final screenMode = _resolveScreenMode();
final safeHeight = _resolveSafeHeight();
final fallback = screenSize.width;
if (safeHeight > 0 && screenSize.width > 0) {
final ratio = _designWidth / safeHeight;
final bodyHeight = screenSize.width / ratio;
return bodyHeight.clamp(0.0, maxHeight).toDouble();
final ratio = roomGameDesignWidth / safeHeight;
return screenSize.width / ratio;
}
if (screenMode == 'hd') {
final bodyHeight = screenSize.width * _hdDesignHeight / _designWidth;
return bodyHeight.clamp(0.0, maxHeight).toDouble();
return screenSize.width * _hdDesignHeight / roomGameDesignWidth;
}
return fallback.clamp(0.0, maxHeight).toDouble();
return fallback;
}
void _log(String message) {
@ -465,8 +464,27 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
};
}
Map<String, dynamic> _buildLaunchPayloadForLog() {
return <String, dynamic>{
'provider': widget.launchModel.provider,
'gameId': widget.launchModel.gameId,
'providerGameId': widget.launchModel.providerGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'entry': widget.launchModel.entry.toJson(),
'launchConfig': widget.launchModel.launchConfig.toJson(),
'roomState': widget.launchModel.roomState.toJson(),
'game': <String, dynamic>{
'gameId': widget.game.gameId,
'provider': widget.game.provider,
'gameType': widget.game.gameType,
'name': widget.game.name,
'launchMode': widget.game.launchMode,
},
};
}
Map<String, dynamic> _buildLaunchSummary() {
final payload = _buildLaunchPayload();
final payload = _buildLaunchPayloadForLog();
return <String, dynamic>{
'roomId': widget.roomId,
'provider': widget.game.provider,
@ -526,7 +544,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
@override
Widget build(BuildContext context) {
final bodyHeight = _calculateBodyHeight(context);
final headerHeight = 44.w;
final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
@ -546,7 +564,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
),
child: Container(
width: ScreenUtil().screenWidth,
height: bodyHeight + headerHeight,
height: bodyHeight + bottomFrameHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
@ -554,19 +572,22 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
left: 0,
right: 0,
top: 0,
height: headerHeight,
child: _buildHeader(),
),
Positioned(
left: 0,
right: 0,
top: headerHeight,
bottom: 0,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
),
),
if (bottomFrameHeight > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomFrameHeight,
child: RoomGameCompatibilityBottomFrame(
height: bottomFrameHeight,
),
),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const IgnorePointer(
@ -584,36 +605,6 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
);
}
Widget _buildHeader() {
return Container(
color: const Color(0xFF102D28),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(
child: Text(
widget.game.name.isEmpty ? 'Leader Game' : widget.game.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () {
unawaited(_closeAndExit());
},
icon: Icon(Icons.close, color: Colors.white, size: 20.w),
splashRadius: 18.w,
),
],
),
);
}
Widget _buildErrorState() {
return Positioned.fill(
child: Container(

View File

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'dart:convert';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/utils/room_game_viewport.dart';
import 'package:yumi/modules/room_game/views/baishun_game_page.dart';
import 'package:yumi/modules/room_game/views/leader_game_page.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
@ -210,11 +211,17 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.maybeOf(context);
final screenHeight = mediaQuery?.size.height ?? ScreenUtil().screenHeight;
final sheetHeight = clampRoomGameHeight(
screenHeight * 0.5,
resolveRoomGameAvailableHeight(context),
);
return SafeArea(
top: false,
child: SizedBox(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight * 0.5,
height: sheetHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final innerLeft = constraints.maxWidth * 0.05;

View File

@ -434,12 +434,11 @@ class _SearchRoomListState extends State<SearchRoomList> {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (context != null) {
Provider.of<RtcProvider>(
context,
listen: false,
).joinVoiceRoomSession(context, e.id ?? "");
}
Provider.of<RtcProvider>(context, listen: false).joinVoiceRoomSession(
context,
e.id ?? "",
previewData: RoomEntryPreviewData.fromSocialChatRoom(e),
);
},
child: Stack(
alignment: Alignment.bottomCenter,
@ -543,9 +542,9 @@ class _SearchRoomListState extends State<SearchRoomList> {
width: 20.w,
height: 20.w,
),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? SizedBox(width: 3.w)
: Container(height: 10.w),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? SizedBox(width: 3.w)
: Container(height: 10.w),
(e.extValues?.roomSetting?.password?.isEmpty ?? false)
? text(
e.displayMemberCount,

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
@ -6,9 +7,14 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/tools/sc_version_utils.dart';
import 'package:yumi/shared/tools/sc_google_auth_utils.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_auth_type.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/services/home/home_room_preload_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
@ -90,6 +96,7 @@ class _SplashPageState extends State<SplashPage> {
_selectedVariant = _pickSplashVariant();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(FileCacheManager.getInstance().getFilePath());
SCHomeRoomPreloadManager.instance.preloadInitialHomeRooms();
unawaited(WeeklyStarSplashCache.refreshCacheInBackground());
unawaited(LastWeeklyCPSplashCache.refreshCacheInBackground());
});
@ -448,6 +455,15 @@ class _SplashPageState extends State<SplashPage> {
var token = AccountStorage().getToken();
if (user != null && token.isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
return;
}
final restoredUser = await _tryRestoreLoginSession();
if (!mounted) {
return;
}
if (restoredUser != null &&
(restoredUser.token ?? "").trim().isNotEmpty) {
SCNavigatorUtils.push(context, SCRoutes.home, replace: true);
} else {
SCNavigatorUtils.pushLoginIfNeeded(context, replace: true);
}
@ -459,6 +475,28 @@ class _SplashPageState extends State<SplashPage> {
}
}
Future<SocialChatLoginRes?> _tryRestoreLoginSession() async {
if (!Platform.isAndroid) {
return null;
}
try {
final googleUser = await SCGoogleAuthService.signInSilently();
final uid = googleUser?.uid ?? "";
if (uid.isEmpty) {
return null;
}
final user = await SCAccountRepository().loginForChannel(
SCAuthType.GOOGLE.name,
uid,
);
AccountStorage().setCurrentUser(user);
return user;
} catch (e) {
debugPrint('restore login session failed: $e');
return null;
}
}
///
void _cancelTimer() {
// `Timer``cancel`

View File

@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
@ -15,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import '../../../../shared/data_sources/models/enum/sc_level_type.dart';
class UserLevelPage extends StatefulWidget {
const UserLevelPage({super.key});
@override
_UserLevelPageState createState() => _UserLevelPageState();
State<UserLevelPage> createState() => _UserLevelPageState();
}
class _UserLevelPageState extends State<UserLevelPage> {
@ -78,26 +79,26 @@ class _UserLevelPageState extends State<UserLevelPage> {
maxWidth: 115.w,
fontWeight: FontWeight.bold,
AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname ??
.getCurrentUser()
?.userProfile
?.userNickname ??
"",
fontSize: 15.sp,
type:
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
"",
needScroll:
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
10,
),
SizedBox(width: 3.w),
@ -162,7 +163,6 @@ class _UserLevelPageState extends State<UserLevelPage> {
textColor: Colors.white,
),
Image.asset("sc_images/level/sc_icon_user_level_center_bg_1.png"),
Image.asset("sc_images/level/sc_icon_user_level_center_bg_2.png"),
text(
SCAppLocalizations.of(context)!.howToUpgrade,
fontSize: 20.sp,
@ -171,7 +171,9 @@ class _UserLevelPageState extends State<UserLevelPage> {
),
SizedBox(height: 3),
text(
SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints,
SCAppLocalizations.of(
context,
)!.spendCoinsToGainExperiencePoints,
fontSize: 12.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white,
@ -196,7 +198,7 @@ class _UserLevelPageState extends State<UserLevelPage> {
SCNavigatorUtils.push(context, StoreRoute.list);
},
),
SizedBox(height: 20.w,)
SizedBox(height: 20.w),
],
),
)

View File

@ -1,7 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:marquee/marquee.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
@ -16,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import '../../../../shared/data_sources/models/enum/sc_level_type.dart';
class WealthLevelPage extends StatefulWidget {
const WealthLevelPage({super.key});
@override
_WealthLevelPageState createState() => _WealthLevelPageState();
State<WealthLevelPage> createState() => _WealthLevelPageState();
}
class _WealthLevelPageState extends State<WealthLevelPage> {
@ -79,26 +79,26 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
maxWidth: 125.w,
fontWeight: FontWeight.bold,
AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname ??
.getCurrentUser()
?.userProfile
?.userNickname ??
"",
fontSize: 15.sp,
type:
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
AccountStorage()
.getCurrentUser()
?.userProfile
?.getVIP()
?.name ??
"",
needScroll:
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
(AccountStorage()
.getCurrentUser()
?.userProfile
?.userNickname
?.characters
.length ??
0) >
10,
),
SizedBox(width: 3.w),
@ -163,16 +163,17 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
textColor: Colors.white,
),
Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_1.png"),
Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_2.png"),
text(
SCAppLocalizations.of(context)!.howToUpgrade,
fontSize: 20.sp,
fontWeight: FontWeight.bold,
textColor:SocialChatTheme.primaryLight,
textColor: SocialChatTheme.primaryLight,
),
SizedBox(height: 3),
text(
SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints,
SCAppLocalizations.of(
context,
)!.spendCoinsToGainExperiencePoints,
fontSize: 12.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white,
@ -197,7 +198,7 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
SCNavigatorUtils.push(context, StoreRoute.list);
},
),
SizedBox(height: 20.w,)
SizedBox(height: 20.w),
],
),
)

View File

@ -7,14 +7,18 @@ 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/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';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class MePage2 extends StatefulWidget {
const MePage2({super.key});
@ -24,7 +28,13 @@ class MePage2 extends StatefulWidget {
}
class _MePage2State extends State<MePage2> {
static const String _inviteActivityUrl =
'https://h5.haiyihy.com/app-invite/index.html';
static const double _inviteActivityHorizontalScale = 750 / 662;
Map<String, SCUserCounterRes> _counterMap = {};
SCVipStatusRes? _vipStatus;
bool _isVipStatusLoading = false;
@override
void initState() {
@ -42,6 +52,7 @@ class _MePage2State extends State<MePage2> {
profileManager.fetchUserProfileData();
profileManager.balance();
_loadCounter();
_loadVipStatus();
}
Future<void> _loadCounter() async {
@ -61,6 +72,77 @@ class _MePage2State extends State<MePage2> {
} catch (_) {}
}
Future<void> _loadVipStatus() async {
if (mounted) {
setState(() => _isVipStatusLoading = true);
}
try {
final status = await _loadVipEntryStatus();
debugPrint(
'[VIP][MeEntry] status loaded active=${status.active} '
'level=${status.level} levelCode=${status.levelCode} '
'expireAt=${status.expireAt} '
'badge=${_vipEntryBadgeLogValue(status.badge)}',
);
if (!mounted) return;
setState(() {
_vipStatus = status;
_isVipStatusLoading = false;
});
} catch (error) {
debugPrint('[VIP][MeEntry] status load failed error=$error');
if (!mounted) return;
setState(() => _isVipStatusLoading = false);
}
}
Future<SCVipStatusRes> _loadVipEntryStatus() async {
final repository = SCVipRepositoryImp();
try {
final home = await repository.vipHome();
final state = home.state;
if (state != null && state.levelInt > 0) {
debugPrint('[VIP][MeEntry] use vip home state');
return state;
}
debugPrint('[VIP][MeEntry] vip home state empty, fallback status');
} catch (error) {
debugPrint('[VIP][MeEntry] vip home load failed error=$error');
}
return repository.vipStatus();
}
void _refreshAfterVipReturn(SocialChatUserProfileManager profileManager) {
debugPrint('[VIP][MeEntry] refresh user data after vip page return');
profileManager.fetchUserProfileData(loadGuardCount: false);
profileManager.balance();
_loadCounter();
_loadVipStatus();
}
void _openInviteActivity() {
final inviteActivityUrl = _appendToken(_inviteActivityUrl);
if (inviteActivityUrl.isEmpty) return;
SCNavigatorUtils.push(
context,
"${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(inviteActivityUrl)}&showTitle=false",
);
}
String _appendToken(String url) {
final token = AccountStorage().getToken();
if (token.isEmpty) return url;
final uri = Uri.tryParse(url);
if (uri == null) return url;
final queryParameters = Map<String, String>.from(uri.queryParameters);
queryParameters['token'] = token;
return uri.replace(queryParameters: queryParameters).toString();
}
@override
Widget build(BuildContext context) {
return SafeArea(
@ -79,8 +161,8 @@ class _MePage2State extends State<MePage2> {
SizedBox(height: 12.w),
_buildEntryRow(),
SizedBox(height: 12.w),
_buildWalletCard(),
SizedBox(height: 12.w),
_buildWalletVipRow(profileManager),
_buildInviteActivityBanner(),
_buildMenuCard2(),
SizedBox(height: 12.w),
_buildMenuCard([
@ -223,12 +305,16 @@ class _MePage2State extends State<MePage2> {
iconPath: 'sc_images/index/sc_icon_bag.png',
onTap: () => SCNavigatorUtils.push(context, StoreRoute.bags),
),
],
);
}
Widget _buildWalletVipRow(SocialChatUserProfileManager profileManager) {
return Row(
children: [
Expanded(child: _buildVipEntryCard(profileManager)),
SizedBox(width: 10.w),
_buildEntryItem(
title: SCAppLocalizations.of(context)!.level,
iconPath: 'sc_images/index/sc_icon_level.png',
onTap: () => SCNavigatorUtils.push(context, SCMainRoute.levelList),
),
Expanded(child: _buildWalletCard()),
],
);
}
@ -237,8 +323,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(
@ -246,44 +331,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,
),
);
},
),
),
],
),
@ -292,8 +386,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,
),
],
),
@ -301,6 +395,251 @@ class _MePage2State extends State<MePage2> {
);
}
Widget _buildVipEntryCard(SocialChatUserProfileManager profileManager) {
final vipLevel = _vipEntryLevel(profileManager);
final isActiveVip = vipLevel > 0;
final badgeResource = isActiveVip ? _vipStatus?.badge : null;
return SCDebounceWidget(
onTap: () async {
await SCNavigatorUtils.push(context, VipRoute.detail);
if (mounted) {
_refreshAfterVipReturn(profileManager);
}
},
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:
_isVipStatusLoading && _vipStatus == null
? SizedBox(
width: 16.w,
height: 16.w,
child: CircularProgressIndicator(
color: const Color(0xFFFFD155),
strokeWidth: 2.w,
),
)
: isActiveVip
? _buildVipLevelMark(vipLevel)
: Text(
'Not activated',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(
0xFFFFE8A7,
).withValues(alpha: 0.82),
fontSize: 11.sp,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
PositionedDirectional(
end: 2.w,
top: 8.w,
child: Opacity(
opacity: isActiveVip ? 1 : 0.52,
child: _buildVipEntryBadge(resource: badgeResource),
),
),
],
),
),
);
}
Widget _buildVipEntryBadge({required SCVipResourceRes? resource}) {
final empty = SizedBox(width: 52.w, height: 52.w);
final url = resource?.previewUrl?.trim();
if (url == null || url.isEmpty) {
return empty;
}
return netImage(
url: url,
width: 52.w,
height: 52.w,
fit: BoxFit.contain,
noDefaultImg: true,
errorWidget: empty,
);
}
String _vipEntryBadgeLogValue(SCVipResourceRes? resource) {
if (resource == null) {
return 'null';
}
return '{id:${resource.resourceId},name:${resource.name},'
'hasPreviewUrl:${resource.hasPreviewUrl},'
'previewUrl:${resource.previewUrl}}';
}
int _vipEntryLevel(SocialChatUserProfileManager profileManager) {
final status = _vipStatus;
if (status != null && status.isActive && status.levelInt > 0) {
return status.levelInt.clamp(1, 5).toInt();
}
final vipResource = profileManager.currentUserProfile?.getVIP();
return _parseVipLevel(vipResource?.name) ??
_parseVipLevel(vipResource?.code) ??
0;
}
int? _parseVipLevel(String? value) {
final text = value?.trim();
if (text == null || text.isEmpty) {
return null;
}
final vipMatch = RegExp(
r'VIP\s*([1-5])',
caseSensitive: false,
).firstMatch(text);
if (vipMatch != null) {
return int.tryParse(vipMatch.group(1) ?? '');
}
final trailingLevelMatch = RegExp(r'([1-5])$').firstMatch(text);
if (trailingLevelMatch != null) {
return int.tryParse(trailingLevelMatch.group(1) ?? '');
}
return null;
}
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,
child: LayoutBuilder(
builder: (context, constraints) {
final width =
constraints.maxWidth.isFinite
? constraints.maxWidth
: ScreenUtil().screenWidth - 20.w;
final height = width / 5;
return SizedBox(
width: width,
height: height,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Positioned.fill(
child: Transform(
alignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_inviteActivityHorizontalScale,
1.0,
1.0,
),
child: SCSvgaAssetWidget(
assetPath: _inviteActivitySvgaAsset,
width: width,
height: height,
active: true,
loop: true,
fit: BoxFit.fill,
allowDrawingOverflow: true,
),
),
),
],
),
);
},
),
);
}
String get _inviteActivitySvgaAsset {
switch (SCGlobalConfig.lang) {
case 'ar':
return 'sc_images/index/sc_invite_activity_rtl.svga';
case 'bn':
case 'fa':
return 'sc_images/index/sc_invite_activity_fa.svga';
case 'tr':
case 'pt':
return 'sc_images/index/sc_invite_activity_pt.svga';
default:
return 'sc_images/index/sc_invite_activity.svga';
}
}
Widget _buildMenuCard2() {
final userProfile = context.watch<SocialChatUserProfileManager>();
final items = <_MenuRowData>[];
@ -343,7 +682,7 @@ class _MePage2State extends State<MePage2> {
if (userProfile.userIdentity?.bdLeader ?? false) {
items.add(
_MenuRowData(
title: SCAppLocalizations.of(context)!.bdLeader,
title: SCAppLocalizations.of(context)!.admin,
assetIcon: 'sc_images/index/sc_icon_bd_leader.png',
onTap: () {
SCNavigatorUtils.push(
@ -459,6 +798,8 @@ class _MePage2State extends State<MePage2> {
required String title,
required String iconPath,
required VoidCallback onTap,
double? iconWidth,
double? iconHeight,
}) {
return Expanded(
child: SCDebounceWidget(
@ -474,10 +815,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

@ -0,0 +1,392 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
class VipBenefitPage extends StatefulWidget {
const VipBenefitPage({super.key, this.initialIndex = 0});
final int initialIndex;
@override
State<VipBenefitPage> createState() => _VipBenefitPageState();
}
class _VipBenefitPageState extends State<VipBenefitPage> {
static const List<_VipBenefitData> _benefits = [
_VipBenefitData(
title: 'Gif Profile Picture',
description: 'You can upload a Gif image as your profile picture',
icon: Icons.image_outlined,
),
_VipBenefitData(
title: 'Colorful Nickname',
description: 'Your nickname can use a special colorful display effect',
icon: Icons.notes_rounded,
),
_VipBenefitData(
title: 'Gif Room Picture',
description: 'You can use a Gif image as your room cover picture',
icon: Icons.home_rounded,
),
_VipBenefitData(
title: 'Colorful ID',
description: 'Your user ID can use a highlighted colorful style',
icon: Icons.pin_rounded,
isIdIcon: true,
),
];
late int _selectedIndex;
@override
void initState() {
super.initState();
_selectedIndex = widget.initialIndex.clamp(0, _benefits.length - 1).toInt();
}
@override
Widget build(BuildContext context) {
final selected = _benefits[_selectedIndex];
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF0D2B22), Color(0xFF0E0E0E)],
stops: const [0, 0.40],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: Image.asset(
'sc_images/vip/sc_vip_page_bg.png',
width: ScreenUtil().screenWidth,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 300.w,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.36),
Colors.black.withValues(alpha: 0.62),
Colors.transparent,
],
stops: const [0, 0.52, 1],
),
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
_buildTopBar(context),
SizedBox(height: 18.w),
_buildBenefitTabs(),
SizedBox(height: 36.w),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(36.w, 0, 36.w, 32.w),
child: _buildBenefitCard(selected),
),
),
],
),
),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(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(
'VIP Benefits',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildBenefitTabs() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int index = 0; index < _benefits.length; index++)
_buildBenefitTab(index, _benefits[index]),
],
);
}
Widget _buildBenefitTab(int index, _VipBenefitData item) {
final selected = index == _selectedIndex;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedIndex = index),
child: SizedBox(
width: 58.w,
child: Column(
children: [
Container(
width: 54.w,
height: 54.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
selected
? Colors.white.withValues(alpha: 0.08)
: Colors.transparent,
),
alignment: Alignment.center,
child: _buildBenefitIcon(item, size: 28.w),
),
SizedBox(height: 8.w),
AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: selected ? 1 : 0,
child: Container(
width: 20.w,
height: 3.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999.w),
),
),
),
],
),
),
);
}
Widget _buildBenefitCard(_VipBenefitData item) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF171717),
borderRadius: BorderRadius.circular(12.w),
),
padding: EdgeInsets.fromLTRB(14.w, 22.w, 14.w, 14.w),
child: Column(
children: [
Text(
item.title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 14.w),
Text(
item.description,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 14.sp,
fontWeight: FontWeight.w400,
height: 1.22,
),
),
SizedBox(height: 18.w),
_buildPreviewPlaceholder(item),
],
),
);
}
Widget _buildPreviewPlaceholder(_VipBenefitData item) {
return Container(
width: double.infinity,
height: 260.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF234F73), Color(0xFFF5F5F5)],
stops: const [0, 0.58],
),
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 18.w,
top: 16.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 24.w,
),
),
Positioned(
top: 80.w,
child: Container(
width: 64.w,
height: 64.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(color: Colors.white, width: 2.w),
),
alignment: Alignment.center,
child: _buildBenefitIcon(item, size: 34.w),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 110.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.w),
topRight: Radius.circular(18.w),
bottomLeft: Radius.circular(12.w),
bottomRight: Radius.circular(12.w),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
style: TextStyle(
color: const Color(0xFF333333),
fontSize: 17.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 10.w),
Container(
width: 88.w,
height: 18.w,
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(9.w),
),
alignment: Alignment.center,
child: Text(
'Placeholder',
style: TextStyle(
color: const Color(0xFF555555),
fontSize: 9.sp,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildBenefitIcon(_VipBenefitData item, {required double size}) {
if (item.isIdIcon) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.w),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF7244), Color(0xFFFFD84E)],
),
),
alignment: Alignment.center,
child: Text(
'ID',
style: TextStyle(
color: Colors.white,
fontSize: (size * 0.42).sp,
fontWeight: FontWeight.w800,
height: 1,
),
),
);
}
return ShaderMask(
shaderCallback:
(bounds) => const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF6F3D), Color(0xFFFFD84E)],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Icon(item.icon, color: Colors.white, size: size),
);
}
}
class _VipBenefitData {
const _VipBenefitData({
required this.title,
required this.description,
required this.icon,
this.isIdIcon = false,
});
final String title;
final String description;
final IconData icon;
final bool isIdIcon;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
class VipInstructionPage extends StatefulWidget {
const VipInstructionPage({super.key});
@override
State<VipInstructionPage> createState() => _VipInstructionPageState();
}
class _VipInstructionPageState extends State<VipInstructionPage> {
_VipInstructionTab _selectedTab = _VipInstructionTab.record;
static const List<_VipRecordRow> _recordRows = [
_VipRecordRow(
level: 1,
validDays: '30days',
type: 'Expiration',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 2,
validDays: '10days',
type: 'Card',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 3,
validDays: '30days',
type: 'Renew',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 4,
validDays: '30days',
type: 'Purchase',
time: '21/09/2029\n12:99:00',
),
_VipRecordRow(
level: 5,
validDays: '30days',
type: 'Upgrade',
time: '21/09/2029\n12:99:00',
),
];
static const List<_VipDescriptionSection> _descriptionSections = [
_VipDescriptionSection(
'Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.',
),
_VipDescriptionSection(
'You can upgrade to a higher VIP level by paying the price difference. After upgrading, a new 30-day validity period will be given.',
bullets: [
'Price Difference = Target Price - Remaining Price for Current Days.',
'The upgrade takes effect immediately, and the remaining time of the previous level will be overwritten.',
],
),
_VipDescriptionSection(
'Once a higher-level VIP is activated, it is not possible to purchase a lower-level VIP.',
),
_VipDescriptionSection(
'Different levels cannot be stacked simultaneously; only one level can be activated at a time.',
),
_VipDescriptionSection(
'If using a card from your inventory to activate a new level:',
bullets: [
'If the new level is higher than the current level, it will directly upgrade, and the remaining time of the previous level will be cleared.',
'If the new level is lower than the current level, activation cannot be used.',
],
),
_VipDescriptionSection(
'After the VIP expires, the corresponding privileges will be lost, but the purchase records will not be cleared.',
),
_VipDescriptionSection(
'The platform reserves the final right to interpret the VIP rules.',
),
];
static const Color _tableLightColor = Color(0xFF262626);
static const Color _tableDarkColor = Color(0xFF191919);
static const Color _tableDividerColor = Color(0xFF333333);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Color(0xFF0D2B22), Color(0xFF0E0E0E)],
stops: const [0, 0.40],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: Image.asset(
'sc_images/vip/sc_vip_page_bg.png',
width: ScreenUtil().screenWidth,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 300.w,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.36),
Colors.black.withValues(alpha: 0.62),
Colors.transparent,
],
stops: const [0, 0.52, 1],
),
),
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
_buildTopBar(context),
SizedBox(height: 6.w),
_buildTabs(),
Expanded(
child:
_selectedTab == _VipInstructionTab.record
? _buildRecordView()
: _buildDescriptionView(),
),
],
),
),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(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(
'VIP Rules',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildTabs() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildTab(_VipInstructionTab.record, 'Record'),
SizedBox(width: 45.5.w),
_buildTab(_VipInstructionTab.description, 'Description'),
],
);
}
Widget _buildTab(_VipInstructionTab tab, String title) {
final selected = _selectedTab == tab;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _selectedTab = tab),
child: Column(
children: [
Text(
title,
style: TextStyle(
color:
selected ? Colors.white : Colors.white.withValues(alpha: 0.5),
fontFamily: 'Source Han Sans SC',
fontSize: 18.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 1,
),
),
SizedBox(height: 8.w),
AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: selected ? 1 : 0,
child: Container(
width: 18.w,
height: 4.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999.w),
),
),
),
],
),
);
}
Widget _buildRecordView() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 18.w, 16.w, 24.w),
child: _buildRecordTable(),
);
}
Widget _buildRecordTable() {
return ClipRRect(
borderRadius: BorderRadius.circular(12.w),
child: Container(
color: _tableDarkColor,
child: Column(
children: [
_buildTableRow(
const ['Level', 'Valid Days', 'type', 'Time'],
height: 38.5.w,
isHeader: true,
isLastRow: false,
),
for (int i = 0; i < _recordRows.length; i++)
_buildRecordDataRow(
_recordRows[i],
isLastRow: i == _recordRows.length - 1,
),
],
),
),
);
}
Widget _buildTableRow(
List<String> values, {
required double height,
required bool isHeader,
required bool isLastRow,
}) {
return SizedBox(
height: height,
child: Row(
children: List.generate(values.length, (index) {
return _buildTableCell(
columnIndex: index,
isLastColumn: index == values.length - 1,
isLastRow: isLastRow,
flex: _columnFlex(index),
child: Text(
values[index],
textAlign: TextAlign.center,
style: _tableTextStyle,
),
);
}),
),
);
}
Widget _buildRecordDataRow(_VipRecordRow row, {required bool isLastRow}) {
return SizedBox(
height: 76.w,
child: Row(
children: [
_buildTableCell(
columnIndex: 0,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(0),
child: Image.asset(
'sc_images/vip/sc_vip_badge_${row.level}.png',
width: 60.w,
height: 60.w,
fit: BoxFit.contain,
),
),
_buildTableCell(
columnIndex: 1,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(1),
child: Text(
row.validDays,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
_buildTableCell(
columnIndex: 2,
isLastColumn: false,
isLastRow: isLastRow,
flex: _columnFlex(2),
child: Text(
row.type,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
_buildTableCell(
columnIndex: 3,
isLastColumn: true,
isLastRow: isLastRow,
flex: _columnFlex(3),
child: Text(
row.time,
textAlign: TextAlign.center,
style: _tableTextStyle,
),
),
],
),
);
}
Widget _buildTableCell({
required int columnIndex,
required bool isLastColumn,
required bool isLastRow,
required int flex,
required Widget child,
}) {
return Expanded(
flex: flex,
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
decoration: BoxDecoration(
color: columnIndex.isEven ? _tableLightColor : _tableDarkColor,
border: Border(
right:
isLastColumn
? BorderSide.none
: BorderSide(color: _tableDividerColor, width: 1.w),
bottom:
isLastRow
? BorderSide.none
: BorderSide(color: _tableDividerColor, width: 1.w),
),
),
child: child,
),
);
}
Widget _buildDescriptionView() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 18.w, 16.w, 32.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < _descriptionSections.length; i++) ...[
_buildDescriptionSection(i + 1, _descriptionSections[i]),
SizedBox(height: 18.w),
],
],
),
);
}
Widget _buildDescriptionSection(int index, _VipDescriptionSection section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$index. ${section.text}', style: _descriptionTextStyle),
if (section.bullets.isNotEmpty) ...[
SizedBox(height: 8.w),
for (final bullet in section.bullets) ...[
Padding(
padding: EdgeInsetsDirectional.only(start: 18.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: _descriptionHighlightStyle),
SizedBox(width: 8.w),
Expanded(
child: Text(bullet, style: _descriptionHighlightStyle),
),
],
),
),
SizedBox(height: 4.w),
],
],
],
);
}
int _columnFlex(int index) {
switch (index) {
case 0:
return 92;
case 1:
return 100;
case 2:
return 114;
case 3:
default:
return 110;
}
}
TextStyle get _tableTextStyle => TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 12.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
TextStyle get _descriptionTextStyle => TextStyle(
color: Colors.white,
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
TextStyle get _descriptionHighlightStyle => TextStyle(
color: const Color(0xFFFF9830),
fontFamily: 'Source Han Sans SC',
fontSize: 16.sp,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.w400,
height: 14.5 / 12,
);
}
enum _VipInstructionTab { record, description }
class _VipRecordRow {
const _VipRecordRow({
required this.level,
required this.validDays,
required this.type,
required this.time,
});
final int level;
final String validDays;
final String type;
final String time;
}
class _VipDescriptionSection {
const _VipDescriptionSection(this.text, {this.bullets = const []});
final String text;
final List<String> bullets;
}

View File

@ -0,0 +1,32 @@
import 'package:fluro/fluro.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
import 'package:yumi/modules/user/vip/vip_benefit_page.dart';
import 'package:yumi/modules/user/vip/vip_detail_page.dart';
import 'package:yumi/modules/user/vip/vip_instruction_page.dart';
class VipRoute implements SCIRouterProvider {
static String detail = '/main/me/vip/detail';
static String instruction = '/main/me/vip/instruction';
static String benefit = '/main/me/vip/benefit';
@override
void initRouter(FluroRouter router) {
router.define(
detail,
handler: Handler(handlerFunc: (_, params) => const VipDetailPage()),
);
router.define(
instruction,
handler: Handler(handlerFunc: (_, params) => const VipInstructionPage()),
);
router.define(
benefit,
handler: Handler(
handlerFunc:
(_, params) => VipBenefitPage(
initialIndex: int.tryParse(params['index']?.first ?? '') ?? 0,
),
),
);
}
}

View File

@ -12,6 +12,7 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_deviceId_utils.dart';
import 'package:yumi/shared/tools/sc_h5_url_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/tools/sc_url_launcher_utils.dart';
import 'package:yumi/main.dart';
@ -46,12 +47,7 @@ class _WebViewPageState extends State<WebViewPage> {
@override
void initState() {
super.initState();
if (widget.url.split("?").length > 1) {
urlLink = "${widget.url}&";
} else {
urlLink = "${widget.url}?";
}
urlLink = "${urlLink}lang=${SCGlobalConfig.lang}";
urlLink = SCH5UrlUtils.appendAppParams(widget.url);
// WebViewController
_controller =
WebViewController()

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -304,16 +303,16 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (memberList.isNotEmpty) {
if (memberList.first.userID ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
Provider.of<RealTimeCommunicationManager>(
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
).engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
);
unawaited(
rtcProvider.handleSelfMicRemovedByRemote(refreshMicList: false),
);
///退
Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
).exitCurrentVoiceRoomSession(false).whenComplete(() {
rtcProvider.exitCurrentVoiceRoomSession(false).whenComplete(() {
SCRoomUtils.closeAllDialogs();
SmartDialog.show(
tag: "showConfirmDialog",
@ -890,6 +889,28 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
///
void seedSystemRoomTips(String tips, {String groupId = ""}) {
final normalizedTips = tips.trim();
if (normalizedTips.isEmpty) {
return;
}
for (final msg in roomAllMsgList) {
if (msg.type == SCRoomMsgType.systemTips && msg.msg == normalizedTips) {
if ((msg.groupId ?? "").isEmpty && groupId.isNotEmpty) {
msg.groupId = groupId;
}
return;
}
}
addMsg(
Msg(
groupId: groupId,
msg: normalizedTips,
type: SCRoomMsgType.systemTips,
),
);
}
addMsg(Msg msg) {
final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg);
if (mergedGiftMsg != null) {
@ -916,7 +937,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
}
msgChatListener?.call(msg);
} else if (msg.type == SCRoomMsgType.image) {
} else if (msg.type == SCRoomMsgType.image ||
(msg.type == SCRoomMsgType.emoticons && (msg.number ?? -1) < 0)) {
roomChatMsgList.insert(0, msg);
if (roomChatMsgList.length > 250) {
print('大于200条消息');
@ -1394,16 +1416,16 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
if (msg.type == SCRoomMsgType.killXiaMai) {
///
if (msg.msg == AccountStorage().getCurrentUser()?.userProfile?.id) {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
}
Provider.of<RealTimeCommunicationManager>(
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).retrieveMicrophoneList(notifyIfUnchanged: false);
);
if (msg.msg == AccountStorage().getCurrentUser()?.userProfile?.id) {
unawaited(
rtcProvider.handleSelfMicRemovedByRemote(refreshMicList: false),
);
}
rtcProvider.retrieveMicrophoneList(notifyIfUnchanged: false);
return;
}
@ -1431,11 +1453,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
return;
}
if (msg.type == SCRoomMsgType.emoticons) {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).starPlayEmoji(msg);
return;
if ((msg.number ?? -1) >= 0) {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).starPlayEmoji(msg);
return;
}
}
if (msg.type == SCRoomMsgType.micChange) {
Provider.of<RealTimeCommunicationManager>(

View File

@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
class SCHomeRoomPreloadManager {
SCHomeRoomPreloadManager._();
static final SCHomeRoomPreloadManager instance = SCHomeRoomPreloadManager._();
static const Duration _partyRoomCacheTtl = Duration(seconds: 30);
Future<List<SocialChatRoomRes>>? _partyRoomsFuture;
List<SocialChatRoomRes>? _partyRooms;
DateTime? _partyRoomsLoadedAt;
void preloadInitialHomeRooms() {
final token = AccountStorage().getToken();
if (AccountStorage().getCurrentUser() == null || token.isEmpty) {
return;
}
unawaited(
loadPartyRooms().catchError((error, stackTrace) {
debugPrint('[HomePreload] party rooms preload failed: $error');
return <SocialChatRoomRes>[];
}),
);
}
Future<List<SocialChatRoomRes>> loadPartyRooms({bool forceRefresh = false}) {
if (!forceRefresh && _hasFreshPartyRoomCache) {
return Future.value(List<SocialChatRoomRes>.from(_partyRooms!));
}
if (!forceRefresh) {
final pendingFuture = _partyRoomsFuture;
if (pendingFuture != null) {
return pendingFuture;
}
}
final future = SCChatRoomRepository()
.discovery(allRegion: true)
.then((rooms) {
_partyRooms = List<SocialChatRoomRes>.from(rooms);
_partyRoomsLoadedAt = DateTime.now();
return List<SocialChatRoomRes>.from(rooms);
})
.whenComplete(() {
_partyRoomsFuture = null;
});
_partyRoomsFuture = future;
return future;
}
bool get _hasFreshPartyRoomCache {
final rooms = _partyRooms;
final loadedAt = _partyRoomsLoadedAt;
if (rooms == null || loadedAt == null) {
return false;
}
return DateTime.now().difference(loadedAt) < _partyRoomCacheTtl;
}
}

View File

@ -0,0 +1,165 @@
import 'dart:io';
import 'package:on_audio_query/on_audio_query.dart';
import 'package:path/path.dart' as p;
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart';
class LocalMusicScanner {
LocalMusicScanner({OnAudioQuery? audioQuery})
: _audioQuery = audioQuery ?? OnAudioQuery();
final OnAudioQuery _audioQuery;
Future<List<SCMusicFolderMode>> scanMp3Folders({
List<SCMusicMode> addedSongs = const [],
}) async {
final hasPermission = await SCPermissionUtils.checkAudioPermission();
if (!hasPermission) {
return const <SCMusicFolderMode>[];
}
final List<SongModel> songs;
try {
songs = await _audioQuery.querySongs(
sortType: SongSortType.TITLE,
orderType: OrderType.ASC_OR_SMALLER,
uriType: UriType.EXTERNAL,
ignoreCase: true,
);
} catch (_) {
return const <SCMusicFolderMode>[];
}
final addedIds = addedSongs.map((item) => item.id).toSet();
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
final byFolder = <String, List<SCMusicMode>>{};
final seen = <String>{};
for (final song in songs) {
if (!_isMp3(song)) {
continue;
}
final localPath = _songString(song, "_data").trim();
if (localPath.isEmpty || !_fileExists(localPath)) {
continue;
}
final id = _songString(song, "_id");
final key = id.isNotEmpty ? id : localPath;
if (!seen.add(key)) {
continue;
}
final folderPath = p.dirname(localPath);
final fileName = _safeFileName(song, localPath);
final title = _safeTitle(song, fileName);
final model = SCMusicMode(
id: key,
title: title,
artist: _songString(song, "artist"),
durationMs: _songInt(song, "duration"),
localPath: localPath,
contentUri: _songString(song, "_uri"),
folderPath: folderPath,
fileName: fileName,
size: _songInt(song, "_size"),
addedAt: DateTime.now().millisecondsSinceEpoch,
);
byFolder.putIfAbsent(folderPath, () => <SCMusicMode>[]).add(model);
}
final folders =
byFolder.entries.map((entry) {
final path = entry.key;
final folderSongs =
entry.value..sort(
(a, b) =>
a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final addedCount =
folderSongs.where((song) {
return addedIds.contains(song.id) ||
addedPaths.contains(song.localPath);
}).length;
return SCMusicFolderMode(
id: path,
name: p.basename(path).isEmpty ? path : p.basename(path),
path: path,
songs: List<SCMusicMode>.unmodifiable(folderSongs),
addedCount: addedCount,
);
}).toList()
..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return List<SCMusicFolderMode>.unmodifiable(folders);
}
bool _isMp3(SongModel song) {
final extension = _songString(
song,
"file_extension",
).toLowerCase().replaceFirst(".", "");
if (extension == "mp3") {
return true;
}
return _songString(song, "_display_name").toLowerCase().endsWith(".mp3") ||
_songString(song, "_data").toLowerCase().endsWith(".mp3");
}
bool _fileExists(String path) {
try {
return File(path).existsSync();
} catch (_) {
return false;
}
}
String _safeFileName(SongModel song, String localPath) {
final displayName = _songString(song, "_display_name").trim();
if (displayName.isNotEmpty) {
return displayName;
}
return p.basename(localPath);
}
String _safeTitle(SongModel song, String fileName) {
final title = _songString(song, "title").trim();
if (title.isNotEmpty) {
return title;
}
final displayNameWOExt = _songString(song, "_display_name_wo_ext").trim();
if (displayNameWOExt.isNotEmpty) {
return displayNameWOExt;
}
return p.basenameWithoutExtension(fileName);
}
String _songString(SongModel song, String key) {
final value = _songValue(song, key);
if (value == null) {
return "";
}
return value.toString();
}
int _songInt(SongModel song, String key) {
final value = _songValue(song, key);
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "") ?? 0;
}
Object? _songValue(SongModel song, String key) {
try {
return song.getMap[key];
} catch (_) {
return null;
}
}
}

View File

@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/room_music_repository.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
enum RoomMusicPlayMode { list, shuffle, single }
class RoomMusicManager extends ChangeNotifier {
RoomMusicManager({RoomMusicRepository? repository})
: _repository = repository ?? RoomMusicRepository() {
_playlist = _repository.loadSongs();
}
final RoomMusicRepository _repository;
final Random _random = Random();
RtcProvider? _rtcProvider;
Timer? _positionTimer;
List<SCMusicMode> _playlist = <SCMusicMode>[];
SCMusicMode? _current;
RoomMusicPlayMode _playMode = RoomMusicPlayMode.list;
int _volume = 70;
int _positionMs = 0;
int _durationMs = 0;
int _lastStartAtMs = 0;
int _ignoreStoppedUntilMs = 0;
bool _isPlaying = false;
bool _isPaused = false;
bool _isStarting = false;
bool _isStoppingByUser = false;
bool _lastLoopback = true;
bool _roomPlayerVisible = false;
List<SCMusicMode> get playlist => List.unmodifiable(_playlist);
SCMusicMode? get current => _current;
RoomMusicPlayMode get playMode => _playMode;
int get volume => _volume;
int get positionMs => _positionMs;
int get durationMs =>
_durationMs > 0 ? _durationMs : (_current?.durationMs ?? 0);
bool get isPlaying => _isPlaying;
bool get isPaused => _isPaused;
bool get hasCurrent => _current != null;
bool get roomPlayerVisible => _roomPlayerVisible;
bool get isPublishingToRoom => _isPlaying && !_lastLoopback;
int get currentIndex {
final currentId = _current?.id;
if (currentId == null) {
return -1;
}
return _playlist.indexWhere((item) => item.id == currentId);
}
void attachRtcProvider(RtcProvider provider) {
if (_rtcProvider == provider) {
return;
}
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
_rtcProvider = provider;
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
}
Future<void> reload() async {
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> applyFolderSelection({
required SCMusicFolderMode folder,
required Set<String> selectedIds,
}) async {
final currentById = {for (final item in _playlist) item.id: item};
final folderIds = folder.songs.map((item) => item.id).toSet();
final next = <SCMusicMode>[
..._playlist.where((item) => !folderIds.contains(item.id)),
];
final now = DateTime.now().millisecondsSinceEpoch;
for (final song in folder.songs) {
if (!selectedIds.contains(song.id)) {
continue;
}
final existing = currentById[song.id];
next.add(
(existing ?? song).copyWith(
addedAt:
existing?.addedAt == null || existing!.addedAt <= 0
? now
: existing.addedAt,
),
);
}
await _savePlaylist(next);
}
Future<void> deleteMusic(SCMusicMode item) async {
final deletingCurrent = _current?.id == item.id;
if (deletingCurrent) {
await stop(clearCurrent: true);
}
await _savePlaylist(_playlist.where((song) => song.id != item.id).toList());
}
Future<void> reorder(int oldIndex, int newIndex) async {
if (oldIndex < 0 || oldIndex >= _playlist.length) {
return;
}
final next = [..._playlist];
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = next.removeAt(oldIndex);
next.insert(newIndex.clamp(0, next.length).toInt(), item);
await _savePlaylist(next);
}
Future<void> play(SCMusicMode item, {int startPositionMs = 0}) async {
if (item.playPath.isEmpty) {
SCTts.show("Music file not found");
return;
}
if (!item.playPath.startsWith("content://") &&
!File(item.localPath).existsSync()) {
SCTts.show("Music file not found");
await deleteMusic(item);
return;
}
_current = item;
_durationMs = item.durationMs;
_positionMs = startPositionMs;
_isPaused = false;
_roomPlayerVisible = true;
await _startCurrent(startPositionMs: startPositionMs);
}
Future<void> pause() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
await _rtcProvider?.pauseRoomMusicAudioMixing();
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
}
Future<void> resume() async {
if (_current == null || _rtcProvider == null) {
return;
}
if (_isPaused) {
await _rtcProvider?.resumeRoomMusicAudioMixing();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
return;
}
await _startCurrent(startPositionMs: _positionMs);
}
Future<void> stop({bool clearCurrent = false}) async {
_isStoppingByUser = true;
try {
await _rtcProvider?.stopRoomMusicAudioMixing();
} catch (_) {
// Ignore stop failures; local state must still be reset.
} finally {
_isStoppingByUser = false;
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
if (clearCurrent) {
_current = null;
_durationMs = 0;
_roomPlayerVisible = false;
}
notifyListeners();
}
}
Future<void> stopForRoomExit() async {
await stop(clearCurrent: true);
}
Future<void> previous() async {
if (_playlist.isEmpty) {
return;
}
final index = currentIndex;
final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
await play(_playlist[targetIndex]);
}
Future<void> next({bool fromAutoComplete = false}) async {
if (_playlist.isEmpty) {
await stop(clearCurrent: true);
return;
}
if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) {
await play(_current ?? _playlist.first);
return;
}
int targetIndex = 0;
final index = currentIndex;
if (_playMode == RoomMusicPlayMode.shuffle) {
if (_playlist.length == 1) {
targetIndex = 0;
} else {
do {
targetIndex = _random.nextInt(_playlist.length);
} while (targetIndex == index);
}
} else {
targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length;
}
await play(_playlist[targetIndex]);
}
Future<void> seek(int positionMs) async {
final safePosition = positionMs.clamp(0, durationMs).toInt();
_positionMs = safePosition;
await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
notifyListeners();
}
Future<void> setVolume(int volume) async {
_volume = volume.clamp(0, 100).toInt();
await _rtcProvider?.setRoomMusicAudioMixingVolume(_volume);
notifyListeners();
}
String switchPlayMode() {
switch (_playMode) {
case RoomMusicPlayMode.list:
_playMode = RoomMusicPlayMode.shuffle;
notifyListeners();
return "随机播放";
case RoomMusicPlayMode.shuffle:
_playMode = RoomMusicPlayMode.single;
notifyListeners();
return "单曲循环播放";
case RoomMusicPlayMode.single:
_playMode = RoomMusicPlayMode.list;
notifyListeners();
return "列表顺序播放";
}
}
void toggleRoomPlayerVisible() {
if (_current == null) {
return;
}
_roomPlayerVisible = !_roomPlayerVisible;
notifyListeners();
}
void hideRoomPlayer() {
if (!_roomPlayerVisible) {
return;
}
_roomPlayerVisible = false;
notifyListeners();
}
Future<void> syncPlaybackRouting() async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || !_isPlaying) {
return;
}
final nextLoopback = !rtcProvider.isOnMai();
if (nextLoopback == _lastLoopback) {
return;
}
final position = await rtcProvider
.getRoomMusicAudioMixingPosition()
.catchError((_) => _positionMs);
_ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900;
try {
await rtcProvider.stopRoomMusicAudioMixing();
await _startCurrent(startPositionMs: position);
} catch (_) {
_isPlaying = false;
_isPaused = false;
notifyListeners();
}
}
Future<void> _savePlaylist(List<SCMusicMode> songs) async {
await _repository.saveSongs(songs);
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> _startCurrent({required int startPositionMs}) async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || _isStarting) {
return;
}
_isStarting = true;
try {
await _waitForStartInterval();
_lastLoopback = !rtcProvider.isOnMai();
await rtcProvider.startRoomMusicAudioMixing(
filePath: current.playPath,
loopback: _lastLoopback,
cycle: 1,
startPos: startPositionMs,
);
_lastStartAtMs = DateTime.now().millisecondsSinceEpoch;
await rtcProvider.setRoomMusicAudioMixingVolume(_volume);
_positionMs = startPositionMs;
_durationMs =
current.durationMs > 0
? current.durationMs
: await rtcProvider.getRoomMusicAudioMixingDuration();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
} catch (_) {
_isPlaying = false;
_isPaused = false;
_stopPositionTimer();
SCTts.show("Music playback failed");
notifyListeners();
} finally {
_isStarting = false;
}
}
Future<void> _waitForStartInterval() async {
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _lastStartAtMs;
if (elapsed < 550) {
await Future<void>.delayed(Duration(milliseconds: 550 - elapsed));
}
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
unawaited(_refreshPosition());
});
}
void _stopPositionTimer() {
_positionTimer?.cancel();
_positionTimer = null;
}
Future<void> _refreshPosition() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
try {
final position = await _rtcProvider!.getRoomMusicAudioMixingPosition();
final duration = await _rtcProvider!.getRoomMusicAudioMixingDuration();
if (position >= 0) {
_positionMs = position;
}
if (duration > 0) {
_durationMs = duration;
}
notifyListeners();
} catch (_) {
// Position polling is best-effort only.
}
}
void _handleMixingStateChanged(
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStatePaused:
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStateStopped:
if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) {
return;
}
_stopPositionTimer();
if (reason ==
AudioMixingReasonType.audioMixingReasonAllLoopsCompleted) {
unawaited(next(fromAutoComplete: true));
return;
}
if (!_isStoppingByUser) {
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
notifyListeners();
}
break;
case AudioMixingStateType.audioMixingStateFailed:
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
SCTts.show("Music playback failed");
notifyListeners();
break;
}
}
void _handleMicRouteChanged(bool isOnMic) {
unawaited(syncPlaybackRouting());
}
void _restoreCurrentAfterPlaylistChange() {
final currentId = _current?.id;
if (currentId == null) {
return;
}
final index = _playlist.indexWhere((item) => item.id == currentId);
if (index >= 0) {
_current = _playlist[index];
return;
}
_current = null;
_isPlaying = false;
_isPaused = false;
_roomPlayerVisible = false;
_positionMs = 0;
_durationMs = 0;
_stopPositionTimer();
}
@override
void dispose() {
_positionTimer?.cancel();
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
super.dispose();
}
}

View File

@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
class RoomMusicRepository {
List<SCMusicMode> loadSongs() {
final raw = DataPersistence.getUserRoomMusic();
if (raw.trim().isEmpty) {
return <SCMusicMode>[];
}
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <SCMusicMode>[];
}
final songs =
decoded
.map((item) => SCMusicMode.fromJson(item))
.where((item) => item.hasPlayablePath)
.toList()
..sort((a, b) {
final sort = a.sortIndex.compareTo(b.sortIndex);
if (sort != 0) {
return sort;
}
return a.addedAt.compareTo(b.addedAt);
});
return _normalizeSort(songs);
} catch (_) {
return <SCMusicMode>[];
}
}
Future<bool> saveSongs(List<SCMusicMode> songs) {
final normalized = _normalizeSort(_dedupeSongs(songs));
return DataPersistence.setUserRoomMusic(
jsonEncode(normalized.map((item) => item.toJson()).toList()),
);
}
List<SCMusicMode> _dedupeSongs(List<SCMusicMode> songs) {
final seen = <String>{};
final result = <SCMusicMode>[];
for (final song in songs) {
final key = song.id.isNotEmpty ? song.id : song.localPath;
if (key.isEmpty || !seen.add(key)) {
continue;
}
result.add(song);
}
return result;
}
List<SCMusicMode> _normalizeSort(List<SCMusicMode> songs) {
return List<SCMusicMode>.generate(
songs.length,
(index) => songs[index].copyWith(sortIndex: index),
growable: true,
);
}
}

View File

@ -9,11 +9,12 @@ import 'package:yumi/shared/business_logic/models/res/sc_edit_room_info_res.dart
import 'package:yumi/shared/business_logic/models/res/my_room_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_contribute_level_res.dart';
class SocialChatRoomManager extends ChangeNotifier {
MyRoomRes? myRoom;
SCRoomContributeLevelRes? roomContributeLevelRes;
bool isMyRoomLoading = false;
bool hasLoadedMyRoom = false;
class SocialChatRoomManager extends ChangeNotifier {
MyRoomRes? myRoom;
SCRoomContributeLevelRes? roomContributeLevelRes;
String? _roomContributeLevelRoomId;
bool isMyRoomLoading = false;
bool hasLoadedMyRoom = false;
Future<void> fetchMyRoomData() async {
if (isMyRoomLoading) {
@ -73,13 +74,41 @@ class SocialChatRoomManager extends ChangeNotifier {
SCLoadingManager.hide();
}
void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) {
if (forceRefresh) {
roomContributeLevelRes = null;
}
SCChatRoomRepository().roomContributionActivity(roomId).then((value) {
roomContributeLevelRes = value;
notifyListeners();
});
}
}
void clearContributionLevelData({bool notify = true}) {
_roomContributeLevelRoomId = null;
if (roomContributeLevelRes == null) {
return;
}
roomContributeLevelRes = null;
if (notify) {
notifyListeners();
}
}
void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) {
final resolvedRoomId = roomId.trim();
if (resolvedRoomId.isEmpty) {
clearContributionLevelData();
return;
}
final shouldClear =
forceRefresh || _roomContributeLevelRoomId != resolvedRoomId;
_roomContributeLevelRoomId = resolvedRoomId;
if (shouldClear && roomContributeLevelRes != null) {
roomContributeLevelRes = null;
notifyListeners();
}
SCChatRoomRepository()
.roomContributionActivity(resolvedRoomId)
.then((value) {
if (_roomContributeLevelRoomId != resolvedRoomId) {
return;
}
roomContributeLevelRes = value;
notifyListeners();
})
.catchError((_) {});
}
}

View File

@ -505,7 +505,13 @@ class SocialChatUserProfile {
}
PropsResources? getVIP() {
return null;
PropsResources? pr;
_useProps?.forEach((value) {
if (value.propsResources?.type == SCPropsType.NOBLE_VIP.name) {
pr = value.propsResources;
}
});
return pr;
}
PropsResources? getDataCard() {

View File

@ -0,0 +1,743 @@
class SCVipResourceRes {
SCVipResourceRes({
String? resourceId,
String? name,
String? url,
String? sourceUrl,
String? resourceUrl,
String? cover,
String? coverUrl,
}) {
_resourceId = resourceId;
_name = name;
_url = url;
_sourceUrl = sourceUrl;
_resourceUrl = resourceUrl;
_cover = cover;
_coverUrl = coverUrl;
}
SCVipResourceRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_resourceId = _stringValue(map['resourceId']);
_name = _stringValue(map['name']);
_url = _stringValue(map['url']);
_sourceUrl = _stringValue(map['sourceUrl']);
_resourceUrl = _stringValue(map['resourceUrl']);
_cover = _stringValue(map['cover']);
_coverUrl = _stringValue(map['coverUrl']);
}
String? _resourceId;
String? _name;
String? _url;
String? _sourceUrl;
String? _resourceUrl;
String? _cover;
String? _coverUrl;
String? get resourceId => _resourceId;
String? get name => _name;
String? get url => _url;
String? get sourceUrl => _sourceUrl;
String? get resourceUrl => _resourceUrl;
String? get cover => _cover;
String? get coverUrl => _coverUrl;
String? get previewUrl {
final cover = _firstNonBlank([_coverUrl, _cover]);
if (cover != null) return cover;
final staticUrl = _firstNonBlank(
[_url, _resourceUrl, _sourceUrl].where((value) {
final text = value?.trim();
return text != null && text.isNotEmpty && !_isSvgaUrl(text);
}),
);
if (staticUrl != null) return staticUrl;
return _firstNonBlank([_url, _resourceUrl, _sourceUrl]);
}
String? get sourceResourceUrl =>
_firstNonBlank([_sourceUrl, _resourceUrl, _url]);
bool get hasIdentity {
final id = _resourceId?.trim();
return id != null && id.isNotEmpty && id != '0';
}
bool get hasAnyUrl =>
_firstNonBlank([_url, _sourceUrl, _resourceUrl, _cover, _coverUrl]) !=
null;
bool get hasPreviewUrl => previewUrl != null;
bool get hasSourceUrl => sourceResourceUrl != null;
bool get hasContent =>
hasAnyUrl || hasIdentity || (_name?.trim().isNotEmpty ?? false);
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['resourceId'] = _resourceId;
map['name'] = _name;
map['url'] = _url;
map['sourceUrl'] = _sourceUrl;
map['resourceUrl'] = _resourceUrl;
map['cover'] = _cover;
map['coverUrl'] = _coverUrl;
return map;
}
}
class SCVipStatusRes {
SCVipStatusRes({
String? userId,
String? sysOrigin,
bool? active,
String? status,
num? level,
String? levelCode,
String? displayName,
String? startAt,
String? expireAt,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_active = active;
_status = status;
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_startAt = startAt;
_expireAt = expireAt;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
}
SCVipStatusRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_active = _boolValue(map['active']);
_status = _stringValue(map['status']);
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
}
String? _userId;
String? _sysOrigin;
bool? _active;
String? _status;
num? _level;
String? _levelCode;
String? _displayName;
String? _startAt;
String? _expireAt;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get active => _active;
String? get status => _status;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool get isActive => _active == true && (_level ?? 0) > 0;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP $levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['active'] = _active;
map['status'] = _status;
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
return map;
}
}
class SCVipLevelConfigRes {
SCVipLevelConfigRes({
num? level,
String? levelCode,
String? displayName,
bool? enabled,
num? durationDays,
num? priceGold,
SCVipResourceRes? badge,
SCVipResourceRes? avatarFrame,
SCVipResourceRes? entryEffect,
SCVipResourceRes? chatBubble,
SCVipResourceRes? floatPicture,
bool? canPurchase,
num? payableGold,
}) {
_level = level;
_levelCode = levelCode;
_displayName = displayName;
_enabled = enabled;
_durationDays = durationDays;
_priceGold = priceGold;
_badge = badge;
_avatarFrame = avatarFrame;
_entryEffect = entryEffect;
_chatBubble = chatBubble;
_floatPicture = floatPicture;
_canPurchase = canPurchase;
_payableGold = payableGold;
}
SCVipLevelConfigRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_level = _numValue(map['level']);
_levelCode = _stringValue(map['levelCode']);
_displayName = _stringValue(map['displayName']);
_enabled = _boolValue(map['enabled']);
_durationDays = _numValue(map['durationDays']);
_priceGold = _numValue(map['priceGold']);
_badge = _resourceValue(map['badge']);
_avatarFrame = _resourceValue(map['avatarFrame']);
_entryEffect = _resourceValue(map['entryEffect']);
_chatBubble = _resourceValue(map['chatBubble']);
_floatPicture = _resourceValue(map['floatPicture']);
_canPurchase = _boolValue(map['canPurchase']);
_payableGold = _numValue(map['payableGold']);
}
num? _level;
String? _levelCode;
String? _displayName;
bool? _enabled;
num? _durationDays;
num? _priceGold;
SCVipResourceRes? _badge;
SCVipResourceRes? _avatarFrame;
SCVipResourceRes? _entryEffect;
SCVipResourceRes? _chatBubble;
SCVipResourceRes? _floatPicture;
bool? _canPurchase;
num? _payableGold;
num? get level => _level;
String? get levelCode => _levelCode;
String? get displayName => _displayName;
bool? get enabled => _enabled;
num? get durationDays => _durationDays;
num? get priceGold => _priceGold;
SCVipResourceRes? get badge => _badge;
SCVipResourceRes? get avatarFrame => _avatarFrame;
SCVipResourceRes? get entryEffect => _entryEffect;
SCVipResourceRes? get chatBubble => _chatBubble;
SCVipResourceRes? get floatPicture => _floatPicture;
bool? get canPurchase => _canPurchase;
num? get payableGold => _payableGold;
int get levelInt => (_level ?? 0).toInt();
String get displayNameText {
final name = _displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final code = _levelCode?.trim();
if (code != null && code.isNotEmpty) return code;
return levelInt > 0 ? 'VIP$levelInt' : 'VIP';
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['level'] = _level;
map['levelCode'] = _levelCode;
map['displayName'] = _displayName;
map['enabled'] = _enabled;
map['durationDays'] = _durationDays;
map['priceGold'] = _priceGold;
if (_badge != null) map['badge'] = _badge?.toJson();
if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson();
if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson();
if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson();
if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson();
map['canPurchase'] = _canPurchase;
map['payableGold'] = _payableGold;
return map;
}
}
class SCVipHomeRes {
SCVipHomeRes({
String? userId,
String? sysOrigin,
bool? configured,
SCVipStatusRes? state,
List<SCVipLevelConfigRes>? levels,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_configured = configured;
_state = state;
_levels = levels;
}
SCVipHomeRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_configured = _boolValue(map['configured']);
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
_levels =
_asList(
map['levels'],
).map((dynamic item) => SCVipLevelConfigRes.fromJson(item)).toList();
}
String? _userId;
String? _sysOrigin;
bool? _configured;
SCVipStatusRes? _state;
List<SCVipLevelConfigRes>? _levels;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
bool? get configured => _configured;
SCVipStatusRes? get state => _state;
List<SCVipLevelConfigRes> get levels => _levels ?? <SCVipLevelConfigRes>[];
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['configured'] = _configured;
if (_state != null) map['state'] = _state?.toJson();
map['levels'] = levels.map((item) => item.toJson()).toList();
return map;
}
}
class SCVipPreviewRes {
SCVipPreviewRes({
String? userId,
String? sysOrigin,
String? orderType,
num? currentLevel,
num? targetLevel,
num? durationDays,
num? originalPriceGold,
num? payableGold,
String? startAt,
String? expireAt,
SCVipLevelConfigRes? targetConfig,
}) {
_userId = userId;
_sysOrigin = sysOrigin;
_orderType = orderType;
_currentLevel = currentLevel;
_targetLevel = targetLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_payableGold = payableGold;
_startAt = startAt;
_expireAt = expireAt;
_targetConfig = targetConfig;
}
SCVipPreviewRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_userId = _stringValue(map['userId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_orderType = _stringValue(map['orderType']);
_currentLevel = _numValue(map['currentLevel']);
_targetLevel = _numValue(map['targetLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_payableGold = _numValue(map['payableGold']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
final targetConfigMap = _asMap(map['targetConfig']);
_targetConfig =
targetConfigMap != null && targetConfigMap.isNotEmpty
? SCVipLevelConfigRes.fromJson(targetConfigMap)
: null;
}
String? _userId;
String? _sysOrigin;
String? _orderType;
num? _currentLevel;
num? _targetLevel;
num? _durationDays;
num? _originalPriceGold;
num? _payableGold;
String? _startAt;
String? _expireAt;
SCVipLevelConfigRes? _targetConfig;
String? get userId => _userId;
String? get sysOrigin => _sysOrigin;
String? get orderType => _orderType;
num? get currentLevel => _currentLevel;
num? get targetLevel => _targetLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get payableGold => _payableGold;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
SCVipLevelConfigRes? get targetConfig => _targetConfig;
int get targetLevelInt => (_targetLevel ?? 0).toInt();
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['userId'] = _userId;
map['sysOrigin'] = _sysOrigin;
map['orderType'] = _orderType;
map['currentLevel'] = _currentLevel;
map['targetLevel'] = _targetLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['payableGold'] = _payableGold;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
if (_targetConfig != null) {
map['targetConfig'] = _targetConfig?.toJson();
}
return map;
}
}
class SCVipPurchaseRes {
SCVipPurchaseRes({
bool? success,
SCVipOrderRes? order,
SCVipStatusRes? state,
}) {
_success = success;
_order = order;
_state = state;
}
SCVipPurchaseRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_success = _boolValue(map['success']);
final orderMap = _asMap(map['order']);
_order =
orderMap != null && orderMap.isNotEmpty
? SCVipOrderRes.fromJson(orderMap)
: null;
final stateMap = _asMap(map['state']);
_state =
stateMap != null && stateMap.isNotEmpty
? SCVipStatusRes.fromJson(stateMap)
: null;
}
bool? _success;
SCVipOrderRes? _order;
SCVipStatusRes? _state;
bool? get success => _success;
SCVipOrderRes? get order => _order;
SCVipStatusRes? get state => _state;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['success'] = _success;
if (_order != null) map['order'] = _order?.toJson();
if (_state != null) map['state'] = _state?.toJson();
return map;
}
}
class SCVipOrdersRes {
SCVipOrdersRes({List<SCVipOrderRes>? records, num? total}) {
_records = records;
_total = total;
}
SCVipOrdersRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_records =
_asList(
map['records'],
).map((dynamic item) => SCVipOrderRes.fromJson(item)).toList();
_total = _numValue(map['total']);
}
List<SCVipOrderRes>? _records;
num? _total;
List<SCVipOrderRes> get records => _records ?? <SCVipOrderRes>[];
num? get total => _total;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['records'] = records.map((item) => item.toJson()).toList();
map['total'] = _total;
return map;
}
}
class SCVipOrderRes {
SCVipOrderRes({
String? id,
String? eventId,
String? sysOrigin,
String? userId,
String? orderType,
num? fromLevel,
num? toLevel,
num? durationDays,
num? originalPriceGold,
num? paidGold,
String? status,
String? startAt,
String? expireAt,
}) {
_id = id;
_eventId = eventId;
_sysOrigin = sysOrigin;
_userId = userId;
_orderType = orderType;
_fromLevel = fromLevel;
_toLevel = toLevel;
_durationDays = durationDays;
_originalPriceGold = originalPriceGold;
_paidGold = paidGold;
_status = status;
_startAt = startAt;
_expireAt = expireAt;
}
SCVipOrderRes.fromJson(dynamic json) {
final map = _asMap(json);
if (map == null) return;
_id = _stringValue(map['id']);
_eventId = _stringValue(map['eventId']);
_sysOrigin = _stringValue(map['sysOrigin']);
_userId = _stringValue(map['userId']);
_orderType = _stringValue(map['orderType']);
_fromLevel = _numValue(map['fromLevel']);
_toLevel = _numValue(map['toLevel']);
_durationDays = _numValue(map['durationDays']);
_originalPriceGold = _numValue(map['originalPriceGold']);
_paidGold = _numValue(map['paidGold']);
_status = _stringValue(map['status']);
_startAt = _stringValue(map['startAt']);
_expireAt = _stringValue(map['expireAt']);
}
String? _id;
String? _eventId;
String? _sysOrigin;
String? _userId;
String? _orderType;
num? _fromLevel;
num? _toLevel;
num? _durationDays;
num? _originalPriceGold;
num? _paidGold;
String? _status;
String? _startAt;
String? _expireAt;
String? get id => _id;
String? get eventId => _eventId;
String? get sysOrigin => _sysOrigin;
String? get userId => _userId;
String? get orderType => _orderType;
num? get fromLevel => _fromLevel;
num? get toLevel => _toLevel;
num? get durationDays => _durationDays;
num? get originalPriceGold => _originalPriceGold;
num? get paidGold => _paidGold;
String? get status => _status;
String? get startAt => _startAt;
String? get expireAt => _expireAt;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['eventId'] = _eventId;
map['sysOrigin'] = _sysOrigin;
map['userId'] = _userId;
map['orderType'] = _orderType;
map['fromLevel'] = _fromLevel;
map['toLevel'] = _toLevel;
map['durationDays'] = _durationDays;
map['originalPriceGold'] = _originalPriceGold;
map['paidGold'] = _paidGold;
map['status'] = _status;
map['startAt'] = _startAt;
map['expireAt'] = _expireAt;
return map;
}
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
List<dynamic> _asList(dynamic value) {
if (value is List) return value;
return <dynamic>[];
}
SCVipResourceRes? _resourceValue(dynamic value) {
final map = _asMap(value);
if (map == null || map.isEmpty) return null;
return SCVipResourceRes.fromJson(map);
}
String? _stringValue(dynamic value) {
if (value == null) return null;
return value.toString();
}
num? _numValue(dynamic value) {
if (value == null) return null;
if (value is num) return value;
return num.tryParse(value.toString());
}
bool? _boolValue(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value != 0;
final lower = value.toString().toLowerCase();
if (lower == 'true') return true;
if (lower == 'false') return false;
return null;
}
String? _firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim();
if (text != null && text.isNotEmpty) {
return text;
}
}
return null;
}
bool _isSvgaUrl(String value) {
final lower = value.toLowerCase();
return lower.endsWith('.svga') || lower.contains('.svga?');
}

View File

@ -44,7 +44,7 @@ abstract class SocialChatUserRepository {
String openId,
SCUserProfileCmd userProfileCmd, {
SCMobileAuthCmd? mobileAuthCmd,
String invitePeople,
String invitationCode,
});
///

View File

@ -0,0 +1,19 @@
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
abstract class SocialChatVipRepository {
Future<SCVipStatusRes> vipStatus();
Future<SCVipHomeRes> vipHome();
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
});
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
});
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20});
}

View File

@ -15,4 +15,5 @@ enum SCSysytemMessageType{
CP_LOVE_LETTER,
USER_COINS_RECEIVED,
COIN_SELLER_COINS_RECEIVED,
}
REGISTER_REWARD_GRANTED,
}

View File

@ -0,0 +1,29 @@
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class SCMusicFolderMode {
const SCMusicFolderMode({
required this.id,
required this.name,
required this.path,
required this.songs,
this.addedCount = 0,
});
final String id;
final String name;
final String path;
final List<SCMusicMode> songs;
final int addedCount;
int get musicCount => songs.length;
SCMusicFolderMode copyWith({List<SCMusicMode>? songs, int? addedCount}) {
return SCMusicFolderMode(
id: id,
name: name,
path: path,
songs: songs ?? this.songs,
addedCount: addedCount ?? this.addedCount,
);
}
}

View File

@ -1,37 +1,149 @@
class SCMusicMode {
SCMusicMode({String? title, String? artist,int? duration,String? localPath}) {
_title = title;
_artist = artist;
_duration = duration;
_localPath = localPath;
}
String? _title;
String? _artist;
int? _duration;
String? _localPath;
String? get title => _title;
String? get artist => _artist;
int? get duration => _duration;
String? get localPath => _localPath;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _duration;
map['localPath'] = _localPath;
return map;
}
SCMusicMode.fromJson(dynamic json) {
_title = json['title'];
_artist = json['artist'];
_duration = json['duration'];
_localPath = json['localPath'];
}
}
import 'dart:convert';
class SCMusicMode {
SCMusicMode({
String? id,
String? title,
String? artist,
int? duration,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
_id = id;
_title = title;
_artist = artist;
_durationMs = durationMs ?? duration;
_localPath = localPath;
_contentUri = contentUri;
_folderPath = folderPath;
_fileName = fileName;
_size = size;
_sortIndex = sortIndex;
_addedAt = addedAt;
}
String? _id;
String? _title;
String? _artist;
int? _durationMs;
String? _localPath;
String? _contentUri;
String? _folderPath;
String? _fileName;
int? _size;
int? _sortIndex;
int? _addedAt;
String get id {
final rawId = (_id ?? "").trim();
if (rawId.isNotEmpty) {
return rawId;
}
final source = playPath.isNotEmpty ? playPath : title;
return base64Url.encode(utf8.encode(source)).replaceAll('=', '');
}
String get title => (_title ?? _fileName ?? "").trim();
String get artist => (_artist ?? "").trim();
int get durationMs => _durationMs ?? 0;
int get duration => durationMs;
String get localPath => (_localPath ?? "").trim();
String get contentUri => (_contentUri ?? "").trim();
String get folderPath => (_folderPath ?? "").trim();
String get fileName => (_fileName ?? title).trim();
int get size => _size ?? 0;
int get sortIndex => _sortIndex ?? 0;
int get addedAt => _addedAt ?? 0;
String get playPath => contentUri.isNotEmpty ? contentUri : localPath;
bool get hasPlayablePath => playPath.isNotEmpty;
SCMusicMode copyWith({
String? id,
String? title,
String? artist,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
return SCMusicMode(
id: id ?? _id,
title: title ?? _title,
artist: artist ?? _artist,
durationMs: durationMs ?? _durationMs,
localPath: localPath ?? _localPath,
contentUri: contentUri ?? _contentUri,
folderPath: folderPath ?? _folderPath,
fileName: fileName ?? _fileName,
size: size ?? _size,
sortIndex: sortIndex ?? _sortIndex,
addedAt: addedAt ?? _addedAt,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _durationMs;
map['durationMs'] = _durationMs;
map['localPath'] = _localPath;
map['contentUri'] = _contentUri;
map['folderPath'] = _folderPath;
map['fileName'] = _fileName;
map['size'] = _size;
map['sortIndex'] = _sortIndex;
map['addedAt'] = _addedAt;
return map;
}
SCMusicMode.fromJson(dynamic json) {
if (json is! Map) {
return;
}
_id = json['id']?.toString();
_title = json['title']?.toString();
_artist = json['artist']?.toString();
_durationMs = _asInt(json['durationMs'] ?? json['duration']);
_localPath = json['localPath']?.toString();
_contentUri = json['contentUri']?.toString();
_folderPath = json['folderPath']?.toString();
_fileName = json['fileName']?.toString();
_size = _asInt(json['size']);
_sortIndex = _asInt(json['sortIndex']);
_addedAt = _asInt(json['addedAt']);
}
static int? _asInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "");
}
}

View File

@ -0,0 +1,83 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class DurableAuthStorage {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/durable_auth_storage',
);
static const String _sessionKey = 'auth_session_v1';
static Future<void> saveSession({
required String token,
required String currentUserJson,
}) async {
if (!Platform.isIOS) {
return;
}
if (token.trim().isEmpty || currentUserJson.trim().isEmpty) {
await clearSession();
return;
}
await _invokeSafely('write', <String, Object>{
'key': _sessionKey,
'value': jsonEncode(<String, String>{
'token': token,
'currentUser': currentUserJson,
}),
});
}
static Future<DurableAuthSession?> readSession() async {
if (!Platform.isIOS) {
return null;
}
try {
final raw = await _channel.invokeMethod<String>('read', <String, Object>{
'key': _sessionKey,
});
if (raw == null || raw.trim().isEmpty) {
return null;
}
final json = jsonDecode(raw);
if (json is! Map) {
return null;
}
final token = (json['token'] as String? ?? '').trim();
final currentUserJson = json['currentUser'] as String? ?? '';
if (token.isEmpty || currentUserJson.trim().isEmpty) {
return null;
}
return DurableAuthSession(token: token, currentUserJson: currentUserJson);
} catch (_) {
return null;
}
}
static Future<void> clearSession() async {
if (!Platform.isIOS) {
return;
}
await _invokeSafely('delete', <String, Object>{'key': _sessionKey});
}
static Future<void> _invokeSafely(
String method,
Map<String, Object> arguments,
) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} catch (_) {}
}
}
class DurableAuthSession {
const DurableAuthSession({
required this.token,
required this.currentUserJson,
});
final String token;
final String currentUserJson;
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
@ -5,6 +6,7 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_message_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/durable_auth_storage.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
@ -48,6 +50,12 @@ class AccountStorage {
String userJson = jsonEncode(_currentUser?.toJson());
if (userJson.isNotEmpty) {
DataPersistence.setCurrentUser(userJson);
unawaited(
DurableAuthStorage.saveSession(
token: _currentUser!.token ?? "",
currentUserJson: userJson,
),
);
}
}
@ -119,6 +127,30 @@ class AccountStorage {
return token;
}
Future<void> restoreDurableSessionIfNeeded() async {
final localToken = DataPersistence.getToken().trim();
final localUserJson = DataPersistence.getCurrentUser().trim();
if (localToken.isNotEmpty && localUserJson.isNotEmpty) {
return;
}
final session = await DurableAuthStorage.readSession();
if (session == null) {
return;
}
token = session.token;
await DataPersistence.setToken(session.token);
await DataPersistence.setCurrentUser(session.currentUserJson);
try {
_currentUser = SocialChatLoginRes.fromJson(
jsonDecode(session.currentUserJson),
);
} catch (_) {
_currentUser = null;
}
}
void setToken(String tk) {
token = tk;
DataPersistence.setToken(token);
@ -129,6 +161,7 @@ class AccountStorage {
_currentUser = null;
DataPersistence.setToken("");
DataPersistence.setCurrentUser("");
unawaited(DurableAuthStorage.clearSession());
}
///退

View File

@ -340,8 +340,13 @@ class BaseNetworkClient {
response: e.response,
error: '服务器错误: ${e.response?.data["errorCode"]}',
);
case DioExceptionType.cancel:
return DioException(requestOptions: e.requestOptions, error: 'Cancel');
case DioExceptionType.cancel:
return DioException(
requestOptions: e.requestOptions,
response: e.response,
type: DioExceptionType.cancel,
error: e.error ?? 'Cancel',
);
default:
return DioException(
requestOptions: e.requestOptions,

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
@ -11,7 +11,7 @@ import 'package:yumi/shared/data_sources/sources/remote/net/sc_logger.dart';
NetworkClient get http => _httpInstance;
late final NetworkClient _httpInstance = NetworkClient();
final NetworkClient _httpInstance = NetworkClient();
final RegExp _encryptedRoutePattern = RegExp(r'^[0-9a-f]{32,}$');
@ -439,13 +439,15 @@ class ResponseData<T> {
Map<String, dynamic> json, {
T Function(dynamic)? fromJsonT,
}) {
final dynamic responseBody =
json.containsKey('body') ? json['body'] : json['data'];
if (fromJsonT == null) {
return ResponseData(
status: json['status'],
errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'],
body: json['body'],
body: responseBody,
time: json['time'],
);
} else {
@ -454,7 +456,7 @@ class ResponseData<T> {
errorCode: json['errorCode'],
errorCodeName: json['errorCodeName'],
errorMsg: json['errorMsg'],
body: json['body'] != null ? _parseData(json['body'], fromJsonT) : null,
body: responseBody != null ? _parseData(responseBody, fromJsonT) : null,
time: json['time'],
);
}
@ -486,8 +488,8 @@ class TimeOutInterceptor extends Interceptor {
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout) {
print('超时错误: URL => ${err.requestOptions.baseUrl}');
print('当前使用的Host: ${SCGlobalConfig.apiHost}');
debugPrint('超时错误: URL => ${err.requestOptions.baseUrl}');
debugPrint('当前使用的Host: ${SCGlobalConfig.apiHost}');
}
//
@ -510,7 +512,7 @@ class TimeOutInterceptor extends Interceptor {
_timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () {
SCLoadingManager.hide();
if (!cancelToken.isCancelled) {
cancelToken.cancel('请求超时(${timeoutSeconds}');
cancelToken.cancel('请求超时($timeoutSeconds秒');
//
_timers.remove(requestKey);
}

View File

@ -60,18 +60,16 @@ class SCAccountRepository implements SocialChatUserRepository {
String openId,
SCUserProfileCmd userProfileCmd, {
SCMobileAuthCmd? mobileAuthCmd,
String invitePeople = "",
String invitationCode = "",
}) async {
var param = {};
param["type"] = type;
param["openId"] = openId;
param["profile"] = userProfileCmd.toJson();
param["invitationCode"] = invitationCode;
if (mobileAuthCmd != null) {
param["mobile"] = mobileAuthCmd.toJson();
}
if (invitePeople.isNotEmpty) {
param["invitePeople"] = invitePeople;
}
final result = await http.post(
"aa35848b37e5ba74d93e5a1f719c579cb289b5940c15040473b6f57550de35df",
data: param,

View File

@ -0,0 +1,308 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart';
import 'package:yumi/shared/business_logic/repositories/vip_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
const String _vipApiHost = String.fromEnvironment(
'VIP_API_HOST',
defaultValue: '',
);
class SCVipRepositoryImp implements SocialChatVipRepository {
static SCVipRepositoryImp? _instance;
SCVipRepositoryImp._internal();
factory SCVipRepositoryImp() {
return _instance ??= SCVipRepositoryImp._internal();
}
@override
Future<SCVipStatusRes> vipStatus() async {
final path = _vipPath('/status');
_logRequest('GET', path);
try {
final result = await http.get<SCVipStatusRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipStatusRes.fromJson(json),
);
_logResponse('GET', path, _statusSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
Future<SCVipHomeRes> vipHome() async {
final path = _vipPath('/home');
_logRequest('GET', path);
try {
final result = await http.get<SCVipHomeRes>(
path,
extra: _vipExtra(),
fromJson: (json) => SCVipHomeRes.fromJson(json),
);
_logResponse('GET', path, _homeSummary(result));
return result;
} catch (error) {
_logError('GET', path, error);
rethrow;
}
}
@override
Future<SCVipPreviewRes> vipPreview({
int? targetLevel,
String? targetLevelCode,
}) async {
if (targetLevel == null && (targetLevelCode?.isEmpty ?? true)) {
throw ArgumentError('targetLevel or targetLevelCode is required');
}
final params = <String, dynamic>{};
if (targetLevel != null) {
params['targetLevel'] = targetLevel;
}
if (targetLevelCode != null && targetLevelCode.isNotEmpty) {
params['targetLevelCode'] = targetLevelCode;
}
final path = _vipPath('/preview');
_logRequest('POST', path, body: params);
try {
final result = await http.post<SCVipPreviewRes>(
path,
data: params,
extra: _vipExtra(),
fromJson: (json) => SCVipPreviewRes.fromJson(json),
);
_logResponse('POST', path, _previewSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: params);
rethrow;
}
}
@override
Future<SCVipPurchaseRes> vipPurchase({
required int targetLevel,
required String bizIdempotentKey,
}) async {
final path = _vipPath('/purchase');
final body = {
'targetLevel': targetLevel,
'bizIdempotentKey': bizIdempotentKey,
};
_logRequest('POST', path, body: body);
try {
final result = await http.post<SCVipPurchaseRes>(
path,
data: body,
extra: _vipExtra(),
fromJson: (json) => SCVipPurchaseRes.fromJson(json),
);
_logResponse('POST', path, _purchaseSummary(result));
return result;
} catch (error) {
_logError('POST', path, error, body: body);
rethrow;
}
}
@override
Future<SCVipOrdersRes> vipOrders({int cursor = 1, int limit = 20}) async {
final path = _vipPath('/orders');
final query = {'cursor': cursor, 'limit': limit};
_logRequest('GET', path, query: query);
try {
final result = await http.get<SCVipOrdersRes>(
path,
queryParams: query,
extra: _vipExtra(),
fromJson: (json) => SCVipOrdersRes.fromJson(json),
);
_logResponse('GET', path, _ordersSummary(result));
return result;
} catch (error) {
_logError('GET', path, error, query: query);
rethrow;
}
}
static bool get _hasExplicitVipApiHost => _vipApiHost.trim().isNotEmpty;
static String get _activeBaseUrl =>
_hasExplicitVipApiHost ? _vipApiHost.trim() : SCGlobalConfig.apiHost;
static String _vipPath(String suffix) {
final baseUrl = _activeBaseUrl.trim();
final parsed = Uri.tryParse(baseUrl);
final pathSegments =
parsed?.pathSegments.where((segment) => segment.isNotEmpty).toSet() ??
<String>{};
final baseAlreadyContainsGo = pathSegments.contains('go');
if (_hasExplicitVipApiHost || baseAlreadyContainsGo) {
return '/app/vip$suffix';
}
return '/go/app/vip$suffix';
}
static Map<String, dynamic>? _vipExtra() {
if (!_hasExplicitVipApiHost) {
return null;
}
return {BaseNetworkClient.baseUrlOverrideKey: _vipApiHost.trim()};
}
static void _logRequest(
String method,
String path, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Request] $method $path '
'baseUrl=$_activeBaseUrl query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})}',
);
}
static void _logResponse(
String method,
String path,
Map<String, dynamic> summary,
) {
debugPrint('[VIP][Response] $method $path summary=${_json(summary)}');
}
static void _logError(
String method,
String path,
Object error, {
Map<String, dynamic>? query,
Map<String, dynamic>? body,
}) {
debugPrint(
'[VIP][Error] $method $path query=${_json(query ?? const {})} '
'body=${_json(body ?? const {})} error=$error',
);
if (error is DioException) {
debugPrint(
'[VIP][DioError] $method $path '
'uri=${error.requestOptions.uri} '
'statusCode=${error.response?.statusCode} '
'message=${error.message} '
'response=${_json(error.response?.data)}',
);
}
}
static Map<String, dynamic> _statusSummary(SCVipStatusRes result) {
return {
'active': result.active,
'status': result.status,
'level': result.level,
'levelCode': result.levelCode,
'expireAt': result.expireAt,
'badge': _resourceSummary(result.badge),
'avatarFrame': _resourceSummary(result.avatarFrame),
'entryEffect': _resourceSummary(result.entryEffect),
'chatBubble': _resourceSummary(result.chatBubble),
'floatPicture': _resourceSummary(result.floatPicture),
};
}
static Map<String, dynamic> _homeSummary(SCVipHomeRes result) {
return {
'configured': result.configured,
'state': result.state == null ? null : _statusSummary(result.state!),
'levelsCount': result.levels.length,
'levels':
result.levels
.map(
(level) => {
'level': level.level,
'levelCode': level.levelCode,
'enabled': level.enabled,
'durationDays': level.durationDays,
'priceGold': level.priceGold,
'canPurchase': level.canPurchase,
'payableGold': level.payableGold,
'badge': _resourceSummary(level.badge),
'avatarFrame': _resourceSummary(level.avatarFrame),
'entryEffect': _resourceSummary(level.entryEffect),
'chatBubble': _resourceSummary(level.chatBubble),
'floatPicture': _resourceSummary(level.floatPicture),
},
)
.toList(),
};
}
static Map<String, dynamic>? _resourceSummary(SCVipResourceRes? resource) {
if (resource?.hasContent != true) {
return null;
}
return {
'id': resource?.resourceId,
'name': resource?.name,
'hasPreviewUrl': resource?.hasPreviewUrl == true,
'hasSourceUrl': resource?.hasSourceUrl == true,
'previewUrl': resource?.previewUrl,
'sourceUrl': resource?.sourceResourceUrl,
};
}
static Map<String, dynamic> _previewSummary(SCVipPreviewRes result) {
return {
'orderType': result.orderType,
'currentLevel': result.currentLevel,
'targetLevel': result.targetLevel,
'durationDays': result.durationDays,
'originalPriceGold': result.originalPriceGold,
'payableGold': result.payableGold,
'expireAt': result.expireAt,
};
}
static Map<String, dynamic> _purchaseSummary(SCVipPurchaseRes result) {
final order = result.order;
return {
'success': result.success,
'order':
order == null
? null
: {
'id': order.id,
'orderType': order.orderType,
'fromLevel': order.fromLevel,
'toLevel': order.toLevel,
'paidGold': order.paidGold,
'status': order.status,
'expireAt': order.expireAt,
},
'state': result.state == null ? null : _statusSummary(result.state!),
};
}
static Map<String, dynamic> _ordersSummary(SCVipOrdersRes result) {
return {'recordsCount': result.records.length, 'total': result.total};
}
static String _json(dynamic value) {
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/tools/sc_string_utils.dart';
import 'package:yumi/shared/tools/sc_h5_url_utils.dart';
import 'package:yumi/shared/tools/sc_url_launcher_utils.dart';
import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart';
import 'package:yumi/modules/index/main_route.dart';
@ -35,12 +36,17 @@ class SCBannerUtils {
}
if (SCStringUtils.checkIfUrl(params)) {
final h5Url = _appendToken(params);
SCNavigatorUtils.push(
context,
"${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(params)}&showTitle=false",
"${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(h5Url)}&showTitle=false",
replace: false,
);
}
}
}
static String _appendToken(String url) {
return SCH5UrlUtils.appendToken(url);
}
}

View File

@ -0,0 +1,60 @@
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
class SCH5UrlUtils {
static String appendAppParams(String url) {
return _appendQueryParams(url, appendLang: true, appendToken: true);
}
static String appendToken(String url) {
return _appendQueryParams(url, appendLang: false, appendToken: true);
}
static String _appendQueryParams(
String url, {
required bool appendLang,
required bool appendToken,
}) {
final trimmedUrl = url.trim();
if (trimmedUrl.isEmpty) {
return url;
}
final uri = Uri.tryParse(trimmedUrl);
if (uri == null) {
return url;
}
final queryParameters = <String, dynamic>{};
uri.queryParametersAll.forEach((key, values) {
queryParameters[key] = List<String>.from(values);
});
var hasChanges = false;
if (appendLang && !_hasQueryKey(queryParameters, 'lang')) {
queryParameters['lang'] = SCGlobalConfig.lang;
hasChanges = true;
}
final token = AccountStorage().getToken();
if (appendToken &&
token.isNotEmpty &&
!_hasQueryKey(queryParameters, 'token')) {
queryParameters['token'] = token;
hasChanges = true;
}
if (!hasChanges) {
return url;
}
return uri.replace(queryParameters: queryParameters).toString();
}
static bool _hasQueryKey(Map<String, dynamic> queryParameters, String key) {
final lowerKey = key.toLowerCase();
return queryParameters.keys.any(
(queryKey) => queryKey.toLowerCase() == lowerKey,
);
}
}

View File

@ -17,6 +17,7 @@ class SCHeartbeatUtils {
String status,
bool upMick, {
String? roomId,
bool rethrowOnError = false,
}) async {
cancelTimer();
_c = status;
@ -40,6 +41,9 @@ class SCHeartbeatUtils {
try {
cancelTimer();
} catch (_) {}
if (rethrowOnError) {
rethrow;
}
}
}

View File

@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
class SCInstallReferrerUtils {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/install_referrer',
);
static const String _cachedInviteCodeKey = 'install_referrer_invite_code';
static const String _cachedRawReferrerKey = 'install_referrer_raw';
static Future<String> getInviteCode() async {
final cachedInviteCode = DataPersistence.getString(_cachedInviteCodeKey);
if (cachedInviteCode.isNotEmpty) {
return cachedInviteCode;
}
final cachedRawReferrer = DataPersistence.getString(_cachedRawReferrerKey);
if (cachedRawReferrer.isNotEmpty) {
return extractInviteCode(cachedRawReferrer);
}
if (!Platform.isAndroid) {
return '';
}
try {
final referrer = await _channel.invokeMethod<String>(
'getInstallReferrer',
);
if (referrer == null || referrer.trim().isEmpty) {
return '';
}
await DataPersistence.setString(_cachedRawReferrerKey, referrer);
final inviteCode = extractInviteCode(referrer);
if (inviteCode.isNotEmpty) {
await DataPersistence.setString(_cachedInviteCodeKey, inviteCode);
}
return inviteCode;
} on PlatformException catch (e) {
debugPrint('get install referrer failed: ${e.code} ${e.message}');
return '';
} catch (e) {
debugPrint('get install referrer failed: $e');
return '';
}
}
static String extractInviteCode(String referrer) {
return _extractInviteCode(referrer, 0);
}
static String _extractInviteCode(String input, int depth) {
if (depth > 4) {
return '';
}
final value = input.trim();
if (value.isEmpty) {
return '';
}
final params = _queryParameters(value);
final inviteCode = _inviteCodeFromParams(params);
if (inviteCode.isNotEmpty) {
return inviteCode;
}
final nestedReferrer = _nestedReferrerFromParams(params);
if (nestedReferrer != null && nestedReferrer.isNotEmpty) {
final nestedInviteCode = _extractInviteCode(nestedReferrer, depth + 1);
if (nestedInviteCode.isNotEmpty) {
return nestedInviteCode;
}
}
final decodedValue = _decodeComponent(value);
if (decodedValue != value) {
return _extractInviteCode(decodedValue, depth + 1);
}
return '';
}
static Map<String, String> _queryParameters(String value) {
final uri = Uri.tryParse(value);
if (uri != null && uri.hasQuery) {
return uri.queryParameters;
}
if (!value.contains('=')) {
return const {};
}
try {
return Uri.splitQueryString(value);
} catch (_) {
return const {};
}
}
static String _inviteCodeFromParams(Map<String, String> params) {
for (final entry in params.entries) {
final key = entry.key.toLowerCase();
if (key == 'invite_code' || key == 'invitecode') {
return entry.value.trim();
}
}
return '';
}
static String? _nestedReferrerFromParams(Map<String, String> params) {
for (final entry in params.entries) {
if (entry.key.toLowerCase() == 'referrer') {
return entry.value;
}
}
return null;
}
static String _decodeComponent(String value) {
try {
return Uri.decodeComponent(value);
} catch (_) {
return value;
}
}
}

View File

@ -1,23 +1,42 @@
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils{
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
}
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils {
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
static Future<bool> checkAudioPermission() async {
if (!Platform.isAndroid) {
return true;
}
final androidInfo = await DeviceInfoPlugin().androidInfo;
final permission =
androidInfo.version.sdkInt >= 33
? Permission.audio
: Permission.storage;
var status = await permission.status;
if (status.isDenied || status.isRestricted || status.isLimited) {
status = await permission.request();
}
return status.isGranted;
}
}

View File

@ -109,10 +109,7 @@ class SCChatRoomHelper {
).retrieveMicrophoneList();
Provider.of<RealTimeCommunicationManager>(context, listen: false)
.closeFullGame = true;
SCNavigatorUtils.push(
context,
'${VoiceRoomRoute.voiceRoom}?id=${Provider.of<RealTimeCommunicationManager>(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}',
);
VoiceRoomRoute.openVoiceRoom(context);
}
static double getCurrenProgress(

View File

@ -1425,4 +1425,243 @@ class SCSystemMessageUtils {
}
return Container();
}
static Widget buildRegisterRewardGrantedMessage(
data,
Function(BuildContext ct, String content) showMsgItemMenu,
BuildContext context,
) {
final content = _registerRewardGrantedContent(data, context);
return Builder(
builder: (ct) {
return GestureDetector(
onLongPress: () {
showMsgItemMenu(ct, content);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(blurRadius: 2.w, color: Colors.black26)],
borderRadius: BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(8.w),
bottomLeft: Radius.circular(8.w),
bottomRight: Radius.circular(8),
),
),
padding: EdgeInsets.all(8.0.w),
child: ExtendedText(
content,
style: TextStyle(fontSize: 14.sp, color: Colors.black54),
),
),
);
},
);
}
static String _registerRewardGrantedContent(
dynamic data,
BuildContext context,
) {
final l10n = SCAppLocalizations.of(context)!;
final rewards =
_extractRegisterRewardItems(data)
.map((item) => _registerRewardItemLabel(l10n, item))
.where((item) => item.isNotEmpty)
.toList();
if (rewards.isNotEmpty) {
return l10n.registerRewardReceived(rewards.join(', '));
}
final plainContent = _plainRegisterRewardContent(data);
if (plainContent.isNotEmpty) {
return l10n.registerRewardReceived(plainContent);
}
return l10n.receiveSucc;
}
static List<dynamic> _extractRegisterRewardItems(dynamic value) {
final decoded = _decodeJsonValue(value);
if (decoded is List) {
return decoded;
}
if (decoded is Map) {
final nestedItems = _extractNestedRegisterRewardItems(decoded);
if (nestedItems.isNotEmpty) {
return nestedItems;
}
if (_looksLikeRewardItem(decoded)) {
return [decoded];
}
}
return const <dynamic>[];
}
static String _registerRewardItemLabel(
SCAppLocalizations l10n,
dynamic item,
) {
final decoded = _decodeJsonValue(item);
if (decoded is! Map) {
return _readPlainText(decoded);
}
if (!_looksLikeRewardItem(decoded)) {
final nestedItems = _extractNestedRegisterRewardItems(decoded);
if (nestedItems.isNotEmpty) {
return nestedItems
.map((item) => _registerRewardItemLabel(l10n, item))
.where((item) => item.isNotEmpty)
.join(', ');
}
}
final type = _readPlainText(decoded['type']).trim().toUpperCase();
final detailType =
_readPlainText(decoded['detailType']).trim().toUpperCase();
final quantity = _rewardQuantity(decoded);
if (type == 'GOLD' ||
type == 'COIN' ||
type == 'COINS' ||
detailType == 'GOLD' ||
detailType == 'COIN' ||
detailType == 'COINS') {
return l10n.coins2(quantity.isEmpty ? '0' : quantity);
}
final name = _pickFirstNonEmpty([
_readPlainText(decoded['name']),
_readPlainText(decoded['badgeName']),
_readPlainText(decoded['remark']),
_readPlainText(decoded['title']),
_readPlainText(decoded['content']),
detailType,
type,
]);
if (name.isEmpty) {
return '';
}
final count = _asInt(decoded['quantity']);
if (count > 1) {
return '$name x$count';
}
return name;
}
static List<dynamic> _extractNestedRegisterRewardItems(
Map<dynamic, dynamic> value,
) {
const nestedKeys = [
'rewardItems',
'rewards',
'items',
'activityRewardProps',
'rewardConfig',
'propsGroup',
'propsGroups',
'data',
'content',
];
for (final key in nestedKeys) {
final items = _extractRegisterRewardItems(value[key]);
if (items.isNotEmpty) {
return items;
}
}
return const <dynamic>[];
}
static String _rewardQuantity(Map<dynamic, dynamic> item) {
final quantity = _asInt(item['quantity']);
if (quantity > 0) {
return quantity.toString();
}
return _pickFirstNonEmpty([
_readPlainText(item['amount']),
_readPlainText(item['content']),
]);
}
static String _plainRegisterRewardContent(dynamic data) {
final decoded = _decodeJsonValue(data);
if (decoded is Map) {
return _pickFirstNonEmpty([
_readPlainText(decoded['message']),
_readPlainText(decoded['title']),
_readPlainText(decoded['content']),
]);
}
return _readPlainText(decoded);
}
static dynamic _decodeJsonValue(dynamic value) {
if (value is! String) {
return value;
}
final text = value.trim();
if (!_looksLikeStructuredText(text)) {
return value;
}
try {
return jsonDecode(text);
} catch (_) {
return value;
}
}
static bool _looksLikeRewardItem(Map<dynamic, dynamic> value) {
const rewardKeys = [
'type',
'detailType',
'name',
'quantity',
'amount',
'remark',
'badgeName',
];
return rewardKeys.any(value.containsKey);
}
static String _pickFirstNonEmpty(List<String> values) {
for (final value in values) {
final text = value.trim();
if (text.isNotEmpty) {
return text;
}
}
return '';
}
static String _readPlainText(dynamic value) {
final text = _asString(value).trim();
if (text.isEmpty || _looksLikeStructuredText(text)) {
return '';
}
return text;
}
static bool _looksLikeStructuredText(String value) {
final text = value.trimLeft();
return text.startsWith('{') || text.startsWith('[');
}
static String _asString(dynamic value) {
if (value == null) {
return '';
}
return value.toString();
}
static int _asInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(_asString(value)) ?? 0;
}
}

View File

@ -1,5 +1,6 @@
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@ -288,7 +289,16 @@ class OpenRoomLoadingDialog extends Dialog {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Image.asset("sc_images/raw/loading.webp", width: 80.w),
SizedBox(
width: 80.w,
height: 80.w,
child: Center(
child: CupertinoActivityIndicator(
color: Colors.white24,
radius: 14.w,
),
),
),
SizedBox(height: 12.w),
Text(
"正在进入聊天室......",

View File

@ -40,30 +40,20 @@ class RoomBottomChatEntry extends StatelessWidget {
],
),
child: Padding(
padding: EdgeInsetsDirectional.only(start: 14.w, end: 14.w),
child: Row(
children: [
Image.asset(
'sc_images/room/icon_room_input_t.png',
width: 22.w,
height: 22.w,
fit: BoxFit.contain,
padding: EdgeInsetsDirectional.only(start: 16.w, end: 16.w),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
localizations?.translate('roomBottomGreeting') ?? 'Hi...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w700,
height: 1.0,
),
SizedBox(width: 10.w),
Expanded(
child: Text(
localizations?.translate('roomBottomGreeting') ?? 'Hi...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
],
),
),
),
),

View File

@ -1,9 +1,14 @@
import 'package:flutter/cupertino.dart';
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svga/flutter_svga.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
@ -18,22 +23,9 @@ class LuckGiftNomorAnimWidget extends StatefulWidget {
}
class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
static const double _groupShiftXRatio = 26 / 422;
static const double _groupShiftYRatio = 18 / 422;
static const double _groupShiftYExtraUnits = 40;
static const double _avatarScale = 0.8;
static const double _avatarExtraShiftXUnits = -2;
static const double _avatarExtraShiftYUnits = 18;
static const double _avatarDiameterRatio = 76 / 422;
static const double _avatarLeftRatio = 152 / 422;
static const double _avatarTopRatio = 64 / 422;
static const double _awardTopRatio = 153 / 422;
static const double _awardXOffsetRatio = -13 / 422;
static const double _multipleTopRatio = 193 / 422;
static const double _multipleXOffsetRatio = -22 / 422;
String? _currentBurstEventId;
bool _isRewardAmountVisible = false;
static const String _avatarDynamicKey = "avatar";
static const String _awardAmountDynamicKey = "digit01";
static const String _multipleDynamicKey = "digit02";
bool _shouldPlayRewardBurst(Data rewardData) {
return RealTimeMessagingManager.shouldPlayLuckyGiftBurst(rewardData);
@ -56,38 +48,201 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
return multiple.toString();
}
Widget _buildSenderAvatar({
required String avatarUrl,
required double outerSize,
}) {
final avatarPadding = outerSize * 0.045;
final innerSize = outerSize - avatarPadding * 2;
return RepaintBoundary(
child: Container(
width: outerSize,
height: outerSize,
padding: EdgeInsets.all(avatarPadding),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFD680),
boxShadow: const [
BoxShadow(
color: Color(0x55243A74),
blurRadius: 16,
offset: Offset(0, 6),
Future<void> _configureRewardBurstSvga(
MovieEntity movieEntity,
Data rewardData,
) async {
final avatarUrl = (rewardData.userAvatar ?? '').trim();
if (avatarUrl.isNotEmpty) {
try {
final avatarImage = await _buildAvatarLayerImage(avatarUrl);
if (avatarImage != null) {
movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey);
}
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] avatar sync failed: $error');
}
}
try {
final awardAmountImage = await _buildTextLayerImage(
text: _formatAwardAmount(rewardData.awardAmount ?? 0),
logicalWidth: 140,
logicalHeight: 65,
fontSize: 42,
color: const Color(0xFFFFF3B6),
shadowColor: const Color(0xCC7A3E00),
);
movieEntity.dynamicItem.setImage(
awardAmountImage,
_awardAmountDynamicKey,
);
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] award amount sync failed: $error');
}
final multiple = rewardData.multiple ?? 0;
if (multiple > 0) {
try {
final multipleImage = await _buildTextLayerImage(
text: 'x${_formatMultiple(multiple)}',
logicalWidth: 166,
logicalHeight: 37,
fontSize: 28,
color: const Color(0xFFF4F6FF),
shadowColor: const Color(0x990B2D72),
);
movieEntity.dynamicItem.setImage(multipleImage, _multipleDynamicKey);
} catch (error) {
debugPrint('[LuckGiftBurstSVGA] multiple sync failed: $error');
}
}
}
Future<ui.Image?> _buildAvatarLayerImage(String avatarUrl) async {
final avatarImage = await _loadUiImage(
avatarUrl,
logicalWidth: 100,
logicalHeight: 100,
);
if (avatarImage == null) {
return null;
}
const double size = 100;
const double pixelRatio = 3;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
final rect = Rect.fromLTWH(0, 0, size, size);
final center = rect.center;
canvas.drawCircle(
center,
size / 2,
Paint()
..color = const Color(0xFFFFD680)
..isAntiAlias = true,
);
canvas.save();
canvas.clipPath(Path()..addOval(rect.deflate(5)));
paintImage(
canvas: canvas,
rect: rect.deflate(5),
image: avatarImage,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
canvas.restore();
final picture = recorder.endRecording();
return picture.toImage(
(size * pixelRatio).round(),
(size * pixelRatio).round(),
);
}
Future<ui.Image> _buildTextLayerImage({
required String text,
required double logicalWidth,
required double logicalHeight,
required double fontSize,
required Color color,
required Color shadowColor,
}) async {
const double pixelRatio = 3;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
canvas.clipRect(Rect.fromLTWH(0, 0, logicalWidth, logicalHeight));
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
maxLines: 1,
textAlign: TextAlign.center,
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
color: color,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
decoration: TextDecoration.none,
shadows: [
Shadow(
color: shadowColor,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipOval(
child: netImage(
url: avatarUrl,
width: innerSize,
height: innerSize,
fit: BoxFit.cover,
shape: BoxShape.circle,
),
),
),
)..layout();
final maxTextWidth = logicalWidth - 8;
final maxTextHeight = logicalHeight - 6;
final scale = math.min(
1.0,
math.min(
maxTextWidth / math.max(textPainter.width, 1),
maxTextHeight / math.max(textPainter.height, 1),
),
);
canvas.save();
canvas.translate(logicalWidth / 2, logicalHeight / 2);
canvas.scale(scale, scale);
textPainter.paint(
canvas,
Offset(-textPainter.width / 2, -textPainter.height / 2),
);
canvas.restore();
final picture = recorder.endRecording();
return picture.toImage(
(logicalWidth * pixelRatio).round(),
(logicalHeight * pixelRatio).round(),
);
}
Future<ui.Image?> _loadUiImage(
String resource, {
double? logicalWidth,
double? logicalHeight,
}) async {
final target = resource.trim();
if (target.isEmpty) {
return null;
}
final provider = buildCachedImageProvider(
target,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
);
final stream = provider.resolve(ImageConfiguration.empty);
final completer = Completer<ui.Image?>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(imageInfo, synchronousCall) {
if (!completer.isCompleted) {
completer.complete(imageInfo.image);
}
stream.removeListener(listener);
},
onError: (Object error, StackTrace? stackTrace) {
if (!completer.isCompleted) {
completer.complete(null);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
stream.removeListener(listener);
return null;
},
);
}
@ -98,32 +253,8 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
builder: (context, provider, child) {
final rewardData = provider.currentPlayingLuckGift?.data;
if (rewardData == null || !_shouldPlayRewardBurst(rewardData)) {
_currentBurstEventId = null;
_isRewardAmountVisible = false;
return const SizedBox.shrink();
}
final screenWidth = ScreenUtil().screenWidth;
final groupShiftX = screenWidth * _groupShiftXRatio;
final groupShiftY =
screenWidth * _groupShiftYRatio + _groupShiftYExtraUnits.w;
final avatarBaseDiameter = screenWidth * _avatarDiameterRatio;
final avatarDiameter = avatarBaseDiameter * _avatarScale;
final avatarInset = (avatarBaseDiameter - avatarDiameter) / 2;
final avatarLeft =
screenWidth * _avatarLeftRatio +
groupShiftX +
avatarInset +
_avatarExtraShiftXUnits.w;
final avatarTop =
screenWidth * _avatarTopRatio +
groupShiftY +
avatarInset +
_avatarExtraShiftYUnits.w;
final awardTop = screenWidth * _awardTopRatio + groupShiftY;
final awardXOffset = screenWidth * _awardXOffsetRatio + groupShiftX;
final multipleTop = screenWidth * _multipleTopRatio + groupShiftY;
final multipleXOffset =
screenWidth * _multipleXOffsetRatio + groupShiftX;
final rewardAnimationKey = ValueKey<String>(
'${rewardData.sendUserId ?? ""}'
'|${rewardData.acceptUserId ?? ""}'
@ -131,132 +262,23 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
'|${rewardData.multiple ?? 0}'
'|${rewardData.normalizedMultipleType}',
);
if (_currentBurstEventId != rewardAnimationKey.value) {
_currentBurstEventId = rewardAnimationKey.value;
_isRewardAmountVisible = false;
}
return SizedBox(
height: 380.w,
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 10.w),
child: SCSvgaAssetWidget(
key: ValueKey<String>('burst|${rewardAnimationKey.value}'),
assetPath: LuckGiftNomorAnimWidget._rewardBurstAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
clearsAfterStop: true,
onPlaybackStarted: () {
if (!mounted) {
return;
}
if (!_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = true;
});
}
},
onPlaybackCompleted: () {
if (!mounted) {
return;
}
if (_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = false;
});
}
},
),
),
if (_isRewardAmountVisible &&
(rewardData.userAvatar ?? '').trim().isNotEmpty)
Positioned(
left: avatarLeft,
top: avatarTop,
child: _buildSenderAvatar(
avatarUrl: (rewardData.userAvatar ?? '').trim(),
outerSize: avatarDiameter,
),
),
if (_isRewardAmountVisible)
Positioned(
top: awardTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(awardXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: screenWidth * 0.34,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
),
),
),
),
),
),
),
if (_isRewardAmountVisible && (rewardData.multiple ?? 0) > 0)
Positioned(
top: multipleTop,
left: 0,
right: 0,
child: Transform.translate(
offset: Offset(multipleXOffset, 0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: screenWidth * 0.18,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'x${_formatMultiple(rewardData.multiple ?? 0)}',
maxLines: 1,
style: TextStyle(
fontSize: 17.sp,
color: const Color(0xFFF4F6FF),
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0x990B2D72),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
),
),
),
),
),
),
],
child: Padding(
padding: EdgeInsets.only(top: 10.w),
child: SCSvgaAssetWidget(
key: ValueKey<String>('burst|${rewardAnimationKey.value}'),
assetPath: LuckGiftNomorAnimWidget._rewardBurstAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
clearsAfterStop: true,
dynamicIdentity: rewardAnimationKey.value,
movieConfigurer:
(movieEntity) =>
_configureRewardBurstSvga(movieEntity, rewardData),
),
),
);
},

View File

@ -70,7 +70,7 @@ class EmptyMaiSelect extends StatelessWidget {
}
}
} else {
MicRes? userWheat = provider.roomWheatMap[index];
MicRes? userWheat = provider.micAtIndexForDisplay(index);
if (userWheat?.user?.id ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
if (provider.isFz()) {
@ -248,7 +248,7 @@ class EmptyMaiSelect extends StatelessWidget {
() {
Navigator.of(context).pop();
showBottomInCenterDialog(
context!,
context,
RoomUserInfoCard(userId: clickUser?.id),
);
},

View File

@ -1,133 +1,123 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/index/main_route.dart';
import '../../components/sc_float_ichart.dart';
class ExitMinRoomPage extends StatefulWidget {
bool isFz;
String roomId;
ExitMinRoomPage(this.isFz, this.roomId);
@override
_ExitMinRoomPageState createState() => _ExitMinRoomPageState();
}
class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
SCUserIdentityRes? userIdentity;
bool isLoading = true;
@override
void initState() {
super.initState();
SCAccountRepository()
.userIdentity(
userId:
Provider.of<RtcProvider>(
context,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.userId,
)
.then((v) {
userIdentity = v;
isLoading = false;
setState(() {});
})
.catchError((e) {
isLoading = false;
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
SCDebounceWidget(
child: Container(color: Colors.transparent),
onTap: () {
Navigator.of(context).pop();
},
),
Column(
spacing: 55.w,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_min_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.mInimize,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
SCFloatIchart().show();
SCNavigatorUtils.popUntil(
context,
ModalRoute.withName(SCRoutes.home),
);
Provider.of<GiftAnimationManager>(
context,
listen: false,
).cleanupAnimationResources();
},
),
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_exit_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.exit,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
SCNavigatorUtils.goBack(context);
Provider.of<RtcProvider>(
context,
listen: false,
).exitCurrentVoiceRoomSession(false);
},
),
],
),
],
);
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart';
import '../../components/sc_float_ichart.dart';
class ExitMinRoomPage extends StatefulWidget {
final bool isFz;
final String roomId;
const ExitMinRoomPage(this.isFz, this.roomId, {super.key});
@override
State<ExitMinRoomPage> createState() => _ExitMinRoomPageState();
}
class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
SCDebounceWidget(
child: Container(color: Colors.transparent),
onTap: () {
Navigator.of(context).pop();
},
),
Column(
spacing: 55.w,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_min_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.mInimize,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () async {
final rtcProvider = context.read<RtcProvider>();
if (rtcProvider.roomStartupStatus ==
RoomStartupStatus.loading ||
rtcProvider.roomStartupStatus == RoomStartupStatus.failed) {
SCNavigatorUtils.goBack(context);
await rtcProvider.exitCurrentVoiceRoomSession(false);
return;
}
final giftAnimationManager =
context.read<GiftAnimationManager>();
final rtmProvider = context.read<RtmProvider>();
rtcProvider.setRoomVisualEffectsEnabled(false);
rtmProvider.msgFloatingGiftListener = null;
rtmProvider.msgLuckyGiftRewardTickerListener = null;
RoomEntranceHelper.clearQueue();
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(
reason: 'room_minimize',
);
SCGiftVapSvgaManager().stopPlayback();
unawaited(context.read<RoomMusicManager>().stopForRoomExit());
giftAnimationManager.cleanupAnimationResources();
SCFloatIchart().show();
SCNavigatorUtils.popUntil(
context,
ModalRoute.withName(SCRoutes.home),
);
},
),
SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_exit_room.png",
width: 48.w,
height: 48.w,
),
SizedBox(height: 8.w),
text(
SCAppLocalizations.of(context)!.exit,
fontSize: 15.sp,
textColor: Colors.white,
fontWeight: FontWeight.w500,
),
],
),
onTap: () {
final rtcProvider = context.read<RtcProvider>();
SCNavigatorUtils.goBack(context);
rtcProvider.exitCurrentVoiceRoomSession(false);
},
),
],
),
],
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicFloatingEntry extends StatelessWidget {
const RoomMusicFloatingEntry({super.key});
@override
Widget build(BuildContext context) {
return Selector<RoomMusicManager, bool>(
selector: (_, manager) => manager.current != null,
builder: (context, visible, child) {
if (!visible) {
return const SizedBox.shrink();
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: context.read<RoomMusicManager>().toggleRoomPlayerVisible,
child: SizedBox(
width: 44.w,
height: 44.w,
child: Image.asset(
"sc_images/room/sc_music_material_room_side.png",
fit: BoxFit.contain,
),
),
);
},
);
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicPlayerBar extends StatelessWidget {
const RoomMusicPlayerBar({super.key});
static const String _playAsset = "sc_images/room/sc_music_material_play.png";
static const String _pauseAsset =
"sc_images/room/sc_music_material_pause.png";
static const String _previousAsset =
"sc_images/room/sc_icon_room_music_previous.png";
static const String _nextAsset = "sc_images/room/sc_icon_room_music_next.png";
static const String _volumeAsset =
"sc_images/room/sc_music_material_volume.png";
@override
Widget build(BuildContext context) {
return Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final current = manager.current;
if (current == null) {
return const SizedBox.shrink();
}
final duration = manager.durationMs <= 0 ? 1 : manager.durationMs;
final position = manager.positionMs.clamp(0, duration).toInt();
return Container(
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xF20B3029),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 18.w,
offset: Offset(0, -6.w),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
current.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
),
],
),
SizedBox(height: 6.w),
Row(
children: [
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 6.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: duration.toDouble(),
value: position.toDouble(),
onChanged:
(value) => manager.seek(value.round().toInt()),
),
),
),
Text(
_formatDuration(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
],
),
SizedBox(height: 4.w),
Row(
children: [
_IconButton(
asset: _modeAsset(manager.playMode),
size: 34.w,
onTap: () {
final message = manager.switchPlayMode();
ScaffoldMessenger.maybeOf(
context,
)?.hideCurrentSnackBar();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(_localizedModeText(context, message)),
duration: const Duration(milliseconds: 900),
),
);
},
),
const Spacer(),
_IconButton(
asset: _previousAsset,
size: 34.w,
onTap: () {
manager.previous();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: manager.isPlaying ? _pauseAsset : _playAsset,
size: 48.w,
onTap: () {
manager.isPlaying ? manager.pause() : manager.resume();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: _nextAsset,
size: 34.w,
onTap: () {
manager.next();
},
),
const Spacer(),
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
SizedBox(
width: 82.w,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 5.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: 100,
value: manager.volume.toDouble(),
onChanged: (value) {
manager.setVolume(value.round());
},
),
),
),
],
),
],
),
),
);
},
);
}
static String _formatDuration(int milliseconds) {
final totalSeconds = (milliseconds / 1000).floor();
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return "$minutes:${seconds.toString().padLeft(2, '0')}";
}
static String _modeAsset(RoomMusicPlayMode mode) {
switch (mode) {
case RoomMusicPlayMode.list:
return "sc_images/room/sc_music_material_sequence.png";
case RoomMusicPlayMode.shuffle:
return "sc_images/room/sc_music_material_shuffle.png";
case RoomMusicPlayMode.single:
return "sc_images/room/sc_music_material_single_loop.png";
}
}
static String _localizedModeText(BuildContext context, String message) {
switch (message) {
case "随机播放":
return RoomMusicTexts.t(context, "roomMusicShuffleMode", message);
case "单曲循环播放":
return RoomMusicTexts.t(context, "roomMusicSingleMode", message);
case "列表顺序播放":
default:
return RoomMusicTexts.t(context, "roomMusicListMode", message);
}
}
}
class _IconButton extends StatelessWidget {
const _IconButton({
required this.asset,
required this.size,
required this.onTap,
});
final String asset;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: size,
height: size,
child: Center(child: Image.asset(asset, width: size, height: size)),
),
);
}
}

View File

@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicRoomPlayer extends StatelessWidget {
const RoomMusicRoomPlayer({super.key});
static const String _closeAsset =
"sc_images/room/sc_music_room_player_close.png";
static const String _minimizeAsset =
"sc_images/room/sc_music_room_player_minimize.png";
static const String _manageAsset =
"sc_images/room/sc_music_room_player_manage.png";
static const String _previousAsset =
"sc_images/room/sc_music_room_player_previous.png";
static const String _nextAsset =
"sc_images/room/sc_music_room_player_next.png";
static const String _playAsset = "sc_images/room/sc_music_material_play.png";
static const String _pauseAsset =
"sc_images/room/sc_music_material_pause.png";
static const String _volumeAsset =
"sc_images/room/sc_music_material_volume.png";
@override
Widget build(BuildContext context) {
return Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final current = manager.current;
if (current == null || !manager.roomPlayerVisible) {
return const SizedBox.shrink();
}
final duration = manager.durationMs <= 0 ? 1 : manager.durationMs;
final position = manager.positionMs.clamp(0, duration).toInt();
return Container(
padding: EdgeInsets.fromLTRB(16.w, 10.w, 16.w, 10.w),
decoration: BoxDecoration(
color: const Color(0xE6072A24),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.10)),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_PlainIconButton(
asset: _closeAsset,
size: 24.w,
onTap: () => manager.stop(clearCurrent: true),
),
Expanded(
child: Text(
current.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
_PlainIconButton(
asset: _minimizeAsset,
size: 24.w,
onTap: manager.hideRoomPlayer,
),
],
),
SizedBox(height: 6.w),
Row(
children: [
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
SizedBox(width: 8.w),
Expanded(
child: _MusicSlider(
value: position.toDouble(),
max: duration.toDouble(),
onChanged: (value) => manager.seek(value.round()),
),
),
SizedBox(width: 8.w),
Text(
_formatDuration(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
],
),
SizedBox(height: 12.w),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_PlainIconButton(
asset: _modeAsset(manager.playMode),
size: 28.w,
onTap: manager.switchPlayMode,
),
_PlainIconButton(
asset: _previousAsset,
size: 42.w,
onTap: manager.previous,
),
_PlainIconButton(
asset: manager.isPlaying ? _pauseAsset : _playAsset,
size: 42.w,
onTap: () {
manager.isPlaying ? manager.pause() : manager.resume();
},
),
_PlainIconButton(
asset: _nextAsset,
size: 42.w,
onTap: manager.next,
),
_PlainIconButton(
asset: _manageAsset,
size: 28.w,
onTap:
() => VoiceRoomRoute.openRoomMusic(
context,
rootNavigator: true,
),
),
],
),
SizedBox(height: 12.w),
Row(
children: [
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
SizedBox(width: 8.w),
Expanded(
child: _MusicSlider(
value: manager.volume.toDouble(),
max: 100,
onChanged: (value) => manager.setVolume(value.round()),
),
),
SizedBox(width: 8.w),
SizedBox(
width: 38.w,
child: Text(
"${manager.volume}%",
textAlign: TextAlign.end,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
),
),
),
],
),
],
),
),
);
},
);
}
static String _formatDuration(int milliseconds) {
final totalSeconds = (milliseconds / 1000).floor();
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return "$minutes:${seconds.toString().padLeft(2, '0')}";
}
static String _modeAsset(RoomMusicPlayMode mode) {
switch (mode) {
case RoomMusicPlayMode.list:
return "sc_images/room/sc_music_material_sequence.png";
case RoomMusicPlayMode.shuffle:
return "sc_images/room/sc_music_material_shuffle.png";
case RoomMusicPlayMode.single:
return "sc_images/room/sc_music_material_single_loop.png";
}
}
}
class _MusicSlider extends StatelessWidget {
const _MusicSlider({
required this.value,
required this.max,
required this.onChanged,
});
final double value;
final double max;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final safeMax = max <= 0 ? 1.0 : max;
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3.w,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 5.w),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(alpha: 0.18),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: safeMax,
value: value.clamp(0, safeMax).toDouble(),
onChanged: onChanged,
),
);
}
}
class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({
required this.asset,
required this.size,
required this.onTap,
});
final String asset;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: size + 12.w,
height: size + 12.w,
child: Center(child: Image.asset(asset, width: size, height: size)),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
@ -13,7 +14,6 @@ import 'package:yumi/ui_kit/widgets/room/room_menu_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import '../../../app/routes/sc_fluro_navigator.dart';
@ -37,6 +37,8 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
static const double _floatingButtonBottomOffset = 54;
static const double _giftActionWidth = 52;
static const double _circleActionWidth = 46;
static const double _emojiActionWidth = 38;
static const double _chatEmojiGap = 8;
static const double _compactGap = 14;
static const double _horizontalPadding = 16;
@ -59,7 +61,10 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
builder: (context, bottomSnapshot, child) {
return LayoutBuilder(
builder: (context, constraints) {
final inputWidth = constraints.maxWidth / 3;
final inputWidth = _resolveInputWidth(
maxWidth: constraints.maxWidth,
showMic: bottomSnapshot.showMic,
);
final giftCenterX = _resolveGiftCenterX(
maxWidth: constraints.maxWidth,
inputWidth: inputWidth,
@ -107,6 +112,17 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildChatControls(double inputWidth) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildChatEntry(inputWidth),
SizedBox(width: _chatEmojiGap.w),
_buildEmojiAction(),
],
);
}
Widget _buildBottomBar({
required double inputWidth,
required bool showMic,
@ -127,7 +143,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
_buildChatControls(inputWidth),
giftAction,
_buildMicAction(isMic: isMic),
menuAction,
@ -137,7 +153,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
_buildChatControls(inputWidth),
const Spacer(),
giftAction,
SizedBox(width: _compactGap.w),
@ -157,6 +173,44 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildEmojiAction() {
return SizedBox(
width: _emojiActionWidth.w,
height: 48.w,
child: SCDebounceWidget(
onTap: () {
if (SCRoomUtils.touristCanMsg(context)) {
Navigator.push(
context,
PopRoute(child: const RoomMsgInput(initialShowEmoji: true)),
);
}
},
child: Center(
child: Image.asset(
"sc_images/room/sc_icon_room_bottom_emoji.png",
width: 34.w,
height: 34.w,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
),
);
}
double _resolveInputWidth({required double maxWidth, required bool showMic}) {
final contentWidth = maxWidth - (_horizontalPadding.w * 2);
final actionWidth =
_emojiActionWidth.w +
_chatEmojiGap.w +
_giftActionWidth.w +
_circleActionWidth.w * (showMic ? 3 : 2) +
(showMic ? 16.w : _compactGap.w * 2);
final availableWidth = contentWidth - actionWidth;
return availableWidth.clamp(82.w, 112.w).toDouble();
}
double _resolveGiftCenterX({
required double maxWidth,
required double inputWidth,
@ -165,17 +219,28 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
final contentWidth = maxWidth - (_horizontalPadding.w * 2);
if (showMic) {
final occupiedWidth =
inputWidth + _giftActionWidth.w + _circleActionWidth.w * 3;
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
_giftActionWidth.w +
_circleActionWidth.w * 3;
final gapCount = 4;
final gap = ((contentWidth - occupiedWidth) / gapCount).clamp(
0.0,
double.infinity,
);
return _horizontalPadding.w + inputWidth + gap + (_giftActionWidth.w / 2);
return _horizontalPadding.w +
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
gap +
(_giftActionWidth.w / 2);
}
final fixedWidth =
inputWidth +
_chatEmojiGap.w +
_emojiActionWidth.w +
_giftActionWidth.w +
_circleActionWidth.w * 2 +
_compactGap.w * 2;
@ -275,32 +340,8 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
final provider = context.read<RtcProvider>();
setState(() {
provider.isMic = !provider.isMic;
provider.roomWheatMap.forEach((k, v) {
final seat = provider.micAtIndexForDisplay(k);
if ((seat?.user?.id ?? "").trim() ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "")
.trim() &&
!(seat?.micMute ?? true)) {
if (!provider.isMic) {
provider.engine?.adjustRecordingSignalVolume(100);
provider.engine?.setClientRole(
role: ClientRoleType.clientRoleBroadcaster,
);
provider.engine?.muteLocalAudioStream(false);
} else {
if (provider.isMusicPlaying) {
provider.engine?.adjustRecordingSignalVolume(0);
} else {
provider.engine?.setClientRole(
role: ClientRoleType.clientRoleAudience,
);
provider.engine?.muteLocalAudioStream(provider.isMic);
}
}
}
});
});
unawaited(provider.setSelfMicMutedFromBottom(provider.isMic));
},
child: RoomBottomCircleAction(
child: Image.asset(

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class RoomEmojiAssetImage extends StatefulWidget {
const RoomEmojiAssetImage({
super.key,
required this.asset,
required this.width,
required this.height,
this.fit = BoxFit.contain,
});
final String asset;
final double width;
final double height;
final BoxFit fit;
@override
State<RoomEmojiAssetImage> createState() => _RoomEmojiAssetImageState();
}
class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
with AutomaticKeepAliveClientMixin {
late AssetImage _imageProvider;
@override
void initState() {
super.initState();
_imageProvider = AssetImage(widget.asset);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_precache();
}
@override
void didUpdateWidget(covariant RoomEmojiAssetImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.asset != widget.asset) {
_imageProvider = AssetImage(widget.asset);
_precache();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return RepaintBoundary(
child: Image(
image: _imageProvider,
width: widget.width,
height: widget.height,
fit: widget.fit,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
),
);
}
void _precache() {
precacheImage(_imageProvider, context);
}
@override
bool get wantKeepAlive => true;
}

View File

@ -37,8 +37,9 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuSound = 4;
static const int _menuReport = 5;
static const int _menuBackground = 6;
static const int _menuMusic = 7;
static bool get _showBackgroundFeature => false;
List<RoomMenu> items1 = [];
List<RoomMenu> items2 = [];
@override
@ -48,14 +49,6 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
@override
Widget build(BuildContext context) {
// items1.clear();
// items1.add(
// RoomMenu(
// 0,
// SCAppLocalizations.of(context)!.music,
// "sc_icon_room_music_tag.png",
// ),
// );
// items1.add(
// RoomMenu(
// 1,
@ -119,7 +112,8 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
: "sc_icon_mic_open.png",
),
);
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
if (_showBackgroundFeature &&
Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
_menuBackground,
@ -127,6 +121,21 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
"sc_icon_room_background.png",
),
);
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
} else {
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
}
if (!Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
@ -189,7 +198,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("sc_images/room/${item.icon}", width: 45.w, height: 45.w),
_buildMenuIcon(item),
SizedBox(height: 10.w),
text(
item.title,
@ -204,7 +213,13 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
],
),
onTap: () async {
if (item.id == _menuMicManagement) {
if (item.id == _menuMusic) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
VoiceRoomRoute.openRoomMusic(
navigatorKey.currentState!.context,
rootNavigator: true,
);
} else if (item.id == _menuMicManagement) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SmartDialog.show(
tag: "showRoomMicSwitch",
@ -277,6 +292,30 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
},
);
}
Widget _buildMenuIcon(RoomMenu item) {
if (item.id != _menuMusic) {
return Image.asset(
"sc_images/room/${item.icon}",
width: 45.w,
height: 45.w,
);
}
return Container(
width: 45.w,
height: 45.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.16),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/${item.icon}",
width: 26.w,
height: 26.w,
),
);
}
}
class RoomMenu {

View File

@ -1,10 +1,8 @@
import 'package:extended_text/extended_text.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/config/business_logic_strategy.dart';
import 'package:yumi/app/constants/sc_emoji_datas.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/app/constants/sc_screen.dart';
@ -14,21 +12,51 @@ import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/business_logic/usecases/sc_case.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/tools/sc_keybord_util.dart';
import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
///
class RoomMsgInput extends StatefulWidget {
final String? atTextContent;
final bool initialShowEmoji;
const RoomMsgInput({super.key, this.atTextContent});
const RoomMsgInput({
super.key,
this.atTextContent,
this.initialShowEmoji = false,
});
@override
State<RoomMsgInput> createState() => _RoomMsgInputState();
}
class _RoomMsgInputState extends State<RoomMsgInput> {
static const List<String> _roomEmojiAssets = [
"sc_images/room/emoji/fluent_emoji_01.webp",
"sc_images/room/emoji/fluent_emoji_02.webp",
"sc_images/room/emoji/fluent_emoji_03.webp",
"sc_images/room/emoji/fluent_emoji_04.webp",
"sc_images/room/emoji/fluent_emoji_05.webp",
"sc_images/room/emoji/fluent_emoji_06.webp",
"sc_images/room/emoji/fluent_emoji_07.webp",
"sc_images/room/emoji/fluent_emoji_08.webp",
"sc_images/room/emoji/fluent_emoji_09.webp",
"sc_images/room/emoji/fluent_emoji_10.webp",
"sc_images/room/emoji/monkey_1.gif",
"sc_images/room/emoji/monkey_2.gif",
"sc_images/room/emoji/monkey_3.gif",
"sc_images/room/emoji/monkey_4.gif",
"sc_images/room/emoji/monkey_5.gif",
"sc_images/room/emoji/monkey_6.gif",
"sc_images/room/emoji/monkey_7.gif",
"sc_images/room/emoji/monkey_8.gif",
"sc_images/room/emoji/monkey_9.gif",
"sc_images/room/emoji/monkey_10.gif",
];
bool showSend = false;
bool showEmoji = false;
bool _emojiAssetsPrecached = false;
final FocusNode msgNode = FocusNode();
final TextEditingController controller = TextEditingController();
@ -39,6 +67,7 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
@override
void initState() {
super.initState();
showEmoji = widget.initialShowEmoji;
if (widget.atTextContent != null) {
controller.value = TextEditingValue(text: widget.atTextContent ?? "");
showSend = controller.text.trim().isNotEmpty;
@ -52,6 +81,14 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (showEmoji) {
_precacheRoomEmojis();
}
}
@override
Widget build(BuildContext context) {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
@ -101,8 +138,8 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
child: Image.asset(
showEmoji
? _strategy.getSCMessageChatPageChatKeyboardIcon()
: _strategy.getSCMessageChatPageEmojiIcon(),
color: Colors.white,
: "sc_images/room/sc_icon_room_bottom_emoji.png",
color: showEmoji ? Colors.white : null,
fit: BoxFit.contain,
),
),
@ -135,7 +172,7 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
widget.atTextContent != null ? TextDirection.ltr : null,
specialTextSpanBuilder: AtTextSpanBuilder(),
focusNode: msgNode,
autofocus: true,
autofocus: !widget.initialShowEmoji,
textInputAction: TextInputAction.send,
onTap: () {
if (showEmoji) {
@ -210,16 +247,21 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
crossAxisSpacing: 8.w,
mainAxisSpacing: 8.w,
),
itemCount: SCEmojiDatas.smileys.length,
itemCount: _roomEmojiAssets.length,
itemBuilder: (context, index) {
final emoji = SCEmojiDatas.smileys[index];
final emojiAsset = _roomEmojiAssets[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_insertEmoji(emoji);
_sendRoomEmoji(emojiAsset);
},
child: Center(
child: ExtendedText(emoji, style: TextStyle(fontSize: sp(15))),
child: RoomEmojiAssetImage(
key: ValueKey(emojiAsset),
asset: emojiAsset,
width: 30.w,
height: 30.w,
),
),
);
},
@ -237,30 +279,47 @@ class _RoomMsgInputState extends State<RoomMsgInput> {
}
SCKeybordUtil.hide(context);
_precacheRoomEmojis();
setState(() {
showEmoji = true;
});
}
void _insertEmoji(String emoji) {
final value = controller.value;
final selection = value.selection;
final fallbackOffset = value.text.length;
final start = selection.isValid ? selection.start : fallbackOffset;
final end = selection.isValid ? selection.end : fallbackOffset;
final safeStart = start < 0 ? fallbackOffset : start;
final safeEnd = end < 0 ? fallbackOffset : end;
final nextText = value.text.replaceRange(safeStart, safeEnd, emoji);
void _precacheRoomEmojis() {
if (_emojiAssetsPrecached) {
return;
}
_emojiAssetsPrecached = true;
for (final asset in _roomEmojiAssets) {
precacheImage(AssetImage(asset), context);
}
}
controller.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: safeStart + emoji.length),
composing: TextRange.empty,
void _sendRoomEmoji(String emojiAsset) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currenRoom = rtcProvider.currenRoom;
final currentUser = AccountStorage().getCurrentUser()?.userProfile;
if (currenRoom == null || currentUser == null) {
return;
}
final seatIndex = rtcProvider.userOnMaiInIndex(currentUser.id ?? "");
final msg = Msg(
groupId: currenRoom.roomProfile?.roomProfile?.roomAccount ?? "",
role: rtcProvider.currenRoom?.entrants?.roles ?? "",
msg: emojiAsset,
type: SCRoomMsgType.emoticons,
user: currentUser,
number: seatIndex,
);
setState(() {
showSend = controller.text.trim().isNotEmpty;
});
if (seatIndex > -1) {
rtcProvider.starPlayEmoji(msg);
}
Provider.of<RtmProvider>(
context,
listen: false,
).dispatchMessage(msg, addLocal: seatIndex < 0);
}
void _sendMessage() {

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart';
import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart';
class RoomPlayWidget extends StatefulWidget {
const RoomPlayWidget({super.key});
@ -16,6 +17,11 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
return SizedBox.expand(
child: Stack(
children: [
PositionedDirectional(
end: 15.w,
bottom: 150.w,
child: const RoomMusicFloatingEntry(),
),
PositionedDirectional(
end: 15.w,
bottom: 100.w,

View File

@ -13,6 +13,8 @@ class RoomSeatWidget extends StatefulWidget {
}
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
static const int _defaultVoiceRoomSeatCount = 10;
int _lastSeatCount = 0;
int _normalizeSeatCount(int? seatCount) {
@ -40,19 +42,65 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
return _lastSeatCount;
}
if (snapshot.hasCurrentRoom && seatCount == 0) {
return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount;
}
return seatCount;
}
int _seatCountFromMicIndexes(Iterable<num> micIndexes) {
var maxIndex = -1;
var count = 0;
for (final micIndex in micIndexes) {
count += 1;
final resolvedIndex = micIndex.toInt();
if (resolvedIndex > maxIndex) {
maxIndex = resolvedIndex;
}
}
final indexedSeatCount = _normalizeSeatCount(maxIndex + 1);
if (indexedSeatCount > 0) {
return indexedSeatCount;
}
return _normalizeSeatCount(count);
}
int _seatContentVersion(RtcProvider provider) {
var version = provider.roomWheatMap.length;
final micIndexes =
provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b));
for (final micIndex in micIndexes) {
final seat = provider.micAtIndexForDisplay(micIndex);
final user = seat?.user;
version = Object.hash(
version,
micIndex,
seat?.micLock ?? false,
seat?.micMute ?? false,
user?.id ?? "",
user?.userAvatar ?? "",
user?.userNickname ?? "",
user?.roles ?? "",
user?.getHeaddress()?.sourceUrl ?? "",
user?.getVIP()?.name ?? "",
);
}
return version;
}
@override
Widget build(BuildContext context) {
return Selector<RtcProvider, _RoomSeatLayoutSnapshot>(
selector:
(context, provider) => _RoomSeatLayoutSnapshot(
seatCount: provider.roomWheatMap.length,
seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys),
seatContentVersion: _seatContentVersion(provider),
configuredSeatCount:
provider.currenRoom?.roomProfile?.roomSetting?.mikeSize
?.toInt() ??
provider.previewRoomSeatCount ??
0,
hasCurrentRoom: provider.currenRoom != null,
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
),
@ -210,12 +258,16 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
class _RoomSeatLayoutSnapshot {
const _RoomSeatLayoutSnapshot({
required this.seatCount,
required this.seatContentVersion,
required this.configuredSeatCount,
required this.hasCurrentRoom,
required this.isExitingCurrentVoiceRoomSession,
});
final int seatCount;
final int seatContentVersion;
final int configuredSeatCount;
final bool hasCurrentRoom;
final bool isExitingCurrentVoiceRoomSession;
@override
@ -225,7 +277,9 @@ class _RoomSeatLayoutSnapshot {
}
return other is _RoomSeatLayoutSnapshot &&
other.seatCount == seatCount &&
other.seatContentVersion == seatContentVersion &&
other.configuredSeatCount == configuredSeatCount &&
other.hasCurrentRoom == hasCurrentRoom &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession;
}
@ -233,7 +287,9 @@ class _RoomSeatLayoutSnapshot {
@override
int get hashCode => Object.hash(
seatCount,
seatContentVersion,
configuredSeatCount,
hasCurrentRoom,
isExitingCurrentVoiceRoomSession,
);
}

View File

@ -210,6 +210,10 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
clearsAfterStop: widget.clearsAfterStop,
filterQuality: widget.filterQuality,
allowDrawingOverflow: widget.allowDrawingOverflow,
preferredSize:
widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
),
),
);

View File

@ -765,6 +765,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
id3:
dependency: transitive
description:
name: id3
sha256: "24176a6e08db6297c8450079e94569cd8387f913c817e5e3d862be7dc191e0b8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
image:
dependency: transitive
description:
@ -1068,6 +1076,44 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
on_audio_query:
dependency: "direct main"
description:
path: "local_packages/on_audio_query-2.9.0"
relative: true
source: path
version: "2.9.0"
on_audio_query_android:
dependency: transitive
description:
path: "local_packages/on_audio_query_android-1.1.0"
relative: true
source: path
version: "1.1.0"
on_audio_query_ios:
dependency: transitive
description:
name: on_audio_query_ios
sha256: "9b3efa39a656fa3720980e3c6a1f55b7257d0032a45ffeb3f70eaa2c7f10f929"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
on_audio_query_platform_interface:
dependency: transitive
description:
name: on_audio_query_platform_interface
sha256: c23e019a31bd0774828476e428fd33b0dd1d82c9d4791dba80429358fc65dcd3
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.7.0"
on_audio_query_web:
dependency: transitive
description:
name: on_audio_query_web
sha256: "990efa52d879e6caffa97f24b34acd9caa1ce2c4c4cb873fe5a899a9b1af02c7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
package_config:
dependency: transitive
description:
@ -1093,7 +1139,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.2.1+5
version: 1.2.6+8
environment:
@ -118,9 +118,12 @@ dependencies:
#缩略图
video_thumbnail: ^0.5.6
badges: ^3.1.2
#本地路径
path_provider: ^2.1.4
photo_view: ^0.15.0
#本地路径
path_provider: ^2.1.4
path: ^1.9.0
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
photo_view: ^0.15.0
flutter_staggered_grid_view: ^0.7.0
marquee: ^2.3.0
extended_nested_scroll_view: ^6.2.1
@ -161,6 +164,7 @@ flutter:
- sc_images/daily_sign_in/
- sc_images/register_reward/
- sc_images/room/
- sc_images/room/emoji/
- sc_images/room/entrance/
- sc_images/room/anim/
- sc_images/room/anim/gift/
@ -170,4 +174,5 @@ flutter:
- sc_images/msg/
- sc_images/level/
- sc_images/coupon/
- sc_images/vip/
- fonts/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

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

Some files were not shown because too many files have changed in this diff Show More