游戏1.0提交

This commit is contained in:
hy001 2026-04-15 22:55:31 +08:00
parent 73f1cf1199
commit 80e9f3a7d0
17 changed files with 1705 additions and 452 deletions

View File

@ -1,33 +1,55 @@
plugins {
id("com.google.gms.google-services") version "4.4.3" apply false
}
buildscript {
repositories {
google()
mavenCentral()
// 使用阿里云镜像加速
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
}
}
allprojects {
repositories {
// 使用阿里云镜像加速
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
// 备用官方仓库
google()
mavenCentral()
}
}
// 以下是你的现有配置
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
plugins {
id("com.google.gms.google-services") version "4.4.3" apply false
}
val mirrorRepos = listOf(
"https://maven.aliyun.com/repository/google",
"https://maven.aliyun.com/repository/public",
"https://maven.aliyun.com/repository/gradle-plugin",
)
fun org.gradle.api.artifacts.dsl.RepositoryHandler.addMirrorRepos() {
mirrorRepos.forEach { url ->
maven(url = uri(url))
}
}
buildscript {
repositories {
maven(url = uri("https://maven.aliyun.com/repository/google"))
maven(url = uri("https://maven.aliyun.com/repository/public"))
maven(url = uri("https://maven.aliyun.com/repository/gradle-plugin"))
google()
mavenCentral()
}
}
allprojects {
repositories {
addMirrorRepos()
google()
mavenCentral()
}
}
gradle.beforeProject {
buildscript.repositories.apply {
addMirrorRepos()
google()
mavenCentral()
}
repositories.apply {
addMirrorRepos()
google()
mavenCentral()
}
}
// 以下是你的现有配置
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
@ -40,4 +62,4 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
}

View File

