diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ce86ffe..cfec910 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,13 +18,17 @@ - - - - - - - + + + + + + + + + + + diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index b279e2f..9983fd5 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -147,8 +147,9 @@ "systemAnnouncement": "إعلان النظام", "doNotClickUnfamiliarTips": "ماتضغطش على الروابط الغير معروفة، حيث قد تكشف معلوماتك الشخصية. متشاركش أبدا بطاقة الهوية أو معلومات البطاقة البنكية ديالك مع أي واحد.", "atTag": "@تاغ", - "sayHi2": "قل مرحبا", - "msgSendRedEnvelopeTips": "سيتم فرض رسوم خدمة بنسبة 10% على المظاريف الحمراء، ولن يحصل المستلمون إلا على 90% من قيمة المظروف. يجب أن يكون مستوى ثروة المرسل أعلى من المستوى 10.", + "sayHi2": "قل مرحبا", + "roomBottomGreeting": "مرحبًا...", + "msgSendRedEnvelopeTips": "سيتم فرض رسوم خدمة بنسبة 10% على المظاريف الحمراء، ولن يحصل المستلمون إلا على 90% من قيمة المظروف. يجب أن يكون مستوى ثروة المرسل أعلى من المستوى 10.", "reapply": "أعد التقديم", "cancelRequest": "إلغاء الطلب", "supporter": "مشجع", diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index e2790f3..6d0fb8a 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -187,8 +187,9 @@ "vistors": "ভিজিটর", "fans": "ফ্যান", "balanceNotEnough": "সোনা কয়েন ব্যালেন্স অপর্যাপ্ত। আপনি কি রিচার্জ করতে চান?", - "skip": "{1} স্কিপ করুন", - "letGoToWatch": "চলুন দেখতে যাই!", + "skip": "{1} স্কিপ করুন", + "roomBottomGreeting": "হাই...", + "letGoToWatch": "চলুন দেখতে যাই!", "launchedARocket": "রকেট চালু করেছে", "sendUserId": "ব্যবহারকারী ID পাঠান:{1}", "giveUpIdentity": "আইডেন্টিটি ছেড়ে দিন", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index c624327..6d701ee 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -78,8 +78,9 @@ "systemAnnouncement": "System Announcement", "doNotClickUnfamiliarTips": "Do not click unfamiliar links, as they can expose your personal information. Never share your ID or bank card details with anyone.", "atTag": "@Tag", - "sayHi2": "Say Hi", - "canSendMsgTips": "Both parties need to follow each other before they can send private messages.", + "sayHi2": "Say Hi", + "roomBottomGreeting": "Hi...", + "canSendMsgTips": "Both parties need to follow each other before they can send private messages.", "msgSendRedEnvelopeTips": "*A 10% service fee will be charged on red envelopes, and recipients will only receive 90% of the red envelope's value. The sender's wealth level must be higher than Level 10.", "reapply": "Reapply", "cancelRequest": "Cancel Request", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index db3e44e..f256bfd 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -68,8 +68,9 @@ "systemAnnouncement": "Sistem Duyurusu", "doNotClickUnfamiliarTips": "Tanımadığınız bağlantılara tıklayın, çünkü bunlar kişisel bilgilerinizi ifşa edebilir. Kimlik numaranızı veya banka kartı detaylarınızı asla kimseyle paylaşmayın.", "atTag": "@Etiket", - "sayHi2": "Merhaba De", - "canSendMsgTips": "Özel mesaj göndermek için her iki tarafın da birbirini takip etmesi gerekir.", + "sayHi2": "Merhaba De", + "roomBottomGreeting": "Merhaba...", + "canSendMsgTips": "Özel mesaj göndermek için her iki tarafın da birbirini takip etmesi gerekir.", "msgSendRedEnvelopeTips": "*Kırmızı zarflar üzerinde %10 hizmet ücreti kesilecektir ve alıcılar yalnızca kırmızı zarfın değerinin %90'ını alacaktır. Göndericinin servet seviyesi 10. Seviyeden yüksek olmalıdır.", "reapply": "Tekrar Başvur", "cancelRequest": "İsteği İptal Et", diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index 28248ab..a5a0d10 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -14,6 +14,7 @@ import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; import '../../shared/tools/sc_heartbeat_utils.dart'; import '../../shared/data_sources/models/enum/sc_heartbeat_status.dart'; import '../../ui_kit/components/sc_float_ichart.dart'; @@ -181,30 +182,30 @@ class _SCIndexPageState extends State { _bottomItems.add( BottomNavigationBarItem( - icon: Image.asset( - "sc_images/index/sc_icon_home_no.png", - width: 35.w, - height: 35.w, + icon: _buildBottomTabIcon( + active: false, + svgaPath: "sc_images/index/sc_icon_home_anim.svga", + fallbackPath: "sc_images/index/sc_icon_home_no.png", ), - activeIcon: Image.asset( - "sc_images/index/sc_icon_home_en.png", - width: 35.w, - height: 35.w, + activeIcon: _buildBottomTabIcon( + active: true, + svgaPath: "sc_images/index/sc_icon_home_anim.svga", + fallbackPath: "sc_images/index/sc_icon_home_en.png", ), label: SCAppLocalizations.of(context)!.home, ), ); _bottomItems.add( BottomNavigationBarItem( - icon: Image.asset( - "sc_images/index/sc_icon_home_no.png", - width: 35.w, - height: 35.w, + icon: _buildBottomTabIcon( + active: false, + svgaPath: "sc_images/index/sc_icon_explore_anim.svga", + fallbackPath: "sc_images/index/sc_icon_explore_no.png", ), - activeIcon: Image.asset( - "sc_images/index/sc_icon_home_en.png", - width: 35.w, - height: 35.w, + activeIcon: _buildBottomTabIcon( + active: true, + svgaPath: "sc_images/index/sc_icon_explore_anim.svga", + fallbackPath: "sc_images/index/sc_icon_explore_en.png", ), label: SCAppLocalizations.of(context)!.explore, ), @@ -226,16 +227,16 @@ class _SCIndexPageState extends State { fontWeight: FontWeight.w600, ), alignment: AlignmentDirectional.topEnd, - child: Image.asset( - "sc_images/index/sc_icon_message_no.png", - width: 35.w, - height: 35.w, + child: _buildBottomTabIcon( + active: false, + svgaPath: "sc_images/index/sc_icon_message_anim.svga", + fallbackPath: "sc_images/index/sc_icon_message_no.png", ), ) - : Image.asset( - "sc_images/index/sc_icon_message_no.png", - width: 35.w, - height: 35.w, + : _buildBottomTabIcon( + active: false, + svgaPath: "sc_images/index/sc_icon_message_anim.svga", + fallbackPath: "sc_images/index/sc_icon_message_no.png", ); }, ), @@ -253,16 +254,16 @@ class _SCIndexPageState extends State { fontWeight: FontWeight.w600, ), alignment: AlignmentDirectional.topEnd, - child: Image.asset( - "sc_images/index/sc_icon_message_en.png", - width: 35.w, - height: 35.w, + child: _buildBottomTabIcon( + active: true, + svgaPath: "sc_images/index/sc_icon_message_anim.svga", + fallbackPath: "sc_images/index/sc_icon_message_en.png", ), ) - : Image.asset( - "sc_images/index/sc_icon_message_en.png", - width: 35.w, - height: 35.w, + : _buildBottomTabIcon( + active: true, + svgaPath: "sc_images/index/sc_icon_message_anim.svga", + fallbackPath: "sc_images/index/sc_icon_message_en.png", ); }, ), @@ -271,18 +272,33 @@ class _SCIndexPageState extends State { ); _bottomItems.add( BottomNavigationBarItem( - icon: Image.asset( - "sc_images/index/sc_icon_me_no.png", - width: 35.w, - height: 35.w, + icon: _buildBottomTabIcon( + active: false, + svgaPath: "sc_images/index/sc_icon_me_anim.svga", + fallbackPath: "sc_images/index/sc_icon_me_no.png", ), - activeIcon: Image.asset( - "sc_images/index/sc_icon_me_en.png", - width: 35.w, - height: 35.w, + activeIcon: _buildBottomTabIcon( + active: true, + svgaPath: "sc_images/index/sc_icon_me_anim.svga", + fallbackPath: "sc_images/index/sc_icon_me_en.png", ), label: SCAppLocalizations.of(context)!.me, ), ); } + + Widget _buildBottomTabIcon({ + required bool active, + required String svgaPath, + required String fallbackPath, + }) { + return SCSvgaAssetWidget( + assetPath: svgaPath, + width: 35.w, + height: 35.w, + active: active, + loop: false, + fallback: Image.asset(fallbackPath, width: 35.w, height: 35.w), + ); + } } diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index ac61c19..1b15128 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -12,8 +12,8 @@ import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/shared/business_logic/models/res/join_room_res.dart'; import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; - -import '../../../shared/data_sources/models/enum/sc_room_special_mike_type.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; ///麦位 class SCSeatItem extends StatefulWidget { @@ -426,17 +426,44 @@ class _SonicState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { + final isCurrentUserSeat = _isCurrentUserSeat(); return Stack( clipBehavior: Clip.none, children: [ - SonicItem(ctrList[0], widget.room, isGameModel: widget.isGameModel), - SonicItem(ctrList[1], widget.room, isGameModel: widget.isGameModel), - SonicItem(ctrList[2], widget.room, isGameModel: widget.isGameModel), - SonicItem(ctrList[3], widget.room, isGameModel: widget.isGameModel), + SonicItem( + ctrList[0], + widget.room, + isGameModel: widget.isGameModel, + isCurrentUserSeat: isCurrentUserSeat, + ), + SonicItem( + ctrList[1], + widget.room, + isGameModel: widget.isGameModel, + isCurrentUserSeat: isCurrentUserSeat, + ), + SonicItem( + ctrList[2], + widget.room, + isGameModel: widget.isGameModel, + isCurrentUserSeat: isCurrentUserSeat, + ), + SonicItem( + ctrList[3], + widget.room, + isGameModel: widget.isGameModel, + isCurrentUserSeat: isCurrentUserSeat, + ), ], ); } + bool _isCurrentUserSeat() { + final userId = provider.roomWheatMap[widget.index]?.user?.id; + return (userId?.isNotEmpty ?? false) && + userId == AccountStorage().getCurrentUser()?.userProfile?.id; + } + void _checkSoundAni(int volume) async { if (volume <= 20) return; // await Future.delayed(Duration(milliseconds: 10)); @@ -472,67 +499,82 @@ class _SonicState extends State with TickerProviderStateMixin { } class SonicItem extends AnimatedWidget { - Animation animation; - JoinRoomRes? room; - bool isGameModel = false; + final Animation animation; + final JoinRoomRes? room; + final bool isGameModel; + final bool isCurrentUserSeat; - SonicItem(this.animation, this.room, {Key? key, this.isGameModel = false}) - : super(listenable: animation); + SonicItem( + this.animation, + this.room, { + super.key, + this.isGameModel = false, + required this.isCurrentUserSeat, + }) : super(listenable: animation); @override Widget build(BuildContext context) { + final specialMikeType = room?.roomProfile?.roomSetting?.roomSpecialMikeType; + final Widget fallbackWidget = _buildFallbackWidget( + specialMikeType, + animation.value, + ); + return Visibility( - visible: animation!.value > 0, + visible: animation.value > 0, child: ScaleTransition( - scale: Tween(begin: 1.0, end: 1.4).animate(animation!), - child: Container( + scale: Tween(begin: 1.0, end: 1.4).animate(animation), + child: SizedBox( height: width(isGameModel ? 34 : 52), width: width(isGameModel ? 34 : 52), - decoration: - (room?.roomProfile?.roomSetting?.roomSpecialMikeType ?? "") - .isEmpty || - (room?.roomProfile?.roomSetting?.roomSpecialMikeType != - SCRoomSpecialMikeType.TYPE_VIP3.name && - room?.roomProfile?.roomSetting?.roomSpecialMikeType != - SCRoomSpecialMikeType.TYPE_VIP4.name && - room?.roomProfile?.roomSetting?.roomSpecialMikeType != - SCRoomSpecialMikeType.TYPE_VIP5.name && - room?.roomProfile?.roomSetting?.roomSpecialMikeType != - SCRoomSpecialMikeType.TYPE_VIP6.name) - ? BoxDecoration( - shape: BoxShape.circle, - color: SocialChatTheme.primaryColor.withOpacity( - 1 - animation!.value, - ), - ) - : BoxDecoration(), - child: - room?.roomProfile?.roomSetting?.roomSpecialMikeType == - SCRoomSpecialMikeType.TYPE_VIP3.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip3_sonic_anim.webp", - ) - : (room?.roomProfile?.roomSetting?.roomSpecialMikeType == - SCRoomSpecialMikeType.TYPE_VIP4.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip4_sonic_anim.webp", - ) - : (room?.roomProfile?.roomSetting?.roomSpecialMikeType == - SCRoomSpecialMikeType.TYPE_VIP5.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip5_sonic_anim.webp", - ) - : (room - ?.roomProfile - ?.roomSetting - ?.roomSpecialMikeType == - SCRoomSpecialMikeType.TYPE_VIP6.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip6_sonic_anim.webp", - ) - : Container()))), + child: SCSvgaAssetWidget( + assetPath: _resolveSvgaAssetPath(specialMikeType), + active: true, + loop: true, + fit: BoxFit.contain, + fallback: fallbackWidget, + ), ), ), ); } + + String _resolveSvgaAssetPath(String? specialMikeType) { + switch (specialMikeType) { + case "TYPE_VIP3": + return "sc_images/room/sc_icon_room_vip3_sonic_anim.svga"; + case "TYPE_VIP4": + return "sc_images/room/sc_icon_room_vip4_sonic_anim.svga"; + case "TYPE_VIP5": + return "sc_images/room/sc_icon_room_vip5_sonic_anim.svga"; + case "TYPE_VIP6": + return "sc_images/room/sc_icon_room_vip6_sonic_anim.svga"; + default: + return isCurrentUserSeat + ? "sc_images/room/sc_icon_room_self_sonic_anim.svga" + : "sc_images/room/sc_icon_room_other_sonic_anim.svga"; + } + } + + Widget _buildFallbackWidget(String? specialMikeType, double animationValue) { + switch (specialMikeType) { + case "TYPE_VIP3": + return Image.asset("sc_images/room/sc_icon_room_vip3_sonic_anim.webp"); + case "TYPE_VIP4": + return Image.asset("sc_images/room/sc_icon_room_vip4_sonic_anim.webp"); + case "TYPE_VIP5": + return Image.asset("sc_images/room/sc_icon_room_vip5_sonic_anim.webp"); + case "TYPE_VIP6": + return Image.asset("sc_images/room/sc_icon_room_vip6_sonic_anim.webp"); + default: + return DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: SocialChatTheme.primaryColor.withValues( + alpha: 1 - animationValue, + ), + ), + ); + } + } } diff --git a/lib/modules/room_game/bridge/baishun_js_bridge.dart b/lib/modules/room_game/bridge/baishun_js_bridge.dart index 973df5b..15a860b 100644 --- a/lib/modules/room_game/bridge/baishun_js_bridge.dart +++ b/lib/modules/room_game/bridge/baishun_js_bridge.dart @@ -7,6 +7,7 @@ class BaishunBridgeActions { static const String gameLoaded = 'gameLoaded'; static const String gameRecharge = 'gameRecharge'; static const String destroy = 'destroy'; + static const String debugLog = 'debugLog'; } class BaishunBridgeMessage { @@ -18,22 +19,50 @@ class BaishunBridgeMessage { final String action; final Map payload; + static BaishunBridgeMessage fromAction(String action, String rawMessage) { + return BaishunBridgeMessage( + action: action, + payload: _parsePayload(rawMessage), + ); + } + static BaishunBridgeMessage parse(String rawMessage) { try { final dynamic decoded = jsonDecode(rawMessage); if (decoded is Map) { - final rawPayload = decoded['payload']; return BaishunBridgeMessage( - action: (decoded['action'] ?? '').toString(), - payload: - rawPayload is Map - ? rawPayload - : const {}, + action: + (decoded['action'] ?? decoded['cmd'] ?? decoded['event'] ?? '') + .toString() + .trim(), + payload: _parsePayload( + decoded['payload'] ?? decoded['data'] ?? decoded['params'], + ), ); } } catch (_) {} return BaishunBridgeMessage(action: rawMessage.trim()); } + + static Map _parsePayload(dynamic rawPayload) { + if (rawPayload is Map) { + return rawPayload; + } + if (rawPayload is String) { + final trimmed = rawPayload.trim(); + if (trimmed.isEmpty) { + return const {}; + } + try { + final dynamic decoded = jsonDecode(trimmed); + if (decoded is Map) { + return decoded; + } + } catch (_) {} + return {'raw': trimmed}; + } + return const {}; + } } class BaishunJsBridge { @@ -47,22 +76,251 @@ class BaishunJsBridge { } window.__baishunBridgeReady = true; window.NativeBridge = window.NativeBridge || {}; - window.NativeBridge.getConfig = function(callback) { - if (typeof callback === 'function') { - window.__baishunGetConfigCallback = callback; + function toPayload(input) { + if (typeof input === 'function') { + window.__baishunGetConfigCallback = input; + return {}; } - $channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.getConfig}' })); + 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 rememberJsCallback(payload) { + if (payload && typeof payload.jsCallback === 'string' && payload.jsCallback.trim()) { + window.__baishunLastJsCallback = payload.jsCallback.trim(); + } + } + function postAction(action, input) { + const payload = toPayload(input); + rememberJsCallback(payload); + $channelName.postMessage(JSON.stringify({ action: action, payload: payload })); + return payload; + } + function clipText(value, limit) { + const text = (value == null ? '' : String(value)).trim(); + if (!text) { + return ''; + } + return text.length > limit ? text.slice(0, limit) + '...' : text; + } + function stringifyForLog(value) { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return clipText(value, 320); + } + try { + return clipText(JSON.stringify(value), 320); + } catch (_) { + return clipText(String(value), 320); + } + } + function postDebug(tag, payload) { + $channelName.postMessage(JSON.stringify({ + action: '${BaishunBridgeActions.debugLog}', + payload: { + tag: tag, + message: stringifyForLog(payload) + } + })); + } + function ensureChannelAlias(name, handler) { + if (!window[name] || typeof window[name].postMessage !== 'function') { + window[name] = { postMessage: handler }; + } + } + function ensureWebkitHandler(name, handler) { + window.webkit = window.webkit || {}; + window.webkit.messageHandlers = window.webkit.messageHandlers || {}; + if (!window.webkit.messageHandlers[name] || typeof window.webkit.messageHandlers[name].postMessage !== 'function') { + window.webkit.messageHandlers[name] = { postMessage: handler }; + } + } + window.NativeBridge.getConfigSync = function() { + return window.__baishunLastConfig || null; + }; + window.NativeBridge.getConfig = function(params) { + postAction('${BaishunBridgeActions.getConfig}', params); return window.__baishunLastConfig || null; }; window.NativeBridge.destroy = function(payload) { - $channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.destroy}', payload: payload || {} })); + postAction('${BaishunBridgeActions.destroy}', payload); }; window.NativeBridge.gameRecharge = function(payload) { - $channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.gameRecharge}', payload: payload || {} })); + postAction('${BaishunBridgeActions.gameRecharge}', payload); }; window.NativeBridge.gameLoaded = function(payload) { - $channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.gameLoaded}', payload: payload || {} })); + postAction('${BaishunBridgeActions.gameLoaded}', payload); }; + window.__baishunDebugLog = postDebug; + ensureChannelAlias('getConfig', function(message) { + window.NativeBridge.getConfig(message); + }); + ensureChannelAlias('destroy', function(message) { + window.NativeBridge.destroy(message); + }); + ensureChannelAlias('gameRecharge', function(message) { + window.NativeBridge.gameRecharge(message); + }); + ensureChannelAlias('gameLoaded', function(message) { + window.NativeBridge.gameLoaded(message); + }); + ensureWebkitHandler('getConfig', function(message) { + window.NativeBridge.getConfig(message); + }); + ensureWebkitHandler('destroy', function(message) { + window.NativeBridge.destroy(message); + }); + ensureWebkitHandler('gameRecharge', function(message) { + window.NativeBridge.gameRecharge(message); + }); + ensureWebkitHandler('gameLoaded', function(message) { + window.NativeBridge.gameLoaded(message); + }); + if (!window.__baishunNetworkDebugReady) { + window.__baishunNetworkDebugReady = true; + if (typeof window.addEventListener === 'function') { + window.addEventListener('error', function(event) { + const target = event && event.target; + if (target && target !== window) { + postDebug('resource.error', { + tag: target.tagName || '', + source: target.currentSrc || target.src || target.href || '', + outerHTML: target.outerHTML || '' + }); + return; + } + postDebug('window.error', { + message: event && event.message, + source: event && event.filename, + line: event && event.lineno, + column: event && event.colno, + stack: event && event.error && event.error.stack + }); + }, true); + window.addEventListener('unhandledrejection', function(event) { + postDebug('promise.reject', { + reason: event && event.reason + }); + }); + } + if (window.console && !window.__baishunConsoleDebugReady) { + window.__baishunConsoleDebugReady = true; + ['log', 'info', 'warn', 'error'].forEach(function(level) { + const original = typeof window.console[level] === 'function' + ? window.console[level].bind(window.console) + : null; + window.console[level] = function() { + try { + postDebug('console.' + level, Array.prototype.slice.call(arguments)); + } catch (_) {} + if (original) { + return original.apply(window.console, arguments); + } + return undefined; + }; + }); + } + if (typeof window.fetch === 'function') { + const originalFetch = window.fetch.bind(window); + window.fetch = function(input, init) { + const url = typeof input === 'string' ? input : (input && input.url) || ''; + const method = (init && init.method) || 'GET'; + postDebug('fetch.start', { method: method, url: url }); + return originalFetch(input, init) + .then(function(response) { + const cloned = response && typeof response.clone === 'function' ? response.clone() : null; + if (cloned && typeof cloned.text === 'function') { + cloned.text().then(function(text) { + if (!response.ok || /failed|error|exception|get user info/i.test(text)) { + postDebug('fetch.end', { + method: method, + url: url, + status: response.status, + body: text + }); + } else { + postDebug('fetch.end', { + method: method, + url: url, + status: response.status + }); + } + }).catch(function(error) { + postDebug('fetch.readError', { + method: method, + url: url, + error: error && error.message ? error.message : String(error) + }); + }); + } else { + postDebug('fetch.end', { + method: method, + url: url, + status: response && response.status + }); + } + return response; + }) + .catch(function(error) { + postDebug('fetch.error', { + method: method, + url: url, + error: error && error.message ? error.message : String(error) + }); + throw error; + }); + }; + } + if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) { + const xhrOpen = window.XMLHttpRequest.prototype.open; + const xhrSend = window.XMLHttpRequest.prototype.send; + window.XMLHttpRequest.prototype.open = function(method, url) { + this.__baishunMethod = method || 'GET'; + this.__baishunUrl = url || ''; + return xhrOpen.apply(this, arguments); + }; + window.XMLHttpRequest.prototype.send = function(body) { + const method = this.__baishunMethod || 'GET'; + const url = this.__baishunUrl || ''; + postDebug('xhr.start', { method: method, url: url }); + this.addEventListener('loadend', function() { + const responseText = typeof this.responseText === 'string' ? this.responseText : ''; + if (this.status >= 400 || /failed|error|exception|get user info/i.test(responseText)) { + postDebug('xhr.end', { + method: method, + url: url, + status: this.status, + body: responseText + }); + } else { + postDebug('xhr.end', { + method: method, + url: url, + status: this.status + }); + } + }); + this.addEventListener('error', function() { + postDebug('xhr.error', { + method: method, + url: url, + status: this.status + }); + }); + return xhrSend.apply(this, arguments); + }; + } + } window.baishunChannel = window.baishunChannel || {}; window.baishunChannel.walletUpdate = function(payload) { window.__baishunLastWalletPayload = payload || {}; @@ -72,35 +330,80 @@ class BaishunJsBridge { if (typeof window.onWalletUpdate === 'function') { window.onWalletUpdate(payload || {}); } - if (typeof window.dispatchEvent === 'function') { + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { window.dispatchEvent(new CustomEvent('walletUpdate', { detail: payload || {} })); } }; - if (typeof window.onBaishunBridgeReady === 'function') { - window.onBaishunBridgeReady(window.NativeBridge); - } - if (typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('baishunBridgeReady', { detail: { nativeBridge: true } })); + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent('baishunBridgeReady')); } })(); '''; } - static String buildConfigScript(BaishunBridgeConfigModel config) { + static String buildConfigScript( + BaishunBridgeConfigModel config, { + String? jsCallbackPath, + }) { final payload = jsonEncode(config.toJson()); + final encodedCallbackPath = jsonEncode(jsCallbackPath?.trim() ?? ''); return ''' (function() { const config = $payload; + const explicitCallbackPath = $encodedCallbackPath; + function resolvePath(path) { + if (!path || typeof path !== 'string') { + return null; + } + const parts = path.split('.').filter(Boolean); + if (!parts.length) { + return null; + } + let scope = window; + for (let i = 0; i < parts.length - 1; i += 1) { + scope = scope ? scope[parts[i]] : null; + } + if (!scope) { + return null; + } + const methodName = parts[parts.length - 1]; + const method = scope[methodName]; + if (typeof method !== 'function') { + return null; + } + return { scope: scope, method: method }; + } + function invokeCallbackPath(path) { + const target = resolvePath(path); + if (!target) { + return false; + } + target.method.call(target.scope, config); + return true; + } window.__baishunLastConfig = config; + window.baishunBridgeConfig = config; + window.NativeBridge = window.NativeBridge || {}; + window.NativeBridge.config = config; + if (explicitCallbackPath) { + window.__baishunLastJsCallback = explicitCallbackPath; + } if (typeof window.__baishunGetConfigCallback === 'function') { window.__baishunGetConfigCallback(config); + window.__baishunGetConfigCallback = null; + } + if (window.__baishunLastJsCallback) { + invokeCallbackPath(window.__baishunLastJsCallback); } if (typeof window.onBaishunConfig === 'function') { window.onBaishunConfig(config); } - if (typeof window.dispatchEvent === 'function') { + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { window.dispatchEvent(new CustomEvent('baishunConfig', { detail: config })); } + if (typeof document !== 'undefined' && typeof document.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + document.dispatchEvent(new CustomEvent('baishunConfig', { detail: config })); + } })(); '''; } @@ -119,26 +422,4 @@ class BaishunJsBridge { })(); '''; } - - static String injectBootstrapHtml({ - required String html, - required Uri entryUri, - required BaishunBridgeConfigModel config, - }) { - final baseHref = htmlEscape.convert(entryUri.toString()); - final injection = ''' - - - '''; - - final headPattern = RegExp(r']*>', caseSensitive: false); - final headMatch = headPattern.firstMatch(html); - if (headMatch != null) { - return html.replaceRange(headMatch.end, headMatch.end, injection); - } - return '$injection$html'; - } } diff --git a/lib/modules/room_game/views/baishun_debug_panel.dart b/lib/modules/room_game/views/baishun_debug_panel.dart new file mode 100644 index 0000000..f6e0374 --- /dev/null +++ b/lib/modules/room_game/views/baishun_debug_panel.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class BaishunDebugLogEntry { + const BaishunDebugLogEntry({ + required this.timestamp, + required this.tag, + required this.message, + }); + + final DateTime timestamp; + final String tag; + final String message; + + String get formattedTime { + final hh = timestamp.hour.toString().padLeft(2, '0'); + final mm = timestamp.minute.toString().padLeft(2, '0'); + final ss = timestamp.second.toString().padLeft(2, '0'); + return '$hh:$mm:$ss'; + } +} + +class BaishunDebugSnapshot { + const BaishunDebugSnapshot({ + required this.roomId, + required this.gameName, + required this.gameId, + required this.gameSessionId, + required this.entryUrl, + required this.launchMode, + required this.loading, + required this.pageFinished, + required this.receivedBridgeMessage, + required this.injectCount, + required this.configSendCount, + required this.lastBridgeSource, + required this.lastBridgeAction, + required this.lastBridgePayload, + required this.lastJsCallback, + required this.lastConfigSummary, + required this.errorMessage, + required this.logs, + }); + + final String roomId; + final String gameName; + final String gameId; + final String gameSessionId; + final String entryUrl; + final String launchMode; + final bool loading; + final bool pageFinished; + final bool receivedBridgeMessage; + final int injectCount; + final int configSendCount; + final String lastBridgeSource; + final String lastBridgeAction; + final String lastBridgePayload; + final String lastJsCallback; + final String lastConfigSummary; + final String errorMessage; + final List logs; +} + +class BaishunDebugPanel extends StatefulWidget { + const BaishunDebugPanel({ + super.key, + required this.snapshot, + required this.onReload, + required this.onInjectBridge, + required this.onReplayConfig, + required this.onWalletUpdate, + required this.onClearLogs, + }); + + final BaishunDebugSnapshot snapshot; + final VoidCallback onReload; + final VoidCallback onInjectBridge; + final VoidCallback onReplayConfig; + final VoidCallback onWalletUpdate; + final VoidCallback onClearLogs; + + @override + State createState() => _BaishunDebugPanelState(); +} + +class _BaishunDebugPanelState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final maxWidth = (ScreenUtil().screenWidth - 24.w).clamp(220.w, 360.w); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: + _expanded + ? Container( + key: const ValueKey('baishun_debug_panel_expanded'), + width: maxWidth, + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: const Color(0xE616302B), + borderRadius: BorderRadius.circular(16.w), + border: Border.all( + color: Colors.white.withValues(alpha: 0.14), + width: 1.w, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.22), + blurRadius: 18.w, + offset: Offset(0, 8.w), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'BAISHUN DEBUG', + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + const Spacer(), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + _expanded = false; + }); + }, + child: Padding( + padding: EdgeInsets.all(2.w), + child: Icon( + Icons.close, + size: 16.w, + color: Colors.white70, + ), + ), + ), + ], + ), + SizedBox(height: 8.w), + _InfoLine(label: 'Room', value: widget.snapshot.roomId), + _InfoLine( + label: 'Game', + value: + '${widget.snapshot.gameName} (${widget.snapshot.gameId})', + ), + _InfoLine( + label: 'Session', + value: widget.snapshot.gameSessionId, + ), + _InfoLine( + label: 'Launch', + value: widget.snapshot.launchMode, + ), + _InfoLine( + label: 'Loading', + value: widget.snapshot.loading ? 'YES' : 'NO', + ), + _InfoLine( + label: 'PageFinished', + value: widget.snapshot.pageFinished ? 'YES' : 'NO', + ), + _InfoLine( + label: 'BridgeMsg', + value: + widget.snapshot.receivedBridgeMessage ? 'YES' : 'NO', + ), + _InfoLine( + label: 'InjectCount', + value: '${widget.snapshot.injectCount}', + ), + _InfoLine( + label: 'ConfigSend', + value: '${widget.snapshot.configSendCount}', + ), + _InfoLine( + label: 'LastSource', + value: + widget.snapshot.lastBridgeSource.isEmpty + ? '--' + : widget.snapshot.lastBridgeSource, + ), + _InfoLine( + label: 'LastAction', + value: + widget.snapshot.lastBridgeAction.isEmpty + ? '--' + : widget.snapshot.lastBridgeAction, + ), + _InfoLine( + label: 'LastCallback', + value: + widget.snapshot.lastJsCallback.isEmpty + ? '--' + : widget.snapshot.lastJsCallback, + ), + if (widget.snapshot.lastConfigSummary.isNotEmpty) + _MultilineLine( + label: 'Config', + value: widget.snapshot.lastConfigSummary, + ), + _MultilineLine( + label: 'EntryUrl', + value: widget.snapshot.entryUrl, + ), + if (widget.snapshot.lastBridgePayload.isNotEmpty) + _MultilineLine( + label: 'Payload', + value: widget.snapshot.lastBridgePayload, + ), + if (widget.snapshot.errorMessage.isNotEmpty) + _MultilineLine( + label: 'Error', + value: widget.snapshot.errorMessage, + valueColor: const Color(0xFFFFB4B4), + ), + SizedBox(height: 10.w), + Wrap( + spacing: 8.w, + runSpacing: 8.w, + children: [ + _DebugActionChip( + label: 'Reload', + onTap: widget.onReload, + ), + _DebugActionChip( + label: 'Inject', + onTap: widget.onInjectBridge, + ), + _DebugActionChip( + label: 'ReplayConfig', + onTap: widget.onReplayConfig, + ), + _DebugActionChip( + label: 'Wallet', + onTap: widget.onWalletUpdate, + ), + _DebugActionChip( + label: 'ClearLogs', + onTap: widget.onClearLogs, + ), + ], + ), + SizedBox(height: 10.w), + Text( + 'Recent Logs', + style: TextStyle( + color: Colors.white, + fontSize: 11.sp, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.w), + Container( + height: 180.w, + padding: EdgeInsets.all(8.w), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(12.w), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + ), + ), + child: + widget.snapshot.logs.isEmpty + ? Center( + child: Text( + 'No bridge logs yet', + style: TextStyle( + color: Colors.white54, + fontSize: 11.sp, + ), + ), + ) + : ListView.separated( + itemCount: widget.snapshot.logs.length, + separatorBuilder: + (_, __) => SizedBox(height: 6.w), + itemBuilder: (context, index) { + final entry = widget.snapshot.logs[index]; + return Text( + '[${entry.formattedTime}] ${entry.tag} ${entry.message}', + style: TextStyle( + color: Colors.white70, + fontSize: 10.sp, + height: 1.35, + ), + ); + }, + ), + ), + ], + ), + ) + : GestureDetector( + key: const ValueKey('baishun_debug_panel_collapsed'), + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + _expanded = true; + }); + }, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 8.w, + ), + decoration: BoxDecoration( + color: const Color(0xD916302B), + borderRadius: BorderRadius.circular(999.w), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + ), + ), + child: Text( + 'BS DEBUG', + style: TextStyle( + color: Colors.white, + fontSize: 11.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _InfoLine extends StatelessWidget { + const _InfoLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: 4.w), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 82.w, + child: Text( + '$label:', + style: TextStyle( + color: Colors.white60, + fontSize: 10.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + height: 1.3, + ), + ), + ), + ], + ), + ); + } +} + +class _MultilineLine extends StatelessWidget { + const _MultilineLine({ + required this.label, + required this.value, + this.valueColor = Colors.white, + }); + + final String label; + final String value; + final Color valueColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: 4.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: TextStyle( + color: Colors.white60, + fontSize: 10.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 2.w), + SelectableText( + value, + style: TextStyle(color: valueColor, fontSize: 10.sp, height: 1.35), + ), + ], + ), + ); + } +} + +class _DebugActionChip extends StatelessWidget { + const _DebugActionChip({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999.w), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Text( + label, + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/modules/room_game/views/baishun_game_page.dart b/lib/modules/room_game/views/baishun_game_page.dart index 0bb982e..1651e70 100644 --- a/lib/modules/room_game/views/baishun_game_page.dart +++ b/lib/modules/room_game/views/baishun_game_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +9,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/modules/room_game/bridge/baishun_js_bridge.dart'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/views/baishun_debug_panel.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'; @@ -33,10 +33,23 @@ class BaishunGamePage extends StatefulWidget { class _BaishunGamePageState extends State { final RoomGameRepository _repository = RoomGameRepository(); late final WebViewController _controller; + Timer? _bridgeBootstrapTimer; + Timer? _loadingFallbackTimer; + final List _debugLogs = []; bool _isLoading = true; bool _isClosing = false; + bool _didReceiveBridgeMessage = false; + bool _didFinishPageLoad = false; + bool _hasDeliveredLaunchConfig = false; + int _bridgeInjectCount = 0; + int _configSendCount = 0; String? _errorMessage; + String _lastBridgeSource = ''; + String _lastBridgeAction = ''; + String _lastBridgePayload = ''; + String _lastJsCallback = ''; + String _lastConfigSummary = ''; @override void initState() { @@ -49,12 +62,55 @@ class _BaishunGamePageState extends State { BaishunJsBridge.channelName, onMessageReceived: _handleBridgeMessage, ) + ..addJavaScriptChannel( + BaishunBridgeActions.getConfig, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + BaishunBridgeActions.getConfig, + message, + ), + ) + ..addJavaScriptChannel( + BaishunBridgeActions.destroy, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + BaishunBridgeActions.destroy, + message, + ), + ) + ..addJavaScriptChannel( + BaishunBridgeActions.gameRecharge, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + BaishunBridgeActions.gameRecharge, + message, + ), + ) + ..addJavaScriptChannel( + BaishunBridgeActions.gameLoaded, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + BaishunBridgeActions.gameLoaded, + message, + ), + ) ..setNavigationDelegate( NavigationDelegate( + onPageStarted: (String _) { + _prepareForPageLoad(); + _appendDebugLog('page', 'page started'); + }, onPageFinished: (String _) async { - await _primeBridgeHandshake(); + _didFinishPageLoad = true; + _appendDebugLog('page', 'page finished'); + await _injectBridge(reason: 'page_finished'); }, onWebResourceError: (WebResourceError error) { + _stopBridgeBootstrap(); + _appendDebugLog( + 'error', + 'web resource error: ${error.description}', + ); if (!mounted) { return; } @@ -68,9 +124,69 @@ class _BaishunGamePageState extends State { unawaited(_loadGameEntry()); } + @override + void dispose() { + _stopBridgeBootstrap(); + super.dispose(); + } + + void _prepareForPageLoad() { + _didReceiveBridgeMessage = false; + _didFinishPageLoad = false; + _hasDeliveredLaunchConfig = false; + _bridgeInjectCount = 0; + _configSendCount = 0; + _stopBridgeBootstrap(); + _lastBridgeSource = ''; + _lastBridgeAction = ''; + _lastBridgePayload = ''; + _lastJsCallback = ''; + _lastConfigSummary = ''; + _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; + } + _appendDebugLog('loading', 'timeout fallback, hide loading'); + setState(() { + _isLoading = false; + }); + }); + if (!mounted) { + return; + } + setState(() { + _isLoading = true; + _errorMessage = null; + }); + } + + void _stopBridgeBootstrap() { + _bridgeBootstrapTimer?.cancel(); + _bridgeBootstrapTimer = null; + _loadingFallbackTimer?.cancel(); + _loadingFallbackTimer = null; + } + Future _loadGameEntry() async { try { + _prepareForPageLoad(); final entryUrl = widget.launchModel.entry.entryUrl.trim(); + _appendDebugLog( + 'launch', + 'load entry launchMode=${widget.launchModel.entry.launchMode} url=$entryUrl', + ); if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) { final html = await rootBundle.loadString( 'assets/debug/baishun_mock/index.html', @@ -86,14 +202,9 @@ class _BaishunGamePageState extends State { if (uri == null) { throw Exception('Invalid game entry url: $entryUrl'); } - final html = await _fetchRemoteEntryHtml(uri); - final injectedHtml = BaishunJsBridge.injectBootstrapHtml( - html: html, - entryUri: uri, - config: widget.launchModel.bridgeConfig, - ); - await _controller.loadHtmlString(injectedHtml, baseUrl: uri.toString()); + await _controller.loadRequest(uri); } catch (error) { + _appendDebugLog('error', 'load entry failed: $error'); if (!mounted) { return; } @@ -104,57 +215,131 @@ class _BaishunGamePageState extends State { } } - Future _fetchRemoteEntryHtml(Uri uri) async { - final client = HttpClient(); - client.userAgent = 'YumiBaishunBridge/1.0'; - try { - final request = await client.getUrl(uri); - final response = await request.close(); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException( - 'Failed to load remote entry html (${response.statusCode})', - uri: uri, - ); - } - return response.transform(utf8.decoder).join(); - } finally { - client.close(force: true); + Future _injectBridge({String reason = 'manual'}) async { + _bridgeInjectCount += 1; + if (reason != 'bootstrap') { + _appendDebugLog('inject', 'reason=$reason count=$_bridgeInjectCount'); } - } - - Future _injectBridge() async { try { await _controller.runJavaScript(BaishunJsBridge.bootstrapScript()); + } catch (_) {} + } + + Future _sendConfigToGame({ + required String reason, + String? jsCallbackPath, + bool force = false, + }) async { + if (_hasDeliveredLaunchConfig && !force) { + _appendDebugLog( + 'config', + 'skip duplicate config send because BAISHUN code is one-time', + ); + return; + } + _configSendCount += 1; + _hasDeliveredLaunchConfig = true; + final rawConfig = widget.launchModel.bridgeConfig; + final config = _buildEffectiveBridgeConfig(rawConfig); + final code = config.code.trim(); + final maskedCode = + code.length <= 8 + ? code + : '${code.substring(0, 4)}...${code.substring(code.length - 4)}'; + _lastConfigSummary = + 'appChannel=${config.appChannel}, appId=${config.appId}, userId=${config.userId}, roomId=${config.roomId}, gameMode=${config.gameMode}, language=${config.language}(raw=${rawConfig.language}), gsp=${config.gsp}, code=$maskedCode(len=${code.length})'; + if (config.language != rawConfig.language.trim()) { + _appendDebugLog( + 'config', + 'normalize language ${rawConfig.language} -> ${config.language} by BAISHUN table', + ); + } + _appendDebugLog( + 'config', + 'reason=$reason count=$_configSendCount callback=${jsCallbackPath?.trim().isNotEmpty == true ? jsCallbackPath!.trim() : '--'} $_lastConfigSummary', + ); + if (mounted) { + setState(() {}); + } + try { await _controller.runJavaScript( - BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig), + BaishunJsBridge.buildConfigScript( + config, + jsCallbackPath: jsCallbackPath, + ), ); } catch (_) {} } - Future _primeBridgeHandshake() async { - await _injectBridge(); - for (final delay in [ - const Duration(milliseconds: 400), - const Duration(milliseconds: 1200), - ]) { - Future.delayed(delay, () async { - if (!mounted || !_isLoading || _errorMessage != null) { - return; - } - await _injectBridge(); - }); - } - } - Future _handleBridgeMessage(JavaScriptMessage message) async { final bridgeMessage = BaishunBridgeMessage.parse(message.message); + await _dispatchBridgeMessage( + bridgeMessage, + source: BaishunJsBridge.channelName, + rawMessage: message.message, + ); + } + + Future _handleNamedChannelMessage( + String action, + JavaScriptMessage message, + ) async { + final bridgeMessage = BaishunBridgeMessage.fromAction( + action, + message.message, + ); + await _dispatchBridgeMessage( + bridgeMessage, + source: 'channel:$action', + rawMessage: message.message, + ); + } + + Future _dispatchBridgeMessage( + BaishunBridgeMessage bridgeMessage, { + required String source, + required String rawMessage, + }) async { + if (bridgeMessage.action == BaishunBridgeActions.debugLog) { + final tag = bridgeMessage.payload['tag']?.toString().trim(); + final message = bridgeMessage.payload['message']?.toString().trim(); + if ((tag ?? '').isNotEmpty || (message ?? '').isNotEmpty) { + _appendDebugLog( + tag == null || tag.isEmpty ? 'h5' : 'h5:$tag', + message == null || message.isEmpty ? rawMessage.trim() : message, + ); + } + return; + } + final callbackPath = _extractCallbackPath(bridgeMessage.payload); + if (bridgeMessage.action.isNotEmpty) { + _didReceiveBridgeMessage = true; + _stopBridgeBootstrap(); + _lastBridgeSource = source; + _lastBridgeAction = bridgeMessage.action; + _lastBridgePayload = _formatPayload(bridgeMessage.payload); + if (callbackPath.isNotEmpty) { + _lastJsCallback = callbackPath; + } + _appendDebugLog( + 'bridge', + '$source -> ${bridgeMessage.action}${callbackPath.isEmpty ? '' : ' callback=$callbackPath'}${_lastBridgePayload.isEmpty ? '' : ' payload=$_lastBridgePayload'}', + ); + if (mounted) { + setState(() {}); + } + } else if (rawMessage.trim().isNotEmpty) { + _appendDebugLog('bridge', '$source -> raw=${rawMessage.trim()}'); + } switch (bridgeMessage.action) { case BaishunBridgeActions.getConfig: - await _controller.runJavaScript( - BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig), + await _sendConfigToGame( + reason: 'get_config', + jsCallbackPath: callbackPath.isEmpty ? null : callbackPath, ); break; case BaishunBridgeActions.gameLoaded: + _appendDebugLog('loading', 'gameLoaded received'); if (!mounted) { return; } @@ -164,10 +349,12 @@ class _BaishunGamePageState extends State { }); break; case BaishunBridgeActions.gameRecharge: + _appendDebugLog('bridge', 'open recharge page'); await SCNavigatorUtils.push(context, WalletRoute.recharge); await _notifyWalletUpdate(); break; case BaishunBridgeActions.destroy: + _appendDebugLog('bridge', 'destroy requested by H5'); await _closeAndExit(reason: 'h5_destroy'); break; default: @@ -176,6 +363,7 @@ class _BaishunGamePageState extends State { } Future _notifyWalletUpdate() async { + _appendDebugLog('wallet', 'send walletUpdate to H5'); try { await _controller.runJavaScript( BaishunJsBridge.buildWalletUpdateScript(), @@ -187,13 +375,39 @@ class _BaishunGamePageState extends State { if (!mounted) { return; } - setState(() { - _errorMessage = null; - _isLoading = true; - }); + _appendDebugLog('panel', 'manual reload'); await _loadGameEntry(); } + Future _manualInjectBridge() async { + await _injectBridge(reason: 'manual'); + } + + Future _replayLastConfig() async { + final callbackPath = _lastJsCallback.trim(); + _appendDebugLog( + 'panel', + callbackPath.isEmpty + ? 'manual replay config without jsCallback' + : 'manual replay config to $callbackPath', + ); + await _sendConfigToGame( + reason: 'manual_replay', + jsCallbackPath: callbackPath.isEmpty ? null : callbackPath, + force: true, + ); + } + + void _clearDebugLogs() { + if (!mounted) { + return; + } + setState(() { + _debugLogs.clear(); + }); + _appendDebugLog('panel', 'logs cleared'); + } + Future _closeAndExit({String reason = 'page_back'}) async { if (_isClosing) { return; @@ -213,6 +427,138 @@ class _BaishunGamePageState extends State { } } + String _extractCallbackPath(Map payload) { + final candidates = [ + payload['jsCallback']?.toString() ?? '', + payload['callback']?.toString() ?? '', + ]; + for (final candidate in candidates) { + final trimmed = candidate.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + } + return ''; + } + + BaishunBridgeConfigModel _buildEffectiveBridgeConfig( + BaishunBridgeConfigModel rawConfig, + ) { + final normalizedLanguage = _mapBaishunLanguage(rawConfig.language); + if (normalizedLanguage == rawConfig.language.trim()) { + return rawConfig; + } + return BaishunBridgeConfigModel( + appName: rawConfig.appName, + appChannel: rawConfig.appChannel, + appId: rawConfig.appId, + userId: rawConfig.userId, + code: rawConfig.code, + roomId: rawConfig.roomId, + gameMode: rawConfig.gameMode, + language: normalizedLanguage, + gsp: rawConfig.gsp, + gameConfig: rawConfig.gameConfig, + ); + } + + String _mapBaishunLanguage(String rawLanguage) { + final trimmed = rawLanguage.trim(); + if (trimmed.isEmpty) { + return '2'; + } + final lower = trimmed.toLowerCase(); + const supportedCodes = { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + }; + if (supportedCodes.contains(lower)) { + return lower; + } + if (lower.startsWith('zh')) { + return '0'; + } + switch (lower) { + case 'en': + case 'english': + return '2'; + case 'id': + case 'in': + case 'indonesian': + return '3'; + case 'ms': + case 'malay': + return '4'; + case 'th': + case 'thai': + return '5'; + case 'vi': + case 'vietnamese': + return '6'; + case 'ar': + case 'arabic': + return '7'; + case 'fil': + case 'tl': + case 'tagalog': + return '8'; + case 'pt': + case 'portuguese': + return '9'; + case 'tr': + case 'turkish': + return '10'; + case 'ur': + case 'urdu': + return '11'; + default: + return '2'; + } + } + + String _formatPayload(Map payload) { + if (payload.isEmpty) { + return ''; + } + try { + return jsonEncode(payload); + } catch (_) { + return payload.toString(); + } + } + + void _appendDebugLog(String tag, String message) { + const maxLogEntries = 80; + final entry = BaishunDebugLogEntry( + timestamp: DateTime.now(), + tag: tag, + message: message, + ); + if (!mounted) { + _debugLogs.insert(0, entry); + if (_debugLogs.length > maxLogEntries) { + _debugLogs.removeRange(maxLogEntries, _debugLogs.length); + } + return; + } + setState(() { + _debugLogs.insert(0, entry); + if (_debugLogs.length > maxLogEntries) { + _debugLogs.removeRange(maxLogEntries, _debugLogs.length); + } + }); + } + @override Widget build(BuildContext context) { return PopScope( @@ -281,12 +627,42 @@ class _BaishunGamePageState extends State { ), if (_errorMessage != null) _buildErrorState(), if (_isLoading && _errorMessage == null) - const IgnorePointer( - ignoring: true, - child: BaishunLoadingView( - message: 'Waiting for gameLoaded...', - ), + const BaishunLoadingView( + message: 'Waiting for gameLoaded...', ), + Positioned( + left: 12.w, + bottom: 12.w, + child: BaishunDebugPanel( + snapshot: BaishunDebugSnapshot( + roomId: widget.roomId, + gameName: widget.game.name, + gameId: widget.game.gameId, + gameSessionId: widget.launchModel.gameSessionId, + entryUrl: widget.launchModel.entry.entryUrl, + launchMode: widget.launchModel.entry.launchMode, + loading: _isLoading, + pageFinished: _didFinishPageLoad, + receivedBridgeMessage: _didReceiveBridgeMessage, + injectCount: _bridgeInjectCount, + configSendCount: _configSendCount, + lastBridgeSource: _lastBridgeSource, + lastBridgeAction: _lastBridgeAction, + lastBridgePayload: _lastBridgePayload, + lastJsCallback: _lastJsCallback, + lastConfigSummary: _lastConfigSummary, + errorMessage: _errorMessage ?? '', + logs: List.unmodifiable( + _debugLogs, + ), + ), + onReload: _reload, + onInjectBridge: _manualInjectBridge, + onReplayConfig: _replayLastConfig, + onWalletUpdate: _notifyWalletUpdate, + onClearLogs: _clearDebugLogs, + ), + ), ], ), ), diff --git a/lib/modules/user/edit/edit_user_info_page2.dart b/lib/modules/user/edit/edit_user_info_page2.dart index 50c36fc..f5814fb 100644 --- a/lib/modules/user/edit/edit_user_info_page2.dart +++ b/lib/modules/user/edit/edit_user_info_page2.dart @@ -8,7 +8,6 @@ import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; -import 'package:yumi/main.dart'; import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; @@ -16,23 +15,23 @@ import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; -import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import '../../../shared/tools/sc_pick_utils.dart'; -import '../../../shared/business_logic/models/res/country_res.dart'; +import '../../../shared/business_logic/models/res/login_res.dart'; import '../../../shared/business_logic/usecases/sc_accurate_length_limiting_textInput_formatter.dart'; -import '../../country/country_route.dart'; class EditUserInfoPage2 extends StatefulWidget { - const EditUserInfoPage2({Key? key}) : super(key: key); + const EditUserInfoPage2({super.key}); @override - _EditUserInfoPage2State createState() => _EditUserInfoPage2State(); + State createState() => _EditUserInfoPage2State(); } class _EditUserInfoPage2State extends State with SingleTickerProviderStateMixin { + static const Object _noChange = Object(); + String? userCover; String? bornMonth; @@ -44,45 +43,22 @@ class _EditUserInfoPage2State extends State DateTime? birthdayDate; num? sex; num? age; - Country? country; + bool _isSubmitting = false; @override void initState() { super.initState(); - userCover = - AccountStorage().getCurrentUser()?.userProfile?.userAvatar ?? ""; - nickName = - AccountStorage().getCurrentUser()?.userProfile?.userNickname ?? ""; - autograph = AccountStorage().getCurrentUser()?.userProfile?.autograph ?? ""; - hobby = AccountStorage().getCurrentUser()?.userProfile?.hobby ?? ""; - country = Provider.of( - context, - listen: false, - ).findCountryByName( - AccountStorage().getCurrentUser()?.userProfile?.countryName ?? "", - ); - num m = AccountStorage().getCurrentUser()?.userProfile?.bornMonth ?? 0; - if (m < 10) { - bornMonth = "0$m"; - } else { - bornMonth = "$m"; - } - num d = AccountStorage().getCurrentUser()?.userProfile?.bornDay ?? 0; - if (d < 10) { - bornDay = "0$d"; - } else { - bornDay = "$d"; - } - bornYear = "${AccountStorage().getCurrentUser()?.userProfile?.bornYear}"; - age = - DateTime.now().year - - (AccountStorage().getCurrentUser()?.userProfile?.bornYear ?? 0); - sex = AccountStorage().getCurrentUser()?.userProfile?.userSex; - } - - @override - void dispose() { - super.dispose(); + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + userCover = currentProfile?.userAvatar ?? ""; + nickName = currentProfile?.userNickname ?? ""; + autograph = currentProfile?.autograph ?? ""; + hobby = currentProfile?.hobby ?? ""; + birthdayDate = _birthdayFromProfile(currentProfile); + bornMonth = _formatTwoDigits(currentProfile?.bornMonth); + bornDay = _formatTwoDigits(currentProfile?.bornDay); + bornYear = _formatYear(currentProfile?.bornYear); + age = currentProfile?.age ?? _ageFromBirthday(birthdayDate); + sex = currentProfile?.userSex; } String? _preferNonEmpty(String? primary, String? fallback) { @@ -110,6 +86,77 @@ class _EditUserInfoPage2State extends State return _preferNonEmpty(primary, fallback); } + DateTime? _birthdayFromProfile([SocialChatUserProfile? profile]) { + final targetProfile = + profile ?? AccountStorage().getCurrentUser()?.userProfile; + final year = targetProfile?.bornYear?.toInt() ?? 0; + final month = targetProfile?.bornMonth?.toInt() ?? 0; + final day = targetProfile?.bornDay?.toInt() ?? 0; + if (year <= 0 || month <= 0 || day <= 0) { + return null; + } + try { + return DateTime(year, month, day); + } catch (_) { + return null; + } + } + + String _formatTwoDigits(num? value) { + final intValue = value?.toInt() ?? 0; + if (intValue <= 0) { + return ""; + } + return intValue < 10 ? "0$intValue" : "$intValue"; + } + + String _formatYear(num? value) { + final intValue = value?.toInt() ?? 0; + if (intValue <= 0) { + return ""; + } + return "$intValue"; + } + + num? _ageFromBirthday(DateTime? date) { + if (date == null) { + return null; + } + return (DateTime.now().year - date.year).abs(); + } + + String _birthdayDisplayText() { + if ((bornYear ?? "").isEmpty || + (bornMonth ?? "").isEmpty || + (bornDay ?? "").isEmpty) { + return ""; + } + return "$bornYear-$bornMonth-$bornDay"; + } + + bool _isSameDate(DateTime first, DateTime second) { + return first.year == second.year && + first.month == second.month && + first.day == second.day; + } + + bool _hasChanges(List values) { + return values.any((value) => !identical(value, _noChange)); + } + + void _syncLocalProfileState(SocialChatUserProfile profile) { + userCover = _preferUsableAvatar(profile.userAvatar, userCover); + nickName = profile.userNickname ?? nickName; + autograph = profile.autograph ?? autograph; + hobby = profile.hobby ?? hobby; + sex = profile.userSex ?? sex; + birthdayDate = _birthdayFromProfile(profile) ?? birthdayDate; + bornYear = _formatYear(profile.bornYear); + bornMonth = _formatTwoDigits(profile.bornMonth); + bornDay = _formatTwoDigits(profile.bornDay); + age = profile.age ?? _ageFromBirthday(birthdayDate) ?? age; + } + @override Widget build(BuildContext context) { return Stack( @@ -179,7 +226,7 @@ class _EditUserInfoPage2State extends State debugPrint("[Profile Avatar] uploaded url: $url"); userCover = url; setState(() {}); - submitAvatarOnly(context); + submitAvatarOnly(); } }); }, @@ -204,13 +251,11 @@ class _EditUserInfoPage2State extends State _buildItem( "${SCAppLocalizations.of(context)!.birthday}:", - "$bornYear-$bornMonth-$bornDay", + _birthdayDisplayText(), () { _selectDate(); }, ), - _buildControlItem(), - _buildItem( "${SCAppLocalizations.of(context)!.bio}:", autograph ?? "", @@ -236,13 +281,12 @@ class _EditUserInfoPage2State extends State } Future _selectDate() async { + final initialDate = birthdayDate ?? _getBefor18(); + DateTime selectedDate = initialDate; SmartDialog.show( tag: "showSelectDate", alignment: Alignment.bottomCenter, animationType: SmartAnimationType.fade, - onDismiss: () { - submit(context); - }, builder: (_) { return SafeArea( top: false, @@ -292,21 +336,23 @@ class _EditUserInfoPage2State extends State ), ), onTap: () { - num m = birthdayDate!.month; - if (m < 10) { - bornMonth = "0$m"; - } else { - bornMonth = "$m"; - } - num d = birthdayDate!.day; - if (d < 10) { - bornDay = "0$d"; - } else { - bornDay = "$d"; - } - bornYear = "${birthdayDate?.year}"; + final hasChanged = + !_isSameDate(initialDate, selectedDate); + birthdayDate = selectedDate; + bornMonth = _formatTwoDigits(selectedDate.month); + bornDay = _formatTwoDigits(selectedDate.day); + bornYear = "${selectedDate.year}"; + age = _ageFromBirthday(selectedDate); setState(() {}); SmartDialog.dismiss(tag: "showSelectDate"); + if (hasChanged) { + submit( + ageValue: age, + bornYearValue: selectedDate.year, + bornMonthValue: selectedDate.month, + bornDayValue: selectedDate.day, + ); + } }, ), SizedBox(width: 10.w), @@ -315,11 +361,10 @@ class _EditUserInfoPage2State extends State Expanded( child: CupertinoDatePicker( mode: CupertinoDatePickerMode.date, - initialDateTime: _getBefor18(), + initialDateTime: initialDate, maximumDate: _getBefor18(), onDateTimeChanged: (date) { - birthdayDate = date; - age = DateTime.now().year - date.year; + selectedDate = date; }, ), ), @@ -346,32 +391,127 @@ class _EditUserInfoPage2State extends State return eighteenYearsAgo; } - int sTime = 0; - - void submitAvatarOnly(BuildContext context) async { + void submitAvatarOnly() { if ((userCover ?? "").trim().isEmpty) { return; } - int bTime = DateTime.now().millisecondsSinceEpoch; - if (bTime - sTime <= 5000) { + submit(userAvatarValue: userCover); + } + + void submit({ + Object? userAvatarValue = _noChange, + Object? userNicknameValue = _noChange, + Object? userSexValue = _noChange, + Object? ageValue = _noChange, + Object? bornYearValue = _noChange, + Object? bornMonthValue = _noChange, + Object? bornDayValue = _noChange, + Object? hobbyValue = _noChange, + Object? autographValue = _noChange, + }) async { + if (!_hasChanges([ + userAvatarValue, + userNicknameValue, + userSexValue, + ageValue, + bornYearValue, + bornMonthValue, + bornDayValue, + hobbyValue, + autographValue, + ])) { return; } - sTime = bTime; + if (!identical(userNicknameValue, _noChange) && + ((userNicknameValue as String?) ?? "").isEmpty) { + SCTts.show(SCAppLocalizations.of(context)!.pleaseEnterNickname); + return; + } + if (_isSubmitting) { + return; + } + _isSubmitting = true; SCLoadingManager.show(); - debugPrint("[Profile Avatar] submit avatar only: {userAvatar: $userCover}"); + debugPrint( + "[Profile Edit] incremental payload: {userAvatar: ${identical(userAvatarValue, _noChange) ? "" : userAvatarValue}, userNickname: ${identical(userNicknameValue, _noChange) ? "" : userNicknameValue}, userSex: ${identical(userSexValue, _noChange) ? "" : userSexValue}, age: ${identical(ageValue, _noChange) ? "" : ageValue}, bornYear: ${identical(bornYearValue, _noChange) ? "" : bornYearValue}, bornMonth: ${identical(bornMonthValue, _noChange) ? "" : bornMonthValue}, bornDay: ${identical(bornDayValue, _noChange) ? "" : bornDayValue}, hobby: ${identical(hobbyValue, _noChange) ? "" : hobbyValue}, autograph: ${identical(autographValue, _noChange) ? "" : autographValue}}", + ); try { final updatedProfile = await SCAccountRepository().updateUserInfo( - userAvatar: userCover, + userAvatar: + identical(userAvatarValue, _noChange) + ? null + : userAvatarValue as String?, + userSex: + identical(userSexValue, _noChange) ? null : userSexValue as num?, + userNickname: + identical(userNicknameValue, _noChange) + ? null + : userNicknameValue as String?, + age: identical(ageValue, _noChange) ? null : ageValue as num?, + bornDay: + identical(bornDayValue, _noChange) ? null : bornDayValue as num?, + bornMonth: + identical(bornMonthValue, _noChange) + ? null + : bornMonthValue as num?, + bornYear: + identical(bornYearValue, _noChange) ? null : bornYearValue as num?, + hobby: identical(hobbyValue, _noChange) ? null : hobbyValue as String?, + autograph: + identical(autographValue, _noChange) + ? null + : autographValue as String?, ); final mergedProfile = updatedProfile.copyWith( - userAvatar: _preferUsableAvatar(updatedProfile.userAvatar, userCover), + userAvatar: _preferUsableAvatar( + updatedProfile.userAvatar, + identical(userAvatarValue, _noChange) + ? userCover + : userAvatarValue as String?, + ), + userNickname: _preferNonEmpty( + updatedProfile.userNickname, + identical(userNicknameValue, _noChange) + ? nickName + : userNicknameValue as String?, + ), + autograph: _preferNonEmpty( + updatedProfile.autograph, + identical(autographValue, _noChange) + ? autograph + : autographValue as String?, + ), + hobby: _preferNonEmpty( + updatedProfile.hobby, + identical(hobbyValue, _noChange) ? hobby : hobbyValue as String?, + ), + userSex: + updatedProfile.userSex ?? + (identical(userSexValue, _noChange) ? sex : userSexValue as num?), + age: + updatedProfile.age ?? + (identical(ageValue, _noChange) ? age : ageValue as num?), + bornDay: + updatedProfile.bornDay ?? + (identical(bornDayValue, _noChange) + ? birthdayDate?.day + : bornDayValue as num?), + bornMonth: + updatedProfile.bornMonth ?? + (identical(bornMonthValue, _noChange) + ? birthdayDate?.month + : bornMonthValue as num?), + bornYear: + updatedProfile.bornYear ?? + (identical(bornYearValue, _noChange) + ? birthdayDate?.year + : bornYearValue as num?), ); debugPrint( - "[Profile Avatar] merged avatar-only profile avatar: ${mergedProfile.userAvatar ?? ""}", + "[Profile Edit] merged profile avatar: ${mergedProfile.userAvatar ?? ""}", ); - userCover = mergedProfile.userAvatar; + _syncLocalProfileState(mergedProfile); if (!mounted) { - SCLoadingManager.hide(); return; } Provider.of( @@ -380,87 +520,13 @@ class _EditUserInfoPage2State extends State ).syncCurrentUserProfile(mergedProfile); Provider.of(context, listen: false).needUpDataUserInfo = true; + setState(() {}); SCTts.show(SCAppLocalizations.of(context)!.operationSuccessful); } catch (e) { debugPrint(e.toString()); + } finally { + _isSubmitting = false; SCLoadingManager.hide(); - return; - } - SCLoadingManager.hide(); - if (mounted) { - setState(() {}); - } - } - - void submit(BuildContext context) async { - if (nickName.isEmpty) { - SCTts.show(SCAppLocalizations.of(context)!.pleaseEnterNickname); - return; - } - int bTime = DateTime.now().millisecondsSinceEpoch; - if (bTime - sTime > 5000) { - sTime = bTime; - SCLoadingManager.show(); - debugPrint( - "[Profile Avatar] submit payload: {userAvatar: $userCover, userNickname: $nickName, userSex: $sex, age: $age, bornYear: ${birthdayDate?.year}, bornMonth: ${birthdayDate?.month}, bornDay: ${birthdayDate?.day}, countryId: ${country?.id}, hobby: $hobby, autograph: $autograph}", - ); - try { - final updatedProfile = await SCAccountRepository().updateUserInfo( - userAvatar: userCover, - userSex: sex, - userNickname: nickName, - age: age, - bornDay: birthdayDate?.day, - bornMonth: birthdayDate?.month, - bornYear: birthdayDate?.year, - hobby: hobby, - autograph: autograph, - countryId: country?.id, - ); - final mergedProfile = updatedProfile.copyWith( - userAvatar: _preferUsableAvatar(updatedProfile.userAvatar, userCover), - userNickname: _preferNonEmpty(updatedProfile.userNickname, nickName), - autograph: _preferNonEmpty(updatedProfile.autograph, autograph), - hobby: _preferNonEmpty(updatedProfile.hobby, hobby), - countryName: _preferNonEmpty( - updatedProfile.countryName, - country?.countryName, - ), - countryCode: _preferNonEmpty( - updatedProfile.countryCode, - country?.alphaTwo, - ), - countryId: _preferNonEmpty(updatedProfile.countryId, country?.id), - userSex: updatedProfile.userSex ?? sex, - age: updatedProfile.age ?? age, - bornDay: updatedProfile.bornDay ?? birthdayDate?.day, - bornMonth: updatedProfile.bornMonth ?? birthdayDate?.month, - bornYear: updatedProfile.bornYear ?? birthdayDate?.year, - ); - debugPrint( - "[Profile Avatar] merged profile avatar: ${mergedProfile.userAvatar ?? ""}", - ); - userCover = mergedProfile.userAvatar; - if (!mounted) { - SCLoadingManager.hide(); - return; - } - Provider.of( - context, - listen: false, - ).syncCurrentUserProfile(mergedProfile); - Provider.of(context, listen: false).needUpDataUserInfo = - true; - SCTts.show(SCAppLocalizations.of(context)!.operationSuccessful); - } catch (e) { - debugPrint(e.toString()); - SCLoadingManager.hide(); - return; - } - SCLoadingManager.hide(); - if (mounted) { - setState(() {}); - } } } @@ -488,10 +554,13 @@ class _EditUserInfoPage2State extends State SizedBox(height: 8.w), GestureDetector( onTap: () { + SCNavigatorUtils.goBack(context); + if (sex == 1) { + return; + } sex = 1; setState(() {}); - submit(context); - SCNavigatorUtils.goBack(context); + submit(userSexValue: sex); }, behavior: HitTestBehavior.opaque, child: Row( @@ -513,10 +582,13 @@ class _EditUserInfoPage2State extends State SizedBox(height: 8.w), GestureDetector( onTap: () { + SCNavigatorUtils.goBack(context); + if (sex == 0) { + return; + } sex = 0; setState(() {}); - submit(context); - SCNavigatorUtils.goBack(context); + submit(userSexValue: sex); }, behavior: HitTestBehavior.opaque, child: Row( @@ -581,79 +653,10 @@ class _EditUserInfoPage2State extends State ); } - _buildControlItem() { - return SCDebounceWidget( - child: Column( - children: [ - Row( - children: [ - SizedBox(width: 15.w), - text( - SCAppLocalizations.of(context)!.country, - textColor: Colors.white, - fontSize: 15.sp, - fontWeight: FontWeight.w600, - ), - Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - netImage( - url: country?.nationalFlag ?? "", - width: 26.w, - height: 16.w, - borderRadius: BorderRadius.all(Radius.circular(2.w)), - ), - SizedBox(width: 5.w), - text( - country?.countryName ?? "", - textColor: Colors.white, - fontSize: 15.sp, - ), - ], - ), - Icon( - Icons.keyboard_arrow_right, - size: 20.w, - color: Colors.white70, - ), - SizedBox(width: 8.w), - ], - ), - SizedBox(height: 8.w), - Container( - margin: EdgeInsets.only(left: 15.w, right: 15.w), - color: Colors.white12, - height: 0.5.w, - width: ScreenUtil().screenWidth, - ), - ], - ), - onTap: () { - SCNavigatorUtils.push( - context, - CountryRoute.country, - replace: false, - ).then((res) { - var c = - Provider.of( - navigatorKey.currentState!.context, - listen: false, - ).selectCountryInfo; - if (c != null) { - country = c; - submit(navigatorKey.currentState!.context); - setState(() {}); - } - }); - }, - ); - } - void _showInputBioHobby(String content, int type) { SmartDialog.dismiss(tag: "showInputBioHobby"); - TextEditingController _inputController = TextEditingController(); - _inputController.text = content; + TextEditingController inputController = TextEditingController(); + inputController.text = content; SmartDialog.show( tag: "showInputBioHobby", alignment: Alignment.center, @@ -702,7 +705,7 @@ class _EditUserInfoPage2State extends State border: Border.all(color: Color(0xffE6E6E6), width: 0.5.w), ), child: TextField( - controller: _inputController, + controller: inputController, maxLines: 5, inputFormatters: [ SCAccurateLengthLimitingTextInputFormatter(50), @@ -756,16 +759,27 @@ class _EditUserInfoPage2State extends State onTap: () { SmartDialog.dismiss(tag: "showInputBioHobby"); if (type == 1) { - autograph = _inputController.text; + if (inputController.text == autograph) { + return; + } + autograph = inputController.text; setState(() {}); + submit(autographValue: autograph); } else if (type == 2) { - hobby = _inputController.text; + if (inputController.text == hobby) { + return; + } + hobby = inputController.text; setState(() {}); + submit(hobbyValue: hobby); } else if (type == 3) { - nickName = _inputController.text; + if (inputController.text == nickName) { + return; + } + nickName = inputController.text; setState(() {}); + submit(userNicknameValue: nickName); } - submit(navigatorKey.currentState!.context); }, ), ], diff --git a/lib/modules/webview/webview_page.dart b/lib/modules/webview/webview_page.dart index 4d4c9df..dc09675 100644 --- a/lib/modules/webview/webview_page.dart +++ b/lib/modules/webview/webview_page.dart @@ -13,6 +13,7 @@ import 'package:yumi/app/constants/sc_screen.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_deviceId_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; import 'package:yumi/main.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/modules/index/main_route.dart'; @@ -119,6 +120,22 @@ class _WebViewPageState extends State { ) ..setNavigationDelegate( NavigationDelegate( + onNavigationRequest: (NavigationRequest request) async { + if (!SCUrlLauncherUtils.shouldOpenExternally(request.url)) { + return NavigationDecision.navigate; + } + + final launched = await SCUrlLauncherUtils.launchExternal( + request.url, + ); + if (launched) { + return NavigationDecision.prevent; + } + + return SCUrlLauncherUtils.isHttpUrl(request.url) + ? NavigationDecision.navigate + : NavigationDecision.prevent; + }, onProgress: (int progress) { setState(() { _progress = progress / 100.0; @@ -229,13 +246,17 @@ class _WebViewPageState extends State { widget.showTitle == "true" ? AppBar( elevation: 0, - backgroundColor: SCGlobalConfig.businessLogicStrategy.getWebViewPageAppBarBackgroundColor(), + backgroundColor: + SCGlobalConfig.businessLogicStrategy + .getWebViewPageAppBarBackgroundColor(), centerTitle: true, title: Text( _title.isNotEmpty ? _title : widget.title, style: TextStyle( fontSize: sp(18), - color: SCGlobalConfig.businessLogicStrategy.getWebViewPageTitleTextColor(), + color: + SCGlobalConfig.businessLogicStrategy + .getWebViewPageTitleTextColor(), ), ), leading: GestureDetector( @@ -246,7 +267,13 @@ class _WebViewPageState extends State { child: Container( padding: EdgeInsets.only(left: width(15), right: width(15)), child: Center( - child: Icon(Icons.arrow_back_ios, size: 20.w, color: SCGlobalConfig.businessLogicStrategy.getWebViewPageBackArrowColor()), + child: Icon( + Icons.arrow_back_ios, + size: 20.w, + color: + SCGlobalConfig.businessLogicStrategy + .getWebViewPageBackArrowColor(), + ), ), ), ), @@ -276,9 +303,14 @@ class _WebViewPageState extends State { return progress < 1.0 ? LinearProgressIndicator( value: progress, - backgroundColor: SCGlobalConfig.businessLogicStrategy.getWebViewPageProgressBarBackgroundColor(), + backgroundColor: + SCGlobalConfig.businessLogicStrategy + .getWebViewPageProgressBarBackgroundColor(), minHeight: 1.0, - valueColor: AlwaysStoppedAnimation(SCGlobalConfig.businessLogicStrategy.getWebViewPageProgressBarActiveColor()), + valueColor: AlwaysStoppedAnimation( + SCGlobalConfig.businessLogicStrategy + .getWebViewPageProgressBarActiveColor(), + ), ) : const SizedBox.shrink(); } @@ -287,5 +319,4 @@ class _WebViewPageState extends State { print('_onWebResourceError:${error.description}'); // 可以在这里添加错误处理逻辑,比如显示错误页面 } - } diff --git a/lib/shared/tools/sc_banner_utils.dart b/lib/shared/tools/sc_banner_utils.dart index 8752ca1..b0e5245 100644 --- a/lib/shared/tools/sc_banner_utils.dart +++ b/lib/shared/tools/sc_banner_utils.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_string_utils.dart'; +import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; import 'package:yumi/modules/index/main_route.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; @@ -8,7 +9,10 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import '../../shared/data_sources/models/enum/sc_banner_open_type.dart'; class SCBannerUtils { - static void openBanner(SCIndexBannerRes item, BuildContext context) { + static Future openBanner( + SCIndexBannerRes item, + BuildContext context, + ) async { if (item.content == SCBannerOpenType.ENTER_ROOM.name) { var params = item.params; if (params != null && params.isNotEmpty) { @@ -16,10 +20,24 @@ class SCBannerUtils { } } else { var params = item.params; - if (SCStringUtils.checkIfUrl(params ?? "")) { + if (params == null || params.isEmpty) { + return; + } + + if (SCUrlLauncherUtils.shouldOpenExternally(params)) { + final launched = await SCUrlLauncherUtils.launchExternal(params); + if (launched) { + return; + } + if (!context.mounted) { + return; + } + } + + if (SCStringUtils.checkIfUrl(params)) { SCNavigatorUtils.push( context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(params ?? "")}&showTitle=false", + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(params)}&showTitle=false", replace: false, ); } diff --git a/lib/shared/tools/sc_url_launcher_utils.dart b/lib/shared/tools/sc_url_launcher_utils.dart new file mode 100644 index 0000000..3c2cd15 --- /dev/null +++ b/lib/shared/tools/sc_url_launcher_utils.dart @@ -0,0 +1,119 @@ +import 'package:url_launcher/url_launcher.dart'; + +class SCUrlLauncherUtils { + static const Set _httpSchemes = {'http', 'https'}; + + static bool isHttpUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return false; + } + return _httpSchemes.contains(uri.scheme.toLowerCase()); + } + + static bool isCustomSchemeUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme.isEmpty) { + return false; + } + return !_httpSchemes.contains(uri.scheme.toLowerCase()); + } + + static bool isWhatsAppUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return false; + } + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + return scheme == 'whatsapp' || + host == 'wa.me' || + host == 'api.whatsapp.com' || + host == 'whatsapp.com' || + host.endsWith('.whatsapp.com'); + } + + static bool shouldOpenExternally(String url) { + return isCustomSchemeUrl(url) || isWhatsAppUrl(url); + } + + static Future launchExternal(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme.isEmpty) { + return false; + } + + final launchTargets = []; + final normalizedUri = _normalizeWhatsAppUri(uri); + if (normalizedUri != null && normalizedUri.toString() != uri.toString()) { + launchTargets.add(normalizedUri); + } + launchTargets.add(uri); + + for (final target in launchTargets) { + try { + final launched = await launchUrl( + target, + mode: LaunchMode.externalApplication, + ); + if (launched) { + return true; + } + } catch (_) {} + } + + return false; + } + + static Uri? _normalizeWhatsAppUri(Uri uri) { + if (uri.scheme.toLowerCase() == 'whatsapp') { + return uri; + } + + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + if (!_httpSchemes.contains(scheme)) { + return null; + } + + if (host == 'wa.me') { + final pathSegments = + uri.pathSegments.where((segment) => segment.isNotEmpty).toList(); + if (pathSegments.length != 1 || + !_looksLikeWhatsAppPhone(pathSegments.first)) { + return null; + } + return Uri( + scheme: 'whatsapp', + host: 'send', + queryParameters: { + 'phone': pathSegments.first, + if ((uri.queryParameters['text'] ?? '').isNotEmpty) + 'text': uri.queryParameters['text']!, + }, + ); + } + + if (host == 'api.whatsapp.com' && uri.path.toLowerCase() == '/send') { + final phone = uri.queryParameters['phone'] ?? ''; + final text = uri.queryParameters['text'] ?? ''; + if (phone.isEmpty && text.isEmpty) { + return null; + } + return Uri( + scheme: 'whatsapp', + host: 'send', + queryParameters: { + if (phone.isNotEmpty) 'phone': phone, + if (text.isNotEmpty) 'text': text, + }, + ); + } + + return null; + } + + static bool _looksLikeWhatsAppPhone(String value) { + return RegExp(r'^\d+$').hasMatch(value); + } +} diff --git a/lib/ui_kit/widgets/msg/message_conversation_list_page.dart b/lib/ui_kit/widgets/msg/message_conversation_list_page.dart index 612fb44..c79951b 100644 --- a/lib/ui_kit/widgets/msg/message_conversation_list_page.dart +++ b/lib/ui_kit/widgets/msg/message_conversation_list_page.dart @@ -13,11 +13,11 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_text_elem.dart'; import 'package:yumi/app_localizations.dart'; -import 'package:yumi/ui_kit/components/dialog/dialog_base.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/app/constants/sc_global_config.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/ui_kit/components/dialog/dialog_base.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_date_utils.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; @@ -229,7 +229,9 @@ class _MessageConversationListPageState }) { if (chatList.isEmpty) { if (isTop) return Container(); - return mainEmpty(); + return widget.isRoom + ? mainEmpty() + : mainEmpty(image: const SizedBox.shrink()); } return Container( margin: margin ?? EdgeInsets.only(bottom: 56.w), @@ -348,25 +350,25 @@ class _ConversationItemState extends State { }*/ // print('time :${Platform.isAndroid ? (conversation.lastMsg?.timestamp ?? 0) * 1000 : conversation.lastMsg?.timestamp}'); return GestureDetector( - onTap: () async { - if (conversation != null) { - conversation.unreadCount = 0; - var bool = await Provider.of( - context, + onTap: () async { + if (conversation != null) { + conversation.unreadCount = 0; + var bool = await Provider.of( + context, listen: false, - ).startConversation(conversation); - if (!bool) return; - var json = jsonEncode(widget.conversation.toJson()); - final route = - SCGlobalConfig.isSystemConversationId(conversation.conversationID) - ? SCChatRouter.systemChat - : SCChatRouter.chat; - SCNavigatorUtils.push( - context, - "$route?conversation=${Uri.encodeComponent(json)}", - ); - } - }, + ).startConversation(conversation); + if (!bool) return; + var json = jsonEncode(widget.conversation.toJson()); + final route = + SCGlobalConfig.isSystemConversationId(conversation.conversationID) + ? SCChatRouter.systemChat + : SCChatRouter.chat; + SCNavigatorUtils.push( + context, + "$route?conversation=${Uri.encodeComponent(json)}", + ); + } + }, onLongPress: () { showDeleteConfirm(); }, @@ -526,13 +528,15 @@ class _ConversationItemState extends State { ); } - void loadUserInfo() { - if (!SCGlobalConfig.isSystemConversationId(conversation.conversationID) && - conversation.conversationID != "customer" && - conversation.conversationID != "article") { - SCAccountRepository().loadUserInfo("${conversation.userID}").then((value) { - user = value; - setState(() {}); + void loadUserInfo() { + if (!SCGlobalConfig.isSystemConversationId(conversation.conversationID) && + conversation.conversationID != "customer" && + conversation.conversationID != "article") { + SCAccountRepository().loadUserInfo("${conversation.userID}").then(( + value, + ) { + user = value; + setState(() {}); }); } } diff --git a/lib/ui_kit/widgets/room/bottom/room_bottom_chat_entry.dart b/lib/ui_kit/widgets/room/bottom/room_bottom_chat_entry.dart new file mode 100644 index 0000000..7aa5493 --- /dev/null +++ b/lib/ui_kit/widgets/room/bottom/room_bottom_chat_entry.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; + +class RoomBottomChatEntry extends StatelessWidget { + const RoomBottomChatEntry({ + super.key, + required this.width, + required this.onTap, + }); + + final double width; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context); + + return SizedBox( + width: width, + height: 48.w, + child: SCDebounceWidget( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0x33FFFFFF), Color(0x18FFFFFF)], + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + ), + borderRadius: BorderRadius.circular(24.w), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10.w, + offset: Offset(0, 3.w), + ), + ], + ), + child: Padding( + padding: EdgeInsetsDirectional.only(start: 14.w, end: 14.w), + child: Row( + children: [ + Image.asset( + 'sc_images/room/icon_room_input_t.png', + width: 22.w, + height: 22.w, + fit: BoxFit.contain, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + localizations?.translate('roomBottomGreeting') ?? 'Hi...', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 15.sp, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/bottom/room_bottom_circle_action.dart b/lib/ui_kit/widgets/room/bottom/room_bottom_circle_action.dart new file mode 100644 index 0000000..ae2eec0 --- /dev/null +++ b/lib/ui_kit/widgets/room/bottom/room_bottom_circle_action.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class RoomBottomCircleAction extends StatelessWidget { + const RoomBottomCircleAction({super.key, required this.child, this.size}); + + final Widget child; + final double? size; + + @override + Widget build(BuildContext context) { + final dimension = size ?? 46.w; + + return Container( + width: dimension, + height: dimension, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0x33FFFFFF), Color(0x18FFFFFF)], + begin: AlignmentDirectional.topStart, + end: AlignmentDirectional.bottomEnd, + ), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10.w, + offset: Offset(0, 3.w), + ), + ], + ), + child: child, + ); + } +} diff --git a/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart b/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart new file mode 100644 index 0000000..54973ea --- /dev/null +++ b/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart @@ -0,0 +1,80 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; + +class RoomBottomGiftButton extends StatefulWidget { + const RoomBottomGiftButton({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + State createState() => _RoomBottomGiftButtonState(); +} + +class _RoomBottomGiftButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2600), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? + WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations; + + return SizedBox( + width: 52.w, + height: 52.w, + child: SCDebounceWidget( + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _controller, + child: _buildGiftCore(), + builder: (context, child) { + final progress = disableAnimations ? 0.0 : _controller.value; + final rotation = math.sin(progress * math.pi * 2) * 0.07; + final offsetY = math.sin(progress * math.pi * 4) * 1.4; + + return Transform.translate( + offset: Offset(0, offsetY), + child: Transform.rotate(angle: rotation, child: child), + ); + }, + ), + ), + ); + } + + Widget _buildGiftCore() { + return SizedBox( + width: 48.w, + height: 48.w, + child: Image.asset( + 'sc_images/room/sc_icon_botton_gift.png', + width: 48.w, + height: 48.w, + fit: BoxFit.contain, + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index 5069a39..675fe93 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -2,14 +2,17 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/modules/gift/gift_page.dart'; +import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_chat_entry.dart'; +import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_circle_action.dart'; +import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart'; import 'package:yumi/ui_kit/widgets/room/room_menu_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; -import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/modules/gift/gift_page.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; import '../../../modules/index/main_route.dart'; @@ -20,7 +23,7 @@ class RoomBottomWidget extends StatefulWidget { const RoomBottomWidget({super.key}); @override - _RoomBottomWidgetState createState() => _RoomBottomWidgetState(); + State createState() => _RoomBottomWidgetState(); } class _RoomBottomWidgetState extends State { @@ -28,187 +31,199 @@ class _RoomBottomWidgetState extends State { @override Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - Container( - height: 55.w, - margin: EdgeInsetsDirectional.only(start: 25.w), - child: Row( - children: [ - SCDebounceWidget( - onTap: () { - if (SCRoomUtils.touristCanMsg(context)) { - Navigator.push(context, PopRoute(child: RoomMsgInput())); - } - }, - child: Image.asset( - "sc_images/room/icon_room_input_t.png", - width: 30.w, - height: 30.w, - ), - ), - Spacer(), - Consumer( - builder: (context, ref, child) { - return _mic1(ref); - }, - ), - SizedBox(width: 10.w), - SCDebounceWidget( - child: Image.asset( - "sc_images/room/sc_icon_botton_menu.png", - width: 30.w, - height: 30.w, - fit: BoxFit.contain, - ), - onTap: () { - SmartDialog.show( - tag: "showRoomMenuDialog", - alignment: Alignment.bottomCenter, - debounce: true, - animationType: SmartAnimationType.fade, - maskColor: Colors.transparent, - clickMaskDismiss: true, - builder: (_) { - return RoomMenuDialog(roomMenuStime1, (eTime) { - roomMenuStime1 = eTime; - }); - }, - ); - }, - ), - SizedBox(width: 10.w), - SCDebounceWidget( - child: Selector( - selector: (c, p) => p.allUnReadCount, - shouldRebuild: (prev, next) => prev != next, - builder: (_, allUnReadCount, __) { - return allUnReadCount > 0 - ? Badge( - backgroundColor: Colors.red, - label: text( - "${allUnReadCount > 99 ? "99+" : allUnReadCount}", - fontSize: 9.sp, - textColor: Colors.white, - fontWeight: FontWeight.w600, - ), - alignment: AlignmentDirectional.topEnd, - child: Image.asset( - "sc_images/room/sc_icon_botton_message.png", - width: 30.w, - height: 30.w, - fit: BoxFit.contain, - ), + return SizedBox( + height: 72.w, + child: Consumer( + builder: (context, rtcProvider, child) { + final showMic = _shouldShowMic(rtcProvider); + + return LayoutBuilder( + builder: (context, constraints) { + final inputWidth = constraints.maxWidth / 3; + final giftAction = RoomBottomGiftButton(onTap: _showGiftPanel); + final messageAction = _buildMessageAction(); + final menuAction = _buildMenuAction(); + + return Padding( + padding: EdgeInsetsDirectional.only(start: 16.w, end: 16.w), + child: + showMic + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildChatEntry(inputWidth), + giftAction, + messageAction, + _buildMicAction(rtcProvider), + menuAction, + ], ) - : Image.asset( - "sc_images/room/sc_icon_botton_message.png", - width: 30.w, - height: 30.w, - fit: BoxFit.contain, - ); - }, - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.message}?isFromRoom=true", - ); - }, - ), - SizedBox(width: 15.w), - ], - ), - ), - PositionedDirectional( - bottom: 8.w, - child: SCDebounceWidget( - onTap: () { - SmartDialog.show( - tag: "showGiftControl", - alignment: Alignment.bottomCenter, - maskColor: Colors.transparent, - animationType: SmartAnimationType.fade, - clickMaskDismiss: true, - builder: (_) { - return GiftPage(); - }, + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildChatEntry(inputWidth), + const Spacer(), + giftAction, + SizedBox(width: 14.w), + messageAction, + SizedBox(width: 14.w), + menuAction, + ], + ), ); }, - child: Image.asset( - "sc_images/room/sc_icon_botton_gift.png", - width: 45.w, - height: 45.w, - fit: BoxFit.contain, - ), - ), - ), - ], + ); + }, + ), ); } - _mic1(RtcProvider provider) { - ///默认不显示 - bool show = false; + Widget _buildChatEntry(double inputWidth) { + return RoomBottomChatEntry( + width: inputWidth, + onTap: () { + if (SCRoomUtils.touristCanMsg(context)) { + Navigator.push(context, PopRoute(child: RoomMsgInput())); + } + }, + ); + } + + void _showGiftPanel() { + SmartDialog.show( + tag: "showGiftControl", + alignment: Alignment.bottomCenter, + maskColor: Colors.transparent, + animationType: SmartAnimationType.fade, + clickMaskDismiss: true, + builder: (_) { + return GiftPage(); + }, + ); + } + + Widget _buildMenuAction() { + return SCDebounceWidget( + onTap: () { + SmartDialog.show( + tag: "showRoomMenuDialog", + alignment: Alignment.bottomCenter, + debounce: true, + animationType: SmartAnimationType.fade, + maskColor: Colors.transparent, + clickMaskDismiss: true, + builder: (_) { + return RoomMenuDialog(roomMenuStime1, (eTime) { + roomMenuStime1 = eTime; + }); + }, + ); + }, + child: RoomBottomCircleAction( + child: Image.asset( + "sc_images/room/sc_icon_botton_menu.png", + width: 20.w, + height: 20.w, + fit: BoxFit.contain, + ), + ), + ); + } + + Widget _buildMessageAction() { + return SCDebounceWidget( + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.message}?isFromRoom=true", + ); + }, + child: Selector( + selector: (c, p) => p.allUnReadCount, + shouldRebuild: (prev, next) => prev != next, + builder: (_, allUnReadCount, __) { + final action = RoomBottomCircleAction( + child: Image.asset( + "sc_images/room/sc_icon_botton_message.png", + width: 20.w, + height: 20.w, + fit: BoxFit.contain, + ), + ); + + if (allUnReadCount <= 0) { + return action; + } + + return Badge( + backgroundColor: Colors.red, + label: text( + "${allUnReadCount > 99 ? "99+" : allUnReadCount}", + fontSize: 9.sp, + textColor: Colors.white, + fontWeight: FontWeight.w600, + ), + alignment: AlignmentDirectional.topEnd, + child: action, + ); + }, + ), + ); + } + + Widget _buildMicAction(RtcProvider provider) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + provider.isMic = !provider.isMic; + + provider.roomWheatMap.forEach((k, v) { + if (v.user?.id == + AccountStorage().getCurrentUser()?.userProfile?.id && + !provider.roomWheatMap[k]!.micMute!) { + if (!provider.isMic) { + provider.engine?.adjustRecordingSignalVolume(100); + provider.engine?.setClientRole( + role: ClientRoleType.clientRoleBroadcaster, + ); + provider.engine?.muteLocalAudioStream(false); + } else { + if (provider.isMusicPlaying) { + provider.engine?.adjustRecordingSignalVolume(0); + } else { + provider.engine?.setClientRole( + role: ClientRoleType.clientRoleAudience, + ); + provider.engine?.muteLocalAudioStream(provider.isMic); + } + } + } + }); + }); + }, + child: RoomBottomCircleAction( + child: Image.asset( + "sc_images/room/${provider.isMic ? 'sc_icon_botton_mic_close' : 'sc_icon_botton_mic_open'}.png", + width: 20.w, + height: 20.w, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ), + ); + } + + bool _shouldShowMic(RtcProvider provider) { + var show = false; - ///在麦上 provider.roomWheatMap.forEach((k, v) { if (v.user?.id == AccountStorage().getCurrentUser()?.userProfile?.id) { show = true; } }); - return Visibility( - visible: show, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - setState(() { - provider.isMic = !provider.isMic; - - ///没被禁麦才显示 - provider.roomWheatMap.forEach((k, v) { - if (v.user?.id == - AccountStorage().getCurrentUser()?.userProfile?.id && - !provider.roomWheatMap[k]!.micMute!) { - if (!provider.isMic) { - provider.engine?.adjustRecordingSignalVolume(100); - provider.engine?.setClientRole( - role: ClientRoleType.clientRoleBroadcaster, - ); - provider.engine?.muteLocalAudioStream(false); - } else { - if (provider.isMusicPlaying) { - provider.engine?.adjustRecordingSignalVolume(0); - } else { - provider.engine?.setClientRole( - role: ClientRoleType.clientRoleAudience, - ); - provider.engine?.muteLocalAudioStream(provider.isMic); - } - } - } - }); - }); - }, - child: Padding( - padding: EdgeInsets.only(right: 12.w), - child: Container( - width: 30.w, - height: 30.w, - alignment: Alignment.center, - // decoration: BoxDecoration( - // color: Colors.white.withOpacity(0.2), shape: BoxShape.circle - // ), - child: Image.asset( - "sc_images/room/${provider.isMic ? 'sc_icon_botton_mic_close' : 'sc_icon_botton_mic_open'}.png", - width: 30.w, - fit: BoxFit.fill, - gaplessPlayback: true, - ), - ), - ), - ), - ); + return show; } } diff --git a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart index 661f20f..ee01c8e 100644 --- a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart +++ b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/modules/room_game/views/room_game_list_sheet.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; class RoomGameEntryButton extends StatelessWidget { const RoomGameEntryButton({super.key}); @@ -17,28 +18,17 @@ class RoomGameEntryButton extends StatelessWidget { barrierColor: Colors.black54, ); }, - child: Container( + child: SCSvgaAssetWidget( + assetPath: "sc_images/room/sc_icon_room_game_entry_anim.svga", width: 44.w, height: 44.w, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: const Color(0xff09372E).withValues(alpha: 0.72), - border: Border.all( - color: Colors.white.withValues(alpha: 0.22), - width: 1.w, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 10.w, - offset: Offset(0, 4.w), - ), - ], - ), - child: Text( - '🎮', - style: TextStyle(fontSize: 22.sp), + active: true, + loop: true, + fallback: Image.asset( + "sc_images/room/sc_icon_botton_game.png", + width: 44.w, + height: 44.w, + fit: BoxFit.contain, ), ), ); @@ -46,10 +36,7 @@ class RoomGameEntryButton extends StatelessWidget { } class RoomGameBottomSheet extends StatelessWidget { - const RoomGameBottomSheet({ - super.key, - required this.roomContext, - }); + const RoomGameBottomSheet({super.key, required this.roomContext}); final BuildContext roomContext; diff --git a/lib/ui_kit/widgets/room/room_online_user_widget.dart b/lib/ui_kit/widgets/room/room_online_user_widget.dart index 9b604ef..17a7f88 100644 --- a/lib/ui_kit/widgets/room/room_online_user_widget.dart +++ b/lib/ui_kit/widgets/room/room_online_user_widget.dart @@ -131,8 +131,9 @@ class _RoomOnlineUserWidgetState extends State { builder: (context, ref, child) { return GestureDetector( child: Container( - width: 90.w, + constraints: BoxConstraints(minWidth: 90.w, maxWidth: 104.w), height: 27.w, + padding: EdgeInsetsDirectional.only(start: 8.w, end: 4.w), decoration: BoxDecoration( color: Colors.white10, borderRadius: BorderRadiusDirectional.only( @@ -149,12 +150,18 @@ class _RoomOnlineUserWidgetState extends State { width: 18.w, height: 18.w, ), - SizedBox(width: 5.w), - text( - "${ref.roomContributeLevelRes?.thisWeekIntegral ?? 0}", - fontSize: 13.sp, - textColor: Colors.orangeAccent, - fontWeight: FontWeight.w600, + SizedBox(width: 4.w), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: text( + "${ref.roomContributeLevelRes?.thisWeekIntegral ?? 0}", + fontSize: 13.sp, + textColor: Colors.orangeAccent, + fontWeight: FontWeight.w600, + ), + ), ), Icon( Icons.chevron_right, diff --git a/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart b/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart new file mode 100644 index 0000000..efe6e9e --- /dev/null +++ b/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svga/flutter_svga.dart'; + +class SCSvgaAssetWidget extends StatefulWidget { + const SCSvgaAssetWidget({ + super.key, + required this.assetPath, + this.width, + this.height, + this.active = true, + this.loop = false, + this.fit = BoxFit.contain, + this.filterQuality = FilterQuality.low, + this.allowDrawingOverflow = false, + this.fallback, + }); + + final String assetPath; + final double? width; + final double? height; + final bool active; + final bool loop; + final BoxFit fit; + final FilterQuality filterQuality; + final bool allowDrawingOverflow; + final Widget? fallback; + + @override + State createState() => _SCSvgaAssetWidgetState(); +} + +class _SCSvgaAssetWidgetState extends State + with SingleTickerProviderStateMixin { + static final Map _cache = {}; + static final Map> _loadingTasks = + >{}; + + late final SVGAAnimationController _controller; + String? _loadedAssetPath; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _controller = SVGAAnimationController(vsync: this); + _loadAsset(); + } + + @override + void didUpdateWidget(covariant SCSvgaAssetWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath) { + _loadAsset(); + return; + } + if (oldWidget.active != widget.active || oldWidget.loop != widget.loop) { + _syncPlayback( + restartIfActive: widget.active && oldWidget.active != widget.active, + ); + } + } + + Future _loadAsset() async { + final assetPath = widget.assetPath; + setState(() { + _hasError = false; + }); + try { + final movieEntity = await _obtainMovieEntity(assetPath); + if (!mounted || widget.assetPath != assetPath) { + return; + } + _loadedAssetPath = assetPath; + _controller.videoItem = movieEntity; + _syncPlayback(restartIfActive: true); + } catch (error) { + debugPrint('[SCSVGA] load failed asset=$assetPath error=$error'); + if (!mounted || widget.assetPath != assetPath) { + return; + } + setState(() { + _hasError = true; + }); + } + } + + Future _obtainMovieEntity(String assetPath) async { + final cached = _cache[assetPath]; + if (cached != null) { + return cached; + } + + final loadingTask = _loadingTasks[assetPath]; + if (loadingTask != null) { + return loadingTask; + } + + final future = () async { + final entity = await SVGAParser.shared.decodeFromAssets(assetPath); + entity.autorelease = false; + _cache[assetPath] = entity; + return entity; + }(); + + _loadingTasks[assetPath] = future; + try { + return await future; + } finally { + _loadingTasks.remove(assetPath); + } + } + + void _syncPlayback({bool restartIfActive = false}) { + if (_controller.videoItem == null) { + return; + } + + if (!widget.active) { + _controller.stop(); + _controller.reset(); + return; + } + + if (widget.loop) { + if (restartIfActive || !_controller.isAnimating) { + _controller.reset(); + _controller.repeat(); + } + return; + } + + if (restartIfActive || !_controller.isAnimating) { + _controller.reset(); + _controller.repeat(count: 1); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError) { + return _buildFallback(); + } + + final videoItem = _controller.videoItem; + if (videoItem == null || _loadedAssetPath != widget.assetPath) { + return _buildFallback(); + } + + return SizedBox( + width: widget.width, + height: widget.height, + child: IgnorePointer( + child: SVGAImage( + _controller, + fit: widget.fit, + clearsAfterStop: false, + filterQuality: widget.filterQuality, + allowDrawingOverflow: widget.allowDrawingOverflow, + ), + ), + ); + } + + Widget _buildFallback() { + return SizedBox( + width: widget.width, + height: widget.height, + child: widget.fallback ?? const SizedBox.shrink(), + ); + } +} diff --git a/sc_images/index/sc_icon_explore_anim.svga b/sc_images/index/sc_icon_explore_anim.svga new file mode 100644 index 0000000..b5b2a11 Binary files /dev/null and b/sc_images/index/sc_icon_explore_anim.svga differ diff --git a/sc_images/index/sc_icon_home_anim.svga b/sc_images/index/sc_icon_home_anim.svga new file mode 100644 index 0000000..afbbd06 Binary files /dev/null and b/sc_images/index/sc_icon_home_anim.svga differ diff --git a/sc_images/index/sc_icon_me_anim.svga b/sc_images/index/sc_icon_me_anim.svga new file mode 100644 index 0000000..8a6bcde Binary files /dev/null and b/sc_images/index/sc_icon_me_anim.svga differ diff --git a/sc_images/index/sc_icon_message_anim.svga b/sc_images/index/sc_icon_message_anim.svga new file mode 100644 index 0000000..651723a Binary files /dev/null and b/sc_images/index/sc_icon_message_anim.svga differ diff --git a/sc_images/index/sc_icon_recommend_rank_top1_anim.svga b/sc_images/index/sc_icon_recommend_rank_top1_anim.svga new file mode 100644 index 0000000..fc20924 Binary files /dev/null and b/sc_images/index/sc_icon_recommend_rank_top1_anim.svga differ diff --git a/sc_images/index/sc_icon_recommend_rank_top2_anim.svga b/sc_images/index/sc_icon_recommend_rank_top2_anim.svga new file mode 100644 index 0000000..23a618a Binary files /dev/null and b/sc_images/index/sc_icon_recommend_rank_top2_anim.svga differ diff --git a/sc_images/index/sc_icon_recommend_rank_top3_anim.svga b/sc_images/index/sc_icon_recommend_rank_top3_anim.svga new file mode 100644 index 0000000..542c177 Binary files /dev/null and b/sc_images/index/sc_icon_recommend_rank_top3_anim.svga differ diff --git a/sc_images/room/sc_icon_room_game_entry_anim.svga b/sc_images/room/sc_icon_room_game_entry_anim.svga new file mode 100644 index 0000000..c2b9838 Binary files /dev/null and b/sc_images/room/sc_icon_room_game_entry_anim.svga differ diff --git a/sc_images/room/sc_icon_room_other_sonic_anim.svga b/sc_images/room/sc_icon_room_other_sonic_anim.svga new file mode 100644 index 0000000..b86ebbe Binary files /dev/null and b/sc_images/room/sc_icon_room_other_sonic_anim.svga differ diff --git a/sc_images/room/sc_icon_room_self_sonic_anim.svga b/sc_images/room/sc_icon_room_self_sonic_anim.svga new file mode 100644 index 0000000..78bc27d Binary files /dev/null and b/sc_images/room/sc_icon_room_self_sonic_anim.svga differ diff --git a/sc_images/room/sc_icon_room_vip3_sonic_anim.svga b/sc_images/room/sc_icon_room_vip3_sonic_anim.svga new file mode 100644 index 0000000..b9d5539 Binary files /dev/null and b/sc_images/room/sc_icon_room_vip3_sonic_anim.svga differ diff --git a/sc_images/room/sc_icon_room_vip4_sonic_anim.svga b/sc_images/room/sc_icon_room_vip4_sonic_anim.svga new file mode 100644 index 0000000..1103a45 Binary files /dev/null and b/sc_images/room/sc_icon_room_vip4_sonic_anim.svga differ diff --git a/sc_images/room/sc_icon_room_vip5_sonic_anim.svga b/sc_images/room/sc_icon_room_vip5_sonic_anim.svga new file mode 100644 index 0000000..5e408fe Binary files /dev/null and b/sc_images/room/sc_icon_room_vip5_sonic_anim.svga differ diff --git a/sc_images/room/sc_icon_room_vip6_sonic_anim.svga b/sc_images/room/sc_icon_room_vip6_sonic_anim.svga new file mode 100644 index 0000000..2e69efa Binary files /dev/null and b/sc_images/room/sc_icon_room_vip6_sonic_anim.svga differ diff --git a/需求进度.md b/需求进度.md index 53683d8..80cb072 100644 --- a/需求进度.md +++ b/需求进度.md @@ -13,6 +13,17 @@ - 本轮按需求暂未处理网络链路上的启动等待,例如审核态检查或远端启动页配置请求。 ## 已完成模块 +- 已按本轮动效替换需求完成首页底部 tab 动效接入,并同步将本轮新增本地 `.svga` 资源统一改成项目命名风格:当前 `Home / Explore / Message / Me` 已分别使用 `sc_icon_home_anim.svga / sc_icon_explore_anim.svga / sc_icon_message_anim.svga / sc_icon_me_anim.svga`,并继续保留原有 png 作为失败兜底;后续新增本地动效资源默认也按 `sc_icon_*_anim` 规则命名。 +- 已将语言房右侧 `game` 悬浮入口从 emoji 占位替换为本地动效资源,并按最新反馈移除外层圆形 `container`:当前直接使用 `sc_icon_room_game_entry_anim.svga` 本体作为入口展示,尺寸与原入口占位一致;若 SVGA 加载失败,会自动回退到项目原有 `sc_icon_botton_game.png`。 +- 已完成语言房房间麦位声波资源替换,并同步按项目规范重命名:VIP3/4/5/6 麦位现分别改接 `sc_icon_room_vip3/4/5/6_sonic_anim.svga`,普通麦位新增区分“本人/他人”声波资源,默认分别使用 `sc_icon_room_self_sonic_anim.svga` 与 `sc_icon_room_other_sonic_anim.svga`;原有说话触发阈值、错峰扩散逻辑和旧 `webp`/纯色圆形 fallback 仍保留。 +- 已修复语言房顶部左侧房间榜单入口出现 `right overflowed` 的问题:当前贡献值区域已改为约束宽度 + 自适应缩放文本,不再因为积分位数变长把右箭头顶出容器。 +- 已根据最新确认回退首页 Party 区的 `recommend_rank_top` 三个素材替换:财富榜、房间榜、魅力榜前三头像现已恢复为原先版本,不再在该位置叠加 `recommend_rank_top` 动态头像框;相关素材仍保留在工程内并已按规范重命名,后续待确认真正使用位置。 +- 已新增通用本地 SVGA 资源组件 `lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart`,用于统一处理本地 assets 解码、内存缓存、单次播放/循环播放和失败兜底;本轮底部 tab、语言房 game 按钮和麦位声波均复用这套实现,避免后续重复写播放器逻辑。 +- 已按最新语言房底部栏视觉需求完成 UI 重构:底部 5 个入口现改为同一水平基线布局,移除礼物按钮原先的中间悬浮 `Stack` 结构,并最终按“1、2、5、3、4”顺序排列,也就是“输入入口、礼物、消息、麦克风、菜单”;麦克风隐藏时继续保留占位,保证整体间距平均、视觉对齐稳定。 +- 已将语言房左侧输入入口改为独立组件:当前已撤回自绘聊天气泡图标方案,直接恢复使用项目原有的 `sc_images/room/icon_room_input_t.png` 资源作为左侧输入 icon,并继续放入约三分之一屏宽的圆角矩形中,右侧保留多语言问候文案 `roomBottomGreeting`,避免继续在自绘气泡尾巴形态上反复调整。 +- 已将语言房礼物入口拆成独立组件:礼物图标缩小后当前尺寸只略大于右侧 3 个圆按钮,并保留常驻轻量摇晃动画;根据最新反馈,当前已移除礼物按钮的粒子特效,并进一步去掉礼物入口外层 `container`,直接使用与原先按钮壳同尺寸的礼物图片本体作为可点击 UI,避免额外底壳干扰观感。 +- 已统一语言房其余四个入口的按钮壳样式:输入入口、消息、菜单、麦克风现统一使用淡色半透明背景 container,消息红点计数、菜单弹窗、消息跳转和麦克风开关等原有功能逻辑保持不变,本轮只调整 UI 展示与布局层。 +- 已按最新交互要求调整麦克风隐藏态布局:当第 4 个图标(麦克风)可见时,底部栏保持当前 5 个槽位的位置不变;当麦克风隐藏时,不再为其保留空槽位,礼物、消息、菜单 3 个入口会自动向右收拢成一组,避免右侧中间出现空位。 - 已排查语言房“双设备、不同账号进入同一房间,上麦后无声”的直接原因:当前 RTC 进房时统一以 Agora `Audience` 身份加入频道,见 `lib/services/audio/rtc_manager.dart` 中 `joinChannel()` 的 `clientRoleType: clientRoleAudience`;而本地麦克风开关 `isMic` 默认值为 `true`(当前语义实际是“闭麦”),上麦 `shangMai()` 后只有在 `!isMic` 时才会切到 `Broadcaster` 并取消本地静音,所以“只上麦、不点底部麦克风按钮”时不会发声。 - 已补充语言房当前真实交互结论:现有实现里“上麦”和“开麦”是两个动作。用户点空麦位后只是占麦;还需要再点击底部麦克风按钮,触发 `lib/ui_kit/widgets/room/room_bottom_widget.dart` 中的角色切换,才能真正开始向房间发送音频。 - 已同步补充该需求的后续决策点:如果产品预期是“上麦即能说话”,则需要单独改需求并调整实现为“上麦成功后自动切 `Broadcaster` 且自动开麦”;如果继续保留当前双步骤交互,则至少要补一条明确提示文案/引导,并把“双设备不同账号同房,上麦后需手动开麦才能互听”加入验收用例,避免测试误判为 RTC 故障。 @@ -21,6 +32,16 @@ - 已为语言房页面补充右下方 `game` 悬浮入口:入口位于聊天区右下侧、距右侧约 `15.w`、位于底部操作栏上方约 `100.w`,当前先使用代码绘制的 `🎮` 占位图标,并已在实现处标注后续替换为正式 UI 图片资源。 - 已新增语言房游戏底部弹窗:点击 `game` 入口后会从底部弹出约三分之一屏高的面板,采用房间页现有半透明磨砂风格,便于后续继续沿用当前视觉体系。 - 已补齐语言房游戏列表占位结构:弹窗内部先用静态游戏数据驱动,并按“`ListView` 可上下滑动 + 每行 `5` 个图标位”的方式实现,后续只需替换入口图、列表图标和真实接口数据即可继续开发。 +- 已排查语言房游戏 H5 无法正常加载的直接原因:当前房内 BAISHUN 游戏页把“加载完成”几乎完全绑定在 H5 主动回调 `gameLoaded` 上,同时 `window.NativeBridge` 又是在 WebView `onPageFinished` 后才注入;真实 H5 若在更早的启动阶段就尝试读取桥接配置或判定 NativeBridge 是否存在,就会错过这次注入,表现为页面一直停留在 `Waiting for gameLoaded...`,因此问题核心更接近“App 与 H5 的桥接初始化时序不稳 + 加载态过度依赖 H5 回调”,而不只是单纯的页面 URL 无法访问。 +- 已对语言房游戏 H5 链路补齐客户端兜底:房内游戏页现在会在页面启动阶段提前并重复尝试注入 BAISHUN bridge,不再只等首个 `onPageFinished`;同时加载遮罩已增加超时收口,不会因为 H5 没有及时回调 `gameLoaded` 就无限卡住。桥接侧也已补充 `getConfigSync`、全局配置缓存和 `baishunBridgeReady/baishunConfig` 事件分发,降低 H5 初始化阶段拿不到配置的概率,便于继续和 H5 联调确认其最终采用的接入方式。 +- 已按《BAISHUN 游戏对接文档 1.3.2》重新核对客户端桥接协议:文档里的核心要求并不只是 `NativeBridge` 存在,还包括 `getConfig` 入参携带 `jsCallback`、客户端回调指定 JS 函数、以及 Flutter 章节单独列出的 `getConfig / destroy / gameRecharge / gameLoaded` 四个通道名;当前房内游戏桥接已补充这些文档协议的兼容层,便于直接验证真实 H5 当前到底走的是哪一条调用路径。 +- 已新增 BAISHUN 临时调试面板,并封装为独立组件 `lib/modules/room_game/views/baishun_debug_panel.dart`:面板会展示 `entryUrl`、桥接注入次数、最近一次 `jsCallback`、最后一条桥接消息及日志列表,并提供 `Reload / Inject / ReplayConfig / Wallet / ClearLogs` 操作,专门用于这次房内游戏 H5 真机联调;该面板不是正式功能,后续调试完成后需要整块删除。 +- 已根据 BAISHUN 文档中“`code` 为一次性参数且唯一”的约束继续收紧客户端下发策略:当前房内游戏页在 bootstrap / `pageFinished` 阶段只注入桥接方法,不再主动把完整配置反复推给 H5;真正含 `code` 的配置现在只会在 H5 明确调用 `getConfig` 后回一次,避免因为重复下发一次性 `code` 导致 BAISHUN 后续拉取 `sstoken / user_info` 失败。调试面板也已补充 `ConfigSend` 计数和最近一次配置摘要,便于真机确认是否存在重复下发。 +- 已继续增强 BAISHUN 临时调试面板的排障能力:当前已在 H5 侧临时注入 `fetch / XMLHttpRequest / window.onerror / unhandledrejection` 日志回传,相关失败请求、状态码和响应片段会直接显示在 `Recent Logs`,专门用于继续定位当前 `get user info failed` 是否发生在百顺后续拉取 `sstoken / user_info` 的网络链路;这同样只是联调用临时能力,后续调试完成后需要删除。 +- 已根据《BAISHUN 游戏对接文档 1.3.2》补齐 `language` 字段兼容:文档表 3 要求传给游戏的语言值是字符串数字枚举(如英文 `2`、阿语 `7`、土耳其语 `10`),而当前客户端真实下发值曾是 `en`;现已在房内游戏页发送配置前统一转换为 BAISHUN 约定值,并把 `raw -> normalized` 结果记录进调试日志,便于继续确认 `get user info failed` 是否由协议字段不匹配导致。 +- 已根据最新真机日志继续收敛 BAISHUN 问题边界:当前调试面板已确认 `ConfigSend=1`、`language=2(raw=en)`、`getConfig_1_complete` 与 `gameLoaded_1_complete` 都已正常走通,说明 Flutter 侧桥接、语言字段和“一次性 code 重复下发”目前都不是主要矛盾;同时 `Recent Logs` 里未出现浏览器侧 `fetch/XHR` 失败,但仍出现 `get user info failed` 弹窗与 H5 内部 `InvalidStateError`,因此当前更倾向于 BAISHUN 服务端继续调用商户侧 `/v1/api/get_sstoken` / `/v1/api/get_user_info` 的链路失败,或其返回内容不符合文档要求,H5 页面里的报错更像后续症状而非首因。 +- 已继续增强 BAISHUN 临时调试能力用于下一轮真机排障:当前除了原有 `fetch/XHR/window.onerror/unhandledrejection` 外,还会额外回传 H5 的 `console.log/info/warn/error` 与资源加载失败信息,并把本地日志保留条数放宽到 `80` 条、面板日志区适当加高;这些都只服务当前联调,不属于正式功能,问题定位完成后需要一并删除。 +- 已通过 2026-04-16 真机最新控制台日志拿到更直接的证据:H5 侧明确打印了 `{"msgId":"Connect","errCode":1015,"errMsg":"1015-SSToken接口错误","data":null}`,并伴随 `WebSocket Error / WebSocket Close / Reconnect` 日志,说明当前问题已可进一步收敛为百顺游戏在建立长连接前获取 `SSToken` 失败;这表明主要矛盾已经不在 Flutter 容器桥接,而在百顺服务端访问商户侧 `/v1/api/get_sstoken` 的链路、该接口的业务结果、或其返回格式与文档约定不一致。 - 创建并持续维护进度跟踪文件。 - 已继续排查语言房 gift 动画链路:确认送礼后会同时走本地房间消息、滚屏礼物条和大额礼物全局飘屏三条路径,并修复动画管理器在控制器尚未绑定完成时提前消费队列导致后续动画不再播放的问题。 - 已定位并修复语言房礼物飘屏资源引用错误:代码里误写 `sc_icon_gift_flosc_bg` / `sc_icon_luck_gift_flosc_*`,实际资源文件名为 `float`,导致点击送礼后飘屏背景图加载失败,相关动画无法正常显示。 @@ -54,6 +75,7 @@ - 已继续修正个人资料更新接口的提交结构:排查发现注册接口使用嵌套 `profile`,而资料更新接口此前只发扁平字段;现已改为同时提交 `id + 扁平字段 + profile`,并在 `profile` 内同步带上 `userAvatar/avatar`,用于兼容后端可能按嵌套对象取值的情况。 - 已按最新联调结果再次收窄个人资料更新参数:日志已证明后端能收到请求但会忽略新头像地址,当前已改为头像相关只提交单一 `userAvatar` 字段,并移除 `avatar/profile` 冗余结构,便于继续验证后端是否对字段名或重复参数敏感。 - 已继续将个人页头像上传链路与通用资料提交拆开:上传成功后当前会单独触发一次仅含 `userAvatar` 的更新请求,不再混带昵称、性别、年龄、国家等字段,便于排除其它参数对头像更新接口的干扰。 +- 已继续收敛个人主页资料编辑页提交行为:当前已移除资料列表页中的 `country` 条目及对应跳转/提交逻辑,并将昵称、性别、生日、Bio、Hobby、头像改为“改哪项只提交哪项”的增量更新;同时修正生日弹窗只会在确认且日期真实变更后才触发提交,避免取消或未改动时误发请求。 - 完成仓库结构、依赖引用、资源体积和 APK 组成的第一轮排查。 - 移除 4 个当前未在业务层直接使用的插件依赖:`loading_indicator_view_plus`、`social_sharing_plus`、`flutter_foreground_task`、`on_audio_query`,并清理相关平台声明。 - 修补 `image_cropper 5.0.1` Android 兼容问题,切换到本地 path 依赖以恢复构建。 @@ -107,6 +129,7 @@ - `lib/services/room/rc_room_manager.dart` - `lib/services/audio/rtc_manager.dart` - `lib/modules/room/edit/room_edit_page.dart` +- `lib/modules/user/edit/edit_user_info_page2.dart` - `lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart` - `lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart` - `lib/shared/tools/sc_room_profile_cache.dart` @@ -117,7 +140,11 @@ - `lib/shared/business_logic/models/res/join_room_res.dart` - `lib/ui_kit/components/sc_compontent.dart` - `lib/ui_kit/components/sc_float_ichart.dart` +- `lib/modules/index/index_page.dart` +- `lib/modules/room/seat/sc_seat_item.dart` - `lib/ui_kit/widgets/room/room_head_widget.dart` +- `lib/ui_kit/widgets/room/room_game_bottom_sheet.dart` +- `lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart` - `lib/modules/room/detail/room_detail_page.dart` - `lib/modules/home/popular/party/sc_home_party_page.dart` - `lib/modules/home/popular/mine/sc_home_mine_skeleton.dart`