bug fix
This commit is contained in:
parent
9a889e32cb
commit
85a1b01347
@ -339,12 +339,14 @@
|
||||
"roomRedPacketSendSuccess": "تم إرسال حزمة الحظ",
|
||||
"roomRedPacketComingSoon": "قريباً",
|
||||
"roomRedPacketRulesTitle": "القواعد",
|
||||
"roomRedPacketRulesContent": "1. يمكن لأعضاء الغرفة استلام حزمة الحظ بعد وقت الاستلام المحدد.\n2. سيتم خصم العملات بعد الإرسال، ويمكن إرجاع العملات غير المستلمة بعد انتهاء النشاط.\n3. يرجى الحفاظ على تفاعل ودي داخل الغرفة وعدم استخدام حزم الحظ للرسائل المزعجة أو الاحتيال.\n4. سيتم تحديث النص النهائي للقواعد بعد تأكيد واجهة الخادم.",
|
||||
"roomRedPacketRulesContent": "1. يمكنك إنفاق العملات لإرسال حزمة حظ. إذا فتح الآخرون حزمة الحظ الخاصة بك، يمكنهم الحصول على عدد عشوائي من العملات.\n\n2. بعد مرور ساعتين على إرسال حزمة الحظ، ستُعاد العملات التي لم تُستلم إلى محفظتك.\n\n3. بعد إرسال حزمة حظ على مستوى الخادم، سيظهر شريط حزمة حظ على مستوى الخادم، وستحصل على المزيد من الاهتمام.\n\n4. يمكنك إرسال %1$s حزمة حظ للغرفة يومياً.\n\n5. لا يمكن استلام حزم الحظ التي يرسلها مستخدمون من منطقة مختلفة.",
|
||||
"roomRedPacketHistoryTitle": "سجل إرسال حزم الحظ",
|
||||
"roomRedPacketHistoryItemTitle": "حزمة حظ ({1} مظروفاً)",
|
||||
"roomRedPacketHistoryStatusSent": "تم الإرسال",
|
||||
"roomRedPacketHistoryStatusPending": "قيد الانتظار",
|
||||
"roomRedPacketHistoryStatusReturned": "تم الإرجاع",
|
||||
"roomRedPacketHistoryRefund": "المسترد: {1}",
|
||||
"roomRedPacketClaimRecordsTitle": "سجل الاستلام",
|
||||
"roomRedPacketChatMessage": "أرسلت لك مظروفاً أحمر، افتحه الآن!",
|
||||
"roomRedPacketReceivedMessage": "استلم {1} عملة من حزمة الحظ {2}",
|
||||
"roomRedPacketOpenSubtitle": "وصلت حزمة الحظ",
|
||||
@ -358,7 +360,9 @@
|
||||
"roomRedPacketInsufficientBalance": "العملات الذهبية غير كافية.",
|
||||
"roomRedPacketPresenceRequired": "يرجى البقاء داخل الغرفة لاستخدام حزم الحظ.",
|
||||
"roomRedPacketNetworkError": "تعذر معالجة حزمة الحظ الآن.",
|
||||
"roomRedPacketAlreadyClaimed": "لقد استلمت حزمة الحظ هذه بالفعل.",
|
||||
"roomRedPacketOpenWaiting": "يمكن فتحها بعد {1} دقائق.",
|
||||
"roomRedPacketOpenWaitingCountdown": "متاح للاستلام بعد {1}",
|
||||
"roomRedPacketOkay": "حسناً",
|
||||
"roomRedPacketFloatingTitle": "{1} أرسل مظروفاً أحمر",
|
||||
"roomRedPacketFloatingSubtitle": "أرسل مظروفاً أحمر",
|
||||
@ -577,10 +581,15 @@
|
||||
"rewardClaimFailed": "فشل استلام المكافأة",
|
||||
"nickName": "الاسم المستعار",
|
||||
"giftSpecialEffects": "تأثيرات الهدايا الخاصة",
|
||||
"country": "دولة",
|
||||
"basicFeatures": "الميزات الأساسية",
|
||||
"floatingAnimationInGlobal": "الرسوم المتحركة العائمة في العالم",
|
||||
"joinMemberTips": "إذا كنت زائرًا في الغرفة، لا يمكنك أخذ الميكروفون.",
|
||||
"country": "دولة",
|
||||
"basicFeatures": "الميزات الأساسية",
|
||||
"floatingAnimationInGlobal": "الرسوم المتحركة العائمة في العالم",
|
||||
"broadcast": "البث",
|
||||
"broadcastDisplay": "عرض البث",
|
||||
"broadcastAllPlaces": "كل الأماكن",
|
||||
"broadcastRoomOnly": "داخل الغرفة",
|
||||
"broadcastOff": "إغلاق البث",
|
||||
"joinMemberTips": "إذا كنت زائرًا في الغرفة، لا يمكنك أخذ الميكروفون.",
|
||||
"countryRegion": "البلد والمنطقة",
|
||||
"gender": "الجنس",
|
||||
"likedYourComment": "أعجب بتعليقك.",
|
||||
|
||||
@ -187,12 +187,14 @@
|
||||
"roomRedPacketSendSuccess": "লাকি প্যাক পাঠানো হয়েছে",
|
||||
"roomRedPacketComingSoon": "শীঘ্রই আসছে",
|
||||
"roomRedPacketRulesTitle": "নিয়ম",
|
||||
"roomRedPacketRulesContent": "1. নির্বাচিত গ্রহণের সময় শেষ হলে রুমের সদস্যরা প্রতিটি লাকি প্যাক গ্রহণ করতে পারবেন।\n2. পাঠানোর পরে কয়েন কেটে নেওয়া হবে। কার্যক্রম শেষ হলে অব্যবহৃত কয়েন ফেরত দেওয়া যেতে পারে।\n3. রুমের যোগাযোগ বন্ধুত্বপূর্ণ রাখুন এবং স্প্যাম বা প্রতারণার জন্য লাকি প্যাক ব্যবহার করবেন না।\n4. ব্যাকএন্ড API নিশ্চিত হওয়ার পরে চূড়ান্ত নিয়মের লেখা আপডেট করা হবে।",
|
||||
"roomRedPacketRulesContent": "1. আপনি কয়েন খরচ করে লাকি প্যাক পাঠাতে পারেন। অন্যরা আপনার লাকি প্যাক খুললে তারা এলোমেলো পরিমাণ কয়েন পেতে পারে।\n\n2. লাকি প্যাক পাঠানোর ২ ঘণ্টা পরে, যে কয়েনগুলো কেউ গ্রহণ করেনি সেগুলো আপনার ওয়ালেটে ফেরত যাবে।\n\n3. সার্ভার-ব্যাপী লাকি প্যাক পাঠানোর পর সার্ভার-ব্যাপী লাকি প্যাক ব্যানার দেখানো হবে, ফলে আপনি আরও বেশি মনোযোগ পাবেন।\n\n4. আপনি প্রতিদিন %1$sটি রুম লাকি প্যাক পাঠাতে পারবেন।\n\n5. একই অঞ্চলের নয় এমন ব্যবহারকারীদের পাঠানো লাকি প্যাক গ্রহণ করা যাবে না।",
|
||||
"roomRedPacketHistoryTitle": "লাকি প্যাক পাঠানোর ইতিহাস",
|
||||
"roomRedPacketHistoryItemTitle": "লাকি প্যাক ({1} খাম)",
|
||||
"roomRedPacketHistoryStatusSent": "পাঠানো হয়েছে",
|
||||
"roomRedPacketHistoryStatusPending": "অপেক্ষায়",
|
||||
"roomRedPacketHistoryStatusReturned": "ফেরত দেওয়া হয়েছে",
|
||||
"roomRedPacketHistoryRefund": "ফেরত: {1}",
|
||||
"roomRedPacketClaimRecordsTitle": "গ্রহণের রেকর্ড",
|
||||
"roomRedPacketChatMessage": "আমি তোমাকে একটি লাল খাম পাঠিয়েছি, এখনই খোলো!",
|
||||
"roomRedPacketReceivedMessage": "{2} লাকি প্যাক থেকে {1} কয়েন গ্রহণ করুন",
|
||||
"roomRedPacketOpenSubtitle": "লাকি প্যাক এসেছে",
|
||||
@ -206,7 +208,9 @@
|
||||
"roomRedPacketInsufficientBalance": "স্বর্ণ কয়েন যথেষ্ট নয়।",
|
||||
"roomRedPacketPresenceRequired": "লাকি প্যাক ব্যবহার করতে রুমে থাকুন।",
|
||||
"roomRedPacketNetworkError": "এখন লাকি প্যাক প্রক্রিয়া করা যাচ্ছে না।",
|
||||
"roomRedPacketAlreadyClaimed": "তুমি ইতিমধ্যে এই লাকি প্যাক গ্রহণ করেছ।",
|
||||
"roomRedPacketOpenWaiting": "{1} মিনিট পরে খোলা যাবে।",
|
||||
"roomRedPacketOpenWaitingCountdown": "{1} পরে গ্রহণ করা যাবে",
|
||||
"roomRedPacketOkay": "ঠিক আছে",
|
||||
"roomRedPacketFloatingTitle": "{1} একটি লাল খাম পাঠিয়েছে",
|
||||
"roomRedPacketFloatingSubtitle": "একটি লাল খাম পাঠিয়েছে",
|
||||
@ -498,6 +502,11 @@
|
||||
"importantReminder": "গুরুত্বপূর্ণ স্মারক",
|
||||
"entryVehicleAnimation": "প্রবেশ যান অ্যানিমেশন",
|
||||
"floatingAnimationInGlobal": "গ্লোবালে ভাসমান অ্যানিমেশন",
|
||||
"broadcast": "ব্রডকাস্ট",
|
||||
"broadcastDisplay": "ব্রডকাস্ট দেখানো",
|
||||
"broadcastAllPlaces": "সব জায়গায়",
|
||||
"broadcastRoomOnly": "রুমে",
|
||||
"broadcastOff": "ব্রডকাস্ট বন্ধ",
|
||||
"entryVehicleAnimation2": "VIP4 বা তার বেশি অধিকারধারী ব্যবহারকারীরা এই ফিচার ব্যবহার করে গাড়ির অ্যানিমেশন বন্ধ করতে পারেন।",
|
||||
"dailyTasks": "দৈনন্দিন টাস্ক",
|
||||
"exclusiveForNewcomers": "নতুনদের জন্য বিশেষ",
|
||||
|
||||
@ -158,16 +158,18 @@
|
||||
"roomRedPacketSendSuccess": "Lucky pack sent",
|
||||
"roomRedPacketComingSoon": "Coming soon",
|
||||
"roomRedPacketRulesTitle": "Rules",
|
||||
"roomRedPacketRulesContent": "1. Each lucky pack can be claimed by room members after the selected pickup time.\n2. Coins are deducted after sending. Unclaimed coins can be returned after the activity ends.\n3. Please keep room interaction friendly and do not use lucky packs for spam or fraud.\n4. Final rule text will be updated after the backend API is confirmed.",
|
||||
"roomRedPacketRulesContent": "1. You can spend coins to send a Lucky Pack. If others open your Lucky Pack, they can receive a random amount of coins.\n\n2. Two hours after a Lucky Pack is sent, any unclaimed coins will be returned to your wallet.\n\n3. After you send a server-wide Lucky Pack, a server-wide Lucky Pack banner will be triggered, bringing you more attention.\n\n4. You can send %1$s room Lucky Packs per day.\n\n5. Lucky Packs sent by users outside your region cannot be claimed.",
|
||||
"roomRedPacketHistoryTitle": "Lucky Pack Delivery History",
|
||||
"roomRedPacketHistoryItemTitle": "Lucky Bag ({1} red envelopes)",
|
||||
"roomRedPacketHistoryStatusSent": "Sent",
|
||||
"roomRedPacketHistoryStatusPending": "Pending",
|
||||
"roomRedPacketHistoryStatusReturned": "Returned",
|
||||
"roomRedPacketHistoryRefund": "Refund: {1}",
|
||||
"roomRedPacketClaimRecordsTitle": "Claim Records",
|
||||
"roomRedPacketChatMessage": "I sent you a red envelope, open it right away!",
|
||||
"roomRedPacketReceivedMessage": "Claim {1} coins from the {2} Lucky Pack",
|
||||
"roomRedPacketOpenSubtitle": "The Lucky Pack Has Arrived",
|
||||
"roomRedPacketOpenYouGot": "You Got",
|
||||
"roomRedPacketOpenYouGot": "You Get",
|
||||
"roomRedPacketClaimSuccessTips": "Coins have been sent to your wallet.",
|
||||
"roomRedPacketOpenSoldOut": "The Lucky Pack Has Already Sold Out.",
|
||||
"roomRedPacketOpenExpired": "The Lucky Pack Has Expired.",
|
||||
@ -177,7 +179,9 @@
|
||||
"roomRedPacketInsufficientBalance": "Insufficient gold coins.",
|
||||
"roomRedPacketPresenceRequired": "Please stay in the room to use lucky packs.",
|
||||
"roomRedPacketNetworkError": "Unable to process the lucky pack right now.",
|
||||
"roomRedPacketAlreadyClaimed": "You have already claimed this lucky pack.",
|
||||
"roomRedPacketOpenWaiting": "Can be opened in {1} minutes.",
|
||||
"roomRedPacketOpenWaitingCountdown": "Available For Pickup After {1}",
|
||||
"roomRedPacketOkay": "Okay",
|
||||
"roomRedPacketFloatingTitle": "{1} sent a red envelope",
|
||||
"roomRedPacketFloatingSubtitle": "Sent a red envelope",
|
||||
@ -468,6 +472,11 @@
|
||||
"importantReminder": "Important Reminder",
|
||||
"entryVehicleAnimation": "Entry vehicle animation",
|
||||
"floatingAnimationInGlobal": "Floating animation in global",
|
||||
"broadcast": "Broadcast",
|
||||
"broadcastDisplay": "Broadcast display",
|
||||
"broadcastAllPlaces": "All places",
|
||||
"broadcastRoomOnly": "In room",
|
||||
"broadcastOff": "Close broadcast",
|
||||
"entryVehicleAnimation2": "Users with VIP4 or higher privileges can use this function to disable vehicle animations.",
|
||||
"dailyTasks": "Daily Tasks",
|
||||
"exclusiveForNewcomers": "Exclusive For Newcomers",
|
||||
|
||||
@ -147,12 +147,14 @@
|
||||
"roomRedPacketSendSuccess": "Şans paketi gönderildi",
|
||||
"roomRedPacketComingSoon": "Yakında",
|
||||
"roomRedPacketRulesTitle": "Kurallar",
|
||||
"roomRedPacketRulesContent": "1. Her şans paketi, seçilen alma süresinden sonra oda üyeleri tarafından alınabilir.\n2. Gönderimden sonra jetonlar düşülür. Alınmayan jetonlar etkinlik bittikten sonra iade edilebilir.\n3. Lütfen oda etkileşimini dostça tutun ve şans paketlerini spam veya dolandırıcılık için kullanmayın.\n4. Nihai kural metni backend API onaylandıktan sonra güncellenecek.",
|
||||
"roomRedPacketRulesContent": "1. Jeton harcayarak Şans Paketi gönderebilirsin. Başkaları Şans Paketini açarsa rastgele miktarda jeton kazanabilir.\n\n2. Şans Paketi gönderildikten 2 saat sonra alınmayan jetonlar cüzdanına iade edilir.\n\n3. Tüm sunucuya Şans Paketi gönderdikten sonra tüm sunucu Şans Paketi bannerı tetiklenir ve daha fazla dikkat çekersin.\n\n4. Günde %1$s oda Şans Paketi gönderebilirsin.\n\n5. Aynı bölgeden olmayan kullanıcıların gönderdiği Şans Paketleri alınamaz.",
|
||||
"roomRedPacketHistoryTitle": "Şans Paketi Gönderim Geçmişi",
|
||||
"roomRedPacketHistoryItemTitle": "Şans Paketi ({1} zarf)",
|
||||
"roomRedPacketHistoryStatusSent": "Gönderildi",
|
||||
"roomRedPacketHistoryStatusPending": "Beklemede",
|
||||
"roomRedPacketHistoryStatusReturned": "İade edildi",
|
||||
"roomRedPacketHistoryRefund": "İade: {1}",
|
||||
"roomRedPacketClaimRecordsTitle": "Alma Kayıtları",
|
||||
"roomRedPacketChatMessage": "Sana bir kırmızı zarf gönderdim, hemen aç!",
|
||||
"roomRedPacketReceivedMessage": "{2} Şans Paketinden {1} jeton al",
|
||||
"roomRedPacketOpenSubtitle": "Şans Paketi Geldi",
|
||||
@ -166,7 +168,9 @@
|
||||
"roomRedPacketInsufficientBalance": "Altın jeton yetersiz.",
|
||||
"roomRedPacketPresenceRequired": "Şans paketlerini kullanmak için odada kalın.",
|
||||
"roomRedPacketNetworkError": "Şans paketi şu anda işlenemiyor.",
|
||||
"roomRedPacketAlreadyClaimed": "Bu şans paketini zaten aldın.",
|
||||
"roomRedPacketOpenWaiting": "{1} dakika sonra açılabilir.",
|
||||
"roomRedPacketOpenWaitingCountdown": "{1} sonra alınabilir",
|
||||
"roomRedPacketOkay": "Tamam",
|
||||
"roomRedPacketFloatingTitle": "{1} kırmızı zarf gönderdi",
|
||||
"roomRedPacketFloatingSubtitle": "Kırmızı zarf gönderdi",
|
||||
@ -464,10 +468,15 @@
|
||||
"giftSpecialEffects": "Hediye Özel Efektleri",
|
||||
"basicFeatures": "Temel Özellikler",
|
||||
"task": "Görev",
|
||||
"importantReminder": "Önemli Hatırlatma",
|
||||
"entryVehicleAnimation": "Giriş Aracı Animasyonu",
|
||||
"importantReminder": "Önemli Hatırlatma",
|
||||
"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.",
|
||||
"broadcast": "Yayın",
|
||||
"broadcastDisplay": "Yayın gösterimi",
|
||||
"broadcastAllPlaces": "Her yerde",
|
||||
"broadcastRoomOnly": "Odada",
|
||||
"broadcastOff": "Yayını kapat",
|
||||
"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",
|
||||
"exclusiveForNewcomers": "Yeni Gelenlere Özel",
|
||||
"resetsDailyAtMidnight": "Her gün gece yarısı sıfırlanır",
|
||||
|
||||
@ -17,7 +17,7 @@ class SCVariant1Config implements AppConfig {
|
||||
String get apiHost => const String.fromEnvironment(
|
||||
'API_HOST',
|
||||
defaultValue: 'https://jvapi.haiyihy.com/',
|
||||
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖
|
||||
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.64:1100/”,线上“https://jvapi.haiyihy.com/”
|
||||
|
||||
@override
|
||||
String get imgHost => 'https://img.atuchat.com/'; // 测试图片服务器,上架前需替换为正式域名
|
||||
|
||||
@ -3,6 +3,9 @@ import 'package:yumi/app/config/business_logic_strategy.dart';
|
||||
|
||||
class SCGlobalConfig {
|
||||
static const Set<String> _knownLowPerformanceModels = {"redmi 14c"};
|
||||
static const String floatingBroadcastScopeOff = "off";
|
||||
static const String floatingBroadcastScopeRoom = "room";
|
||||
static const String floatingBroadcastScopeAll = "all";
|
||||
|
||||
static String get apiHost => AppConfig.current.apiHost;
|
||||
static String get imgHost => AppConfig.current.imgHost;
|
||||
@ -130,9 +133,51 @@ class SCGlobalConfig {
|
||||
|
||||
///全局飘屏
|
||||
static bool _isFloatingAnimationInGlobal = true;
|
||||
static String _floatingBroadcastScope = floatingBroadcastScopeAll;
|
||||
static bool get isFloatingAnimationInGlobal => _isFloatingAnimationInGlobal;
|
||||
static set isFloatingAnimationInGlobal(bool value) =>
|
||||
_isFloatingAnimationInGlobal = value;
|
||||
static set isFloatingAnimationInGlobal(bool value) {
|
||||
_isFloatingAnimationInGlobal = value;
|
||||
_floatingBroadcastScope =
|
||||
value ? floatingBroadcastScopeAll : floatingBroadcastScopeOff;
|
||||
}
|
||||
|
||||
static String get floatingBroadcastScope => _floatingBroadcastScope;
|
||||
static set floatingBroadcastScope(String value) {
|
||||
_floatingBroadcastScope = normalizeFloatingBroadcastScope(value);
|
||||
_isFloatingAnimationInGlobal =
|
||||
_floatingBroadcastScope != floatingBroadcastScopeOff;
|
||||
}
|
||||
|
||||
static bool get isFloatingBroadcastClosed =>
|
||||
_floatingBroadcastScope == floatingBroadcastScopeOff;
|
||||
static bool get isFloatingBroadcastRoomOnly =>
|
||||
_floatingBroadcastScope == floatingBroadcastScopeRoom;
|
||||
static bool get isFloatingBroadcastAll =>
|
||||
_floatingBroadcastScope == floatingBroadcastScopeAll;
|
||||
|
||||
static String normalizeFloatingBroadcastScope(String? value) {
|
||||
switch (value) {
|
||||
case floatingBroadcastScopeOff:
|
||||
case floatingBroadcastScopeRoom:
|
||||
case floatingBroadcastScopeAll:
|
||||
return value!;
|
||||
default:
|
||||
return floatingBroadcastScopeAll;
|
||||
}
|
||||
}
|
||||
|
||||
static String clampFloatingBroadcastScope(String value) {
|
||||
final scope = normalizeFloatingBroadcastScope(value);
|
||||
return _isLowPerformanceDevice ? floatingBroadcastScopeOff : scope;
|
||||
}
|
||||
|
||||
static String floatingBroadcastScopeStorageKey(String? account) {
|
||||
return "$account-FloatingBroadcastScope";
|
||||
}
|
||||
|
||||
static String legacyFloatingAnimationInGlobalStorageKey(String? account) {
|
||||
return "$account-FloatingAnimationInGlobal";
|
||||
}
|
||||
|
||||
///幸运礼物特效开关
|
||||
static bool _isLuckGiftSpecialEffects = true;
|
||||
|
||||
@ -397,6 +397,16 @@ class SCAppLocalizations {
|
||||
String get floatingAnimationInGlobal =>
|
||||
translate('floatingAnimationInGlobal');
|
||||
|
||||
String get broadcast => translate('broadcast');
|
||||
|
||||
String get broadcastDisplay => translate('broadcastDisplay');
|
||||
|
||||
String get broadcastAllPlaces => translate('broadcastAllPlaces');
|
||||
|
||||
String get broadcastRoomOnly => translate('broadcastRoomOnly');
|
||||
|
||||
String get broadcastOff => translate('broadcastOff');
|
||||
|
||||
String get theRedEnvelopeHasExpired => translate('theRedEnvelopeHasExpired');
|
||||
|
||||
String get wishingYouHappinessEveryDay =>
|
||||
@ -610,94 +620,107 @@ class SCAppLocalizations {
|
||||
|
||||
String get redEnvelope => translate('redEnvelope');
|
||||
|
||||
String get roomRedPacketGoldCoins => translate('roomRedPacketGoldCoins');
|
||||
|
||||
String get roomRedPacketQuantity => translate('roomRedPacketQuantity');
|
||||
|
||||
String get roomRedPacketPickupTime => translate('roomRedPacketPickupTime');
|
||||
|
||||
String get roomRedPacketClaimNow => translate('roomRedPacketClaimNow');
|
||||
|
||||
String get roomRedPacketSendLuckyPack =>
|
||||
translate('roomRedPacketSendLuckyPack');
|
||||
|
||||
String get roomRedPacketSending => translate('roomRedPacketSending');
|
||||
|
||||
String get roomRedPacketSendSuccess => translate('roomRedPacketSendSuccess');
|
||||
|
||||
String get roomRedPacketComingSoon => translate('roomRedPacketComingSoon');
|
||||
|
||||
String roomRedPacketInMinutes(String minutes) =>
|
||||
translate('roomRedPacketInMinutes').replaceAll('{1}', minutes);
|
||||
|
||||
String get roomRedPacketRulesTitle => translate('roomRedPacketRulesTitle');
|
||||
|
||||
String get roomRedPacketRulesContent =>
|
||||
translate('roomRedPacketRulesContent');
|
||||
|
||||
String get roomRedPacketHistoryTitle =>
|
||||
translate('roomRedPacketHistoryTitle');
|
||||
|
||||
String roomRedPacketHistoryItemTitle(String count) =>
|
||||
translate('roomRedPacketHistoryItemTitle').replaceAll('{1}', count);
|
||||
|
||||
String get roomRedPacketHistoryStatusSent =>
|
||||
translate('roomRedPacketHistoryStatusSent');
|
||||
|
||||
String get roomRedPacketHistoryStatusPending =>
|
||||
translate('roomRedPacketHistoryStatusPending');
|
||||
|
||||
String get roomRedPacketHistoryStatusReturned =>
|
||||
translate('roomRedPacketHistoryStatusReturned');
|
||||
|
||||
String get roomRedPacketChatMessage => translate('roomRedPacketChatMessage');
|
||||
|
||||
String roomRedPacketReceivedMessage(String amount, String name) => translate(
|
||||
'roomRedPacketReceivedMessage',
|
||||
).replaceAll('{1}', amount).replaceAll('{2}', name);
|
||||
|
||||
String get roomRedPacketOpenSubtitle =>
|
||||
translate('roomRedPacketOpenSubtitle');
|
||||
|
||||
String get roomRedPacketOpenYouGot => translate('roomRedPacketOpenYouGot');
|
||||
|
||||
String get roomRedPacketClaimSuccessTips =>
|
||||
translate('roomRedPacketClaimSuccessTips');
|
||||
|
||||
String get roomRedPacketOpenSoldOut => translate('roomRedPacketOpenSoldOut');
|
||||
|
||||
String get roomRedPacketOpenExpired => translate('roomRedPacketOpenExpired');
|
||||
|
||||
String get roomRedPacketRegionNotMatch =>
|
||||
translate('roomRedPacketRegionNotMatch');
|
||||
|
||||
String get roomRedPacketNotStarted =>
|
||||
translate('roomRedPacketNotStarted');
|
||||
|
||||
String get roomRedPacketDailyLimitExceeded =>
|
||||
translate('roomRedPacketDailyLimitExceeded');
|
||||
|
||||
String get roomRedPacketInsufficientBalance =>
|
||||
translate('roomRedPacketInsufficientBalance');
|
||||
|
||||
String get roomRedPacketPresenceRequired =>
|
||||
translate('roomRedPacketPresenceRequired');
|
||||
|
||||
String get roomRedPacketNetworkError =>
|
||||
translate('roomRedPacketNetworkError');
|
||||
|
||||
String roomRedPacketOpenWaiting(String minutes) =>
|
||||
translate('roomRedPacketOpenWaiting').replaceAll('{1}', minutes);
|
||||
|
||||
String get roomRedPacketOkay => translate('roomRedPacketOkay');
|
||||
|
||||
String roomRedPacketFloatingTitle(String name) =>
|
||||
translate('roomRedPacketFloatingTitle').replaceAll('{1}', name);
|
||||
|
||||
String get roomRedPacketFloatingSubtitle =>
|
||||
translate('roomRedPacketFloatingSubtitle');
|
||||
|
||||
String get dice => translate('dice');
|
||||
String get roomRedPacketGoldCoins => translate('roomRedPacketGoldCoins');
|
||||
|
||||
String get roomRedPacketQuantity => translate('roomRedPacketQuantity');
|
||||
|
||||
String get roomRedPacketPickupTime => translate('roomRedPacketPickupTime');
|
||||
|
||||
String get roomRedPacketClaimNow => translate('roomRedPacketClaimNow');
|
||||
|
||||
String get roomRedPacketSendLuckyPack =>
|
||||
translate('roomRedPacketSendLuckyPack');
|
||||
|
||||
String get roomRedPacketSending => translate('roomRedPacketSending');
|
||||
|
||||
String get roomRedPacketSendSuccess => translate('roomRedPacketSendSuccess');
|
||||
|
||||
String get roomRedPacketComingSoon => translate('roomRedPacketComingSoon');
|
||||
|
||||
String roomRedPacketInMinutes(String minutes) =>
|
||||
translate('roomRedPacketInMinutes').replaceAll('{1}', minutes);
|
||||
|
||||
String get roomRedPacketRulesTitle => translate('roomRedPacketRulesTitle');
|
||||
|
||||
String roomRedPacketRulesContent(String dailyLimit) => translate(
|
||||
'roomRedPacketRulesContent',
|
||||
).replaceAll('{1}', dailyLimit).replaceAll('%1\$s', dailyLimit);
|
||||
|
||||
String get roomRedPacketHistoryTitle =>
|
||||
translate('roomRedPacketHistoryTitle');
|
||||
|
||||
String roomRedPacketHistoryItemTitle(String count) =>
|
||||
translate('roomRedPacketHistoryItemTitle').replaceAll('{1}', count);
|
||||
|
||||
String get roomRedPacketHistoryStatusSent =>
|
||||
translate('roomRedPacketHistoryStatusSent');
|
||||
|
||||
String get roomRedPacketHistoryStatusPending =>
|
||||
translate('roomRedPacketHistoryStatusPending');
|
||||
|
||||
String get roomRedPacketHistoryStatusReturned =>
|
||||
translate('roomRedPacketHistoryStatusReturned');
|
||||
|
||||
String roomRedPacketHistoryRefund(String amount) =>
|
||||
translate('roomRedPacketHistoryRefund').replaceAll('{1}', amount);
|
||||
|
||||
String get roomRedPacketClaimRecordsTitle =>
|
||||
translate('roomRedPacketClaimRecordsTitle');
|
||||
|
||||
String get roomRedPacketChatMessage => translate('roomRedPacketChatMessage');
|
||||
|
||||
String roomRedPacketReceivedMessage(String amount, String name) => translate(
|
||||
'roomRedPacketReceivedMessage',
|
||||
).replaceAll('{1}', amount).replaceAll('{2}', name);
|
||||
|
||||
String get roomRedPacketOpenSubtitle =>
|
||||
translate('roomRedPacketOpenSubtitle');
|
||||
|
||||
String get roomRedPacketOpenYouGot => translate('roomRedPacketOpenYouGot');
|
||||
|
||||
String get roomRedPacketClaimSuccessTips =>
|
||||
translate('roomRedPacketClaimSuccessTips');
|
||||
|
||||
String get roomRedPacketOpenSoldOut => translate('roomRedPacketOpenSoldOut');
|
||||
|
||||
String get roomRedPacketOpenExpired => translate('roomRedPacketOpenExpired');
|
||||
|
||||
String get roomRedPacketRegionNotMatch =>
|
||||
translate('roomRedPacketRegionNotMatch');
|
||||
|
||||
String get roomRedPacketNotStarted => translate('roomRedPacketNotStarted');
|
||||
|
||||
String get roomRedPacketDailyLimitExceeded =>
|
||||
translate('roomRedPacketDailyLimitExceeded');
|
||||
|
||||
String get roomRedPacketInsufficientBalance =>
|
||||
translate('roomRedPacketInsufficientBalance');
|
||||
|
||||
String get roomRedPacketPresenceRequired =>
|
||||
translate('roomRedPacketPresenceRequired');
|
||||
|
||||
String get roomRedPacketNetworkError =>
|
||||
translate('roomRedPacketNetworkError');
|
||||
|
||||
String get roomRedPacketAlreadyClaimed =>
|
||||
translate('roomRedPacketAlreadyClaimed');
|
||||
|
||||
String roomRedPacketOpenWaiting(String minutes) =>
|
||||
translate('roomRedPacketOpenWaiting').replaceAll('{1}', minutes);
|
||||
|
||||
String roomRedPacketOpenWaitingCountdown(String countdown) => translate(
|
||||
'roomRedPacketOpenWaitingCountdown',
|
||||
).replaceAll('{1}', countdown);
|
||||
|
||||
String get roomRedPacketOkay => translate('roomRedPacketOkay');
|
||||
|
||||
String roomRedPacketFloatingTitle(String name) =>
|
||||
translate('roomRedPacketFloatingTitle').replaceAll('{1}', name);
|
||||
|
||||
String get roomRedPacketFloatingSubtitle =>
|
||||
translate('roomRedPacketFloatingSubtitle');
|
||||
|
||||
String get dice => translate('dice');
|
||||
|
||||
String get rps => translate('rps');
|
||||
|
||||
|
||||
@ -134,6 +134,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
List<MicRes> acceptUsers, {
|
||||
required SocialChatGiftRes gift,
|
||||
required int quantity,
|
||||
required String giftBatchId,
|
||||
int animationCount = 1,
|
||||
}) {
|
||||
final targetUserIds = _resolveAcceptUserIds(acceptUsers);
|
||||
@ -175,6 +176,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
user: currentUser,
|
||||
toUser: targetUser,
|
||||
targetUserIds: targetUserIds,
|
||||
giftBatchId: giftBatchId,
|
||||
number: quantity,
|
||||
customAnimationCount: animationCount,
|
||||
),
|
||||
@ -1347,23 +1349,28 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
'elapsedMs=${stopwatch.elapsedMilliseconds}',
|
||||
);
|
||||
if (request.isLuckyGiftRequest) {
|
||||
final giftBatchId = _createGiftBatchId(request);
|
||||
_showLocalLuckyGiftFeedback(
|
||||
request.acceptUsers,
|
||||
gift: request.gift,
|
||||
quantity: request.quantity,
|
||||
giftBatchId: giftBatchId,
|
||||
animationCount: request.clickCount,
|
||||
);
|
||||
await sendLuckGiftAnimOtherMsg(
|
||||
request.acceptUsers,
|
||||
gift: request.gift,
|
||||
quantity: request.quantity,
|
||||
giftBatchId: giftBatchId,
|
||||
animationCount: request.clickCount,
|
||||
);
|
||||
} else {
|
||||
final giftBatchId = _createGiftBatchId(request);
|
||||
sendGiftMsg(
|
||||
request.acceptUsers,
|
||||
gift: request.gift,
|
||||
quantity: request.quantity,
|
||||
giftBatchId: giftBatchId,
|
||||
animationCount: request.clickCount,
|
||||
);
|
||||
}
|
||||
@ -1390,6 +1397,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
List<MicRes> acceptUsers, {
|
||||
required SocialChatGiftRes gift,
|
||||
required int quantity,
|
||||
required String giftBatchId,
|
||||
int animationCount = 1,
|
||||
}) {
|
||||
///发送一条IM消息
|
||||
@ -1426,6 +1434,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
user: AccountStorage().getCurrentUser()?.userProfile,
|
||||
toUser: u.user,
|
||||
targetUserIds: targetUserIds,
|
||||
giftBatchId: giftBatchId,
|
||||
number: quantity,
|
||||
customAnimationCount: animationCount,
|
||||
type: SCRoomMsgType.gift,
|
||||
@ -1686,6 +1695,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
List<MicRes> acceptUsers, {
|
||||
required SocialChatGiftRes gift,
|
||||
required int quantity,
|
||||
required String giftBatchId,
|
||||
int animationCount = 1,
|
||||
}) async {
|
||||
final targetUserIds = _resolveAcceptUserIds(acceptUsers);
|
||||
@ -1713,6 +1723,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
user: AccountStorage().getCurrentUser()?.userProfile,
|
||||
toUser: firstTargetUser,
|
||||
targetUserIds: targetUserIds,
|
||||
giftBatchId: giftBatchId,
|
||||
number: quantity,
|
||||
customAnimationCount: animationCount,
|
||||
type: SCRoomMsgType.luckGiftAnimOther,
|
||||
@ -1738,6 +1749,20 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
}
|
||||
return targetUserIds;
|
||||
}
|
||||
|
||||
String _createGiftBatchId(_GiftSendRequest request) {
|
||||
final sortedAcceptUserIds = List<String>.from(request.acceptUserIds)
|
||||
..sort();
|
||||
return [
|
||||
request.isLuckyGiftRequest ? 'lucky' : 'gift',
|
||||
request.roomId,
|
||||
request.gift.id ?? '',
|
||||
request.quantity,
|
||||
request.clickCount,
|
||||
sortedAcceptUserIds.join(','),
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
].join('|');
|
||||
}
|
||||
}
|
||||
|
||||
class CheckNumber extends StatelessWidget {
|
||||
|
||||
@ -25,11 +25,23 @@ import '../../../index/main_route.dart';
|
||||
const Duration _kPartySkeletonAnimationDuration = Duration(milliseconds: 1450);
|
||||
const Color _kPartySkeletonShell = Color(0xFF0F3730);
|
||||
const Color _kPartySkeletonShellStrong = Color(0xFF1A4A41);
|
||||
const Color _kPartySkeletonBoneBase = Color(0xFF2B5C53);
|
||||
const Color _kPartySkeletonBoneHighlight = Color(0xFF5F8177);
|
||||
const Color _kPartySkeletonBorder = Color(0x66D8B57B);
|
||||
|
||||
class SCHomePartyPage extends StatefulWidget {
|
||||
const Color _kPartySkeletonBoneBase = Color(0xFF2B5C53);
|
||||
const Color _kPartySkeletonBoneHighlight = Color(0xFF5F8177);
|
||||
const Color _kPartySkeletonBorder = Color(0x66D8B57B);
|
||||
|
||||
class _RoomCountryFilterOption {
|
||||
const _RoomCountryFilterOption({
|
||||
required this.key,
|
||||
required this.name,
|
||||
this.flagUrl,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String name;
|
||||
final String? flagUrl;
|
||||
}
|
||||
|
||||
class SCHomePartyPage extends StatefulWidget {
|
||||
const SCHomePartyPage({super.key});
|
||||
|
||||
@override
|
||||
@ -67,6 +79,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
bool _wasTickerModeEnabled = false;
|
||||
bool _hasCompletedInitialRoomLoad = false;
|
||||
DateTime? _lastRoomCounterHydrationAt;
|
||||
String? _selectedCountryKey;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@ -198,6 +211,9 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
|
||||
bool _sameRoom(SocialChatRoomRes previous, SocialChatRoomRes next) {
|
||||
return previous.id == next.id &&
|
||||
previous.countryCode == next.countryCode &&
|
||||
previous.countryName == next.countryName &&
|
||||
previous.nationalFlag == next.nationalFlag &&
|
||||
previous.roomName == next.roomName &&
|
||||
previous.roomCover == next.roomCover &&
|
||||
previous.roomGameIcon == next.roomGameIcon &&
|
||||
@ -229,6 +245,73 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<SocialChatRoomRes> _filteredRooms() {
|
||||
final selectedCountryKey = _selectedCountryKey;
|
||||
if ((selectedCountryKey ?? "").isEmpty) {
|
||||
return rooms;
|
||||
}
|
||||
return rooms
|
||||
.where((room) => _countryFilterKeyForRoom(room) == selectedCountryKey)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<_RoomCountryFilterOption> _roomCountryFilterOptions() {
|
||||
final options = <_RoomCountryFilterOption>[];
|
||||
final seenKeys = <String>{};
|
||||
for (final room in rooms) {
|
||||
final key = _countryFilterKeyForRoom(room);
|
||||
final name = _countryFilterNameForRoom(room);
|
||||
if (key == null || name == null || seenKeys.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
options.add(
|
||||
_RoomCountryFilterOption(
|
||||
key: key,
|
||||
name: name,
|
||||
flagUrl: _trimToNull(room.nationalFlag),
|
||||
),
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
bool _syncCountryFilterWithRooms() {
|
||||
final selectedCountryKey = _selectedCountryKey;
|
||||
if ((selectedCountryKey ?? "").isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final hasSelectedCountry = rooms.any(
|
||||
(room) => _countryFilterKeyForRoom(room) == selectedCountryKey,
|
||||
);
|
||||
if (hasSelectedCountry) {
|
||||
return false;
|
||||
}
|
||||
_selectedCountryKey = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
String? _countryFilterKeyForRoom(SocialChatRoomRes room) {
|
||||
final countryName = _trimToNull(room.countryName);
|
||||
if (countryName != null) {
|
||||
return countryName.toLowerCase();
|
||||
}
|
||||
final countryCode = _trimToNull(room.countryCode);
|
||||
return countryCode?.toUpperCase();
|
||||
}
|
||||
|
||||
String? _countryFilterNameForRoom(SocialChatRoomRes room) {
|
||||
return _trimToNull(room.countryName) ?? _trimToNull(room.countryCode);
|
||||
}
|
||||
|
||||
String? _trimToNull(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
Future<void> _syncVisibleRoomMemberCounts({bool force = false}) async {
|
||||
if (!mounted || rooms.isEmpty || _isHydratingVisibleRoomCounters) {
|
||||
return;
|
||||
@ -241,7 +324,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
return;
|
||||
}
|
||||
final targetRoomIds =
|
||||
rooms
|
||||
_filteredRooms()
|
||||
.take(_roomCounterHydrationLimit)
|
||||
.map((room) => room.id ?? "")
|
||||
.where((roomId) => roomId.trim().isNotEmpty)
|
||||
@ -323,6 +406,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
}
|
||||
setState(() {
|
||||
rooms = mergedRooms;
|
||||
_syncCountryFilterWithRooms();
|
||||
});
|
||||
unawaited(_syncVisibleRoomMemberCounts(force: true));
|
||||
} catch (_) {
|
||||
@ -623,24 +707,187 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
child: mainEmpty(msg: SCAppLocalizations.of(context)!.noData),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
),
|
||||
padding: EdgeInsets.all(10),
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildItem(rooms[index], index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowLeaderboardSkeleton(SCAppGeneralManager ref) {
|
||||
final countryOptions = _roomCountryFilterOptions();
|
||||
final visibleRooms = _filteredRooms();
|
||||
final countrySelector = _buildCountryFilterSelector(countryOptions);
|
||||
if (visibleRooms.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
countrySelector,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16.w),
|
||||
child: mainEmpty(msg: SCAppLocalizations.of(context)!.noData),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
countrySelector,
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(10.w, 8.w, 10.w, 10.w),
|
||||
itemCount: visibleRooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildItem(visibleRooms[index], index);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCountryFilterSelector(
|
||||
List<_RoomCountryFilterOption> countryOptions,
|
||||
) {
|
||||
if (countryOptions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.fromSTEB(0, 10.w, 0, 8.w),
|
||||
child: SizedBox(
|
||||
height: 26.w,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsetsDirectional.symmetric(horizontal: 10.w),
|
||||
itemCount: countryOptions.length + 1,
|
||||
separatorBuilder: (context, index) => SizedBox(width: 5.w),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return _buildCountryFilterChip(
|
||||
label: SCAppLocalizations.of(context)!.all,
|
||||
);
|
||||
}
|
||||
final option = countryOptions[index - 1];
|
||||
return Consumer<SCAppGeneralManager>(
|
||||
builder: (context, provider, child) {
|
||||
final country = provider.findCountryByName(option.name);
|
||||
final countryFlagUrl =
|
||||
_trimToNull(option.flagUrl) ??
|
||||
_trimToNull(country?.nationalFlag);
|
||||
final countryLabel =
|
||||
_trimToNull(country?.aliasName) ??
|
||||
_trimToNull(country?.countryName) ??
|
||||
option.name;
|
||||
return _buildCountryFilterChip(
|
||||
label: countryLabel,
|
||||
countryKey: option.key,
|
||||
flagUrl: countryFlagUrl,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCountryFilterChip({
|
||||
required String label,
|
||||
String? countryKey,
|
||||
String? flagUrl,
|
||||
}) {
|
||||
final isSelected = _selectedCountryKey == countryKey;
|
||||
final resolvedFlagUrl = _trimToNull(flagUrl);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (_selectedCountryKey == countryKey) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedCountryKey = countryKey;
|
||||
});
|
||||
unawaited(_syncVisibleRoomMemberCounts(force: true));
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
height: 24.w,
|
||||
constraints: BoxConstraints(minWidth: 45.w, maxWidth: 95.w),
|
||||
alignment: AlignmentDirectional.center,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors:
|
||||
isSelected
|
||||
? const [Color(0xFF5C4430), Color(0xFF231B14)]
|
||||
: [
|
||||
Colors.black.withValues(alpha: 0.34),
|
||||
const Color(0xFF0F3730).withValues(alpha: 0.38),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999.w),
|
||||
border: Border.all(
|
||||
color:
|
||||
isSelected
|
||||
? const Color(0xFFE8C681)
|
||||
: const Color(0x66D8B57B),
|
||||
width: 1.w,
|
||||
),
|
||||
boxShadow:
|
||||
isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFE8C681).withValues(alpha: 0.16),
|
||||
blurRadius: 5.w,
|
||||
offset: Offset(0, 2.w),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (resolvedFlagUrl != null) ...[
|
||||
netImage(
|
||||
url: resolvedFlagUrl,
|
||||
width: 15.w,
|
||||
height: 10.w,
|
||||
borderRadius: BorderRadius.circular(2.w),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: Directionality.of(context),
|
||||
style: TextStyle(
|
||||
color:
|
||||
isSelected
|
||||
? const Color(0xFFFFE4A3)
|
||||
: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 8.sp,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowLeaderboardSkeleton(SCAppGeneralManager ref) {
|
||||
return _isLeaderboardLoading && !_hasLeaderboardData(ref);
|
||||
}
|
||||
|
||||
@ -985,6 +1232,7 @@ class _HomePartyPageState extends State<SCHomePartyPage>
|
||||
.loadPartyRooms(forceRefresh: forceRefresh)
|
||||
.then((values) {
|
||||
rooms = _mergeLatestRoomsWithCurrentCounters(values);
|
||||
_syncCountryFilterWithRooms();
|
||||
isLoading = false;
|
||||
_hasCompletedInitialRoomLoad = true;
|
||||
_refreshController.refreshCompleted();
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/main.dart' show routeObserver;
|
||||
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
|
||||
import 'package:yumi/modules/home/index_home_page.dart';
|
||||
import 'package:yumi/modules/chat/message/sc_message_page.dart';
|
||||
@ -35,7 +36,8 @@ class SCIndexPage extends StatefulWidget {
|
||||
State<SCIndexPage> createState() => _SCIndexPageState();
|
||||
}
|
||||
|
||||
class _SCIndexPageState extends State<SCIndexPage> with WidgetsBindingObserver {
|
||||
class _SCIndexPageState extends State<SCIndexPage>
|
||||
with WidgetsBindingObserver, RouteAware {
|
||||
static const Duration _registerRewardSocketWaitTimeout = Duration(seconds: 6);
|
||||
|
||||
int _currentIndex = 0;
|
||||
@ -50,6 +52,7 @@ class _SCIndexPageState extends State<SCIndexPage> with WidgetsBindingObserver {
|
||||
bool _showRegisterRewardAfterDailySignIn = false;
|
||||
StreamSubscription<RegisterRewardGrantedEvent>? _registerRewardSubscription;
|
||||
Completer<void>? _registerRewardSocketCompleter;
|
||||
PageRoute<dynamic>? _routeObserverRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -96,6 +99,8 @@ class _SCIndexPageState extends State<SCIndexPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
routeObserver.unsubscribe(this);
|
||||
OverlayManager().setHomeRootTabsVisible(false);
|
||||
_registerRewardSubscription?.cancel();
|
||||
WakelockPlus.disable();
|
||||
super.dispose();
|
||||
@ -116,6 +121,42 @@ class _SCIndexPageState extends State<SCIndexPage> with WidgetsBindingObserver {
|
||||
super.didChangeDependencies();
|
||||
SCFloatIchart().init(context);
|
||||
_rebuildBottomItemsIfNeeded();
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute<dynamic> && _routeObserverRoute != route) {
|
||||
if (_routeObserverRoute != null) {
|
||||
routeObserver.unsubscribe(this);
|
||||
}
|
||||
_routeObserverRoute = route;
|
||||
routeObserver.subscribe(this, route);
|
||||
_setHomeRootTabsVisible(route.isCurrent);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush() {
|
||||
_setHomeRootTabsVisible(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
_setHomeRootTabsVisible(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPushNext() {
|
||||
_setHomeRootTabsVisible(false);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop() {
|
||||
_setHomeRootTabsVisible(false);
|
||||
}
|
||||
|
||||
void _setHomeRootTabsVisible(bool visible) {
|
||||
if (!mounted && visible) {
|
||||
return;
|
||||
}
|
||||
OverlayManager().setHomeRootTabsVisible(visible);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -62,6 +62,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
static const Duration _giftFlightBatchDedupeWindow = Duration(
|
||||
milliseconds: 650,
|
||||
);
|
||||
static const Duration _giftVisualBatchDedupeWindow = Duration(seconds: 4);
|
||||
|
||||
late TabController _tabController;
|
||||
final List<Widget> _pages = [AllChatPage(), ChatPage(), GiftChatPage()];
|
||||
@ -71,6 +72,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
final Map<String, _LuckyGiftComboSession> _luckyGiftComboSessions =
|
||||
<String, _LuckyGiftComboSession>{};
|
||||
final Map<String, Timer> _giftFlightBatchDedupeTimers = <String, Timer>{};
|
||||
final Map<String, Timer> _giftVisualBatchDedupeTimers = <String, Timer>{};
|
||||
int _shownRoomStartupFailureToken = 0;
|
||||
RtcProvider? _rtcProvider;
|
||||
bool _roomProviderRebuildScheduled = false;
|
||||
@ -92,6 +94,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
).toggleGiftAnimationVisibility(false);
|
||||
_clearLuckyGiftComboSessions();
|
||||
_clearGiftFlightBatchDedupeTimers();
|
||||
_clearGiftVisualBatchDedupeTimers();
|
||||
_giftSeatFlightController.clear();
|
||||
}
|
||||
});
|
||||
@ -178,6 +181,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
RoomEntranceHelper.clearQueue();
|
||||
_clearLuckyGiftComboSessions();
|
||||
_clearGiftFlightBatchDedupeTimers();
|
||||
_clearGiftVisualBatchDedupeTimers();
|
||||
_giftSeatFlightController.clear();
|
||||
OverlayManager().removeRoom();
|
||||
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
|
||||
@ -487,36 +491,47 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
|
||||
return;
|
||||
}
|
||||
var giftModel = LGiftModel();
|
||||
giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}";
|
||||
giftModel.sendUserName = msg.user?.userNickname ?? "";
|
||||
giftModel.sendToUserName = msg.toUser?.userNickname ?? "";
|
||||
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
|
||||
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
|
||||
giftModel.giftCount = msg.number ?? 0;
|
||||
giftModel.giftCountStepUnit = _resolveGiftCountStepUnit(msg);
|
||||
unawaited(
|
||||
warmImageResource(
|
||||
giftModel.sendUserPic,
|
||||
logicalWidth: 26.w,
|
||||
logicalHeight: 26.w,
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
warmImageResource(
|
||||
giftModel.giftPic,
|
||||
logicalWidth: 34.w,
|
||||
logicalHeight: 34.w,
|
||||
),
|
||||
);
|
||||
Provider.of<GiftAnimationManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).enqueueGiftAnimation(giftModel);
|
||||
|
||||
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
|
||||
final targetUserIds = _resolveGiftTargetUserIds(msg);
|
||||
final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds);
|
||||
final shouldPlayBatchVisuals =
|
||||
targetUserIds.length <= 1 ||
|
||||
targetGroupKey == null ||
|
||||
_markGiftVisualBatchForPlayback(msg, targetGroupKey);
|
||||
if (shouldPlayBatchVisuals) {
|
||||
var giftModel = LGiftModel();
|
||||
giftModel.labelId = _buildGiftTickerLabelId(msg, targetGroupKey);
|
||||
giftModel.sendUserName = msg.user?.userNickname ?? "";
|
||||
giftModel.sendToUserName = _resolveGiftTickerTargetName(
|
||||
msg,
|
||||
targetUserIds,
|
||||
);
|
||||
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
|
||||
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
|
||||
giftModel.giftCount = msg.number ?? 0;
|
||||
giftModel.giftCountStepUnit = _resolveGiftCountStepUnit(msg);
|
||||
unawaited(
|
||||
warmImageResource(
|
||||
giftModel.sendUserPic,
|
||||
logicalWidth: 26.w,
|
||||
logicalHeight: 26.w,
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
warmImageResource(
|
||||
giftModel.giftPic,
|
||||
logicalWidth: 34.w,
|
||||
logicalHeight: 34.w,
|
||||
),
|
||||
);
|
||||
Provider.of<GiftAnimationManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).enqueueGiftAnimation(giftModel);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
|
||||
if (_supportsComboMilestoneEffects(msg)) {
|
||||
_handleComboMilestoneVisuals(msg, targetGroupKey);
|
||||
}
|
||||
@ -851,6 +866,22 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
return targetUserIds;
|
||||
}
|
||||
|
||||
String _buildGiftTickerLabelId(Msg msg, String? targetGroupKey) {
|
||||
final giftId = (msg.gift?.id ?? "").trim();
|
||||
final senderId = (msg.user?.id ?? "").trim();
|
||||
if ((targetGroupKey ?? "").trim().isNotEmpty) {
|
||||
return "$giftId|$senderId|$targetGroupKey";
|
||||
}
|
||||
return "$giftId|$senderId|${msg.toUser?.id ?? ""}";
|
||||
}
|
||||
|
||||
String _resolveGiftTickerTargetName(Msg msg, List<String> targetUserIds) {
|
||||
if (targetUserIds.length > 1) {
|
||||
return SCAppLocalizations.of(context)?.multiple ?? "Multiple";
|
||||
}
|
||||
return msg.toUser?.userNickname ?? "";
|
||||
}
|
||||
|
||||
List<String> _normalizeGiftTargetUserIds(List<String> targetUserIds) {
|
||||
final normalizedTargetUserIds = <String>[];
|
||||
for (final targetUserId in targetUserIds) {
|
||||
@ -873,6 +904,51 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
return normalizedTargetUserIds.join(",");
|
||||
}
|
||||
|
||||
bool _markGiftVisualBatchForPlayback(Msg msg, String targetGroupKey) {
|
||||
final batchKey = _buildGiftVisualBatchKey(msg, targetGroupKey);
|
||||
if (batchKey.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (_giftVisualBatchDedupeTimers.containsKey(batchKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasStableBatchId = (msg.giftBatchId ?? "").trim().isNotEmpty;
|
||||
final dedupeWindow =
|
||||
hasStableBatchId
|
||||
? _giftVisualBatchDedupeWindow
|
||||
: _giftFlightBatchDedupeWindow;
|
||||
late final Timer timer;
|
||||
timer = Timer(dedupeWindow, () {
|
||||
if (identical(_giftVisualBatchDedupeTimers[batchKey], timer)) {
|
||||
_giftVisualBatchDedupeTimers.remove(batchKey);
|
||||
}
|
||||
});
|
||||
_giftVisualBatchDedupeTimers[batchKey] = timer;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _buildGiftVisualBatchKey(Msg msg, String targetGroupKey) {
|
||||
final stableBatchId = (msg.giftBatchId ?? "").trim();
|
||||
if (stableBatchId.isNotEmpty) {
|
||||
return "batch|$stableBatchId";
|
||||
}
|
||||
|
||||
final timeBucket =
|
||||
((msg.time ?? DateTime.now().millisecondsSinceEpoch) / 150).floor();
|
||||
return [
|
||||
"legacy",
|
||||
msg.type ?? "",
|
||||
msg.groupId ?? "",
|
||||
msg.gift?.id ?? "",
|
||||
msg.user?.id ?? "",
|
||||
targetGroupKey,
|
||||
msg.number?.toString() ?? "",
|
||||
msg.customAnimationCount?.toString() ?? "",
|
||||
timeBucket.toString(),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
String _buildGiftFlightBatchKey(Msg msg, List<String> targetUserIds) {
|
||||
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
|
||||
normalizedTargetUserIds.sort();
|
||||
@ -928,6 +1004,13 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
_giftFlightBatchDedupeTimers.clear();
|
||||
}
|
||||
|
||||
void _clearGiftVisualBatchDedupeTimers() {
|
||||
for (final timer in _giftVisualBatchDedupeTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_giftVisualBatchDedupeTimers.clear();
|
||||
}
|
||||
|
||||
bool _shouldPlaySeatFlightGiftAnimation(Msg msg) {
|
||||
final gift = msg.gift;
|
||||
if (gift == null) {
|
||||
|
||||
247
lib/modules/user/settings/broadcast/broadcast_settings_page.dart
Normal file
247
lib/modules/user/settings/broadcast/broadcast_settings_page.dart
Normal file
@ -0,0 +1,247 @@
|
||||
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/constants/sc_global_config.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||||
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/components/text/sc_text.dart';
|
||||
|
||||
class BroadcastSettingsPage extends StatefulWidget {
|
||||
const BroadcastSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<BroadcastSettingsPage> createState() => _BroadcastSettingsPageState();
|
||||
}
|
||||
|
||||
class _BroadcastSettingsPageState extends State<BroadcastSettingsPage> {
|
||||
static const String _scopePickerTag = "showBroadcastScopePicker";
|
||||
|
||||
late String _selectedScope;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedScope = _readSavedScope();
|
||||
SCGlobalConfig.floatingBroadcastScope = _selectedScope;
|
||||
}
|
||||
|
||||
String? get _account =>
|
||||
AccountStorage().getCurrentUser()?.userProfile?.account;
|
||||
|
||||
String _readSavedScope() {
|
||||
final savedScope = DataPersistence.getString(
|
||||
SCGlobalConfig.floatingBroadcastScopeStorageKey(_account),
|
||||
);
|
||||
if (savedScope.isNotEmpty) {
|
||||
return SCGlobalConfig.normalizeFloatingBroadcastScope(savedScope);
|
||||
}
|
||||
final isLegacyEnabled = DataPersistence.getBool(
|
||||
SCGlobalConfig.legacyFloatingAnimationInGlobalStorageKey(_account),
|
||||
defaultValue: true,
|
||||
);
|
||||
return isLegacyEnabled
|
||||
? SCGlobalConfig.floatingBroadcastScopeAll
|
||||
: SCGlobalConfig.floatingBroadcastScopeOff;
|
||||
}
|
||||
|
||||
String _scopeTitle(BuildContext context, String scope) {
|
||||
final l10n = SCAppLocalizations.of(context)!;
|
||||
switch (SCGlobalConfig.normalizeFloatingBroadcastScope(scope)) {
|
||||
case SCGlobalConfig.floatingBroadcastScopeOff:
|
||||
return l10n.broadcastOff;
|
||||
case SCGlobalConfig.floatingBroadcastScopeRoom:
|
||||
return l10n.broadcastRoomOnly;
|
||||
case SCGlobalConfig.floatingBroadcastScopeAll:
|
||||
default:
|
||||
return l10n.broadcastAllPlaces;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectScope(String scope) async {
|
||||
final normalizedScope = SCGlobalConfig.normalizeFloatingBroadcastScope(
|
||||
scope,
|
||||
);
|
||||
setState(() {
|
||||
_selectedScope = normalizedScope;
|
||||
});
|
||||
SCGlobalConfig.floatingBroadcastScope = normalizedScope;
|
||||
OverlayManager().refreshDisplayPreference();
|
||||
await DataPersistence.setString(
|
||||
SCGlobalConfig.floatingBroadcastScopeStorageKey(_account),
|
||||
normalizedScope,
|
||||
);
|
||||
await DataPersistence.setBool(
|
||||
SCGlobalConfig.legacyFloatingAnimationInGlobalStorageKey(_account),
|
||||
normalizedScope != SCGlobalConfig.floatingBroadcastScopeOff,
|
||||
);
|
||||
SmartDialog.dismiss(tag: _scopePickerTag);
|
||||
}
|
||||
|
||||
void _showScopePicker() {
|
||||
SmartDialog.show(
|
||||
tag: _scopePickerTag,
|
||||
alignment: Alignment.bottomCenter,
|
||||
animationType: SmartAnimationType.fade,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(10.w)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildScopeOption(SCGlobalConfig.floatingBroadcastScopeAll),
|
||||
Divider(height: 1.w, color: const Color(0xffeeeeee)),
|
||||
_buildScopeOption(SCGlobalConfig.floatingBroadcastScopeRoom),
|
||||
Divider(height: 1.w, color: const Color(0xffeeeeee)),
|
||||
_buildScopeOption(SCGlobalConfig.floatingBroadcastScopeOff),
|
||||
Container(height: 6.w, color: const Color(0xfff5f5f5)),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => SmartDialog.dismiss(tag: _scopePickerTag),
|
||||
child: Container(
|
||||
height: 48.w,
|
||||
alignment: Alignment.center,
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.cancel,
|
||||
textColor: Colors.black45,
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScopeOption(String scope) {
|
||||
final selected =
|
||||
SCGlobalConfig.normalizeFloatingBroadcastScope(_selectedScope) ==
|
||||
SCGlobalConfig.normalizeFloatingBroadcastScope(scope);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _selectScope(scope),
|
||||
child: Container(
|
||||
height: 48.w,
|
||||
padding: EdgeInsets.symmetric(horizontal: 18.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: text(
|
||||
_scopeTitle(context, scope),
|
||||
textColor: Colors.black87,
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18.w,
|
||||
child:
|
||||
selected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
size: 16.w,
|
||||
color: const Color(0xff18F2B1),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy.getSettingsPageBackgroundImage(),
|
||||
width: ScreenUtil().screenWidth,
|
||||
height: ScreenUtil().screenHeight,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: SocialChatStandardAppBar(
|
||||
title: SCAppLocalizations.of(context)!.broadcast,
|
||||
actions: [],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.w),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.w, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
child: SCDebounceWidget(
|
||||
onTap: _showScopePicker,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 18.w),
|
||||
Expanded(
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.broadcastDisplay,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPagePrimaryTextColor(),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
),
|
||||
text(
|
||||
_scopeTitle(context, _selectedScope),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageSecondaryTextColor(),
|
||||
fontSize: 12.sp,
|
||||
maxWidth: 120.w,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Icon(
|
||||
SCGlobalConfig.lang == "ar"
|
||||
? Icons.keyboard_arrow_left
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 15.w,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageIconColor(),
|
||||
),
|
||||
SizedBox(width: 18.w),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,21 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
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/ui_kit/components/appbar/socialchat_appbar.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/components/text/sc_text.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||||
import 'package:yumi/modules/index/main_route.dart';
|
||||
import 'package:yumi/modules/user/settings/settings_route.dart';
|
||||
|
||||
import 'package:yumi/ui_kit/components/dialog/dialog_base.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
|
||||
import 'package:yumi/shared/tools/sc_app_utils.dart';
|
||||
|
||||
///设置页面
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
_SettingsPageState createState() => _SettingsPageState();
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
@ -56,7 +52,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
margin: EdgeInsetsDirectional.only(start: 15.w),
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.account,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPageSecondaryTextColor(),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageSecondaryTextColor(),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
@ -65,8 +63,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
margin: EdgeInsets.symmetric(vertical: 8.w, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageMainContainerBackgroundColor(),
|
||||
border: Border.all(color: SCGlobalConfig.businessLogicStrategy.getSettingsPageMainContainerBorderColor(), width: 1.w),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageMainContainerBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageMainContainerBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@ -76,7 +81,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SizedBox(width: 18.w),
|
||||
text(
|
||||
SCAppLocalizations.of(context)!.account,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPagePrimaryTextColor(),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPagePrimaryTextColor(),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
Spacer(),
|
||||
@ -85,7 +92,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
? Icons.keyboard_arrow_left
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 15.w,
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageIconColor(),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageIconColor(),
|
||||
),
|
||||
SizedBox(width: 18.w),
|
||||
],
|
||||
@ -106,8 +115,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
margin: EdgeInsets.symmetric(horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(color: SCGlobalConfig.businessLogicStrategy.getSettingsPageContainerBorderColor(), width: 1.w),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@ -117,7 +133,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SizedBox(width: 18.w),
|
||||
text(
|
||||
SCAppLocalizations.of(context)!.blockedList,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPagePrimaryTextColor(),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPagePrimaryTextColor(),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
Spacer(),
|
||||
@ -126,7 +144,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
? Icons.keyboard_arrow_left
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 15.w,
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageIconColor(),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageIconColor(),
|
||||
),
|
||||
SizedBox(width: 18.w),
|
||||
],
|
||||
@ -147,8 +167,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
margin: EdgeInsetsDirectional.only(start: 15.w),
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.about,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPageAboutTextColor(),
|
||||
SCAppLocalizations.of(context)!.other,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageAboutTextColor(),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
@ -157,8 +179,79 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
margin: EdgeInsets.symmetric(vertical: 8.w, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(color: SCGlobalConfig.businessLogicStrategy.getSettingsPageContainerBorderColor(), width: 1.w),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SCDebounceWidget(
|
||||
onTap: () {
|
||||
SCNavigatorUtils.push(
|
||||
context,
|
||||
SettingsRoute.broadcast,
|
||||
replace: false,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 18.w),
|
||||
text(
|
||||
SCAppLocalizations.of(context)!.broadcast,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPagePrimaryTextColor(),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
Spacer(),
|
||||
Icon(
|
||||
SCGlobalConfig.lang == "ar"
|
||||
? Icons.keyboard_arrow_left
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 15.w,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageIconColor(),
|
||||
),
|
||||
SizedBox(width: 18.w),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
Container(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
margin: EdgeInsetsDirectional.only(start: 15.w),
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.about,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageAboutTextColor(),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.w),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.w, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageContainerBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@ -175,7 +268,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SizedBox(width: 18.w),
|
||||
text(
|
||||
SCAppLocalizations.of(context)!.aboutUs,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPagePrimaryTextColor(),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPagePrimaryTextColor(),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
Spacer(),
|
||||
@ -184,7 +279,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
? Icons.keyboard_arrow_left
|
||||
: Icons.keyboard_arrow_right,
|
||||
size: 15.w,
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageIconColor(),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageIconColor(),
|
||||
),
|
||||
SizedBox(width: 18.w),
|
||||
],
|
||||
@ -193,7 +290,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 45.w,),
|
||||
SizedBox(height: 45.w),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@ -207,12 +304,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(25.w),
|
||||
color: SCGlobalConfig.businessLogicStrategy.getSettingsPageButtonBackgroundColor(),
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageButtonBackgroundColor(),
|
||||
),
|
||||
child: text(
|
||||
SCAppLocalizations.of(context)!.logout,
|
||||
fontSize: 14.sp,
|
||||
textColor: SCGlobalConfig.businessLogicStrategy.getSettingsPageButtonTextColor(),
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getSettingsPageButtonTextColor(),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import 'package:fluro/fluro.dart';
|
||||
import 'package:fluro/src/fluro_router.dart';
|
||||
import 'package:yumi/modules/user/settings/account/set/pwd/reset_pwd_page.dart';
|
||||
import 'package:yumi/modules/user/settings/account/set/pwd/set_pwd_page.dart';
|
||||
import 'package:yumi/modules/user/settings/settings_page.dart';
|
||||
@ -10,6 +9,7 @@ import 'package:yumi/modules/user/settings/account/account_page.dart';
|
||||
import 'package:yumi/modules/user/settings/account/delete/delete_account_page.dart';
|
||||
import 'package:yumi/modules/user/settings/account/set/set_account_page.dart';
|
||||
import 'package:yumi/modules/user/settings/blacklist/user_blacklist_page.dart';
|
||||
import 'package:yumi/modules/user/settings/broadcast/broadcast_settings_page.dart';
|
||||
|
||||
class SettingsRoute implements SCIRouterProvider {
|
||||
static String settings = '/me/settings';
|
||||
@ -20,12 +20,13 @@ class SettingsRoute implements SCIRouterProvider {
|
||||
static String resetPwd = '/me/settings/account/setAccount/resetPwd';
|
||||
static String setPwd = '/me/settings/account/setAccount/setPwd';
|
||||
static String about = '/me/settings/account/about';
|
||||
static String broadcast = '/me/settings/broadcast';
|
||||
|
||||
@override
|
||||
void initRouter(FluroRouter router) {
|
||||
router.define(
|
||||
settings,
|
||||
handler: Handler(handlerFunc: (_, params) => SettingsPage()),
|
||||
handler: Handler(handlerFunc: (_, params) => const SettingsPage()),
|
||||
);
|
||||
router.define(
|
||||
account,
|
||||
@ -53,10 +54,16 @@ class SettingsRoute implements SCIRouterProvider {
|
||||
handler: Handler(handlerFunc: (_, params) => AboutPage()),
|
||||
);
|
||||
|
||||
router.define(
|
||||
broadcast,
|
||||
handler: Handler(
|
||||
handlerFunc: (_, params) => const BroadcastSettingsPage(),
|
||||
),
|
||||
);
|
||||
|
||||
router.define(
|
||||
blockedList,
|
||||
handler: Handler(handlerFunc: (_, params) => UserBlockedListPage()),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,9 +5,10 @@ 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_room_repository_imp.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
|
||||
import 'package:yumi/shared/tools/sc_entry_popup_coordinator.dart';
|
||||
|
||||
class TaskPage extends StatefulWidget {
|
||||
const TaskPage({super.key});
|
||||
@ -282,6 +283,8 @@ class _TaskPageState extends State<TaskPage> {
|
||||
iconAsset: _iconForTask(task),
|
||||
iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover),
|
||||
jumpPage: _nonEmpty(task.jumpPage),
|
||||
jumpType: _nonEmpty(task.jumpType),
|
||||
roomId: _nonEmpty(task.roomId),
|
||||
fromApi: id.isNotEmpty,
|
||||
);
|
||||
}
|
||||
@ -289,11 +292,12 @@ class _TaskPageState extends State<TaskPage> {
|
||||
Future<void> _handleTaskAction(_TaskUiItem task) async {
|
||||
debugPrint(
|
||||
'[TaskCenter][Page] action id=${task.id} status=${task.status.name} '
|
||||
'fromApi=${task.fromApi} jumpPage=${task.jumpPage}',
|
||||
'fromApi=${task.fromApi} jumpType=${task.jumpType} '
|
||||
'jumpPage=${task.jumpPage} roomId=${task.roomId}',
|
||||
);
|
||||
if (task.status == _TaskActionStatus.claimed) return;
|
||||
if (task.status == _TaskActionStatus.go) {
|
||||
_openJumpPage(task.jumpPage);
|
||||
await _openJumpPage(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -320,19 +324,123 @@ class _TaskPageState extends State<TaskPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openJumpPage(String? jumpPage) {
|
||||
final target = jumpPage?.trim() ?? '';
|
||||
Future<void> _openJumpPage(_TaskUiItem task) async {
|
||||
var target = _taskJumpUrl(task);
|
||||
if (target.isEmpty) return;
|
||||
if (target.startsWith('http://') || target.startsWith('https://')) {
|
||||
SCNavigatorUtils.push(
|
||||
context,
|
||||
'${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(target)}&showTitle=false',
|
||||
var uri = Uri.tryParse(target);
|
||||
var path = uri?.path ?? '';
|
||||
var roomId = uri?.queryParameters['roomId']?.trim() ?? '';
|
||||
if ((path == '/room' || path == '/game-center') && roomId.isEmpty) {
|
||||
debugPrint(
|
||||
'[TaskCenter][Page] room jump missing roomId, fallback first room '
|
||||
'target=$target taskId=${task.id}',
|
||||
);
|
||||
return;
|
||||
final fallbackTarget = await _firstRoomJumpUrl(target);
|
||||
if (!mounted) return;
|
||||
if (fallbackTarget.isEmpty) {
|
||||
_showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry);
|
||||
return;
|
||||
}
|
||||
target = fallbackTarget;
|
||||
uri = Uri.tryParse(target);
|
||||
path = uri?.path ?? '';
|
||||
roomId = uri?.queryParameters['roomId']?.trim() ?? '';
|
||||
}
|
||||
if (target.startsWith('/')) {
|
||||
SCNavigatorUtils.push(context, target);
|
||||
|
||||
final jumpType = _taskJumpType(task, target);
|
||||
debugPrint(
|
||||
'[TaskCenter][Page] open jump type=$jumpType url=$target '
|
||||
'taskId=${task.id}',
|
||||
);
|
||||
await SCEntryPopupCoordinator.openConfiguredJump(
|
||||
context,
|
||||
jumpType: jumpType,
|
||||
jumpUrl: target,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _firstRoomJumpUrl(String target) async {
|
||||
try {
|
||||
final rooms = await SCChatRoomRepository().discovery(allRegion: true);
|
||||
for (final room in rooms) {
|
||||
final roomId = room.id?.trim() ?? '';
|
||||
if (roomId.isEmpty) continue;
|
||||
final resolvedTarget = _withRoomId(target, roomId);
|
||||
debugPrint(
|
||||
'[TaskCenter][Page] fallback first room roomId=$roomId '
|
||||
'target=$resolvedTarget',
|
||||
);
|
||||
return resolvedTarget;
|
||||
}
|
||||
debugPrint('[TaskCenter][Page] fallback first room empty list');
|
||||
} catch (error) {
|
||||
debugPrint('[TaskCenter][Page] fallback first room failed error=$error');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _withRoomId(String target, String roomId) {
|
||||
final uri = Uri.tryParse(target);
|
||||
if (uri == null) {
|
||||
return '/room?roomId=${Uri.encodeComponent(roomId)}';
|
||||
}
|
||||
final params = Map<String, String>.from(uri.queryParameters);
|
||||
params['roomId'] = roomId;
|
||||
return uri.replace(queryParameters: params).toString();
|
||||
}
|
||||
|
||||
String _taskJumpUrl(_TaskUiItem task) {
|
||||
final target = task.jumpPage?.trim() ?? '';
|
||||
final roomId = task.roomId?.trim() ?? '';
|
||||
if (target.isEmpty) {
|
||||
if (_isGameJumpType(task.jumpType)) {
|
||||
return roomId.isEmpty
|
||||
? '/game-center'
|
||||
: '/game-center?roomId=${Uri.encodeComponent(roomId)}';
|
||||
}
|
||||
if (_isRoomJumpType(task.jumpType)) {
|
||||
return roomId.isEmpty
|
||||
? '/room'
|
||||
: '/room?roomId=${Uri.encodeComponent(roomId)}';
|
||||
}
|
||||
return roomId.isEmpty
|
||||
? ''
|
||||
: '/room?roomId=${Uri.encodeComponent(roomId)}';
|
||||
}
|
||||
if (roomId.isEmpty) return target;
|
||||
|
||||
final uri = Uri.tryParse(target);
|
||||
if (uri == null) return target;
|
||||
if (uri.path != '/room' && uri.path != '/game-center') return target;
|
||||
return _withRoomId(target, roomId);
|
||||
}
|
||||
|
||||
String _taskJumpType(_TaskUiItem task, String target) {
|
||||
final jumpType = task.jumpType?.trim();
|
||||
if (jumpType != null && jumpType.isNotEmpty) {
|
||||
if (_isGameJumpType(jumpType)) return 'GAME';
|
||||
if (_isRoomJumpType(jumpType)) return 'APP_ROUTE';
|
||||
return jumpType;
|
||||
}
|
||||
final uri = Uri.tryParse(target);
|
||||
if (uri?.hasScheme == true) {
|
||||
return 'H5';
|
||||
}
|
||||
if (uri?.path == '/game-center') {
|
||||
return 'GAME';
|
||||
}
|
||||
return 'APP_ROUTE';
|
||||
}
|
||||
|
||||
bool _isRoomJumpType(String? jumpType) {
|
||||
final normalized = jumpType?.trim().toUpperCase() ?? '';
|
||||
return normalized == 'ROOM' ||
|
||||
normalized == 'VOICE_ROOM' ||
|
||||
normalized == 'VOICECHAT_ROOM';
|
||||
}
|
||||
|
||||
bool _isGameJumpType(String? jumpType) {
|
||||
return jumpType?.trim().toUpperCase() == 'GAME';
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
@ -393,7 +501,9 @@ class _TaskPageState extends State<TaskPage> {
|
||||
return '{id=${task.taskId},type=${task.taskType},'
|
||||
'status=${task.taskStatus},collected=${task.isRewardCollected},'
|
||||
'current=${task.completedValue},target=${task.targetValue},'
|
||||
'quantity=${task.quantity},name=${task.taskName}}';
|
||||
'quantity=${task.quantity},name=${task.taskName},'
|
||||
'jumpType=${task.jumpType},jumpPage=${task.jumpPage},'
|
||||
'roomId=${task.roomId}}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
@ -798,6 +908,8 @@ class _TaskUiItem {
|
||||
required this.iconAsset,
|
||||
this.iconUrl,
|
||||
this.jumpPage,
|
||||
this.jumpType,
|
||||
this.roomId,
|
||||
this.fromApi = false,
|
||||
});
|
||||
|
||||
@ -809,6 +921,8 @@ class _TaskUiItem {
|
||||
final String iconAsset;
|
||||
final String? iconUrl;
|
||||
final String? jumpPage;
|
||||
final String? jumpType;
|
||||
final String? roomId;
|
||||
final bool fromApi;
|
||||
|
||||
_TaskUiItem copyWith({_TaskActionStatus? status}) {
|
||||
@ -821,6 +935,8 @@ class _TaskUiItem {
|
||||
iconAsset: iconAsset,
|
||||
iconUrl: iconUrl,
|
||||
jumpPage: jumpPage,
|
||||
jumpType: jumpType,
|
||||
roomId: roomId,
|
||||
fromApi: fromApi,
|
||||
);
|
||||
}
|
||||
|
||||
@ -2017,6 +2017,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
int? previewSeatCount,
|
||||
RoomEntryPreviewData? previewData,
|
||||
}) async {
|
||||
SCFloatIchart().remove();
|
||||
int nextTime = DateTime.now().millisecondsSinceEpoch;
|
||||
if (nextTime - startTime < 1000) {
|
||||
//频繁点击
|
||||
@ -2034,7 +2035,6 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
if (roomId == currenRoom?.roomProfile?.roomProfile?.id) {
|
||||
if (_currentRoomIsEntryPreview ||
|
||||
_roomStartupStatus == RoomStartupStatus.loading) {
|
||||
SCFloatIchart().remove();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
@ -2061,10 +2061,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
_startRoomStatePolling();
|
||||
_setRoomStartupReady();
|
||||
setRoomVisualEffectsEnabled(true);
|
||||
SCFloatIchart().remove();
|
||||
VoiceRoomRoute.openVoiceRoom(context);
|
||||
} else {
|
||||
SCFloatIchart().remove();
|
||||
if (currenRoom != null) {
|
||||
_switchAwayFromCurrentRoomForNewEntry();
|
||||
}
|
||||
@ -2788,7 +2786,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
})
|
||||
.catchError((e) {});
|
||||
loadRoomRedPacketList(1);
|
||||
_refreshRoomRedPacketListAfterEntry();
|
||||
|
||||
fetchRoomTaskClaimableCount();
|
||||
}
|
||||
@ -2921,17 +2919,60 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
return result;
|
||||
}
|
||||
|
||||
void _refreshRoomRedPacketListAfterEntry() {
|
||||
final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
|
||||
if (roomId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final delay in const [
|
||||
Duration.zero,
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 6),
|
||||
]) {
|
||||
unawaited(
|
||||
Future<void>.delayed(delay).then((_) {
|
||||
return _refreshRoomRedPacketListIfCurrent(roomId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshRoomRedPacketListIfCurrent(String roomId) async {
|
||||
final currentRoomId =
|
||||
currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
|
||||
if (roomId.trim().isEmpty || currentRoomId != roomId.trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadRoomRedPacketList(1);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][List] refresh after entry failed roomId=$roomId error=$error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startRoomRedPacketPresenceHeartbeat(String roomId) {
|
||||
final normalizedRoomId = roomId.trim();
|
||||
_stopRoomRedPacketPresenceHeartbeat();
|
||||
if (normalizedRoomId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'[RoomRedPacket][Presence] start heartbeat roomId=$normalizedRoomId',
|
||||
);
|
||||
void sendHeartbeat() {
|
||||
if (!_isCurrentRoomId(normalizedRoomId)) {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][Presence] stop heartbeat because room changed '
|
||||
'roomId=$normalizedRoomId current=${currenRoom?.roomProfile?.roomProfile?.id}',
|
||||
);
|
||||
_stopRoomRedPacketPresenceHeartbeat();
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'[RoomRedPacket][Presence] heartbeat roomId=$normalizedRoomId',
|
||||
);
|
||||
SCChatRoomRepository()
|
||||
.roomRedPacketPresenceHeartbeat(normalizedRoomId)
|
||||
.catchError((e) {
|
||||
@ -2948,7 +2989,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _stopRoomRedPacketPresenceHeartbeat() {
|
||||
_roomRedPacketPresenceTimer?.cancel();
|
||||
if (_roomRedPacketPresenceTimer != null) {
|
||||
debugPrint('[RoomRedPacket][Presence] stop heartbeat');
|
||||
_roomRedPacketPresenceTimer?.cancel();
|
||||
}
|
||||
_roomRedPacketPresenceTimer = null;
|
||||
}
|
||||
|
||||
|
||||
@ -56,6 +56,9 @@ import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart';
|
||||
import 'package:yumi/services/general/sc_app_general_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/invite/invite_room_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
|
||||
import 'package:yumi/services/gift/gift_system_manager.dart';
|
||||
@ -553,7 +556,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
|
||||
_onNewMessage(V2TimMessage message) {
|
||||
if (message.groupID != null) {
|
||||
///全服通知 / 语区红包广播群
|
||||
///全服通知 / 语区广播群
|
||||
if (message.groupID == SCGlobalConfig.bigBroadcastGroup ||
|
||||
message.groupID == roomRedPacketBroadcastGroupId) {
|
||||
_newBroadCastMsgRecv(message.groupID!, message);
|
||||
@ -627,13 +630,24 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
Future<void> syncRoomRedPacketBroadcastGroup() async {
|
||||
for (var attempt = 0; attempt < 3 && !isLogout; attempt++) {
|
||||
try {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][IM] sync region group attempt=${attempt + 1}',
|
||||
);
|
||||
final group = await SCChatRoomRepository().roomRedPacketImGroup();
|
||||
if (!group.canJoin) {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][IM] skip join configured=${group.configured} '
|
||||
'groupId=${group.groupId} region=${group.regionCode} status=${group.status}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final newGroupId = group.groupId.trim();
|
||||
if (newGroupId == roomRedPacketBroadcastGroupId) {
|
||||
roomRedPacketBroadcastRegionCode = group.regionCode.trim();
|
||||
debugPrint(
|
||||
'[RoomRedPacket][IM] region group unchanged group=$newGroupId '
|
||||
'region=${group.regionCode}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final joinResult = await TencentImSDKPlugin.v2TIMManager.joinGroup(
|
||||
@ -1026,6 +1040,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (msg.type == SCRoomMsgType.roomRedPacket &&
|
||||
_hasRoomRedPacketMessage(msg)) {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][Chat] skip duplicate packetId=${_roomRedPacketMessageKey(msg)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
roomAllMsgList.insert(0, msg);
|
||||
if (roomAllMsgList.length > 250) {
|
||||
@ -1090,6 +1111,38 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasRoomRedPacketMessage(Msg msg) {
|
||||
final key = _roomRedPacketMessageKey(msg);
|
||||
if (key == null || key.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return roomAllMsgList.any(
|
||||
(item) =>
|
||||
item.type == SCRoomMsgType.roomRedPacket &&
|
||||
_roomRedPacketMessageKey(item) == key,
|
||||
);
|
||||
}
|
||||
|
||||
String? _roomRedPacketMessageKey(Msg msg) {
|
||||
if (msg.type != SCRoomMsgType.roomRedPacket) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(msg.msg ?? '');
|
||||
if (decoded is Map) {
|
||||
for (final key in const ['packetId', 'packetNo', 'id']) {
|
||||
final value = decoded[key]?.toString().trim();
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Msg? _mergeGiftMessageIfNeeded(Msg incoming) {
|
||||
if (incoming.type != SCRoomMsgType.gift &&
|
||||
incoming.type != SCRoomMsgType.luckGiftAnimOther) {
|
||||
@ -1101,11 +1154,23 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
mergeTarget.number = (mergeTarget.number ?? 0) + (incoming.number ?? 0);
|
||||
if (_shouldAccumulateGiftMessage(mergeTarget, incoming)) {
|
||||
mergeTarget.number = (mergeTarget.number ?? 0) + (incoming.number ?? 0);
|
||||
} else if ((mergeTarget.number ?? 0) <= 0) {
|
||||
mergeTarget.number = incoming.number;
|
||||
}
|
||||
mergeTarget.time = DateTime.now().millisecondsSinceEpoch;
|
||||
if ((incoming.msg ?? "").trim().isNotEmpty) {
|
||||
mergeTarget.msg = incoming.msg;
|
||||
}
|
||||
if ((mergeTarget.giftBatchId ?? "").trim().isEmpty &&
|
||||
(incoming.giftBatchId ?? "").trim().isNotEmpty) {
|
||||
mergeTarget.giftBatchId = incoming.giftBatchId;
|
||||
}
|
||||
if ((mergeTarget.targetUserIds ?? const <String>[]).isEmpty &&
|
||||
(incoming.targetUserIds ?? const <String>[]).isNotEmpty) {
|
||||
mergeTarget.targetUserIds = List<String>.from(incoming.targetUserIds!);
|
||||
}
|
||||
|
||||
_moveMessageToFront(roomGiftMsgList, mergeTarget);
|
||||
_moveMessageToFront(roomAllMsgList, mergeTarget);
|
||||
@ -1127,11 +1192,67 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool _isSameGiftComboMessage(Msg existing, Msg incoming) {
|
||||
return existing.type == incoming.type &&
|
||||
existing.groupId == incoming.groupId &&
|
||||
existing.user?.id == incoming.user?.id &&
|
||||
existing.toUser?.id == incoming.toUser?.id &&
|
||||
existing.gift?.id == incoming.gift?.id;
|
||||
if (existing.type != incoming.type ||
|
||||
existing.groupId != incoming.groupId ||
|
||||
existing.user?.id != incoming.user?.id ||
|
||||
existing.gift?.id != incoming.gift?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final existingBatchId = (existing.giftBatchId ?? "").trim();
|
||||
final incomingBatchId = (incoming.giftBatchId ?? "").trim();
|
||||
if (existingBatchId.isNotEmpty && existingBatchId == incomingBatchId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing.toUser?.id == incoming.toUser?.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final existingTargetKey = _giftTargetGroupKey(existing);
|
||||
final incomingTargetKey = _giftTargetGroupKey(incoming);
|
||||
return existingTargetKey.isNotEmpty &&
|
||||
existingTargetKey == incomingTargetKey;
|
||||
}
|
||||
|
||||
bool _shouldAccumulateGiftMessage(Msg existing, Msg incoming) {
|
||||
final existingBatchId = (existing.giftBatchId ?? "").trim();
|
||||
final incomingBatchId = (incoming.giftBatchId ?? "").trim();
|
||||
if (existingBatchId.isNotEmpty && existingBatchId == incomingBatchId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final existingTargetKey = _giftTargetGroupKey(existing);
|
||||
final incomingTargetKey = _giftTargetGroupKey(incoming);
|
||||
if (existing.toUser?.id != incoming.toUser?.id &&
|
||||
existingTargetKey.isNotEmpty &&
|
||||
existingTargetKey == incomingTargetKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
String _giftTargetGroupKey(Msg msg) {
|
||||
final targetUserIds = <String>[];
|
||||
void addTargetUserId(String? userId) {
|
||||
final normalizedUserId = (userId ?? "").trim();
|
||||
if (normalizedUserId.isEmpty ||
|
||||
targetUserIds.contains(normalizedUserId)) {
|
||||
return;
|
||||
}
|
||||
targetUserIds.add(normalizedUserId);
|
||||
}
|
||||
|
||||
for (final userId in msg.targetUserIds ?? const <String>[]) {
|
||||
addTargetUserId(userId);
|
||||
}
|
||||
addTargetUserId(msg.toUser?.id);
|
||||
if (targetUserIds.length <= 1) {
|
||||
return "";
|
||||
}
|
||||
targetUserIds.sort();
|
||||
return targetUserIds.join(",");
|
||||
}
|
||||
|
||||
void _moveMessageToFront(List<Msg> messages, Msg target) {
|
||||
@ -1320,11 +1441,261 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
void _handleRegionGiftBroadcast(dynamic data) {
|
||||
final payload = _broadcastPayloadMap(data);
|
||||
if (payload.isEmpty) {
|
||||
debugPrint('[RoomRegionBroadcast][Gift] skip empty payload');
|
||||
return;
|
||||
}
|
||||
if (!_isSameRegionBroadcastPayload(payload)) {
|
||||
final payloadRegion = _payloadText(payload['regionCode']);
|
||||
debugPrint(
|
||||
'[RoomRegionBroadcast][Gift] skip region mismatch '
|
||||
'payloadRegion=$payloadRegion currentRegion=$roomRedPacketBroadcastRegionCode',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final roomId = _payloadText(payload['roomId']);
|
||||
if (roomId.isEmpty) {
|
||||
debugPrint('[RoomRegionBroadcast][Gift] skip empty roomId');
|
||||
return;
|
||||
}
|
||||
if (_isCurrentVoiceRoom(roomId)) {
|
||||
debugPrint(
|
||||
'[RoomRegionBroadcast][Gift] skip current room duplicate roomId=$roomId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final giftId = _payloadText(payload['giftId']);
|
||||
final quantityValue =
|
||||
_payloadNum(payload['giftQuantity']) ??
|
||||
_payloadNum(payload['quantity']) ??
|
||||
_payloadNum(payload['giftCount']) ??
|
||||
1;
|
||||
final quantity = quantityValue <= 0 ? 1 : quantityValue;
|
||||
final giftCandy = _payloadNum(payload['giftCandy']) ?? 0;
|
||||
final actualAmount = _payloadNum(payload['actualAmount']);
|
||||
final amount =
|
||||
actualAmount != null && actualAmount > 0
|
||||
? actualAmount
|
||||
: giftCandy * quantity;
|
||||
final acceptUserIds = _payloadStringList(payload['acceptUserIds']);
|
||||
final toUserId = _firstNonBlank([
|
||||
acceptUserIds.isNotEmpty ? acceptUserIds.first : '',
|
||||
_payloadText(payload['acceptUserId']),
|
||||
_payloadText(payload['acceptAccount']),
|
||||
_payloadText(payload['toUserId']),
|
||||
]);
|
||||
final senderId = _firstNonBlank([
|
||||
_payloadText(payload['actualAccount']),
|
||||
_payloadText(payload['sendUserId']),
|
||||
_payloadText(payload['userId']),
|
||||
_payloadText(payload['account']),
|
||||
]);
|
||||
final senderName = _firstNonBlank([
|
||||
_payloadText(payload['userNickname']),
|
||||
_payloadText(payload['sendUserName']),
|
||||
_payloadText(payload['nickname']),
|
||||
_payloadText(payload['userName']),
|
||||
]);
|
||||
final receiverName = _firstNonBlank([
|
||||
_payloadText(payload['acceptNickname']),
|
||||
_payloadText(payload['acceptUserNickname']),
|
||||
_payloadText(payload['toUserName']),
|
||||
]);
|
||||
final giftUrl = _resolveRegionGiftPhoto(payload);
|
||||
|
||||
final message = SCFloatingMessage(
|
||||
type: 1,
|
||||
roomId: roomId,
|
||||
userId: senderId,
|
||||
toUserId: toUserId,
|
||||
userName: senderName,
|
||||
toUserName: receiverName,
|
||||
userAvatarUrl: _payloadText(payload['userAvatar']),
|
||||
giftUrl: giftUrl,
|
||||
giftId: giftId,
|
||||
number: quantity,
|
||||
coins: amount,
|
||||
broadcastScope: SCFloatingMessage.broadcastScopeRegion,
|
||||
priority: 900,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[RoomRegionBroadcast][Gift] enqueue roomId=$roomId giftId=$giftId '
|
||||
'sender=$senderId amount=$amount quantity=$quantity giftUrl=$giftUrl',
|
||||
);
|
||||
_enqueueGiftFloatingMessage(
|
||||
message,
|
||||
dedupKey: _giftFloatingDedupKey(
|
||||
roomId: roomId,
|
||||
senderId: senderId,
|
||||
receiverId: toUserId,
|
||||
giftId: giftId,
|
||||
quantity: quantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveRegionGiftPhoto(Map<String, dynamic> payload) {
|
||||
final giftId = _payloadText(payload['giftId']);
|
||||
final fallbackUrl = _firstNonBlank([
|
||||
_payloadText(payload['giftPhoto']),
|
||||
_payloadText(payload['giftCover']),
|
||||
_payloadText(payload['giftUrl']),
|
||||
_payloadText(payload['giftIcon']),
|
||||
_payloadText(payload['giftImage']),
|
||||
_payloadText(payload['cover']),
|
||||
]);
|
||||
final currentContext = context;
|
||||
if (giftId.isEmpty || currentContext == null || !currentContext.mounted) {
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
final appGeneralManager = Provider.of<SCAppGeneralManager>(
|
||||
currentContext,
|
||||
listen: false,
|
||||
);
|
||||
final localGift = appGeneralManager.getGiftByIdOrStandardId(giftId);
|
||||
final localGiftPhoto = (localGift?.giftPhoto ?? '').trim();
|
||||
debugPrint(
|
||||
'[RoomRegionBroadcast][Gift] resolve gift photo giftId=$giftId '
|
||||
'localPhoto=$localGiftPhoto fallbackUrl=$fallbackUrl',
|
||||
);
|
||||
if (localGiftPhoto.isNotEmpty) {
|
||||
return localGiftPhoto;
|
||||
}
|
||||
unawaited(appGeneralManager.giftList());
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
bool _isCurrentVoiceRoom(String roomId) {
|
||||
final currentContext = context;
|
||||
if (roomId.trim().isEmpty ||
|
||||
currentContext == null ||
|
||||
!currentContext.mounted) {
|
||||
return false;
|
||||
}
|
||||
final currentRoomId =
|
||||
Provider.of<RealTimeCommunicationManager>(
|
||||
currentContext,
|
||||
listen: false,
|
||||
).currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
|
||||
'';
|
||||
return currentRoomId.isNotEmpty && currentRoomId == roomId.trim();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _broadcastPayloadMap(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map) {
|
||||
return data.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
if (data is String && data.trim().isNotEmpty) {
|
||||
try {
|
||||
return _broadcastPayloadMap(json.decode(data));
|
||||
} catch (_) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
String _payloadText(dynamic value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return value.toString().trim();
|
||||
}
|
||||
|
||||
num? _payloadNum(dynamic value) {
|
||||
if (value is num) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return num.tryParse(value.trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _payloadStringList(dynamic value) {
|
||||
if (value is Iterable) {
|
||||
return value
|
||||
.map((item) => _payloadText(item))
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
if (value is String) {
|
||||
final textValue = value.trim();
|
||||
if (textValue.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
if (textValue.startsWith('[')) {
|
||||
try {
|
||||
final decoded = json.decode(textValue);
|
||||
if (decoded is Iterable) {
|
||||
return _payloadStringList(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return textValue
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
void _showCurrentRoomRedPacketDialog(dynamic payload) {
|
||||
final currentContext = context;
|
||||
if (currentContext == null || !currentContext.mounted) {
|
||||
return;
|
||||
}
|
||||
if (_isCurrentUserRoomRedPacketPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
final dialogData = RoomRedPacketUiData.fromJson(
|
||||
_broadcastPayloadMap(payload),
|
||||
);
|
||||
if (!dialogData.hasPacketId ||
|
||||
!dialogData.isOpenable ||
|
||||
RoomRedPacketOpenDialog.isClaimedLocally(dialogData.packetId)) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!currentContext.mounted) {
|
||||
return;
|
||||
}
|
||||
RoomRedPacketOpenDialog.show(currentContext, dialogData);
|
||||
});
|
||||
}
|
||||
|
||||
bool _isCurrentUserRoomRedPacketPayload(dynamic payload) {
|
||||
final map = _broadcastPayloadMap(payload);
|
||||
final senderId = _firstNonBlank([
|
||||
_payloadText(map['actualAccount']),
|
||||
_payloadText(map['userId']),
|
||||
_payloadText(map['sendUserId']),
|
||||
_payloadText(map['account']),
|
||||
]);
|
||||
final currentUserId =
|
||||
AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? '';
|
||||
return senderId.isNotEmpty &&
|
||||
currentUserId.isNotEmpty &&
|
||||
senderId == currentUserId;
|
||||
}
|
||||
|
||||
bool isLogout = false;
|
||||
|
||||
logout() async {
|
||||
roomRedPacketBroadcastGroupId = null;
|
||||
roomRedPacketBroadcastRegionCode = null;
|
||||
RoomRedPacketOpenDialog.clearClaimedState();
|
||||
RoomRedPacketPendingCache.instance.clear();
|
||||
V2TimCallback logoutRes = await TencentImSDKPlugin.v2TIMManager.logout();
|
||||
TencentImSDKPlugin.v2TIMManager
|
||||
.getMessageManager()
|
||||
@ -1339,6 +1710,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
try {
|
||||
String? customData = message.customElem?.data;
|
||||
if (customData != null && customData.isNotEmpty) {
|
||||
final isRegionBroadcastGroup = groupID == roomRedPacketBroadcastGroupId;
|
||||
final data = json.decode(customData);
|
||||
var type = data["type"];
|
||||
if (type == "SYS_ACTIVITY") {
|
||||
@ -1423,6 +1795,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
if (!_isSameRoomRedPacketRegion(fData)) {
|
||||
return;
|
||||
}
|
||||
RoomRedPacketPendingCache.instance.upsertPayload(fData);
|
||||
SCFloatingMessage msg = SCFloatingMessage(
|
||||
type: 4,
|
||||
roomId: fData["roomId"],
|
||||
@ -1432,6 +1805,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
toUserId: fData["packetId"],
|
||||
coins: fData["totalAmount"],
|
||||
number: fData["totalCount"],
|
||||
broadcastScope:
|
||||
isRegionBroadcastGroup
|
||||
? SCFloatingMessage.broadcastScopeRegion
|
||||
: '',
|
||||
priority: 1000,
|
||||
);
|
||||
if (msg.roomId ==
|
||||
@ -1451,8 +1828,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
fData,
|
||||
),
|
||||
);
|
||||
_showCurrentRoomRedPacketDialog(fData);
|
||||
}
|
||||
OverlayManager().addMessage(msg);
|
||||
} else if (type == SCRoomMsgType.sendGift) {
|
||||
if (isRegionBroadcastGroup) {
|
||||
_handleRegionGiftBroadcast(data["data"]);
|
||||
}
|
||||
} else if (type == SCRoomMsgType.inviteRoom) {
|
||||
///邀请进入房间
|
||||
var fdata = data["data"];
|
||||
@ -1500,6 +1882,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
if (!_isSameRoomRedPacketRegion(fData)) {
|
||||
return;
|
||||
}
|
||||
RoomRedPacketPendingCache.instance.upsertPayload(fData);
|
||||
SCFloatingMessage msg = SCFloatingMessage(
|
||||
type: 4,
|
||||
roomId: fData["roomId"],
|
||||
@ -1516,6 +1899,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
listen: false,
|
||||
).loadRoomRedPacketList(1);
|
||||
addMsg(_buildRoomRedPacketMsg(groupID, fData));
|
||||
_showCurrentRoomRedPacketDialog(fData);
|
||||
OverlayManager().addMessage(msg);
|
||||
return;
|
||||
}
|
||||
@ -2008,10 +2392,11 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool _isSameRoomRedPacketRegion(dynamic payload) {
|
||||
final map =
|
||||
payload is Map
|
||||
? payload.map((key, value) => MapEntry(key.toString(), value))
|
||||
: const <String, dynamic>{};
|
||||
return _isSameRegionBroadcastPayload(payload);
|
||||
}
|
||||
|
||||
bool _isSameRegionBroadcastPayload(dynamic payload) {
|
||||
final map = _broadcastPayloadMap(payload);
|
||||
final packetRegion = map['regionCode']?.toString().trim();
|
||||
final currentRegion = roomRedPacketBroadcastRegionCode?.trim();
|
||||
if (packetRegion == null ||
|
||||
|
||||
@ -7,10 +7,12 @@ class SCRoomRedPacketSentRecordRes {
|
||||
this.totalCount = 0,
|
||||
this.remainAmount = 0,
|
||||
this.remainCount = 0,
|
||||
this.refundAmount = 0,
|
||||
this.claimStartTime = '',
|
||||
this.expireTime = '',
|
||||
this.status = '',
|
||||
this.createTime = '',
|
||||
this.records = const <SCRoomRedPacketSentClaimRecordRes>[],
|
||||
});
|
||||
|
||||
factory SCRoomRedPacketSentRecordRes.fromJson(dynamic json) {
|
||||
@ -26,10 +28,18 @@ class SCRoomRedPacketSentRecordRes {
|
||||
totalCount: _numValue(map, const ['totalCount', 'count']),
|
||||
remainAmount: _numValue(map, const ['remainAmount']),
|
||||
remainCount: _numValue(map, const ['remainCount']),
|
||||
refundAmount: _numValue(map, const [
|
||||
'refundAmount',
|
||||
'returnAmount',
|
||||
'returnedAmount',
|
||||
]),
|
||||
claimStartTime: _stringValue(map, const ['claimStartTime']),
|
||||
expireTime: _stringValue(map, const ['expireTime']),
|
||||
status: _stringValue(map, const ['status', 'statusDesc']),
|
||||
createTime: _stringValue(map, const ['createTime', 'sendTime', 'sentAt']),
|
||||
records: _recordListValue(
|
||||
map,
|
||||
).map(SCRoomRedPacketSentClaimRecordRes.fromJson).toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,10 +50,105 @@ class SCRoomRedPacketSentRecordRes {
|
||||
final num totalCount;
|
||||
final num remainAmount;
|
||||
final num remainCount;
|
||||
final num refundAmount;
|
||||
final String claimStartTime;
|
||||
final String expireTime;
|
||||
final String status;
|
||||
final String createTime;
|
||||
final List<SCRoomRedPacketSentClaimRecordRes> records;
|
||||
}
|
||||
|
||||
class SCRoomRedPacketSentClaimRecordRes {
|
||||
const SCRoomRedPacketSentClaimRecordRes({
|
||||
this.userName = '',
|
||||
this.userAvatar = '',
|
||||
this.amount = 0,
|
||||
this.claimTime = '',
|
||||
});
|
||||
|
||||
factory SCRoomRedPacketSentClaimRecordRes.fromJson(dynamic json) {
|
||||
final map =
|
||||
json is Map
|
||||
? json.map((key, value) => MapEntry(key.toString(), value))
|
||||
: const <String, dynamic>{};
|
||||
return SCRoomRedPacketSentClaimRecordRes(
|
||||
userName: _stringValue(map, const [
|
||||
'userName',
|
||||
'userNickname',
|
||||
'nickname',
|
||||
'name',
|
||||
'account',
|
||||
]),
|
||||
userAvatar: _stringValue(map, const [
|
||||
'userAvatar',
|
||||
'avatar',
|
||||
'avatarUrl',
|
||||
]),
|
||||
amount: _numValue(map, const ['amount', 'claimAmount', 'grabAmount']),
|
||||
claimTime: _stringValue(map, const [
|
||||
'grabTime',
|
||||
'claimTime',
|
||||
'createTime',
|
||||
'time',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
final String userName;
|
||||
final String userAvatar;
|
||||
final num amount;
|
||||
final String claimTime;
|
||||
}
|
||||
|
||||
class SCRoomRedPacketClaimRecordsRes {
|
||||
const SCRoomRedPacketClaimRecordsRes({
|
||||
this.packetNo = '',
|
||||
this.totalCount = 0,
|
||||
this.claimedCount = 0,
|
||||
this.claimedAmount = 0,
|
||||
this.records = const <SCRoomRedPacketSentClaimRecordRes>[],
|
||||
});
|
||||
|
||||
factory SCRoomRedPacketClaimRecordsRes.fromJson(dynamic json) {
|
||||
final map =
|
||||
json is Map
|
||||
? json.map((key, value) => MapEntry(key.toString(), value))
|
||||
: const <String, dynamic>{};
|
||||
return SCRoomRedPacketClaimRecordsRes(
|
||||
packetNo: _stringValue(map, const ['packetNo', 'packetId', 'id']),
|
||||
totalCount: _numValue(map, const ['totalCount', 'count']),
|
||||
claimedCount: _numValue(map, const ['claimedCount', 'grabbedCount']),
|
||||
claimedAmount: _numValue(map, const ['claimedAmount', 'grabbedAmount']),
|
||||
records: _recordListValue(
|
||||
map,
|
||||
).map(SCRoomRedPacketSentClaimRecordRes.fromJson).toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
final String packetNo;
|
||||
final num totalCount;
|
||||
final num claimedCount;
|
||||
final num claimedAmount;
|
||||
final List<SCRoomRedPacketSentClaimRecordRes> records;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'packetNo': packetNo,
|
||||
'totalCount': totalCount,
|
||||
'claimedCount': claimedCount,
|
||||
'claimedAmount': claimedAmount,
|
||||
'records': records
|
||||
.map(
|
||||
(record) => {
|
||||
'userName': record.userName,
|
||||
'userAvatar': record.userAvatar,
|
||||
'amount': record.amount,
|
||||
'claimTime': record.claimTime,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
String _stringValue(Map<String, dynamic> json, List<String> keys) {
|
||||
@ -71,3 +176,19 @@ num _numValue(Map<String, dynamic> json, List<String> keys) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<dynamic> _recordListValue(Map<String, dynamic> json) {
|
||||
for (final key in const [
|
||||
'records',
|
||||
'claimRecords',
|
||||
'grabRecords',
|
||||
'receiveRecords',
|
||||
'items',
|
||||
]) {
|
||||
final value = json[key];
|
||||
if (value is List) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// taskType : 0
|
||||
// taskName : ""
|
||||
// taskDesc : ""
|
||||
// jumpPage : ""
|
||||
// jumpType : ""
|
||||
// roomId : ""
|
||||
// conditionType : ""
|
||||
// sortOrder : 0
|
||||
// taskId : "0"
|
||||
@ -17,6 +21,8 @@ class SCTaskListRes {
|
||||
String? taskName,
|
||||
String? taskDesc,
|
||||
String? jumpPage,
|
||||
String? jumpType,
|
||||
String? roomId,
|
||||
String? conditionType,
|
||||
num? sortOrder,
|
||||
String? taskId,
|
||||
@ -33,6 +39,8 @@ class SCTaskListRes {
|
||||
_taskName = taskName;
|
||||
_taskDesc = taskDesc;
|
||||
_jumpPage = jumpPage;
|
||||
_jumpType = jumpType;
|
||||
_roomId = roomId;
|
||||
_conditionType = conditionType;
|
||||
_sortOrder = sortOrder;
|
||||
_taskId = taskId;
|
||||
@ -47,27 +55,51 @@ class SCTaskListRes {
|
||||
}
|
||||
|
||||
SCTaskListRes.fromJson(dynamic json) {
|
||||
_taskType = _asNum(json['taskType']);
|
||||
_taskName = _asString(json['taskName']);
|
||||
_taskDesc = _asString(json['taskDesc']);
|
||||
_jumpPage = _asString(json['jumpPage']);
|
||||
_conditionType = _asString(json['conditionType']);
|
||||
_sortOrder = _asNum(json['sortOrder']);
|
||||
_taskId = _asString(json['taskId']);
|
||||
_taskStatus = _asNum(json['taskStatus']);
|
||||
_cover = _asString(json['cover']);
|
||||
_taskIcon = _asString(json['taskIcon']);
|
||||
_conditionValue = _asString(json['conditionValue']);
|
||||
_completedValue = _asNum(json['completedValue']);
|
||||
_isRewardCollected = _asNum(json['isRewardCollected']);
|
||||
_quantity = _asNum(json['quantity']);
|
||||
_targetValue = _asNum(json['targetValue']);
|
||||
final map = _asMap(json) ?? const <String, dynamic>{};
|
||||
_rawJson = Map<String, dynamic>.from(map);
|
||||
_taskType = _asNum(map['taskType']);
|
||||
_taskName = _asString(map['taskName']);
|
||||
_taskDesc = _asString(map['taskDesc']);
|
||||
_jumpPage = _firstString([
|
||||
map['jumpPage'],
|
||||
map['jumpUrl'],
|
||||
map['jumpURI'],
|
||||
map['jumpUri'],
|
||||
map['route'],
|
||||
map['actionUrl'],
|
||||
map['targetUrl'],
|
||||
]);
|
||||
_jumpType = _firstString([
|
||||
map['jumpType'],
|
||||
map['actionType'],
|
||||
map['clickType'],
|
||||
map['targetType'],
|
||||
]);
|
||||
_roomId = _firstString([
|
||||
map['roomId'],
|
||||
map['targetRoomId'],
|
||||
map['jumpRoomId'],
|
||||
_nestedValue(map, 'roomId'),
|
||||
]);
|
||||
_conditionType = _asString(map['conditionType']);
|
||||
_sortOrder = _asNum(map['sortOrder']);
|
||||
_taskId = _asString(map['taskId']);
|
||||
_taskStatus = _asNum(map['taskStatus']);
|
||||
_cover = _asString(map['cover']);
|
||||
_taskIcon = _asString(map['taskIcon']);
|
||||
_conditionValue = _asString(map['conditionValue']);
|
||||
_completedValue = _asNum(map['completedValue']);
|
||||
_isRewardCollected = _asNum(map['isRewardCollected']);
|
||||
_quantity = _asNum(map['quantity']);
|
||||
_targetValue = _asNum(map['targetValue']);
|
||||
}
|
||||
|
||||
num? _taskType;
|
||||
String? _taskName;
|
||||
String? _taskDesc;
|
||||
String? _jumpPage;
|
||||
String? _jumpType;
|
||||
String? _roomId;
|
||||
String? _conditionType;
|
||||
num? _sortOrder;
|
||||
String? _taskId;
|
||||
@ -79,6 +111,7 @@ class SCTaskListRes {
|
||||
num? _isRewardCollected;
|
||||
num? _quantity;
|
||||
num? _targetValue;
|
||||
Map<String, dynamic> _rawJson = const <String, dynamic>{};
|
||||
|
||||
num? get taskType => _taskType;
|
||||
|
||||
@ -88,6 +121,10 @@ class SCTaskListRes {
|
||||
|
||||
String? get jumpPage => _jumpPage;
|
||||
|
||||
String? get jumpType => _jumpType;
|
||||
|
||||
String? get roomId => _roomId;
|
||||
|
||||
String? get conditionType => _conditionType;
|
||||
|
||||
num? get sortOrder => _sortOrder;
|
||||
@ -109,12 +146,17 @@ class SCTaskListRes {
|
||||
num? get quantity => _quantity;
|
||||
num? get targetValue => _targetValue;
|
||||
|
||||
Map<String, dynamic> get rawJson =>
|
||||
Map<String, dynamic>.unmodifiable(_rawJson);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['taskType'] = _taskType;
|
||||
map['taskName'] = _taskName;
|
||||
map['taskDesc'] = _taskDesc;
|
||||
map['jumpPage'] = _jumpPage;
|
||||
map['jumpType'] = _jumpType;
|
||||
map['roomId'] = _roomId;
|
||||
map['conditionType'] = _conditionType;
|
||||
map['sortOrder'] = _sortOrder;
|
||||
map['taskId'] = _taskId;
|
||||
@ -129,11 +171,53 @@ class SCTaskListRes {
|
||||
return map;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(value);
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
if (decoded is Map) return Map<String, dynamic>.from(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _asString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
static String? _firstString(Iterable<dynamic> values) {
|
||||
for (final value in values) {
|
||||
final text = _asString(value)?.trim();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static dynamic _nestedValue(Map<String, dynamic> map, String key) {
|
||||
const nestedKeys = [
|
||||
'jumpParams',
|
||||
'jumpParam',
|
||||
'jumpParameters',
|
||||
'params',
|
||||
'extra',
|
||||
'ext',
|
||||
'extend',
|
||||
];
|
||||
for (final nestedKey in nestedKeys) {
|
||||
final nested = _asMap(map[nestedKey]);
|
||||
if (nested != null && nested.containsKey(key)) {
|
||||
return nested[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static num? _asNum(dynamic value) {
|
||||
if (value is num) return value;
|
||||
if (value is String) return num.tryParse(value);
|
||||
|
||||
@ -249,6 +249,11 @@ abstract class SocialChatRoomRepository {
|
||||
int limit = 20,
|
||||
});
|
||||
|
||||
///查询红包领取记录
|
||||
Future<SCRoomRedPacketClaimRecordsRes> roomRedPacketClaimRecords(
|
||||
String packetNo,
|
||||
);
|
||||
|
||||
///查询房间奖励信息
|
||||
Future<SCRoomRewardInfoRes> roomRewardInfo(String roomId);
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
class SCFloatingMessage {
|
||||
static const String broadcastScopeRegion = 'region';
|
||||
|
||||
String? userId; // 可选:用户ID
|
||||
String? roomId; // 可选:房间ID
|
||||
String? toUserId; // 可选:用户ID
|
||||
@ -14,6 +16,7 @@ class SCFloatingMessage {
|
||||
num? coins;
|
||||
num? number;
|
||||
num? multiple;
|
||||
String? broadcastScope; // region:来自语区 IM 群,只能按区域飘屏规则展示
|
||||
int priority = 10; //排序权重
|
||||
|
||||
SCFloatingMessage({
|
||||
@ -33,6 +36,7 @@ class SCFloatingMessage {
|
||||
this.coins = 0,
|
||||
this.priority = 10,
|
||||
this.multiple = 10,
|
||||
this.broadcastScope = '',
|
||||
});
|
||||
|
||||
SCFloatingMessage.fromJson(dynamic json) {
|
||||
@ -52,8 +56,11 @@ class SCFloatingMessage {
|
||||
number = json['number'];
|
||||
priority = json['priority'];
|
||||
multiple = json['multiple'];
|
||||
broadcastScope = json['broadcastScope'];
|
||||
}
|
||||
|
||||
bool get isRegionBroadcast => broadcastScope == broadcastScopeRegion;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['userId'] = userId;
|
||||
@ -72,6 +79,7 @@ class SCFloatingMessage {
|
||||
map['number'] = number;
|
||||
map['priority'] = priority;
|
||||
map['multiple'] = multiple;
|
||||
map['broadcastScope'] = broadcastScope;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import 'package:yumi/shared/tools/sc_network_image_utils.dart';
|
||||
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
|
||||
import 'package:yumi/shared/tools/sc_entrance_vap_svga_manager.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/floating/floating_game_screen_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart';
|
||||
@ -23,14 +25,18 @@ typedef FloatingScreenManager = OverlayManager;
|
||||
class OverlayManager {
|
||||
static const int _maxLowPerformanceCompensationQueueLength = 8;
|
||||
static const Duration _compensationDedupWindow = Duration(seconds: 3);
|
||||
static const Duration _redPacketFloatingDedupWindow = Duration(minutes: 30);
|
||||
|
||||
final SCPriorityQueue<SCFloatingMessage> _messageQueue = SCPriorityQueue(
|
||||
(a, b) => b.priority.compareTo(a.priority),
|
||||
);
|
||||
final Map<String, DateTime> _recentCompensationKeys = {};
|
||||
final Map<String, DateTime> _recentFloatingKeys = {};
|
||||
bool _isPlaying = false;
|
||||
OverlayEntry? _currentOverlayEntry;
|
||||
SCFloatingMessage? _currentMessage;
|
||||
bool _homeRootTabsVisible = false;
|
||||
int _suppressedCount = 0;
|
||||
|
||||
bool _isProcessing = false;
|
||||
bool _isDisposed = false;
|
||||
@ -42,13 +48,18 @@ class OverlayManager {
|
||||
OverlayManager._internal();
|
||||
|
||||
void addMessage(SCFloatingMessage message) {
|
||||
if (_isDisposed) return;
|
||||
if (SCGlobalConfig.isFloatingAnimationInGlobal) {
|
||||
if (_isDisposed || _isSuppressed) return;
|
||||
if (!SCGlobalConfig.isFloatingBroadcastClosed) {
|
||||
if (_isRecentFloatingDuplicate(message)) {
|
||||
return;
|
||||
}
|
||||
_rememberFloatingMessage(message);
|
||||
_enqueueMessage(
|
||||
message,
|
||||
debugLabel: 'floating_message_type_${message.type ?? -1}',
|
||||
);
|
||||
} else {
|
||||
_removeActiveMessage(scheduleNext: false);
|
||||
_messageQueue.clear();
|
||||
_isPlaying = false;
|
||||
_isProcessing = false;
|
||||
@ -60,7 +71,10 @@ class OverlayManager {
|
||||
SCFloatingMessage message, {
|
||||
String? dedupKey,
|
||||
}) {
|
||||
if (_isDisposed || !SCGlobalConfig.isLowPerformanceDevice) {
|
||||
if (_isDisposed ||
|
||||
_isSuppressed ||
|
||||
!SCGlobalConfig.isLowPerformanceDevice ||
|
||||
SCGlobalConfig.isFloatingBroadcastClosed) {
|
||||
return;
|
||||
}
|
||||
final key = dedupKey ?? _compensationKeyFor(message);
|
||||
@ -83,7 +97,7 @@ class OverlayManager {
|
||||
SCRoomEffectScheduler().scheduleDeferredEffect(
|
||||
debugLabel: debugLabel,
|
||||
action: () {
|
||||
if (_isDisposed) {
|
||||
if (_isDisposed || _isSuppressed) {
|
||||
return;
|
||||
}
|
||||
_messageQueue.add(message);
|
||||
@ -113,6 +127,47 @@ class OverlayManager {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isRecentFloatingDuplicate(SCFloatingMessage message) {
|
||||
_pruneRecentFloatingKeys();
|
||||
final key = _floatingDedupKeyFor(message);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
final recentTime = _recentFloatingKeys[key];
|
||||
if (recentTime == null) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(recentTime) <
|
||||
_redPacketFloatingDedupWindow;
|
||||
}
|
||||
|
||||
void _rememberFloatingMessage(SCFloatingMessage message) {
|
||||
final key = _floatingDedupKeyFor(message);
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
_recentFloatingKeys[key] = DateTime.now();
|
||||
_pruneRecentFloatingKeys();
|
||||
}
|
||||
|
||||
void _pruneRecentFloatingKeys() {
|
||||
final now = DateTime.now();
|
||||
_recentFloatingKeys.removeWhere(
|
||||
(_, time) => now.difference(time) > _redPacketFloatingDedupWindow,
|
||||
);
|
||||
}
|
||||
|
||||
String? _floatingDedupKeyFor(SCFloatingMessage message) {
|
||||
if (message.type != 4) {
|
||||
return null;
|
||||
}
|
||||
final packetId = message.toUserId?.trim() ?? '';
|
||||
if (packetId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return 'room_red_packet|$packetId';
|
||||
}
|
||||
|
||||
String _compensationKeyFor(SCFloatingMessage message) {
|
||||
return [
|
||||
message.type,
|
||||
@ -178,6 +233,7 @@ class OverlayManager {
|
||||
}
|
||||
|
||||
void _safeScheduleNext() {
|
||||
if (_isSuppressed) return;
|
||||
if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return;
|
||||
_isProcessing = true;
|
||||
|
||||
@ -189,7 +245,9 @@ class OverlayManager {
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
if (_isPlaying || _messageQueue.isEmpty || _isDisposed) return;
|
||||
if (_isPlaying || _messageQueue.isEmpty || _isDisposed || _isSuppressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final context = navigatorKey.currentState?.context;
|
||||
@ -305,10 +363,95 @@ class OverlayManager {
|
||||
|
||||
void activate() {
|
||||
_isDisposed = false;
|
||||
_loadSavedDisplayPreference();
|
||||
}
|
||||
|
||||
void _loadSavedDisplayPreference() {
|
||||
final account = AccountStorage().getCurrentUser()?.userProfile?.account;
|
||||
final savedFloatingBroadcastScope = DataPersistence.getString(
|
||||
SCGlobalConfig.floatingBroadcastScopeStorageKey(account),
|
||||
);
|
||||
if (savedFloatingBroadcastScope.isNotEmpty) {
|
||||
final scope = SCGlobalConfig.clampFloatingBroadcastScope(
|
||||
savedFloatingBroadcastScope,
|
||||
);
|
||||
SCGlobalConfig.floatingBroadcastScope = scope;
|
||||
return;
|
||||
}
|
||||
final defaultEffectsEnabled = !SCGlobalConfig.isLowPerformanceDevice;
|
||||
final isLegacyFloatingAnimationInGlobal = DataPersistence.getBool(
|
||||
SCGlobalConfig.legacyFloatingAnimationInGlobalStorageKey(account),
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
);
|
||||
SCGlobalConfig.floatingBroadcastScope =
|
||||
SCGlobalConfig.clampVisualEffectPreference(
|
||||
isLegacyFloatingAnimationInGlobal,
|
||||
)
|
||||
? SCGlobalConfig.floatingBroadcastScopeAll
|
||||
: SCGlobalConfig.floatingBroadcastScopeOff;
|
||||
}
|
||||
|
||||
void setHomeRootTabsVisible(bool visible) {
|
||||
if (_homeRootTabsVisible == visible) {
|
||||
return;
|
||||
}
|
||||
_homeRootTabsVisible = visible;
|
||||
if (!visible) {
|
||||
_removeActiveRegionBroadcastMessage();
|
||||
_removeRegionBroadcastMessages();
|
||||
return;
|
||||
}
|
||||
_safeScheduleNext();
|
||||
}
|
||||
|
||||
void beginSuppressFloatingScreens({String reason = 'unknown'}) {
|
||||
_suppressedCount += 1;
|
||||
debugPrint(
|
||||
'[FloatingScreen] suppress begin reason=$reason count=$_suppressedCount',
|
||||
);
|
||||
_removeActiveMessage(scheduleNext: false);
|
||||
_messageQueue.clear();
|
||||
_isPlaying = false;
|
||||
_isProcessing = false;
|
||||
}
|
||||
|
||||
void endSuppressFloatingScreens({String reason = 'unknown'}) {
|
||||
if (_suppressedCount > 0) {
|
||||
_suppressedCount -= 1;
|
||||
}
|
||||
debugPrint(
|
||||
'[FloatingScreen] suppress end reason=$reason count=$_suppressedCount',
|
||||
);
|
||||
if (!_isSuppressed) {
|
||||
_safeScheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
void refreshDisplayPreference() {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
if (SCGlobalConfig.isFloatingBroadcastClosed) {
|
||||
_removeActiveMessage(scheduleNext: false);
|
||||
_messageQueue.clear();
|
||||
return;
|
||||
}
|
||||
final context = navigatorKey.currentState?.context;
|
||||
final activeMessage = _currentMessage;
|
||||
if (context != null &&
|
||||
context.mounted &&
|
||||
activeMessage != null &&
|
||||
!_shouldDisplayMessage(context, activeMessage)) {
|
||||
_removeActiveMessage();
|
||||
return;
|
||||
}
|
||||
_safeScheduleNext();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_homeRootTabsVisible = false;
|
||||
_suppressedCount = 0;
|
||||
_currentOverlayEntry?.remove();
|
||||
_currentOverlayEntry = null;
|
||||
_currentMessage = null;
|
||||
@ -352,14 +495,32 @@ class OverlayManager {
|
||||
|
||||
bool get isPlaying => _isPlaying;
|
||||
|
||||
bool get _isSuppressed => _suppressedCount > 0;
|
||||
|
||||
bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
if (SCGlobalConfig.isFloatingBroadcastClosed) {
|
||||
return false;
|
||||
}
|
||||
if (message.isRegionBroadcast) {
|
||||
return _shouldDisplayRegionBroadcastMessage(context, message);
|
||||
}
|
||||
if (SCGlobalConfig.isFloatingBroadcastRoomOnly) {
|
||||
return _isCurrentRoomFloatingMessage(context, message);
|
||||
}
|
||||
if (message.type != 0 && message.type != 1 && message.type != 5) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return _isCurrentRoomFloatingMessage(context, message);
|
||||
}
|
||||
|
||||
bool _isCurrentRoomFloatingMessage(
|
||||
BuildContext context,
|
||||
SCFloatingMessage message,
|
||||
) {
|
||||
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
|
||||
context,
|
||||
listen: false,
|
||||
@ -376,12 +537,58 @@ class OverlayManager {
|
||||
return currentRoomId == messageRoomId;
|
||||
}
|
||||
|
||||
void _removeActiveRoomMessage() {
|
||||
bool _shouldDisplayRegionBroadcastMessage(
|
||||
BuildContext context,
|
||||
SCFloatingMessage message,
|
||||
) {
|
||||
if (SCGlobalConfig.isFloatingBroadcastClosed) {
|
||||
return false;
|
||||
}
|
||||
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final currentRoomId =
|
||||
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
|
||||
final messageRoomId = (message.roomId ?? "").trim();
|
||||
final isCurrentVisibleRoom =
|
||||
rtcProvider.shouldShowRoomVisualEffects &&
|
||||
currentRoomId.isNotEmpty &&
|
||||
messageRoomId.isNotEmpty &&
|
||||
currentRoomId == messageRoomId;
|
||||
if (isCurrentVisibleRoom) {
|
||||
// 区域红包在当前房间仍然需要展示;区域礼物当前房间由 RTM 层跳过,避免和房间 IM 礼物飘屏重复。
|
||||
return message.type == 4;
|
||||
}
|
||||
if (SCGlobalConfig.isFloatingBroadcastRoomOnly) {
|
||||
return false;
|
||||
}
|
||||
if (rtcProvider.shouldShowRoomVisualEffects) {
|
||||
return false;
|
||||
}
|
||||
return SCGlobalConfig.isFloatingBroadcastAll && _homeRootTabsVisible;
|
||||
}
|
||||
|
||||
void _removeRegionBroadcastMessages() {
|
||||
final newQueue = SCPriorityQueue<SCFloatingMessage>(
|
||||
(a, b) => b.priority.compareTo(a.priority),
|
||||
);
|
||||
|
||||
while (_messageQueue.isNotEmpty) {
|
||||
final message = _messageQueue.removeFirst();
|
||||
if (!message.isRegionBroadcast) {
|
||||
newQueue.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
while (newQueue.isNotEmpty) {
|
||||
_messageQueue.add(newQueue.removeFirst());
|
||||
}
|
||||
}
|
||||
|
||||
void _removeActiveRegionBroadcastMessage() {
|
||||
final activeMessage = _currentMessage;
|
||||
if (activeMessage == null ||
|
||||
(activeMessage.type != 0 &&
|
||||
activeMessage.type != 1 &&
|
||||
activeMessage.type != 5)) {
|
||||
if (activeMessage == null || !activeMessage.isRegionBroadcast) {
|
||||
return;
|
||||
}
|
||||
_currentOverlayEntry?.remove();
|
||||
@ -390,4 +597,25 @@ class OverlayManager {
|
||||
_isPlaying = false;
|
||||
_safeScheduleNext();
|
||||
}
|
||||
|
||||
void _removeActiveRoomMessage() {
|
||||
final activeMessage = _currentMessage;
|
||||
if (activeMessage == null ||
|
||||
(activeMessage.type != 0 &&
|
||||
activeMessage.type != 1 &&
|
||||
activeMessage.type != 5)) {
|
||||
return;
|
||||
}
|
||||
_removeActiveMessage();
|
||||
}
|
||||
|
||||
void _removeActiveMessage({bool scheduleNext = true}) {
|
||||
_currentOverlayEntry?.remove();
|
||||
_currentOverlayEntry = null;
|
||||
_currentMessage = null;
|
||||
_isPlaying = false;
|
||||
if (scheduleNext) {
|
||||
_safeScheduleNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,12 +55,12 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
///app/popups/entry
|
||||
///go/app/popups/entry
|
||||
@override
|
||||
Future<SCEntryPopupRes> entryPopup() async {
|
||||
final deviceId = await SCDeviceIdUtils.getDeviceId();
|
||||
final result = await http.post<SCEntryPopupRes>(
|
||||
"/app/popups/entry",
|
||||
"/go/app/popups/entry",
|
||||
data: {
|
||||
"scene": "APP_ENTER",
|
||||
"platform": Platform.isIOS ? "ios" : "android",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:yumi/app/constants/sc_room_msg_type.dart';
|
||||
import 'package:yumi/shared/business_logic/models/req/sc_give_away_gift_room_acceptscmd.dart';
|
||||
@ -51,6 +53,86 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
debugPrint('[GiftFX][Repo] $message');
|
||||
}
|
||||
|
||||
Future<T> _traceRoomRedPacketApi<T>(
|
||||
String method,
|
||||
String path,
|
||||
Future<T> Function() request, {
|
||||
dynamic data,
|
||||
dynamic query,
|
||||
}) async {
|
||||
final startedAt = DateTime.now();
|
||||
debugPrint(
|
||||
'[RoomRedPacket][API] -> $method $path '
|
||||
'query=${_debugJson(query)} data=${_debugJson(data)}',
|
||||
);
|
||||
try {
|
||||
final result = await request();
|
||||
debugPrint(
|
||||
'[RoomRedPacket][API] <- $method $path '
|
||||
'cost=${DateTime.now().difference(startedAt).inMilliseconds}ms '
|
||||
'result=${_debugResult(result)}',
|
||||
);
|
||||
return result;
|
||||
} catch (error, stackTrace) {
|
||||
_logRoomRedPacketApiError(method, path, error, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _logRoomRedPacketApiError(
|
||||
String method,
|
||||
String path,
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
) {
|
||||
if (error is DioException) {
|
||||
final request = error.requestOptions;
|
||||
debugPrint(
|
||||
'[RoomRedPacket][API] !! $method $path '
|
||||
'resolved=${request.method} ${request.uri} '
|
||||
'status=${error.response?.statusCode} '
|
||||
'type=${error.type} message=${error.message} '
|
||||
'error=${error.error} response=${_debugJson(error.response?.data)}',
|
||||
);
|
||||
} else {
|
||||
debugPrint('[RoomRedPacket][API] !! $method $path error=$error');
|
||||
}
|
||||
debugPrint(
|
||||
'[RoomRedPacket][API] stack=${stackTrace.toString().split('\n').take(8).join(' | ')}',
|
||||
);
|
||||
}
|
||||
|
||||
String _debugJson(dynamic value) {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return jsonEncode(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _debugResult(dynamic value) {
|
||||
if (value == null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value is List) {
|
||||
return 'List(length=${value.length})';
|
||||
}
|
||||
try {
|
||||
final dynamic json = value.toJson();
|
||||
return '${value.runtimeType} ${_debugJson(json)}';
|
||||
} catch (_) {
|
||||
return value.runtimeType.toString();
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _jsonInt64(String value) {
|
||||
final normalized = value.trim();
|
||||
return int.tryParse(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
bool _isBrokenLocalMediaUrl(String? url) {
|
||||
return (url ?? "").contains("/external/oss/local/");
|
||||
}
|
||||
@ -780,16 +862,22 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
parm["roomId"] = roomId;
|
||||
parm["current"] = current;
|
||||
parm["size"] = size;
|
||||
final result = await http.get<List<SCRoomRedPacketListRes>>(
|
||||
"318de84592dca60c287ba2f54d60756abc4a9e8c4704c0f73c085e7ab50e7fdb",
|
||||
queryParams: parm,
|
||||
fromJson:
|
||||
(json) =>
|
||||
(json as List)
|
||||
.map((e) => SCRoomRedPacketListRes.fromJson(e))
|
||||
.toList(),
|
||||
const path =
|
||||
"318de84592dca60c287ba2f54d60756abc4a9e8c4704c0f73c085e7ab50e7fdb";
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<List<SCRoomRedPacketListRes>>(
|
||||
path,
|
||||
queryParams: parm,
|
||||
fromJson:
|
||||
(json) =>
|
||||
(json as List)
|
||||
.map((e) => SCRoomRedPacketListRes.fromJson(e))
|
||||
.toList(),
|
||||
),
|
||||
query: parm,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
///room/red-packet/create
|
||||
@ -804,17 +892,22 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Map<String, dynamic> parm = {};
|
||||
parm["requestId"] =
|
||||
"room-red-packet-${DateTime.now().microsecondsSinceEpoch}";
|
||||
parm["roomId"] = roomId;
|
||||
parm["roomId"] = _jsonInt64(roomId);
|
||||
parm["packetMode"] = packetMode;
|
||||
parm["totalAmount"] = totalAmount;
|
||||
parm["totalCount"] = totalCount;
|
||||
final result = await http.post<SCRoomRedPacketListRes>(
|
||||
"/app/voice-room/red-packet/send",
|
||||
parm["totalAmount"] = _jsonInt64(totalAmount);
|
||||
parm["totalCount"] = _jsonInt64(totalCount);
|
||||
const path = "/go/app/voice-room/red-packet/send";
|
||||
return _traceRoomRedPacketApi(
|
||||
'POST',
|
||||
path,
|
||||
() => http.post<SCRoomRedPacketListRes>(
|
||||
path,
|
||||
data: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketListRes.fromJson(json),
|
||||
),
|
||||
data: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketListRes.fromJson(json),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
///room/red-packet/grab
|
||||
@ -822,13 +915,18 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Future<SCRoomRedPacketGrabRes> roomRedPacketGrab(String packetId) async {
|
||||
Map<String, dynamic> parm = {};
|
||||
parm["packetNo"] = packetId;
|
||||
final result = await http.post<SCRoomRedPacketGrabRes>(
|
||||
"/app/voice-room/red-packet/claim",
|
||||
const path = "/go/app/voice-room/red-packet/claim";
|
||||
return _traceRoomRedPacketApi(
|
||||
'POST',
|
||||
path,
|
||||
() => http.post<SCRoomRedPacketGrabRes>(
|
||||
path,
|
||||
data: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketGrabRes.fromJson(json),
|
||||
),
|
||||
data: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketGrabRes.fromJson(json),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
///room/red-packet/detail
|
||||
@ -836,30 +934,45 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Future<SCRoomRedPacketDetailRes> roomRedPacketDetail(String packetId) async {
|
||||
Map<String, dynamic> parm = {};
|
||||
parm["packetNo"] = packetId;
|
||||
final result = await http.get<SCRoomRedPacketDetailRes>(
|
||||
"/app/voice-room/red-packet/detail",
|
||||
queryParams: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketDetailRes.fromJson(json),
|
||||
const path = "/go/app/voice-room/red-packet/detail";
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<SCRoomRedPacketDetailRes>(
|
||||
path,
|
||||
queryParams: parm,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketDetailRes.fromJson(json),
|
||||
),
|
||||
query: parm,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SCRoomRedPacketConfigRes> roomRedPacketConfig() async {
|
||||
return http.get<SCRoomRedPacketConfigRes>(
|
||||
"/app/voice-room/red-packet/config",
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketConfigRes.fromJson(json),
|
||||
const path = "/go/app/voice-room/red-packet/config";
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<SCRoomRedPacketConfigRes>(
|
||||
path,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketConfigRes.fromJson(json),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SCRoomRedPacketImGroupRes> roomRedPacketImGroup() async {
|
||||
return http.get<SCRoomRedPacketImGroupRes>(
|
||||
"/app/voice-room/red-packet/im-group",
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketImGroupRes.fromJson(json),
|
||||
const path = "/go/app/voice-room/red-packet/im-group";
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<SCRoomRedPacketImGroupRes>(
|
||||
path,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketImGroupRes.fromJson(json),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -867,11 +980,18 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Future<SCRoomRedPacketPresenceRes> roomRedPacketPresenceHeartbeat(
|
||||
String roomId,
|
||||
) async {
|
||||
return http.post<SCRoomRedPacketPresenceRes>(
|
||||
"/app/voice-room/red-packet/presence/heartbeat",
|
||||
data: {"roomId": roomId},
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json),
|
||||
const path = "/go/app/voice-room/red-packet/presence/heartbeat";
|
||||
final data = {"roomId": _jsonInt64(roomId)};
|
||||
return _traceRoomRedPacketApi(
|
||||
'POST',
|
||||
path,
|
||||
() => http.post<SCRoomRedPacketPresenceRes>(
|
||||
path,
|
||||
data: data,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json),
|
||||
),
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
@ -879,11 +999,18 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Future<SCRoomRedPacketPresenceRes> roomRedPacketPresenceLeave(
|
||||
String roomId,
|
||||
) async {
|
||||
return http.post<SCRoomRedPacketPresenceRes>(
|
||||
"/app/voice-room/red-packet/presence/leave",
|
||||
data: {"roomId": roomId},
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json),
|
||||
const path = "/go/app/voice-room/red-packet/presence/leave";
|
||||
final data = {"roomId": _jsonInt64(roomId)};
|
||||
return _traceRoomRedPacketApi(
|
||||
'POST',
|
||||
path,
|
||||
() => http.post<SCRoomRedPacketPresenceRes>(
|
||||
path,
|
||||
data: data,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json),
|
||||
),
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
@ -892,15 +1019,41 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
int cursor = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
return http.get<List<SCRoomRedPacketSentRecordRes>>(
|
||||
"/app/voice-room/red-packet/sent-records",
|
||||
queryParams: {"cursor": cursor, "limit": limit},
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson:
|
||||
(json) =>
|
||||
_redPacketRecordListBody(
|
||||
json,
|
||||
).map((e) => SCRoomRedPacketSentRecordRes.fromJson(e)).toList(),
|
||||
const path = "/go/app/voice-room/red-packet/sent-records";
|
||||
final query = {"cursor": cursor, "limit": limit};
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<List<SCRoomRedPacketSentRecordRes>>(
|
||||
path,
|
||||
queryParams: query,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson:
|
||||
(json) =>
|
||||
_redPacketRecordListBody(
|
||||
json,
|
||||
).map((e) => SCRoomRedPacketSentRecordRes.fromJson(e)).toList(),
|
||||
),
|
||||
query: query,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SCRoomRedPacketClaimRecordsRes> roomRedPacketClaimRecords(
|
||||
String packetNo,
|
||||
) async {
|
||||
const path = "/go/app/voice-room/red-packet/claim-records";
|
||||
final query = {"packetNo": packetNo};
|
||||
return _traceRoomRedPacketApi(
|
||||
'GET',
|
||||
path,
|
||||
() => http.get<SCRoomRedPacketClaimRecordsRes>(
|
||||
path,
|
||||
queryParams: query,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCRoomRedPacketClaimRecordsRes.fromJson(json),
|
||||
),
|
||||
query: query,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -964,11 +964,11 @@ class SCAccountRepository implements SocialChatUserRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
///app/task-center/list
|
||||
///go/app/task-center/list
|
||||
@override
|
||||
Future<List<SCTaskListRes>> tasks() async {
|
||||
try {
|
||||
final tasks = await _taskList("/app/task-center/list", source: "new");
|
||||
final tasks = await _taskList("/go/app/task-center/list", source: "new");
|
||||
if (kDebugMode && tasks.isEmpty) {
|
||||
await _debugProbeLegacyTaskList();
|
||||
}
|
||||
@ -1026,13 +1026,13 @@ class SCAccountRepository implements SocialChatUserRepository {
|
||||
return "sample=$sample";
|
||||
}
|
||||
|
||||
///app/task-center/claim
|
||||
///go/app/task-center/claim
|
||||
@override
|
||||
Future<bool> taskReward(String taskId) async {
|
||||
try {
|
||||
debugPrint("[TaskCenter][Repo] claim request source=new taskId=$taskId");
|
||||
final result = await http.post<bool>(
|
||||
"/app/task-center/claim",
|
||||
"/go/app/task-center/claim",
|
||||
data: {"taskId": taskId},
|
||||
extra: const {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => json as bool,
|
||||
@ -1061,14 +1061,14 @@ class SCAccountRepository implements SocialChatUserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
///app/task-center/claimable-count
|
||||
///go/app/task-center/claimable-count
|
||||
@override
|
||||
Future<int> taskClaimableCount() async {
|
||||
try {
|
||||
return await _taskClaimableCount("/app/task-center/claimable-count");
|
||||
return await _taskClaimableCount("/go/app/task-center/claimable-count");
|
||||
} catch (error) {
|
||||
_debugTaskError("new count", error);
|
||||
return _taskClaimableCount("/app/v2/task/claimable-count");
|
||||
return _taskClaimableCount("/go/app/v2/task/claimable-count");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -143,16 +143,6 @@ class SCVipRepositoryImp implements SocialChatVipRepository {
|
||||
_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';
|
||||
}
|
||||
|
||||
|
||||
@ -130,20 +130,50 @@ class SCEntryPopupCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
final jumpType = popup.jumpType.trim().toUpperCase();
|
||||
if (jumpType.isEmpty || jumpType == 'NOOP') {
|
||||
await openConfiguredJump(
|
||||
context,
|
||||
jumpType: popup.jumpType,
|
||||
jumpUrl: popup.jumpUrl,
|
||||
onSelectRootTab: onSelectRootTab,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> openConfiguredJump(
|
||||
BuildContext context, {
|
||||
required String jumpType,
|
||||
required String jumpUrl,
|
||||
SCEntryPopupRootTabSelector? onSelectRootTab,
|
||||
}) async {
|
||||
final resolvedJumpUrl = jumpUrl.trim();
|
||||
if (resolvedJumpUrl.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (jumpType == 'H5') {
|
||||
await _openH5(context, popup.jumpUrl);
|
||||
|
||||
final normalizedJumpType = jumpType.trim().toUpperCase();
|
||||
if (normalizedJumpType.isEmpty) {
|
||||
final uri = Uri.tryParse(resolvedJumpUrl);
|
||||
if (uri?.hasScheme == true) {
|
||||
await _openH5(context, resolvedJumpUrl);
|
||||
return;
|
||||
}
|
||||
await _openAppRoute(context, resolvedJumpUrl, onSelectRootTab);
|
||||
return;
|
||||
}
|
||||
if (jumpType == 'GAME') {
|
||||
await _openGameCenter(context, popup.jumpUrl);
|
||||
|
||||
final resolvedJumpType = normalizedJumpType;
|
||||
if (resolvedJumpType == 'NOOP') {
|
||||
return;
|
||||
}
|
||||
if (jumpType == 'APP_ROUTE') {
|
||||
await _openAppRoute(context, popup.jumpUrl, onSelectRootTab);
|
||||
if (resolvedJumpType == 'H5') {
|
||||
await _openH5(context, resolvedJumpUrl);
|
||||
return;
|
||||
}
|
||||
if (resolvedJumpType == 'GAME') {
|
||||
await _openGameCenter(context, resolvedJumpUrl);
|
||||
return;
|
||||
}
|
||||
if (resolvedJumpType == 'APP_ROUTE') {
|
||||
await _openAppRoute(context, resolvedJumpUrl, onSelectRootTab);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -233,31 +233,45 @@ class SCChatRoomHelper {
|
||||
|
||||
static void roomSCGlobalConfig(String roomId) {
|
||||
final defaultEffectsEnabled = !SCGlobalConfig.isLowPerformanceDevice;
|
||||
final account = AccountStorage().getCurrentUser()?.userProfile?.account;
|
||||
SCGlobalConfig
|
||||
.isGiftSpecialEffects = SCGlobalConfig.clampVisualEffectPreference(
|
||||
DataPersistence.getBool(
|
||||
"${AccountStorage().getCurrentUser()?.userProfile?.account}-GiftSpecialEffects",
|
||||
"$account-GiftSpecialEffects",
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
),
|
||||
);
|
||||
SCGlobalConfig
|
||||
.isEntryVehicleAnimation = SCGlobalConfig.clampVisualEffectPreference(
|
||||
DataPersistence.getBool(
|
||||
"${AccountStorage().getCurrentUser()?.userProfile?.account}-EntryVehicleAnimation",
|
||||
"$account-EntryVehicleAnimation",
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
),
|
||||
);
|
||||
SCGlobalConfig
|
||||
.isFloatingAnimationInGlobal = SCGlobalConfig.clampVisualEffectPreference(
|
||||
DataPersistence.getBool(
|
||||
"${AccountStorage().getCurrentUser()?.userProfile?.account}-FloatingAnimationInGlobal",
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
),
|
||||
final savedFloatingBroadcastScope = DataPersistence.getString(
|
||||
SCGlobalConfig.floatingBroadcastScopeStorageKey(account),
|
||||
);
|
||||
if (savedFloatingBroadcastScope.isNotEmpty) {
|
||||
final scope = SCGlobalConfig.clampFloatingBroadcastScope(
|
||||
savedFloatingBroadcastScope,
|
||||
);
|
||||
SCGlobalConfig.floatingBroadcastScope = scope;
|
||||
} else {
|
||||
final isLegacyFloatingAnimationInGlobal = DataPersistence.getBool(
|
||||
SCGlobalConfig.legacyFloatingAnimationInGlobalStorageKey(account),
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
);
|
||||
SCGlobalConfig.floatingBroadcastScope =
|
||||
SCGlobalConfig.clampVisualEffectPreference(
|
||||
isLegacyFloatingAnimationInGlobal,
|
||||
)
|
||||
? SCGlobalConfig.floatingBroadcastScopeAll
|
||||
: SCGlobalConfig.floatingBroadcastScopeOff;
|
||||
}
|
||||
SCGlobalConfig
|
||||
.isLuckGiftSpecialEffects = SCGlobalConfig.clampVisualEffectPreference(
|
||||
DataPersistence.getBool(
|
||||
"${AccountStorage().getCurrentUser()?.userProfile?.account}-LuckGiftSpecialEffects",
|
||||
"$account-LuckGiftSpecialEffects",
|
||||
defaultValue: defaultEffectsEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
@ -22,9 +22,9 @@ Offset kDefaultFloatOffset = Offset(
|
||||
|
||||
class SCFloatIchart {
|
||||
/// 单例模式
|
||||
static final SCFloatIchart _SCFloatIchart = SCFloatIchart._internal(); //1
|
||||
static final SCFloatIchart _scFloatIchart = SCFloatIchart._internal(); //1
|
||||
factory SCFloatIchart() {
|
||||
return _SCFloatIchart;
|
||||
return _scFloatIchart;
|
||||
}
|
||||
|
||||
SCFloatIchart._internal();
|
||||
@ -58,11 +58,11 @@ class SCFloatIchart {
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (overlayEntry != null && overlayEntry!.mounted) {
|
||||
if (_inserted) {
|
||||
overlayEntry?.remove();
|
||||
_inserted = false;
|
||||
}
|
||||
final entry = overlayEntry;
|
||||
overlayEntry = null;
|
||||
_inserted = false;
|
||||
if (entry != null && entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,8 +112,8 @@ class SCFloatIchart {
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
child: float(),
|
||||
margin: EdgeInsets.only(bottom: height(kToolbarHeight)),
|
||||
child: float(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -144,11 +144,15 @@ class FloatRoomWindow extends StatefulWidget {
|
||||
final SocialChatLoginRes? user;
|
||||
final Function remove;
|
||||
|
||||
const FloatRoomWindow({Key? key, this.room, this.user, required this.remove})
|
||||
: super(key: key);
|
||||
const FloatRoomWindow({
|
||||
super.key,
|
||||
this.room,
|
||||
this.user,
|
||||
required this.remove,
|
||||
});
|
||||
|
||||
@override
|
||||
_FloatRoomWindowState createState() => _FloatRoomWindowState();
|
||||
State<FloatRoomWindow> createState() => _FloatRoomWindowState();
|
||||
}
|
||||
|
||||
class _FloatRoomWindowState extends State<FloatRoomWindow> {
|
||||
|
||||
@ -22,12 +22,14 @@ class DailySignInDialogItem {
|
||||
required this.isFinalDay,
|
||||
this.subtitle = '',
|
||||
this.cover = '',
|
||||
this.isCoinReward = false,
|
||||
});
|
||||
|
||||
final int day;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String cover;
|
||||
final bool isCoinReward;
|
||||
final DailySignInRewardStatus status;
|
||||
final bool isFinalDay;
|
||||
|
||||
@ -38,6 +40,7 @@ class DailySignInDialogItem {
|
||||
String? title,
|
||||
String? subtitle,
|
||||
String? cover,
|
||||
bool? isCoinReward,
|
||||
DailySignInRewardStatus? status,
|
||||
bool? isFinalDay,
|
||||
}) {
|
||||
@ -46,6 +49,7 @@ class DailySignInDialogItem {
|
||||
title: title ?? this.title,
|
||||
subtitle: subtitle ?? this.subtitle,
|
||||
cover: cover ?? this.cover,
|
||||
isCoinReward: isCoinReward ?? this.isCoinReward,
|
||||
status: status ?? this.status,
|
||||
isFinalDay: isFinalDay ?? this.isFinalDay,
|
||||
);
|
||||
@ -147,6 +151,7 @@ class DailySignInDialogData {
|
||||
title: _resolveTitle(rewardDay),
|
||||
subtitle: _resolveSubtitle(rewardDay),
|
||||
cover: _resolveCover(rewardDay),
|
||||
isCoinReward: _isCoinRewardDay(rewardDay),
|
||||
status: status,
|
||||
isFinalDay: day == 7,
|
||||
);
|
||||
@ -211,8 +216,17 @@ class DailySignInDialogData {
|
||||
}
|
||||
|
||||
static String _resolveCover(SCSignInRewardDay? rewardDay) {
|
||||
if (_isCoinRewardDay(rewardDay)) {
|
||||
return _DailySignInAssets.yumiCoin;
|
||||
}
|
||||
return _firstRewardItem(rewardDay)?.cover.trim() ?? '';
|
||||
}
|
||||
|
||||
static bool _isCoinRewardDay(SCSignInRewardDay? rewardDay) {
|
||||
final rewardItem = _firstRewardItem(rewardDay);
|
||||
final type = rewardItem?.type.trim().toUpperCase() ?? '';
|
||||
return type == 'GOLD' || type == 'COIN' || type == 'COINS';
|
||||
}
|
||||
}
|
||||
|
||||
class DailySignInDialog extends StatefulWidget {
|
||||
@ -711,7 +725,10 @@ class _RewardCover extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = item.isFinalDay ? 30 * scale : 28 * scale;
|
||||
final size =
|
||||
item.isCoinReward
|
||||
? (item.isFinalDay ? 36 * scale : 34 * scale)
|
||||
: (item.isFinalDay ? 30 * scale : 28 * scale);
|
||||
final cover = item.cover;
|
||||
final borderColor =
|
||||
item.status == DailySignInRewardStatus.current
|
||||
@ -719,7 +736,14 @@ class _RewardCover extends StatelessWidget {
|
||||
: const Color(0xFFB58E43);
|
||||
|
||||
Widget child;
|
||||
if (cover.startsWith('http://') || cover.startsWith('https://')) {
|
||||
if (item.isCoinReward) {
|
||||
child = Image.asset(
|
||||
_DailySignInAssets.yumiCoin,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
} else if (cover.startsWith('http://') || cover.startsWith('https://')) {
|
||||
child = CachedNetworkImage(
|
||||
imageUrl: cover,
|
||||
width: size,
|
||||
@ -740,6 +764,10 @@ class _RewardCover extends StatelessWidget {
|
||||
child = _placeholder(size, borderColor);
|
||||
}
|
||||
|
||||
if (item.isCoinReward) {
|
||||
return SizedBox(width: size, height: size, child: child);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@ -794,4 +822,5 @@ class _DailySignInAssets {
|
||||
'sc_images/daily_sign_in/reward_final_bg.png';
|
||||
static const String rewardPendingBackground =
|
||||
'sc_images/daily_sign_in/reward_pending_bg.png';
|
||||
static const String yumiCoin = 'sc_images/register_reward/yumi_coin.png';
|
||||
}
|
||||
|
||||
@ -266,34 +266,11 @@ class _RegisterRewardItemCard extends StatelessWidget {
|
||||
height: 88 * scale,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
Container(
|
||||
width: 44 * scale,
|
||||
height: 44 * scale,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.10),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
width: 1.2 * scale,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: 0.10),
|
||||
blurRadius: 8 * scale,
|
||||
offset: Offset.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 18 * scale,
|
||||
height: 18 * scale,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
),
|
||||
Image.asset(
|
||||
_RegisterRewardAssets.yumiCoin,
|
||||
width: 58 * scale,
|
||||
height: 58 * scale,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -319,5 +296,6 @@ class _RegisterRewardAssets {
|
||||
'sc_images/register_reward/dialog_background.png';
|
||||
static const String goldGlowBackground =
|
||||
'sc_images/register_reward/gold_glow_background.png';
|
||||
static const String yumiCoin = 'sc_images/register_reward/yumi_coin.png';
|
||||
static const String closeIcon = 'sc_images/register_reward/close_icon.png';
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart';
|
||||
|
||||
class FloatingRoomRedenvelopeScreenWidget extends StatefulWidget {
|
||||
final SCFloatingMessage message;
|
||||
@ -278,6 +279,7 @@ class _FloatingRoomRedenvelopeScreenWidgetState
|
||||
if (roomId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
RoomRedPacketPendingCache.instance.upsertFloatingMessage(widget.message);
|
||||
final currentContext = navigatorKey.currentState?.context ?? context;
|
||||
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
|
||||
currentContext,
|
||||
@ -299,7 +301,9 @@ class _FloatingRoomRedenvelopeScreenWidgetState
|
||||
final detail = await SCChatRoomRepository().roomRedPacketDetail(
|
||||
fallbackData.packetId,
|
||||
);
|
||||
dialogData = RoomRedPacketUiData.fromJson(detail.toJson());
|
||||
dialogData = RoomRedPacketUiData.fromJson(
|
||||
detail.toJson(),
|
||||
).withFallbackProfile(fallbackData);
|
||||
} catch (_) {}
|
||||
if (mounted) {
|
||||
final showContext = navigatorKey.currentState?.context;
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_error.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart';
|
||||
|
||||
@ -17,7 +20,7 @@ class RoomRedPacketChatMessage extends StatelessWidget {
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: SCDebounceWidget(
|
||||
debounceTime: const Duration(milliseconds: 220),
|
||||
onTap: () => RoomRedPacketOpenDialog.show(context, data),
|
||||
onTap: () => _openRedPacket(context),
|
||||
child: Container(
|
||||
width: 300.w,
|
||||
constraints: BoxConstraints(minHeight: 78.w),
|
||||
@ -67,4 +70,63 @@ class RoomRedPacketChatMessage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openRedPacket(BuildContext context) async {
|
||||
var dialogData = RoomRedPacketOpenDialog.withLocalClaimState(data);
|
||||
var alreadyClaimed = _isClaimed(dialogData);
|
||||
|
||||
if (!alreadyClaimed &&
|
||||
data.hasPacketId &&
|
||||
!RoomRedPacketOpenDialog.isClaimedLocally(data.packetId) &&
|
||||
!_isLocalPreviewPacket(data.packetId)) {
|
||||
try {
|
||||
final detail = await SCChatRoomRepository().roomRedPacketDetail(
|
||||
data.packetId,
|
||||
);
|
||||
dialogData = RoomRedPacketOpenDialog.withLocalClaimState(
|
||||
RoomRedPacketUiData.fromJson(
|
||||
detail.toJson(),
|
||||
).withFallbackProfile(data),
|
||||
);
|
||||
alreadyClaimed = _isClaimed(dialogData);
|
||||
if (alreadyClaimed) {
|
||||
RoomRedPacketOpenDialog.rememberClaimed(
|
||||
dialogData.packetId,
|
||||
dialogData.claimedAmount,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (_isAlreadyClaimedError(error)) {
|
||||
RoomRedPacketOpenDialog.rememberClaimed(data.packetId, 0);
|
||||
}
|
||||
dialogData = RoomRedPacketOpenDialog.withLocalClaimState(data);
|
||||
alreadyClaimed = _isClaimed(dialogData);
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (alreadyClaimed) {
|
||||
SCTts.show(SCAppLocalizations.of(context)!.roomRedPacketAlreadyClaimed);
|
||||
}
|
||||
RoomRedPacketOpenDialog.show(context, dialogData);
|
||||
}
|
||||
|
||||
bool _isClaimed(RoomRedPacketUiData item) {
|
||||
return item.grabbed || item.claimedAmount > 0;
|
||||
}
|
||||
|
||||
bool _isAlreadyClaimedError(Object error) {
|
||||
final code = roomRedPacketErrorCode(error);
|
||||
return code == 'voice_room_red_packet_already_grabbed' ||
|
||||
code == 'voice_room_red_packet_already_claimed';
|
||||
}
|
||||
|
||||
bool _isLocalPreviewPacket(String packetId) {
|
||||
final normalizedPacketId = packetId.trim();
|
||||
return normalizedPacketId.isEmpty ||
|
||||
normalizedPacketId.startsWith('mock-') ||
|
||||
normalizedPacketId.startsWith('test-');
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,360 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_sent_record_res.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_effect_guard.dart';
|
||||
|
||||
class RoomRedPacketClaimRecordPage extends StatefulWidget {
|
||||
const RoomRedPacketClaimRecordPage({super.key, required this.record});
|
||||
|
||||
final SCRoomRedPacketSentRecordRes record;
|
||||
|
||||
static Route<void> route({required SCRoomRedPacketSentRecordRes record}) {
|
||||
return PageRouteBuilder<void>(
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder:
|
||||
(_, __, ___) => ColoredBox(
|
||||
color: _RoomRedPacketClaimRecordPageState._backgroundColor,
|
||||
child: RoomRedPacketClaimRecordPage(record: record),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<RoomRedPacketClaimRecordPage> createState() =>
|
||||
_RoomRedPacketClaimRecordPageState();
|
||||
}
|
||||
|
||||
class _RoomRedPacketClaimRecordPageState
|
||||
extends State<RoomRedPacketClaimRecordPage> {
|
||||
static const Color _backgroundColor = Color(0xFF052821);
|
||||
final RoomRedPacketEffectGuard _effectGuard = RoomRedPacketEffectGuard(
|
||||
reason: 'room_red_packet_claim_record_page',
|
||||
);
|
||||
late List<_RoomRedPacketClaimItem> _items = widget.record.records
|
||||
.map(_RoomRedPacketClaimItem.fromSentRecord)
|
||||
.toList(growable: false);
|
||||
late bool _loading =
|
||||
_items.isEmpty && widget.record.packetNo.trim().isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_effectGuard.activate();
|
||||
_loadDetailRecords();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_effectGuard.release();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = SCAppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
backgroundColor: _backgroundColor,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_RoomRedPacketClaimRecordTopBar(
|
||||
title: localizations.roomRedPacketClaimRecordsTitle,
|
||||
),
|
||||
Expanded(
|
||||
child:
|
||||
_loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFFF4A3C),
|
||||
),
|
||||
)
|
||||
: _items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
localizations.noData,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsetsDirectional.fromSTEB(
|
||||
16.w,
|
||||
10.w,
|
||||
16.w,
|
||||
24.w,
|
||||
),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder:
|
||||
(_, __) => Divider(
|
||||
height: 1.w,
|
||||
thickness: 0.5.w,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return _RoomRedPacketClaimRow(item: _items[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadDetailRecords() async {
|
||||
final packetNo = widget.record.packetNo.trim();
|
||||
if (packetNo.isEmpty) {
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final claimRecords = await SCChatRoomRepository()
|
||||
.roomRedPacketClaimRecords(packetNo);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final recordItems = claimRecords.records
|
||||
.map(_RoomRedPacketClaimItem.fromSentRecord)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
if (recordItems.isNotEmpty || _items.isEmpty) {
|
||||
_items = recordItems;
|
||||
}
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
await _loadDetailRecordsFallback(packetNo);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDetailRecordsFallback(String packetNo) async {
|
||||
try {
|
||||
final detail = await SCChatRoomRepository().roomRedPacketDetail(packetNo);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final detailItems = (detail.records ?? const [])
|
||||
.map(_RoomRedPacketClaimItem.fromDetailRecord)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
if (detailItems.isNotEmpty || _items.isEmpty) {
|
||||
_items = detailItems;
|
||||
}
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketClaimRow extends StatelessWidget {
|
||||
const _RoomRedPacketClaimRow({required this.item});
|
||||
|
||||
final _RoomRedPacketClaimItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 58.w,
|
||||
child: Row(
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
_ClaimAvatar(url: item.avatar),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name.trim().isEmpty ? '--' : item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.w),
|
||||
Text(
|
||||
item.claimedAt,
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.45),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
_formatAmount(item.amount),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClaimAvatar extends StatelessWidget {
|
||||
const _ClaimAvatar({required this.url});
|
||||
|
||||
final String url;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipOval(
|
||||
child: Container(
|
||||
width: 34.w,
|
||||
height: 34.w,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
alignment: Alignment.center,
|
||||
child:
|
||||
url.trim().isNotEmpty
|
||||
? netImage(
|
||||
url: url,
|
||||
width: 34.w,
|
||||
height: 34.w,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Image.asset(
|
||||
'sc_images/room/red_packet/small_packet_icon.png',
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketClaimRecordTopBar extends StatelessWidget {
|
||||
const _RoomRedPacketClaimRecordTopBar({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textDirection = Directionality.of(context);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: SizedBox(
|
||||
width: 44.w,
|
||||
height: 52.w,
|
||||
child: Icon(
|
||||
textDirection == TextDirection.rtl
|
||||
? Icons.chevron_right
|
||||
: Icons.chevron_left,
|
||||
color: Colors.white,
|
||||
size: 31.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 58.w),
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketClaimItem {
|
||||
const _RoomRedPacketClaimItem({
|
||||
required this.name,
|
||||
required this.avatar,
|
||||
required this.amount,
|
||||
required this.claimedAt,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String avatar;
|
||||
final int amount;
|
||||
final String claimedAt;
|
||||
|
||||
factory _RoomRedPacketClaimItem.fromSentRecord(
|
||||
SCRoomRedPacketSentClaimRecordRes record,
|
||||
) {
|
||||
return _RoomRedPacketClaimItem(
|
||||
name: record.userName,
|
||||
avatar: record.userAvatar,
|
||||
amount: record.amount.round(),
|
||||
claimedAt: _formatHistoryTime(record.claimTime),
|
||||
);
|
||||
}
|
||||
|
||||
factory _RoomRedPacketClaimItem.fromDetailRecord(dynamic record) {
|
||||
return _RoomRedPacketClaimItem(
|
||||
name: record.userName ?? '',
|
||||
avatar: record.userAvatar ?? '',
|
||||
amount: (record.amount ?? 0).round(),
|
||||
claimedAt: _formatHistoryTime(record.grabTime ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatAmount(int value) {
|
||||
final raw = value.abs().toString();
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < raw.length; i++) {
|
||||
if (i > 0 && (raw.length - i) % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
buffer.write(raw[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _formatHistoryTime(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return raw;
|
||||
}
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} '
|
||||
'${two(parsed.hour)}:${two(parsed.minute)}:${two(parsed.second)}';
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
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';
|
||||
|
||||
class RoomRedPacketEffectGuard {
|
||||
RoomRedPacketEffectGuard({required this.reason});
|
||||
|
||||
static int _activeCount = 0;
|
||||
|
||||
final String reason;
|
||||
bool _active = false;
|
||||
|
||||
void activate() {
|
||||
if (_active) {
|
||||
return;
|
||||
}
|
||||
_active = true;
|
||||
_activeCount += 1;
|
||||
if (_activeCount != 1) {
|
||||
return;
|
||||
}
|
||||
OverlayManager().beginSuppressFloatingScreens(reason: reason);
|
||||
SCRoomEffectScheduler().clearDeferredTasks(reason: reason);
|
||||
SCGiftVapSvgaManager().stopPlayback();
|
||||
SCGiftVapSvgaManager().pauseAnim();
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (!_active) {
|
||||
return;
|
||||
}
|
||||
_active = false;
|
||||
if (_activeCount > 0) {
|
||||
_activeCount -= 1;
|
||||
}
|
||||
if (_activeCount != 0) {
|
||||
return;
|
||||
}
|
||||
OverlayManager().endSuppressFloatingScreens(reason: reason);
|
||||
SCGiftVapSvgaManager().resumeAnim();
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,9 @@ String roomRedPacketErrorMessage(BuildContext context, Object error) {
|
||||
return localizations.roomRedPacketOpenExpired;
|
||||
case 'voice_room_red_packet_finished':
|
||||
return localizations.roomRedPacketOpenSoldOut;
|
||||
case 'voice_room_red_packet_already_grabbed':
|
||||
case 'voice_room_red_packet_already_claimed':
|
||||
return localizations.roomRedPacketAlreadyClaimed;
|
||||
case 'voice_room_red_packet_daily_limit_exceeded':
|
||||
return localizations.roomRedPacketDailyLimitExceeded;
|
||||
case 'voice_room_red_packet_insufficient_balance':
|
||||
|
||||
@ -3,10 +3,25 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_sent_record_res.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_claim_record_page.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_effect_guard.dart';
|
||||
|
||||
class RoomRedPacketHistoryPage extends StatefulWidget {
|
||||
const RoomRedPacketHistoryPage({super.key});
|
||||
|
||||
static Route<void> route() {
|
||||
return PageRouteBuilder<void>(
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder:
|
||||
(_, __, ___) => const ColoredBox(
|
||||
color: _RoomRedPacketHistoryPageState._backgroundColor,
|
||||
child: RoomRedPacketHistoryPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<RoomRedPacketHistoryPage> createState() =>
|
||||
_RoomRedPacketHistoryPageState();
|
||||
@ -14,15 +29,25 @@ class RoomRedPacketHistoryPage extends StatefulWidget {
|
||||
|
||||
class _RoomRedPacketHistoryPageState extends State<RoomRedPacketHistoryPage> {
|
||||
static const Color _backgroundColor = Color(0xFF052821);
|
||||
final RoomRedPacketEffectGuard _effectGuard = RoomRedPacketEffectGuard(
|
||||
reason: 'room_red_packet_history_page',
|
||||
);
|
||||
bool _loading = true;
|
||||
List<_RoomRedPacketHistoryItem> _items = const <_RoomRedPacketHistoryItem>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_effectGuard.activate();
|
||||
_loadRecords();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_effectGuard.release();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = SCAppLocalizations.of(context)!;
|
||||
@ -54,17 +79,17 @@ class _RoomRedPacketHistoryPageState extends State<RoomRedPacketHistoryPage> {
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsetsDirectional.fromSTEB(
|
||||
20.w,
|
||||
16.w,
|
||||
10.w,
|
||||
20.w,
|
||||
16.w,
|
||||
24.w,
|
||||
),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder:
|
||||
(_, __) => Divider(
|
||||
height: 10.w,
|
||||
height: 1.w,
|
||||
thickness: 0.5.w,
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return _RoomRedPacketHistoryTile(item: _items[index]);
|
||||
@ -106,77 +131,111 @@ class _RoomRedPacketHistoryTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = SCAppLocalizations.of(context)!;
|
||||
final isRtl = Directionality.of(context) == TextDirection.rtl;
|
||||
return SizedBox(
|
||||
height: 48.w,
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'sc_images/room/red_packet/small_packet_icon.png',
|
||||
width: 30.w,
|
||||
height: 30.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
return SCDebounceWidget(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(RoomRedPacketClaimRecordPage.route(record: item.rawRecord));
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 58.w),
|
||||
child: Row(
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
Image.asset(
|
||||
'sc_images/room/red_packet/small_packet_icon.png',
|
||||
width: 36.w,
|
||||
height: 36.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizations.roomRedPacketHistoryItemTitle(
|
||||
item.count.toString(),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.w),
|
||||
Text(
|
||||
item.sentAt,
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.45),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment:
|
||||
isRtl ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
localizations.roomRedPacketHistoryItemTitle(
|
||||
item.count.toString(),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatSignedAmount(item.amount),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Icon(
|
||||
isRtl ? Icons.chevron_left : Icons.chevron_right,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
size: 16.w,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 3.w),
|
||||
Text(
|
||||
item.sentAt,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.45),
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
if (item.refundAmount > 0) ...[
|
||||
SizedBox(height: 3.w),
|
||||
Text(
|
||||
localizations.roomRedPacketHistoryRefund(
|
||||
_bidiSafeLtr(_formatAmount(item.refundAmount)),
|
||||
),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFFF332B),
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
SizedBox(height: 3.w),
|
||||
Text(
|
||||
_statusText(localizations, item.status),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: _statusColor(item.status),
|
||||
fontSize: 8.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment:
|
||||
isRtl ? CrossAxisAlignment.start : CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatSignedAmount(item.amount),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.w),
|
||||
Text(
|
||||
_statusText(localizations, item.status),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: _statusColor(item.status),
|
||||
fontSize: 8.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -208,15 +267,7 @@ class _RoomRedPacketHistoryTile extends StatelessWidget {
|
||||
|
||||
String _formatSignedAmount(int value) {
|
||||
final sign = value < 0 ? '-' : '+';
|
||||
final raw = value.abs().toString();
|
||||
final buffer = StringBuffer(sign);
|
||||
for (int i = 0; i < raw.length; i++) {
|
||||
if (i > 0 && (raw.length - i) % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
buffer.write(raw[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
return '$sign${_formatAmount(value.abs())}';
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,22 +280,28 @@ class _RoomRedPacketHistoryTopBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final textDirection = Directionality.of(context);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: 6.w,
|
||||
start: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(
|
||||
textDirection == TextDirection.rtl
|
||||
? Icons.chevron_right
|
||||
: Icons.chevron_left,
|
||||
color: Colors.white,
|
||||
size: 28.w,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: SizedBox(
|
||||
width: 44.w,
|
||||
height: 52.w,
|
||||
child: Icon(
|
||||
textDirection == TextDirection.rtl
|
||||
? Icons.chevron_right
|
||||
: Icons.chevron_left,
|
||||
color: Colors.white,
|
||||
size: 31.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -272,23 +329,35 @@ class _RoomRedPacketHistoryItem {
|
||||
const _RoomRedPacketHistoryItem({
|
||||
required this.count,
|
||||
required this.amount,
|
||||
required this.refundAmount,
|
||||
required this.sentAt,
|
||||
required this.status,
|
||||
required this.rawRecord,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final int amount;
|
||||
final int refundAmount;
|
||||
final String sentAt;
|
||||
final _RoomRedPacketHistoryStatus status;
|
||||
final SCRoomRedPacketSentRecordRes rawRecord;
|
||||
|
||||
factory _RoomRedPacketHistoryItem.fromRecord(
|
||||
SCRoomRedPacketSentRecordRes record,
|
||||
) {
|
||||
final status = _statusFromRecord(record);
|
||||
return _RoomRedPacketHistoryItem(
|
||||
count: record.totalCount.round(),
|
||||
amount: -record.totalAmount.round(),
|
||||
sentAt: _formatTime(record.createTime),
|
||||
status: _statusFromRecord(record),
|
||||
refundAmount:
|
||||
record.refundAmount > 0
|
||||
? record.refundAmount.round()
|
||||
: status == _RoomRedPacketHistoryStatus.returned
|
||||
? record.remainAmount.round()
|
||||
: 0,
|
||||
sentAt: _formatHistoryTime(record.createTime),
|
||||
status: status,
|
||||
rawRecord: record,
|
||||
);
|
||||
}
|
||||
|
||||
@ -304,16 +373,30 @@ class _RoomRedPacketHistoryItem {
|
||||
}
|
||||
return _RoomRedPacketHistoryStatus.sent;
|
||||
}
|
||||
|
||||
static String _formatTime(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return raw;
|
||||
}
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} '
|
||||
'${two(parsed.hour)}:${two(parsed.minute)}:${two(parsed.second)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatAmount(int value) {
|
||||
final raw = value.abs().toString();
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < raw.length; i++) {
|
||||
if (i > 0 && (raw.length - i) % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
buffer.write(raw[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _formatHistoryTime(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return raw;
|
||||
}
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} '
|
||||
'${two(parsed.hour)}:${two(parsed.minute)}:${two(parsed.second)}';
|
||||
}
|
||||
|
||||
String _bidiSafeLtr(String value) => '\u200E$value\u200E';
|
||||
|
||||
enum _RoomRedPacketHistoryStatus { sent, pending, returned }
|
||||
|
||||
@ -45,6 +45,11 @@ class RoomRedPacketUiData {
|
||||
}
|
||||
|
||||
factory RoomRedPacketUiData.fromListRes(SCRoomRedPacketListRes res) {
|
||||
final countdown = res.countdown ?? 0;
|
||||
final claimStartTimeMs = _claimStartTimeMsFromValues(
|
||||
claimStartTime: res.claimStartTime,
|
||||
countdown: countdown,
|
||||
);
|
||||
return RoomRedPacketUiData(
|
||||
packetId: res.packetId ?? '',
|
||||
senderName: res.userName ?? '',
|
||||
@ -54,13 +59,13 @@ class RoomRedPacketUiData {
|
||||
totalCount: res.totalCount ?? 0,
|
||||
remainCount: res.remainCount ?? res.totalCount ?? 0,
|
||||
expireMinutes: res.expireMinutes ?? 0,
|
||||
countdown: res.countdown ?? 0,
|
||||
countdown: countdown,
|
||||
status: res.status ?? 0,
|
||||
statusDesc: res.statusDesc ?? '',
|
||||
grabbed: res.grabbed ?? false,
|
||||
claimedAmount: 0,
|
||||
createTime: res.createTime ?? '',
|
||||
claimStartTimeMs: _timestampValueFromDynamic(res.claimStartTime),
|
||||
claimStartTimeMs: claimStartTimeMs,
|
||||
expireTimeMs: _timestampValueFromDynamic(res.expireTime),
|
||||
);
|
||||
}
|
||||
@ -80,6 +85,16 @@ class RoomRedPacketUiData {
|
||||
final fallbackName = fallbackUser?.userNickname ?? '';
|
||||
final fallbackAvatar = fallbackUser?.userAvatar ?? '';
|
||||
final fallbackId = fallbackUser?.id ?? '';
|
||||
final countdown = _intValue(json, const ['countdown']);
|
||||
final claimStartTimeMs = _claimStartTimeMsFromValues(
|
||||
claimStartTime: _firstValue(json, const ['claimStartTime']),
|
||||
countdown: countdown,
|
||||
);
|
||||
final totalCount = _numValue(json, const ['totalCount', 'count']);
|
||||
final remainCount =
|
||||
_hasUsableValue(json, const ['remainCount', 'leftCount'])
|
||||
? _numValue(json, const ['remainCount', 'leftCount'])
|
||||
: totalCount;
|
||||
return RoomRedPacketUiData(
|
||||
packetId: _stringValue(json, const ['packetId', 'packetNo', 'id']),
|
||||
senderName: _stringValue(json, const [
|
||||
@ -99,10 +114,10 @@ class RoomRedPacketUiData {
|
||||
'senderId',
|
||||
], fallback: fallbackId),
|
||||
totalAmount: _numValue(json, const ['totalAmount']),
|
||||
totalCount: _numValue(json, const ['totalCount', 'count']),
|
||||
remainCount: _numValue(json, const ['remainCount', 'leftCount']),
|
||||
totalCount: totalCount,
|
||||
remainCount: remainCount,
|
||||
expireMinutes: _numValue(json, const ['expireMinutes']),
|
||||
countdown: _intValue(json, const ['countdown']),
|
||||
countdown: countdown,
|
||||
status: _numValue(json, const ['status']),
|
||||
statusDesc: _stringValue(json, const ['statusDesc', 'status']),
|
||||
grabbed:
|
||||
@ -110,7 +125,7 @@ class RoomRedPacketUiData {
|
||||
_numValue(json, const ['myAmount']) > 0,
|
||||
claimedAmount: _numValue(json, const ['myAmount', 'claimedAmount']),
|
||||
createTime: _stringValue(json, const ['createTime', 'sentAt']),
|
||||
claimStartTimeMs: _timestampValue(json, const ['claimStartTime']),
|
||||
claimStartTimeMs: claimStartTimeMs,
|
||||
expireTimeMs: _timestampValue(json, const ['expireTime']),
|
||||
);
|
||||
}
|
||||
@ -134,25 +149,72 @@ class RoomRedPacketUiData {
|
||||
|
||||
bool get hasPacketId => packetId.trim().isNotEmpty;
|
||||
|
||||
RoomRedPacketUiData withFallbackProfile(RoomRedPacketUiData fallback) {
|
||||
final json = toJson();
|
||||
final keepFallbackAvailability =
|
||||
fallback.isWaiting && !grabbed && claimedAmount <= 0;
|
||||
final explicitSoldOut =
|
||||
_isExplicitSoldOutStatus(status, statusDesc) &&
|
||||
!keepFallbackAvailability;
|
||||
if (packetId.trim().isEmpty && fallback.packetId.trim().isNotEmpty) {
|
||||
json['packetId'] = fallback.packetId;
|
||||
}
|
||||
if (senderName.trim().isEmpty && fallback.senderName.trim().isNotEmpty) {
|
||||
json['userName'] = fallback.senderName;
|
||||
}
|
||||
if (senderAvatar.trim().isEmpty &&
|
||||
fallback.senderAvatar.trim().isNotEmpty) {
|
||||
json['userAvatar'] = fallback.senderAvatar;
|
||||
}
|
||||
if (senderId.trim().isEmpty && fallback.senderId.trim().isNotEmpty) {
|
||||
json['userId'] = fallback.senderId;
|
||||
}
|
||||
if (totalAmount <= 0 && fallback.totalAmount > 0) {
|
||||
json['totalAmount'] = fallback.totalAmount;
|
||||
}
|
||||
if (totalCount <= 0 && fallback.totalCount > 0) {
|
||||
json['totalCount'] = fallback.totalCount;
|
||||
}
|
||||
if (!explicitSoldOut && remainCount <= 0 && fallback.remainCount > 0) {
|
||||
json['remainCount'] = fallback.remainCount;
|
||||
}
|
||||
if (keepFallbackAvailability) {
|
||||
json['status'] = fallback.status;
|
||||
json['statusDesc'] = fallback.statusDesc;
|
||||
}
|
||||
if (countdown <= 0 && fallback.countdown > 0) {
|
||||
json['countdown'] = fallback.countdown;
|
||||
}
|
||||
if (claimStartTimeMs <= 0 && fallback.claimStartTimeMs > 0) {
|
||||
json['claimStartTime'] = fallback.claimStartTimeMs;
|
||||
}
|
||||
if (expireTimeMs <= 0 && fallback.expireTimeMs > 0) {
|
||||
json['expireTime'] = fallback.expireTimeMs;
|
||||
}
|
||||
return RoomRedPacketUiData.fromJson(json);
|
||||
}
|
||||
|
||||
int get waitingSeconds {
|
||||
if (countdown > 0) {
|
||||
return countdown;
|
||||
if (claimStartTimeMs > 0) {
|
||||
final diff = claimStartTimeMs - DateTime.now().millisecondsSinceEpoch;
|
||||
if (diff <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (diff / 1000).ceil();
|
||||
}
|
||||
final diff = claimStartTimeMs - DateTime.now().millisecondsSinceEpoch;
|
||||
if (diff <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (diff / 1000).ceil();
|
||||
return countdown > 0 ? countdown : 0;
|
||||
}
|
||||
|
||||
bool get isWaiting => waitingSeconds > 0;
|
||||
|
||||
bool get isSoldOut {
|
||||
final normalizedStatus = statusDesc.toLowerCase();
|
||||
return remainCount <= 0 ||
|
||||
status == 2 ||
|
||||
normalizedStatus.contains('finish') ||
|
||||
normalizedStatus.contains('sold');
|
||||
if (isWaiting) {
|
||||
return false;
|
||||
}
|
||||
if (_isExplicitSoldOutStatus(status, statusDesc)) {
|
||||
return true;
|
||||
}
|
||||
return totalCount > 0 && remainCount <= 0;
|
||||
}
|
||||
|
||||
bool get isExpired {
|
||||
@ -250,6 +312,53 @@ class RoomRedPacketUiData {
|
||||
return _numValue(json, keys).round();
|
||||
}
|
||||
|
||||
static bool _hasUsableValue(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
if (!json.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
final value = json[key];
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value is String && value.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _isExplicitSoldOutStatus(num status, String statusDesc) {
|
||||
final normalizedStatus = statusDesc.toLowerCase();
|
||||
return status == 2 ||
|
||||
normalizedStatus.contains('finish') ||
|
||||
normalizedStatus.contains('sold');
|
||||
}
|
||||
|
||||
static dynamic _firstValue(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
if (json.containsKey(key)) {
|
||||
return json[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int _claimStartTimeMsFromValues({
|
||||
required dynamic claimStartTime,
|
||||
required int countdown,
|
||||
}) {
|
||||
final parsedClaimStartTime = _timestampValueFromDynamic(claimStartTime);
|
||||
if (parsedClaimStartTime > 0) {
|
||||
return parsedClaimStartTime;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return DateTime.now().millisecondsSinceEpoch + countdown * 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int _timestampValue(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final timestamp = _timestampValueFromDynamic(json[key]);
|
||||
|
||||
@ -1,22 +1,70 @@
|
||||
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:provider/provider.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_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/components/sc_tts.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_error.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart';
|
||||
|
||||
class RoomRedPacketOpenDialog extends StatefulWidget {
|
||||
const RoomRedPacketOpenDialog({super.key, required this.data});
|
||||
const RoomRedPacketOpenDialog({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.dataList,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
static const String dialogTag = 'showRoomRedPacketOpenDialog';
|
||||
static final Map<String, num> _claimedAmountByPacketId = {};
|
||||
static final ValueNotifier<int> claimedStateNotifier = ValueNotifier<int>(0);
|
||||
|
||||
final RoomRedPacketUiData data;
|
||||
final List<RoomRedPacketUiData>? dataList;
|
||||
final int initialIndex;
|
||||
|
||||
static void show(BuildContext context, RoomRedPacketUiData data) {
|
||||
final normalizedData = withLocalClaimState(data);
|
||||
showList(
|
||||
context,
|
||||
_dialogPacketsForContext(context, normalizedData),
|
||||
initialPacketId: normalizedData.packetId,
|
||||
);
|
||||
}
|
||||
|
||||
static void showList(
|
||||
BuildContext context,
|
||||
List<RoomRedPacketUiData> dataList, {
|
||||
String? initialPacketId,
|
||||
}) {
|
||||
final normalizedList = dataList
|
||||
.map(withLocalClaimState)
|
||||
.where((item) => item.hasPacketId)
|
||||
.toList(growable: false);
|
||||
final packets =
|
||||
normalizedList.isNotEmpty
|
||||
? normalizedList
|
||||
: dataList.map(withLocalClaimState).toList(growable: false);
|
||||
if (packets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var initialIndex = 0;
|
||||
if (initialPacketId != null && initialPacketId.trim().isNotEmpty) {
|
||||
final matchedIndex = packets.indexWhere(
|
||||
(item) => item.packetId == initialPacketId,
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
initialIndex = matchedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
SmartDialog.dismiss(tag: dialogTag);
|
||||
SmartDialog.show(
|
||||
tag: dialogTag,
|
||||
@ -24,86 +72,178 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
|
||||
animationType: SmartAnimationType.fade,
|
||||
maskColor: Colors.black.withValues(alpha: 0.55),
|
||||
clickMaskDismiss: true,
|
||||
builder: (_) => RoomRedPacketOpenDialog(data: data),
|
||||
builder:
|
||||
(_) => RoomRedPacketOpenDialog(
|
||||
data: packets[initialIndex],
|
||||
dataList: packets,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static bool isClaimedLocally(String packetId) {
|
||||
return _claimedAmountByPacketId.containsKey(_normalizePacketId(packetId));
|
||||
}
|
||||
|
||||
static void rememberClaimed(String packetId, num amount) {
|
||||
final normalizedPacketId = _normalizePacketId(packetId);
|
||||
if (normalizedPacketId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_claimedAmountByPacketId[normalizedPacketId] = amount;
|
||||
RoomRedPacketPendingCache.instance.removePacket(normalizedPacketId);
|
||||
claimedStateNotifier.value++;
|
||||
}
|
||||
|
||||
static void clearClaimedState() {
|
||||
if (_claimedAmountByPacketId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_claimedAmountByPacketId.clear();
|
||||
claimedStateNotifier.value++;
|
||||
}
|
||||
|
||||
static RoomRedPacketUiData withLocalClaimState(RoomRedPacketUiData data) {
|
||||
final normalizedPacketId = _normalizePacketId(data.packetId);
|
||||
if (!_claimedAmountByPacketId.containsKey(normalizedPacketId)) {
|
||||
return data;
|
||||
}
|
||||
final amount = _claimedAmountByPacketId[normalizedPacketId] ?? 0;
|
||||
final json =
|
||||
data.toJson()
|
||||
..['grabbed'] = true
|
||||
..['claimedAmount'] = amount > 0 ? amount : data.claimedAmount;
|
||||
return RoomRedPacketUiData.fromJson(json);
|
||||
}
|
||||
|
||||
static List<RoomRedPacketUiData> _dialogPacketsForContext(
|
||||
BuildContext context,
|
||||
RoomRedPacketUiData data,
|
||||
) {
|
||||
final packets = <RoomRedPacketUiData>[];
|
||||
try {
|
||||
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
|
||||
final currentRoomId =
|
||||
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
|
||||
for (final packet in rtcProvider.redPacketList.map(
|
||||
RoomRedPacketUiData.fromListRes,
|
||||
)) {
|
||||
final normalizedPacket = withLocalClaimState(packet);
|
||||
if (!_shouldIncludeRoomPacket(normalizedPacket)) {
|
||||
continue;
|
||||
}
|
||||
_addPacketIfAbsent(packets, normalizedPacket);
|
||||
}
|
||||
for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom(
|
||||
currentRoomId,
|
||||
)) {
|
||||
final normalizedPacket = withLocalClaimState(packet);
|
||||
if (!_shouldIncludeRoomPacket(normalizedPacket)) {
|
||||
continue;
|
||||
}
|
||||
_addPacketIfAbsent(packets, normalizedPacket);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (data.hasPacketId) {
|
||||
_addPacketIfAbsent(packets, data, insertAtStart: true);
|
||||
}
|
||||
return packets.isEmpty ? [data] : packets;
|
||||
}
|
||||
|
||||
static String _normalizePacketId(String packetId) => packetId.trim();
|
||||
|
||||
static bool _shouldIncludeRoomPacket(RoomRedPacketUiData data) {
|
||||
final normalizedStatus = data.statusDesc.toLowerCase();
|
||||
final explicitSoldOut =
|
||||
data.status == 2 ||
|
||||
normalizedStatus.contains('finish') ||
|
||||
normalizedStatus.contains('sold');
|
||||
return !data.grabbed && !data.isExpired && !explicitSoldOut;
|
||||
}
|
||||
|
||||
static void _addPacketIfAbsent(
|
||||
List<RoomRedPacketUiData> packets,
|
||||
RoomRedPacketUiData data, {
|
||||
bool insertAtStart = false,
|
||||
}) {
|
||||
final packetId = data.packetId.trim();
|
||||
final existingIndex =
|
||||
packetId.isEmpty
|
||||
? -1
|
||||
: packets.indexWhere((item) => item.packetId.trim() == packetId);
|
||||
if (existingIndex >= 0) {
|
||||
if (insertAtStart) {
|
||||
packets
|
||||
..removeAt(existingIndex)
|
||||
..insert(0, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (insertAtStart) {
|
||||
packets.insert(0, data);
|
||||
} else {
|
||||
packets.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
State<RoomRedPacketOpenDialog> createState() =>
|
||||
_RoomRedPacketOpenDialogState();
|
||||
}
|
||||
|
||||
class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
late _RoomRedPacketOpenState _state;
|
||||
bool _claiming = false;
|
||||
num? _claimAmount;
|
||||
late final PageController _pageController;
|
||||
|
||||
bool get _usePendingBackground =>
|
||||
_state == _RoomRedPacketOpenState.ready ||
|
||||
_state == _RoomRedPacketOpenState.waiting;
|
||||
List<RoomRedPacketUiData> get _packets {
|
||||
final dataList = widget.dataList;
|
||||
if (dataList != null && dataList.isNotEmpty) {
|
||||
return dataList;
|
||||
}
|
||||
return [widget.data];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_state = _initialState(widget.data);
|
||||
_pageController = PageController(
|
||||
initialPage: widget.initialIndex.clamp(0, _packets.length - 1),
|
||||
viewportFraction: _packets.length > 1 ? 0.74 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 红包弹层依赖整张位图背景,下面这些定位值按设计图拆开集中管理:
|
||||
// 未打开态素材是 472x602,对应设计稿里约 236x301 的显示比例;
|
||||
// 后续如果还要对稿,优先只微调 avatarTop / senderNameTop /
|
||||
// centerContentTop / actionBottom,不要改动各层级顺序。
|
||||
final dialogWidth = (_usePendingBackground ? 236 : 244).w;
|
||||
final dialogHeight = (_usePendingBackground ? 301 : 322).w;
|
||||
final avatarSize = (_usePendingBackground ? 42 : 66).w;
|
||||
final avatarTop = (_usePendingBackground ? 32 : -18).w;
|
||||
final senderNameTop = (_usePendingBackground ? 86 : 64).w;
|
||||
final centerContentTop = (_usePendingBackground ? 104 : 100).w;
|
||||
final actionBottom = (_usePendingBackground ? 62 : 58).w;
|
||||
final packets = _packets;
|
||||
final hasMultiplePackets = packets.length > 1;
|
||||
return SizedBox(
|
||||
width: ScreenUtil().screenWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: dialogWidth,
|
||||
height: dialogHeight,
|
||||
child: Stack(
|
||||
width: ScreenUtil().screenWidth,
|
||||
height: 344.w,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
_usePendingBackground
|
||||
? _RoomRedPacketOpenAssets.pendingBackground
|
||||
: _RoomRedPacketOpenAssets.background,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned(top: avatarTop, child: _buildAvatar(avatarSize)),
|
||||
Positioned(
|
||||
top: senderNameTop,
|
||||
left: 24.w,
|
||||
right: 24.w,
|
||||
child: _buildSenderName(),
|
||||
),
|
||||
Positioned(
|
||||
top: centerContentTop,
|
||||
left: 24.w,
|
||||
right: 24.w,
|
||||
child: _buildCenterContent(),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: actionBottom,
|
||||
child:
|
||||
_state == _RoomRedPacketOpenState.ready
|
||||
? _buildOpenButton(enabled: true)
|
||||
: _buildStateButton(),
|
||||
),
|
||||
],
|
||||
itemCount: packets.length,
|
||||
physics:
|
||||
hasMultiplePackets
|
||||
? const BouncingScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
padEnds: hasMultiplePackets,
|
||||
itemBuilder: (context, index) {
|
||||
return Center(
|
||||
child: _RoomRedPacketOpenCard(data: packets[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.w),
|
||||
@ -122,6 +262,112 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketOpenCard extends StatefulWidget {
|
||||
const _RoomRedPacketOpenCard({required this.data});
|
||||
|
||||
final RoomRedPacketUiData data;
|
||||
|
||||
@override
|
||||
State<_RoomRedPacketOpenCard> createState() => _RoomRedPacketOpenCardState();
|
||||
}
|
||||
|
||||
class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> {
|
||||
Timer? _countdownTimer;
|
||||
late _RoomRedPacketOpenState _state;
|
||||
bool _claiming = false;
|
||||
num? _claimAmount;
|
||||
|
||||
bool get _usePendingBackground =>
|
||||
_state == _RoomRedPacketOpenState.ready ||
|
||||
_state == _RoomRedPacketOpenState.waiting;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_state = _initialState(widget.data);
|
||||
_syncCountdownTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _RoomRedPacketOpenCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.data.packetId != widget.data.packetId) {
|
||||
_state = _initialState(widget.data);
|
||||
_claimAmount = null;
|
||||
_claiming = false;
|
||||
_syncCountdownTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 红包弹层依赖整张位图背景,下面这些定位值按设计图拆开集中管理:
|
||||
// 未打开态素材是 472x602,对应设计稿里约 236x301 的显示比例;
|
||||
// 多红包时外层 PageView 只负责横向滑动和露出左右卡片,这里仍然只调单张卡片。
|
||||
final dialogWidth = (_usePendingBackground ? 236 : 244).w;
|
||||
final dialogHeight = (_usePendingBackground ? 301 : 322).w;
|
||||
final avatarSize = (_usePendingBackground ? 42 : 66).w;
|
||||
final avatarTop = (_usePendingBackground ? 32 : -18).w;
|
||||
final senderNameTop = (_usePendingBackground ? 86 : 76).w;
|
||||
final centerContentTop = (_usePendingBackground ? 104 : 118).w;
|
||||
final actionBottom = (_usePendingBackground ? 62 : 58).w;
|
||||
|
||||
return SizedBox(
|
||||
width: dialogWidth,
|
||||
height: dialogHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
_usePendingBackground
|
||||
? _RoomRedPacketOpenAssets.pendingBackground
|
||||
: _RoomRedPacketOpenAssets.background,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned(top: avatarTop, child: _buildAvatar(avatarSize)),
|
||||
Positioned(
|
||||
top: senderNameTop,
|
||||
left: 24.w,
|
||||
right: 24.w,
|
||||
child: _buildSenderName(),
|
||||
),
|
||||
Positioned(
|
||||
top: centerContentTop,
|
||||
left: 24.w,
|
||||
right: 24.w,
|
||||
child: _buildCenterContent(),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: actionBottom,
|
||||
child:
|
||||
_state == _RoomRedPacketOpenState.ready
|
||||
? _buildOpenButton(enabled: true)
|
||||
: _buildStateButton(),
|
||||
),
|
||||
if (_state == _RoomRedPacketOpenState.waiting)
|
||||
Positioned(
|
||||
left: 18.w,
|
||||
right: 18.w,
|
||||
bottom: 38.w,
|
||||
child: _buildWaitingCountdownText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(double size) {
|
||||
if (widget.data.senderAvatar.trim().isNotEmpty) {
|
||||
@ -172,9 +418,12 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFA2352D),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color:
|
||||
_usePendingBackground
|
||||
? const Color(0xFFFFF0CB)
|
||||
: const Color(0xFFE63D32),
|
||||
fontSize: (_usePendingBackground ? 12 : 15).sp,
|
||||
fontWeight: _usePendingBackground ? FontWeight.w700 : FontWeight.w800,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
);
|
||||
@ -188,11 +437,7 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
case _RoomRedPacketOpenState.opened:
|
||||
return _buildOpenedContent(localizations);
|
||||
case _RoomRedPacketOpenState.waiting:
|
||||
return _buildMessageContent(
|
||||
localizations.roomRedPacketOpenWaiting(
|
||||
_waitingMinutes(widget.data).toString(),
|
||||
),
|
||||
);
|
||||
return _buildWaitingContent(localizations);
|
||||
case _RoomRedPacketOpenState.soldOut:
|
||||
return _buildMessageContent(localizations.roomRedPacketOpenSoldOut);
|
||||
case _RoomRedPacketOpenState.expired:
|
||||
@ -211,7 +456,7 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF8B5B35),
|
||||
color: const Color(0xFFFFDCA4),
|
||||
fontSize: 12.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w500,
|
||||
@ -219,27 +464,72 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.w),
|
||||
_buildTotalAmountText(fontSize: 23.sp),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingContent(SCAppLocalizations localizations) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 6.w),
|
||||
Text(
|
||||
_formatNumber(widget.data.totalAmount.round()),
|
||||
localizations.roomRedPacketOpenSubtitle,
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFA2352D),
|
||||
fontSize: 23.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFFFFDCA4),
|
||||
fontSize: 12.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.w),
|
||||
_buildTotalAmountText(fontSize: 23.sp),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingCountdownText() {
|
||||
return Text(
|
||||
SCAppLocalizations.of(context)!.roomRedPacketOpenWaitingCountdown(
|
||||
_formatDuration(widget.data.waitingSeconds),
|
||||
),
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFFFDCA4),
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalAmountText({required double fontSize}) {
|
||||
return Text(
|
||||
_formatNumber(widget.data.totalAmount.round()),
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFFFF0CB),
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOpenedContent(SCAppLocalizations localizations) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 2.w),
|
||||
Text(
|
||||
localizations.roomRedPacketOpenYouGot,
|
||||
textScaler: TextScaler.noScaling,
|
||||
@ -247,35 +537,33 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF8B5B35),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFFFF7A70),
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 13.w),
|
||||
SizedBox(height: 10.w),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
_RoomRedPacketOpenAssets.amountIcon,
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_formatNumber(
|
||||
(_claimAmount ?? widget.data.mockClaimAmount).round(),
|
||||
),
|
||||
_formatNumber(_displayClaimAmount.round()),
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFA2352D),
|
||||
fontSize: 28.sp,
|
||||
fontSize: 25.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
@ -374,12 +662,27 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
return _RoomRedPacketOpenState.ready;
|
||||
}
|
||||
|
||||
int _waitingMinutes(RoomRedPacketUiData data) {
|
||||
final waitingSeconds = data.waitingSeconds;
|
||||
if (waitingSeconds > 0) {
|
||||
return (waitingSeconds / 60).ceil();
|
||||
void _syncCountdownTimer() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = null;
|
||||
if (_state != _RoomRedPacketOpenState.waiting) {
|
||||
return;
|
||||
}
|
||||
return data.expireMinutes.round();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (widget.data.waitingSeconds <= 0) {
|
||||
setState(() {
|
||||
_state = _initialState(widget.data);
|
||||
});
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = null;
|
||||
_refreshRoomRedPacketList();
|
||||
return;
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _claimRedPacket() async {
|
||||
@ -387,10 +690,13 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
return;
|
||||
}
|
||||
if (_isLocalPreviewPacket) {
|
||||
final amount = widget.data.mockClaimAmount;
|
||||
RoomRedPacketOpenDialog.rememberClaimed(widget.data.packetId, amount);
|
||||
setState(() {
|
||||
_claimAmount = widget.data.mockClaimAmount;
|
||||
_claimAmount = amount;
|
||||
_state = _RoomRedPacketOpenState.opened;
|
||||
});
|
||||
_syncCountdownTimer();
|
||||
return;
|
||||
}
|
||||
setState(() => _claiming = true);
|
||||
@ -401,10 +707,13 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final amount = result.amount ?? widget.data.mockClaimAmount;
|
||||
RoomRedPacketOpenDialog.rememberClaimed(widget.data.packetId, amount);
|
||||
setState(() {
|
||||
_claimAmount = result.amount ?? widget.data.mockClaimAmount;
|
||||
_claimAmount = amount;
|
||||
_state = _RoomRedPacketOpenState.opened;
|
||||
});
|
||||
_refreshRoomRedPacketList();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -417,8 +726,16 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
_state = _RoomRedPacketOpenState.expired;
|
||||
} else if (code == 'voice_room_red_packet_finished') {
|
||||
_state = _RoomRedPacketOpenState.soldOut;
|
||||
} else if (code == 'voice_room_red_packet_already_grabbed' ||
|
||||
code == 'voice_room_red_packet_already_claimed') {
|
||||
RoomRedPacketOpenDialog.rememberClaimed(
|
||||
widget.data.packetId,
|
||||
widget.data.claimedAmount,
|
||||
);
|
||||
_state = _RoomRedPacketOpenState.opened;
|
||||
}
|
||||
});
|
||||
_syncCountdownTimer();
|
||||
SCTts.show(roomRedPacketErrorMessage(context, error));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@ -427,6 +744,17 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshRoomRedPacketList() {
|
||||
try {
|
||||
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
|
||||
unawaited(
|
||||
rtcProvider.loadRoomRedPacketList(1).catchError((_) {
|
||||
return rtcProvider.redPacketList;
|
||||
}),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool get _isLocalPreviewPacket {
|
||||
final packetId = widget.data.packetId.trim();
|
||||
return packetId.isEmpty ||
|
||||
@ -434,6 +762,21 @@ class _RoomRedPacketOpenDialogState extends State<RoomRedPacketOpenDialog> {
|
||||
packetId.startsWith('test-');
|
||||
}
|
||||
|
||||
num get _displayClaimAmount {
|
||||
final amount = _claimAmount ?? widget.data.claimedAmount;
|
||||
if (amount > 0) {
|
||||
return amount;
|
||||
}
|
||||
return widget.data.mockClaimAmount;
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final normalizedSeconds = seconds <= 0 ? 0 : seconds;
|
||||
final minutes = normalizedSeconds ~/ 60;
|
||||
final remainderSeconds = normalizedSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainderSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatNumber(int value) {
|
||||
final raw = value.toString();
|
||||
final buffer = StringBuffer();
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
|
||||
class RoomRedPacketPendingCache extends ChangeNotifier {
|
||||
RoomRedPacketPendingCache._();
|
||||
|
||||
static final RoomRedPacketPendingCache instance =
|
||||
RoomRedPacketPendingCache._();
|
||||
static const Duration _fallbackTtl = Duration(hours: 2);
|
||||
|
||||
final Map<String, Map<String, _CachedRoomRedPacket>> _packetsByRoom = {};
|
||||
|
||||
void upsertPayload(dynamic payload) {
|
||||
final map = _payloadMap(payload);
|
||||
if (map.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final roomId = _textValue(map['roomId']);
|
||||
if (roomId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
upsert(roomId: roomId, packet: RoomRedPacketUiData.fromJson(map));
|
||||
}
|
||||
|
||||
void upsertFloatingMessage(SCFloatingMessage message) {
|
||||
final roomId = message.roomId?.trim() ?? '';
|
||||
final packetId = message.toUserId?.trim() ?? '';
|
||||
if (roomId.isEmpty || packetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
upsert(
|
||||
roomId: roomId,
|
||||
packet: RoomRedPacketUiData.fromJson({
|
||||
'packetId': packetId,
|
||||
'userName': message.userName,
|
||||
'userAvatar': message.userAvatarUrl,
|
||||
'userId': message.userId,
|
||||
'totalAmount': message.coins,
|
||||
'totalCount': message.number,
|
||||
'remainCount': message.number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void upsert({required String roomId, required RoomRedPacketUiData packet}) {
|
||||
final normalizedRoomId = roomId.trim();
|
||||
final packetId = packet.packetId.trim();
|
||||
if (normalizedRoomId.isEmpty || packetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_pruneExpired();
|
||||
if (!_shouldKeep(packet)) {
|
||||
removePacket(packetId);
|
||||
return;
|
||||
}
|
||||
final roomPackets = _packetsByRoom.putIfAbsent(
|
||||
normalizedRoomId,
|
||||
() => <String, _CachedRoomRedPacket>{},
|
||||
);
|
||||
final existingPacket = roomPackets[packetId]?.packet;
|
||||
final mergedPacket =
|
||||
existingPacket == null
|
||||
? packet
|
||||
: packet.withFallbackProfile(existingPacket);
|
||||
roomPackets[packetId] = _CachedRoomRedPacket(mergedPacket, DateTime.now());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<RoomRedPacketUiData> packetsForRoom(String roomId) {
|
||||
final normalizedRoomId = roomId.trim();
|
||||
if (normalizedRoomId.isEmpty) {
|
||||
return const <RoomRedPacketUiData>[];
|
||||
}
|
||||
_pruneExpired(notify: false);
|
||||
final roomPackets = _packetsByRoom[normalizedRoomId];
|
||||
if (roomPackets == null || roomPackets.isEmpty) {
|
||||
return const <RoomRedPacketUiData>[];
|
||||
}
|
||||
return roomPackets.values
|
||||
.map((item) => item.packet)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
void removePacket(String packetId) {
|
||||
final normalizedPacketId = packetId.trim();
|
||||
if (normalizedPacketId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var changed = false;
|
||||
for (final roomPackets in _packetsByRoom.values) {
|
||||
changed = roomPackets.remove(normalizedPacketId) != null || changed;
|
||||
}
|
||||
_packetsByRoom.removeWhere((_, packets) => packets.isEmpty);
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_packetsByRoom.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_packetsByRoom.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _pruneExpired({bool notify = true}) {
|
||||
final now = DateTime.now();
|
||||
var changed = false;
|
||||
for (final roomPackets in _packetsByRoom.values) {
|
||||
roomPackets.removeWhere((_, cached) {
|
||||
final shouldRemove =
|
||||
now.difference(cached.receivedAt) > _fallbackTtl ||
|
||||
!_shouldKeep(cached.packet);
|
||||
changed = changed || shouldRemove;
|
||||
return shouldRemove;
|
||||
});
|
||||
}
|
||||
_packetsByRoom.removeWhere((_, packets) => packets.isEmpty);
|
||||
if (changed && notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldKeep(RoomRedPacketUiData packet) {
|
||||
return packet.hasPacketId &&
|
||||
!packet.grabbed &&
|
||||
!packet.isExpired &&
|
||||
!packet.isSoldOut;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _payloadMap(dynamic payload) {
|
||||
if (payload is Map<String, dynamic>) {
|
||||
return payload;
|
||||
}
|
||||
if (payload is Map) {
|
||||
return payload.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
static String _textValue(dynamic value) => value?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
class _CachedRoomRedPacket {
|
||||
const _CachedRoomRedPacket(this.packet, this.receivedAt);
|
||||
|
||||
final RoomRedPacketUiData packet;
|
||||
final DateTime receivedAt;
|
||||
}
|
||||
@ -1,12 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
|
||||
|
||||
class RoomRedPacketRulePage extends StatelessWidget {
|
||||
class RoomRedPacketRulePage extends StatefulWidget {
|
||||
const RoomRedPacketRulePage({super.key});
|
||||
|
||||
@override
|
||||
State<RoomRedPacketRulePage> createState() => _RoomRedPacketRulePageState();
|
||||
}
|
||||
|
||||
class _RoomRedPacketRulePageState extends State<RoomRedPacketRulePage> {
|
||||
static const Color _backgroundColor = Color(0xFF052821);
|
||||
static const Color _textColor = Colors.white;
|
||||
String _dailySendLimit = '...';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConfig();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -24,7 +37,7 @@ class RoomRedPacketRulePage extends StatelessWidget {
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsetsDirectional.fromSTEB(28.w, 18.w, 28.w, 32.w),
|
||||
child: Text(
|
||||
localizations.roomRedPacketRulesContent,
|
||||
localizations.roomRedPacketRulesContent(_dailySendLimit),
|
||||
textAlign: TextAlign.start,
|
||||
style: TextStyle(
|
||||
color: _textColor,
|
||||
@ -40,6 +53,21 @@ class RoomRedPacketRulePage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
try {
|
||||
final config = await SCChatRoomRepository().roomRedPacketConfig();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final limit = config.dailySendLimit;
|
||||
setState(() => _dailySendLimit = limit > 0 ? limit.toString() : '--');
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _dailySendLimit = '--');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketRuleTopBar extends StatelessWidget {
|
||||
@ -57,14 +85,14 @@ class _RoomRedPacketRuleTopBar extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: 22.w,
|
||||
start: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: SizedBox(
|
||||
width: 36.w,
|
||||
width: 44.w,
|
||||
height: 58.w,
|
||||
child: Icon(
|
||||
textDirection == TextDirection.rtl
|
||||
|
||||
@ -1,14 +1,23 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/app/constants/sc_room_msg_type.dart';
|
||||
import 'package:yumi/main.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/services/audio/rtm_manager.dart';
|
||||
import 'package:yumi/services/auth/user_profile_manager.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_list_res.dart';
|
||||
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.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';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_error.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_history_page.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_rule_page.dart';
|
||||
@ -99,7 +108,6 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
int _selectedAmount = _defaultAmountOptions.first;
|
||||
int _selectedCount = _defaultCountOptions.first;
|
||||
int _selectedPickupMinutes = _defaultPickupOptions.first.minutes;
|
||||
bool _redPacketEnabled = true;
|
||||
bool _isSending = false;
|
||||
|
||||
@override
|
||||
@ -458,7 +466,6 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
const _PickupOption(minutes: 0),
|
||||
];
|
||||
setState(() {
|
||||
_redPacketEnabled = config.enabled;
|
||||
_amountOptions = List<int>.of(nextAmountOptions);
|
||||
_countOptions = List<int>.of(nextCountOptions);
|
||||
_pickupOptions =
|
||||
@ -487,10 +494,6 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
return;
|
||||
}
|
||||
final localizations = SCAppLocalizations.of(context)!;
|
||||
if (!_redPacketEnabled) {
|
||||
SCTts.show(localizations.roomRedPacketComingSoon);
|
||||
return;
|
||||
}
|
||||
widget.onSend?.call(
|
||||
RoomRedPacketDraft(
|
||||
totalAmount: _selectedAmount,
|
||||
@ -528,6 +531,7 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
rtcProvider.loadRoomRedPacketList(1).catchError((_) {
|
||||
return rtcProvider.redPacketList;
|
||||
});
|
||||
_showSentRedPacketFallback(result, rtcProvider);
|
||||
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
|
||||
SCTts.show(localizations.roomRedPacketSendSuccess);
|
||||
} catch (error) {
|
||||
@ -541,6 +545,83 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showSentRedPacketFallback(
|
||||
SCRoomRedPacketListRes result,
|
||||
RtcProvider rtcProvider,
|
||||
) {
|
||||
final packetId = (result.packetId ?? '').trim();
|
||||
if (packetId.isEmpty) {
|
||||
debugPrint(
|
||||
'[RoomRedPacket][SendFallback] skip local UI because packetId is empty result=${result.toJson()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final roomProfile = rtcProvider.currenRoom?.roomProfile?.roomProfile;
|
||||
final currentUser = AccountStorage().getCurrentUser()?.userProfile;
|
||||
final resultRoomId = result.roomId?.trim() ?? '';
|
||||
final roomId =
|
||||
resultRoomId.isNotEmpty ? resultRoomId : roomProfile?.id ?? '';
|
||||
final senderId = currentUser?.id ?? result.userId?.toString() ?? '';
|
||||
final senderName =
|
||||
currentUser?.userNickname ?? result.userName ?? 'Lucky Pack';
|
||||
final senderAvatar = currentUser?.userAvatar ?? result.userAvatar ?? '';
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final claimStartTime =
|
||||
result.claimStartTime ??
|
||||
(_selectedPickupMinutes > 0
|
||||
? (nowMs + _selectedPickupMinutes * 60 * 1000).toString()
|
||||
: '');
|
||||
final payload =
|
||||
Map<String, dynamic>.from(result.toJson())
|
||||
..['packetId'] = packetId
|
||||
..['packetNo'] = packetId
|
||||
..['roomId'] = roomId
|
||||
..['actualAccount'] = senderId
|
||||
..['userId'] = senderId
|
||||
..['userNickname'] = senderName
|
||||
..['userName'] = senderName
|
||||
..['userAvatar'] = senderAvatar
|
||||
..['totalAmount'] = result.totalAmount ?? _selectedAmount
|
||||
..['totalCount'] = result.totalCount ?? _selectedCount
|
||||
..['remainCount'] =
|
||||
result.remainCount ?? result.totalCount ?? _selectedCount
|
||||
..['packetMode'] =
|
||||
result.packetMode ??
|
||||
(_selectedPickupMinutes <= 0 ? 'IMMEDIATE' : 'DELAYED')
|
||||
..['countdown'] =
|
||||
result.countdown ??
|
||||
(_selectedPickupMinutes > 0 ? _selectedPickupMinutes * 60 : 0)
|
||||
..['claimStartTime'] = claimStartTime
|
||||
..['grabbed'] = result.grabbed ?? false;
|
||||
|
||||
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
|
||||
rtmProvider.addMsg(
|
||||
Msg(
|
||||
groupId: roomProfile?.roomAccount ?? '',
|
||||
msg: jsonEncode(payload),
|
||||
type: SCRoomMsgType.roomRedPacket,
|
||||
user: currentUser,
|
||||
),
|
||||
);
|
||||
OverlayManager().addMessage(
|
||||
SCFloatingMessage(
|
||||
type: 4,
|
||||
roomId: roomId,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
userAvatarUrl: senderAvatar,
|
||||
toUserId: packetId,
|
||||
coins: result.totalAmount ?? _selectedAmount,
|
||||
number: result.totalCount ?? _selectedCount,
|
||||
broadcastScope: SCFloatingMessage.broadcastScopeRegion,
|
||||
priority: 1000,
|
||||
),
|
||||
);
|
||||
debugPrint(
|
||||
'[RoomRedPacket][SendFallback] inserted local chat/floating packetId=$packetId roomId=$roomId',
|
||||
);
|
||||
}
|
||||
|
||||
void _openRulePage() {
|
||||
final navigator = navigatorKey.currentState;
|
||||
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
|
||||
@ -561,9 +642,7 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
|
||||
return;
|
||||
}
|
||||
Future<void>.delayed(const Duration(milliseconds: 80), () {
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (_) => const RoomRedPacketHistoryPage()),
|
||||
);
|
||||
navigator.push(RoomRedPacketHistoryPage.route());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -930,7 +930,7 @@ class _MsgItemState extends State<MsgItem> {
|
||||
),
|
||||
TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text: widget.msg.toUser?.userNickname ?? "",
|
||||
text: _giftTargetName(context),
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: SocialChatTheme.primaryColor,
|
||||
@ -967,6 +967,18 @@ class _MsgItemState extends State<MsgItem> {
|
||||
);
|
||||
}
|
||||
|
||||
String _giftTargetName(BuildContext context) {
|
||||
final targetUserIds =
|
||||
widget.msg.targetUserIds
|
||||
?.map((id) => id.trim())
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
if (targetUserIds != null && targetUserIds.length > 1) {
|
||||
return SCAppLocalizations.of(context)!.multiple;
|
||||
}
|
||||
return widget.msg.toUser?.userNickname ?? "";
|
||||
}
|
||||
|
||||
///进入房间
|
||||
_enterRoomItem(BuildContext context) {
|
||||
List<InlineSpan> spans = [];
|
||||
@ -1715,6 +1727,7 @@ class Msg {
|
||||
// 送礼给谁
|
||||
SocialChatUserProfile? toUser;
|
||||
List<String>? targetUserIds;
|
||||
String? giftBatchId;
|
||||
int? time = 0;
|
||||
|
||||
//动画是否已经播放(骰子、石头剪刀布、随机数字)
|
||||
@ -1735,6 +1748,7 @@ class Msg {
|
||||
this.customAnimationCount,
|
||||
this.awardAmount,
|
||||
this.targetUserIds,
|
||||
this.giftBatchId,
|
||||
this.hasPlayedAnimation,
|
||||
this.needUpDataUserInfo,
|
||||
}) {
|
||||
@ -1750,6 +1764,7 @@ class Msg {
|
||||
number = json['number'];
|
||||
customAnimationCount = json['customAnimationCount'];
|
||||
awardAmount = json['awardAmount'];
|
||||
giftBatchId = json['giftBatchId']?.toString();
|
||||
final dynamic rawTargetUserIds = json['targetUserIds'];
|
||||
if (rawTargetUserIds is List) {
|
||||
targetUserIds =
|
||||
@ -1787,6 +1802,9 @@ class Msg {
|
||||
map['customAnimationCount'] = customAnimationCount;
|
||||
map['hasPlayedAnimation'] = hasPlayedAnimation;
|
||||
map['needUpDataUserInfo'] = needUpDataUserInfo;
|
||||
if ((giftBatchId ?? "").trim().isNotEmpty) {
|
||||
map['giftBatchId'] = giftBatchId;
|
||||
}
|
||||
if (targetUserIds != null) {
|
||||
map['targetUserIds'] = targetUserIds;
|
||||
}
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/app/constants/sc_room_msg_type.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/services/audio/rtm_manager.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.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});
|
||||
@ -22,6 +30,11 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
|
||||
bottom: 150.w,
|
||||
child: const RoomMusicFloatingEntry(),
|
||||
),
|
||||
PositionedDirectional(
|
||||
end: 15.w,
|
||||
bottom: 200.w,
|
||||
child: const _RoomRedPacketFloatingEntry(),
|
||||
),
|
||||
PositionedDirectional(
|
||||
end: 15.w,
|
||||
bottom: 100.w,
|
||||
@ -37,3 +50,185 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketFloatingEntry extends StatelessWidget {
|
||||
const _RoomRedPacketFloatingEntry();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
RoomRedPacketPendingCache.instance,
|
||||
RoomRedPacketOpenDialog.claimedStateNotifier,
|
||||
]),
|
||||
builder: (context, child) {
|
||||
return Consumer2<RtcProvider, RtmProvider>(
|
||||
builder: (context, rtcProvider, rtmProvider, child) {
|
||||
final snapshot = _RoomRedPacketEntrySnapshot.fromProviders(
|
||||
rtcProvider: rtcProvider,
|
||||
rtmProvider: rtmProvider,
|
||||
);
|
||||
if (snapshot.packets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SCDebounceWidget(
|
||||
onTap: () {
|
||||
final freshSnapshot = _RoomRedPacketEntrySnapshot.fromProviders(
|
||||
rtcProvider: Provider.of<RtcProvider>(context, listen: false),
|
||||
rtmProvider: Provider.of<RtmProvider>(context, listen: false),
|
||||
);
|
||||
if (freshSnapshot.packets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
RoomRedPacketOpenDialog.showList(
|
||||
context,
|
||||
freshSnapshot.packets,
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 44.w,
|
||||
height: 44.w,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withValues(alpha: 0.22),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFFD56A),
|
||||
width: 1.3.w,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
'sc_images/room/red_packet/small_packet_icon.png',
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
if (snapshot.packets.length > 1)
|
||||
PositionedDirectional(
|
||||
top: -2.w,
|
||||
end: -2.w,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minWidth: 16.w),
|
||||
height: 16.w,
|
||||
padding: EdgeInsets.symmetric(horizontal: 4.w),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3D34),
|
||||
borderRadius: BorderRadius.circular(8.w),
|
||||
border: Border.all(color: Colors.white, width: 1.w),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.packets.length > 99
|
||||
? '99+'
|
||||
: snapshot.packets.length.toString(),
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRedPacketEntrySnapshot {
|
||||
const _RoomRedPacketEntrySnapshot(this.packets, this.signature);
|
||||
|
||||
factory _RoomRedPacketEntrySnapshot.fromProviders({
|
||||
required RtcProvider rtcProvider,
|
||||
required RtmProvider rtmProvider,
|
||||
}) {
|
||||
final packets = <RoomRedPacketUiData>[];
|
||||
final currentRoomId =
|
||||
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
|
||||
for (final packet in rtcProvider.redPacketList.map(
|
||||
RoomRedPacketUiData.fromListRes,
|
||||
)) {
|
||||
_addVisiblePacket(packets, packet);
|
||||
}
|
||||
for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom(
|
||||
currentRoomId,
|
||||
)) {
|
||||
_addVisiblePacket(packets, packet);
|
||||
}
|
||||
for (final msg in rtmProvider.roomChatMsgList) {
|
||||
if (msg.type != SCRoomMsgType.roomRedPacket) {
|
||||
continue;
|
||||
}
|
||||
_addVisiblePacket(
|
||||
packets,
|
||||
RoomRedPacketUiData.fromMessagePayload(
|
||||
payload: msg.msg,
|
||||
fallbackUser: msg.user,
|
||||
),
|
||||
);
|
||||
}
|
||||
final signature = packets
|
||||
.map(
|
||||
(item) =>
|
||||
'${item.packetId}|${item.status}|${item.remainCount}|${item.grabbed}|${item.claimedAmount}|${item.claimStartTimeMs}|${item.expireTimeMs}',
|
||||
)
|
||||
.join(';');
|
||||
return _RoomRedPacketEntrySnapshot(packets, signature);
|
||||
}
|
||||
|
||||
final List<RoomRedPacketUiData> packets;
|
||||
final String signature;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return other is _RoomRedPacketEntrySnapshot && other.signature == signature;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => signature.hashCode;
|
||||
}
|
||||
|
||||
void _addVisiblePacket(
|
||||
List<RoomRedPacketUiData> packets,
|
||||
RoomRedPacketUiData packet,
|
||||
) {
|
||||
final normalizedPacket = RoomRedPacketOpenDialog.withLocalClaimState(packet);
|
||||
if (!_shouldShowRoomRedPacketEntry(normalizedPacket)) {
|
||||
return;
|
||||
}
|
||||
final packetId = normalizedPacket.packetId.trim();
|
||||
if (packetId.isNotEmpty &&
|
||||
packets.any((item) => item.packetId.trim() == packetId)) {
|
||||
return;
|
||||
}
|
||||
packets.add(normalizedPacket);
|
||||
}
|
||||
|
||||
bool _shouldShowRoomRedPacketEntry(RoomRedPacketUiData item) {
|
||||
final normalizedStatus = item.statusDesc.toLowerCase();
|
||||
final explicitSoldOut =
|
||||
item.status == 2 ||
|
||||
normalizedStatus.contains('finish') ||
|
||||
normalizedStatus.contains('sold');
|
||||
return !item.grabbed && !item.isExpired && !explicitSoldOut;
|
||||
}
|
||||
|
||||
BIN
sc_images/register_reward/yumi_coin.png
Normal file
BIN
sc_images/register_reward/yumi_coin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 767 KiB |
26
需求进度.md
26
需求进度.md
@ -18,6 +18,10 @@
|
||||
- 已按后端《语音房红包 Flutter 接口对接文档》开始接口对接:发送/领取/详情/配置/发包记录切到 `/app/voice-room/red-packet/*`;TIM 登录后拉取当前用户语区红包 IM 群并加入,`0/10013` 都按成功处理,红包飘屏只从全服广播群或当前语区红包群解析,并按 `regionCode` 做同语区过滤;进入房间后每 30 秒上报红包 presence,离开/切房时调用 leave;发送面板、领取弹层和历史页已接真实接口,错误码先映射为多语言提示。
|
||||
- 已按 2026-05-07 17:55 截图反馈微调:Rules 页面顶部导航栏改为满宽安全区布局,返回按钮回到左侧并保留 RTL 方向;红包飘窗重算红条、头像、小红包和 `GO` 图层位置/尺寸,文本禁用系统缩放并使用 `FittedBox` 避免 bottom overflow;未打开红包弹层按 472x602 素材比例收窄,并把头像、昵称、说明、金额和 `OPEN` 整体上移,定位常量已集中注释方便继续对稿。
|
||||
- 已按要求移除房间游戏图标左侧的临时 `TEST` 红包 UI 测试按钮,同时删除本地模拟发送红包消息/飘屏的测试方法,保留真实红包消息链路。
|
||||
- 已为红包接口排查补充 debug 日志:所有 `/app/voice-room/red-packet/*` 请求都会在控制台输出 `[RoomRedPacket][API]` 请求 path、query/data、耗时、失败 response 和 resolved URL;语区 IM 群同步输出 `[RoomRedPacket][IM]`;进入房间红包 presence 心跳输出 `[RoomRedPacket][Presence]`,方便定位 `the path was not found` 具体来自哪条接口。
|
||||
- 已根据当前测试环境 404 排查结果,把红包新接口统一试切为 `/go/app/voice-room/red-packet/*` 前缀;旧的房间红包列表加密 path 暂不改动,便于对比新旧链路。
|
||||
- 已根据 Go 后端 400 日志修正请求字段类型:`presence` 的 `roomId`、发送红包的 `roomId/totalAmount/totalCount` 改为数字 JSON 提交,避免 `cannot unmarshal string into Go struct field ... int64`;同时发送按钮暂不再用配置接口返回的 `enabled=false` 拦截,方便继续联调真实发送接口。
|
||||
- 已补齐发送成功后的本地兜底 UI:如果后端发送接口成功但语区 IM 群暂未推送 `ROOM_RED_PACKET`,前端会用 send 返回的 `packetId/packetNo` 立即插入一条可点击的聊天红包气泡并触发红包飘窗;RTM 消息列表已按红包 packetId 去重,避免后续后端推送恢复后重复展示。
|
||||
|
||||
## 本轮个人资料页改版(分步重做)
|
||||
- 已按 2026-05-06 最新反馈回到旧版个人资料页结构上分步处理,不再一次性重做整页。
|
||||
@ -521,6 +525,28 @@
|
||||
- 当前工程内文件 `SHA256` 为:
|
||||
- `a1706496a01f74d27e6c60598144cecc978934b02a87567e1ced887b5b0185d5`
|
||||
|
||||
## 语音房区域飘屏
|
||||
- 已开始按《语音房区域飘屏-Flutter接口对接文档》落地:继续复用当前 `/go/app/voice-room/red-packet/im-group` 加入语区 IM 群,红包 `ROOM_RED_PACKET` 和大额礼物 `SEND_GIFT` 都从语区群接收;红包区域过滤仍尊重后端语区群,同时保留前端 `regionCode` 兜底校验。
|
||||
- 已给 `SCFloatingMessage` 增加 `broadcastScope=region` 作用域,`OverlayManager` 会把区域飘屏限制为“当前房间同房红包可展示”或“退出房间后首页四个一级 Tab 可展示”,避免在二级页、非当前房间页继续刷区域飘屏。
|
||||
- 已让首页主框架 `SCIndexPage` 接入 `RouteAware`,在首页/探索/消息/我的四个一级 Tab 位于栈顶时开启区域飘屏展示,进入房间页或其它二级页面时关闭并清理排队中的区域飘屏。
|
||||
- 已新增区域群 `SEND_GIFT` 处理:当前房间同房礼物会跳过以避免和房间 IM 礼物飘屏重复;非当前房间礼物会优先用本地礼物列表解析 `giftPhoto`,缺失时回退后端 payload 图片,点击仍复用现有礼物飘屏的 `SCRoomUtils.goRoom(...)` 进房逻辑。
|
||||
- 已继续处理红包领取入口和多红包交互:领取弹层支持多个红包横向滑动,未领取/倒计时态都会显示红包总金额;倒计时从后端 `claimStartTime` 或 `countdown` 折算成本地目标时间并每秒刷新,倒计时归零后自动切到可打开态并刷新房间红包列表;房间右侧游戏图标下方新增红包入口,有可展示红包时出现,点击进入红包领取弹层。
|
||||
- 已按 2026-05-08 最新截图继续修正领取层:`PageView` 关闭裁剪并加高承载区域,避免已领取态头像被顶部裁掉;聊天红包/飘屏红包点开时会自动合并当前房间红包列表,保证两个及以上红包可左右滑动并露出左右卡片;倒计时文案移到 `OPEN` 按钮下方;房间右侧红包入口改到音乐图标上方,显示条件改为“未领取、未过期、且服务端未明确 sold/finished”,避免缺失 `remainCount` 时入口被误隐藏。
|
||||
- 已继续修正房间右侧红包入口和已领取态:右侧入口现在同时合并后端房间红包列表与聊天区 `ROOM_RED_PACKET` 消息,避免列表接口未及时刷新时入口不出现;领取成功后会记录本机已领状态,聊天红包点击前也会拉取红包详情,已领取红包会提示“已领取过”并进入已领取结果态,不再显示可 `OPEN` 的领取态。
|
||||
- 已按新设计补充红包发送历史样式:历史列表先改为“发送红包头部 + 领取人明细”分组展示,头部展示发包金额/退款金额,明细展示领取人头像、昵称、领取时间和领取金额;发送记录若未直接返回领取明细,会用红包详情接口补拉 `records`;Rules/History 顶部返回按钮统一向左贴齐。
|
||||
- 已继续按反馈拆分历史详情:红包历史页现在只展示用户发出的红包记录,点击某条红包后进入独立的领取记录页;领取记录页按需调用红包详情接口拉取该红包的领取人列表,避免历史页直接展开导致视觉拥挤和一次性请求过多。
|
||||
- 已按 2026-05-08 16:21/16:24/16:29 截图继续修正红包领取弹层:红包详情缺少头像/昵称时会保留聊天消息或飘窗 payload 的用户资料,避免过期/抢完态顶部回退成默认红包图;多红包横向滑动的 `viewportFraction` 调大,拉开左右卡片间距;已领取成功态补强昵称展示,昵称改为红色大字,`You Get` 与金额字号、颜色、金币图标尺寸按设计稿重新微调。
|
||||
- 已修正发送红包后的飘屏兜底策略:发送成功后本地立即插入聊天红包消息并触发一次红包飘屏,`OverlayManager` 再按 `packetId` 做 30 分钟红包飘屏去重,避免后端语区 IM 回推后重复飘;同时把历史页和领取记录页顶部导航栏改为满宽布局,返回按钮真正贴齐左侧。
|
||||
- 已修正倒计时红包从聊天气泡进入被误判“已抢完”的问题:红包详情缺少 `remainCount/countdown/claimStartTime` 或状态字段不一致时,会合并聊天/房间列表里的倒计时与余量数据,未来可领取时间优先于抢完状态判断,保证聊天气泡入口和右侧红包入口展示一致。
|
||||
- 已补齐房间外区域红包飘屏进房后的领取入口:同语区 IM 收到 `ROOM_RED_PACKET` 时会按 `roomId + packetId` 缓存一份待领取红包;用户在房间外点飘屏进房后,右侧红包入口会合并接口列表、聊天红包消息和这份区域飘屏缓存,避免列表接口未及时返回时没有红包图标;领取成功或切账号登出会清理对应缓存。
|
||||
- 已补齐普通进房/重进房的红包入口刷新:正常进入房间不会回放 AVChatRoom 历史红包消息,所以右侧红包入口主链路仍依赖房间红包列表接口;进房后现在会在 0s/2s/6s 连续刷新红包列表,覆盖“刚发延时红包后用户马上进房、列表短暂未同步”的情况。
|
||||
- 已修正右侧多个红包入口领取后的残留:本机已领取状态现在会通知右侧红包入口重建,领取成功会同步移除待领取缓存并过滤列表/聊天来源里的已领红包;入口点击前也会重新计算最新可领列表,避免继续打开最后一个已领取红包。
|
||||
- 已补齐房间内同语区即时红包的主动弹层:当前房间收到 `ROOM_RED_PACKET` 时会写入缓存、刷新列表、插入聊天消息,并在非本人发送且红包已可领取时立即打开领取弹层;延时红包仍保留右侧入口和聊天入口,不强制弹等待层。
|
||||
- 已给红包历史页和领取记录页加上房间特效屏蔽:进入页面时会立即清掉当前飘屏队列、停止并暂停礼物 `VAP/SVGA` 播放和延迟特效任务;从历史页继续进入领取记录页时保持屏蔽状态,直到两个页面都退出后才恢复,避免房间里的飘屏或 SVGA 漏到红包二级页。
|
||||
- 已修正红包历史页和领取记录页的 RTL 排版:列表主文本列改回 `start` 对齐以跟随 RTL 靠右,金额列跟随 `end` 靠外侧;金额和时间强制按 LTR 渲染,避免阿语环境下时间顺序错乱或负号跑到数字后面。
|
||||
- 已按最新接口文档接入红包领取记录新接口:领取记录页优先调用 `GET /go/app/voice-room/red-packet/claim-records?packetNo=...` 拉取领取人列表,失败时再兜底旧 detail records;同时历史页和领取记录页跳转改为深色无默认转场 route,避免进入页面时先闪一下白屏。
|
||||
- 已将红包规则页替换为正式规则文案,并同步更新 `en/ar/bn/tr` 多语言;第 4 条每日房间红包发送上限不再写死,进入规则页时调用红包配置接口读取 `dailySendLimit` 后填入 `%1$s` 占位。
|
||||
|
||||
## 已知问题
|
||||
- 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji` 与 `monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。
|
||||
- 已按最新截图回调把语音房底部聊天胶囊底色改回原先的灰色半透明风格,同时保留短胶囊和右侧独立表情按钮;针对 gif 表情在选择栏、聊天框、麦位展示中偶发闪烁的问题,新增统一的房间表情 asset 展示组件,复用 `AssetImage`、提前预热本地 gif、用 `RepaintBoundary` 隔离重绘,并让 GridView 表情 item 保持状态,减少父组件刷新时 gif 重建和首帧跳动。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user