@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.2 -Djdk.tls.client.protocols=TLSv1.2 -Djava.net.preferIPv4Stack=true
android.useAndroidX=true
android.enableJetifier=true
systemProp.https.protocols=TLSv1.2
systemProp.jdk.tls.client.protocols=TLSv1.2
systemProp.java.net.preferIPv4Stack=true

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>BAISHUN Mock</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #07110f 0%, #11342d 100%);
color: #f4fffb;
}
.page {
min-height: 100vh;
padding: 24px;
box-sizing: border-box;
}
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0 0 18px;
line-height: 1.5;
color: rgba(244, 255, 251, 0.8);
}
.panel {
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
button {
border: 0;
border-radius: 999px;
background: #2ae0a1;
color: #03231c;
padding: 10px 16px;
font-weight: 700;
}
pre {
margin-top: 16px;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.24);
padding: 12px;
border-radius: 12px;
min-height: 160px;
}
</style>
</head>
<body>
<div class="page">
<h1>BAISHUN Mock H5</h1>
<p>
This page is only for Flutter bridge bring-up. It exercises
getConfig, gameLoaded, gameRecharge, destroy and walletUpdate.
</p>
<div class="panel">
<div class="row">
<button onclick="requestConfig()">getConfig</button>
<button onclick="notifyLoaded()">gameLoaded</button>
<button onclick="requestRecharge()">gameRecharge</button>
<button onclick="requestDestroy()">destroy</button>
</div>
<pre id="log"></pre>
</div>
</div>
<script>
const logEl = document.getElementById("log");
function writeLog(title, payload) {
const line = `[${new Date().toLocaleTimeString()}] ${title}\n${JSON.stringify(
payload,
null,
2
)}\n\n`;
logEl.textContent = line + logEl.textContent;
}
function requestConfig() {
if (!window.NativeBridge || typeof window.NativeBridge.getConfig !== "function") {
writeLog("getConfig", { error: "NativeBridge not ready" });
return;
}
window.NativeBridge.getConfig(function (config) {
writeLog("getConfig callback", config);
});
}
function notifyLoaded() {
window.NativeBridge &&
window.NativeBridge.gameLoaded &&
window.NativeBridge.gameLoaded({ status: "loaded" });
writeLog("gameLoaded", { status: "sent" });
}
function requestRecharge() {
window.NativeBridge &&
window.NativeBridge.gameRecharge &&
window.NativeBridge.gameRecharge({ source: "mock-h5" });
writeLog("gameRecharge", { source: "mock-h5" });
}
function requestDestroy() {
window.NativeBridge &&
window.NativeBridge.destroy &&
window.NativeBridge.destroy({ source: "mock-h5" });
writeLog("destroy", { source: "mock-h5" });
}
window.walletUpdate = function (payload) {
writeLog("walletUpdate", payload || {});
};
window.addEventListener("walletUpdate", function (event) {
writeLog("walletUpdate event", event.detail || {});
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

View File

@ -0,0 +1,116 @@
import 'dart:convert';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
class BaishunBridgeActions {
static const String getConfig = 'getConfig';
static const String gameLoaded = 'gameLoaded';
static const String gameRecharge = 'gameRecharge';
static const String destroy = 'destroy';
}
class BaishunBridgeMessage {
const BaishunBridgeMessage({
required this.action,
this.payload = const <String, dynamic>{},
});
final String action;
final Map<String, dynamic> payload;
static BaishunBridgeMessage parse(String rawMessage) {
try {
final dynamic decoded = jsonDecode(rawMessage);
if (decoded is Map<String, dynamic>) {
final rawPayload = decoded['payload'];
return BaishunBridgeMessage(
action: (decoded['action'] ?? '').toString(),
payload:
rawPayload is Map<String, dynamic>
? rawPayload
: const <String, dynamic>{},
);
}
} catch (_) {}
return BaishunBridgeMessage(action: rawMessage.trim());
}
}
class BaishunJsBridge {
static const String channelName = 'BaishunBridgeChannel';
static String bootstrapScript() {
return '''
(function() {
if (window.__baishunBridgeReady) {
return;
}
window.__baishunBridgeReady = true;
window.NativeBridge = window.NativeBridge || {};
window.NativeBridge.getConfig = function(callback) {
if (typeof callback === 'function') {
window.__baishunGetConfigCallback = callback;
}
$channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.getConfig}' }));
return window.__baishunLastConfig || null;
};
window.NativeBridge.destroy = function(payload) {
$channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.destroy}', payload: payload || {} }));
};
window.NativeBridge.gameRecharge = function(payload) {
$channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.gameRecharge}', payload: payload || {} }));
};
window.NativeBridge.gameLoaded = function(payload) {
$channelName.postMessage(JSON.stringify({ action: '${BaishunBridgeActions.gameLoaded}', payload: payload || {} }));
};
window.baishunChannel = window.baishunChannel || {};
window.baishunChannel.walletUpdate = function(payload) {
window.__baishunLastWalletPayload = payload || {};
if (typeof window.walletUpdate === 'function') {
window.walletUpdate(payload || {});
}
if (typeof window.onWalletUpdate === 'function') {
window.onWalletUpdate(payload || {});
}
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('walletUpdate', { detail: payload || {} }));
}
};
})();
''';
}
static String buildConfigScript(BaishunBridgeConfigModel config) {
final payload = jsonEncode(config.toJson());
return '''
(function() {
const config = $payload;
window.__baishunLastConfig = config;
if (typeof window.__baishunGetConfigCallback === 'function') {
window.__baishunGetConfigCallback(config);
return;
}
if (typeof window.onBaishunConfig === 'function') {
window.onBaishunConfig(config);
}
})();
''';
}
static String buildWalletUpdateScript({Map<String, dynamic>? payload}) {
final safePayload = jsonEncode(
payload ??
<String, dynamic>{
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
);
return '''
(function() {
const payload = $safePayload;
if (window.baishunChannel && typeof window.baishunChannel.walletUpdate === 'function') {
window.baishunChannel.walletUpdate(payload);
}
})();
''';
}
}

View File

@ -0,0 +1,341 @@
class RoomGameShortcutModel {
const RoomGameShortcutModel({
required this.gameId,
required this.vendorType,
required this.vendorGameId,
required this.name,
required this.cover,
required this.launchMode,
required this.gameMode,
});
final String gameId;
final String vendorType;
final int vendorGameId;
final String name;
final String cover;
final String launchMode;
final int gameMode;
factory RoomGameShortcutModel.fromJson(Map<String, dynamic> json) {
return RoomGameShortcutModel(
gameId: _asString(json['gameId']),
vendorType: _asString(json['vendorType']),
vendorGameId: _asInt(json['vendorGameId']),
name: _asString(json['name']),
cover: _asString(json['cover']),
launchMode: _asString(json['launchMode']),
gameMode: _asInt(json['gameMode']),
);
}
}
class RoomGameListItemModel {
const RoomGameListItemModel({
required this.gameId,
required this.vendorType,
required this.vendorGameId,
required this.name,
required this.cover,
required this.category,
required this.sort,
required this.launchMode,
required this.fullScreen,
required this.gameMode,
required this.safeHeight,
required this.orientation,
required this.packageVersion,
required this.status,
});
final String gameId;
final String vendorType;
final int vendorGameId;
final String name;
final String cover;
final String category;
final int sort;
final String launchMode;
final bool fullScreen;
final int gameMode;
final int safeHeight;
final int orientation;
final String packageVersion;
final String status;
bool get isBaishun => vendorType.toUpperCase() == 'BAISHUN';
factory RoomGameListItemModel.fromJson(Map<String, dynamic> json) {
return RoomGameListItemModel(
gameId: _asString(json['gameId']),
vendorType: _asString(json['vendorType']),
vendorGameId: _asInt(json['vendorGameId']),
name: _asString(json['name']),
cover: _asString(json['cover']),
category: _asString(json['category']),
sort: _asInt(json['sort']),
launchMode: _asString(json['launchMode']),
fullScreen: _asBool(json['fullScreen']),
gameMode: _asInt(json['gameMode']),
safeHeight: _asInt(json['safeHeight']),
orientation: _asInt(json['orientation']),
packageVersion: _asString(json['packageVersion']),
status: _asString(json['status']),
);
}
factory RoomGameListItemModel.debugMock() {
return const RoomGameListItemModel(
gameId: 'bs_mock',
vendorType: 'BAISHUN',
vendorGameId: 999001,
name: 'BAISHUN Mock',
cover: '',
category: 'CHAT_ROOM',
sort: 999999,
launchMode: 'H5_REMOTE',
fullScreen: true,
gameMode: 3,
safeHeight: 800,
orientation: 1,
packageVersion: 'debug',
status: 'DEBUG',
);
}
}
class RoomGameListResponseModel {
const RoomGameListResponseModel({required this.items});
final List<RoomGameListItemModel> items;
factory RoomGameListResponseModel.fromJson(Map<String, dynamic> json) {
final list = json['items'] as List<dynamic>? ?? const <dynamic>[];
return RoomGameListResponseModel(
items:
list
.whereType<Map<String, dynamic>>()
.map(RoomGameListItemModel.fromJson)
.toList(),
);
}
}
class BaishunGameConfigModel {
const BaishunGameConfigModel({
required this.sceneMode,
required this.currencyIcon,
});
final int sceneMode;
final String currencyIcon;
factory BaishunGameConfigModel.fromJson(Map<String, dynamic> json) {
return BaishunGameConfigModel(
sceneMode: _asInt(json['sceneMode']),
currencyIcon: _asString(json['currencyIcon']),
);
}
Map<String, dynamic> toJson() {
return {'sceneMode': sceneMode, 'currencyIcon': currencyIcon};
}
}
class BaishunBridgeConfigModel {
const BaishunBridgeConfigModel({
required this.appName,
required this.appChannel,
required this.appId,
required this.userId,
required this.code,
required this.roomId,
required this.gameMode,
required this.language,
required this.gsp,
required this.gameConfig,
});
final String appName;
final String appChannel;
final int appId;
final String userId;
final String code;
final String roomId;
final String gameMode;
final String language;
final int gsp;
final BaishunGameConfigModel gameConfig;
factory BaishunBridgeConfigModel.fromJson(Map<String, dynamic> json) {
return BaishunBridgeConfigModel(
appName: _asString(json['appName']),
appChannel: _asString(json['appChannel']),
appId: _asInt(json['appId']),
userId: _asString(json['userId']),
code: _asString(json['code']),
roomId: _asString(json['roomId']),
gameMode: _asString(json['gameMode']),
language: _asString(json['language']),
gsp: _asInt(json['gsp']),
gameConfig: BaishunGameConfigModel.fromJson(
json['gameConfig'] as Map<String, dynamic>? ??
const <String, dynamic>{},
),
);
}
Map<String, dynamic> toJson() {
return {
'appName': appName,
'appChannel': appChannel,
'appId': appId,
'userId': userId,
'code': code,
'roomId': roomId,
'gameMode': gameMode,
'language': language,
'gsp': gsp,
'gameConfig': gameConfig.toJson(),
};
}
}
class BaishunLaunchEntryModel {
const BaishunLaunchEntryModel({
required this.launchMode,
required this.entryUrl,
required this.previewUrl,
required this.downloadUrl,
required this.packageVersion,
required this.orientation,
required this.safeHeight,
});
final String launchMode;
final String entryUrl;
final String previewUrl;
final String downloadUrl;
final String packageVersion;
final int orientation;
final int safeHeight;
factory BaishunLaunchEntryModel.fromJson(Map<String, dynamic> json) {
return BaishunLaunchEntryModel(
launchMode: _asString(json['launchMode']),
entryUrl: _asString(json['entryUrl']),
previewUrl: _asString(json['previewUrl']),
downloadUrl: _asString(json['downloadUrl']),
packageVersion: _asString(json['packageVersion']),
orientation: _asInt(json['orientation']),
safeHeight: _asInt(json['safeHeight']),
);
}
}
class BaishunRoomStateModel {
const BaishunRoomStateModel({
required this.roomId,
required this.state,
required this.gameSessionId,
required this.currentGameId,
required this.currentVendorGameId,
required this.currentGameName,
required this.currentGameCover,
required this.hostUserId,
});
final String roomId;
final String state;
final String gameSessionId;
final String currentGameId;
final int currentVendorGameId;
final String currentGameName;
final String currentGameCover;
final int hostUserId;
factory BaishunRoomStateModel.fromJson(Map<String, dynamic> json) {
return BaishunRoomStateModel(
roomId: _asString(json['roomId']),
state: _asString(json['state']),
gameSessionId: _asString(json['gameSessionId']),
currentGameId: _asString(json['currentGameId']),
currentVendorGameId: _asInt(json['currentVendorGameId']),
currentGameName: _asString(json['currentGameName']),
currentGameCover: _asString(json['currentGameCover']),
hostUserId: _asInt(json['hostUserId']),
);
}
}
class BaishunLaunchModel {
const BaishunLaunchModel({
required this.gameSessionId,
required this.vendorType,
required this.gameId,
required this.vendorGameId,
required this.entry,
required this.bridgeConfig,
required this.roomState,
});
final String gameSessionId;
final String vendorType;
final String gameId;
final int vendorGameId;
final BaishunLaunchEntryModel entry;
final BaishunBridgeConfigModel bridgeConfig;
final BaishunRoomStateModel roomState;
factory BaishunLaunchModel.fromJson(Map<String, dynamic> json) {
return BaishunLaunchModel(
gameSessionId: _asString(json['gameSessionId']),
vendorType: _asString(json['vendorType']),
gameId: _asString(json['gameId']),
vendorGameId: _asInt(json['vendorGameId']),
entry: BaishunLaunchEntryModel.fromJson(
json['entry'] as Map<String, dynamic>? ?? const <String, dynamic>{},
),
bridgeConfig: BaishunBridgeConfigModel.fromJson(
json['bridgeConfig'] as Map<String, dynamic>? ??
const <String, dynamic>{},
),
roomState: BaishunRoomStateModel.fromJson(
json['roomState'] as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
}
String _asString(dynamic value) {
if (value == null) {
return '';
}
return value.toString().trim();
}
int _asInt(dynamic value) {
if (value == null) {
return 0;
}
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value.toString()) ?? 0;
}
bool _asBool(dynamic value) {
if (value is bool) {
return value;
}
if (value is num) {
return value != 0;
}
if (value is String) {
return value.toLowerCase() == 'true' || value == '1';
}
return false;
}

View File

@ -0,0 +1,115 @@
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
const String _gameApiHost = String.fromEnvironment(
'GAME_API_HOST',
defaultValue: '',
);
class RoomGameApi {
RoomGameApi({NetworkClient? client}) : _client = client ?? http;
final NetworkClient _client;
Map<String, dynamic>? _buildExtra([Map<String, dynamic>? extra]) {
final merged = <String, dynamic>{...?extra};
final trimmedGameApiHost = _gameApiHost.trim();
if (trimmedGameApiHost.isNotEmpty) {
merged[BaseNetworkClient.baseUrlOverrideKey] = trimmedGameApiHost;
}
return merged.isEmpty ? null : merged;
}
Future<List<RoomGameShortcutModel>> fetchShortcutGames({
required String roomId,
}) {
return _client.get<List<RoomGameShortcutModel>>(
'/app/game/room/shortcut',
queryParams: {'roomId': roomId},
extra: _buildExtra(),
fromJson: (dynamic json) {
final list = json as List<dynamic>? ?? const <dynamic>[];
return list
.whereType<Map<String, dynamic>>()
.map(RoomGameShortcutModel.fromJson)
.toList();
},
);
}
Future<RoomGameListResponseModel> fetchRoomGames({
required String roomId,
String category = '',
}) {
final query = <String, dynamic>{'roomId': roomId};
if (category.isNotEmpty) {
query['category'] = category;
}
return _client.get<RoomGameListResponseModel>(
'/app/game/room/list',
queryParams: query,
extra: _buildExtra(const <String, dynamic>{
BaseNetworkClient.silentErrorToastKey: true,
}),
fromJson:
(dynamic json) => RoomGameListResponseModel.fromJson(
json as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
Future<BaishunRoomStateModel> fetchRoomState({required String roomId}) {
return _client.get<BaishunRoomStateModel>(
'/app/game/baishun/state',
queryParams: {'roomId': roomId},
extra: _buildExtra(),
fromJson:
(dynamic json) => BaishunRoomStateModel.fromJson(
json as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
Future<BaishunLaunchModel> launchBaishunGame({
required String roomId,
required String gameId,
required int sceneMode,
required String clientOrigin,
}) {
return _client.post<BaishunLaunchModel>(
'/app/game/baishun/launch',
data: {
'roomId': roomId,
'gameId': gameId,
'sceneMode': sceneMode,
'clientOrigin': clientOrigin,
},
extra: _buildExtra(),
fromJson:
(dynamic json) => BaishunLaunchModel.fromJson(
json as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
Future<BaishunRoomStateModel> closeBaishunGame({
required String roomId,
required String gameSessionId,
String reason = 'user_exit',
}) {
return _client.post<BaishunRoomStateModel>(
'/app/game/baishun/close',
data: {
'roomId': roomId,
'gameSessionId': gameSessionId,
'reason': reason,
},
extra: _buildExtra(),
fromJson:
(dynamic json) => BaishunRoomStateModel.fromJson(
json as Map<String, dynamic>? ?? const <String, dynamic>{},
),
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
import 'package:yumi/modules/room_game/data/room_game_api.dart';
class RoomGameRepository {
RoomGameRepository({RoomGameApi? api}) : _api = api ?? RoomGameApi();
final RoomGameApi _api;
Future<List<RoomGameShortcutModel>> fetchShortcutGames({
required String roomId,
}) {
return _api.fetchShortcutGames(roomId: roomId);
}
Future<List<RoomGameListItemModel>> fetchRoomGames({
required String roomId,
String category = '',
}) async {
final response = await _api.fetchRoomGames(roomId: roomId, category: category);
return response.items;
}
Future<BaishunRoomStateModel> fetchRoomState({required String roomId}) {
return _api.fetchRoomState(roomId: roomId);
}
Future<BaishunLaunchModel> launchBaishunGame({
required String roomId,
required String gameId,
int sceneMode = 0,
required String clientOrigin,
}) {
return _api.launchBaishunGame(
roomId: roomId,
gameId: gameId,
sceneMode: sceneMode,
clientOrigin: clientOrigin,
);
}
Future<BaishunRoomStateModel> closeBaishunGame({
required String roomId,
required String gameSessionId,
String reason = 'user_exit',
}) {
return _api.closeBaishunGame(
roomId: roomId,
gameSessionId: gameSessionId,
reason: reason,
);
}
}

View File

@ -0,0 +1,296 @@
import 'dart:async';
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_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();
late final WebViewController _controller;
bool _isLoading = true;
bool _isClosing = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel(
BaishunJsBridge.channelName,
onMessageReceived: _handleBridgeMessage,
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (String _) async {
await _injectBridge();
},
onWebResourceError: (WebResourceError error) {
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.description;
_isLoading = false;
});
},
),
);
unawaited(_loadGameEntry());
}
Future<void> _loadGameEntry() async {
try {
final entryUrl = widget.launchModel.entry.entryUrl.trim();
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) {
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.toString();
_isLoading = false;
});
}
}
Future<void> _injectBridge() async {
try {
await _controller.runJavaScript(BaishunJsBridge.bootstrapScript());
await _controller.runJavaScript(
BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig),
);
} catch (_) {}
}
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
final bridgeMessage = BaishunBridgeMessage.parse(message.message);
switch (bridgeMessage.action) {
case BaishunBridgeActions.getConfig:
await _controller.runJavaScript(
BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig),
);
break;
case BaishunBridgeActions.gameLoaded:
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = null;
});
break;
case BaishunBridgeActions.gameRecharge:
await SCNavigatorUtils.push(context, WalletRoute.recharge);
await _notifyWalletUpdate();
break;
case BaishunBridgeActions.destroy:
await _closeAndExit(reason: 'h5_destroy');
break;
default:
break;
}
}
Future<void> _notifyWalletUpdate() async {
try {
await _controller.runJavaScript(BaishunJsBridge.buildWalletUpdateScript());
} catch (_) {}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
setState(() {
_errorMessage = null;
_isLoading = true;
});
await _loadGameEntry();
}
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();
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
await _closeAndExit();
},
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.w),
topRight: Radius.circular(24.w),
),
child: Container(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight * 0.8,
color: const Color(0xFF081915),
child: Column(
children: [
SizedBox(height: 10.w),
Container(
width: 34.w,
height: 4.w,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(99.w),
),
),
SizedBox(height: 6.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10.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)),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const BaishunLoadingView(message: 'Waiting for gameLoaded...'),
],
),
),
],
),
),
),
);
}
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'),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class BaishunLoadingView extends StatelessWidget {
const BaishunLoadingView({
super.key,
this.message = 'Loading game...',
});
final String message;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.42),
alignment: Alignment.center,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 18.w),
decoration: BoxDecoration(
color: const Color(0xFF102D28).withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(16.w),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CupertinoActivityIndicator(color: Colors.white),
SizedBox(height: 10.w),
Text(
message,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,338 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.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/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/modules/room_game/views/baishun_game_page.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
class RoomGameListSheet extends StatefulWidget {
const RoomGameListSheet({
super.key,
required this.roomContext,
});
final BuildContext roomContext;
@override
State<RoomGameListSheet> createState() => _RoomGameListSheetState();
}
class _RoomGameListSheetState extends State<RoomGameListSheet> {
static const int _itemsPerRow = 5;
final RoomGameRepository _repository = RoomGameRepository();
late Future<List<RoomGameListItemModel>> _gamesFuture;
String _roomId = '';
String? _launchingGameId;
@override
void initState() {
super.initState();
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
_roomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? '';
_gamesFuture = _loadGames();
}
Future<List<RoomGameListItemModel>> _loadGames() async {
if (_roomId.isEmpty) {
return const <RoomGameListItemModel>[];
}
final items = await _repository.fetchRoomGames(
roomId: _roomId,
category: 'CHAT_ROOM',
);
return items;
}
Future<void> _retry() async {
if (!mounted) {
return;
}
setState(() {
_gamesFuture = _loadGames();
});
}
Future<void> _openGame(RoomGameListItemModel game) async {
if (_roomId.isEmpty) {
SCTts.show('roomId is empty');
return;
}
if (!game.isBaishun) {
SCTts.show('Only BAISHUN games are wired for now');
return;
}
setState(() {
_launchingGameId = game.gameId;
});
try {
final launchModel = await _repository.launchBaishunGame(
roomId: _roomId,
gameId: game.gameId,
clientOrigin: Platform.isAndroid ? 'ANDROID' : 'IOS',
);
if (!mounted) {
return;
}
Navigator.of(context).pop();
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!widget.roomContext.mounted) {
return;
}
showBottomInBottomDialog(
widget.roomContext,
BaishunGamePage(
roomId: _roomId,
game: game,
launchModel: launchModel,
),
barrierColor: Colors.black54,
barrierDismissible: false,
);
} catch (error) {
SCTts.show('Launch failed');
} finally {
if (mounted) {
setState(() {
_launchingGameId = null;
});
}
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.w),
topRight: Radius.circular(18.w),
),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight / 3,
color: const Color(0xff09372E).withValues(alpha: 0.88),
child: Column(
children: [
SizedBox(height: 10.w),
Container(
width: 34.w,
height: 4.w,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(99.w),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16.w, 14.w, 16.w, 10.w),
child: Row(
children: [
text(
'Games',
fontSize: 16,
fontWeight: FontWeight.w700,
textColor: Colors.white,
),
const Spacer(),
text(
_roomId.isEmpty ? 'Debug room' : 'Room $_roomId',
fontSize: 11,
textColor: Colors.white70,
),
],
),
),
Expanded(
child: FutureBuilder<List<RoomGameListItemModel>>(
future: _gamesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (snapshot.hasError) {
return _buildErrorState();
}
final items =
snapshot.data ?? const <RoomGameListItemModel>[];
if (items.isEmpty) {
return _buildEmptyState();
}
final rowCount = (items.length / _itemsPerRow).ceil();
return ListView.builder(
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 20.w),
itemCount: rowCount,
itemBuilder: (context, rowIndex) {
return Padding(
padding: EdgeInsets.only(bottom: 12.w),
child: Row(
children: List.generate(_itemsPerRow, (columnIndex) {
final itemIndex =
rowIndex * _itemsPerRow + columnIndex;
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child:
itemIndex < items.length
? _RoomGameTile(
item: items[itemIndex],
loading:
_launchingGameId ==
items[itemIndex].gameId,
onTap: () => _openGame(items[itemIndex]),
)
: const SizedBox.shrink(),
),
);
}),
),
);
},
);
},
),
),
],
),
),
),
),
);
}
Widget _buildErrorState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
text('Game list failed', fontSize: 13, textColor: Colors.white),
SizedBox(height: 8.w),
TextButton(onPressed: _retry, child: const Text('Retry')),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: text(
'No game configured',
fontSize: 13,
textColor: Colors.white70,
),
);
}
}
class _RoomGameTile extends StatelessWidget {
const _RoomGameTile({
required this.item,
required this.onTap,
required this.loading,
});
final RoomGameListItemModel item;
final VoidCallback onTap;
final bool loading;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: loading ? null : onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xff18F2B1).withValues(alpha: 0.26),
Colors.white.withValues(alpha: 0.08),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1.w,
),
),
clipBehavior: Clip.antiAlias,
child: _buildCover(),
),
if (loading)
Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.45),
),
alignment: Alignment.center,
child: SizedBox(
width: 16.w,
height: 16.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
),
],
),
SizedBox(height: 6.w),
text(
item.name,
fontSize: 11,
textColor: Colors.white,
fontWeight: FontWeight.w500,
textAlign: TextAlign.center,
maxLines: 1,
),
],
),
);
}
Widget _buildCover() {
if (item.cover.startsWith('http://') || item.cover.startsWith('https://')) {
return CustomCachedImage(
imageUrl: item.cover,
width: 48.w,
height: 48.w,
fit: BoxFit.cover,
borderRadius: 24.w,
);
}
return Icon(
Icons.sports_esports_outlined,
size: 22.w,
color: Colors.white,
);
}
}

