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