This commit is contained in:
roxy 2026-04-22 20:08:45 +08:00
parent a5bc029113
commit 5086cdd97c
36 changed files with 4168 additions and 321 deletions

View File

@ -8,6 +8,7 @@
"privaceyPolicy": "سياسة الخصوصية",
"tips": "تنبيه",
"mine": "حسابي",
"official": "رسمي",
"searchNoDataTips": "أدخل معرف الغرفة أو المستخدم الذي تريد البحث عنه.",
"party": "حفلة",
"event": "حدث",
@ -25,6 +26,7 @@
"roomMemberFee": "رسوم عضو الغرفة",
"blockedList2": "قائمة المحظورين",
"roomTheme2": "سمة الغرفة",
"background": "الخلفية",
"roomPassword": "كلمة مرور الغرفة",
"numberOfMic": "عدد الميكروفونات",
"pleaseEnterContent": "يرجى إدخال المحتوى",
@ -280,6 +282,7 @@
"wealthLevel": "مستوى الثروة",
"userLevel": "مستوى المستخدم",
"themeGoToUploadTips": "1. ستتم مراجعة التحميل خلال 24 ساعة بعد نجاحه.\n2. ستُعاد جميع العملات إذا فشلت المراجعة.",
"pleaseUploadAccordingToExample": "يرجى الرفع وفقًا للمثال المعروض.",
"goToUpload": "الانتقال للرفع",
"rechargeAgency": "وكالة الشحن",
"logout": "تسجيل الخروج",
@ -409,6 +412,7 @@
"boxContributeTips": "تم الاستثمار اليوم بالفعل، يرجى عدم الاستثمار مرة أخرى.",
"help": "مساعدة",
"approved": "معتمد",
"approvedWithinOneMinute": "ستتم الموافقة عليه وعرضه خلال دقيقة واحدة من الإرسال",
"onlineUsers": "المستخدمون عبر الإنترنت ({1}/{2}):",
"applyToJoin": "قدّم للانضمام",
"hostList": "قائمة المضيف",
@ -596,6 +600,8 @@
"superFans": "كبار المعجبين:",
"special": "خاص",
"custom": "مخصص",
"customBackground": "خلفية مخصصة",
"example": "مثال",
"store": "المتجر",
"viewFrame": "عرض الإطار",
"headdress": "إطارات",
@ -625,6 +631,7 @@
"successfulWear": "تم الارتداء بنجاح",
"confirmUnUseTips": "هل تؤكد إزالة استخدامه؟",
"inUse": "قيد الاستخدام",
"staticOrGifImage": "صورة ثابتة / GIF",
"confirmUseTips": "هل تريد التأكيد على استخدامه؟",
"pleaseUploadUserAvatar": "يرجى رفع صورة شخصية.",
"myItems": "مقتنياتي",

View File

@ -8,6 +8,7 @@
"privaceyPolicy": "গোপনীয়তা নীতি",
"tips": "টিপস",
"mine": "আমার",
"official": "অফিশিয়াল",
"games": "গেমস",
"party": "পার্টি",
"yes": "হ্যাঁ",
@ -23,6 +24,7 @@
"roomMemberFee": "রুম সদস্য ফি",
"blockedList2": "ব্লক করা তালিকা",
"roomTheme2": "রুম থিম",
"background": "ব্যাকগ্রাউন্ড",
"roomPassword": "রুম পাসওয়ার্ড",
"numberOfMic": "মাইকের সংখ্যা",
"pleaseEnterContent": "অনুগ্রহ করে বিষয়বস্তু লিখুন",
@ -231,6 +233,7 @@
"luck": "ভাগ্য",
"level": "স্তর",
"themeGoToUploadTips": "1.আপলোড সফল হলে 24 ঘন্টার মধ্যে রিভিউ করা হবে।\n2.রিভিউ ব্যর্থ হলে সমস্ত কয়েন ফেরত দেওয়া হবে।",
"pleaseUploadAccordingToExample": "দয়া করে দেখানো উদাহরণ অনুযায়ী আপলোড করুন।",
"home": "হোম",
"explore": "অন্বেষণ",
"me": "আমি",
@ -283,6 +286,7 @@
"following": "ফলো করা হচ্ছে",
"agent": "এজেন্ট",
"approved": "অনুমোদিত",
"approvedWithinOneMinute": "সাবমিট করার ১ মিনিটের মধ্যে অনুমোদিত হয়ে প্রদর্শিত হবে",
"onlineUsers": "অনলাইন ব্যবহারকারী({1}/{2}):",
"applyToJoin": "যোগদানের জন্য আবেদন করুন",
"supporterList": "সমর্থক তালিকা",
@ -613,6 +617,8 @@
"successfulWear": "সফলভাবে পরা হয়েছে",
"confirmUnUseTips": "আপনি কি এটি খুলে ফেলতে চান?",
"custom": "কাস্টম",
"customBackground": "কাস্টম ব্যাকগ্রাউন্ড",
"example": "উদাহরণ",
"myItems": "আমার আইটেম",
"use": "ব্যবহার করুন",
"unUse": "ব্যবহার না করুন",
@ -629,6 +635,7 @@
"expired": "মেয়াদ শেষ",
"day": "দিন",
"inUse": "ব্যবহারে",
"staticOrGifImage": "স্ট্যাটিক ইমেজ / GIF",
"confirmUseTips": "আপনি কি ব্যবহার করতে নিশ্চিত করছেন?",
"pleaseUploadUserAvatar": "অনুগ্রহ করে একটি প্রোফাইল ছবি আপলোড করুন।",
"joinMemberTips": "আপনি যদি রুমে পর্যটক হন, তবে আপনি মাইক্রোফোন নিতে পারবেন না।",

View File

@ -10,6 +10,7 @@
"searchNoDataTips": "Enter the room or user ID you want to search for.",
"games": "Games",
"mine": "Mine",
"official": "Official",
"party": "Party",
"other": "Other",
"yes": "Yes",
@ -22,6 +23,7 @@
"cancelRoomPassword": "Are you sure you want to delete the room password?",
"roomMemberFee": "Room Member Fee",
"roomTheme2": "Room Theme",
"background": "Background",
"blockedList2": "Blocked List",
"roomPassword": "Room Password",
"numberOfMic": "Number of Mic",
@ -202,6 +204,7 @@
"luck": "Luck",
"level": "Level",
"themeGoToUploadTips": "1. Review will be completed within 24 hours after the upload succeeds.\n2. All coins will be returned if the review fails.",
"pleaseUploadAccordingToExample": "Please upload according to the example provided.",
"home": "Home",
"explore": "Explore",
"me": "Me",
@ -254,6 +257,7 @@
"following": "Following",
"agent": "Agency",
"approved": "Approved",
"approvedWithinOneMinute": "Approved and displayed within 1 minute of submission",
"onlineUsers": "Online Users({1}/{2}):",
"applyToJoin": "Apply to join",
"supporterList": "Supporter List",
@ -613,6 +617,8 @@
"successfulWear": "Successful wear",
"confirmUnUseTips": "Do you confirm to remove it?",
"custom": "Custom",
"customBackground": "Custom Background",
"example": "Example",
"myItems": "My items",
"use": "Use",
"unUse": "Unequip",
@ -629,6 +635,7 @@
"expired": "Expired",
"day": "Day",
"inUse": "In use",
"staticOrGifImage": "Static image / GIF",
"confirmUseTips": "Do you want to confirm using it?",
"pleaseUploadUserAvatar": "Please upload an avatar.",
"joinMemberTips": "If you are a visitor in the room, you cannot take the microphone.",

View File

@ -9,6 +9,7 @@
"tips": "İpuçları",
"searchNoDataTips": "Aramak istediğiniz oda veya kullanıcı kimliğini girin.",
"mine": "Benim",
"official": "Resmi",
"party": "Parti",
"myRoom": "Odam",
"other": "Diğer",
@ -26,6 +27,7 @@
"roomMemberFee": "Oda Üyelik Ücreti",
"blockedList2": "Engellenen Liste",
"roomTheme2": "Oda Teması",
"background": "Arka Plan",
"roomPassword": "Oda Şifresi",
"noHistoricalRecordsAvailable": "Geçmiş kayıt bulunamadı.",
"inviteNewUsersToEarnCoins": "Yeni kullanıcıları davet ederek coin kazanın",
@ -191,6 +193,7 @@
"luck": "Şans",
"level": "Seviye",
"themeGoToUploadTips": "1. Yükleme başarılı olduktan sonra 24 saat içinde inceleme yapılacaktır.\n2. İnceleme başarısız olursa tüm jettonlar iade edilecektir.",
"pleaseUploadAccordingToExample": "Lütfen verilen örneğe göre yükleyin.",
"home": "Anasayfa",
"explore": "Keşfet",
"me": "Ben",
@ -253,6 +256,7 @@
"following": "Takip Edilenler",
"agent": "Temsilcilik",
"approved": "Onaylandı",
"approvedWithinOneMinute": "Gönderimden sonraki 1 dakika içinde onaylanır ve gösterilir",
"onlineUsers": "Çevrimiçi Kullanıcılar({1}/{2}):",
"applyToJoin": "Katılmak İçin Başvur",
"supporterList": "Destekçi Listesi",
@ -613,6 +617,8 @@
"successfulWear": "Başarıyla takıldı",
"confirmUnUseTips": "Kaldırmak için onaylıyor musunuz?",
"custom": "Özel",
"customBackground": "Özel Arka Plan",
"example": "Örnek",
"myItems": "Eşyalarım",
"use": "Kullan",
"unUse": "Kullanmama",
@ -629,6 +635,7 @@
"expired": "Süresi Doldu",
"day": "Gün",
"inUse": "Kullanımda",
"staticOrGifImage": "Statik görsel / GIF",
"confirmUseTips": "Kullanmak için onaylıyor musunuz?",
"pleaseUploadUserAvatar": "Lütfen bir profil fotoğrafı yükleyin.",
"joinMemberTips": "Eğer odada turist iseniz, mikroyu alamazsınız.",

View File

