diff --git a/lib/modules/room_game/bridge/hotgame_js_bridge.dart b/lib/modules/room_game/bridge/hotgame_js_bridge.dart new file mode 100644 index 0000000..b72b1b8 --- /dev/null +++ b/lib/modules/room_game/bridge/hotgame_js_bridge.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +class HotgameBridgeActions { + static const String recharge = 'recharge'; + static const String quit = 'quit'; + static const String loadComplete = 'loadComplete'; + static const String debugLog = 'debugLog'; + + static String normalize(String action) { + final trimmed = action.trim(); + final lower = trimmed.toLowerCase(); + switch (lower) { + case 'recharge': + case 'pay': + case 'gamepay': + case 'gamerecharge': + case 'insufficient': + case 'insufficientbalance': + return recharge; + case 'quit': + case 'exit': + case 'close': + case 'closegame': + case 'destroy': + return quit; + case 'loadcomplete': + case 'gameloaded': + case 'loaded': + case 'ready': + return loadComplete; + case 'debuglog': + return debugLog; + default: + return trimmed; + } + } +} + +class HotgameBridgeMessage { + const HotgameBridgeMessage({ + required this.action, + this.payload = const {}, + }); + + final String action; + final Map payload; + + static HotgameBridgeMessage fromAction(String action, String rawMessage) { + return HotgameBridgeMessage( + action: HotgameBridgeActions.normalize(action), + payload: _parsePayload(rawMessage), + ); + } + + static HotgameBridgeMessage parse(String rawMessage) { + try { + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is Map) { + return HotgameBridgeMessage( + action: HotgameBridgeActions.normalize( + (decoded['action'] ?? + decoded['cmd'] ?? + decoded['event'] ?? + decoded['method'] ?? + decoded['type'] ?? + '') + .toString(), + ), + payload: _parsePayload( + decoded['payload'] ?? decoded['data'] ?? decoded['params'], + ), + ); + } + } catch (_) {} + return HotgameBridgeMessage( + action: HotgameBridgeActions.normalize(rawMessage), + ); + } + + 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 HotgameJsBridge { + static const String channelName = 'HotgameBridgeChannel'; + + static String bootstrapScript({Map? launchPayload}) { + final safeLaunchPayload = jsonEncode( + launchPayload ?? const {}, + ); + return ''' + (function() { + const launchPayload = $safeLaunchPayload; + + function toPayload(input) { + if (typeof input === 'string') { + try { + return JSON.parse(input); + } catch (_) { + return input.trim() ? { raw: input.trim() } : {}; + } + } + if (input && typeof input === 'object') { + return input; + } + return {}; + } + + function stringifyForLog(value) { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value.length > 320 ? value.slice(0, 320) + '...' : value; + } + try { + const text = JSON.stringify(value); + return text.length > 320 ? text.slice(0, 320) + '...' : text; + } catch (_) { + return String(value); + } + } + + function postMessage(action, input) { + const payload = toPayload(input); + $channelName.postMessage(JSON.stringify({ + action: action, + payload: payload + })); + return payload; + } + + function postDebug(tag, payload) { + postMessage('${HotgameBridgeActions.debugLog}', { + tag: tag, + message: stringifyForLog(payload) + }); + } + + function dispatchLaunchPayload(payload) { + const normalizedPayload = + payload && typeof payload === 'object' ? payload : {}; + const launchConfig = + normalizedPayload.launchConfig && + typeof normalizedPayload.launchConfig === 'object' + ? normalizedPayload.launchConfig + : {}; + const entry = + normalizedPayload.entry && typeof normalizedPayload.entry === 'object' + ? normalizedPayload.entry + : {}; + window.__hotgameLaunchPayload = normalizedPayload; + window.hotgameLaunchPayload = normalizedPayload; + window.__hotgameLaunchConfig = launchConfig; + window.hotgameLaunchConfig = launchConfig; + window.__hotgameLaunchEntry = entry; + window.hotgameLaunchEntry = entry; + if (typeof window.onHotgameLaunchConfig === 'function') { + window.onHotgameLaunchConfig(launchConfig, normalizedPayload); + } + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + window.dispatchEvent( + new CustomEvent('hotgameLaunchConfig', { detail: launchConfig }) + ); + } + } + + function installPostMessageObject(name, action) { + const existing = window[name]; + if (!existing || typeof existing.postMessage !== 'function') { + window[name] = { + postMessage: function(body) { + return postMessage(action, body); + } + }; + } + } + + function installWebkitHandler(name, action) { + 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: function(body) { + return postMessage(action, body); + } + }; + } + } + + function installJsBridgeFunction(name, action) { + window.JsBridge = window.JsBridge || {}; + window.JsBridge[name] = function(body) { + return postMessage(action, body || {}); + }; + } + + if (!window.__hotgameBridgeReady) { + window.__hotgameBridgeReady = true; + installJsBridgeFunction('recharge', '${HotgameBridgeActions.recharge}'); + installJsBridgeFunction('quit', '${HotgameBridgeActions.quit}'); + installJsBridgeFunction('exit', '${HotgameBridgeActions.quit}'); + installJsBridgeFunction('closeGame', '${HotgameBridgeActions.quit}'); + installPostMessageObject('recharge', '${HotgameBridgeActions.recharge}'); + installPostMessageObject('quit', '${HotgameBridgeActions.quit}'); + installPostMessageObject('loadComplete', '${HotgameBridgeActions.loadComplete}'); + installWebkitHandler('recharge', '${HotgameBridgeActions.recharge}'); + installWebkitHandler('quit', '${HotgameBridgeActions.quit}'); + installWebkitHandler('loadComplete', '${HotgameBridgeActions.loadComplete}'); + window.NativeBridge = window.NativeBridge || {}; + window.NativeBridge.recharge = function(body) { + return postMessage('${HotgameBridgeActions.recharge}', body || {}); + }; + window.NativeBridge.quit = function(body) { + return postMessage('${HotgameBridgeActions.quit}', body || {}); + }; + window.NativeBridge.closeGame = function(body) { + return postMessage('${HotgameBridgeActions.quit}', body || {}); + }; + window.NativeBridge.rechargeSuccess = function(body) { + return notifyRechargeSuccess(body || {}); + }; + window.__hotgameBridgeDebug = postDebug; + postDebug('bridge.ready', { + href: window.location && window.location.href, + hasJsBridge: !!window.JsBridge, + hasRechargeSuccess: typeof window.rechargeSuccess === 'function' + }); + } + + function notifyRechargeSuccess(body) { + const payload = body || {}; + let notified = false; + if (typeof window.rechargeSuccess === 'function') { + window.rechargeSuccess(); + notified = true; + } + if (typeof window.onRechargeSuccess === 'function') { + window.onRechargeSuccess(payload); + notified = true; + } + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + window.dispatchEvent( + new CustomEvent('rechargeSuccess', { detail: payload }) + ); + } + return notified; + } + + window.__hotgameNotifyRechargeSuccess = notifyRechargeSuccess; + dispatchLaunchPayload(launchPayload); + })(); + '''; + } + + static String buildRechargeSuccessScript({Map? payload}) { + final safePayload = jsonEncode( + payload ?? + {'timestamp': DateTime.now().millisecondsSinceEpoch}, + ); + return ''' + (function() { + const payload = $safePayload; + if (typeof window.__hotgameBridgeDebug === 'function') { + window.__hotgameBridgeDebug('recharge.success', payload); + } + if (typeof window.__hotgameNotifyRechargeSuccess === 'function') { + window.__hotgameNotifyRechargeSuccess(payload); + return; + } + if (typeof window.rechargeSuccess === 'function') { + window.rechargeSuccess(); + } + if (typeof window.onRechargeSuccess === 'function') { + window.onRechargeSuccess(payload); + } + if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') { + window.dispatchEvent( + new CustomEvent('rechargeSuccess', { detail: payload }) + ); + } + })(); + '''; + } +} diff --git a/lib/modules/room_game/data/models/room_game_models.dart b/lib/modules/room_game/data/models/room_game_models.dart index a763d88..de459d2 100644 --- a/lib/modules/room_game/data/models/room_game_models.dart +++ b/lib/modules/room_game/data/models/room_game_models.dart @@ -127,6 +127,10 @@ class RoomGameListItemModel { bool get isLeader => provider.toUpperCase() == 'LEADER' || gameType.toUpperCase() == 'LEADER'; + bool get isHotgame => + provider.toUpperCase() == 'HOTGAME' || + gameType.toUpperCase() == 'HOTGAME'; + factory RoomGameListItemModel.fromJson(Map json) { return RoomGameListItemModel( gameId: _asString(json['gameId']), diff --git a/lib/modules/room_game/views/hotgame_game_page.dart b/lib/modules/room_game/views/hotgame_game_page.dart new file mode 100644 index 0000000..e104943 --- /dev/null +++ b/lib/modules/room_game/views/hotgame_game_page.dart @@ -0,0 +1,640 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/modules/room_game/bridge/hotgame_js_bridge.dart'; +import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; +import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/utils/room_game_viewport.dart'; +import 'package:yumi/modules/room_game/views/baishun_loading_view.dart'; +import 'package:yumi/modules/wallet/wallet_route.dart'; +import 'package:yumi/shared/tools/sc_h5_url_utils.dart'; +import 'package:yumi/ui_kit/components/sc_tts.dart'; + +class HotgameGamePage extends StatefulWidget { + const HotgameGamePage({ + super.key, + required this.roomId, + required this.game, + required this.launchModel, + }); + + final String roomId; + final RoomGameListItemModel game; + final BaishunLaunchModel launchModel; + + @override + State createState() => _HotgameGamePageState(); +} + +class _HotgameGamePageState extends State { + static const double _sevenScreenRatio = 0.72; + + final RoomGameRepository _repository = RoomGameRepository(); + final Set> _webGestureRecognizers = + >{ + Factory(() => EagerGestureRecognizer()), + }; + late final WebViewController _controller; + + Timer? _bridgeBootstrapTimer; + Timer? _loadingFallbackTimer; + + bool _isLoading = true; + bool _isClosing = false; + bool _didReceiveBridgeMessage = false; + bool _didFinishPageLoad = false; + bool _didInjectProgressBridge = false; + int _bridgeInjectCount = 0; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _controller = + WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.black) + ..addJavaScriptChannel( + HotgameJsBridge.channelName, + onMessageReceived: _handleBridgeMessage, + ) + ..addJavaScriptChannel( + HotgameBridgeActions.recharge, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + HotgameBridgeActions.recharge, + message, + ), + ) + ..addJavaScriptChannel( + HotgameBridgeActions.quit, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + HotgameBridgeActions.quit, + message, + ), + ) + ..addJavaScriptChannel( + HotgameBridgeActions.loadComplete, + onMessageReceived: + (JavaScriptMessage message) => _handleNamedChannelMessage( + HotgameBridgeActions.loadComplete, + message, + ), + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + _log('page_started url=${_clip(url, 240)}'); + _prepareForPageLoad(); + }, + onPageFinished: (String url) async { + _didFinishPageLoad = true; + _log('page_finished url=${_clip(url, 240)}'); + await _injectBridge(reason: 'page_finished'); + if (!mounted || _errorMessage != null) { + return; + } + setState(() { + _isLoading = false; + }); + }, + onProgress: (int progress) { + if (_didInjectProgressBridge || + progress < 10 || + _errorMessage != null) { + return; + } + _didInjectProgressBridge = true; + unawaited(_injectBridge(reason: 'progress_$progress')); + }, + onWebResourceError: (WebResourceError error) { + _log( + 'web_resource_error code=${error.errorCode} ' + 'type=${error.errorType} desc=${_clip(error.description, 300)}', + ); + _stopBridgeBootstrap(reason: 'web_resource_error'); + if (!mounted) { + return; + } + setState(() { + _errorMessage = error.description; + _isLoading = false; + }); + }, + ), + ); + _log('init launch=${_stringifyForLog(_buildLaunchSummary())}'); + unawaited(_loadGameEntry()); + } + + @override + void dispose() { + _stopBridgeBootstrap(reason: 'dispose'); + super.dispose(); + } + + void _prepareForPageLoad() { + _didReceiveBridgeMessage = false; + _didFinishPageLoad = false; + _didInjectProgressBridge = false; + _bridgeInjectCount = 0; + _stopBridgeBootstrap(reason: 'prepare_page_load'); + _bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), ( + Timer timer, + ) { + if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { + timer.cancel(); + return; + } + unawaited(_injectBridge(reason: 'bootstrap')); + if (_didFinishPageLoad && timer.tick >= 12) { + timer.cancel(); + } + }); + _loadingFallbackTimer = Timer(const Duration(seconds: 6), () { + if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { + return; + } + setState(() { + _isLoading = false; + }); + }); + if (!mounted) { + return; + } + setState(() { + _isLoading = true; + _errorMessage = null; + }); + } + + void _stopBridgeBootstrap({String reason = 'manual'}) { + if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) { + _log('stop_bootstrap reason=$reason'); + } + _bridgeBootstrapTimer?.cancel(); + _bridgeBootstrapTimer = null; + _loadingFallbackTimer?.cancel(); + _loadingFallbackTimer = null; + } + + Future _loadGameEntry() async { + try { + _prepareForPageLoad(); + final entryUrl = widget.launchModel.entry.entryUrl.trim(); + _log( + 'load_entry launchMode=${widget.launchModel.entry.launchMode} ' + 'entryUrl=${_clip(entryUrl, 240)}', + ); + if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) { + await _controller.loadHtmlString(_buildMockHtml()); + return; + } + final h5Url = SCH5UrlUtils.appendToken(entryUrl); + final uri = Uri.tryParse(h5Url); + if (uri == null) { + throw Exception('Invalid hotgame entry url: $entryUrl'); + } + await _controller.loadRequest(uri); + } catch (error) { + _log('load_entry_error error=${_clip(error.toString(), 400)}'); + if (!mounted) { + return; + } + setState(() { + _errorMessage = error.toString(); + _isLoading = false; + }); + } + } + + Future _injectBridge({String reason = 'manual'}) async { + _bridgeInjectCount += 1; + if (reason != 'bootstrap' || + _bridgeInjectCount <= 3 || + _bridgeInjectCount % 5 == 0) { + _log('inject_bridge reason=$reason count=$_bridgeInjectCount'); + } + try { + await _controller.runJavaScript( + HotgameJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()), + ); + } catch (error) { + _log( + 'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}', + ); + } + } + + Future _handleBridgeMessage(JavaScriptMessage message) async { + _log('channel_message raw=${_clip(message.message, 600)}'); + final bridgeMessage = HotgameBridgeMessage.parse(message.message); + await _dispatchBridgeMessage(bridgeMessage); + } + + Future _handleNamedChannelMessage( + String action, + JavaScriptMessage message, + ) async { + final bridgeMessage = HotgameBridgeMessage.fromAction( + action, + message.message, + ); + _log('named_channel action=$action raw=${_clip(message.message, 600)}'); + await _dispatchBridgeMessage(bridgeMessage); + } + + Future _dispatchBridgeMessage( + HotgameBridgeMessage bridgeMessage, + ) async { + if (bridgeMessage.action == HotgameBridgeActions.debugLog) { + final tag = bridgeMessage.payload['tag']?.toString().trim(); + final message = bridgeMessage.payload['message']?.toString() ?? ''; + _log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}'); + return; + } + + _didReceiveBridgeMessage = true; + _stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}'); + _log( + 'bridge_action action=${bridgeMessage.action} ' + 'payload=${_stringifyForLog(_sanitizeForLog(bridgeMessage.payload))}', + ); + + switch (bridgeMessage.action) { + case HotgameBridgeActions.recharge: + await _openRechargeAndNotifyGame(); + break; + case HotgameBridgeActions.quit: + await _closeAndExit(reason: 'h5_quit'); + break; + case HotgameBridgeActions.loadComplete: + if (!mounted) { + return; + } + setState(() { + _isLoading = false; + _errorMessage = null; + }); + break; + default: + _log('bridge_action_unhandled action=${bridgeMessage.action}'); + break; + } + } + + Future _openRechargeAndNotifyGame() async { + _log('hotgame_recharge open_wallet'); + await SCNavigatorUtils.push(context, WalletRoute.recharge); + try { + await _controller.runJavaScript( + HotgameJsBridge.buildRechargeSuccessScript(), + ); + } catch (error) { + _log( + 'recharge_success_notify_error error=${_clip(error.toString(), 300)}', + ); + } + } + + Future _reload() async { + if (!mounted) { + return; + } + setState(() { + _errorMessage = null; + _isLoading = true; + }); + await _loadGameEntry(); + } + + Future _closeAndExit({String reason = 'user_exit'}) async { + if (_isClosing) { + return; + } + _isClosing = true; + try { + final sessionId = widget.launchModel.gameSessionId.trim(); + if (sessionId.isNotEmpty && !sessionId.startsWith('mock')) { + await _repository.closeGame( + provider: widget.game.provider, + roomId: widget.roomId, + gameSessionId: sessionId, + reason: reason, + params: const {}, + ); + } + } catch (error) { + _log('close_error error=${_clip(error.toString(), 300)}'); + } + if (mounted) { + Navigator.of(context).pop(); + } + } + + String _resolveScreenMode() { + return resolveRoomGameScreenMode( + widget.game.launchParams, + fullScreen: widget.game.fullScreen, + ); + } + + int _resolveSafeHeight() { + if (widget.launchModel.entry.safeHeight > 0) { + return widget.launchModel.entry.safeHeight; + } + if (widget.game.safeHeight > 0) { + return widget.game.safeHeight; + } + return 0; + } + + double _calculateBodyHeight(BuildContext context) { + final screenMode = _resolveScreenMode(); + final maxDialogHeight = resolveRoomGameAvailableHeight( + context, + reservedTop: 12.w, + ); + if (maxDialogHeight <= 0) { + return 0; + } + + if (screenMode == 'full') { + return maxDialogHeight; + } + if (screenMode == 'seven') { + return maxDialogHeight * _sevenScreenRatio; + } + + return clampRoomGameHeight( + _calculatePreferredBodyHeight(context), + maxDialogHeight, + ); + } + + double _calculatePreferredBodyHeight(BuildContext context) { + final mediaQuery = MediaQuery.maybeOf(context); + final screenSize = mediaQuery?.size ?? Size.zero; + final safeHeight = _resolveSafeHeight(); + final fallback = screenSize.width; + + if (safeHeight > 0 && screenSize.width > 0) { + final ratio = roomGameDesignWidth / safeHeight; + return screenSize.width / ratio; + } + + return fallback; + } + + void _log(String message) {} + + String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + if (trimmed.length <= keepStart + keepEnd) { + return trimmed; + } + return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}'; + } + + Object? _sanitizeForLog(Object? value, {String key = ''}) { + final lowerKey = key.toLowerCase(); + final shouldMask = + lowerKey.contains('code') || + lowerKey.contains('token') || + lowerKey.contains('authorization'); + if (value is Map) { + final result = {}; + for (final MapEntry entry in value.entries) { + result[entry.key.toString()] = _sanitizeForLog( + entry.value, + key: entry.key.toString(), + ); + } + return result; + } + if (value is List) { + return value.map((Object? item) => _sanitizeForLog(item)).toList(); + } + if (value is String) { + final normalized = shouldMask ? _maskValue(value) : value; + return _clip(normalized, 600); + } + return value; + } + + String _clip(String value, [int limit = 600]) { + final trimmed = value.trim(); + if (trimmed.length <= limit) { + return trimmed; + } + return '${trimmed.substring(0, limit)}...'; + } + + String _stringifyForLog(Object? value) { + if (value == null) { + return ''; + } + try { + return _clip(jsonEncode(value), 800); + } catch (_) { + return _clip(value.toString(), 800); + } + } + + Map _buildLaunchPayload() { + return { + 'provider': widget.launchModel.provider, + 'gameId': widget.launchModel.gameId, + 'providerGameId': widget.launchModel.providerGameId, + 'gameSessionId': widget.launchModel.gameSessionId, + 'entry': widget.launchModel.entry.toJson(), + 'launchConfig': widget.launchModel.launchConfig.toJson(), + 'roomState': widget.launchModel.roomState.toJson(), + 'game': { + 'gameId': widget.game.gameId, + 'provider': widget.game.provider, + 'gameType': widget.game.gameType, + 'name': widget.game.name, + 'launchMode': widget.game.launchMode, + 'launchParams': widget.game.launchParams, + }, + }; + } + + Map _buildLaunchSummary() { + final payload = _buildLaunchPayload(); + return { + 'roomId': widget.roomId, + 'provider': widget.game.provider, + 'gameId': widget.game.gameId, + 'providerGameId': widget.launchModel.providerGameId, + 'gameSessionId': widget.launchModel.gameSessionId, + 'entry': _sanitizeForLog(payload['entry']), + 'launchConfig': _sanitizeForLog(payload['launchConfig']), + 'roomState': _sanitizeForLog(payload['roomState']), + }; + } + + String _buildMockHtml() { + return ''' + + + + + + + + +
+