View File

@ -1,13 +1,13 @@
// api.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/modules/auth/login_route.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
@ -20,91 +20,94 @@ export 'package:dio/dio.dart';
_parseAndDecode(String response) => jsonDecode(response);
parseJson(String text) => compute(_parseAndDecode, text);
String _normalizeAuthorizationHeader(Object? authorization) {
if (authorization == null) {
return "";
}
if (authorization is Iterable) {
return authorization.map((value) => value.toString()).join(",");
}
return authorization.toString().trim();
}
bool _shouldLogoutForUnauthorized(DioException e) {
final currentToken = AccountStorage().getToken();
if (currentToken.isEmpty) {
return false;
}
final requestAuthorization = _normalizeAuthorizationHeader(
e.requestOptions.headers["Authorization"],
);
if (requestAuthorization.isEmpty) {
return false;
}
return requestAuthorization == "Bearer $currentToken";
}
Future<bool> _isCurrentSessionStillValid(DioException e) async {
if (!_shouldLogoutForUnauthorized(e)) {
return true;
}
final verificationHeaders = Map<String, dynamic>.from(e.requestOptions.headers)
..remove("Content-Length")
..remove("content-length");
final verificationClient = Dio(
BaseOptions(
baseUrl:
e.requestOptions.baseUrl.isNotEmpty
? e.requestOptions.baseUrl
: SCGlobalConfig.apiHost,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 5),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500,
),
);
try {
final verificationResponse = await verificationClient.get(
"/app/h5/identity",
options: Options(headers: verificationHeaders),
);
final responseData = verificationResponse.data;
if (verificationResponse.statusCode == 401) {
return false;
}
if (responseData is Map<String, dynamic>) {
if (responseData["status"] == true) {
return true;
}
if (responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
return false;
}
}
} on DioException catch (verificationError) {
final responseData = verificationError.response?.data;
if (verificationError.response?.statusCode == 401) {
return false;
}
if (responseData is Map<String, dynamic> &&
responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
return false;
}
} catch (_) {}
//
return true;
}
class BaseNetworkClient {
parseJson(String text) => compute(_parseAndDecode, text);
String _normalizeAuthorizationHeader(Object? authorization) {
if (authorization == null) {
return "";
}
if (authorization is Iterable) {
return authorization.map((value) => value.toString()).join(",");
}
return authorization.toString().trim();
}
bool _shouldLogoutForUnauthorized(DioException e) {
final currentToken = AccountStorage().getToken();
if (currentToken.isEmpty) {
return false;
}
final requestAuthorization = _normalizeAuthorizationHeader(
e.requestOptions.headers["Authorization"],
);
if (requestAuthorization.isEmpty) {
return false;
}
return requestAuthorization == "Bearer $currentToken";
}
Future<bool> _isCurrentSessionStillValid(DioException e) async {
if (!_shouldLogoutForUnauthorized(e)) {
return true;
}
final verificationHeaders =
Map<String, dynamic>.from(e.requestOptions.headers)
..remove("Content-Length")
..remove("content-length");
final verificationClient = Dio(
BaseOptions(
baseUrl:
e.requestOptions.baseUrl.isNotEmpty
? e.requestOptions.baseUrl
: SCGlobalConfig.apiHost,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 5),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500,
),
);
try {
final verificationResponse = await verificationClient.get(
"/app/h5/identity",
options: Options(headers: verificationHeaders),
);
final responseData = verificationResponse.data;
if (verificationResponse.statusCode == 401) {
return false;
}
if (responseData is Map<String, dynamic>) {
if (responseData["status"] == true) {
return true;
}
if (responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
return false;
}
}
} on DioException catch (verificationError) {
final responseData = verificationError.response?.data;
if (verificationError.response?.statusCode == 401) {
return false;
}
if (responseData is Map<String, dynamic> &&
responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
return false;
}
} catch (_) {}
//
return true;
}
class BaseNetworkClient {
final Dio dio;
static const String silentErrorToastKey = 'silentErrorToast';
static const String baseUrlOverrideKey = 'baseUrlOverride';
BaseNetworkClient() : dio = Dio() {
_confD();
@ -112,13 +115,13 @@ class BaseNetworkClient {
}
void _confD() {
dio.transformer = BackgroundTransformer()..jsonDecodeCallback = parseJson;
dio.options = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 30),
contentType: 'application/json; charset=UTF-8',
);
}
dio.transformer = BackgroundTransformer()..jsonDecodeCallback = parseJson;
dio.options = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 30),
contentType: 'application/json; charset=UTF-8',
);
}
void init() {}
@ -126,6 +129,7 @@ class BaseNetworkClient {
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -135,6 +139,7 @@ class BaseNetworkClient {
path,
method: 'GET',
queryParams: queryParams,
extra: extra,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -147,6 +152,7 @@ class BaseNetworkClient {
String path, {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -157,6 +163,7 @@ class BaseNetworkClient {
method: 'PUT',
data: data,
queryParams: queryParams,
extra: extra,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -169,6 +176,7 @@ class BaseNetworkClient {
String path, {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -179,6 +187,7 @@ class BaseNetworkClient {
method: 'POST',
data: data,
queryParams: queryParams,
extra: extra,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -191,6 +200,7 @@ class BaseNetworkClient {
String path, {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
required T Function(dynamic) fromJson,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
@ -201,6 +211,7 @@ class BaseNetworkClient {
method: 'DELETE',
data: data,
queryParams: queryParams,
extra: extra,
fromJson: fromJson,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
@ -214,6 +225,7 @@ class BaseNetworkClient {
required String method,
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? extra,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
@ -224,7 +236,7 @@ class BaseNetworkClient {
path,
data: data,
queryParameters: queryParams,
options: Options(method: method),
options: Options(method: method, extra: extra),
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
@ -255,9 +267,9 @@ class BaseNetworkClient {
error: '业务错误: ${baseResponse.errorMsg}',
);
}
} on DioException catch (e) {
//
throw await _hdlErr(e);
} on DioException catch (e) {
//
throw await _hdlErr(e);
} catch (e) {
SCLoadingManager.hide();
throw Exception('未知错误: $e');
@ -265,11 +277,13 @@ class BaseNetworkClient {
}
//
Future<DioException> _hdlErr(DioException e) async {
SCLoadingManager.hide();
switch (e.type) {
case DioExceptionType.connectionTimeout:
return DioException(requestOptions: e.requestOptions, error: '连接超时');
Future<DioException> _hdlErr(DioException e) async {
SCLoadingManager.hide();
final bool silentErrorToast =
e.requestOptions.extra[BaseNetworkClient.silentErrorToastKey] == true;
switch (e.type) {
case DioExceptionType.connectionTimeout:
return DioException(requestOptions: e.requestOptions, error: '连接超时');
case DioExceptionType.sendTimeout:
return DioException(requestOptions: e.requestOptions, error: '发送超时');
case DioExceptionType.receiveTimeout:
@ -288,24 +302,26 @@ class BaseNetworkClient {
replace: false,
);
}
} else if (errorCode == SCErroCode.authUnauthorized.code) {
//token过期
final shouldLogout =
!SCNavigatorUtils.inLoginPage &&
!await _isCurrentSessionStillValid(e);
final BuildContext? context = navigatorKey.currentContext;
if (context != null && shouldLogout) {
AccountStorage().logout(context);
SCNavigatorUtils.inLoginPage = true;
}
} else {
} else if (errorCode == SCErroCode.authUnauthorized.code) {
//token过期
final shouldLogout =
!SCNavigatorUtils.inLoginPage &&
!await _isCurrentSessionStillValid(e);
final BuildContext? context = navigatorKey.currentContext;
if (context != null && shouldLogout) {
AccountStorage().logout(context);
SCNavigatorUtils.inLoginPage = true;
}
} else {
if (errorMsg.toString().endsWith("balance not made")) {
BuildContext? context = navigatorKey.currentContext;
if (context != null) {
SCRoomUtils.goRecharge(context);
}
} else {
SCTts.show(errorMsg);
if (!silentErrorToast) {
SCTts.show(errorMsg);
}
}
}
return DioException(
@ -313,11 +329,11 @@ class BaseNetworkClient {
response: e.response,
error: '服务器错误: ${e.response?.data["errorCode"]}',
);
case DioExceptionType.cancel:
return DioException(requestOptions: e.requestOptions, error: 'Cancel');
default:
return DioException(
requestOptions: e.requestOptions,
case DioExceptionType.cancel:
return DioException(requestOptions: e.requestOptions, error: 'Cancel');
default:
return DioException(
requestOptions: e.requestOptions,
error: 'Net fail',
);
}

View File

@ -9,7 +9,7 @@ import 'package:yumi/shared/tools/sc_deviceId_utils.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/sc_logger.dart';
NetworkClient get http => _httpInstance;
NetworkClient get http => _httpInstance;
late final NetworkClient _httpInstance = NetworkClient();
@ -271,11 +271,11 @@ const Map<String, String> _localRouteOverrides = {
"/user/vip/ability/update",
};
String _resolveRequestPath(String baseUrl, String path) {
// Prefer the known plain-text route when we have one.
// This avoids environment-specific AES forwarding mismatches at the gateway.
return _localRouteOverrides[path] ?? path;
}
String _resolveRequestPath(String baseUrl, String path) {
// Prefer the known plain-text route when we have one.
// This avoids environment-specific AES forwarding mismatches at the gateway.
return _localRouteOverrides[path] ?? path;
}
class NetworkClient extends BaseNetworkClient {
@override
@ -304,7 +304,15 @@ class ApiInterceptor extends Interceptor {
RequestInterceptorHandler handler,
) async {
// 使 baseUrl
options.baseUrl = SCGlobalConfig.apiHost;
final dynamic baseUrlOverride =
options.extra[BaseNetworkClient.baseUrlOverrideKey];
final String resolvedBaseUrl =
baseUrlOverride is String &&
baseUrlOverride.isNotEmpty &&
baseUrlOverride.startsWith('http')
? baseUrlOverride
: SCGlobalConfig.apiHost;
options.baseUrl = resolvedBaseUrl;
options.path = _resolveRequestPath(options.baseUrl, options.path);
String token = AccountStorage().getToken();
@ -454,24 +462,24 @@ class ResponseData<T> {
}
///
class TimeOutInterceptor extends Interceptor {
static const int globalTimeoutSeconds = 25;
static const String timeoutSecondsKey = "requestTimeoutSeconds";
final Map<String, Timer> _timers = {};
int _resolveTimeoutSeconds(RequestOptions options) {
final dynamic customTimeout = options.extra[timeoutSecondsKey];
if (customTimeout is int && customTimeout > 0) {
return customTimeout;
}
if (customTimeout is String) {
final int? parsed = int.tryParse(customTimeout);
if (parsed != null && parsed > 0) {
return parsed;
}
}
return globalTimeoutSeconds;
}
class TimeOutInterceptor extends Interceptor {
static const int globalTimeoutSeconds = 25;
static const String timeoutSecondsKey = "requestTimeoutSeconds";
final Map<String, Timer> _timers = {};
int _resolveTimeoutSeconds(RequestOptions options) {
final dynamic customTimeout = options.extra[timeoutSecondsKey];
if (customTimeout is int && customTimeout > 0) {
return customTimeout;
}
if (customTimeout is String) {
final int? parsed = int.tryParse(customTimeout);
if (parsed != null && parsed > 0) {
return parsed;
}
}
return globalTimeoutSeconds;
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
@ -490,26 +498,23 @@ class TimeOutInterceptor extends Interceptor {
handler.next(err);
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
//
final cancelToken = options.cancelToken ?? CancelToken();
options.cancelToken = cancelToken;
final String requestKey = options.uri.toString();
final int timeoutSeconds = _resolveTimeoutSeconds(options);
// 15 extra
_timers[requestKey] = Timer(
Duration(seconds: timeoutSeconds),
() {
SCLoadingManager.hide();
if (!cancelToken.isCancelled) {
cancelToken.cancel('请求超时(${timeoutSeconds}秒)');
//
_timers.remove(requestKey);
}
},
);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
//
final cancelToken = options.cancelToken ?? CancelToken();
options.cancelToken = cancelToken;
final String requestKey = options.uri.toString();
final int timeoutSeconds = _resolveTimeoutSeconds(options);
// 15 extra
_timers[requestKey] = Timer(Duration(seconds: timeoutSeconds), () {
SCLoadingManager.hide();
if (!cancelToken.isCancelled) {
cancelToken.cancel('请求超时(${timeoutSeconds}秒)');
//
_timers.remove(requestKey);
}
});
handler.next(options);
}

View File

@ -1,10 +1,8 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
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/components/text/sc_text.dart';
class RoomGameEntryButton extends StatelessWidget {
const RoomGameEntryButton({super.key});
@ -15,7 +13,7 @@ class RoomGameEntryButton extends StatelessWidget {
onTap: () {
showBottomInBottomDialog(
context,
const RoomGameBottomSheet(),
RoomGameBottomSheet(roomContext: context),
barrierColor: Colors.black54,
);
},
@ -39,8 +37,6 @@ class RoomGameEntryButton extends StatelessWidget {
],
),
child: Text(
// `🎮`
// UI
'🎮',
style: TextStyle(fontSize: 22.sp),
),
@ -50,171 +46,15 @@ class RoomGameEntryButton extends StatelessWidget {
}
class RoomGameBottomSheet extends StatelessWidget {
const RoomGameBottomSheet({super.key});
const RoomGameBottomSheet({
super.key,
required this.roomContext,
});
static const int _itemsPerRow = 5;
///
static const List<_RoomGameItem> _mockGames = [
_RoomGameItem(name: 'Ludo', icon: Icons.casino_outlined),
_RoomGameItem(name: 'Dice', icon: Icons.casino),
_RoomGameItem(name: 'UNO', icon: Icons.style_outlined),
_RoomGameItem(name: 'Quiz', icon: Icons.psychology_outlined),
_RoomGameItem(name: 'Race', icon: Icons.sports_motorsports_outlined),
_RoomGameItem(name: 'Poker', icon: Icons.style),
_RoomGameItem(name: 'Lucky', icon: Icons.auto_awesome_outlined),
_RoomGameItem(name: 'Bingo', icon: Icons.grid_on_outlined),
_RoomGameItem(name: '2048', icon: Icons.apps_outlined),
_RoomGameItem(name: 'Chess', icon: Icons.extension_outlined),
_RoomGameItem(name: 'Keno', icon: Icons.confirmation_number_outlined),
_RoomGameItem(name: 'Cards', icon: Icons.view_carousel_outlined),
_RoomGameItem(name: 'Darts', icon: Icons.gps_fixed),
_RoomGameItem(name: 'Puzzle', icon: Icons.interests_outlined),
_RoomGameItem(name: 'Slots', icon: Icons.local_activity_outlined),
_RoomGameItem(name: 'Spin', icon: Icons.blur_circular_outlined),
_RoomGameItem(name: 'Snake', icon: Icons.route_outlined),
_RoomGameItem(name: 'Party', icon: Icons.celebration_outlined),
_RoomGameItem(name: 'Hero', icon: Icons.shield_outlined),
_RoomGameItem(name: 'Arena', icon: Icons.sports_esports_outlined),
];
final BuildContext roomContext;
@override
Widget build(BuildContext context) {
final rowCount = (_mockGames.length / _itemsPerRow).ceil();
return SafeArea(
top: false,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.w),
topRight: Radius.circular(18.w),
),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight / 3,
color: const Color(0xff09372E).withValues(alpha: 0.88),
child: Column(
children: [
SizedBox(height: 10.w),
Container(
width: 34.w,
height: 4.w,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(99.w),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16.w, 14.w, 16.w, 10.w),
child: Row(
children: [
text(
'Games',
fontSize: 16,
fontWeight: FontWeight.w700,
textColor: Colors.white,
),
const Spacer(),
text(
'Static mock data',
fontSize: 11,
textColor: Colors.white70,
),
],
),
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 20.w),
itemCount: rowCount,
itemBuilder: (context, rowIndex) {
return Padding(
padding: EdgeInsets.only(bottom: 12.w),
child: Row(
children: List.generate(_itemsPerRow, (columnIndex) {
final itemIndex =
rowIndex * _itemsPerRow + columnIndex;
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child:
itemIndex < _mockGames.length
? _RoomGameTile(
item: _mockGames[itemIndex],
)
: const SizedBox.shrink(),
),
);
}),
),
);
},
),
),
],
),
),
),
),
);
return RoomGameListSheet(roomContext: roomContext);
}
}
class _RoomGameTile extends StatelessWidget {
const _RoomGameTile({required this.item});
final _RoomGameItem item;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xff18F2B1).withValues(alpha: 0.26),
Colors.white.withValues(alpha: 0.08),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1.w,
),
),
alignment: Alignment.center,
child: Icon(
// Icon UI /
item.icon,
size: 22.w,
color: Colors.white,
),
),
SizedBox(height: 6.w),
text(
item.name,
fontSize: 11,
textColor: Colors.white,
fontWeight: FontWeight.w500,
textAlign: TextAlign.center,
maxLines: 1,
),
],
);
}
}
class _RoomGameItem {
const _RoomGameItem({required this.name, required this.icon});
final String name;
final IconData icon;
}

View File

@ -534,13 +534,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_foreground_task:
dependency: "direct main"
description:
path: "local_packages/flutter_foreground_task-9.1.0"
relative: true
source: path
version: "9.1.0"
flutter_image_compress:
dependency: "direct main"
description:
@ -772,14 +765,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
id3:
dependency: transitive
description:
name: id3
sha256: "24176a6e08db6297c8450079e94569cd8387f913c817e5e3d862be7dc191e0b8"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
image:
dependency: transitive
description:
@ -791,10 +776,9 @@ packages:
image_cropper:
dependency: "direct main"
description:
name: image_cropper
sha256: f4bad5ed2dfff5a7ce0dfbad545b46a945c702bb6182a921488ef01ba7693111
url: "https://pub.dev"
source: hosted
path: "local_packages/image_cropper-5.0.1-patched"
relative: true
source: path
version: "5.0.1"
image_cropper_for_web:
dependency: transitive
@ -996,14 +980,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
loading_indicator_view_plus:
dependency: "direct main"
description:
name: loading_indicator_view_plus
sha256: "23ad380dd677e8e0182f679f29e1221269ad66b9a1a2d70b19e716ee4723c99e"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
logging:
dependency: transitive
description:
@ -1092,44 +1068,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
on_audio_query:
dependency: "direct main"
description:
path: "local_packages/on_audio_query-2.9.0"
relative: true
source: path
version: "2.9.0"
on_audio_query_android:
dependency: transitive
description:
path: "local_packages/on_audio_query_android-1.1.0"
relative: true
source: path
version: "1.1.0"
on_audio_query_ios:
dependency: transitive
description:
name: on_audio_query_ios
sha256: "9b3efa39a656fa3720980e3c6a1f55b7257d0032a45ffeb3f70eaa2c7f10f929"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
on_audio_query_platform_interface:
dependency: transitive
description:
name: on_audio_query_platform_interface
sha256: c23e019a31bd0774828476e428fd33b0dd1d82c9d4791dba80429358fc65dcd3
url: "https://pub.dev"
source: hosted
version: "1.7.0"
on_audio_query_web:
dependency: transitive
description:
name: on_audio_query_web
sha256: "990efa52d879e6caffa97f24b34acd9caa1ce2c4c4cb873fe5a899a9b1af02c7"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
package_config:
dependency: transitive
description:
@ -1455,14 +1393,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
social_sharing_plus:
dependency: "direct main"
description:
name: social_sharing_plus
sha256: d0bd9dc3358cf66a3aac7cce549902360f30bccf44cf6090a708d97cc54ca854
url: "https://pub.dev"
source: hosted
version: "1.2.3"
source_span:
dependency: transitive
description:

View File

@ -148,12 +148,13 @@ flutter:
# - family: MyCustomFont
# fonts:
# - asset: fonts/AAA-bold.ttf
assets:
# 共享资源
# 应用资源路径
- assets/
- assets/l10n/
- sc_images/splash/
assets:
# 共享资源
# 应用资源路径
- assets/
- assets/debug/
- assets/l10n/
- sc_images/splash/
- sc_images/login/
- sc_images/general/
- sc_images/index/