Compare commits
17 Commits
fb86a9438c
...
36ba0f3573
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ba0f3573 | ||
|
|
fd445ccdaa | ||
|
|
9f4df82c42 | ||
|
|
7d12417116 | ||
|
|
62d6405aa0 | ||
|
|
b0d68d2756 | ||
|
|
ddb378f5b4 | ||
|
|
4de26d7663 | ||
|
|
1b4d0c40c6 | ||
|
|
78c3a95160 | ||
|
|
24c4a4b70c | ||
|
|
ddd50a65dc | ||
|
|
a3f8db991d | ||
|
|
4cf895b11e | ||
|
|
83fb6465f9 | ||
|
|
39464469ba | ||
|
|
6db3aa5ae8 |
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
android/app/src/main/res/xml/backup_rules.xml
Normal file
6
android/app/src/main/res/xml/backup_rules.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
</full-backup-content>
|
||||
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
548
docs/android-room-music-execution-plan.md
Normal file
548
docs/android-room-music-execution-plan.md
Normal file
@ -0,0 +1,548 @@
|
||||
# Android 语言房音乐功能执行方案
|
||||
|
||||
整理时间:2026-04-29
|
||||
适用范围:Android 单端,语言房内 MP3 背景音乐添加、管理与播放
|
||||
参考文档:`/Users/nigger/Desktop/Android音乐添加流程.md`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在语言房内补齐一套可上线的 Android 音乐能力:
|
||||
|
||||
- 从房间操作面板进入音乐功能。
|
||||
- 首次为空时引导扫描手机 MP3。
|
||||
- 支持 Android 音频权限申请与本地 MP3 扫描。
|
||||
- 按文件夹展示扫描结果,支持选择音乐添加到房间音乐列表。
|
||||
- 支持已添加音乐的搜索、删除、排序、二次添加、取消已添加。
|
||||
- 支持播放器:播放、暂停、停止、上一首、下一首、播放模式、音量、进度条拖动。
|
||||
- 支持麦下仅自己听,麦上推流给房间其他用户听。
|
||||
- 离房、切房、删除当前播放歌曲时正确停止并清理播放状态。
|
||||
|
||||
不纳入本次范围:
|
||||
|
||||
- iOS 权限和 iOS 媒体库适配。
|
||||
- 在线音乐、歌词、专辑封面精修。
|
||||
- 后台常驻播放通知栏。
|
||||
- 服务端同步歌单。当前按用户本地持久化处理。
|
||||
|
||||
## 2. 项目现状
|
||||
|
||||
已有基础:
|
||||
|
||||
- `agora_rtc_engine: ^6.5.2` 已接入,实际锁定版本为 6.5.3。
|
||||
- `lib/services/audio/rtc_manager.dart` 已有 `startAudioMixing(String localPath, int cycle)` 雏形。
|
||||
- `rtc_manager.dart` 已监听 `onAudioMixingStateChanged`,但只维护了 `isMusicPlaying`。
|
||||
- `lib/shared/data_sources/models/sc_music_mode.dart` 已有基础音乐模型。
|
||||
- `lib/shared/data_sources/sources/local/data_persistence.dart` 已有 `getUserRoomMusic()` / `setUserRoomMusic()`。
|
||||
- `sc_images/room/` 已有音乐相关图标:播放、暂停、上一首、下一首、音量、空态、播放模式、删除、添加、音乐入口等。
|
||||
- 多语言中已有 `music`、`myMusic`、`localMusic`、`add`、`delete`、`search`、`edit`、`confirm`、`cancel` 等基础文案。
|
||||
- `local_packages/on_audio_query-2.9.0` 和 `local_packages/on_audio_query_android-1.1.0` 已存在,可用于 Android MediaStore 音频扫描。
|
||||
|
||||
需要补齐:
|
||||
|
||||
- `pubspec.yaml` 尚未声明 `on_audio_query` 依赖。
|
||||
- AndroidManifest 缺少 Android 13+ 的 `READ_MEDIA_AUDIO`。
|
||||
- `SCMusicMode` 字段不足,缺少稳定 id、content uri、文件夹路径、排序、添加时间等。
|
||||
- 音乐入口在 `lib/ui_kit/widgets/room/room_menu_dialog.dart` 中曾经存在,但当前被注释。
|
||||
- 没有独立音乐状态管理器,播放器状态不应继续堆进已经很长的 `RtcProvider`。
|
||||
- Agora 音频混流只封装了开始播放,缺少暂停、恢复、停止、音量、进度、seek、播放完成切歌、麦上/麦下路由切换。
|
||||
|
||||
## 3. 总体技术方案
|
||||
|
||||
采用“扫描服务 + 歌单状态管理 + Agora 播放适配 + UI 页面”的拆分方式。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["房间菜单 音乐入口"] --> B["RoomMusicPage"]
|
||||
B --> C["RoomMusicManager"]
|
||||
C --> D["RoomMusicRepository"]
|
||||
D --> E["DataPersistence"]
|
||||
C --> F["LocalMusicScanner"]
|
||||
F --> G["on_audio_query / MediaStore"]
|
||||
C --> H["RoomMusicPlayer"]
|
||||
H --> I["RtcProvider / Agora RtcEngine"]
|
||||
```
|
||||
|
||||
推荐新增目录:
|
||||
|
||||
- `lib/services/music/`
|
||||
- `lib/modules/room/music/`
|
||||
- `lib/ui_kit/widgets/room/music/`
|
||||
|
||||
推荐新增核心文件:
|
||||
|
||||
- `lib/shared/data_sources/models/sc_music_mode.dart`:扩展现有模型。
|
||||
- `lib/shared/data_sources/models/sc_music_folder_mode.dart`:新增本地扫描文件夹模型。
|
||||
- `lib/services/music/local_music_scanner.dart`:Android 本地 MP3 扫描。
|
||||
- `lib/services/music/room_music_repository.dart`:已添加歌单本地持久化。
|
||||
- `lib/services/music/room_music_manager.dart`:歌单、选择、播放状态总控。
|
||||
- `lib/modules/room/music/room_music_page.dart`:音乐列表页。
|
||||
- `lib/modules/room/music/room_music_folder_page.dart`:扫描后的文件夹列表页。
|
||||
- `lib/modules/room/music/room_music_select_page.dart`:文件夹内音乐选择页。
|
||||
- `lib/ui_kit/widgets/room/music/room_music_player_bar.dart`:播放器控制条。
|
||||
- `lib/ui_kit/widgets/room/music/room_music_list_item.dart`:音乐列表项。
|
||||
- `lib/ui_kit/widgets/room/music/room_music_empty_state.dart`:空状态。
|
||||
|
||||
## 4. 依赖与权限
|
||||
|
||||
### 4.1 pubspec
|
||||
|
||||
在 `pubspec.yaml` 增加:
|
||||
|
||||
```yaml
|
||||
on_audio_query:
|
||||
path: ./local_packages/on_audio_query-2.9.0
|
||||
```
|
||||
|
||||
如果代码中直接使用 `package:path/path.dart` 做文件夹路径处理,建议把 `path` 显式加入依赖,避免依赖传递包:
|
||||
|
||||
```yaml
|
||||
path: ^1.9.0
|
||||
```
|
||||
|
||||
### 4.2 AndroidManifest
|
||||
|
||||
现有 Manifest 已有:
|
||||
|
||||
- `READ_EXTERNAL_STORAGE`
|
||||
- `WRITE_EXTERNAL_STORAGE`
|
||||
|
||||
需要补充 Android 13+ 音频权限,并建议限制旧权限版本:
|
||||
|
||||
```xml
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
```
|
||||
|
||||
### 4.3 权限工具
|
||||
|
||||
在 `lib/shared/tools/sc_permission_utils.dart` 增加 `checkAudioPermission()`:
|
||||
|
||||
- Android 13+:请求 `Permission.audio`。
|
||||
- Android 12 及以下:请求 `Permission.storage`。
|
||||
- 需要使用项目已有 `device_info_plus` 判断 SDK 版本。
|
||||
- 权限拒绝时不进入扫描结果页,只保留在当前流程;后续可补“去设置开启”引导。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
### 5.1 音乐项
|
||||
|
||||
扩展 `SCMusicMode`,兼容旧字段读取:
|
||||
|
||||
```dart
|
||||
class SCMusicMode {
|
||||
final String id; // 优先用 MediaStore id;兜底用 localPath hash
|
||||
final String title;
|
||||
final String artist;
|
||||
final int durationMs;
|
||||
final String localPath; // SongModel.data
|
||||
final String contentUri; // SongModel.uri,Agora Android 推荐优先使用
|
||||
final String folderPath;
|
||||
final String fileName;
|
||||
final int size;
|
||||
final int sortIndex;
|
||||
final int addedAt;
|
||||
}
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- 扫描只保留 `fileExtension == mp3` 或文件名 `.mp3` 的音频。
|
||||
- 播放时优先使用 `contentUri`,为空再用 `localPath`。
|
||||
- 持久化仍走 `DataPersistence.setUserRoomMusic(jsonEncode(list))`。
|
||||
- 读取旧数据时要兼容当前模型里的 `title`、`artist`、`duration`、`localPath`。
|
||||
|
||||
### 5.2 文件夹模型
|
||||
|
||||
新增 `SCMusicFolderMode`:
|
||||
|
||||
```dart
|
||||
class SCMusicFolderMode {
|
||||
final String id;
|
||||
final String name;
|
||||
final String path;
|
||||
final int musicCount;
|
||||
final int addedCount;
|
||||
final List<SCMusicMode> songs;
|
||||
}
|
||||
```
|
||||
|
||||
文件夹统计规则:
|
||||
|
||||
- 使用 `p.dirname(song.localPath)` 作为文件夹路径。
|
||||
- 单个文件夹只统计该文件夹当前层级直接包含的 MP3。
|
||||
- 子文件夹内 MP3 归入子文件夹,不累计到父文件夹。
|
||||
- 文件夹列表按名称或路径稳定排序。
|
||||
|
||||
## 6. 播放器方案
|
||||
|
||||
### 6.1 播放方式
|
||||
|
||||
统一使用 Agora `startAudioMixing`,原因是麦上需要把音乐推给远端。
|
||||
|
||||
麦下播放:
|
||||
|
||||
```dart
|
||||
engine.startAudioMixing(
|
||||
filePath: playPath,
|
||||
loopback: true,
|
||||
cycle: 1,
|
||||
startPos: currentPositionMs,
|
||||
);
|
||||
```
|
||||
|
||||
麦上播放:
|
||||
|
||||
```dart
|
||||
engine.startAudioMixing(
|
||||
filePath: playPath,
|
||||
loopback: false,
|
||||
cycle: 1,
|
||||
startPos: currentPositionMs,
|
||||
);
|
||||
```
|
||||
|
||||
这里的 `loopback` 是关键:
|
||||
|
||||
- `true`:只本地播放,只有自己听到。
|
||||
- `false`:本地和远端都能听到。
|
||||
|
||||
### 6.2 麦上/麦下同步
|
||||
|
||||
当用户播放中上麦或下麦时,需要切换播放路由:
|
||||
|
||||
1. 记录当前播放位置:`getAudioMixingCurrentPosition()`。
|
||||
2. `stopAudioMixing()`。
|
||||
3. 根据 `RtcProvider.isOnMai()` 重新 `startAudioMixing(loopback: !isOnMai, startPos: position)`。
|
||||
4. 恢复播放态和进度轮询。
|
||||
|
||||
接入点建议:
|
||||
|
||||
- 在 `RtcProvider` 上麦成功、下麦成功、角色切换成功后调用 `RoomMusicManager.syncPlaybackRouting(rtcProvider)`。
|
||||
- 离房清理流程调用 `RoomMusicManager.stopAndReset()`.
|
||||
|
||||
### 6.3 控制能力
|
||||
|
||||
`RoomMusicManager` 需要封装:
|
||||
|
||||
- `play(SCMusicMode item)`
|
||||
- `pause()`
|
||||
- `resume()`
|
||||
- `stop()`
|
||||
- `previous()`
|
||||
- `next({bool fromAutoComplete = false})`
|
||||
- `seek(Duration position)`
|
||||
- `setVolume(int percent)`
|
||||
- `switchPlayMode()`
|
||||
- `deleteMusic(SCMusicMode item)`
|
||||
- `reorder(int oldIndex, int newIndex)`
|
||||
|
||||
Agora 对应 API:
|
||||
|
||||
- `startAudioMixing`
|
||||
- `pauseAudioMixing`
|
||||
- `resumeAudioMixing`
|
||||
- `stopAudioMixing`
|
||||
- `adjustAudioMixingVolume`
|
||||
- `adjustAudioMixingPlayoutVolume`
|
||||
- `adjustAudioMixingPublishVolume`
|
||||
- `getAudioMixingDuration`
|
||||
- `getAudioMixingCurrentPosition`
|
||||
- `setAudioMixingPosition`
|
||||
|
||||
音量策略:
|
||||
|
||||
- UI 展示 0-100%。
|
||||
- 默认同时调用 `adjustAudioMixingPlayoutVolume(volume)` 和 `adjustAudioMixingPublishVolume(volume)`。
|
||||
- 如果后续产品区分“自己听到的音量”和“房间听到的音量”,再拆成两个滑杆。
|
||||
|
||||
### 6.4 播放模式
|
||||
|
||||
定义:
|
||||
|
||||
```dart
|
||||
enum RoomMusicPlayMode {
|
||||
list,
|
||||
shuffle,
|
||||
single,
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 列表顺序播放:播放完成后按当前排序下一首,末尾回到第一首或停止,建议按原型做循环到第一首。
|
||||
- 随机播放:播放完成后随机选择非当前歌曲;只有一首时播放自身。
|
||||
- 单曲循环:播放完成后继续当前歌曲。
|
||||
|
||||
切换 Toast:
|
||||
|
||||
- `列表顺序播放`
|
||||
- `随机播放`
|
||||
- `单曲循环播放`
|
||||
|
||||
### 6.5 播放完成
|
||||
|
||||
监听 `onAudioMixingStateChanged`:
|
||||
|
||||
- `audioMixingStatePlaying`:更新为播放中。
|
||||
- `audioMixingStatePaused`:更新为暂停。
|
||||
- `audioMixingStateStopped` + `audioMixingReasonAllLoopsCompleted`:按播放模式自动下一首。
|
||||
- `audioMixingStateStopped` + `audioMixingReasonStoppedByUser`:停止态。
|
||||
- `audioMixingStateFailed`:Toast 提示并停止当前播放,常见原因是文件被删除、路径无权限、格式不支持。
|
||||
|
||||
## 7. 页面与交互落地
|
||||
|
||||
### 7.1 房间操作面板入口
|
||||
|
||||
修改 `lib/ui_kit/widgets/room/room_menu_dialog.dart`:
|
||||
|
||||
- 恢复 `音乐` 入口。
|
||||
- 点击后关闭菜单,打开 `RoomMusicPage`。
|
||||
- 建议房主/管理员/普通用户都能看到入口;是否允许播放由房间配置 `allowMusic` 决定。如果产品要求强制限制,则判断 `currenRoom.roomProfile.roomSetting.allowMusic`。
|
||||
|
||||
### 7.2 音乐列表页
|
||||
|
||||
`RoomMusicPage` 状态:
|
||||
|
||||
- 空列表:展示空态图标、`没有音乐`、`从手机扫描添加`。
|
||||
- 有列表:顶部 `添加`、搜索入口、编辑入口、总数、歌曲列表。
|
||||
- 播放中:当前播放项高亮,底部显示播放器条。
|
||||
- 编辑态:支持拖拽排序;也可保留左滑删除。
|
||||
|
||||
列表能力:
|
||||
|
||||
- 搜索按 `title` / `fileName` 包含匹配。
|
||||
- 左滑删除,弹窗确认 `确定删除音乐?`。
|
||||
- 删除当前播放歌曲时,先 `stop()`,再删除并持久化。
|
||||
- 拖拽排序后立即更新 `sortIndex` 并持久化。
|
||||
|
||||
### 7.3 添加流程
|
||||
|
||||
流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["点击 添加/从手机扫描添加"] --> B["检查音频权限"]
|
||||
B -->|"拒绝"| C["停留当前页"]
|
||||
B -->|"允许"| D["扫描 MediaStore MP3"]
|
||||
D -->|"无结果"| E["Toast 未找到音乐"]
|
||||
D -->|"有结果"| F["文件夹列表页"]
|
||||
F --> G["选择文件夹"]
|
||||
G --> H["音乐选择页"]
|
||||
H --> I["勾选/取消勾选"]
|
||||
I --> J["点击 添加"]
|
||||
J --> K["更新已添加列表并返回音乐列表"]
|
||||
```
|
||||
|
||||
扫描规则:
|
||||
|
||||
- 每次进入添加流程都重新调用 `querySongs()`。
|
||||
- 过滤 MP3。
|
||||
- 过滤 `duration <= 0` 的异常项。
|
||||
- 文件不存在或 `localPath` 为空的项不进入结果。
|
||||
- 对同一个 MediaStore id 或同一路径去重。
|
||||
|
||||
选择规则:
|
||||
|
||||
- 已添加歌曲显示灰色勾选。
|
||||
- 本次新选歌曲显示高亮勾选。
|
||||
- 用户点击灰色勾选可取消已添加,提交后从列表移除。
|
||||
- 只有存在“新增歌曲”时,添加按钮高亮;如果只有取消已添加,建议按钮文案仍为 `添加` 但可点击,否则无法完成取消动作。这里与原型“无新增则置灰”有轻微冲突,建议和产品确认;若严格按原型,则取消已添加后也需要允许提交,否则规则无法闭环。
|
||||
|
||||
## 8. 具体实施步骤
|
||||
|
||||
### 阶段 1:基础依赖与数据层
|
||||
|
||||
预计 4-6 小时。
|
||||
|
||||
1. 修改 `pubspec.yaml`,接入 `on_audio_query`。
|
||||
2. 修改 `android/app/src/main/AndroidManifest.xml`,补 `READ_MEDIA_AUDIO`。
|
||||
3. 扩展 `SCPermissionUtils.checkAudioPermission()`。
|
||||
4. 扩展 `SCMusicMode`,新增 `SCMusicFolderMode`。
|
||||
5. 新增 `RoomMusicRepository`,封装读取、保存、去重、排序。
|
||||
6. 运行 `flutter pub get` 和基础静态检查。
|
||||
|
||||
### 阶段 2:扫描与添加流程
|
||||
|
||||
预计 6-8 小时。
|
||||
|
||||
1. 新增 `LocalMusicScanner`。
|
||||
2. 使用 `OnAudioQuery.checkAndRequest()` 或项目权限工具 + `querySongs()` 扫描。
|
||||
3. 实现 MP3 过滤、文件夹分组、已添加数量统计。
|
||||
4. 实现文件夹列表页。
|
||||
5. 实现音乐选择页、全选、新增勾选、已添加灰色勾选、取消已添加。
|
||||
6. 添加成功后更新本地持久化并返回音乐列表。
|
||||
|
||||
### 阶段 3:音乐列表与 CRUD
|
||||
|
||||
预计 6-8 小时。
|
||||
|
||||
1. 实现 `RoomMusicPage` 空态、有数据态。
|
||||
2. 接入搜索。
|
||||
3. 接入左滑删除和二次确认。
|
||||
4. 删除当前播放歌曲时停止播放。
|
||||
5. 接入拖拽排序并持久化。
|
||||
6. 接入口入口菜单。
|
||||
|
||||
### 阶段 4:播放器与 Agora 混流
|
||||
|
||||
预计 8-12 小时。
|
||||
|
||||
1. 新增 `RoomMusicManager extends ChangeNotifier`。
|
||||
2. 在 `main.dart` 的 `MultiProvider` 中注册。
|
||||
3. 封装播放、暂停、恢复、停止、上下首。
|
||||
4. 封装进度轮询,间隔建议 500-1000ms。
|
||||
5. 封装 `seek` 与音量调节。
|
||||
6. 实现三种播放模式。
|
||||
7. 处理 `onAudioMixingStateChanged` 自动切歌与失败兜底。
|
||||
8. 在上麦/下麦/离房流程接入播放路由同步和清理。
|
||||
|
||||
### 阶段 5:UI 细节与真机验证
|
||||
|
||||
预计 6-10 小时。
|
||||
|
||||
1. 调整播放器底部高度,避免遮挡列表。
|
||||
2. 接入播放中音乐 icon 旋转动画。
|
||||
3. 补充多语言 key:没有音乐、从手机扫描添加、未找到音乐、确定删除音乐、音乐已删除、总计歌曲、播放模式 Toast 等。
|
||||
4. Android 12、Android 13+ 真机验证权限。
|
||||
5. 真机验证麦上/麦下播放、闭麦不影响音乐输出。
|
||||
6. 回归语言房退出、切房、最小化、重新进入房间。
|
||||
|
||||
## 9. 建议工时
|
||||
|
||||
按 Android 单端、GPT-5.5 + Codex 协作速度估算:
|
||||
|
||||
| 模块 | 预计耗时 |
|
||||
|---|---:|
|
||||
| 依赖、权限、数据模型、持久化 | 4-6 小时 |
|
||||
| MP3 扫描、文件夹分组、添加选择 | 6-8 小时 |
|
||||
| 已添加列表、搜索、删除、排序 | 6-8 小时 |
|
||||
| Agora 播放器、进度、音量、模式、上下首 | 8-12 小时 |
|
||||
| 房间入口、上麦/下麦同步、离房清理 | 4-6 小时 |
|
||||
| Android 真机测试与修复 | 6-10 小时 |
|
||||
|
||||
总计建议预留:34-50 小时。
|
||||
|
||||
如果先做 MVP:
|
||||
|
||||
- 扫描 MP3
|
||||
- 添加到列表
|
||||
- 播放/暂停/停止
|
||||
- 上下首
|
||||
- 音量
|
||||
- 进度条
|
||||
- 删除
|
||||
- 麦下本地播放、麦上推流播放
|
||||
|
||||
预计:22-32 小时。
|
||||
|
||||
完整对齐原型并做稳定上线版本,建议按 40 小时左右排期。
|
||||
|
||||
## 10. 风险与处理
|
||||
|
||||
### 10.1 Android 10+ 路径访问
|
||||
|
||||
Agora 文档建议 Android 播放本地文件优先使用 `content://` URI。`on_audio_query` 的 `SongModel.uri` 可作为 `contentUri` 保存。
|
||||
|
||||
处理:
|
||||
|
||||
- 播放路径优先级:`contentUri` > `localPath`。
|
||||
- 如果 `audioMixingReasonCanNotOpen`,提示文件无法播放,并可从列表移除失效文件。
|
||||
|
||||
### 10.2 添加按钮规则冲突
|
||||
|
||||
原型写到“无新增音乐时添加按钮置灰”,同时又写到“点击置灰已添加可取消,点击添加后移除”。如果只取消已添加但没有新增,按钮置灰会导致无法提交移除。
|
||||
|
||||
建议:
|
||||
|
||||
- 只要本次选择结果和原已添加状态有差异,按钮即可点击。
|
||||
- 如果产品坚持按钮必须叫 `添加`,仍然允许提交取消动作。
|
||||
|
||||
### 10.3 播放状态跨麦位变化
|
||||
|
||||
用户麦下播放时是 `loopback: true`,上麦后需要切到 `loopback: false`。Agora 没有直接切换 loopback 的接口,需要 stop/start。
|
||||
|
||||
处理:
|
||||
|
||||
- 切换前读取进度。
|
||||
- 用 `startPos` 恢复。
|
||||
- 加 500ms 节流,避免连续上麦状态变化触发频繁 start。
|
||||
|
||||
### 10.4 当前播放歌曲被删除
|
||||
|
||||
处理:
|
||||
|
||||
- 删除前判断 `current?.id == target.id`。
|
||||
- 是当前歌曲则先 `stopAudioMixing()`。
|
||||
- 清空当前播放项和进度。
|
||||
- 再持久化删除。
|
||||
|
||||
### 10.5 房间生命周期
|
||||
|
||||
处理:
|
||||
|
||||
- 离房、RTC disconnected 清理、App termination 释放前调用音乐停止。
|
||||
- 切房时不保留上一房间播放状态。
|
||||
- 最小化播放器仅影响 UI,不影响播放。
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
权限与扫描:
|
||||
|
||||
- Android 13+ 首次添加触发音频权限弹窗。
|
||||
- Android 12 及以下可正常读取本地音乐。
|
||||
- 拒绝权限后不会进入扫描结果页。
|
||||
- 无 MP3 时 Toast `未找到音乐`。
|
||||
- 扫描结果只包含 MP3。
|
||||
- 文件夹数量符合“只统计当前层级直接音乐”的规则。
|
||||
|
||||
添加与列表:
|
||||
|
||||
- 空列表展示空态。
|
||||
- 添加后返回音乐列表并展示总数。
|
||||
- 二次添加能展示已添加数量。
|
||||
- 已添加歌曲灰色勾选,新选择歌曲高亮勾选。
|
||||
- 取消已添加并提交后,音乐列表移除对应歌曲。
|
||||
- 搜索能按名称过滤。
|
||||
- 左滑删除有确认弹窗。
|
||||
- 删除成功 Toast `音乐已删除`。
|
||||
- 拖拽排序后重新进入页面仍保持顺序。
|
||||
|
||||
播放:
|
||||
|
||||
- 点击歌曲开始播放。
|
||||
- 播放/暂停/恢复正常。
|
||||
- 上一首/下一首正常。
|
||||
- 进度条自动推进。
|
||||
- 拖动进度条能 seek。
|
||||
- 音量调节生效。
|
||||
- 列表顺序、随机、单曲循环三种模式正常。
|
||||
- 播放完成后按当前模式切歌。
|
||||
- 删除当前播放歌曲后立即停止。
|
||||
|
||||
房间场景:
|
||||
|
||||
- 麦下播放只有自己听到。
|
||||
- 播放中上麦后,其他用户能听到音乐。
|
||||
- 播放中下麦后,其他用户不再听到音乐,本地继续播放。
|
||||
- 闭麦不影响音乐推流。
|
||||
- 离房后音乐停止。
|
||||
- 切房后不会继续上一房间音乐。
|
||||
|
||||
## 12. 推荐落地顺序
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 数据模型、持久化、权限、扫描。
|
||||
2. 文件夹页和选择页。
|
||||
3. 音乐列表页和入口。
|
||||
4. 基础播放、暂停、停止。
|
||||
5. 上下首、进度、音量、模式。
|
||||
6. 麦上/麦下同步。
|
||||
7. 删除、排序、搜索和细节回归。
|
||||
|
||||
这样可以尽早在真机上验证最不确定的两件事:Android MediaStore 扫描结果,以及 Agora 使用 `contentUri` 播放本地 MP3 的兼容性。
|
||||
@ -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
|
||||
|
||||
@ -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 = "";
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>[];
|
||||
|
||||
@ -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(() {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
434
lib/modules/room/background/room_background_preview_page.dart
Normal file
434
lib/modules/room/background/room_background_preview_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@ -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();
|
||||
|
||||
143
lib/modules/room/music/room_music_folder_page.dart
Normal file
143
lib/modules/room/music/room_music_folder_page.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
629
lib/modules/room/music/room_music_page.dart
Normal file
629
lib/modules/room/music/room_music_page.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
259
lib/modules/room/music/room_music_select_page.dart
Normal file
259
lib/modules/room/music/room_music_select_page.dart
Normal 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')}";
|
||||
}
|
||||
}
|
||||
11
lib/modules/room/music/room_music_texts.dart
Normal file
11
lib/modules/room/music/room_music_texts.dart
Normal 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
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
176
lib/modules/room_game/utils/room_game_viewport.dart
Normal file
176
lib/modules/room_game/utils/room_game_viewport.dart
Normal 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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`)方法,取消计时器。
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
788
lib/modules/user/task/task_page.dart
Normal file
788
lib/modules/user/task/task_page.dart
Normal file
@ -0,0 +1,788 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/modules/index/main_route.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
|
||||
|
||||
class TaskPage extends StatefulWidget {
|
||||
const TaskPage({super.key});
|
||||
|
||||
@override
|
||||
State<TaskPage> createState() => _TaskPageState();
|
||||
}
|
||||
|
||||
class _TaskPageState extends State<TaskPage> {
|
||||
static const String _taskMicAsset = 'sc_images/index/sc_icon_task_mic.png';
|
||||
static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png';
|
||||
static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
|
||||
|
||||
final SCAccountRepository _repository = SCAccountRepository();
|
||||
final Set<num> _claimedTaskIds = {};
|
||||
final Set<num> _claimingTaskIds = {};
|
||||
List<SCTaskListRes> _tasks = [];
|
||||
bool _loading = true;
|
||||
String? _toastMessage;
|
||||
Timer? _toastTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTasks();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_toastTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadTasks() async {
|
||||
try {
|
||||
final tasks = await _repository.tasks();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tasks =
|
||||
tasks
|
||||
..sort((a, b) => (a.sortOrder ?? 0).compareTo(b.sortOrder ?? 0));
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tasks = [];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dailyTasks = _sectionTasks(isNewcomer: false);
|
||||
final newcomerTasks = _sectionTasks(isNewcomer: true);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF00221D),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_buildTopBar(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: const Color(0xFF16DBB2),
|
||||
backgroundColor: const Color(0xFF06342A),
|
||||
onRefresh: _loadTasks,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(10.w, 18.w, 10.w, 28.w),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_loading)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 120.w),
|
||||
child: SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF16DBB2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._buildTaskSections(dailyTasks, newcomerTasks),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_toastMessage != null) _buildToast(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return SizedBox(
|
||||
height: 44.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => SCNavigatorUtils.goBack(context),
|
||||
child: SizedBox(
|
||||
width: 44.w,
|
||||
height: 44.w,
|
||||
child: Icon(
|
||||
SCGlobalConfig.lang == 'ar'
|
||||
? Icons.keyboard_arrow_right
|
||||
: Icons.keyboard_arrow_left,
|
||||
color: Colors.white,
|
||||
size: 28.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
SCAppLocalizations.of(context)!.task,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToast() {
|
||||
return Positioned(
|
||||
left: 50.w,
|
||||
right: 50.w,
|
||||
top: 316.w,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 56.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF063F36),
|
||||
borderRadius: BorderRadius.circular(12.w),
|
||||
border: Border.all(color: const Color(0xFF18DDB5), width: 1.w),
|
||||
),
|
||||
child: Text(
|
||||
_toastMessage!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTaskSections(
|
||||
List<_TaskUiItem> dailyTasks,
|
||||
List<_TaskUiItem> newcomerTasks,
|
||||
) {
|
||||
final sections = <Widget>[];
|
||||
final l10n = SCAppLocalizations.of(context)!;
|
||||
if (dailyTasks.isNotEmpty) {
|
||||
sections.add(
|
||||
_TaskSection(
|
||||
title: l10n.dailyTasks,
|
||||
subtitle: l10n.resetsDailyAtMidnight,
|
||||
tasks: dailyTasks,
|
||||
onAction: _handleTaskAction,
|
||||
isClaiming: _claimingTaskIds.contains,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (newcomerTasks.isNotEmpty) {
|
||||
if (sections.isNotEmpty) sections.add(SizedBox(height: 14.w));
|
||||
sections.add(
|
||||
_TaskSection(
|
||||
title: l10n.exclusiveForNewcomers,
|
||||
subtitle: l10n.limitedToOneTime,
|
||||
tasks: newcomerTasks,
|
||||
onAction: _handleTaskAction,
|
||||
isClaiming: _claimingTaskIds.contains,
|
||||
),
|
||||
);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
List<_TaskUiItem> _sectionTasks({required bool isNewcomer}) {
|
||||
final source =
|
||||
_tasks.where((task) {
|
||||
final taskType = (task.taskType ?? 0).toInt();
|
||||
return isNewcomer ? taskType != 0 : taskType == 0;
|
||||
}).toList();
|
||||
final mapped = source.map(_mapTask).toList();
|
||||
if (_tasks.isNotEmpty) return mapped;
|
||||
final demoTasks = _demoTasks(isNewcomer: isNewcomer);
|
||||
return demoTasks
|
||||
.map(
|
||||
(task) =>
|
||||
_claimedTaskIds.contains(task.id)
|
||||
? task.copyWith(status: _TaskActionStatus.claimed)
|
||||
: task,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
_TaskUiItem _mapTask(SCTaskListRes task) {
|
||||
final id = task.taskId ?? -1;
|
||||
final currentValue = _parseNumber(task.completedValue) ?? 0;
|
||||
final targetValue =
|
||||
_parseNumber(task.targetValue) ??
|
||||
_parseNumber(task.conditionValue) ??
|
||||
1000000;
|
||||
final claimed =
|
||||
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
|
||||
final completedByValue = targetValue > 0 && currentValue >= targetValue;
|
||||
final completedByStatus = (task.taskStatus ?? 0) == 1;
|
||||
final status =
|
||||
claimed
|
||||
? _TaskActionStatus.claimed
|
||||
: (completedByStatus || completedByValue)
|
||||
? _TaskActionStatus.claim
|
||||
: _TaskActionStatus.go;
|
||||
|
||||
return _TaskUiItem(
|
||||
id: id,
|
||||
title:
|
||||
_nonEmpty(task.taskName) ??
|
||||
SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
|
||||
progressText:
|
||||
'${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}',
|
||||
rewardText: _formatCompactNumber(task.quantity ?? 1100),
|
||||
status: status,
|
||||
iconAsset: _iconForTask(task),
|
||||
jumpPage: _nonEmpty(task.jumpPage),
|
||||
fromApi: task.taskId != null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleTaskAction(_TaskUiItem task) async {
|
||||
if (task.status == _TaskActionStatus.claimed) return;
|
||||
if (task.status == _TaskActionStatus.go) {
|
||||
_openJumpPage(task.jumpPage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_claimingTaskIds.contains(task.id)) return;
|
||||
setState(() => _claimingTaskIds.add(task.id));
|
||||
var success = true;
|
||||
if (task.fromApi && task.id >= 0) {
|
||||
try {
|
||||
success = await _repository.taskReward(task.id);
|
||||
} catch (_) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_claimingTaskIds.remove(task.id);
|
||||
if (success) _claimedTaskIds.add(task.id);
|
||||
});
|
||||
if (success) {
|
||||
_showToast(SCAppLocalizations.of(context)!.rewardClaimedSuccessfully);
|
||||
unawaited(_loadTasks());
|
||||
} else {
|
||||
_showToast(SCAppLocalizations.of(context)!.rewardClaimFailed);
|
||||
}
|
||||
}
|
||||
|
||||
void _openJumpPage(String? jumpPage) {
|
||||
final target = jumpPage?.trim() ?? '';
|
||||
if (target.isEmpty) return;
|
||||
if (target.startsWith('http://') || target.startsWith('https://')) {
|
||||
SCNavigatorUtils.push(
|
||||
context,
|
||||
'${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(target)}&showTitle=false',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (target.startsWith('/')) {
|
||||
SCNavigatorUtils.push(context, target);
|
||||
}
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
_toastTimer?.cancel();
|
||||
setState(() => _toastMessage = message);
|
||||
_toastTimer = Timer(const Duration(milliseconds: 1600), () {
|
||||
if (mounted) setState(() => _toastMessage = null);
|
||||
});
|
||||
}
|
||||
|
||||
String _iconForTask(SCTaskListRes task) {
|
||||
final text =
|
||||
'${task.conditionType ?? ''} ${task.taskName ?? ''} ${task.taskDesc ?? ''}'
|
||||
.toLowerCase();
|
||||
if (text.contains('gift')) return _taskGiftAsset;
|
||||
if (text.contains('game')) return _taskGameAsset;
|
||||
return _taskMicAsset;
|
||||
}
|
||||
|
||||
static num? _parseNumber(Object? value) {
|
||||
if (value is num) return value;
|
||||
if (value is String) return num.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _nonEmpty(String? value) {
|
||||
final text = value?.trim();
|
||||
return text == null || text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static String _formatPlainNumber(num value) {
|
||||
if (value % 1 == 0) return value.toInt().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
static String _formatCompactNumber(num value) {
|
||||
final absValue = value.abs();
|
||||
if (absValue >= 1000000) {
|
||||
return '${_trimTrailingZero(value / 1000000)}m';
|
||||
}
|
||||
if (absValue >= 1000) {
|
||||
return '${_trimTrailingZero(value / 1000)}k';
|
||||
}
|
||||
return _formatPlainNumber(value);
|
||||
}
|
||||
|
||||
static String _trimTrailingZero(num value) {
|
||||
final text = value.toStringAsFixed(1);
|
||||
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||
}
|
||||
|
||||
List<_TaskUiItem> _demoTasks({required bool isNewcomer}) {
|
||||
final title = SCAppLocalizations.of(context)!.useMicrophoneForOneMin;
|
||||
final baseId = isNewcomer ? -200 : -100;
|
||||
return [
|
||||
_TaskUiItem(
|
||||
id: baseId - 1,
|
||||
title: title,
|
||||
progressText: '0/1000000',
|
||||
rewardText: '1.1k',
|
||||
status: _TaskActionStatus.go,
|
||||
iconAsset: _taskMicAsset,
|
||||
),
|
||||
_TaskUiItem(
|
||||
id: baseId - 2,
|
||||
title: title,
|
||||
progressText: '0/1000000',
|
||||
rewardText: '1.1k',
|
||||
status: _TaskActionStatus.claim,
|
||||
iconAsset: _taskGameAsset,
|
||||
),
|
||||
_TaskUiItem(
|
||||
id: baseId - 3,
|
||||
title: title,
|
||||
progressText: '0/1000000',
|
||||
rewardText: '1.1k',
|
||||
status: _TaskActionStatus.claimed,
|
||||
iconAsset: _taskGiftAsset,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskSection extends StatelessWidget {
|
||||
const _TaskSection({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.tasks,
|
||||
required this.onAction,
|
||||
required this.isClaiming,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<_TaskUiItem> tasks;
|
||||
final ValueChanged<_TaskUiItem> onAction;
|
||||
final bool Function(num id) isClaiming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF052E25),
|
||||
borderRadius: BorderRadius.circular(7.w),
|
||||
border: Border.all(color: const Color(0xFFB4FCCE), width: 1.w),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12.w, 24.w, 12.w, 12.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _GradientTaskTitle(title)),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < tasks.length; i++) ...[
|
||||
_TaskRow(
|
||||
task: tasks[i],
|
||||
onAction: onAction,
|
||||
claiming: isClaiming(tasks[i].id),
|
||||
),
|
||||
if (i != tasks.length - 1)
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 62.w),
|
||||
child: Divider(
|
||||
height: 1.w,
|
||||
thickness: 0.5.w,
|
||||
color: const Color(0xFF295346).withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientTaskTitle extends StatelessWidget {
|
||||
const _GradientTaskTitle(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: 23.w,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
top: 3.w,
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(color: const Color(0xFF157D10)),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(
|
||||
foreground:
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.w
|
||||
..color = const Color(0xFF157D10),
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback:
|
||||
(bounds) => const LinearGradient(
|
||||
begin: Alignment.centerRight,
|
||||
end: Alignment.centerLeft,
|
||||
colors: [
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFBEFAE8),
|
||||
Color(0xFFFFFFFF),
|
||||
],
|
||||
stops: [0.224, 0.4907, 0.7224],
|
||||
).createShader(bounds),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.onAction,
|
||||
required this.claiming,
|
||||
});
|
||||
|
||||
final _TaskUiItem task;
|
||||
final ValueChanged<_TaskUiItem> onAction;
|
||||
final bool claiming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 65.w,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 12.w),
|
||||
Container(
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x30BEF9E8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
task.iconAsset,
|
||||
width: 29.4.w,
|
||||
height: 29.4.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.w),
|
||||
Text(
|
||||
task.progressText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.w),
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'sc_images/general/sc_icon_jb.png',
|
||||
width: 12.w,
|
||||
height: 12.w,
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
task.rewardText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFFFD155),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
_TaskActionButton(
|
||||
status: task.status,
|
||||
claiming: claiming,
|
||||
onTap: () => onAction(task),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskActionButton extends StatelessWidget {
|
||||
const _TaskActionButton({
|
||||
required this.status,
|
||||
required this.claiming,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _TaskActionStatus status;
|
||||
final bool claiming;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = _TaskButtonConfig.fromStatus(
|
||||
status,
|
||||
SCAppLocalizations.of(context)!,
|
||||
);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: claiming ? null : onTap,
|
||||
child: Container(
|
||||
width: config.width.w,
|
||||
height: 20.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10.w),
|
||||
border:
|
||||
config.borderColor == null
|
||||
? null
|
||||
: Border.all(color: config.borderColor!, width: 1.w),
|
||||
gradient: config.gradient,
|
||||
color: config.gradient == null ? config.backgroundColor : null,
|
||||
),
|
||||
child:
|
||||
claiming
|
||||
? SizedBox(
|
||||
width: 10.w,
|
||||
height: 10.w,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5.w,
|
||||
color: config.textColor,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
config.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: config.textColor,
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskButtonConfig {
|
||||
const _TaskButtonConfig({
|
||||
required this.label,
|
||||
required this.textColor,
|
||||
required this.width,
|
||||
this.backgroundColor,
|
||||
this.borderColor,
|
||||
this.gradient,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color textColor;
|
||||
final double width;
|
||||
final Color? backgroundColor;
|
||||
final Color? borderColor;
|
||||
final Gradient? gradient;
|
||||
|
||||
factory _TaskButtonConfig.fromStatus(
|
||||
_TaskActionStatus status,
|
||||
SCAppLocalizations l10n,
|
||||
) {
|
||||
switch (status) {
|
||||
case _TaskActionStatus.claim:
|
||||
return _TaskButtonConfig(
|
||||
label: l10n.claim,
|
||||
textColor: Color(0xFF2B1500),
|
||||
width: 53,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFD981), Color(0xFFFF9E2D)],
|
||||
),
|
||||
);
|
||||
case _TaskActionStatus.claimed:
|
||||
return _TaskButtonConfig(
|
||||
label: l10n.claimed,
|
||||
textColor: Colors.white,
|
||||
width: 58,
|
||||
backgroundColor: const Color(0xFF062E28),
|
||||
borderColor: Colors.white.withValues(alpha: 0.56),
|
||||
);
|
||||
case _TaskActionStatus.go:
|
||||
return _TaskButtonConfig(
|
||||
label: l10n.go,
|
||||
textColor: Colors.white,
|
||||
width: 53,
|
||||
backgroundColor: const Color(0xFF062E28),
|
||||
borderColor: const Color(0xFF98E4C2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskUiItem {
|
||||
const _TaskUiItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.progressText,
|
||||
required this.rewardText,
|
||||
required this.status,
|
||||
required this.iconAsset,
|
||||
this.jumpPage,
|
||||
this.fromApi = false,
|
||||
});
|
||||
|
||||
final num id;
|
||||
final String title;
|
||||
final String progressText;
|
||||
final String rewardText;
|
||||
final _TaskActionStatus status;
|
||||
final String iconAsset;
|
||||
final String? jumpPage;
|
||||
final bool fromApi;
|
||||
|
||||
_TaskUiItem copyWith({_TaskActionStatus? status}) {
|
||||
return _TaskUiItem(
|
||||
id: id,
|
||||
title: title,
|
||||
progressText: progressText,
|
||||
rewardText: rewardText,
|
||||
status: status ?? this.status,
|
||||
iconAsset: iconAsset,
|
||||
jumpPage: jumpPage,
|
||||
fromApi: fromApi,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _TaskActionStatus { go, claim, claimed }
|
||||
15
lib/modules/user/task/task_route.dart
Normal file
15
lib/modules/user/task/task_route.dart
Normal file
@ -0,0 +1,15 @@
|
||||
import 'package:fluro/fluro.dart';
|
||||
import 'package:yumi/app/routes/sc_router_init.dart';
|
||||
import 'package:yumi/modules/user/task/task_page.dart';
|
||||
|
||||
class TaskRoute implements SCIRouterProvider {
|
||||
static String task = '/main/me/task';
|
||||
|
||||
@override
|
||||
void initRouter(FluroRouter router) {
|
||||
router.define(
|
||||
task,
|
||||
handler: Handler(handlerFunc: (_, params) => const TaskPage()),
|
||||
);
|
||||
}
|
||||
}
|
||||
392
lib/modules/user/vip/vip_benefit_page.dart
Normal file
392
lib/modules/user/vip/vip_benefit_page.dart
Normal 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;
|
||||
}
|
||||
1476
lib/modules/user/vip/vip_detail_page.dart
Normal file
1476
lib/modules/user/vip/vip_detail_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
491
lib/modules/user/vip/vip_instruction_page.dart
Normal file
491
lib/modules/user/vip/vip_instruction_page.dart
Normal 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;
|
||||
}
|
||||
32
lib/modules/user/vip/vip_route.dart
Normal file
32
lib/modules/user/vip/vip_route.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
@ -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>(
|
||||
|
||||
65
lib/services/home/home_room_preload_manager.dart
Normal file
65
lib/services/home/home_room_preload_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
165
lib/services/music/local_music_scanner.dart
Normal file
165
lib/services/music/local_music_scanner.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
469
lib/services/music/room_music_manager.dart
Normal file
469
lib/services/music/room_music_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
62
lib/services/music/room_music_repository.dart
Normal file
62
lib/services/music/room_music_repository.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
743
lib/shared/business_logic/models/res/sc_vip_res.dart
Normal file
743
lib/shared/business_logic/models/res/sc_vip_res.dart
Normal 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?');
|
||||
}
|
||||
@ -44,7 +44,7 @@ abstract class SocialChatUserRepository {
|
||||
String openId,
|
||||
SCUserProfileCmd userProfileCmd, {
|
||||
SCMobileAuthCmd? mobileAuthCmd,
|
||||
String invitePeople,
|
||||
String invitationCode,
|
||||
});
|
||||
|
||||
///关注的房间
|
||||
|
||||
19
lib/shared/business_logic/repositories/vip_repository.dart
Normal file
19
lib/shared/business_logic/repositories/vip_repository.dart
Normal 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});
|
||||
}
|
||||
@ -15,4 +15,5 @@ enum SCSysytemMessageType{
|
||||
CP_LOVE_LETTER,
|
||||
USER_COINS_RECEIVED,
|
||||
COIN_SELLER_COINS_RECEIVED,
|
||||
}
|
||||
REGISTER_REWARD_GRANTED,
|
||||
}
|
||||
|
||||
29
lib/shared/data_sources/models/sc_music_folder_mode.dart
Normal file
29
lib/shared/data_sources/models/sc_music_folder_mode.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
///退出登录
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
60
lib/shared/tools/sc_h5_url_utils.dart
Normal file
60
lib/shared/tools/sc_h5_url_utils.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
131
lib/shared/tools/sc_install_referrer_utils.dart
Normal file
131
lib/shared/tools/sc_install_referrer_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
"正在进入聊天室......",
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
32
lib/ui_kit/widgets/room/music/room_music_floating_entry.dart
Normal file
32
lib/ui_kit/widgets/room/music/room_music_floating_entry.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
242
lib/ui_kit/widgets/room/music/room_music_player_bar.dart
Normal file
242
lib/ui_kit/widgets/room/music/room_music_player_bar.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
251
lib/ui_kit/widgets/room/music/room_music_room_player.dart
Normal file
251
lib/ui_kit/widgets/room/music/room_music_room_player.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
67
lib/ui_kit/widgets/room/room_emoji_asset_image.dart
Normal file
67
lib/ui_kit/widgets/room/room_emoji_asset_image.dart
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
48
pubspec.lock
48
pubspec.lock
@ -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"
|
||||
|
||||
13
pubspec.yaml
13
pubspec.yaml
@ -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/
|
||||
|
||||
BIN
sc_images/general/sc_icon_wallet_coin.png
Normal file
BIN
sc_images/general/sc_icon_wallet_coin.png
Normal file
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 |
BIN
sc_images/index/sc_icon_task_game.png
Normal file
BIN
sc_images/index/sc_icon_task_game.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 B |
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user