Hotgame Mock

+ + + + + + +
+
+ + + + '''; + } + + @override + Widget build(BuildContext context) { + final bodyHeight = _calculateBodyHeight(context); + final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context); + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (didPop) { + return; + } + await _closeAndExit(); + }, + child: Material( + color: Colors.transparent, + child: Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.w), + topRight: Radius.circular(24.w), + ), + child: Container( + width: ScreenUtil().screenWidth, + height: bodyHeight + bottomFrameHeight, + color: const Color(0xFF081915), + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomFrameHeight, + child: WebViewWidget( + controller: _controller, + gestureRecognizers: _webGestureRecognizers, + ), + ), + if (bottomFrameHeight > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: bottomFrameHeight, + child: RoomGameCompatibilityBottomFrame( + height: bottomFrameHeight, + ), + ), + if (_errorMessage != null) _buildErrorState(), + if (_isLoading && _errorMessage == null) + const IgnorePointer( + ignoring: true, + child: BaishunLoadingView( + message: 'Waiting for Hotgame...', + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildErrorState() { + return Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.88), + padding: EdgeInsets.symmetric(horizontal: 28.w), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: Colors.white70, size: 34.w), + SizedBox(height: 12.w), + Text( + 'Hotgame failed to load', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.w), + Text( + _errorMessage ?? '', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 12.sp), + ), + SizedBox(height: 16.w), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton(onPressed: _reload, child: const Text('Retry')), + SizedBox(width: 10.w), + TextButton( + onPressed: () { + SCTts.show('Please verify the Hotgame entry url'); + }, + child: const Text('Tips'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/room_game/views/room_game_list_sheet.dart b/lib/modules/room_game/views/room_game_list_sheet.dart index 9d3291a..cda0788 100644 --- a/lib/modules/room_game/views/room_game_list_sheet.dart +++ b/lib/modules/room_game/views/room_game_list_sheet.dart @@ -10,6 +10,7 @@ import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; import 'package:yumi/modules/room_game/utils/room_game_viewport.dart'; import 'package:yumi/modules/room_game/views/baishun_game_page.dart'; +import 'package:yumi/modules/room_game/views/hotgame_game_page.dart'; import 'package:yumi/modules/room_game/views/leader_game_page.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; @@ -33,8 +34,8 @@ class RoomGameListSheet extends StatefulWidget { } class _RoomGameListSheetState extends State { - static const String _logPrefix = '[RoomGameLaunch]'; static const int _itemsPerRow = 4; + static const String _logPrefix = '[RoomGameList]'; static const String _sheetFrameAsset = 'sc_images/room/sc_room_game_sheet_frame.png'; static const String _dividerLayerAsset = @@ -92,7 +93,9 @@ class _RoomGameListSheetState extends State { String targetGameId, ) { final supportedItems = - items.where((item) => item.isBaishun || item.isLeader).toList(); + items + .where((item) => item.isBaishun || item.isLeader || item.isHotgame) + .toList(); final candidates = supportedItems.isNotEmpty ? supportedItems : items; if (targetGameId == '0') { return candidates[math.Random().nextInt(candidates.length)]; @@ -119,7 +122,7 @@ class _RoomGameListSheetState extends State { SCTts.show('roomId is empty'); return; } - if (!game.isBaishun && !game.isLeader) { + if (!game.isBaishun && !game.isLeader && !game.isHotgame) { SCTts.show('This game provider is not wired yet'); return; } @@ -273,6 +276,13 @@ class _RoomGameListSheetState extends State { launchModel: launchModel, ); } + if (game.isHotgame) { + return HotgameGamePage( + roomId: _roomId, + game: game, + launchModel: launchModel, + ); + } return null; } diff --git a/lib/modules/user/edit/edit_user_info_page2.dart b/lib/modules/user/edit/edit_user_info_page2.dart index de8adae..4e01768 100644 --- a/lib/modules/user/edit/edit_user_info_page2.dart +++ b/lib/modules/user/edit/edit_user_info_page2.dart @@ -8,6 +8,7 @@ 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/services/general/sc_app_general_manager.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'; @@ -17,8 +18,11 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; +import 'package:yumi/modules/country/country_route.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/models/res/sc_user_identity_res.dart'; import '../../../shared/business_logic/usecases/sc_accurate_length_limiting_textInput_formatter.dart'; class EditUserInfoPage2 extends StatefulWidget { @@ -37,6 +41,9 @@ class _EditUserInfoPage2State extends State String? bornMonth; String? autograph; String? hobby; + String? countryId; + String? countryCode; + String? countryName; String? bornDay; String? bornYear; String nickName = ""; @@ -53,6 +60,9 @@ class _EditUserInfoPage2State extends State nickName = currentProfile?.userNickname ?? ""; autograph = currentProfile?.autograph ?? ""; hobby = currentProfile?.hobby ?? ""; + countryId = currentProfile?.countryId ?? ""; + countryCode = currentProfile?.countryCode ?? ""; + countryName = currentProfile?.countryName ?? ""; birthdayDate = _birthdayFromProfile(currentProfile) ?? _birthdayFromAge(currentProfile?.age); @@ -63,6 +73,19 @@ class _EditUserInfoPage2State extends State bornYear = _formatYear(currentProfile?.bornYear ?? birthdayDate?.year); age = currentProfile?.age ?? _ageFromBirthday(birthdayDate); sex = currentProfile?.userSex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + Provider.of( + context, + listen: false, + ).getUserIdentity(); + Provider.of( + context, + listen: false, + ).fetchCountryList(); + }); } String? _preferNonEmpty(String? primary, String? fallback) { @@ -157,11 +180,42 @@ class _EditUserInfoPage2State extends State return values.any((value) => !identical(value, _noChange)); } + bool _hasIdentity(SCUserIdentityRes? identity) { + if (identity == null) { + return true; + } + return (identity.anchor ?? false) || + (identity.bd ?? false) || + (identity.agent ?? false) || + (identity.bdLeader ?? false) || + (identity.freightAgent ?? false) || + (identity.superFreightAgent ?? false) || + (identity.admin ?? false) || + (identity.superAdmin ?? false) || + (identity.manager ?? false) || + (identity.yumiManager ?? false); + } + + bool _canChangeCountry(SCUserIdentityRes? identity) { + return !_hasIdentity(identity); + } + + String _countryDisplayText() { + final displayName = (countryName ?? "").trim(); + if (displayName.isNotEmpty) { + return displayName; + } + return (countryCode ?? "").trim(); + } + void _syncLocalProfileState(SocialChatUserProfile profile) { userCover = _preferUsableAvatar(profile.userAvatar, userCover); nickName = profile.userNickname ?? nickName; autograph = profile.autograph ?? autograph; hobby = profile.hobby ?? hobby; + countryId = profile.countryId ?? countryId; + countryCode = profile.countryCode ?? countryCode; + countryName = profile.countryName ?? countryName; sex = profile.userSex ?? sex; birthdayDate = _birthdayFromProfile(profile) ?? @@ -284,6 +338,12 @@ class _EditUserInfoPage2State extends State _showInputBioHobby(hobby ?? "", 2); }, ), + if (_canChangeCountry(ref.userIdentity)) + _buildItem( + "${SCAppLocalizations.of(context)!.countryRegion}:", + _countryDisplayText(), + _openCountryPicker, + ), ], ); }, @@ -421,6 +481,7 @@ class _EditUserInfoPage2State extends State Object? bornDayValue = _noChange, Object? hobbyValue = _noChange, Object? autographValue = _noChange, + Object? countryValue = _noChange, }) async { if (!_hasChanges([ userAvatarValue, @@ -432,6 +493,7 @@ class _EditUserInfoPage2State extends State bornDayValue, hobbyValue, autographValue, + countryValue, ])) { return; } @@ -446,6 +508,8 @@ class _EditUserInfoPage2State extends State _isSubmitting = true; SCLoadingManager.show(); try { + final selectedCountry = + identical(countryValue, _noChange) ? null : countryValue as Country?; final updatedProfile = await SCAccountRepository().updateUserInfo( userAvatar: identical(userAvatarValue, _noChange) @@ -471,6 +535,7 @@ class _EditUserInfoPage2State extends State identical(autographValue, _noChange) ? null : autographValue as String?, + countryId: selectedCountry?.id, ); final mergedProfile = updatedProfile.copyWith( userAvatar: _preferUsableAvatar( @@ -516,6 +581,21 @@ class _EditUserInfoPage2State extends State (identical(bornYearValue, _noChange) ? birthdayDate?.year : bornYearValue as num?), + countryId: _preferNonEmpty( + updatedProfile.countryId, + selectedCountry?.id, + ), + countryCode: _preferNonEmpty( + updatedProfile.countryCode, + selectedCountry?.alphaTwo, + ), + countryName: _preferNonEmpty( + updatedProfile.countryName, + _preferNonEmpty( + selectedCountry?.countryName, + selectedCountry?.aliasName, + ), + ), ); _syncLocalProfileState(mergedProfile); if (!mounted) { @@ -529,13 +609,49 @@ class _EditUserInfoPage2State extends State true; setState(() {}); SCTts.show(SCAppLocalizations.of(context)!.operationSuccessful); - } catch (e) { + if (selectedCountry != null) { + AccountStorage().logout(context); + return; + } + } catch (_) { + // The repository/network layer already shows request errors, so keep the + // current edit page state and only release the submit/loading guards here. } finally { _isSubmitting = false; SCLoadingManager.hide(); } } + Future _openCountryPicker() async { + final userIdentity = + Provider.of( + context, + listen: false, + ).userIdentity; + if (!_canChangeCountry(userIdentity)) { + return; + } + final generalManager = Provider.of( + context, + listen: false, + ); + generalManager.clearCountrySelection(); + await SCNavigatorUtils.push(context, CountryRoute.country, replace: false); + if (!mounted) { + return; + } + final selectedCountry = generalManager.selectCountryInfo; + if (selectedCountry == null) { + return; + } + final selectedCountryId = (selectedCountry.id ?? "").trim(); + if (selectedCountryId.isEmpty || + selectedCountryId == (countryId ?? "").trim()) { + return; + } + submit(countryValue: selectedCountry); + } + // ignore: unused_element void _showSexDialog() { showCenterDialog( diff --git a/test/hotgame_bridge_test.dart b/test/hotgame_bridge_test.dart new file mode 100644 index 0000000..428ba1d --- /dev/null +++ b/test/hotgame_bridge_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/modules/room_game/bridge/hotgame_js_bridge.dart'; +import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; + +void main() { + test('normalizes documented hotgame recharge and quit bridge actions', () { + expect(HotgameBridgeActions.normalize('recharge'), 'recharge'); + expect(HotgameBridgeActions.normalize('pay'), 'recharge'); + expect(HotgameBridgeActions.normalize('gameRecharge'), 'recharge'); + expect(HotgameBridgeActions.normalize('quit'), 'quit'); + expect(HotgameBridgeActions.normalize('closeGame'), 'quit'); + expect(HotgameBridgeActions.normalize('destroy'), 'quit'); + }); + + test('parses hotgame bridge payload maps', () { + final message = HotgameBridgeMessage.parse( + '{"action":"gameRecharge","payload":{"source":"button"}}', + ); + + expect(message.action, HotgameBridgeActions.recharge); + expect(message.payload['source'], 'button'); + }); + + test( + 'bootstrap script contains documented hotgame client bridge methods', + () { + final script = HotgameJsBridge.bootstrapScript(); + + expect(script, contains('window.JsBridge')); + expect(script, contains("installJsBridgeFunction('recharge'")); + expect(script, contains("installJsBridgeFunction('quit'")); + expect(script, contains("installWebkitHandler('recharge'")); + expect(script, contains("installWebkitHandler('quit'")); + expect(script, contains('window.rechargeSuccess')); + }, + ); + + test('detects hotgame room game list items', () { + const item = RoomGameListItemModel( + gameId: 'hg_1', + provider: 'HOTGAME', + gameType: 'HOTGAME', + providerGameId: 'Seven7', + name: 'Lucky77', + cover: '', + category: 'CHAT_ROOM', + sort: 1, + launchMode: 'H5_REMOTE', + fullScreen: false, + gameMode: 3, + safeHeight: 0, + orientation: 0, + packageVersion: '2026', + status: 'ENABLED', + launchParams: {'screenMode': 'seven'}, + ); + + expect(item.isHotgame, isTrue); + expect(item.isBaishun, isFalse); + expect(item.isLeader, isFalse); + }); +}