diff --git a/android/build.gradle.kts b/android/build.gradle.kts index aef8ba5..77e42ea 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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("clean") { delete(rootProject.layout.buildDirectory) -} \ No newline at end of file +} diff --git a/android/gradle.properties b/android/gradle.properties index b7cda7b..e99b4b7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/assets/debug/baishun_mock/index.html b/assets/debug/baishun_mock/index.html new file mode 100644 index 0000000..4167b8d --- /dev/null +++ b/assets/debug/baishun_mock/index.html @@ -0,0 +1,131 @@ + + + + + + BAISHUN Mock + + + +
+

BAISHUN Mock H5

+

+ This page is only for Flutter bridge bring-up. It exercises + getConfig, gameLoaded, gameRecharge, destroy and walletUpdate. +

+
+
+ + + + +
+

+      
+
+ + + diff --git a/design_assets/logo/yumi_chat_party_original_logo_1024.png b/design_assets/logo/yumi_chat_party_original_logo_1024.png new file mode 100644 index 0000000..879503c Binary files /dev/null and b/design_assets/logo/yumi_chat_party_original_logo_1024.png differ diff --git a/design_assets/logo/yumi_chat_party_original_logo_512.png b/design_assets/logo/yumi_chat_party_original_logo_512.png new file mode 100644 index 0000000..c6247de Binary files /dev/null and b/design_assets/logo/yumi_chat_party_original_logo_512.png differ diff --git a/lib/modules/room_game/bridge/baishun_js_bridge.dart b/lib/modules/room_game/bridge/baishun_js_bridge.dart new file mode 100644 index 0000000..a5e3f8e --- /dev/null +++ b/lib/modules/room_game/bridge/baishun_js_bridge.dart @@ -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 {}, + }); + + final String action; + final Map payload; + + static BaishunBridgeMessage parse(String rawMessage) { + try { + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is Map) { + final rawPayload = decoded['payload']; + return BaishunBridgeMessage( + action: (decoded['action'] ?? '').toString(), + payload: + rawPayload is Map + ? rawPayload + : const {}, + ); + } + } 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? payload}) { + final safePayload = jsonEncode( + payload ?? + { + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }, + ); + return ''' + (function() { + const payload = $safePayload; + if (window.baishunChannel && typeof window.baishunChannel.walletUpdate === 'function') { + window.baishunChannel.walletUpdate(payload); + } + })(); + '''; + } +} diff --git a/lib/modules/room_game/data/models/room_game_models.dart b/lib/modules/room_game/data/models/room_game_models.dart new file mode 100644 index 0000000..4d75332 --- /dev/null +++ b/lib/modules/room_game/data/models/room_game_models.dart @@ -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 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 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 items; + + factory RoomGameListResponseModel.fromJson(Map json) { + final list = json['items'] as List? ?? const []; + return RoomGameListResponseModel( + items: + list + .whereType>() + .map(RoomGameListItemModel.fromJson) + .toList(), + ); + } +} + +class BaishunGameConfigModel { + const BaishunGameConfigModel({ + required this.sceneMode, + required this.currencyIcon, + }); + + final int sceneMode; + final String currencyIcon; + + factory BaishunGameConfigModel.fromJson(Map json) { + return BaishunGameConfigModel( + sceneMode: _asInt(json['sceneMode']), + currencyIcon: _asString(json['currencyIcon']), + ); + } + + Map 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 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? ?? + const {}, + ), + ); + } + + Map 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 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 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 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? ?? const {}, + ), + bridgeConfig: BaishunBridgeConfigModel.fromJson( + json['bridgeConfig'] as Map? ?? + const {}, + ), + roomState: BaishunRoomStateModel.fromJson( + json['roomState'] as Map? ?? const {}, + ), + ); + } +} + +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; +} diff --git a/lib/modules/room_game/data/room_game_api.dart b/lib/modules/room_game/data/room_game_api.dart new file mode 100644 index 0000000..68aef34 --- /dev/null +++ b/lib/modules/room_game/data/room_game_api.dart @@ -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? _buildExtra([Map? extra]) { + final merged = {...?extra}; + final trimmedGameApiHost = _gameApiHost.trim(); + if (trimmedGameApiHost.isNotEmpty) { + merged[BaseNetworkClient.baseUrlOverrideKey] = trimmedGameApiHost; + } + return merged.isEmpty ? null : merged; + } + + Future> fetchShortcutGames({ + required String roomId, + }) { + return _client.get>( + '/app/game/room/shortcut', + queryParams: {'roomId': roomId}, + extra: _buildExtra(), + fromJson: (dynamic json) { + final list = json as List? ?? const []; + return list + .whereType>() + .map(RoomGameShortcutModel.fromJson) + .toList(); + }, + ); + } + + Future fetchRoomGames({ + required String roomId, + String category = '', + }) { + final query = {'roomId': roomId}; + if (category.isNotEmpty) { + query['category'] = category; + } + return _client.get( + '/app/game/room/list', + queryParams: query, + extra: _buildExtra(const { + BaseNetworkClient.silentErrorToastKey: true, + }), + fromJson: + (dynamic json) => RoomGameListResponseModel.fromJson( + json as Map? ?? const {}, + ), + ); + } + + Future fetchRoomState({required String roomId}) { + return _client.get( + '/app/game/baishun/state', + queryParams: {'roomId': roomId}, + extra: _buildExtra(), + fromJson: + (dynamic json) => BaishunRoomStateModel.fromJson( + json as Map? ?? const {}, + ), + ); + } + + Future launchBaishunGame({ + required String roomId, + required String gameId, + required int sceneMode, + required String clientOrigin, + }) { + return _client.post( + '/app/game/baishun/launch', + data: { + 'roomId': roomId, + 'gameId': gameId, + 'sceneMode': sceneMode, + 'clientOrigin': clientOrigin, + }, + extra: _buildExtra(), + fromJson: + (dynamic json) => BaishunLaunchModel.fromJson( + json as Map? ?? const {}, + ), + ); + } + + Future closeBaishunGame({ + required String roomId, + required String gameSessionId, + String reason = 'user_exit', + }) { + return _client.post( + '/app/game/baishun/close', + data: { + 'roomId': roomId, + 'gameSessionId': gameSessionId, + 'reason': reason, + }, + extra: _buildExtra(), + fromJson: + (dynamic json) => BaishunRoomStateModel.fromJson( + json as Map? ?? const {}, + ), + ); + } +} diff --git a/lib/modules/room_game/data/room_game_repository.dart b/lib/modules/room_game/data/room_game_repository.dart new file mode 100644 index 0000000..4c8cb6c --- /dev/null +++ b/lib/modules/room_game/data/room_game_repository.dart @@ -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> fetchShortcutGames({ + required String roomId, + }) { + return _api.fetchShortcutGames(roomId: roomId); + } + + Future> fetchRoomGames({ + required String roomId, + String category = '', + }) async { + final response = await _api.fetchRoomGames(roomId: roomId, category: category); + return response.items; + } + + Future fetchRoomState({required String roomId}) { + return _api.fetchRoomState(roomId: roomId); + } + + Future 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 closeBaishunGame({ + required String roomId, + required String gameSessionId, + String reason = 'user_exit', + }) { + return _api.closeBaishunGame( + roomId: roomId, + gameSessionId: gameSessionId, + reason: reason, + ); + } +} diff --git a/lib/modules/room_game/views/baishun_game_page.dart b/lib/modules/room_game/views/baishun_game_page.dart new file mode 100644 index 0000000..75d4211 --- /dev/null +++ b/lib/modules/room_game/views/baishun_game_page.dart @@ -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 createState() => _BaishunGamePageState(); +} + +class _BaishunGamePageState extends State { + 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 _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 _injectBridge() async { + try { + await _controller.runJavaScript(BaishunJsBridge.bootstrapScript()); + await _controller.runJavaScript( + BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig), + ); + } catch (_) {} + } + + Future _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 _notifyWalletUpdate() async { + try { + await _controller.runJavaScript(BaishunJsBridge.buildWalletUpdateScript()); + } catch (_) {} + } + + Future _reload() async { + if (!mounted) { + return; + } + setState(() { + _errorMessage = null; + _isLoading = true; + }); + await _loadGameEntry(); + } + + Future _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'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/room_game/views/baishun_loading_view.dart b/lib/modules/room_game/views/baishun_loading_view.dart new file mode 100644 index 0000000..3363bcb --- /dev/null +++ b/lib/modules/room_game/views/baishun_loading_view.dart @@ -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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/room_game/views/room_game_list_sheet.dart b/lib/modules/room_game/views/room_game_list_sheet.dart new file mode 100644 index 0000000..7fa4dcb --- /dev/null +++ b/lib/modules/room_game/views/room_game_list_sheet.dart @@ -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 createState() => _RoomGameListSheetState(); +} + +class _RoomGameListSheetState extends State { + static const int _itemsPerRow = 5; + + final RoomGameRepository _repository = RoomGameRepository(); + late Future> _gamesFuture; + + String _roomId = ''; + String? _launchingGameId; + + @override + void initState() { + super.initState(); + final rtcProvider = Provider.of(context, listen: false); + _roomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? ''; + _gamesFuture = _loadGames(); + } + + Future> _loadGames() async { + if (_roomId.isEmpty) { + return const []; + } + + final items = await _repository.fetchRoomGames( + roomId: _roomId, + category: 'CHAT_ROOM', + ); + return items; + } + + Future _retry() async { + if (!mounted) { + return; + } + setState(() { + _gamesFuture = _loadGames(); + }); + } + + Future _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.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>( + 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 []; + 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, + ); + } +} diff --git a/lib/shared/data_sources/sources/remote/net/api.dart b/lib/shared/data_sources/sources/remote/net/api.dart index b25542a..cee2079 100644 --- a/lib/shared/data_sources/sources/remote/net/api.dart +++ b/lib/shared/data_sources/sources/remote/net/api.dart @@ -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 _isCurrentSessionStillValid(DioException e) async { - if (!_shouldLogoutForUnauthorized(e)) { - return true; - } - - final verificationHeaders = Map.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) { - 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 && - 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 _isCurrentSessionStillValid(DioException e) async { + if (!_shouldLogoutForUnauthorized(e)) { + return true; + } + + final verificationHeaders = + Map.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) { + 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 && + 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 get( String path, { Map? queryParams, + Map? 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? queryParams, + Map? 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? queryParams, + Map? 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? queryParams, + Map? 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? queryParams, + Map? 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 _hdlErr(DioException e) async { - SCLoadingManager.hide(); - switch (e.type) { - case DioExceptionType.connectionTimeout: - return DioException(requestOptions: e.requestOptions, error: '连接超时'); + Future _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', ); } diff --git a/lib/shared/data_sources/sources/remote/net/network_client.dart b/lib/shared/data_sources/sources/remote/net/network_client.dart index d620955..56bbb94 100644 --- a/lib/shared/data_sources/sources/remote/net/network_client.dart +++ b/lib/shared/data_sources/sources/remote/net/network_client.dart @@ -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 _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 { } /// 超时拦截器 -class TimeOutInterceptor extends Interceptor { - static const int globalTimeoutSeconds = 25; - static const String timeoutSecondsKey = "requestTimeoutSeconds"; - final Map _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 _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); } diff --git a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart index 0eae7cc..661f20f 100644 --- a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart +++ b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart @@ -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; -} diff --git a/pubspec.lock b/pubspec.lock index a83b54f..31f9492 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index b627eb3..69751e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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/