1.0.7 版本需求
@ -325,6 +325,7 @@
|
||||
"inRoom": "في الغرفة",
|
||||
"inRocket": "في الصاروخ",
|
||||
"roomRocketHelpTips": "1. إرسال الهدايا في الغرفة يزيد من طاقة الصاروخ. *هدية واحدة من العملات الذهبية = نقطة طاقة صاروخية واحدة؛ الهدايا المحظوظة تزيد من طاقة الصاروخ بنسبة 4% من قيمة العملة الذهبية للهدية.\n2. بعد اكتمال شحن الطاقة، يمكنك إطلاق الصاروخ. سيتم توزيع المكافآت تلقائيًا بعد إطلاق الصاروخ.\n3. تقدم فئات الصواريخ المختلفة مكافآت مختلفة.\n4. عند إطلاق الصاروخ، يمكن لجميع المستخدمين في الغرفة المطالبة بمكافأة الصاروخ.\n5. سيتم تحديث طاقة الصاروخ في الساعة 0:00 كل يوم.",
|
||||
"roomRocketNoteTitle": "ملاحظة",
|
||||
"roomRocketRecordAction": "السجل",
|
||||
"roomRocketRewardTop1": "الأول",
|
||||
"roomRocketRewardSetOff": "الإطلاق",
|
||||
@ -333,6 +334,7 @@
|
||||
"roomRocketRecordRoom": "الغرفة",
|
||||
"roomRocketRecordReward": "المكافأة",
|
||||
"roomRocketRecordLast35Days": "عرض سجلات آخر 35 يومًا فقط",
|
||||
"roomRocketRecordEmpty": "لم يتم العثور على سجلات مكافآت",
|
||||
"searchCouponHint": "ابحث عن كوبون",
|
||||
"search": "بحث",
|
||||
"checkInSuccessful": "تسجيل الوصول ناجح",
|
||||
|
||||
@ -389,6 +389,7 @@
|
||||
"get": "পান করুন",
|
||||
"inRocket": "রকেটে",
|
||||
"roomRocketHelpTips": "1. রুমে উপহার পাঠান রকেট শক্তি বাড়ায়। *1 সোনা কয়েন উপহার = 1 রকেট শক্তি পয়েন্ট; ভাগ্যশালী উপহার রকেট শক্তি উপহারের সোনা কয়েন মানের 4% পরিমাণে বাড়ায়।\n2. রকেট শক্তি সম্পূর্ণভাবে পূর্ণ হলে, রুম রকেট চালু করতে পারে। চালু করার পর পুরস্কার স্বয়ংক্রিয়ভাবে বিতরণ করা হবে।\n3. বিভিন্ন রকেট স্তর বিভিন্ন পুরস্কার প্রদান করে।\n4. রকেট চালু হলে, রুমের সমস্ত ব্যবহারকারী রকেট পুরস্কার দাবি করতে পারে।5. রকেট শক্তি প্রতিদিন 00:00 এ শূন্য হয়ে যায়।",
|
||||
"roomRocketNoteTitle": "দ্রষ্টব্য",
|
||||
"roomRocketRecordAction": "রেকর্ড",
|
||||
"roomRocketRewardTop1": "শীর্ষ 1",
|
||||
"roomRocketRewardSetOff": "চালু",
|
||||
@ -397,6 +398,7 @@
|
||||
"roomRocketRecordRoom": "রুম",
|
||||
"roomRocketRecordReward": "পুরস্কার",
|
||||
"roomRocketRecordLast35Days": "শুধু গত ৩৫ দিনের রেকর্ড দেখান",
|
||||
"roomRocketRecordEmpty": "কোনো পুরস্কারের রেকর্ড পাওয়া যায়নি",
|
||||
"couponRecord": "কুপন ব্যবহার রেকর্ড",
|
||||
"inRoom": "রুমে",
|
||||
"searchCouponHint": "কুপন সার্চ করুন",
|
||||
|
||||
@ -343,6 +343,7 @@
|
||||
"get": "Get",
|
||||
"inRocket": "In the rocket",
|
||||
"roomRocketHelpTips": "1. Sending gifts in the room increases rocket energy. *1 gold coin gift = 1 rocket energy point; lucky gifts increase rocket energy by 4% of the gift's gold coin value.\n2. Once the rocket energy is fully charged, the room can launch the rocket. Rewards will be automatically distributed after launch.\n3. Different rocket levels offer different rewards.\n4. When the rocket launches, all users in the room can claim the rocket reward.5. Rocket energy is reset at 00:00 every day.",
|
||||
"roomRocketNoteTitle": "Note",
|
||||
"roomRocketRecordAction": "Record",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "Set off",
|
||||
@ -351,6 +352,7 @@
|
||||
"roomRocketRecordRoom": "Room",
|
||||
"roomRocketRecordReward": "Reward",
|
||||
"roomRocketRecordLast35Days": "Show only records from the last 35 days",
|
||||
"roomRocketRecordEmpty": "No reward records found",
|
||||
"couponRecord": "Coupon usage record",
|
||||
"inRoom": "In Room",
|
||||
"searchCouponHint": "Search Coupon",
|
||||
|
||||
@ -343,6 +343,7 @@
|
||||
"get": "Kunin",
|
||||
"inRocket": "Sa rocket",
|
||||
"roomRocketHelpTips": "1. Ang pagpapadala ng mga regalo sa silid ay nagpapataas ng enerhiya ng rocket. *1 regalong gintong barya = 1 rocket energy point; pinapataas ng mga masuwerteng regalo ang rocket energy ng 4% ng halaga ng gintong barya ng regalo.\n2. Kapag ang enerhiya ng rocket ay ganap na na-charge, maaaring ilunsad ng silid ang rocket. Awtomatikong ipapamahagi ang mga reward pagkatapos ilunsad.\n3. Ang iba't ibang antas ng rocket ay nag-aalok ng iba't ibang mga gantimpala.\n4. Kapag naglunsad ang rocket, lahat ng user sa kuwarto ay maaaring mag-claim ng rocket reward.5. Nire-reset ang rocket energy sa 00:00 araw-araw.",
|
||||
"roomRocketNoteTitle": "Paalala",
|
||||
"roomRocketRecordAction": "Record",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "Set off",
|
||||
@ -351,6 +352,7 @@
|
||||
"roomRocketRecordRoom": "Room",
|
||||
"roomRocketRecordReward": "Reward",
|
||||
"roomRocketRecordLast35Days": "Ipakita lang ang records sa nakaraang 35 araw",
|
||||
"roomRocketRecordEmpty": "Walang nakitang reward records",
|
||||
"couponRecord": "Talaan ng paggamit ng kupon",
|
||||
"inRoom": "Sa Kwarto",
|
||||
"searchCouponHint": "Search Coupon",
|
||||
|
||||
@ -343,6 +343,7 @@
|
||||
"get": "पाना",
|
||||
"inRocket": "रॉकेट में",
|
||||
"roomRocketHelpTips": "1. कमरे में उपहार भेजने से रॉकेट ऊर्जा बढ़ती है। *1 सोने का सिक्का उपहार = 1 रॉकेट ऊर्जा बिंदु; भाग्यशाली उपहार रॉकेट ऊर्जा को उपहार के सोने के सिक्के के मूल्य से 4% तक बढ़ा देते हैं।\n2. एक बार रॉकेट ऊर्जा पूरी तरह चार्ज हो जाने पर, कक्ष रॉकेट लॉन्च कर सकता है। लॉन्च के बाद पुरस्कार स्वचालित रूप से वितरित किए जाएंगे।\n3. विभिन्न रॉकेट स्तर अलग-अलग पुरस्कार प्रदान करते हैं।\n4. जब रॉकेट लॉन्च होता है, तो कमरे में मौजूद सभी उपयोगकर्ता रॉकेट इनाम का दावा कर सकते हैं।5. रॉकेट ऊर्जा हर दिन 00:00 बजे रीसेट की जाती है।",
|
||||
"roomRocketNoteTitle": "नोट",
|
||||
"roomRocketRecordAction": "रिकॉर्ड",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "लॉन्च",
|
||||
@ -351,6 +352,7 @@
|
||||
"roomRocketRecordRoom": "कमरा",
|
||||
"roomRocketRecordReward": "इनाम",
|
||||
"roomRocketRecordLast35Days": "केवल पिछले 35 दिनों के रिकॉर्ड दिखाएं",
|
||||
"roomRocketRecordEmpty": "कोई इनाम रिकॉर्ड नहीं मिला",
|
||||
"couponRecord": "कूपन उपयोग रिकॉर्ड",
|
||||
"inRoom": "कमरे में",
|
||||
"searchCouponHint": "कूपन खोजें",
|
||||
|
||||
@ -343,6 +343,7 @@
|
||||
"get": "Mendapatkan",
|
||||
"inRocket": "Di dalam roket",
|
||||
"roomRocketHelpTips": "1. Mengirim hadiah di dalam ruangan meningkatkan energi roket. *1 hadiah koin emas = 1 poin energi roket; hadiah keberuntungan meningkatkan energi roket sebesar 4% dari nilai koin emas hadiah.\n2. Setelah energi roket terisi penuh, ruangan dapat meluncurkan roket. Hadiah akan didistribusikan secara otomatis setelah peluncuran.\n3. Level roket yang berbeda menawarkan hadiah yang berbeda.\n4. Saat roket diluncurkan, semua pengguna di ruangan dapat mengklaim hadiah roket.5. Energi roket disetel ulang pada pukul 00:00 setiap hari.",
|
||||
"roomRocketNoteTitle": "Catatan",
|
||||
"roomRocketRecordAction": "Catatan",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "Diluncurkan",
|
||||
@ -351,6 +352,7 @@
|
||||
"roomRocketRecordRoom": "Ruangan",
|
||||
"roomRocketRecordReward": "Hadiah",
|
||||
"roomRocketRecordLast35Days": "Hanya tampilkan catatan 35 hari terakhir",
|
||||
"roomRocketRecordEmpty": "Tidak ada catatan hadiah",
|
||||
"couponRecord": "Catatan penggunaan kupon",
|
||||
"inRoom": "Di Kamar",
|
||||
"searchCouponHint": "Cari Kupon",
|
||||
|
||||
@ -359,6 +359,7 @@
|
||||
"get": "Al",
|
||||
"inRocket": "Rokette",
|
||||
"roomRocketHelpTips": "1. Odada hediye göndermek roket enerjisini artırır. *1 altın jetton hediyesi = 1 roket enerji puanı; şanslı hediyeler roket enerjisini hediyenin altın jetton değerinin %4'ü kadar artırır.\n2. Roket enerjisi tamamen dolduğunda, oda roketi fırlatabilir. Fırlatmadan sonra ödüller otomatik olarak dağıtılacaktır.\n3. Farklı roket seviyeleri farklı ödüller sunar.\n4. Roket fırlatıldığında, odadaki tüm kullanıcılar roket ödülünü talep edebilir.5. Roket enerjisi her gün 00:00'da sıfırlanır.",
|
||||
"roomRocketNoteTitle": "Not",
|
||||
"roomRocketRecordAction": "Kayıt",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "Fırlatan",
|
||||
@ -367,6 +368,7 @@
|
||||
"roomRocketRecordRoom": "Oda",
|
||||
"roomRocketRecordReward": "Ödül",
|
||||
"roomRocketRecordLast35Days": "Yalnızca son 35 günün kayıtlarını göster",
|
||||
"roomRocketRecordEmpty": "Ödül kaydı bulunamadı",
|
||||
"couponRecord": "Kupon Kullanım Kaydı",
|
||||
"inRoom": "Odada",
|
||||
"searchCouponHint": "Kupon Ara",
|
||||
|
||||
@ -343,6 +343,7 @@
|
||||
"get": "حاصل کریں۔",
|
||||
"inRocket": "راکٹ میں",
|
||||
"roomRocketHelpTips": "1. کمرے میں تحائف بھیجنے سے راکٹ توانائی میں اضافہ ہوتا ہے۔ *1 سونے کا سکہ تحفہ = 1 راکٹ انرجی پوائنٹ؛ خوش قسمت تحائف تحفے کے سونے کے سکوں کی قیمت کا 4% راکٹ توانائی میں اضافہ کرتے ہیں۔\n2. راکٹ کی توانائی مکمل طور پر چارج ہونے کے بعد، کمرہ راکٹ کو لانچ کر سکتا ہے۔ لانچ کے بعد انعامات خود بخود تقسیم ہو جائیں گے۔\n3. راکٹ کی مختلف سطحیں مختلف انعامات پیش کرتی ہیں۔\n4. جب راکٹ لانچ ہوتا ہے، کمرے میں موجود تمام صارفین راکٹ کے انعام کا دعوی کر سکتے ہیں۔5۔ راکٹ توانائی ہر روز 00:00 پر دوبارہ ترتیب دی جاتی ہے۔",
|
||||
"roomRocketNoteTitle": "نوٹ",
|
||||
"roomRocketRecordAction": "ریکارڈ",
|
||||
"roomRocketRewardTop1": "Top1",
|
||||
"roomRocketRewardSetOff": "لانچ",
|
||||
@ -351,6 +352,7 @@
|
||||
"roomRocketRecordRoom": "کمرہ",
|
||||
"roomRocketRecordReward": "انعام",
|
||||
"roomRocketRecordLast35Days": "صرف گزشتہ 35 دنوں کے ریکارڈ دکھائیں",
|
||||
"roomRocketRecordEmpty": "انعام کا کوئی ریکارڈ نہیں ملا",
|
||||
"couponRecord": "کوپن کے استعمال کا ریکارڈ",
|
||||
"inRoom": "کمرے میں",
|
||||
"searchCouponHint": "کوپن تلاش کریں۔",
|
||||
|
||||
BIN
atu_images/room/at_icon_room_rocket_floating_bg.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
BIN
atu_images/room/at_icon_room_rocket_floating_rocket.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
atu_images/room/at_icon_room_rocket_go.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
atu_images/room/at_icon_room_rocket_record_close.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
atu_images/room/at_icon_room_rocket_reward_avatar_frame.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
atu_images/room/at_icon_room_rocket_reward_coin.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
atu_images/room/at_icon_room_rocket_reward_empty.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
atu_images/room/at_icon_room_rocket_reward_gift.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
atu_images/room/at_icon_room_rocket_reward_item_bg.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
design-qa-assets/rocket-floating-comparison.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
design-qa-assets/rocket-floating-full-comparison.png
Normal file
|
After Width: | Height: | Size: 675 KiB |
BIN
design-qa-assets/rocket-floating-implementation.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
design-qa-assets/rocket-reward-comparison.png
Normal file
|
After Width: | Height: | Size: 602 KiB |
BIN
design-qa-assets/rocket-reward-empty-comparison.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
design-qa-assets/rocket-reward-empty-figma.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
design-qa-assets/rocket-reward-empty-full-comparison.png
Normal file
|
After Width: | Height: | Size: 715 KiB |
BIN
design-qa-assets/rocket-reward-empty-implementation.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
design-qa-assets/rocket-reward-full-comparison.png
Normal file
|
After Width: | Height: | Size: 768 KiB |
BIN
design-qa-assets/rocket-reward-implementation.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
56
design-qa.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Rocket Floating Window Design QA
|
||||
|
||||
- Source visual truth: Figma nodes `3462:8689` and `3462:4964` in `vaDp5wwn6AOQVlt9zk0f9r`.
|
||||
- Additional visual truth: Figma node `3462:5730` for the not-won reward state.
|
||||
- Not-won source screenshot: `design-qa-assets/rocket-reward-empty-figma.png`.
|
||||
- Implementation screenshots:
|
||||
- `design-qa-assets/rocket-floating-implementation.png`
|
||||
- `design-qa-assets/rocket-reward-implementation.png`
|
||||
- `design-qa-assets/rocket-reward-empty-implementation.png`
|
||||
- Viewport: 375 × 812 at device pixel ratio 1.
|
||||
- State: floating banner centered after its entrance animation; reward panel open over a dimmed host surface.
|
||||
- Integration: implemented as the independent `RoomRocketFloatingPopup`; the original rocket page/widget remains unchanged.
|
||||
|
||||
## Full-view comparison evidence
|
||||
|
||||
- `design-qa-assets/rocket-floating-full-comparison.png`
|
||||
- `design-qa-assets/rocket-reward-full-comparison.png`
|
||||
- `design-qa-assets/rocket-reward-empty-full-comparison.png`
|
||||
|
||||
The left side is the Figma reference and the right side is the Flutter capture. The host room surface is intentionally represented by a solid test background on the implementation side because the deliverable is the overlay UI. Full-view evidence verifies the overlay position and dimensions within the 375 × 812 viewport.
|
||||
|
||||
## Focused comparison evidence
|
||||
|
||||
- `design-qa-assets/rocket-floating-comparison.png`
|
||||
- `design-qa-assets/rocket-reward-comparison.png`
|
||||
- `design-qa-assets/rocket-reward-empty-comparison.png`
|
||||
|
||||
Focused crops were required because the banner typography, reward-card spacing, small coin labels, and decorative frame details are too small to judge reliably in the full-view comparison.
|
||||
|
||||
## Findings
|
||||
|
||||
- No actionable P0, P1, or P2 visual differences remain.
|
||||
- Fonts and typography: sizes, weights, line heights, truncation, and hierarchy match the source. The capture uses Arial as a QA substitute for Source Han Sans SC; the app continues to use its platform system font, consistent with the existing project setup.
|
||||
- Spacing and layout rhythm: the banner is 355 × 103 and begins at y=115 in the full room viewport. The reward panel is 360 × 576 and begins at x=8, y=118. Reward cards, section headings, rows, and footer loading text align with the Figma coordinates.
|
||||
- Colors and visual tokens: gold/orange surfaces, border highlights, dim mask, and white text use the original Figma raster assets and match the reference palette.
|
||||
- Image quality and asset fidelity: the banner frame, GO button, rocket, reward frame, reward cards, gift, coin, close button, and avatar frame use source assets. New large assets were resized to approximately 3× their rendered dimensions to preserve sharpness while reducing decode cost.
|
||||
- Copy and content: `Reward`, `Congratulations on winning`, `12 users received rewards`, prize values, and loading copy match the reference. Avatar/name/reward records remain UI-preview content pending the later business-data requirements.
|
||||
- Not-won state: the single `RoomRocketRewardPage` switches on `hasRewards`; the 125 × 126 empty reward artwork and `You didn't win a prize. Better luck next time!` copy match node `3462:5730`. The shared frame, close action, winner rows, and loading footer retain the same geometry as the won state.
|
||||
|
||||
## Comparison history
|
||||
|
||||
1. Initial capture exposed reward-title and mini-prize text overflows and incomplete image decoding.
|
||||
- Fix: added constrained scaling for narrow labels and waited for raster decoding before capture.
|
||||
- Post-fix evidence: both focused comparison images; widget test completes without render exceptions.
|
||||
2. Section decorations rendered too densely.
|
||||
- Fix: mirrored and spaced the slash marks to match the Figma headings.
|
||||
- Post-fix evidence: `design-qa-assets/rocket-reward-comparison.png`.
|
||||
3. The floating-window rocket crop was outside the visible slot.
|
||||
- Fix: aligned the source rocket artwork to the top of the 20 × 29 crop.
|
||||
- Post-fix evidence: `design-qa-assets/rocket-floating-comparison.png`.
|
||||
|
||||
## Follow-up polish
|
||||
|
||||
- P3: production captures will naturally differ from the QA placeholders because real user avatars, names, and reward records will be supplied by the later functional requirements.
|
||||
|
||||
final result: passed
|
||||
@ -744,6 +744,8 @@ class ATAppLocalizations {
|
||||
|
||||
String get roomRocketHelpTips => translate('roomRocketHelpTips');
|
||||
|
||||
String get roomRocketNoteTitle => translate('roomRocketNoteTitle');
|
||||
|
||||
String get roomRocketRecordAction => translate('roomRocketRecordAction');
|
||||
|
||||
String get roomRocketRewardTop1 => translate('roomRocketRewardTop1');
|
||||
@ -761,6 +763,8 @@ class ATAppLocalizations {
|
||||
String get roomRocketRecordLast35Days =>
|
||||
translate('roomRocketRecordLast35Days');
|
||||
|
||||
String get roomRocketRecordEmpty => translate('roomRocketRecordEmpty');
|
||||
|
||||
String get giveUpIdentity => translate('giveUpIdentity');
|
||||
|
||||
String get bdLeader => translate('bdLeader');
|
||||
|
||||
100
lib/chatvibe_domain/models/res/at_room_rocket_reward_res.dart
Normal file
@ -0,0 +1,100 @@
|
||||
class ATRoomRocketRewardRes {
|
||||
const ATRoomRocketRewardRes({
|
||||
this.rewardType = '',
|
||||
this.rewardItemId = '',
|
||||
this.rewardItemName = '',
|
||||
this.rewardAmount,
|
||||
this.cover = '',
|
||||
this.rocketLevel = 1,
|
||||
this.isContributor = false,
|
||||
this.contributionEnergy,
|
||||
this.contributionRate,
|
||||
this.message = '',
|
||||
this.userId = '',
|
||||
this.userNickname = '',
|
||||
this.userAvatar = '',
|
||||
});
|
||||
|
||||
final String rewardType;
|
||||
final String rewardItemId;
|
||||
final String rewardItemName;
|
||||
final num? rewardAmount;
|
||||
final String cover;
|
||||
final int rocketLevel;
|
||||
final bool isContributor;
|
||||
final num? contributionEnergy;
|
||||
final num? contributionRate;
|
||||
final String message;
|
||||
|
||||
/// `ROCKET_REWARD_USER` 消息字段;领取接口响应中不包含这些字段。
|
||||
final String userId;
|
||||
final String userNickname;
|
||||
final String userAvatar;
|
||||
|
||||
bool get isCoin {
|
||||
final String type = rewardType.trim().toUpperCase();
|
||||
return type == 'GOLD' || type == 'COIN' || type == 'DIAMOND';
|
||||
}
|
||||
|
||||
String get displayLabel {
|
||||
if (rewardAmount != null) {
|
||||
return _formatNumber(rewardAmount!);
|
||||
}
|
||||
if (rewardItemName.isNotEmpty) {
|
||||
return rewardItemName;
|
||||
}
|
||||
return rewardType.isEmpty ? 'Reward' : rewardType;
|
||||
}
|
||||
|
||||
factory ATRoomRocketRewardRes.fromJson(dynamic data) {
|
||||
final Map<String, dynamic> json = _asMap(data);
|
||||
return ATRoomRocketRewardRes(
|
||||
rewardType: _readString(json['rewardType']),
|
||||
rewardItemId: _readString(json['rewardItemId']),
|
||||
rewardItemName: _readString(json['rewardItemName']),
|
||||
rewardAmount: _readNum(json['rewardAmount']),
|
||||
cover: _readString(json['cover']),
|
||||
rocketLevel: _readInt(json['rocketLevel'], fallback: 1),
|
||||
isContributor: json['isContributor'] == true,
|
||||
contributionEnergy: _readNum(json['contributionEnergy']),
|
||||
contributionRate: _readNum(json['contributionRate']),
|
||||
message: _readString(json['message']),
|
||||
userId: _readString(json['userId']),
|
||||
userNickname: _readString(json['userNickname']),
|
||||
userAvatar: _readString(json['userAvatar']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(dynamic data) {
|
||||
if (data is! Map) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
return data.map(
|
||||
(dynamic key, dynamic value) =>
|
||||
MapEntry<String, dynamic>(key.toString(), value),
|
||||
);
|
||||
}
|
||||
|
||||
String _readString(dynamic value) => value?.toString().trim() ?? '';
|
||||
|
||||
num? _readNum(dynamic value) {
|
||||
if (value is num) {
|
||||
return value;
|
||||
}
|
||||
return num.tryParse(_readString(value));
|
||||
}
|
||||
|
||||
int _readInt(dynamic value, {required int fallback}) {
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(_readString(value)) ?? fallback;
|
||||
}
|
||||
|
||||
String _formatNumber(num value) {
|
||||
if (value is int || value == value.roundToDouble()) {
|
||||
return value.toInt().toString();
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_debouncer/flutter_debouncer.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:aslan/chatvibe_ui/components/at_compontent.dart';
|
||||
import 'package:aslan/chatvibe_core/utilities/at_room_utils.dart';
|
||||
import 'package:aslan/chatvibe_data/models/message/at_floating_message.dart';
|
||||
import 'package:aslan/chatvibe_managers/rtc_manager.dart';
|
||||
import 'package:aslan/main.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class RoomRocketFloatingPopup extends StatefulWidget {
|
||||
final ATFloatingMessage message;
|
||||
final VoidCallback onAnimationCompleted; // 动画完成回调
|
||||
|
||||
const RoomRocketFloatingPopup({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onAnimationCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RoomRocketFloatingPopup> createState() =>
|
||||
_RoomRocketFloatingPopupState();
|
||||
}
|
||||
|
||||
class _RoomRocketFloatingPopupState extends State<RoomRocketFloatingPopup>
|
||||
with TickerProviderStateMixin {
|
||||
Debouncer debouncer = Debouncer();
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _firstAnimation; // 第一段:从右到中
|
||||
late Animation<Offset> _secondAnimation; // 第二段:从中到左
|
||||
late AnimationController _swipeController; // 新增:滑动动画控制器
|
||||
late Animation<Offset> _swipeAnimation; // 新增:滑动动画
|
||||
|
||||
bool _firstPhaseCompleted = false;
|
||||
bool _isSwipeAnimating = false; // 标记是否正在执行滑动动画
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 主动画控制器
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500), // 每段动画1.5秒
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// 滑动动画控制器
|
||||
_swipeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 550), // 滑动动画500ms
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// 监听滑动动画完成
|
||||
_swipeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
widget.onAnimationCompleted();
|
||||
}
|
||||
});
|
||||
|
||||
// 第一段动画:从右侧飘到中间
|
||||
_firstAnimation = Tween<Offset>(
|
||||
begin: const Offset(1.5, 0.0), // 从屏幕右侧外开始
|
||||
end: Offset.zero, // 到屏幕中心
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
|
||||
|
||||
// 第二段动画:从中间飘到左侧
|
||||
_secondAnimation = Tween<Offset>(
|
||||
begin: Offset.zero, // 从屏幕中心开始
|
||||
end: const Offset(-1.5, 0.0), // 到屏幕左侧外
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
|
||||
|
||||
// 滑动动画:从当前位置滑动到左侧屏幕外
|
||||
_swipeAnimation = Tween<Offset>(
|
||||
begin: Offset.zero, // 从当前位置开始
|
||||
end: const Offset(-1.5, 0.0), // 到屏幕左侧外
|
||||
).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn));
|
||||
|
||||
// 延迟启动动画
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_startAnimationSequence();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startAnimationSequence() async {
|
||||
// 第一阶段:从右到中
|
||||
_controller.reset();
|
||||
await _controller.forward();
|
||||
|
||||
// 停顿3秒
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 第二阶段:从中到左
|
||||
setState(() {
|
||||
_firstPhaseCompleted = true;
|
||||
});
|
||||
_controller.reset();
|
||||
await _controller.forward();
|
||||
|
||||
if (mounted && !_isSwipeAnimating) {
|
||||
widget.onAnimationCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_swipeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 如果正在执行滑动动画,使用滑动动画
|
||||
if (_isSwipeAnimating) {
|
||||
return SlideTransition(
|
||||
position: _swipeAnimation,
|
||||
child: _buildRocketAnimation(),
|
||||
);
|
||||
}
|
||||
|
||||
// 否则使用原来的动画
|
||||
return SlideTransition(
|
||||
position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation,
|
||||
child: _buildRocketAnimation(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRocketAnimation() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
debouncer.debounce(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
onDebounce: () {
|
||||
final String targetRoomId = widget.message.roomId?.trim() ?? '';
|
||||
final BuildContext? currentContext =
|
||||
navigatorKey.currentState?.context;
|
||||
if (targetRoomId.isEmpty || currentContext == null) {
|
||||
return;
|
||||
}
|
||||
final String currentRoomId =
|
||||
Provider.of<RealTimeCommunicationManager>(
|
||||
currentContext,
|
||||
listen: false,
|
||||
).currenRoom?.roomProfile?.roomProfile?.id ??
|
||||
'';
|
||||
if (currentRoomId == targetRoomId) {
|
||||
return;
|
||||
}
|
||||
widget.onAnimationCompleted();
|
||||
ATRoomUtils.goRoom(targetRoomId, currentContext, fromFloting: true);
|
||||
},
|
||||
);
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
// 获取当前的文本方向
|
||||
final textDirection = Directionality.of(context);
|
||||
|
||||
// 定义一个根据方向转换速度符号的辅助函数
|
||||
double effectiveVelocity(double velocity) {
|
||||
// 在RTL模式下,反转速度的正负号
|
||||
return textDirection == TextDirection.rtl ? -velocity : velocity;
|
||||
}
|
||||
|
||||
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
|
||||
if (velocity < 0) {
|
||||
///向左滑动,把当前视图向左平移出去
|
||||
_handleSwipeLeft();
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
width: ScreenUtil().screenWidth,
|
||||
height: 148.w,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Container(
|
||||
width: 355.w,
|
||||
height: 103.w,
|
||||
margin: EdgeInsets.only(top: 45.w),
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
"atu_images/room/at_icon_room_rocket_floating_bg.png",
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 57.w,
|
||||
top: 44.w,
|
||||
width: 30.w,
|
||||
height: 30.w,
|
||||
child: _buildAvatar(),
|
||||
),
|
||||
Positioned(
|
||||
left: 93.w,
|
||||
top: 43.w,
|
||||
width: 107.w,
|
||||
child: Text(
|
||||
_displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
height: 16 / 14,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 93.w,
|
||||
top: 65.w,
|
||||
width: 107.w,
|
||||
child: Text(
|
||||
_displaySubtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.61),
|
||||
fontSize: 10.sp,
|
||||
height: 1.2,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 205.w,
|
||||
top: 40.w,
|
||||
width: 36.w,
|
||||
height: 36.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
"atu_images/room/at_icon_room_rocket_reward_item_bg.png",
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
_buildRewardIcon(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 250.w,
|
||||
top: 47.w,
|
||||
width: 51.w,
|
||||
height: 23.w,
|
||||
child: Image.asset(
|
||||
"atu_images/room/at_icon_room_rocket_go.png",
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String get _displayName {
|
||||
final value = widget.message.userName ?? "";
|
||||
return value.isEmpty ? "Namenamna…" : value;
|
||||
}
|
||||
|
||||
String get _displaySubtitle {
|
||||
final value = widget.message.userId ?? "";
|
||||
return value.isEmpty ? "ababababab" : value;
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
final url = widget.message.userAvatarUrl ?? "";
|
||||
return ClipOval(
|
||||
child:
|
||||
url.isEmpty
|
||||
? Image.asset(
|
||||
"atu_images/general/at_icon_avar_defalt.png",
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: netImage(
|
||||
url: url,
|
||||
width: 30.w,
|
||||
height: 30.w,
|
||||
shape: BoxShape.circle,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRewardIcon() {
|
||||
final url = widget.message.giftUrl ?? "";
|
||||
if (url.isEmpty) {
|
||||
return Image.asset(
|
||||
"atu_images/room/at_icon_room_rocket_floating_rocket.png",
|
||||
width: 20.w,
|
||||
height: 29.w,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
);
|
||||
}
|
||||
return netImage(
|
||||
url: url,
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
fit: BoxFit.contain,
|
||||
noDefaultImg: true,
|
||||
);
|
||||
}
|
||||
|
||||
// 处理向左滑动
|
||||
void _handleSwipeLeft() {
|
||||
if (_isSwipeAnimating) return;
|
||||
|
||||
setState(() {
|
||||
_isSwipeAnimating = true;
|
||||
});
|
||||
|
||||
// 停止主动画
|
||||
_controller.stop();
|
||||
|
||||
// 启动滑动动画
|
||||
_swipeController.reset();
|
||||
_swipeController.forward();
|
||||
}
|
||||
}
|
||||
473
lib/chatvibe_ui/widgets/room/rocket/room_rocket_reward_page.dart
Normal file
@ -0,0 +1,473 @@
|
||||
import 'package:aslan/chatvibe_domain/models/res/at_room_rocket_reward_res.dart';
|
||||
import 'package:aslan/chatvibe_ui/components/at_compontent.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
|
||||
class RoomRocketRewardPage extends StatelessWidget {
|
||||
static const String dialogTag = 'showRoomRocketRewardDialog';
|
||||
|
||||
const RoomRocketRewardPage({
|
||||
super.key,
|
||||
required this.rewards,
|
||||
required this.currentUserId,
|
||||
this.igniterUserId = '',
|
||||
});
|
||||
|
||||
final List<ATRoomRocketRewardRes> rewards;
|
||||
final String currentUserId;
|
||||
final String igniterUserId;
|
||||
|
||||
List<ATRoomRocketRewardRes> get _currentUserRewards => rewards
|
||||
.where((reward) => reward.userId == currentUserId)
|
||||
.toList(growable: false);
|
||||
|
||||
List<_RocketRewardWinner> get _winners {
|
||||
final Map<String, List<ATRoomRocketRewardRes>> rewardsByUser =
|
||||
<String, List<ATRoomRocketRewardRes>>{};
|
||||
for (final reward in rewards) {
|
||||
final String userId = reward.userId.trim();
|
||||
if (userId.isEmpty || userId == '0') {
|
||||
continue;
|
||||
}
|
||||
rewardsByUser
|
||||
.putIfAbsent(userId, () => <ATRoomRocketRewardRes>[])
|
||||
.add(reward);
|
||||
}
|
||||
final List<_RocketRewardWinner> winners = rewardsByUser.entries
|
||||
.map((entry) {
|
||||
final ATRoomRocketRewardRes firstReward = entry.value.first;
|
||||
final bool isIgniter = entry.key == igniterUserId;
|
||||
return _RocketRewardWinner(
|
||||
userId: entry.key,
|
||||
name:
|
||||
firstReward.userNickname.isNotEmpty
|
||||
? firstReward.userNickname
|
||||
: 'User ${entry.key}',
|
||||
avatarUrl: firstReward.userAvatar,
|
||||
rewards: List<ATRoomRocketRewardRes>.unmodifiable(entry.value),
|
||||
isIgniter: isIgniter,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
winners.sort((left, right) {
|
||||
if (left.isIgniter != right.isIgniter) {
|
||||
return left.isIgniter ? -1 : 1;
|
||||
}
|
||||
return left.userId.compareTo(right.userId);
|
||||
});
|
||||
return winners;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<ATRoomRocketRewardRes> currentRewards = _currentUserRewards;
|
||||
final List<_RocketRewardWinner> winners = _winners;
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(
|
||||
width: 360.w,
|
||||
height: 576.w,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_record_bg.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 3,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _RocketRewardText(
|
||||
'Reward',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 30.w,
|
||||
right: 24.w,
|
||||
width: 24.w,
|
||||
height: 23.w,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => SmartDialog.dismiss(tag: dialogTag),
|
||||
child: Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_record_close.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 39,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _RocketRewardSectionTitle(
|
||||
label: 'Congratulations on winning',
|
||||
),
|
||||
),
|
||||
if (currentRewards.isNotEmpty)
|
||||
Positioned(
|
||||
top: 65.w,
|
||||
left: 20.w,
|
||||
right: 19.w,
|
||||
height: 206.w,
|
||||
child: _buildPrizeGrid(currentRewards),
|
||||
)
|
||||
else ...[
|
||||
Positioned(
|
||||
top: 88.w,
|
||||
left: 117.w,
|
||||
width: 125.w,
|
||||
height: 126.w,
|
||||
child: Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_reward_empty.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 240,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _RocketRewardText(
|
||||
"You didn't win a prize. Better luck next time!",
|
||||
fontSize: 14,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
top: 291.w,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _RocketRewardSectionTitle(
|
||||
label: '${winners.length} users received rewards',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 309.w,
|
||||
left: 16.w,
|
||||
right: 11.w,
|
||||
height: 204.w,
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: winners.length,
|
||||
itemExtent: 51.w,
|
||||
itemBuilder:
|
||||
(_, index) => _RocketRewardWinnerRow(
|
||||
winner: winners[index],
|
||||
showLargeFrame: index < 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrizeGrid(List<ATRoomRocketRewardRes> currentRewards) {
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 15.w,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: currentRewards.length,
|
||||
itemBuilder:
|
||||
(_, index) => _RocketRewardPrize(reward: currentRewards[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardPrize extends StatelessWidget {
|
||||
const _RocketRewardPrize({required this.reward});
|
||||
|
||||
final ATRoomRocketRewardRes reward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_reward_item_bg.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 11.w,
|
||||
width: 54.w,
|
||||
height: 54.w,
|
||||
child: _RocketRewardIcon(reward: reward, size: 54.w),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10.w,
|
||||
left: 2.w,
|
||||
right: 2.w,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (reward.isCoin) ...[
|
||||
Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_reward_coin.png',
|
||||
width: 12.w,
|
||||
height: 12.w,
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
],
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _RocketRewardText(
|
||||
reward.displayLabel,
|
||||
fontSize: 12,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardWinnerRow extends StatelessWidget {
|
||||
const _RocketRewardWinnerRow({
|
||||
required this.winner,
|
||||
required this.showLargeFrame,
|
||||
});
|
||||
|
||||
final _RocketRewardWinner winner;
|
||||
final bool showLargeFrame;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50.w,
|
||||
height: 50.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_RocketRewardAvatar(url: winner.avatarUrl, size: 30.w),
|
||||
if (showLargeFrame)
|
||||
Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_reward_avatar_frame.png',
|
||||
width: 50.w,
|
||||
height: 50.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _RocketRewardText(
|
||||
winner.name,
|
||||
fontSize: 14,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
...winner.rewards
|
||||
.take(4)
|
||||
.map(
|
||||
(reward) => Padding(
|
||||
padding: EdgeInsets.only(left: 5.w),
|
||||
child: _RocketRewardMiniPrize(reward: reward),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardMiniPrize extends StatelessWidget {
|
||||
const _RocketRewardMiniPrize({required this.reward});
|
||||
|
||||
final ATRoomRocketRewardRes reward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 31.w,
|
||||
height: 32.w,
|
||||
child: Column(
|
||||
children: [
|
||||
_RocketRewardIcon(reward: reward, size: 22.w),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (reward.isCoin)
|
||||
Image.asset(
|
||||
'atu_images/room/at_icon_room_rocket_reward_coin.png',
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
),
|
||||
SizedBox(
|
||||
width: reward.isCoin ? 20.w : 27.w,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _RocketRewardText(
|
||||
reward.displayLabel,
|
||||
fontSize: 8,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardIcon extends StatelessWidget {
|
||||
const _RocketRewardIcon({required this.reward, required this.size});
|
||||
|
||||
final ATRoomRocketRewardRes reward;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (reward.cover.isNotEmpty) {
|
||||
return netImage(
|
||||
url: reward.cover,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
noDefaultImg: true,
|
||||
);
|
||||
}
|
||||
return Image.asset(
|
||||
reward.isCoin
|
||||
? 'atu_images/room/at_icon_room_rocket_reward_coin.png'
|
||||
: 'atu_images/room/at_icon_room_rocket_reward_gift.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardAvatar extends StatelessWidget {
|
||||
const _RocketRewardAvatar({required this.url, required this.size});
|
||||
|
||||
final String url;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipOval(
|
||||
child:
|
||||
url.isEmpty
|
||||
? Image.asset(
|
||||
'atu_images/general/at_icon_avar_defalt.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: netImage(
|
||||
url: url,
|
||||
width: size,
|
||||
height: size,
|
||||
shape: BoxShape.circle,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardSectionTitle extends StatelessWidget {
|
||||
const _RocketRewardSectionTitle({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.diagonal3Values(-1, 1, 1),
|
||||
child: const _RocketRewardText('///', fontSize: 10, letterSpacing: 1),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _RocketRewardText(label, fontSize: 14),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
const _RocketRewardText('///', fontSize: 10, letterSpacing: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardText extends StatelessWidget {
|
||||
const _RocketRewardText(
|
||||
this.value, {
|
||||
required this.fontSize,
|
||||
this.fontWeight = FontWeight.w400,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.letterSpacing,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final double fontSize;
|
||||
final FontWeight fontWeight;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final double? letterSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
value,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize.sp,
|
||||
fontWeight: fontWeight,
|
||||
height: 1,
|
||||
letterSpacing: letterSpacing,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RocketRewardWinner {
|
||||
const _RocketRewardWinner({
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.avatarUrl,
|
||||
required this.rewards,
|
||||
required this.isIgniter,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String name;
|
||||
final String avatarUrl;
|
||||
final List<ATRoomRocketRewardRes> rewards;
|
||||
final bool isIgniter;
|
||||
}
|
||||
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:aslan/app_localizations.dart';
|
||||
import 'package:aslan/chatvibe_core/constants/at_global_config.dart';
|
||||
import 'package:aslan/chatvibe_data/models/enum/at_activity_reward_props_type.dart';
|
||||
import 'package:aslan/chatvibe_data/sources/repositories/room_repository_imp.dart';
|
||||
import 'package:aslan/chatvibe_domain/models/res/at_room_rocket_config_res.dart';
|
||||
@ -166,19 +165,22 @@ class _RoomRocketYellowPageState extends State<RoomRocketYellowPage> {
|
||||
height: 14.w,
|
||||
child: Consumer<RtcProvider>(
|
||||
builder: (context, ref, child) {
|
||||
final progress = _progressValue(ref.roomRocketStatus);
|
||||
final status = ref.roomRocketStatus;
|
||||
final currentEnergy = _currentEnergyValue(status);
|
||||
final maxEnergy = _selectedLevelMaxEnergy;
|
||||
final progress = _progressValue(status);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
text(
|
||||
"${ref.roomRocketStatus?.currentEnergy ?? 0}/${currernRocketLevel?.maxEnergy ?? 0}",
|
||||
"$currentEnergy/$maxEnergy",
|
||||
textColor: Color(0xFFFFDC37),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
text(
|
||||
"${(progress * 100).clamp(0, 100).toInt()}%",
|
||||
"${_progressPercentText(progress)}%",
|
||||
textColor: Color(0xFFFFDC37),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
@ -355,7 +357,9 @@ class _RoomRocketYellowPageState extends State<RoomRocketYellowPage> {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFFFFF1A3), Color(0xFFFFA000)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF59B1D), Color(0xFFFFEF9E)],
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -384,13 +388,25 @@ class _RoomRocketYellowPageState extends State<RoomRocketYellowPage> {
|
||||
}
|
||||
|
||||
double _progressValue(ATRoomRocketStatusRes? status) {
|
||||
final num current =
|
||||
status?.currentEnergy ?? roomRocketStatus?.currentEnergy ?? 0;
|
||||
final num max = currernRocketLevel?.maxEnergy ?? status?.maxEnergy ?? 0;
|
||||
final num current = _currentEnergyValue(status);
|
||||
final num max = _selectedLevelMaxEnergy;
|
||||
if (max <= 0) return 0;
|
||||
return (current / max).clamp(0.0, 1.0).toDouble();
|
||||
}
|
||||
|
||||
num _currentEnergyValue(ATRoomRocketStatusRes? status) {
|
||||
return status?.currentEnergy ?? roomRocketStatus?.currentEnergy ?? 0;
|
||||
}
|
||||
|
||||
num get _selectedLevelMaxEnergy => currernRocketLevel?.maxEnergy ?? 0;
|
||||
|
||||
String _progressPercentText(double progress) {
|
||||
return (progress * 100)
|
||||
.clamp(0, 100)
|
||||
.toStringAsFixed(2)
|
||||
.replaceFirst(RegExp(r'\.?0+$'), '');
|
||||
}
|
||||
|
||||
Widget _buildRewardTabs() {
|
||||
final l10n = ATAppLocalizations.of(context)!;
|
||||
final labels = [
|
||||
@ -523,45 +539,11 @@ class _RoomRocketYellowPageState extends State<RoomRocketYellowPage> {
|
||||
|
||||
void _showHelpDialog() {
|
||||
SmartDialog.show(
|
||||
tag: "showRoomRocketHelpDialog",
|
||||
tag: "showRoomRocketNoteDialog",
|
||||
alignment: Alignment.center,
|
||||
debounce: true,
|
||||
animationType: SmartAnimationType.fade,
|
||||
builder: (_) {
|
||||
return Container(
|
||||
width: ScreenUtil().screenWidth,
|
||||
margin: EdgeInsets.symmetric(horizontal: 15.w),
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
"atu_images/room/at_icon_room_rocket_hepl_bg.png",
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 13.w),
|
||||
Image.asset(
|
||||
ATGlobalConfig.lang == "ar"
|
||||
? "atu_images/room/at_icon_room_rocket_rule_text_ar.png"
|
||||
: "atu_images/room/at_icon_room_rocket_rule_text_en.png",
|
||||
width: 58.w,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
text(
|
||||
ATAppLocalizations.of(context)!.roomRocketHelpTips,
|
||||
maxLines: 25,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
SizedBox(height: 20.w),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (_) => const _RoomRocketNoteDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -576,6 +558,80 @@ class _RoomRocketYellowPageState extends State<RoomRocketYellowPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRocketNoteDialog extends StatelessWidget {
|
||||
const _RoomRocketNoteDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: 360.w,
|
||||
height: 576.w,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Image.asset(
|
||||
"atu_images/room/at_icon_room_rocket_record_bg.png",
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: 3.w,
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: text(
|
||||
ATAppLocalizations.of(context)!.roomRocketNoteTitle,
|
||||
textColor: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: 27.w,
|
||||
end: 24.w,
|
||||
width: 24.w,
|
||||
height: 23.w,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
SmartDialog.dismiss(tag: "showRoomRocketNoteDialog");
|
||||
},
|
||||
child: Image.asset(
|
||||
"atu_images/room/at_icon_room_rocket_record_close.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: 49.w,
|
||||
start: 27.w,
|
||||
end: 27.w,
|
||||
bottom: 27.w,
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
ATAppLocalizations.of(context)!.roomRocketHelpTips,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomRocketRecordDialog extends StatelessWidget {
|
||||
const _RoomRocketRecordDialog();
|
||||
|
||||
@ -597,7 +653,7 @@ class _RoomRocketRecordDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: 12.w,
|
||||
top: 3.w,
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: text(
|
||||
@ -609,19 +665,18 @@ class _RoomRocketRecordDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: 27.w,
|
||||
top: 30.w,
|
||||
end: 24.w,
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
height: 23.w,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
SmartDialog.dismiss(tag: "showRoomRocketRecordDialog");
|
||||
},
|
||||
child: Image.asset(
|
||||
"atu_images/room/at_icon_room_game_close.png",
|
||||
"atu_images/room/at_icon_room_rocket_record_close.png",
|
||||
fit: BoxFit.contain,
|
||||
color: Color(0xFFFFF3A8),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -647,11 +702,22 @@ class _RoomRocketRecordDialog extends StatelessWidget {
|
||||
end: 28.w,
|
||||
bottom: 44.w,
|
||||
child: Center(
|
||||
child: text(
|
||||
ATAppLocalizations.of(context)!.noData,
|
||||
textColor: Color(0xFFFFFFFF),
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
"atu_images/general/at_icon_no_data_icon.png",
|
||||
width: 121.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
text(
|
||||
ATAppLocalizations.of(context)!.roomRocketRecordEmpty,
|
||||
textColor: Color(0xFFFFFFFF),
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
167
test/room_rocket_ui_test.dart
Normal file
@ -0,0 +1,167 @@
|
||||
import 'package:aslan/chatvibe_data/models/message/at_floating_message.dart';
|
||||
import 'package:aslan/chatvibe_domain/models/res/at_room_rocket_reward_res.dart';
|
||||
import 'package:aslan/chatvibe_ui/widgets/room/rocket/room_rocket_floating_popup.dart';
|
||||
import 'package:aslan/chatvibe_ui/widgets/room/rocket/room_rocket_reward_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding
|
||||
.instance
|
||||
.platformDispatcher
|
||||
.views
|
||||
.first
|
||||
.physicalSize = const Size(375, 812);
|
||||
TestWidgetsFlutterBinding
|
||||
.instance
|
||||
.platformDispatcher
|
||||
.views
|
||||
.first
|
||||
.devicePixelRatio = 1;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestWidgetsFlutterBinding.instance.platformDispatcher.views.first
|
||||
.resetPhysicalSize();
|
||||
TestWidgetsFlutterBinding.instance.platformDispatcher.views.first
|
||||
.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
test('rocket reward parses the current backend API and IM fields', () {
|
||||
final ATRoomRocketRewardRes apiReward =
|
||||
ATRoomRocketRewardRes.fromJson(<String, dynamic>{
|
||||
'rewardType': 'GOLD',
|
||||
'rewardItemId': 12,
|
||||
'rewardItemName': 'Gold',
|
||||
'rewardAmount': 18800,
|
||||
'cover': 'https://example.test/gold.png',
|
||||
'rocketLevel': 2,
|
||||
'message': 'Congratulations',
|
||||
});
|
||||
final ATRoomRocketRewardRes messageReward =
|
||||
ATRoomRocketRewardRes.fromJson(<String, dynamic>{
|
||||
'rewardType': 'PROP',
|
||||
'cover': 'https://example.test/frame.png',
|
||||
'rocketLevel': 3,
|
||||
'userId': 10002,
|
||||
'userNickname': 'Rocket Cat',
|
||||
});
|
||||
|
||||
expect(apiReward.rewardItemId, '12');
|
||||
expect(apiReward.displayLabel, '18800');
|
||||
expect(messageReward.userId, '10002');
|
||||
expect(messageReward.userNickname, 'Rocket Cat');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'rocket floating window no longer opens the reward dialog on tap',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
minTextAdapt: true,
|
||||
builder:
|
||||
(_, __) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: RoomRocketFloatingPopup(
|
||||
message: ATFloatingMessage(
|
||||
type: 3,
|
||||
roomId: '',
|
||||
userName: 'Igniter',
|
||||
userId: '163526',
|
||||
rocketLevel: 2,
|
||||
),
|
||||
onAnimationCompleted: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 1500));
|
||||
|
||||
expect(find.text('Igniter'), findsOneWidget);
|
||||
await tester.tap(find.byType(RoomRocketFloatingPopup));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(RoomRocketRewardPage), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'rocket reward panel binds winner count and current user rewards',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testApp(
|
||||
RoomRocketRewardPage(
|
||||
rewards: _sampleRewards(),
|
||||
currentUserId: '10002',
|
||||
igniterUserId: '10003',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('10000'), findsNWidgets(2));
|
||||
expect(find.text('2 users received rewards'), findsOneWidget);
|
||||
expect(find.text('Rocket Cat'), findsOneWidget);
|
||||
expect(find.text('Igniter'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('same reward panel switches to not-won state for another user', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testApp(
|
||||
RoomRocketRewardPage(
|
||||
rewards: _sampleRewards(),
|
||||
currentUserId: '99999',
|
||||
igniterUserId: '10003',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text("You didn't win a prize. Better luck next time!"),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('2 users received rewards'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _testApp(Widget child) {
|
||||
return ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
minTextAdapt: true,
|
||||
builder:
|
||||
(_, __) => MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ATRoomRocketRewardRes> _sampleRewards() {
|
||||
return <ATRoomRocketRewardRes>[
|
||||
ATRoomRocketRewardRes.fromJson(<String, dynamic>{
|
||||
'userId': 10002,
|
||||
'userNickname': 'Rocket Cat',
|
||||
'rewardType': 'GOLD',
|
||||
'rewardAmount': 10000,
|
||||
}),
|
||||
ATRoomRocketRewardRes.fromJson(<String, dynamic>{
|
||||
'userId': 10003,
|
||||
'userNickname': 'Igniter',
|
||||
'rewardType': 'PROP',
|
||||
'rewardItemName': '7 days',
|
||||
}),
|
||||
];
|
||||
}
|
||||