729 lines
22 KiB
Dart
729 lines
22 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/services.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/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';
|
|
|
|
class BaishunGamePage extends StatefulWidget {
|
|
const BaishunGamePage({
|
|
super.key,
|
|
required this.roomId,
|
|
required this.game,
|
|
required this.launchModel,
|
|
});
|
|
|
|
final String roomId;
|
|
final RoomGameListItemModel game;
|
|
final BaishunLaunchModel launchModel;
|
|
|
|
@override
|
|
State<BaishunGamePage> createState() => _BaishunGamePageState();
|
|
}
|
|
|
|
class _BaishunGamePageState extends State<BaishunGamePage> {
|
|
final RoomGameRepository _repository = RoomGameRepository();
|
|
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
|
|
<Factory<OneSequenceGestureRecognizer>>{
|
|
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
|
|
};
|
|
late final WebViewController _controller;
|
|
Timer? _bridgeBootstrapTimer;
|
|
Timer? _loadingFallbackTimer;
|
|
final List<BaishunDebugLogEntry> _debugLogs = <BaishunDebugLogEntry>[];
|
|
|
|
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 = '';
|
|
|
|
int get _launchOrientationValue {
|
|
if (widget.launchModel.entry.orientation != 0) {
|
|
return widget.launchModel.entry.orientation;
|
|
}
|
|
if (widget.game.orientation != 0) {
|
|
return widget.game.orientation;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller =
|
|
WebViewController()
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..setBackgroundColor(Colors.black)
|
|
..addJavaScriptChannel(
|
|
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 {
|
|
_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;
|
|
}
|
|
setState(() {
|
|
_errorMessage = error.description;
|
|
_isLoading = false;
|
|
});
|
|
},
|
|
),
|
|
);
|
|
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<void> _loadGameEntry() async {
|
|
try {
|
|
_prepareForPageLoad();
|
|
final entryUrl = widget.launchModel.entry.entryUrl.trim();
|
|
_appendDebugLog(
|
|
'launch',
|
|
'load entry launchMode=${widget.launchModel.entry.launchMode} orientation=$_launchOrientationValue url=$entryUrl',
|
|
);
|
|
if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) {
|
|
final html = await rootBundle.loadString(
|
|
'assets/debug/baishun_mock/index.html',
|
|
);
|
|
await _controller.loadHtmlString(
|
|
html,
|
|
baseUrl: 'https://baishun.mock.local/',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final uri = Uri.tryParse(entryUrl);
|
|
if (uri == null) {
|
|
throw Exception('Invalid game entry url: $entryUrl');
|
|
}
|
|
await _controller.loadRequest(uri);
|
|
} catch (error) {
|
|
_appendDebugLog('error', 'load entry failed: $error');
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_errorMessage = error.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _injectBridge({String reason = 'manual'}) async {
|
|
_bridgeInjectCount += 1;
|
|
if (reason != 'bootstrap') {
|
|
_appendDebugLog('inject', 'reason=$reason count=$_bridgeInjectCount');
|
|
}
|
|
try {
|
|
await _controller.runJavaScript(BaishunJsBridge.bootstrapScript());
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _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(
|
|
config,
|
|
jsCallbackPath: jsCallbackPath,
|
|
),
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
|
|
final bridgeMessage = BaishunBridgeMessage.parse(message.message);
|
|
await _dispatchBridgeMessage(
|
|
bridgeMessage,
|
|
source: BaishunJsBridge.channelName,
|
|
rawMessage: message.message,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleNamedChannelMessage(
|
|
String action,
|
|
JavaScriptMessage message,
|
|
) async {
|
|
final bridgeMessage = BaishunBridgeMessage.fromAction(
|
|
action,
|
|
message.message,
|
|
);
|
|
await _dispatchBridgeMessage(
|
|
bridgeMessage,
|
|
source: 'channel:$action',
|
|
rawMessage: message.message,
|
|
);
|
|
}
|
|
|
|
Future<void> _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 _sendConfigToGame(
|
|
reason: 'get_config',
|
|
jsCallbackPath: callbackPath.isEmpty ? null : callbackPath,
|
|
);
|
|
break;
|
|
case BaishunBridgeActions.gameLoaded:
|
|
_appendDebugLog('loading', 'gameLoaded received');
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMessage = null;
|
|
});
|
|
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:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _notifyWalletUpdate() async {
|
|
_appendDebugLog('wallet', 'send walletUpdate to H5');
|
|
try {
|
|
await _controller.runJavaScript(
|
|
BaishunJsBridge.buildWalletUpdateScript(),
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _reload() async {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
_appendDebugLog('panel', 'manual reload');
|
|
await _loadGameEntry();
|
|
}
|
|
|
|
Future<void> _manualInjectBridge() async {
|
|
await _injectBridge(reason: 'manual');
|
|
}
|
|
|
|
Future<void> _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<void> _closeAndExit({String reason = 'page_back'}) async {
|
|
if (_isClosing) {
|
|
return;
|
|
}
|
|
_isClosing = true;
|
|
try {
|
|
if (!widget.launchModel.gameSessionId.startsWith('bs_mock_')) {
|
|
await _repository.closeBaishunGame(
|
|
roomId: widget.roomId,
|
|
gameSessionId: widget.launchModel.gameSessionId,
|
|
reason: reason,
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
String _extractCallbackPath(Map<String, dynamic> payload) {
|
|
final candidates = <String>[
|
|
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 = <String>{
|
|
'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<String, dynamic> 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(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
|
if (didPop) {
|
|
return;
|
|
}
|
|
await _closeAndExit();
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xFF081915),
|
|
body: SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(10.w, 6.w, 10.w, 6.w),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _closeAndExit,
|
|
icon: const Icon(
|
|
Icons.arrow_back_ios,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
widget.game.name,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: _closeAndExit,
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: WebViewWidget(
|
|
controller: _controller,
|
|
gestureRecognizers: _webGestureRecognizers,
|
|
),
|
|
),
|
|
if (_errorMessage != null) _buildErrorState(),
|
|
if (_isLoading && _errorMessage == null)
|
|
const IgnorePointer(
|
|
ignoring: true,
|
|
child: 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} / ori=$_launchOrientationValue',
|
|
loading: _isLoading,
|
|
pageFinished: _didFinishPageLoad,
|
|
receivedBridgeMessage: _didReceiveBridgeMessage,
|
|
injectCount: _bridgeInjectCount,
|
|
configSendCount: _configSendCount,
|
|
lastBridgeSource: _lastBridgeSource,
|
|
lastBridgeAction: _lastBridgeAction,
|
|
lastBridgePayload: _lastBridgePayload,
|
|
lastJsCallback: _lastJsCallback,
|
|
lastConfigSummary: _lastConfigSummary,
|
|
errorMessage: _errorMessage ?? '',
|
|
logs: List<BaishunDebugLogEntry>.unmodifiable(
|
|
_debugLogs,
|
|
),
|
|
),
|
|
onReload: _reload,
|
|
onInjectBridge: _manualInjectBridge,
|
|
onReplayConfig: _replayLastConfig,
|
|
onWalletUpdate: _notifyWalletUpdate,
|
|
onClearLogs: _clearDebugLogs,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(
|
|
'Game page 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 check game host and package url');
|
|
},
|
|
child: const Text('Tips'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|