@ -8,8 +8,9 @@ Requirement summary:
- Add an emoji package button next to the voice-room chat entry.
- The emoji package button and text chat must open the same bottom composer sheet.
- Emoji packages are shown only on mic seats.
- Emoji packages must not appear in room chat messages, including the `All`, `Chat`, and `Gift` tabs.
- Users can send emoji packages whether they are on mic or not.
- If the sender is on mic, the emoji package is shown on the sender's mic seat and does not appear in room chat.
- If the sender is not on mic, the emoji package is shown in room chat and does not appear on any mic seat.
- Wait for final backend data and UI assets before starting the real implementation.
## Current Codebase Status
@ -67,7 +68,7 @@ Recommended behavior:
- Tap chat entry: open `RoomMsgInput(initialMode: text)`.
- Tap emoji package button: open `RoomMsgInput(initialMode: emojiPackage)`.
- If the current user is not on mic, the emoji package button can either be disabled or show a toast such as "Take a mic first".
- The emoji package button should always be available. Do not gate it by mic status.
Important layout note:
@ -89,28 +90,29 @@ Recommended send interaction for emoji package:
- The sheet should stay open after sending so the user can send multiple emoji packages continuously.
- If UI later wants tap-to-close, that can be a product choice, but continuous sending is the safer default for room interaction.
### 3. Seat-only display
### 3. Split display rule
Emoji package messages should not enter room chat lists.
Emoji package messages should be routed by the sender's current mic status.
Recommended rule:
- Text messages still follow the existing room chat path.
- Emoji package clicks trigger seat animation only.
- No new item should be inserted into `roomAllMsgList`, `roomChatMsgList`, or `roomGiftMsgList`.
- If the sender is on mic, emoji package clicks trigger seat animation only.
- If the sender is not on mic, emoji package clicks create a room chat message only.
- A single emoji package send should choose one display channel only and must not show in both places at the same time.
## Recommended Send Path
### Recommended payload shape
The smallest-change plan is to reuse the existing room custom message structure:
The smallest-change plan is still to reuse the existing room custom message structure, but make the payload support both seat display and chat display.
```dart
Msg(
groupId: roomAccount,
type: SCRoomMsgType.emoticons,
msg: emoji.sourceUrl,
number: currentSeatIndex,
number: currentSeatIndexOrMinusOne,
user: currentUserProfile,
role: currentUserRole,
)
@ -120,30 +122,43 @@ Recommended field meaning:
- `type`: `EMOTICONS`
- `msg`: selected emoji resource URL
- `number`: current seat index
- `number`: current seat index if the sender is on mic, otherwise `-1`
- `user`: sender profile, for consistency with existing room messages
### Why not send it as a normal chat message
### Recommended send branching
Because `RtmProvider.addMsg(...)` always inserts the message into `roomAllMsgList` first.
The current project already has two useful building blocks:
Current receive side is already good enough:
- In `_newGroupMsg(...)`, `EMOTICONS` goes directly to `RtcProvider.starPlayEmoji(msg)` and then `return`.
- That means remote users will not see emoji package content in room chat.
Current sender side still needs care during implementation:
- If the sender uses `dispatchMessage(...)` with default `addLocal: true`, the local sender will still push one item into `roomAllMsgList`.
- That would violate the requirement that emoji packages must not show in chat.
- `RtcProvider.starPlayEmoji(msg)` for seat playback
- `RtmProvider.addMsg(...)` for room chat insertion
Recommended implementation behavior later:
- Build the `Msg`.
- Resolve the sender's current seat index at click time.
- If the sender is on mic:
- Build the `Msg` with the current seat index.
- First do a local optimistic seat display by calling `RtcProvider.starPlayEmoji(msg)`.
- Then call `dispatchMessage(msg, addLocal: false)`.
- If the sender is not on mic:
- Build the `Msg` with `number: -1`.
- Insert it as a local chat item.
- Then send the RTM message normally so other users also see the chat item.
This gives the sender instant seat feedback while keeping the chat list clean.
This gives the correct split behavior:
- on mic: seat only
- off mic: chat only
### Recommended receive branching
The current receive logic for `EMOTICONS` should be adjusted to branch by seat index.
Recommended behavior:
- If `msg.number >= 0`, treat it as seat display and call `RtcProvider.starPlayEmoji(msg)`.
- If `msg.number < 0`, treat it as a normal room chat item and add it into the room chat list.
Without this receive-side branch, the current implementation would still route all `EMOTICONS` messages to seat display only.
## Recommended Seat Resolution Rule
@ -157,11 +172,7 @@ Reason:
- The user may switch mic seats after opening the popup.
- Using the latest seat index avoids showing the emoji on the wrong seat.
If the returned seat index is `-1`:
- Do not send.
- Show a toast telling the user that only users on mic can use emoji packages.
- If the latest seat index is `-1`, that means the message should go to room chat instead of seat display.
## Backend Dependency Assessment
@ -203,14 +214,14 @@ This keeps the feature aligned with the current room event architecture.
## Optional Sync Decision
There are two possible product definitions for emoji package state:
There are now two display paths, so the sync decision should be split as well.
### Option A: transient RTM-only state
### Seat path
Behavior:
- Emoji package is only an instant seat effect.
- Users who join mid-animation do not need to recover the current emoji state.
- If the sender is on mic, the emoji package is an instant seat effect.
- Users who join mid-animation do not necessarily need to recover the current emoji state.
Pros:
@ -220,15 +231,9 @@ Pros:
Recommended default:
- Yes. This should be the first implementation choice unless product explicitly wants state recovery.
- Yes. Treat the seat path as transient unless product explicitly wants recovery after reconnect or late join.
### Option B: recoverable seat state
Behavior:
- If a user joins the room late or reconnects, they can still see the currently active emoji package on the seat during its valid duration.
If product wants this, backend and model changes are needed:
If product later wants seat-state recovery, backend and model changes are needed:
- mic list response needs to include active emoji state
- active emoji state should include at least resource URL and expiration timing
@ -239,7 +244,16 @@ Important current gap:
- `MicRes.fromJson(...)` does not parse `emojiPath`
- `MicRes.toJson()` also does not serialize `emojiPath`
So if the final backend plan depends on seat polling or room re-entry recovery, this model area will need a small follow-up patch during implementation.
### Chat path
Behavior:
- If the sender is not on mic, the emoji package behaves like a normal room chat message.
Recommended default:
- Reuse the existing room chat message list behavior and message history strategy.
- No special seat-state sync is needed for this path.
## Current Code Caveats To Remember
@ -272,6 +286,11 @@ Meaning:
- The current image emoji path works because the code uses a field-reuse convention.
- Later implementation should stay compatible with this path unless the seat overlay model is cleaned up in one pass.
Additional note after the new requirement:
- The same `EMOTICONS` type now needs a second branch for off-mic chat display.
- So later implementation should not assume that every `EMOTICONS` message must call `starPlayEmoji(...)`.
### 3. Emoji cache utility exists but is not wired into the room sheet yet
`SCAppGeneralManager` already has:
@ -295,11 +314,12 @@ When backend and UI are ready, the development order should be:
1. Add the new emoji package button in `RoomBottomWidget` and adjust layout math together with `_resolveGiftCenterX(...)`.
2. Refactor `RoomMsgInput` into one shared room composer with `text` and `emojiPackage` entry modes.
3. Wire the sheet to load emoji categories and emoji items from `/material/emoji/all`.
4. Add on-mic gating and resolve seat index at click time.
5. On emoji package click, do local optimistic seat playback.
6. Send RTM `EMOTICONS` with `addLocal: false`.
7. Verify that `All`, `Chat`, and `Gift` tabs remain unchanged after sending.
8. If product wants reconnect recovery, then patch `MicRes` parsing and seat sync logic.
4. Resolve seat index at click time and branch by on-mic or off-mic state.
5. If on mic, do local optimistic seat playback and send RTM `EMOTICONS` with `addLocal: false`.
6. If off mic, insert a local chat item and send RTM `EMOTICONS` as a chat-visible message.
7. Adjust receive logic so on-mic emoji goes to seat display and off-mic emoji goes to room chat.
8. Verify that on-mic sends do not pollute chat, while off-mic sends do appear in `All` and `Chat` only.
9. If product wants reconnect recovery for seat effects, then patch `MicRes` parsing and seat sync logic.
## Acceptance Checklist
@ -307,13 +327,15 @@ When backend and UI are ready, the development order should be:
- Chat entry and emoji package button open the same bottom composer sheet.
- Opening from chat goes to text mode.
- Opening from emoji package goes to emoji package mode.
- Only users on mic can send emoji packages.
- Sender sees the emoji package on their own seat immediately.
- Other users see the emoji package on the sender's seat.
- Emoji package sending does not create new rows in `All`, `Chat`, or `Gift`.
- Users on and off mic can both send emoji packages.
- If the sender is on mic, the sender sees the emoji package on their own seat immediately.
- If the sender is on mic, other users see the emoji package on the sender's seat.
- If the sender is on mic, the emoji package does not appear in `All` or `Chat`.
- If the sender is not on mic, the emoji package appears in `All` and `Chat`.
- If the sender is not on mic, the emoji package does not appear on any seat.
- Existing text chat sending is not affected.
- Existing gift floating button remains visually centered after the new button is added.
- Repeated emoji package taps queue and play correctly on the seat.
- Repeated emoji package taps queue and play correctly on the seat path.
## Final Recommendation
@ -329,7 +351,7 @@ Recommended final direction:
- Reuse the existing room composer popup
- Reuse `EMOTICONS` as the transport type
- Use seat-index-based RTM payload
- Do local optimistic seat playback
- Do not insert emoji package messages into any chat list
- Treat backend seat-state recovery as optional, not mandatory
- Use seat-index-based branching in the RTM payload
- On mic: do local optimistic seat playback and keep emoji out of chat
- Off mic: treat emoji as a room chat message and keep it out of seat display
- Treat backend seat-state recovery as optional for the seat path, not mandatory

View File

@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig {
@override
String get apiHost => const String.fromEnvironment(
'API_HOST',
defaultValue: 'https://jvapi.haiyihy.com/',
defaultValue: 'http://192.168.110.43:1100/',
); // 线 --dart-define=API_HOST
@override

View File

@ -68,6 +68,8 @@ class SCAppLocalizations {
String get expirationTime => translate('expirationTime');
String get official => translate('official');
String get inviteNewUsersToEarnCoins =>
translate('inviteNewUsersToEarnCoins');
@ -104,6 +106,8 @@ class SCAppLocalizations {
String get roomTheme2 => translate('roomTheme2');
String get background => translate('background');
String get blockedList2 => translate('blockedList2');
String get crateMyRoom => translate('crateMyRoom');
@ -510,6 +514,8 @@ class SCAppLocalizations {
String get approved => translate('approved');
String get approvedWithinOneMinute => translate('approvedWithinOneMinute');
String get restorePurchasesTips => translate('restorePurchasesTips');
String get rejected => translate('rejected');
@ -1212,6 +1218,9 @@ class SCAppLocalizations {
String get themeGoToUploadTips => translate('themeGoToUploadTips');
String get pleaseUploadAccordingToExample =>
translate('pleaseUploadAccordingToExample');
String get myItems => translate('myItems');
String get items => translate('items');
@ -1307,6 +1316,10 @@ class SCAppLocalizations {
String get custom => translate('custom');
String get customBackground => translate('customBackground');
String get example => translate('example');
String get touristsCannotSendMessages =>
translate('touristsCannotSendMessages');
@ -1450,6 +1463,8 @@ class SCAppLocalizations {
String get mine => translate('mine');
String get staticOrGifImage => translate('staticOrGifImage');
String get party => translate('party');
String get event => translate('event');

View File

@ -0,0 +1,412 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
class RoomBackgroundSelectPage extends StatefulWidget {
const RoomBackgroundSelectPage({super.key});
@override
State<RoomBackgroundSelectPage> createState() =>
_RoomBackgroundSelectPageState();
}
class _RoomBackgroundSelectPageState extends State<RoomBackgroundSelectPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final List<_RoomBackgroundItem> _officialItems;
late final List<_RoomBackgroundItem> _mineItems;
bool get _showAddButton => _tabController.index == 1;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_handleTabChange);
_officialItems = [
_RoomBackgroundItem(inUse: true),
_RoomBackgroundItem(),
_RoomBackgroundItem(),
_RoomBackgroundItem(),
];
_mineItems = [_RoomBackgroundItem(), _RoomBackgroundItem()];
}
@override
void dispose() {
_tabController.removeListener(_handleTabChange);
_tabController.dispose();
super.dispose();
}
void _handleTabChange() {
if (!mounted) {
return;
}
setState(() {});
}
Future<void> _openUploadPage() async {
final uploadedPath = await VoiceRoomRoute.openRoomBackgroundUpload<String>(
context,
);
if (!mounted || (uploadedPath ?? "").trim().isEmpty) {
return;
}
setState(() {
for (final item in _mineItems) {
item.inUse = false;
}
_mineItems.insert(
0,
_RoomBackgroundItem(localImagePath: uploadedPath, inUse: true),
);
});
_tabController.animateTo(1);
}
void _selectItem(List<_RoomBackgroundItem> items, int index) {
setState(() {
for (final item in items) {
item.inUse = false;
}
items[index].inUse = true;
});
}
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Stack(
children: [
Image.asset(
SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(),
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
fit: BoxFit.fill,
),
Scaffold(
backgroundColor: Colors.transparent,
appBar: _buildAppBar(context, localizations),
body: Container(
color: const Color(0xff0F0F0F),
child: Stack(
children: [
Column(
children: [
Container(
height: 0.5,
color: Colors.white.withValues(alpha: 0.15),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_RoomBackgroundGrid(
items: _officialItems,
onTap:
(index) => _selectItem(_officialItems, index),
bottomPadding: 24.w,
),
_RoomBackgroundGrid(
items: _mineItems,
onTap: (index) => _selectItem(_mineItems, index),
bottomPadding: 100.w,
),
],
),
),
],
),
if (_showAddButton)
Positioned(
left: 18.w,
right: 18.w,
bottom: 20.w + bottomPadding,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _openUploadPage,
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
gradient: LinearGradient(
colors: [
SocialChatTheme.primaryLight,
const Color(0xff8BF2D0),
],
),
),
child: Text(
'add',
style: TextStyle(
color: const Color(0xff0B2823),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
),
),
],
);
}
PreferredSizeWidget _buildAppBar(
BuildContext context,
SCAppLocalizations localizations,
) {
return SocialChatAppBar(
backgroundColor: Colors.transparent,
child: Row(
children: [
SizedBox(
width: 52.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: Icon(
SCGlobalConfig.lang == "ar"
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
size: 28.w,
color: Colors.white,
),
),
),
Expanded(
child: Center(
child: SizedBox(
width: 180.w,
child: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white54,
overlayColor: const WidgetStatePropertyAll<Color>(
Colors.transparent,
),
labelStyle: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
indicator: SCFixedWidthTabIndicator(
width: 18.w,
color: SocialChatTheme.primaryLight,
height: 3.w,
borderRadius: 2.w,
),
dividerColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
tabs: [
Tab(text: localizations.official),
Tab(text: localizations.mine),
],
),
),
),
),
SizedBox(
width: 72.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _openUploadPage,
child: Align(
alignment: Alignment.center,
child: Text(
localizations.custom,
style: TextStyle(
color: SocialChatTheme.primaryLight,
fontSize: 15.sp,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
);
}
}
class _RoomBackgroundGrid extends StatelessWidget {
const _RoomBackgroundGrid({
required this.items,
required this.onTap,
required this.bottomPadding,
});
final List<_RoomBackgroundItem> items;
final ValueChanged<int> onTap;
final double bottomPadding;
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: EdgeInsets.fromLTRB(16.w, 16.w, 16.w, bottomPadding),
itemCount: items.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 14.w,
crossAxisSpacing: 14.w,
childAspectRatio: 0.64,
),
itemBuilder: (context, index) {
return _RoomBackgroundCard(
item: items[index],
onTap: () => onTap(index),
);
},
);
}
}
class _RoomBackgroundCard extends StatelessWidget {
const _RoomBackgroundCard({required this.item, required this.onTap});
final _RoomBackgroundItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: const Color(0xff18F2B1).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12.w),
border: Border.all(
color:
item.inUse
? SocialChatTheme.primaryLight
: Colors.white.withValues(alpha: 0.08),
width: 1.w,
),
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(10.w),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.w),
child: _buildPreview(context),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 12.w),
child: Text(
localizations.staticOrGifImage,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
),
],
),
if (item.inUse)
PositionedDirectional(
top: 10.w,
start: 10.w,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.w),
decoration: BoxDecoration(
color: SocialChatTheme.primaryLight,
borderRadius: BorderRadius.circular(999.w),
),
child: Text(
localizations.inUse,
style: TextStyle(
color: const Color(0xff09372E),
fontSize: 11.sp,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
Widget _buildPreview(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
if ((item.localImagePath ?? "").isNotEmpty) {
final file = File(item.localImagePath!);
if (file.existsSync()) {
return Image.file(file, fit: BoxFit.cover);
}
}
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [const Color(0xff0F4C40), const Color(0xff0B2823)],
),
),
child: Stack(
children: [
Center(
child: Icon(
Icons.image_outlined,
size: 34.w,
color: Colors.white.withValues(alpha: 0.45),
),
),
Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Text(
localizations.staticOrGifImage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
class _RoomBackgroundItem {
_RoomBackgroundItem({this.localImagePath, this.inUse = false});
final String? localImagePath;
bool inUse;
}

View File

@ -0,0 +1,238 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image_picker/image_picker.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
class RoomBackgroundUploadPage extends StatefulWidget {
const RoomBackgroundUploadPage({super.key});
@override
State<RoomBackgroundUploadPage> createState() =>
_RoomBackgroundUploadPageState();
}
class _RoomBackgroundUploadPageState extends State<RoomBackgroundUploadPage> {
final ImagePicker _picker = ImagePicker();
File? _selectedImage;
Future<void> _pickImage() async {
final pickedFile = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 90,
maxWidth: 1920,
maxHeight: 1080,
);
if (pickedFile == null || !mounted) {
return;
}
setState(() {
_selectedImage = File(pickedFile.path);
});
}
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
return Stack(
children: [
Image.asset(
SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(),
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
fit: BoxFit.fill,
),
Scaffold(
backgroundColor: Colors.transparent,
appBar: SocialChatStandardAppBar(
title: localizations.customBackground,
actions: const [],
backButtonColor: Colors.white,
backgroundColor: Colors.transparent,
),
body: Container(
color: const Color(0xff0F0F0F),
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(18.w, 20.w, 18.w, 28.w),
child: Column(
children: [
GestureDetector(
onTap: _pickImage,
child: Container(
width: 132.w,
height: 170.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.w),
color: const Color(0xff143B34),
border: Border.all(
color: SocialChatTheme.primaryLight,
width: 1.w,
),
),
child:
_selectedImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(14.w),
child: Image.file(
_selectedImage!,
fit: BoxFit.cover,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.file_upload_outlined,
color: Colors.white,
size: 28.w,
),
SizedBox(height: 10.w),
Text(
localizations.goToUpload,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
SizedBox(height: 16.w),
Text(
localizations.pleaseUploadAccordingToExample,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white54,
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 20.w),
Text(
localizations.example,
style: TextStyle(
color: SocialChatTheme.primaryLight,
fontSize: 18.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 16.w),
Row(
children: [
Expanded(child: _RoomBackgroundExampleCard()),
SizedBox(width: 16.w),
Expanded(child: _RoomBackgroundExampleCard()),
],
),
SizedBox(height: 16.w),
Text(
localizations.approvedWithinOneMinute,
style: TextStyle(
color: const Color(0xffF26F6F),
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 28.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
_selectedImage == null
? null
: () => SCNavigatorUtils.goBackWithParams(
context,
_selectedImage!.path,
),
child: Opacity(
opacity: _selectedImage == null ? 0.45 : 1,
child: Container(
height: 48.w,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
gradient: LinearGradient(
colors: [
SocialChatTheme.primaryLight,
const Color(0xff8BF2D0),
],
),
),
child: Text(
localizations.save,
style: TextStyle(
color: const Color(0xff0B2823),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
),
),
),
],
);
}
}
class _RoomBackgroundExampleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
return Container(
height: 300.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.w),
color: const Color(0xff143B34),
),
child: Stack(
children: [
Positioned.fill(
child: Icon(
Icons.image_outlined,
size: 42.w,
color: Colors.white.withValues(alpha: 0.12),
),
),
Positioned(
left: 12.w,
right: 12.w,
bottom: 18.w,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
border: Border.all(
color: SocialChatTheme.primaryLight,
width: 1.w,
),
color: const Color(0xff1B4A40),
),
child: Text(
localizations.staticOrGifImage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 11.sp,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}

View File

@ -13,6 +13,7 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/room_black_list_res.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
///
class BlockedListPage extends SCPageList {
@ -111,10 +112,28 @@ class _BlockedListPageState
),
),
SizedBox(height: 4.w),
text(
"ID:${res.blacklistUserProfile?.getID()}",
SCSpecialIdBadge(
idText: res.blacklistUserProfile?.getID() ?? "",
showAnimated:
res.blacklistUserProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userId,
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
res.blacklistUserProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 10.sp,
textColor: Colors.white,
),
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 10.sp,
),
animationFit: BoxFit.contain,
),
],
),
@ -151,7 +170,10 @@ class _BlockedListPageState
Function? onErr,
}) async {
try {
var roomList = await SCAccountRepository().roomBlacklist(roomId ?? "", "0");
var roomList = await SCAccountRepository().roomBlacklist(
roomId ?? "",
"0",
);
onSuccess(roomList);
} catch (e) {
if (onErr != null) {

View File

@ -163,32 +163,35 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
?.roomAccount ??
"",
showAnimated:
(ref
ref
.currenRoom
?.roomProfile
?.roomProfile
?.roomAccount
?.isNotEmpty ??
false),
?.userProfile
?.hasSpecialId() ??
false,
assetPath:
SCSpecialIdAssets.roomId,
animationWidth: 124.w,
animationHeight: 30.w,
textPadding: EdgeInsets.fromLTRB(
36.w,
7.w,
18.w,
7.w,
),
animationWidth: 66.w,
animationHeight: 26.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref
.currenRoom
?.roomProfile
?.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
),
animationFit: BoxFit.contain,
),
SizedBox(width: 3.w),
GestureDetector(
@ -231,10 +234,9 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
if (!widget.isHomeowner) {
return;
}
SCNavigatorUtils.push(
VoiceRoomRoute.openRoomEdit(
context,
"${VoiceRoomRoute.roomEdit}?need=false",
replace: false,
needRestCurrentRoomInfo: false,
);
},
),
@ -328,26 +330,31 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
assetPath:
SCSpecialIdAssets
.roomOwnerId,
animationWidth: 132.w,
animationHeight: 32.w,
textPadding:
EdgeInsets.fromLTRB(
40.w,
8.w,
18.w,
8.w,
),
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated:
true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref
.currenRoom
?.roomProfile
?.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle:
TextStyle(
color: Colors.white,
fontSize: 13.sp,
color: Colors.black,
fontSize: 14.sp,
fontWeight:
FontWeight.w700,
FontWeight.w600,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 14.sp,
),
animationFit:
BoxFit.contain,
),
SizedBox(width: 3.w),
GestureDetector(
@ -456,10 +463,9 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
if (!widget.isHomeowner) {
return;
}
SCNavigatorUtils.push(
VoiceRoomRoute.openRoomEdit(
context,
"${VoiceRoomRoute.roomEdit}?need=false",
replace: false,
needRestCurrentRoomInfo: false,
);
},
),
@ -552,10 +558,9 @@ class _RoomDetailPageState extends State<RoomDetailPage> {
),
),
onTap: () {
SCNavigatorUtils.push(
VoiceRoomRoute.openRoomEdit(
context,
"${VoiceRoomRoute.roomEdit}?need=false",
replace: false,
needRestCurrentRoomInfo: false,
);
},
),

View File

@ -412,11 +412,7 @@ class _RoomEditPageState extends State<RoomEditPage> {
],
),
onTap: () {
SCNavigatorUtils.push(
context,
VoiceRoomRoute.roomTheme,
replace: false,
);
VoiceRoomRoute.openRoomTheme(context);
},
),
SizedBox(height: 12.w),

View File

@ -21,6 +21,7 @@ import '../../../ui_kit/components/sc_debounce_widget.dart';
import '../../../ui_kit/components/sc_page_list.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../ui_kit/widgets/id/sc_special_id_badge.dart';
///
class RoomMemberPage extends SCPageList {
@ -195,12 +196,31 @@ class _RoomMemberPageState
),
),
SizedBox(height: 3.w),
text(
"ID:${userInfo.userProfile?.getID()}",
SCSpecialIdBadge(
idText: userInfo.userProfile?.getID() ?? "",
showAnimated:
userInfo.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userId,
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
userInfo.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
textColor: Colors.black,
fontWeight: FontWeight.bold,
),
normalTextStyle: TextStyle(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
animationFit: BoxFit.contain,
),
],
),
Spacer(),

View File

@ -153,11 +153,14 @@ class _RoomOnlinePageState
idText: userInfo.getID(),
showAnimated: userInfo.hasSpecialId(),
assetPath: SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding: EdgeInsets.fromLTRB(33.w, 6.w, 16.w, 6.w),
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
userInfo.shouldShowColoredSpecialIdText(),
animationTextStyle: TextStyle(
color: Colors.white,
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
@ -167,14 +170,14 @@ class _RoomOnlinePageState
fontWeight: FontWeight.bold,
),
showCopyIcon: true,
showCopyIconWhenAnimated: true,
copyIconSize: 12.w,
copyIconSpacing: 5.w,
animationFit: BoxFit.contain,
),
),
onTap: () {
Clipboard.setData(
ClipboardData(text: userInfo.getID() ?? ""),
);
Clipboard.setData(ClipboardData(text: userInfo.getID()));
SCTts.show(
SCAppLocalizations.of(context)!.copiedToClipboard,
);

View File

@ -13,6 +13,7 @@ import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/shared/business_logic/models/res/room_gift_rank_res.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
import 'package:yumi/ui_kit/widgets/room/room_user_info_card.dart';
class RoomGiftRankTabPage extends SCPageList {
@ -113,7 +114,12 @@ class _RoomGiftRankTabPageState
fontSize: 14.sp,
type: userInfo.userProfile?.getVIP()?.name ?? "",
needScroll:
(userInfo.userProfile?.userNickname?.characters.length ?? 0) >
(userInfo
.userProfile
?.userNickname
?.characters
.length ??
0) >
14,
),
],
@ -124,17 +130,34 @@ class _RoomGiftRankTabPageState
child: Row(
textDirection: TextDirection.ltr,
children: [
text(
"ID:${userInfo.userProfile?.getID()}",
SCSpecialIdBadge(
idText: userInfo.userProfile?.getID() ?? "",
showAnimated:
userInfo.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userId,
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
userInfo.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
textColor: Colors.white,
fontWeight: FontWeight.bold,
),
SizedBox(width: 5.w),
Image.asset(
"sc_images/room/sc_icon_user_card_copy_id.png",
width: 12.w,
height: 12.w,
normalTextStyle: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
showCopyIcon: true,
showCopyIconWhenAnimated: true,
copyIconSize: 12.w,
copyIconSpacing: 5.w,
animationFit: BoxFit.contain,
),
SizedBox(width: 8.w),
],
@ -144,7 +167,9 @@ class _RoomGiftRankTabPageState
Clipboard.setData(
ClipboardData(text: userInfo.userProfile?.getID() ?? ""),
);
SCTts.show(SCAppLocalizations.of(context)!.copiedToClipboard);
SCTts.show(
SCAppLocalizations.of(context)!.copiedToClipboard,
);
},
),
],

View File

@ -1,20 +1,103 @@
import 'dart:io';
import 'package:fluro/fluro.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
class VoiceRoomRoute implements SCIRouterProvider {
static String voiceRoom = '/room';
static String roomEdit = '/room/roomEdit';
static String roomTheme = '/room/roomTheme';
static String roomBackgroundSelect = '/room/background/select';
static String roomBackgroundUpload = '/room/background/upload';
static Route<T> _buildRoute<T>(Widget page) {
if (Platform.isIOS) {
return CupertinoPageRoute<T>(builder: (_) => page);
}
return MaterialPageRoute<T>(builder: (_) => page);
}
static Future<T?> openRoomEdit<T>(
BuildContext context, {
required bool needRestCurrentRoomInfo,
bool rootNavigator = true,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildRoute<T>(
RoomEditPage(
needRestCurrentRoomInfo: needRestCurrentRoomInfo ? "true" : "false",
),
),
);
}
static Future<T?> openRoomTheme<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(RoomThemePage()));
}
static Future<T?> openRoomBackgroundSelect<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundSelectPage()));
}
static Future<T?> openRoomBackgroundUpload<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomBackgroundUploadPage()));
}
@override
void initRouter(FluroRouter router) {
router.define(voiceRoom,
handler: Handler(handlerFunc: (_, params) => VoiceRoomPage()));
router.define(roomTheme,
handler: Handler(handlerFunc: (_, params) => RoomThemePage()));
router.define(roomEdit,
handler: Handler(handlerFunc: (_, params) => RoomEditPage(needRestCurrentRoomInfo: params['need']!.first)));
router.define(
voiceRoom,
handler: Handler(handlerFunc: (_, params) => VoiceRoomPage()),
);
router.define(
roomTheme,
handler: Handler(handlerFunc: (_, params) => RoomThemePage()),
);
router.define(
roomBackgroundSelect,
handler: Handler(
handlerFunc: (_, params) => const RoomBackgroundSelectPage(),
),
);
router.define(
roomBackgroundUpload,
handler: Handler(
handlerFunc: (_, params) => const RoomBackgroundUploadPage(),
),
);
router.define(
roomEdit,
handler: Handler(
handlerFunc:
(_, params) => RoomEditPage(
needRestCurrentRoomInfo: params['need']?.first ?? "false",
),
),
);
}
}

View File

@ -0,0 +1,275 @@
import 'dart:convert';
class LeaderBridgeActions {
static const String getConfig = 'getConfig';
static const String pay = 'pay';
static const String closeGame = 'closeGame';
static const String loadComplete = 'loadComplete';
static const String debugLog = 'debugLog';
}
class LeaderBridgeMessage {
const LeaderBridgeMessage({
required this.action,
this.payload = const <String, dynamic>{},
});
final String action;
final Map<String, dynamic> payload;
static LeaderBridgeMessage fromAction(String action, String rawMessage) {
return LeaderBridgeMessage(
action: action,
payload: _parsePayload(rawMessage),
);
}
static LeaderBridgeMessage parse(String rawMessage) {
try {
final dynamic decoded = jsonDecode(rawMessage);
if (decoded is Map<String, dynamic>) {
return LeaderBridgeMessage(
action:
(decoded['action'] ?? decoded['cmd'] ?? decoded['event'] ?? '')
.toString()
.trim(),
payload: _parsePayload(
decoded['payload'] ?? decoded['data'] ?? decoded['params'],
),
);
}
} catch (_) {}
return LeaderBridgeMessage(action: rawMessage.trim());
}
static Map<String, dynamic> _parsePayload(dynamic rawPayload) {
if (rawPayload is Map<String, dynamic>) {
return rawPayload;
}
if (rawPayload is String) {
final trimmed = rawPayload.trim();
if (trimmed.isEmpty) {
return const <String, dynamic>{};
}
try {
final dynamic decoded = jsonDecode(trimmed);
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {}
return <String, dynamic>{'raw': trimmed};
}
return const <String, dynamic>{};
}
}
class LeaderJsBridge {
static const String channelName = 'LeaderBridgeChannel';
static String bootstrapScript({Map<String, dynamic>? launchPayload}) {
final safeLaunchPayload = jsonEncode(
launchPayload ?? const <String, dynamic>{},
);
return '''
(function() {
const launchPayload = $safeLaunchPayload;
function toPayload(input) {
if (typeof input === 'string') {
try {
return JSON.parse(input);
} catch (_) {
return input.trim() ? { raw: input.trim() } : {};
}
}
if (input && typeof input === 'object') {
return input;
}
return {};
}
function stringifyForLog(value) {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value.length > 320 ? value.slice(0, 320) + '...' : value;
}
try {
const text = JSON.stringify(value);
return text.length > 320 ? text.slice(0, 320) + '...' : text;
} catch (_) {
return String(value);
}
}
function postMessage(action, input) {
const payload = toPayload(input);
$channelName.postMessage(JSON.stringify({
action: action,
payload: payload
}));
return payload;
}
function postDebug(tag, payload) {
postMessage('${LeaderBridgeActions.debugLog}', {
tag: tag,
message: stringifyForLog(payload)
});
}
function invokeCallback(callback, payload) {
if (typeof callback === 'function') {
callback(payload);
return true;
}
return false;
}
function dispatchConfigReady(payload) {
if (typeof window.onLeaderLaunchConfig === 'function') {
window.onLeaderLaunchConfig(payload);
}
if (typeof window.onLaunchConfig === 'function') {
window.onLaunchConfig(payload.launchConfig || {}, payload);
}
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(
new CustomEvent('leaderLaunchConfig', { detail: payload })
);
window.dispatchEvent(
new CustomEvent('launchConfig', {
detail: payload.launchConfig || {}
})
);
}
if (typeof document !== 'undefined' &&
typeof document.dispatchEvent === 'function' &&
typeof CustomEvent === 'function') {
document.dispatchEvent(
new CustomEvent('leaderLaunchConfig', { detail: payload })
);
document.dispatchEvent(
new CustomEvent('launchConfig', {
detail: payload.launchConfig || {}
})
);
}
}
function applyLaunchPayload(payload) {
const normalizedPayload =
payload && typeof payload === 'object' ? payload : {};
const payloadText = JSON.stringify(normalizedPayload);
const payloadChanged = window.__leaderLaunchPayloadText !== payloadText;
const launchConfig =
normalizedPayload.launchConfig &&
typeof normalizedPayload.launchConfig === 'object'
? normalizedPayload.launchConfig
: {};
const entry =
normalizedPayload.entry && typeof normalizedPayload.entry === 'object'
? normalizedPayload.entry
: {};
window.__leaderLaunchPayload = normalizedPayload;
window.__leaderLaunchPayloadText = payloadText;
window.leaderLaunchPayload = normalizedPayload;
window.__leaderLaunchConfig = launchConfig;
window.leaderLaunchConfig = launchConfig;
window.__leaderLaunchEntry = entry;
window.leaderLaunchEntry = entry;
window.NativeBridge = window.NativeBridge || {};
window.NativeBridge.config = launchConfig;
window.NativeBridge.launchConfig = launchConfig;
window.NativeBridge.entry = entry;
window.NativeBridge.getConfig = function(callback) {
invokeCallback(callback, launchConfig);
return launchConfig;
};
window.NativeBridge.getLaunchPayload = function(callback) {
invokeCallback(callback, normalizedPayload);
return normalizedPayload;
};
window.getConfig = function(callback) {
return window.NativeBridge.getConfig(callback);
};
window.getConfig.postMessage = function(callback) {
return window.NativeBridge.getConfig(callback);
};
if (payloadChanged) {
dispatchConfigReady(normalizedPayload);
postDebug('config.ready', {
provider: normalizedPayload.provider || '',
gameId: normalizedPayload.gameId || '',
roomId: launchConfig.roomId || '',
code: launchConfig.code
? String(launchConfig.code).slice(0, 6) + '***'
: '',
codeLength: launchConfig.code ? String(launchConfig.code).length : 0,
entryUrl: entry.entryUrl || ''
});
}
}
if (!window.__leaderBridgeReady) {
window.__leaderBridgeReady = true;
function bindCallable(name) {
const callable = function(payload) {
return postMessage(name, payload);
};
callable.postMessage = function(payload) {
return callable(payload);
};
window[name] = callable;
return callable;
}
const pay = bindCallable('${LeaderBridgeActions.pay}');
const closeGame = bindCallable('${LeaderBridgeActions.closeGame}');
const loadComplete = bindCallable('${LeaderBridgeActions.loadComplete}');
window.NativeBridge = window.NativeBridge || {};
window.NativeBridge.pay = pay;
window.NativeBridge.closeGame = closeGame;
window.NativeBridge.loadComplete = loadComplete;
window.__leaderBridgeDebug = postDebug;
postDebug('bridge.ready', {
href: window.location && window.location.href,
hasUpdateCoin: typeof window.updateCoin === 'function'
});
}
applyLaunchPayload(launchPayload);
})();
''';
}
static String buildUpdateCoinScript() {
return '''
(function() {
if (typeof window.updateCoin === 'function') {
window.updateCoin();
return;
}
if (window.NativeBridge && typeof window.NativeBridge.updateCoin === 'function') {
window.NativeBridge.updateCoin();
return;
}
if (typeof window.onUpdateCoin === 'function') {
window.onUpdateCoin();
return;
}
$channelName.postMessage(JSON.stringify({
action: '${LeaderBridgeActions.debugLog}',
payload: {
tag: 'game.updateCoin.missing',
message: 'No updateCoin handler found on game page.'
}
}));
})();
''';
}
}

View File

@ -1,31 +1,80 @@
class RoomGameProviderModel {
const RoomGameProviderModel({required this.key, required this.displayName});
final String key;
final String displayName;
factory RoomGameProviderModel.fromJson(Map<String, dynamic> json) {
return RoomGameProviderModel(
key: _asString(json['key']),
displayName: _asString(json['displayName']),
);
}
}
class RoomGameProviderListResponseModel {
const RoomGameProviderListResponseModel({required this.items});
final List<RoomGameProviderModel> items;
factory RoomGameProviderListResponseModel.fromJson(
Map<String, dynamic> json,
) {
final list = json['items'] as List<dynamic>? ?? const <dynamic>[];
return RoomGameProviderListResponseModel(
items:
list
.whereType<Map<String, dynamic>>()
.map(RoomGameProviderModel.fromJson)
.toList(),
);
}
}
class RoomGameShortcutModel {
const RoomGameShortcutModel({
required this.gameId,
required this.vendorType,
required this.vendorGameId,
required this.provider,
required this.gameType,
required this.providerGameId,
required this.name,
required this.cover,
required this.launchMode,
required this.gameMode,
required this.sort,
required this.launchParams,
});
final String gameId;
final String vendorType;
final int vendorGameId;
final String provider;
final String gameType;
final String providerGameId;
final String name;
final String cover;
final String launchMode;
final int gameMode;
final int sort;
final Map<String, dynamic> launchParams;
String get vendorType => provider;
String get vendorGameId => providerGameId;
factory RoomGameShortcutModel.fromJson(Map<String, dynamic> json) {
return RoomGameShortcutModel(
gameId: _asString(json['gameId']),
vendorType: _asString(json['vendorType']),
vendorGameId: _asInt(json['vendorGameId']),
provider: _asString(json['provider'] ?? json['vendorType']),
gameType:
_asString(json['gameType']).isNotEmpty
? _asString(json['gameType'])
: _asString(json['provider'] ?? json['vendorType']),
providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']),
name: _asString(json['name']),
cover: _asString(json['cover']),
launchMode: _asString(json['launchMode']),
gameMode: _asInt(json['gameMode']),
sort: _asInt(json['sort']),
launchParams: _asMap(json['launchParams']),
);
}
}
@ -33,8 +82,9 @@ class RoomGameShortcutModel {
class RoomGameListItemModel {
const RoomGameListItemModel({
required this.gameId,
required this.vendorType,
required this.vendorGameId,
required this.provider,
required this.gameType,
required this.providerGameId,
required this.name,
required this.cover,
required this.category,
@ -46,11 +96,13 @@ class RoomGameListItemModel {
required this.orientation,
required this.packageVersion,
required this.status,
required this.launchParams,
});
final String gameId;
final String vendorType;
final int vendorGameId;
final String provider;
final String gameType;
final String providerGameId;
final String name;
final String cover;
final String category;
@ -62,14 +114,28 @@ class RoomGameListItemModel {
final int orientation;
final String packageVersion;
final String status;
final Map<String, dynamic> launchParams;
bool get isBaishun => vendorType.toUpperCase() == 'BAISHUN';
String get vendorType => provider;
String get vendorGameId => providerGameId;
bool get isBaishun =>
provider.toUpperCase() == 'BAISHUN' ||
gameType.toUpperCase() == 'BAISHUN';
bool get isLeader =>
provider.toUpperCase() == 'LEADER' || gameType.toUpperCase() == 'LEADER';
factory RoomGameListItemModel.fromJson(Map<String, dynamic> json) {
return RoomGameListItemModel(
gameId: _asString(json['gameId']),
vendorType: _asString(json['vendorType']),
vendorGameId: _asInt(json['vendorGameId']),
provider: _asString(json['provider'] ?? json['vendorType']),
gameType:
_asString(json['gameType']).isNotEmpty
? _asString(json['gameType'])
: _asString(json['provider'] ?? json['vendorType']),
providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']),
name: _asString(json['name']),
cover: _asString(json['cover']),
category: _asString(json['category']),
@ -81,14 +147,16 @@ class RoomGameListItemModel {
orientation: _asInt(json['orientation']),
packageVersion: _asString(json['packageVersion']),
status: _asString(json['status']),
launchParams: _asMap(json['launchParams']),
);
}
factory RoomGameListItemModel.debugMock() {
return const RoomGameListItemModel(
gameId: 'bs_mock',
vendorType: 'BAISHUN',
vendorGameId: 999001,
provider: 'BAISHUN',
gameType: 'BAISHUN',
providerGameId: '999001',
name: 'BAISHUN Mock',
cover: '',
category: 'CHAT_ROOM',
@ -100,6 +168,7 @@ class RoomGameListItemModel {
orientation: 1,
packageVersion: 'debug',
status: 'DEBUG',
launchParams: <String, dynamic>{'gameType': 'BAISHUN'},
);
}
}
@ -125,20 +194,27 @@ class BaishunGameConfigModel {
const BaishunGameConfigModel({
required this.sceneMode,
required this.currencyIcon,
this.rawJson = const <String, dynamic>{},
});
final int sceneMode;
final String currencyIcon;
final Map<String, dynamic> rawJson;
factory BaishunGameConfigModel.fromJson(Map<String, dynamic> json) {
return BaishunGameConfigModel(
sceneMode: _asInt(json['sceneMode']),
currencyIcon: _asString(json['currencyIcon']),
rawJson: _asMap(json),
);
}
Map<String, dynamic> toJson() {
return {'sceneMode': sceneMode, 'currencyIcon': currencyIcon};
return <String, dynamic>{
...rawJson,
'sceneMode': sceneMode,
'currencyIcon': currencyIcon,
};
}
}
@ -154,6 +230,7 @@ class BaishunBridgeConfigModel {
required this.language,
required this.gsp,
required this.gameConfig,
this.rawJson = const <String, dynamic>{},
});
final String appName;
@ -166,6 +243,7 @@ class BaishunBridgeConfigModel {
final String language;
final int gsp;
final BaishunGameConfigModel gameConfig;
final Map<String, dynamic> rawJson;
factory BaishunBridgeConfigModel.fromJson(Map<String, dynamic> json) {
return BaishunBridgeConfigModel(
@ -182,11 +260,13 @@ class BaishunBridgeConfigModel {
json['gameConfig'] as Map<String, dynamic>? ??
const <String, dynamic>{},
),
rawJson: _asMap(json),
);
}
Map<String, dynamic> toJson() {
return {
return <String, dynamic>{
...rawJson,
'appName': appName,
'appChannel': appChannel,
'appId': appId,
@ -210,6 +290,7 @@ class BaishunLaunchEntryModel {
required this.packageVersion,
required this.orientation,
required this.safeHeight,
this.rawJson = const <String, dynamic>{},
});
final String launchMode;
@ -219,6 +300,7 @@ class BaishunLaunchEntryModel {
final String packageVersion;
final int orientation;
final int safeHeight;
final Map<String, dynamic> rawJson;
factory BaishunLaunchEntryModel.fromJson(Map<String, dynamic> json) {
return BaishunLaunchEntryModel(
@ -229,75 +311,121 @@ class BaishunLaunchEntryModel {
packageVersion: _asString(json['packageVersion']),
orientation: _asInt(json['orientation']),
safeHeight: _asInt(json['safeHeight']),
rawJson: _asMap(json),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
...rawJson,
'launchMode': launchMode,
'entryUrl': entryUrl,
'previewUrl': previewUrl,
'downloadUrl': downloadUrl,
'packageVersion': packageVersion,
'orientation': orientation,
'safeHeight': safeHeight,
};
}
}
class BaishunRoomStateModel {
const BaishunRoomStateModel({
required this.roomId,
required this.state,
required this.provider,
required this.gameSessionId,
required this.currentGameId,
required this.currentVendorGameId,
required this.currentProviderGameId,
required this.currentGameName,
required this.currentGameCover,
required this.hostUserId,
this.rawJson = const <String, dynamic>{},
});
final String roomId;
final String state;
final String provider;
final String gameSessionId;
final String currentGameId;
final int currentVendorGameId;
final String currentProviderGameId;
final String currentGameName;
final String currentGameCover;
final int hostUserId;
final Map<String, dynamic> rawJson;
String get currentVendorGameId => currentProviderGameId;
factory BaishunRoomStateModel.fromJson(Map<String, dynamic> json) {
return BaishunRoomStateModel(
roomId: _asString(json['roomId']),
state: _asString(json['state']),
provider: _asString(json['provider'] ?? json['vendorType']),
gameSessionId: _asString(json['gameSessionId']),
currentGameId: _asString(json['currentGameId']),
currentVendorGameId: _asInt(json['currentVendorGameId']),
currentGameId: _asString(json['currentGameId'] ?? json['currentGameID']),
currentProviderGameId: _asString(
json['currentProviderGameId'] ?? json['currentVendorGameId'],
),
currentGameName: _asString(json['currentGameName']),
currentGameCover: _asString(json['currentGameCover']),
hostUserId: _asInt(json['hostUserId']),
rawJson: _asMap(json),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
...rawJson,
'roomId': roomId,
'state': state,
'provider': provider,
'gameSessionId': gameSessionId,
'currentGameId': currentGameId,
'currentProviderGameId': currentProviderGameId,
'currentGameName': currentGameName,
'currentGameCover': currentGameCover,
'hostUserId': hostUserId,
};
}
}
class BaishunLaunchModel {
const BaishunLaunchModel({
required this.gameSessionId,
required this.vendorType,
required this.provider,
required this.gameId,
required this.vendorGameId,
required this.providerGameId,
required this.entry,
required this.bridgeConfig,
required this.launchConfig,
required this.roomState,
});
final String gameSessionId;
final String vendorType;
final String provider;
final String gameId;
final int vendorGameId;
final String providerGameId;
final BaishunLaunchEntryModel entry;
final BaishunBridgeConfigModel bridgeConfig;
final BaishunBridgeConfigModel launchConfig;
final BaishunRoomStateModel roomState;
String get vendorType => provider;
String get vendorGameId => providerGameId;
BaishunBridgeConfigModel get bridgeConfig => launchConfig;
factory BaishunLaunchModel.fromJson(Map<String, dynamic> json) {
return BaishunLaunchModel(
gameSessionId: _asString(json['gameSessionId']),
vendorType: _asString(json['vendorType']),
provider: _asString(json['provider'] ?? json['vendorType']),
gameId: _asString(json['gameId']),
vendorGameId: _asInt(json['vendorGameId']),
providerGameId: _asString(json['providerGameId'] ?? json['vendorGameId']),
entry: BaishunLaunchEntryModel.fromJson(
json['entry'] as Map<String, dynamic>? ?? const <String, dynamic>{},
),
bridgeConfig: BaishunBridgeConfigModel.fromJson(
json['bridgeConfig'] as Map<String, dynamic>? ??
launchConfig: BaishunBridgeConfigModel.fromJson(
(json['launchConfig'] ?? json['bridgeConfig'])
as Map<String, dynamic>? ??
const <String, dynamic>{},
),
roomState: BaishunRoomStateModel.fromJson(
@ -305,6 +433,18 @@ class BaishunLaunchModel {
),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'gameSessionId': gameSessionId,
'provider': provider,
'gameId': gameId,
'providerGameId': providerGameId,
'entry': entry.toJson(),
'launchConfig': launchConfig.toJson(),
'roomState': roomState.toJson(),
};
}
}
String _asString(dynamic value) {
@ -339,3 +479,15 @@ bool _asBool(dynamic value) {
}
return false;
}
Map<String, dynamic> _asMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map(
(dynamic key, dynamic item) => MapEntry(key.toString(), item),
);
}
return const <String, dynamic>{};
}

View File

@ -21,11 +21,23 @@ class RoomGameApi {
return merged.isEmpty ? null : merged;
}
Future<RoomGameProviderListResponseModel> fetchProviders() {
return _client.get<RoomGameProviderListResponseModel>(
'/app/game/providers',
extra: _buildExtra(),
fromJson:
(dynamic json) => RoomGameProviderListResponseModel.fromJson(
json as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
Future<List<RoomGameShortcutModel>> fetchShortcutGames({
required String provider,
required String roomId,
}) {
return _client.get<List<RoomGameShortcutModel>>(
'/app/game/room/shortcut',
'/app/game/providers/$provider/room/shortcut',
queryParams: {'roomId': roomId},
extra: _buildExtra(),
fromJson: (dynamic json) {
@ -39,6 +51,7 @@ class RoomGameApi {
}
Future<RoomGameListResponseModel> fetchRoomGames({
required String provider,
required String roomId,
String category = '',
}) {
@ -47,7 +60,7 @@ class RoomGameApi {
query['category'] = category;
}
return _client.get<RoomGameListResponseModel>(
'/app/game/room/list',
'/app/game/providers/$provider/room/list',
queryParams: query,
extra: _buildExtra(const <String, dynamic>{
BaseNetworkClient.silentErrorToastKey: true,
@ -59,9 +72,12 @@ class RoomGameApi {
);
}
Future<BaishunRoomStateModel> fetchRoomState({required String roomId}) {
Future<BaishunRoomStateModel> fetchRoomState({
required String provider,
required String roomId,
}) {
return _client.get<BaishunRoomStateModel>(
'/app/game/baishun/state',
'/app/game/providers/$provider/state',
queryParams: {'roomId': roomId},
extra: _buildExtra(),
fromJson:
@ -71,19 +87,22 @@ class RoomGameApi {
);
}
Future<BaishunLaunchModel> launchBaishunGame({
Future<BaishunLaunchModel> launchGame({
required String provider,
required String roomId,
required String gameId,
required int sceneMode,
required String clientOrigin,
required Map<String, dynamic> params,
}) {
return _client.post<BaishunLaunchModel>(
'/app/game/baishun/launch',
'/app/game/providers/$provider/launch',
data: {
'roomId': roomId,
'gameId': gameId,
'sceneMode': sceneMode,
'clientOrigin': clientOrigin,
'params': params,
},
extra: _buildExtra(),
fromJson:
@ -93,17 +112,20 @@ class RoomGameApi {
);
}
Future<BaishunRoomStateModel> closeBaishunGame({
Future<BaishunRoomStateModel> closeGame({
required String provider,
required String roomId,
required String gameSessionId,
String reason = 'user_exit',
Map<String, dynamic> params = const <String, dynamic>{},
}) {
return _client.post<BaishunRoomStateModel>(
'/app/game/baishun/close',
'/app/game/providers/$provider/close',
data: {
'roomId': roomId,
'gameSessionId': gameSessionId,
'reason': reason,
'params': params,
},
extra: _buildExtra(),
fromJson:

View File

@ -6,47 +6,111 @@ class RoomGameRepository {
final RoomGameApi _api;
Future<List<RoomGameProviderModel>> fetchProviders() async {
final response = await _api.fetchProviders();
return response.items;
}
Future<List<RoomGameShortcutModel>> fetchShortcutGames({
required String roomId,
}) {
return _api.fetchShortcutGames(roomId: roomId);
}) async {
final providers = await fetchProviders();
if (providers.isEmpty) {
return const <RoomGameShortcutModel>[];
}
final items = <RoomGameShortcutModel>[];
Object? lastError;
for (final provider in providers) {
try {
final result = await _api.fetchShortcutGames(
provider: provider.key,
roomId: roomId,
);
items.addAll(result);
} catch (error) {
lastError = error;
}
}
if (items.isEmpty && lastError != null) {
throw lastError;
}
items.sort((a, b) => a.sort.compareTo(b.sort));
return items;
}
Future<List<RoomGameListItemModel>> fetchRoomGames({
required String roomId,
String category = '',
}) async {
final response = await _api.fetchRoomGames(roomId: roomId, category: category);
return response.items;
final providers = await fetchProviders();
if (providers.isEmpty) {
return const <RoomGameListItemModel>[];
}
Future<BaishunRoomStateModel> fetchRoomState({required String roomId}) {
return _api.fetchRoomState(roomId: roomId);
final items = <RoomGameListItemModel>[];
Object? lastError;
for (final provider in providers) {
try {
final response = await _api.fetchRoomGames(
provider: provider.key,
roomId: roomId,
category: category,
);
items.addAll(response.items);
} catch (error) {
lastError = error;
}
}
Future<BaishunLaunchModel> launchBaishunGame({
if (items.isEmpty && lastError != null) {
throw lastError;
}
items.sort((a, b) => a.sort.compareTo(b.sort));
return items;
}
Future<BaishunRoomStateModel> fetchRoomState({
required String provider,
required String roomId,
}) {
return _api.fetchRoomState(provider: provider, roomId: roomId);
}
Future<BaishunLaunchModel> launchGame({
required String provider,
required String roomId,
required String gameId,
int sceneMode = 0,
int sceneMode = 1,
required String clientOrigin,
Map<String, dynamic> params = const <String, dynamic>{},
}) {
return _api.launchBaishunGame(
return _api.launchGame(
provider: provider,
roomId: roomId,
gameId: gameId,
sceneMode: sceneMode,
clientOrigin: clientOrigin,
params: params,
);
}
Future<BaishunRoomStateModel> closeBaishunGame({
Future<BaishunRoomStateModel> closeGame({
required String provider,
required String roomId,
required String gameSessionId,
String reason = 'user_exit',
Map<String, dynamic> params = const <String, dynamic>{},
}) {
return _api.closeBaishunGame(
return _api.closeGame(
provider: provider,
roomId: roomId,
gameSessionId: gameSessionId,
reason: reason,
params: params,
);
}
}

View File

@ -33,6 +33,7 @@ class BaishunGamePage extends StatefulWidget {
class _BaishunGamePageState extends State<BaishunGamePage> {
static const String _logPrefix = '[BaishunGame]';
static const double _designWidth = 750;
final RoomGameRepository _repository = RoomGameRepository();
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
@ -375,13 +376,15 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
_log('close_start reason=$reason');
try {
if (!widget.launchModel.gameSessionId.startsWith('bs_mock_')) {
final result = await _repository.closeBaishunGame(
final result = await _repository.closeGame(
provider: widget.game.provider,
roomId: widget.roomId,
gameSessionId: widget.launchModel.gameSessionId,
reason: reason,
params: const <String, dynamic>{},
);
_log(
'close_success result=${_stringifyForLog(<String, dynamic>{'roomId': result.roomId, 'state': result.state, 'gameSessionId': result.gameSessionId, 'currentGameId': result.currentGameId, 'hostUserId': result.hostUserId})}',
'close_success result=${_stringifyForLog(<String, dynamic>{'roomId': result.roomId, 'state': result.state, 'provider': result.provider, 'gameSessionId': result.gameSessionId, 'currentGameId': result.currentGameId, 'hostUserId': result.hostUserId})}',
);
}
} catch (error) {
@ -596,9 +599,38 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
}
}
int _resolveSafeHeight() {
if (widget.launchModel.entry.safeHeight > 0) {
return widget.launchModel.entry.safeHeight;
}
if (widget.game.safeHeight > 0) {
return widget.game.safeHeight;
}
return 0;
}
double _calculateWebViewHeight(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final safePadding = MediaQuery.paddingOf(context);
final safeHeight = _resolveSafeHeight();
final fallbackHeight = screenSize.height * 0.5;
if (safeHeight <= 0 || screenSize.width <= 0) {
return fallbackHeight;
}
final ratio = _designWidth / safeHeight;
final webViewHeight = screenSize.width / ratio;
final maxHeight = screenSize.height - safePadding.top - 12.w;
if (maxHeight <= 0) {
return fallbackHeight;
}
return webViewHeight.clamp(0.0, maxHeight).toDouble();
}
@override
Widget build(BuildContext context) {
final topCrop = 28.w;
final webViewHeight = _calculateWebViewHeight(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
@ -618,7 +650,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
),
child: Container(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight * 0.5,
height: webViewHeight,
color: const Color(0xFF081915),
child: Stack(
children: [

View File

@ -0,0 +1,661 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/views/baishun_loading_view.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class LeaderGamePage extends StatefulWidget {
const LeaderGamePage({
super.key,
required this.roomId,
required this.game,
required this.launchModel,
});
final String roomId;
final RoomGameListItemModel game;
final BaishunLaunchModel launchModel;
@override
State<LeaderGamePage> createState() => _LeaderGamePageState();
}
class _LeaderGamePageState extends State<LeaderGamePage> {
static const String _logPrefix = '[LeaderGame]';
static const double _designWidth = 750;
static const double _hdDesignHeight = 1044;
final RoomGameRepository _repository = RoomGameRepository();
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
<Factory<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
};
late final WebViewController _controller;
Timer? _bridgeBootstrapTimer;
Timer? _loadingFallbackTimer;
bool _isLoading = true;
bool _isClosing = false;
bool _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false;
int _bridgeInjectCount = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel(
LeaderJsBridge.channelName,
onMessageReceived: _handleBridgeMessage,
)
..addJavaScriptChannel(
LeaderBridgeActions.getConfig,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
LeaderBridgeActions.getConfig,
message,
),
)
..addJavaScriptChannel(
LeaderBridgeActions.pay,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
LeaderBridgeActions.pay,
message,
),
)
..addJavaScriptChannel(
LeaderBridgeActions.closeGame,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
LeaderBridgeActions.closeGame,
message,
),
)
..addJavaScriptChannel(
LeaderBridgeActions.loadComplete,
onMessageReceived:
(JavaScriptMessage message) => _handleNamedChannelMessage(
LeaderBridgeActions.loadComplete,
message,
),
)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
_log('page_started url=${_clip(url, 240)}');
_prepareForPageLoad();
},
onPageFinished: (String url) async {
_didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}');
await _injectBridge(reason: 'page_finished');
},
onWebResourceError: (WebResourceError error) {
_log(
'web_resource_error code=${error.errorCode} '
'type=${error.errorType} desc=${_clip(error.description, 300)}',
);
_stopBridgeBootstrap(reason: 'web_resource_error');
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.description;
_isLoading = false;
});
},
),
);
_log('init launch=${_stringifyForLog(_buildLaunchSummary())}');
unawaited(_loadGameEntry());
}
@override
void dispose() {
_stopBridgeBootstrap(reason: 'dispose');
super.dispose();
}
void _prepareForPageLoad() {
_didReceiveBridgeMessage = false;
_didFinishPageLoad = false;
_bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
Timer timer,
) {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
timer.cancel();
return;
}
unawaited(_injectBridge(reason: 'bootstrap'));
if (_didFinishPageLoad && timer.tick >= 12) {
timer.cancel();
}
});
_loadingFallbackTimer = Timer(const Duration(seconds: 6), () {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
return;
}
_log('loading_fallback_fire');
setState(() {
_isLoading = false;
});
});
if (!mounted) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
}
void _stopBridgeBootstrap({String reason = 'manual'}) {
if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) {
_log('stop_bootstrap reason=$reason');
}
_bridgeBootstrapTimer?.cancel();
_bridgeBootstrapTimer = null;
_loadingFallbackTimer?.cancel();
_loadingFallbackTimer = null;
}
Future<void> _loadGameEntry() async {
try {
_prepareForPageLoad();
final entryUrl = widget.launchModel.entry.entryUrl.trim();
_log(
'load_entry launchMode=${widget.launchModel.entry.launchMode} '
'entryUrl=${_clip(entryUrl, 240)}',
);
if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) {
await _controller.loadHtmlString(_buildMockHtml());
return;
}
final uri = Uri.tryParse(entryUrl);
if (uri == null) {
throw Exception('Invalid game entry url: $entryUrl');
}
await _controller.loadRequest(uri);
} catch (error) {
_log('load_entry_error error=${_clip(error.toString(), 400)}');
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.toString();
_isLoading = false;
});
}
}
Future<void> _injectBridge({String reason = 'manual'}) async {
_bridgeInjectCount += 1;
if (reason != 'bootstrap' ||
_bridgeInjectCount <= 3 ||
_bridgeInjectCount % 5 == 0) {
_log('inject_bridge reason=$reason count=$_bridgeInjectCount');
}
try {
await _controller.runJavaScript(
LeaderJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()),
);
} catch (error) {
_log(
'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}',
);
}
}
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
_log('channel_message raw=${_clip(message.message, 600)}');
final bridgeMessage = LeaderBridgeMessage.parse(message.message);
await _dispatchBridgeMessage(bridgeMessage);
}
Future<void> _handleNamedChannelMessage(
String action,
JavaScriptMessage message,
) async {
final bridgeMessage = LeaderBridgeMessage.fromAction(
action,
message.message,
);
_log('named_channel action=$action raw=${_clip(message.message, 600)}');
await _dispatchBridgeMessage(bridgeMessage);
}
Future<void> _dispatchBridgeMessage(LeaderBridgeMessage bridgeMessage) async {
if (bridgeMessage.action == LeaderBridgeActions.debugLog) {
final tag = bridgeMessage.payload['tag']?.toString().trim();
final message = bridgeMessage.payload['message']?.toString() ?? '';
_log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}');
return;
}
_didReceiveBridgeMessage = true;
_stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}');
_log(
'bridge_action action=${bridgeMessage.action} '
'payload=${_clip(bridgeMessage.payload.toString(), 800)}',
);
switch (bridgeMessage.action) {
case LeaderBridgeActions.getConfig:
await _injectBridge(reason: 'get_config');
break;
case LeaderBridgeActions.pay:
await SCNavigatorUtils.push(context, WalletRoute.recharge);
await _notifyUpdateCoin();
break;
case LeaderBridgeActions.closeGame:
await _closeAndExit(reason: 'h5_closeGame');
break;
case LeaderBridgeActions.loadComplete:
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = null;
});
break;
default:
_log('bridge_action_unhandled action=${bridgeMessage.action}');
break;
}
}
Future<void> _notifyUpdateCoin() async {
_log('notify_update_coin');
try {
await _controller.runJavaScript(LeaderJsBridge.buildUpdateCoinScript());
} catch (error) {
_log('notify_update_coin_error error=${_clip(error.toString(), 300)}');
}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
setState(() {
_errorMessage = null;
_isLoading = true;
});
await _loadGameEntry();
}
Future<void> _closeAndExit({String reason = 'user_exit'}) async {
if (_isClosing) {
return;
}
_isClosing = true;
try {
final sessionId = widget.launchModel.gameSessionId.trim();
if (sessionId.isNotEmpty && !sessionId.startsWith('mock')) {
await _repository.closeGame(
provider: widget.game.provider,
roomId: widget.roomId,
gameSessionId: sessionId,
reason: reason,
params: const <String, dynamic>{},
);
}
} catch (error) {
_log('close_error error=${_clip(error.toString(), 300)}');
}
if (mounted) {
Navigator.of(context).pop();
}
}
String _resolveScreenMode() {
final launchParams = widget.game.launchParams;
final candidates = <String>[
launchParams['screenMode']?.toString() ?? '',
launchParams['screen_mode']?.toString() ?? '',
launchParams['displayMode']?.toString() ?? '',
launchParams['gameScreenMode']?.toString() ?? '',
];
for (final candidate in candidates) {
final trimmed = candidate.trim();
if (trimmed.isNotEmpty) {
return trimmed.toLowerCase();
}
}
if (widget.game.fullScreen) {
return 'full';
}
return 'half';
}
int _resolveSafeHeight() {
if (widget.launchModel.entry.safeHeight > 0) {
return widget.launchModel.entry.safeHeight;
}
if (widget.game.safeHeight > 0) {
return widget.game.safeHeight;
}
return 0;
}
double _calculateBodyHeight(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final safePadding = MediaQuery.paddingOf(context);
final screenMode = _resolveScreenMode();
final safeHeight = _resolveSafeHeight();
final fallback = screenSize.width;
final maxHeight = screenSize.height - safePadding.top - 24.w;
if (maxHeight <= 0) {
return fallback;
}
if (screenMode == 'full') {
return maxHeight;
}
if (safeHeight > 0 && screenSize.width > 0) {
final ratio = _designWidth / safeHeight;
final bodyHeight = screenSize.width / ratio;
return bodyHeight.clamp(0.0, maxHeight).toDouble();
}
if (screenMode == 'hd') {
final bodyHeight = screenSize.width * _hdDesignHeight / _designWidth;
return bodyHeight.clamp(0.0, maxHeight).toDouble();
}
return fallback.clamp(0.0, maxHeight).toDouble();
}
void _log(String message) {
debugPrint('$_logPrefix $message');
}
String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.length <= keepStart + keepEnd) {
return trimmed;
}
return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}';
}
Object? _sanitizeForLog(Object? value, {String key = ''}) {
final lowerKey = key.toLowerCase();
final shouldMask =
lowerKey.contains('code') ||
lowerKey.contains('token') ||
lowerKey.contains('authorization');
if (value is Map) {
final result = <String, dynamic>{};
for (final MapEntry<dynamic, dynamic> entry in value.entries) {
result[entry.key.toString()] = _sanitizeForLog(
entry.value,
key: entry.key.toString(),
);
}
return result;
}
if (value is List) {
return value.map((Object? item) => _sanitizeForLog(item)).toList();
}
if (value is String) {
final normalized = shouldMask ? _maskValue(value) : value;
return _clip(normalized, 600);
}
return value;
}
String _clip(String value, [int limit = 600]) {
final trimmed = value.trim();
if (trimmed.length <= limit) {
return trimmed;
}
return '${trimmed.substring(0, limit)}...';
}
String _stringifyForLog(Object? value) {
if (value == null) {
return '';
}
try {
return _clip(jsonEncode(value), 800);
} catch (_) {
return _clip(value.toString(), 800);
}
}
Map<String, dynamic> _buildLaunchPayload() {
return <String, dynamic>{
'provider': widget.launchModel.provider,
'gameId': widget.launchModel.gameId,
'providerGameId': widget.launchModel.providerGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'entry': widget.launchModel.entry.toJson(),
'launchConfig': widget.launchModel.launchConfig.toJson(),
'roomState': widget.launchModel.roomState.toJson(),
'game': <String, dynamic>{
'gameId': widget.game.gameId,
'provider': widget.game.provider,
'gameType': widget.game.gameType,
'name': widget.game.name,
'launchMode': widget.game.launchMode,
},
};
}
Map<String, dynamic> _buildLaunchSummary() {
final payload = _buildLaunchPayload();
return <String, dynamic>{
'roomId': widget.roomId,
'provider': widget.game.provider,
'gameId': widget.game.gameId,
'providerGameId': widget.launchModel.providerGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'entry': _sanitizeForLog(payload['entry']),
'launchConfig': _sanitizeForLog(payload['launchConfig']),
'roomState': _sanitizeForLog(payload['roomState']),
};
}
String _buildMockHtml() {
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { margin: 0; font-family: sans-serif; background: #0c1a17; color: #fff; }
.wrap { padding: 24px; }
button {
margin: 8px 8px 0 0;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: #1d8f6d;
color: #fff;
}
#log { margin-top: 16px; white-space: pre-wrap; color: #b9d8ce; }
</style>
</head>
<body>
<div class="wrap">
<h3>Leader Mock</h3>
<p>Use these buttons to verify bridge wiring.</p>
<button onclick="loadComplete()">loadComplete()</button>
<button onclick="console.log(getConfig && getConfig())">getConfig()</button>
<button onclick="pay()">pay()</button>
<button onclick="closeGame()">closeGame()</button>
<button onclick="window.updateCoin && window.updateCoin()">updateCoin()</button>
<div id="log"></div>
</div>
<script>
document.getElementById('log').textContent =
'launchConfig=' + JSON.stringify(window.leaderLaunchConfig || {});
window.updateCoin = function() {
document.getElementById('log').textContent += '\\nupdateCoin called';
};
</script>
</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
final bodyHeight = _calculateBodyHeight(context);
final headerHeight = 44.w;
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
await _closeAndExit();
},
child: Material(
color: Colors.transparent,
child: Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.w),
topRight: Radius.circular(24.w),
),
child: Container(
width: ScreenUtil().screenWidth,
height: bodyHeight + headerHeight,
color: const Color(0xFF081915),
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: headerHeight,
child: _buildHeader(),
),
Positioned(
left: 0,
right: 0,
top: headerHeight,
bottom: 0,
child: WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
),
),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const IgnorePointer(
ignoring: true,
child: BaishunLoadingView(
message: 'Waiting for Leader game...',
),
),
],
),
),
),
),
),
);
}
Widget _buildHeader() {
return Container(
color: const Color(0xFF102D28),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(
child: Text(
widget.game.name.isEmpty ? 'Leader Game' : widget.game.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () {
unawaited(_closeAndExit());
},
icon: Icon(Icons.close, color: Colors.white, size: 20.w),
splashRadius: 18.w,
),
],
),
);
}
Widget _buildErrorState() {
return Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.88),
padding: EdgeInsets.symmetric(horizontal: 28.w),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Colors.white70, size: 34.w),
SizedBox(height: 12.w),
Text(
'Leader game failed to load',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 8.w),
Text(
_errorMessage ?? '',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
),
SizedBox(height: 16.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: _reload, child: const Text('Retry')),
SizedBox(width: 10.w),
TextButton(
onPressed: () {
SCTts.show('Please verify the Leader entry url');
},
child: const Text('Tips'),
),
],
),
],
),
),
);
}
}

View File

@ -1,11 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'dart:convert';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_repository.dart';
import 'package:yumi/modules/room_game/views/baishun_game_page.dart';
import 'package:yumi/modules/room_game/views/leader_game_page.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
@ -22,7 +22,7 @@ class RoomGameListSheet extends StatefulWidget {
}
class _RoomGameListSheetState extends State<RoomGameListSheet> {
static const String _logPrefix = '[BaishunLaunch]';
static const String _logPrefix = '[RoomGameLaunch]';
static const int _itemsPerRow = 4;
static const String _sheetFrameAsset =
'sc_images/room/sc_room_game_sheet_frame.png';
@ -69,8 +69,8 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
SCTts.show('roomId is empty');
return;
}
if (!game.isBaishun) {
SCTts.show('Only BAISHUN games are wired for now');
if (!game.isBaishun && !game.isLeader) {
SCTts.show('This game provider is not wired yet');
return;
}
@ -81,28 +81,19 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
try {
_log(
'launch_request roomId=$_roomId gameId=${game.gameId} '
'vendorGameId=${game.vendorGameId} gameMode=${game.gameMode} '
'origin=${Platform.isAndroid ? 'ANDROID' : 'IOS'}',
'provider=${game.provider} gameType=${game.gameType} '
'providerGameId=${game.providerGameId} gameMode=${game.gameMode} '
'origin=COMMON params=${_stringifyForLog(game.launchParams)}',
);
final launchModel = await _repository.launchBaishunGame(
final launchModel = await _repository.launchGame(
provider: game.provider,
roomId: _roomId,
gameId: game.gameId,
clientOrigin: Platform.isAndroid ? 'ANDROID' : 'IOS',
clientOrigin: 'COMMON',
params: game.launchParams,
);
_log(
'launch_success payload=${_stringifyForLog(<String, dynamic>{
'gameSessionId': launchModel.gameSessionId,
'vendorType': launchModel.vendorType,
'gameId': launchModel.gameId,
'vendorGameId': launchModel.vendorGameId,
'entryUrl': _clip(launchModel.entry.entryUrl, 240),
'launchMode': launchModel.entry.launchMode,
'packageVersion': launchModel.entry.packageVersion,
'orientation': launchModel.entry.orientation,
'safeHeight': launchModel.entry.safeHeight,
'roomState': launchModel.roomState.state,
'bridgeConfig': <String, dynamic>{'userId': launchModel.bridgeConfig.userId, 'roomId': launchModel.bridgeConfig.roomId, 'gameMode': launchModel.bridgeConfig.gameMode, 'language': launchModel.bridgeConfig.language, 'gsp': launchModel.bridgeConfig.gsp, 'code': _maskValue(launchModel.bridgeConfig.code), 'codeLength': launchModel.bridgeConfig.code.length},
})}',
'launch_success payload=${_stringifyForLog(<String, dynamic>{'gameSessionId': launchModel.gameSessionId, 'provider': launchModel.provider, 'gameId': launchModel.gameId, 'providerGameId': launchModel.providerGameId, 'entryUrl': _clip(launchModel.entry.entryUrl, 240), 'launchMode': launchModel.entry.launchMode, 'packageVersion': launchModel.entry.packageVersion, 'orientation': launchModel.entry.orientation, 'safeHeight': launchModel.entry.safeHeight, 'roomState': launchModel.roomState.state, 'launchConfig': _sanitizeForLog(launchModel.launchConfig.toJson())})}',
);
if (!mounted) {
return;
@ -113,9 +104,14 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
if (!widget.roomContext.mounted) {
return;
}
final gamePage = _buildGamePage(game, launchModel);
if (gamePage == null) {
SCTts.show('This game provider is not wired yet');
return;
}
showBottomInBottomDialog(
widget.roomContext,
BaishunGamePage(roomId: _roomId, game: game, launchModel: launchModel),
gamePage,
barrierColor: Colors.black54,
barrierDismissible: true,
);
@ -158,8 +154,59 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
if (value == null) {
return '';
}
try {
return _clip(jsonEncode(value), 800);
} catch (_) {
return _clip(value.toString(), 800);
}
}
Object? _sanitizeForLog(Object? value, {String key = ''}) {
final lowerKey = key.toLowerCase();
final shouldMask =
lowerKey.contains('code') ||
lowerKey.contains('token') ||
lowerKey.contains('authorization');
if (value is Map) {
final result = <String, dynamic>{};
for (final MapEntry<dynamic, dynamic> entry in value.entries) {
result[entry.key.toString()] = _sanitizeForLog(
entry.value,
key: entry.key.toString(),
);
}
return result;
}
if (value is List) {
return value.map((Object? item) => _sanitizeForLog(item)).toList();
}
if (value is String) {
final normalized = shouldMask ? _maskValue(value) : value;
return _clip(normalized, 600);
}
return value;
}
Widget? _buildGamePage(
RoomGameListItemModel game,
BaishunLaunchModel launchModel,
) {
if (game.isBaishun) {
return BaishunGamePage(
roomId: _roomId,
game: game,
launchModel: launchModel,
);
}
if (game.isLeader) {
return LeaderGamePage(
roomId: _roomId,
game: game,
launchModel: launchModel,
);
}
return null;
}
@override
Widget build(BuildContext context) {

View File

@ -158,9 +158,12 @@ class _MePage2State extends State<MePage2> {
idText: profile?.getID() ?? '',
showAnimated: profile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationWidth: 80.w,
animationHeight: 32.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
profile?.shouldShowColoredSpecialIdText() ?? false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
@ -171,6 +174,7 @@ class _MePage2State extends State<MePage2> {
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
animationFit: BoxFit.contain,
),
],
),

View File

@ -397,15 +397,20 @@ class _PersonDetailPageState extends State<PersonDetailPage>
),
SizedBox(height: 5.w),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(width: 25.w),
SCSpecialIdBadge(
idText: ref.userProfile?.getID() ?? "",
showAnimated: ref.userProfile?.hasSpecialId() ?? false,
assetPath: SCSpecialIdAssets.userIdLarge,
animationWidth: 150.w,
animationHeight: 34.w,
textPadding: EdgeInsets.fromLTRB(48.w, 8.w, 18.w, 8.w),
animationWidth: 80.w,
animationHeight: 32.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref.userProfile?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
@ -416,6 +421,7 @@ class _PersonDetailPageState extends State<PersonDetailPage>
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
animationFit: BoxFit.contain,
),
],
),

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:fluro/fluro.dart';
@ -60,6 +61,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Timer? _onlineUsersPollingTimer;
bool _isRefreshingMicList = false;
bool _isRefreshingOnlineUsers = false;
bool _disableMicListRefreshForCurrentSession = false;
bool _disableOnlineUsersRefreshForCurrentSession = false;
bool _pendingMicListRefresh = false;
Timer? _deferredMicListRefreshTimer;
int _lastMicListRefreshStartedAtMs = 0;
@ -166,6 +169,28 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_isRefreshingOnlineUsers = false;
}
bool _isMissingPathError(Object error) {
final normalizedMessage = _errorMessageFrom(error).toLowerCase();
return normalizedMessage.contains('path was not found') ||
normalizedMessage.contains('the path was not found');
}
String _errorMessageFrom(Object error) {
if (error is DioException) {
final responseData = error.response?.data;
if (responseData is Map) {
final dynamic errorMsg = responseData["errorMsg"];
if (errorMsg != null) {
return errorMsg.toString();
}
}
if ((error.message ?? "").trim().isNotEmpty) {
return error.message!;
}
}
return error.toString();
}
bool _sameOnlineUsers(
List<SocialChatUserProfile> previous,
List<SocialChatUserProfile> next,
@ -687,7 +712,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Duration minInterval = Duration.zero,
}) {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty) {
if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) {
return;
}
@ -1027,17 +1052,15 @@ class RealTimeCommunicationManager extends ChangeNotifier {
bool needOpenRedenvelope = false,
String? redPackId,
}) async {
_disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false;
if ((currenRoom?.roomProfile?.roomProfile?.event ==
SCRoomInfoEventType.WAITING_CONFIRMED.name ||
currenRoom?.roomProfile?.roomProfile?.event ==
SCRoomInfoEventType.ID_CHANGE.name) &&
currenRoom?.entrants?.roles == SCRoomRolesType.HOMEOWNER.name) {
///,
SCNavigatorUtils.push(
context!,
"${VoiceRoomRoute.roomEdit}?need=true",
replace: false,
);
VoiceRoomRoute.openRoomEdit(context!, needRestCurrentRoomInfo: true);
return Future;
}
SCRoomUtils.roomSCGlobalConfig(
@ -1143,12 +1166,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///线
Future<void> fetchOnlineUsersList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty || _isRefreshingOnlineUsers) {
if (roomId.isEmpty ||
_isRefreshingOnlineUsers ||
_disableOnlineUsersRefreshForCurrentSession) {
return;
}
_isRefreshingOnlineUsers = true;
try {
final fetchedUsers = await SCChatRoomRepository().roomOnlineUsers(roomId);
final fetchedUsers = await SCChatRoomRepository().roomOnlineUsers(
roomId,
silentErrorToast: true,
);
if (roomId != currenRoom?.roomProfile?.roomProfile?.id) {
return;
}
@ -1158,6 +1186,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (changed || notifyIfUnchanged) {
notifyListeners();
}
} catch (error) {
if (_isMissingPathError(error)) {
_disableOnlineUsersRefreshForCurrentSession = true;
debugPrint(
'[Room][OnlineUsers] suspend auto refresh for current session: ${_errorMessageFrom(error)}',
);
} else {
debugPrint(
'[Room][OnlineUsers] refresh failed: ${_errorMessageFrom(error)}',
);
}
} finally {
_isRefreshingOnlineUsers = false;
}
@ -1165,7 +1204,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Future<void> retrieveMicrophoneList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty) {
if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) {
return;
}
if (_isRefreshingMicList) {
@ -1175,12 +1214,31 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_isRefreshingMicList = true;
_lastMicListRefreshStartedAtMs = DateTime.now().millisecondsSinceEpoch;
try {
final roomWheatList = await SCChatRoomRepository().micList(roomId);
final roomWheatList = await SCChatRoomRepository().micList(
roomId,
silentErrorToast: true,
);
if (roomId != currenRoom?.roomProfile?.roomProfile?.id) {
return;
}
final nextMap = _buildMicMap(roomWheatList);
_applyMicSnapshot(nextMap, notifyIfUnchanged: notifyIfUnchanged);
} catch (error) {
if (_isMissingPathError(error)) {
_disableMicListRefreshForCurrentSession = true;
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
debugPrint(
'[Room][MicList] suspend auto refresh for current session: ${_errorMessageFrom(error)}',
);
if (notifyIfUnchanged) {
notifyListeners();
}
} else {
debugPrint(
'[Room][MicList] refresh failed: ${_errorMessageFrom(error)}',
);
}
} finally {
_isRefreshingMicList = false;
if (_pendingMicListRefresh) {
@ -1284,6 +1342,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
engine = null;
_resetHeartbeatTracking();
_resetLocalAudioRuntimeTracking();
_disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false;
roomRocketStatus = null;
rtmProvider
?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] =

View File

@ -585,6 +585,19 @@ class SocialChatUserProfile {
return false;
}
/// ID VIP
/// useProps NOBLE_VIP
bool hasVipPrivilegeForColoredId() {
return _useProps?.any(
(value) => value.propsResources?.type == SCPropsType.NOBLE_VIP.name,
) ??
false;
}
bool shouldShowColoredSpecialIdText() {
return hasVipPrivilegeForColoredId();
}
void setHeartbeatVal(num? heartbeatVal) {
_heartbeatVal = (_heartbeatVal ?? 0) + (heartbeatVal ?? 0);
}

View File

@ -32,7 +32,7 @@ abstract class SocialChatRoomRepository {
Future<List<SocialChatRoomRes>> discovery({bool? allRegion});
///
Future<List<MicRes>> micList(String roomId);
Future<List<MicRes>> micList(String roomId, {bool silentErrorToast = false});
///
Future<MyRoomRes> specific(String roomId);
@ -69,7 +69,10 @@ abstract class SocialChatRoomRepository {
Future<bool> micKill(String roomId, num mickIndex);
///线
Future<List<SocialChatUserProfile>> roomOnlineUsers(String roomId);
Future<List<SocialChatUserProfile>> roomOnlineUsers(
String roomId, {
bool silentErrorToast = false,
});
///
Future<RoomUserCardRes> roomUserCard(String roomId, String userId);

View File

@ -107,12 +107,19 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
///live/mic/list
@override
Future<List<MicRes>> micList(String roomId) async {
Future<List<MicRes>> micList(
String roomId, {
bool silentErrorToast = false,
}) async {
Map<String, dynamic> queryParams = {};
queryParams["roomId"] = roomId;
final result = await http.get(
"b1d6a1284f5ddeaa1cbb1430f2991e8a",
queryParams: queryParams,
extra:
silentErrorToast
? const {BaseNetworkClient.silentErrorToastKey: true}
: null,
fromJson:
(json) => (json as List).map((e) => MicRes.fromJson(e)).toList(),
);
@ -244,10 +251,17 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
///live/user/list
@override
Future<List<SocialChatUserProfile>> roomOnlineUsers(String roomId) async {
Future<List<SocialChatUserProfile>> roomOnlineUsers(
String roomId, {
bool silentErrorToast = false,
}) async {
final result = await http.get(
"a01ade8fbb604c0904557a9483fcc4d9",
queryParams: {"roomId": roomId},
extra:
silentErrorToast
? const {BaseNetworkClient.silentErrorToastKey: true}
: null,
fromJson:
(json) =>
(json as List)

View File

@ -8,6 +8,18 @@ class SCSpecialIdAssets {
static const String userIdLarge = 'sc_images/general/user_id_4.svga';
}
const LinearGradient _scSpecialIdVipTextGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFFFFD24D),
Color(0xFFFF7A4D),
Color(0xFFFF4FB3),
Color(0xFF8E63FF),
Color(0xFF38D6FF),
],
);
class SCSpecialIdBadge extends StatelessWidget {
const SCSpecialIdBadge({
super.key,
@ -17,10 +29,15 @@ class SCSpecialIdBadge extends StatelessWidget {
this.animationWidth = 120,
this.animationHeight = 28,
this.textPadding = EdgeInsets.zero,
this.showTextWhenAnimated = false,
this.showTextBesideAnimated = false,
this.animatedTextSpacing = 0,
this.animatedTextPrefix = '',
this.animationTextStyle,
this.normalTextStyle,
this.normalPrefix = 'ID:',
this.showCopyIcon = false,
this.showCopyIconWhenAnimated = false,
this.copyIconAssetPath = 'sc_images/room/sc_icon_user_card_copy_id.png',
this.copyIconSize = 12,
this.copyIconSpacing = 5,
@ -28,6 +45,9 @@ class SCSpecialIdBadge extends StatelessWidget {
this.loop = true,
this.active = true,
this.animationFit = BoxFit.fill,
this.inlineAnimationWidth,
this.showAnimatedGradientText = false,
this.animatedTextGradient,
});
final String idText;
@ -36,10 +56,15 @@ class SCSpecialIdBadge extends StatelessWidget {
final double animationWidth;
final double animationHeight;
final EdgeInsetsGeometry textPadding;
final bool showTextWhenAnimated;
final bool showTextBesideAnimated;
final double animatedTextSpacing;
final String animatedTextPrefix;
final TextStyle? animationTextStyle;
final TextStyle? normalTextStyle;
final String normalPrefix;
final bool showCopyIcon;
final bool showCopyIconWhenAnimated;
final String copyIconAssetPath;
final double copyIconSize;
final double copyIconSpacing;
@ -47,6 +72,9 @@ class SCSpecialIdBadge extends StatelessWidget {
final bool loop;
final bool active;
final BoxFit animationFit;
final double? inlineAnimationWidth;
final bool showAnimatedGradientText;
final Gradient? animatedTextGradient;
@override
Widget build(BuildContext context) {
@ -58,11 +86,15 @@ class SCSpecialIdBadge extends StatelessWidget {
final children = <Widget>[
shouldAnimate
? (showTextWhenAnimated
? _buildAnimatedBadge(normalizedId)
: (showTextBesideAnimated
? _buildAnimatedInlineBadge(normalizedId)
: _buildAnimatedAsset()))
: _buildPlainBadge(normalizedId),
];
if (showCopyIcon) {
if (showCopyIcon && (!shouldAnimate || showCopyIconWhenAnimated)) {
children.add(SizedBox(width: copyIconSpacing));
children.add(
Image.asset(
@ -81,6 +113,85 @@ class SCSpecialIdBadge extends StatelessWidget {
);
}
Widget _buildAnimatedAsset() {
return SizedBox(
width: animationWidth,
height: animationHeight,
child: SCSvgaAssetWidget(
assetPath: assetPath!,
width: animationWidth,
height: animationHeight,
active: active,
loop: loop,
fit: animationFit,
fallback: const SizedBox.shrink(),
),
);
}
Widget _buildAnimatedInlineBadge(String normalizedId) {
final compactAnimationWidth = inlineAnimationWidth ?? animationHeight;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: compactAnimationWidth,
height: animationHeight,
child: SCSvgaAssetWidget(
assetPath: assetPath!,
width: compactAnimationWidth,
height: animationHeight,
active: active,
loop: loop,
fit: animationFit,
fallback: const SizedBox.shrink(),
),
),
SizedBox(width: animatedTextSpacing),
_buildGradientAwareText('$animatedTextPrefix$normalizedId'),
],
);
}
Widget _buildGradientAwareText(
String text, {
TextStyle? styleOverride,
TextOverflow overflow = TextOverflow.ellipsis,
}) {
final style =
styleOverride ??
animationTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
);
if (!showAnimatedGradientText) {
return Text(text, maxLines: 1, overflow: overflow, style: style);
}
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) {
final shaderBounds = Rect.fromLTWH(
0,
0,
bounds.width <= 0 ? 1 : bounds.width,
bounds.height <= 0 ? 1 : bounds.height,
);
return (animatedTextGradient ?? _scSpecialIdVipTextGradient)
.createShader(shaderBounds);
},
child: Text(
text,
maxLines: 1,
overflow: overflow,
style: style.copyWith(color: Colors.white),
),
);
}
Widget _buildAnimatedBadge(String normalizedId) {
return SizedBox(
width: animationWidth,
@ -107,17 +218,9 @@ class SCSpecialIdBadge extends StatelessWidget {
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
child: _buildGradientAwareText(
normalizedId,
maxLines: 1,
overflow: TextOverflow.visible,
style:
animationTextStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
@ -129,11 +232,9 @@ class SCSpecialIdBadge extends StatelessWidget {
}
Widget _buildPlainBadge(String normalizedId) {
return Text(
return _buildGradientAwareText(
'$normalPrefix$normalizedId',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
styleOverride:
normalTextStyle ??
const TextStyle(
color: Colors.white,

View File

@ -5,13 +5,12 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:marquee/marquee.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/modules/room/detail/room_detail_page.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart';
import 'package:yumi/ui_kit/widgets/room/exit_min_room_page.dart';
import 'package:yumi/ui_kit/widgets/room/sc_edit_room_announcement_page.dart';
@ -34,6 +33,12 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
roomCover: room?.roomProfile?.roomProfile?.roomCover,
roomName: room?.roomProfile?.roomProfile?.roomName ?? "",
roomDisplayId: room?.roomProfile?.userProfile?.getID() ?? "",
roomShowAnimatedId:
room?.roomProfile?.userProfile?.hasSpecialId() ?? false,
roomShowGradientId:
room?.roomProfile?.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
roomOwnerUserId: roomOwnerUserId,
isRoomOwner: roomOwnerUserId == currentUserId,
);
@ -142,11 +147,28 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
),
),
text(
"ID:${roomSnapshot.roomDisplayId}",
SCSpecialIdBadge(
idText: roomSnapshot.roomDisplayId,
showAnimated: roomSnapshot.roomShowAnimatedId,
assetPath: SCSpecialIdAssets.roomOwnerId,
animationWidth: 48.w,
animationHeight: 18.w,
inlineAnimationWidth: 18.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
roomSnapshot.roomShowGradientId,
animationTextStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white70,
color: Colors.white,
),
normalTextStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
animationFit: BoxFit.contain,
),
],
),
@ -190,10 +212,9 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
),
onTap: () {
if (context.read<RtcProvider>().isFz()) {
SCNavigatorUtils.push(
VoiceRoomRoute.openRoomEdit(
context,
"${VoiceRoomRoute.roomEdit}?need=false",
replace: false,
needRestCurrentRoomInfo: false,
);
} else {
SmartDialog.show(
@ -242,6 +263,8 @@ class _RoomHeadSnapshot {
required this.roomCover,
required this.roomName,
required this.roomDisplayId,
required this.roomShowAnimatedId,
required this.roomShowGradientId,
required this.roomOwnerUserId,
required this.isRoomOwner,
});
@ -250,6 +273,8 @@ class _RoomHeadSnapshot {
final String? roomCover;
final String roomName;
final String roomDisplayId;
final bool roomShowAnimatedId;
final bool roomShowGradientId;
final String roomOwnerUserId;
final bool isRoomOwner;
@ -263,6 +288,8 @@ class _RoomHeadSnapshot {
other.roomCover == roomCover &&
other.roomName == roomName &&
other.roomDisplayId == roomDisplayId &&
other.roomShowAnimatedId == roomShowAnimatedId &&
other.roomShowGradientId == roomShowGradientId &&
other.roomOwnerUserId == roomOwnerUserId &&
other.isRoomOwner == isRoomOwner;
}
@ -273,6 +300,8 @@ class _RoomHeadSnapshot {
roomCover,
roomName,
roomDisplayId,
roomShowAnimatedId,
roomShowGradientId,
roomOwnerUserId,
isRoomOwner,
);

View File

@ -1,5 +1,4 @@
import 'dart:ui' as ui;
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';
@ -17,22 +16,28 @@ import 'package:yumi/services/audio/rtm_manager.dart';
import '../../../app/routes/sc_fluro_navigator.dart';
import '../../../modules/index/main_route.dart';
import '../../../modules/room/voice_room_route.dart';
import '../../../modules/store/store_route.dart';
import '../../components/sc_debounce_widget.dart';
import '../../components/sc_tts.dart';
import '../../components/text/sc_text.dart';
class RoomMenuDialog extends StatefulWidget {
int roomMenuStime = 0;
Function(int eTime) callBack;
final int roomMenuStime;
final Function(int eTime) callBack;
RoomMenuDialog(this.roomMenuStime, this.callBack);
const RoomMenuDialog(this.roomMenuStime, this.callBack, {super.key});
@override
_RoomMenuDialogState createState() => _RoomMenuDialogState();
State<RoomMenuDialog> createState() => _RoomMenuDialogState();
}
class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuMicManagement = 0;
static const int _menuClearMessage = 1;
static const int _menuPicture = 2;
static const int _menuSettings = 3;
static const int _menuSound = 4;
static const int _menuReport = 5;
static const int _menuBackground = 6;
List<RoomMenu> items1 = [];
List<RoomMenu> items2 = [];
@ -75,7 +80,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
0,
_menuMicManagement,
SCAppLocalizations.of(context)!.micManagement,
"sc_icon_menu_mic_model_change.png",
),
@ -84,14 +89,14 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
items2.add(
RoomMenu(
1,
_menuClearMessage,
SCAppLocalizations.of(context)!.clearMessage,
"sc_icon_room_msg_clear.png",
),
);
items2.add(
RoomMenu(
2,
_menuPicture,
SCAppLocalizations.of(context)!.picture,
"sc_icon_room_msg_pic.png",
),
@ -99,7 +104,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
3,
_menuSettings,
SCAppLocalizations.of(context)!.settings,
"sc_icon_room_menu_settins.png",
),
@ -107,17 +112,26 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
}
items2.add(
RoomMenu(
4,
_menuSound,
SCAppLocalizations.of(context)!.sound2,
Provider.of<RtcProvider>(context, listen: false).roomIsMute
? "sc_icon_mic_mute.png"
: "sc_icon_mic_open.png",
),
);
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
_menuBackground,
SCAppLocalizations.of(context)!.background,
"sc_icon_room_background.png",
),
);
}
if (!Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
5,
_menuReport,
SCAppLocalizations.of(context)!.report,
"sc_icon_room_report.png",
),
@ -134,7 +148,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
child: Container(
width: ScreenUtil().screenWidth,
height: 200.w,
color: Color(0xff09372E).withOpacity(0.5),
color: const Color(0xff09372E).withValues(alpha: 0.5),
child: Column(
children: [
Expanded(
@ -190,7 +204,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
],
),
onTap: () async {
if (item.id == 0) {
if (item.id == _menuMicManagement) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SmartDialog.show(
tag: "showRoomMicSwitch",
@ -201,10 +215,10 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
return RoomMicSwitchPage();
},
);
} else if (item.id == 1) {
} else if (item.id == _menuClearMessage) {
Provider.of<RtmProvider>(context, listen: false).clearMessage();
SmartDialog.dismiss(tag: "showRoomMenuDialog");
} else if (item.id == 2) {
} else if (item.id == _menuPicture) {
if (SCRoomUtils.touristCanMsg(context)) {
SCPickUtils.pickImage(context, (bool success, String url) {
if (success) {
@ -234,26 +248,31 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
}, neeCrop: false);
}
} else if (item.id == 3) {
} else if (item.id == _menuSettings) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SCNavigatorUtils.push(
VoiceRoomRoute.openRoomEdit(
navigatorKey.currentState!.context,
"${VoiceRoomRoute.roomEdit}?need=false",
replace: false,
needRestCurrentRoomInfo: false,
);
} else if (item.id == 4) {
} else if (item.id == _menuSound) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
Provider.of<RtcProvider>(
context,
listen: false,
).toggleRemoteAudioMuteForAllUsers();
} else if (item.id == 5) {
} else if (item.id == _menuReport) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SCNavigatorUtils.push(
navigatorKey.currentState!.context,
"${SCMainRoute.report}?type=room&tageId=${Provider.of<RtcProvider>(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}",
replace: false,
);
} else if (item.id == _menuBackground) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
VoiceRoomRoute.openRoomBackgroundSelect(
navigatorKey.currentState!.context,
rootNavigator: true,
);
}
},
);

View File

@ -281,17 +281,18 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
false,
assetPath:
SCSpecialIdAssets.userId,
animationWidth: 110.w,
animationHeight: 28.w,
textPadding:
EdgeInsets.fromLTRB(
33.w,
6.w,
16.w,
6.w,
),
animationWidth: 62.w,
animationHeight: 24.w,
showTextBesideAnimated: true,
animatedTextSpacing: 0,
showAnimatedGradientText:
ref
.userCardInfo
?.userProfile
?.shouldShowColoredSpecialIdText() ??
false,
animationTextStyle: TextStyle(
color: Colors.white,
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
@ -300,6 +301,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
fontSize: 12.sp,
fontWeight: FontWeight.bold,
),
animationFit: BoxFit.contain,
),
],
),

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

1448
语言房重构.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,23 @@
## 当前总目标
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
## 本轮语言房背景页框架(进行中)
- 已按 2026-04-22 当前最新需求先落第一版页面骨架:新增两个独立路由,分别用于“背景选择页”和“自定义背景上传页”;其中语音房底部菜单里的 `Background` 图标入口,现已从旧的房间主题页切到新的背景选择页。
- 背景选择页已按“接近项目商店页”的结构先搭好导航和内容骨架:顶部为返回键、`官方 / 我的` 两个 tab 和右侧 `自定义` 文本按钮;点击右侧 `自定义` 会跳转到新的上传页。当前页内已先补上双列背景卡片栅格、选中高亮和“使用中”状态角标,便于后续继续接后台数据。
- 自定义背景上传页也已完成首版 UI 骨架:顶部为返回键和纯文本标题 `自定义背景图`,中间包含上传框、提示文案、示例区和底部 `Save` 按钮。当前已先接上本地相册选图和回传路径,保存后会把刚选的本地图先带回背景选择页,并插入到 `我的` tab 里做本地预览,先把交互骨架跑通。
- 已按 2026-04-22 最新页面微调继续收口交互细节:背景选择页底部新增了和上传页 `Save` 同风格的悬浮 `add` 按钮,但当前只会在 `Mine` tab 下显示,点击效果与右上角 `Custom` 一致,都会进入上传页;同时 `Official / Mine` tab 的点击波浪反馈已去掉,避免和目标稿风格不一致。
- 已继续统一两页正文区底色:当前背景选择页和背景上传页除顶部导航栏外的正文区域,都固定使用 `#0F0F0F`,不再透出全页背景图;顶部导航区域仍保留现有视觉,以便后续继续微调头部样式。
- 当前这一步仍是“框架版”,还没有正式接入背景后台列表、我的背景接口、上传审核接口和房间背景实际应用逻辑;下一步会在现有骨架上继续把真实数据源、选中态持久化和房内背景切换接进来。
## 本轮靓号动效替换(已完成)
- 已按 2026-04-21 最新靓号动效需求,把桌面“靓号动效”里的 `room_id / room_id_custom / user_id / user_id_4` 四套 `SVGA` 素材导入 Flutter 工程,并新增统一的靓号 ID 展示组件;当前会按 `ownSpecialId != null && expiredTime > now` 判定是否仍为有效靓号,过期靓号会自动回退到原始账号展示。
- 已完成 4 个指定场景的替换:`room_id` 现用于房间详情里的房间号,`room_id_custom` 现用于房间详情里的房主 ID`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;对应文案在命中靓号时会展示靓号号码,未命中时保持原始 `ID:` 文本样式。
- 已完成 4 个指定场景的首轮替换接线:`room_id` 现用于房间详情里的房间号,`user_id` 现用于房间在线列表和房间个人卡片,`user_id_4` 现用于个人主页和 `Me` 页;`room_id_custom` 也已同步导入工程并完成资源校验,后续又按最新规则把房内二级页的“个人 ID”统一收口到小号 `user_id.svga`。命中靓号时当前展示为“`SVGA` 徽章 + 靓号文本”同一行,未命中时保持原始 `ID:` 文本样式。
- 已按 2026-04-21 最新视觉反馈继续微调靓号展示:个人主页的靓号行已改回原来的左对齐位置,不再额外居中;同时 `SVGA` 徽章与靓号文本的间距已整体收窄,并已再次核对 `sc_images/general/room_id_custom.svga` 已实际导入工程,避免后续把素材缺失和样式问题混在一起。
- 已按 2026-04-21 最新补充要求继续统一房内二级页的个人 ID 展示:`SVGA` 与靓号文本间距现已进一步收敛到 `0`;房间成员列表、黑名单、贡献榜、房间个人卡片、房间在线列表以及房间详情里的房主个人 ID命中靓号时都会统一改成“小号 `user_id.svga + 靓号文本`”样式,普通账号则继续沿用原始 `ID:` 文本。
- 已按 2026-04-22 当前 UI 对稿需求,临时把这套靓号展示判定整体反转:当前个人主页、`Me` 页、房间在线列表、房间个人卡片、贡献榜、成员列表、黑名单、房间详情里的房主 ID以及房间详情里的 `room id`,都会在“非靓号”时先显示对应 `SVGA` 徽章,便于集中校对尺寸与排版;这一步仅用于临时验 UI后续确认完会再切回正式的“只有靓号才替换”逻辑。
- 已补上“靓号且 VIP 时 ID 数字显示炫彩渐变字”的组件能力预留:当前统一在靓号 ID 组件里支持渐变文字渲染,并新增 `shouldShowColoredSpecialIdText()` 判断;现阶段先复用 `useProps` 里的 `NOBLE_VIP` 做兼容判断,等后端正式 VIP 字段稳定后可直接切到独立接口,不用再逐页返工。
- 已按 2026-04-22 当前 UI 验收需求,临时把炫彩字展示条件整体放开:现阶段这些靓号 ID 展示位无论是否命中靓号/VIP 判断,都会先直接显示炫彩渐变字,专门用于集中验收字号、渐变配色和与 `SVGA` 徽章的搭配;后续确认完视觉稿后,再统一收回到正式条件判断。
- 已于 2026-04-22 UI 验收完成后收回到正式逻辑:当前只有命中靓号时才会用 `SVGA` 替换原来的 `ID` 标识;而具体的账号数字或靓号数字,只有命中 VIP 条件时才会显示炫彩渐变文本。现阶段 VIP 判断先复用 `useProps` 里的 `NOBLE_VIP`,后端独立字段就绪后可继续无痛切换。
## 本轮房间动效优化(进行中)
- 已按 2026-04-21 当前房间卡顿优化方案先落第一步“统一调度层”:新增房间特效调度器,在全屏 `VAP/SVGA` 高成本特效播放或排队期间,暂缓低优先级的房间进场动画、全局飘屏和座位飞屏入队,待高成本特效清空后按小间隔续播,先减少多条动画链路同一时刻抢主线程的情况。
@ -126,6 +140,8 @@
- 已按最新联调反馈回撤 BAISHUN 页面的横屏适配:当前后续不会接横屏游戏,因此已去掉游戏页按 `orientation` 切换系统方向的逻辑,并撤回 iPhone 端新增的横屏声明,只保留独立路由承载 BAISHUN 页面这一项 Flutter 容器修正。同时已继续增强临时调试,在 H5 侧补充 `pointerdown / touchstart / mousedown / click` 事件回传,用于下一轮真机直接确认“点击是否真的进到了 H5 DOM”这些输入事件日志依旧只用于本轮排障后续需要删除。
- 已按当前收尾需求移除 BAISHUN 临时调试面板:`BS DEBUG` 组件和相关调试入口现已从游戏页删除,不再作为正式功能保留;同时游戏页展示高度已恢复为接近半屏的底部弹出样式,回到最初设定的房内游戏视觉形态。
- 已继续微调 BAISHUN 游戏页高度:由于真实 H5 内容本身就是接近半屏,且顶部还需要预留拖拽条和标题栏空间,当前底部游戏页容器高度已从上一版的 `0.56` 屏高上调到 `0.62`,避免视觉上看起来比预期半屏更矮。
- 已按 2026-04-22 最新后端协议把语言房游戏数据层切到 `provider` 通用接口:当前会先拉 `/app/game/providers`,再按每个 `provider` 分别请求房间游戏列表,并在点击启动时改走 `/app/game/providers/{provider}/launch`、退出时改走 `/app/game/providers/{provider}/close`;列表项里的 `provider / gameType / launchParams` 也已补进 Flutter 模型,并在启动请求里把 `launchParams` 原样透传回后端。现阶段二级游戏页仍只接 BAISHUN bridge其余厂商先保留“未接线”分流提示避免影响现有百顺逻辑。
- 已继续补上新合作商的前端二级游戏壳子:当前语言房游戏列表点击后,除 BAISHUN 继续走原有 `BaishunGamePage` 外,命中 `provider/gameType=LEADER` 时会进入新增的 `LeaderGamePage`;该页已先具备独立 WebView 容器、底部弹窗样式、`pay / closeGame / loadComplete` 三个 H5 -> Flutter bridge 动作监听,以及充值返回后调用 `updateCoin()` 的 Flutter -> H5 回调。现阶段它仍属于前端预埋壳子,待后端 Leader 启动地址和真实 H5 ready 后再继续补联调细节。
- 创建并持续维护进度跟踪文件。
- 已继续排查语言房 gift 动画链路:确认送礼后会同时走本地房间消息、滚屏礼物条和大额礼物全局飘屏三条路径,并修复动画管理器在控制器尚未绑定完成时提前消费队列导致后续动画不再播放的问题。
- 已定位并修复语言房礼物飘屏资源引用错误:代码里误写 `sc_icon_gift_flosc_bg` / `sc_icon_luck_gift_flosc_*`,实际资源文件名为 `float`,导致点击送礼后飘屏背景图加载失败,相关动画无法正常显示。
@ -221,7 +237,11 @@
- `lib/services/gift/gift_animation_manager.dart`
- `lib/services/audio/rtc_manager.dart`
- `lib/modules/room/voice_room_page.dart`
- `lib/modules/room/voice_room_route.dart`
- `lib/modules/room/background/room_background_select_page.dart`
- `lib/modules/room/background/room_background_upload_page.dart`
- `lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart`
- `lib/ui_kit/widgets/room/room_menu_dialog.dart`
- `lib/ui_kit/widgets/room/anim/room_entrance_screen.dart`
- `lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart`
- `lib/modules/room/edit/room_edit_page.dart`
@ -249,6 +269,11 @@
- `lib/modules/user/me_page2.dart`
- `lib/modules/user/profile/person_detail_page.dart`
- `lib/shared/business_logic/models/res/login_res.dart`
- `lib/app_localizations.dart`
- `assets/l10n/intl_en.json`
- `assets/l10n/intl_ar.json`
- `assets/l10n/intl_bn.json`
- `assets/l10n/intl_tr.json`
- `lib/ui_kit/widgets/id/sc_special_id_badge.dart`
- `lib/ui_kit/widgets/room/room_user_info_card.dart`
- `lib/modules/home/popular/party/sc_home_party_page.dart`
@ -442,6 +467,7 @@
- `burst` 头像继续按最新口头要求单独微调:向下 `10` 个设计单位、向左 `2` 个设计单位,其余金额和倍数位置不动
- `burst` 头像又继续单独向下补移 `8` 个设计单位,当前累计为向下 `18`、向左 `2`
- 飞向麦位动画补了统一的“中心图空闲同步”逻辑:目标坐标重试失败放弃、队列被清空、上一段飞行结束后,都会把中心静态礼物图切到队列头或直接清空,避免残留卡死在屏幕中央
- 房间游戏 Leader 二级页已补齐新 provider 化接口下的 `launchConfig` 注入链路:前端现在会保留后端原始 `launchConfig` 字段,并在 WebView 内注入 `leaderLaunchConfig/getConfig()` 供 H5 读取,便于后端完成后直接联调
## 下一步要做什么
- 将新的 `AAB` 上传到 Play Console并在首发流程中继续使用 Google 管理的 app signing key。