Files
bansonic_beta_main/fdGamer/lib/main.dart
T

1125 lines
37 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_windows/webview_windows.dart';
import 'package:window_manager/window_manager.dart';
const int kControlPort = 18961;
const Size kWindowSize = Size(1280, 820);
const String kWindowTitle = '浮动小游戏';
const String kDefaultServerBase = 'http://127.0.0.1:8080';
const String kAppFontFamily = 'Bansonic';
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
final config = _parseLaunchConfig(args);
const windowOptions = WindowOptions(
size: kWindowSize,
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.normal,
title: kWindowTitle,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.setTitle(kWindowTitle);
await windowManager.show();
await windowManager.focus();
});
runApp(FloatingMiniGamesApp(config: config));
}
AppLaunchConfig _parseLaunchConfig(List<String> args) {
String serverBase = kDefaultServerBase;
String? initialUrl;
for (final rawArg in args) {
final arg = rawArg.trim();
if (arg.isEmpty) {
continue;
}
if (arg.startsWith('--server-base=')) {
final value = arg.substring('--server-base='.length).trim();
if (value.isNotEmpty) {
serverBase = _normalizeServerBase(value);
}
continue;
}
if (arg.startsWith('--open-url=')) {
final value = arg.substring('--open-url='.length).trim();
if (value.isNotEmpty) {
initialUrl = _normalizeOpenTarget(value);
}
continue;
}
initialUrl = _normalizeOpenTarget(arg);
}
return AppLaunchConfig(
serverBase: _normalizeServerBase(serverBase),
initialUrl: initialUrl,
);
}
String _normalizeServerBase(String raw) {
final uri = _parseServerBaseUri(raw);
return '${uri.scheme}://${uri.authority}';
}
Uri _parseServerBaseUri(String raw) {
final trimmed = raw.trim().replaceAll('"', '').replaceAll("'", '');
if (trimmed.isEmpty) {
return Uri.parse(kDefaultServerBase);
}
Uri? parsed = Uri.tryParse(trimmed);
if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) {
return parsed.replace(path: '', query: '', fragment: '');
}
parsed = Uri.tryParse('http://$trimmed');
if (parsed != null && parsed.host.isNotEmpty) {
return parsed.replace(path: '', query: '', fragment: '');
}
return Uri.parse(kDefaultServerBase);
}
Uri _buildServerUri(String serverBase, String path) {
final base = _parseServerBaseUri(serverBase);
final normalizedPath = path.startsWith('/') ? path : '/$path';
return base.replace(path: normalizedPath, query: null, fragment: null);
}
String _normalizeOpenTarget(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return trimmed;
}
final uri = Uri.tryParse(trimmed);
if (uri != null && uri.hasScheme) {
return uri.toString();
}
final file = File(trimmed);
if (file.existsSync()) {
return file.absolute.uri.toString();
}
if (trimmed.startsWith(r'\\') || RegExp(r'^[A-Za-z]:[\\/]').hasMatch(trimmed)) {
return File(trimmed).absolute.uri.toString();
}
return trimmed;
}
class AppLaunchConfig {
const AppLaunchConfig({required this.serverBase, this.initialUrl});
final String serverBase;
final String? initialUrl;
}
class MiniGameRecord {
const MiniGameRecord({
required this.id,
required this.name,
required this.gameType,
required this.publisher,
required this.publishDate,
required this.description,
required this.playUrl,
});
final int id;
final String name;
final String gameType;
final String publisher;
final String publishDate;
final String description;
final String playUrl;
factory MiniGameRecord.fromJson(Map<String, dynamic> json, String serverBase) {
final rawPlayUrl = (json['play_url'] ?? '').toString().trim();
return MiniGameRecord(
id: int.tryParse((json['id'] ?? '0').toString()) ?? 0,
name: (json['name'] ?? '').toString(),
gameType: ((json['game_type'] ?? 'official').toString().trim().toLowerCase() == 'community') ? 'community' : 'official',
publisher: (json['publisher'] ?? '').toString(),
publishDate: (json['publish_date'] ?? '').toString(),
description: (json['description'] ?? '').toString(),
playUrl: _absolutizePlayUrl(serverBase, rawPlayUrl),
);
}
}
String _absolutizePlayUrl(String serverBase, String playUrl) {
if (playUrl.isEmpty) {
return playUrl;
}
final uri = Uri.tryParse(playUrl);
if (uri != null && uri.hasScheme) {
return uri.toString();
}
final slashNormalized = playUrl.startsWith('/') ? playUrl : '/$playUrl';
return '${serverBase.replaceAll(RegExp(r'/+$'), '')}$slashNormalized';
}
class FloatingMiniGamesApp extends StatelessWidget {
const FloatingMiniGamesApp({super.key, required this.config});
final AppLaunchConfig config;
@override
Widget build(BuildContext context) {
final baseTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: kAppFontFamily,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF59C6FF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0B0D14),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
);
return MaterialApp(
title: kWindowTitle,
debugShowCheckedModeBanner: false,
theme: baseTheme,
home: MiniGamesShell(config: config),
);
}
}
class MiniGamesShell extends StatefulWidget {
const MiniGamesShell({super.key, required this.config});
final AppLaunchConfig config;
@override
State<MiniGamesShell> createState() => _MiniGamesShellState();
}
class _MiniGamesShellState extends State<MiniGamesShell> {
final WebviewController _controller = WebviewController();
final TextEditingController _searchController = TextEditingController();
HttpServer? _controlServer;
StreamSubscription<String>? _titleSubscription;
StreamSubscription<LoadingState>? _loadingSubscription;
StreamSubscription<String>? _urlSubscription;
StreamSubscription<HistoryChanged>? _historySubscription;
StreamSubscription<WebErrorStatus>? _loadErrorSubscription;
StreamSubscription<dynamic>? _webMessageSubscription;
Timer? _latencyTimer;
bool _initialized = false;
bool _isLoading = false;
bool _showHome = true;
bool _gamesLoading = true;
bool _canGoBack = false;
bool _canGoForward = false;
bool _disposed = false;
String _statusText = '正在初始化浏览器';
String _currentLabel = '小游戏大厅';
String _currentUrl = '';
String _homeError = '';
String _searchQuery = '';
String _typeFilter = 'all';
String? _queuedOpenUrl;
int? _latencyMs;
List<MiniGameRecord> _games = const [];
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
_searchController.addListener(() {
if (!mounted) {
return;
}
setState(() {
_searchQuery = _searchController.text.trim().toLowerCase();
});
});
await _controller.initialize();
await _controller.setBackgroundColor(const Color(0xFF0B0D14));
await _controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny);
await _installBrowserGuards();
_bindControllerStreams();
await _startControlServer();
await _refreshLatency();
_latencyTimer = Timer.periodic(const Duration(seconds: 15), (_) => _refreshLatency());
await _loadMiniGames();
if (widget.config.initialUrl != null && widget.config.initialUrl!.isNotEmpty) {
await _openUrl(widget.config.initialUrl!, label: '外部页面');
}
if (!mounted) {
return;
}
setState(() {
_initialized = true;
_statusText = '网页已加载';
});
if (_queuedOpenUrl != null && _queuedOpenUrl!.isNotEmpty) {
final pending = _queuedOpenUrl!;
_queuedOpenUrl = null;
await _openUrl(pending, label: '外部页面');
}
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_homeError = '初始化失败:$error';
_gamesLoading = false;
_statusText = '初始化失败';
});
}
}
void _bindControllerStreams() {
_loadingSubscription = _controller.loadingState.listen((state) {
if (!mounted) {
return;
}
setState(() {
_isLoading = state != LoadingState.navigationCompleted;
if (!_showHome) {
_statusText = _isLoading ? '页面加载中' : '页面已就绪';
}
});
});
_urlSubscription = _controller.url.listen((url) {
if (!mounted) {
return;
}
setState(() {
_currentUrl = url;
});
});
_titleSubscription = _controller.title.listen((title) {
if (!mounted || title.trim().isEmpty) {
return;
}
setState(() {
if (!_showHome) {
_currentLabel = title.trim();
}
});
});
_historySubscription = _controller.historyChanged.listen((history) {
if (!mounted) {
return;
}
setState(() {
_canGoBack = history.canGoBack;
_canGoForward = history.canGoForward;
});
});
_loadErrorSubscription = _controller.onLoadError.listen((status) {
_showNotice('页面加载失败:${_describeWebError(status)}');
});
_webMessageSubscription = _controller.webMessage.listen((dynamic message) {
if (message is Map) {
_handleBrowserMessage(message.map((key, value) => MapEntry(key.toString(), value)));
}
});
}
Future<void> _installBrowserGuards() async {
await _controller.addScriptToExecuteOnDocumentCreated('''
(() => {
const send = (payload) => {
try {
if (window.chrome && window.chrome.webview) {
window.chrome.webview.postMessage(JSON.stringify(payload));
}
} catch (_) {}
};
const blockKeys = (event) => {
const key = (event.key || '').toLowerCase();
const isF12 = key === 'f12';
const isDevToolsCombo = event.ctrlKey && event.shiftKey && ['i', 'j', 'c'].includes(key);
const isSourceCombo = event.ctrlKey && key === 'u';
if (isF12 || isDevToolsCombo || isSourceCombo) {
event.preventDefault();
event.stopPropagation();
send({ kind: 'blocked-shortcut', key: key });
return false;
}
return true;
};
document.addEventListener('keydown', blockKeys, true);
document.addEventListener('contextmenu', (event) => event.preventDefault(), true);
document.addEventListener('wheel', (event) => {
if (event.ctrlKey) {
event.preventDefault();
}
}, { passive: false, capture: true });
window.addEventListener('error', (event) => {
send({
kind: 'js-error',
message: event.message || '脚本异常',
source: event.filename || '',
line: event.lineno || 0,
column: event.colno || 0
});
}, true);
window.addEventListener('unhandledrejection', (event) => {
const reason = event && event.reason ? (event.reason.message || String(event.reason)) : 'Promise 未处理异常';
send({ kind: 'promise-error', message: reason });
});
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
if (originalFetch) {
window.fetch = async (...args) => {
try {
const response = await originalFetch(...args);
if (!response.ok) {
send({ kind: 'http-error', url: response.url || '', status: response.status, statusText: response.statusText || '' });
}
return response;
} catch (error) {
send({ kind: 'request-error', url: String(args[0] || ''), message: error && error.message ? error.message : String(error) });
throw error;
}
};
}
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this.__fdRequestedUrl = url;
return originalOpen.call(this, method, url, ...rest);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('error', () => {
send({ kind: 'request-error', url: String(this.__fdRequestedUrl || ''), message: 'XMLHttpRequest 失败' });
});
this.addEventListener('loadend', () => {
if (this.status >= 400) {
send({ kind: 'http-error', url: String(this.responseURL || this.__fdRequestedUrl || ''), status: this.status, statusText: this.statusText || '' });
}
});
return originalSend.apply(this, args);
};
})();
''');
}
Future<void> _startControlServer() async {
_controlServer = await HttpServer.bind(InternetAddress.loopbackIPv4, kControlPort);
unawaited(_listenForControlRequests(_controlServer!));
}
Future<void> _listenForControlRequests(HttpServer server) async {
await for (final request in server) {
try {
final path = request.uri.path;
if (request.method == 'GET' && path == '/health') {
await _writeJson(request.response, HttpStatus.ok, <String, Object?>{
'ok': true,
'initialized': _initialized,
'showHome': _showHome,
'status': _statusText,
'latencyMs': _latencyMs,
'currentUrl': _currentUrl,
'serverBase': widget.config.serverBase,
});
continue;
}
if (path == '/open' && (request.method == 'POST' || request.method == 'GET')) {
final targetUrl = await _extractUrl(request);
if (targetUrl == null || targetUrl.trim().isEmpty) {
await _writeJson(request.response, HttpStatus.badRequest, {'ok': false, 'message': 'missing url'});
continue;
}
final normalized = _normalizeOpenTarget(targetUrl);
if (!_initialized) {
_queuedOpenUrl = normalized;
await _writeJson(request.response, HttpStatus.accepted, {'ok': true, 'queued': true, 'url': normalized});
continue;
}
await _openUrl(normalized, label: '外部页面');
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'queued': false, 'url': normalized});
continue;
}
if (path == '/home' && (request.method == 'POST' || request.method == 'GET')) {
await _goHome();
await _writeJson(request.response, HttpStatus.ok, {'ok': true});
continue;
}
if (path == '/back' && (request.method == 'POST' || request.method == 'GET')) {
if (_canGoBack && !_showHome) {
await _controller.goBack();
}
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoBack': _canGoBack});
continue;
}
if (path == '/forward' && (request.method == 'POST' || request.method == 'GET')) {
if (_canGoForward && !_showHome) {
await _controller.goForward();
}
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoForward': _canGoForward});
continue;
}
if (path == '/refresh' && (request.method == 'POST' || request.method == 'GET')) {
await _refreshCurrent();
await _writeJson(request.response, HttpStatus.ok, {'ok': true});
continue;
}
await _writeJson(request.response, HttpStatus.notFound, {'ok': false, 'message': 'not found'});
} catch (error) {
await _writeJson(request.response, HttpStatus.internalServerError, {'ok': false, 'message': error.toString()});
}
}
}
Future<String?> _extractUrl(HttpRequest request) async {
if (request.method == 'GET') {
return request.uri.queryParameters['url'];
}
final body = await utf8.decoder.bind(request).join();
if (body.trim().isEmpty) {
return null;
}
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded['url']?.toString();
}
if (decoded is Map) {
return decoded['url']?.toString();
}
} catch (_) {
return body.trim();
}
return null;
}
Future<void> _writeJson(HttpResponse response, int statusCode, Map<String, Object?> payload) async {
response.statusCode = statusCode;
response.headers.contentType = ContentType.json;
response.write(jsonEncode(payload));
await response.close();
}
Future<void> _refreshLatency() async {
final stopwatch = Stopwatch()..start();
try {
final uri = _buildServerUri(widget.config.serverBase, '/api/ping');
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
try {
final request = await client.getUrl(uri);
final response = await request.close().timeout(const Duration(seconds: 5));
await response.drain<void>();
stopwatch.stop();
if (mounted) {
setState(() {
_latencyMs = stopwatch.elapsedMilliseconds;
});
}
} finally {
client.close(force: true);
}
} catch (_) {
if (mounted) {
setState(() {
_latencyMs = null;
});
}
}
}
Future<void> _loadMiniGames() async {
if (mounted) {
setState(() {
_gamesLoading = true;
_homeError = '';
});
}
try {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
try {
final candidates = <String>[
_buildServerUri(widget.config.serverBase, '/api/mini-games').toString(),
_buildServerUri(widget.config.serverBase, '/api/minigames').toString(),
_buildServerUri(widget.config.serverBase, '/api/easy-games').toString(),
_buildServerUri(widget.config.serverBase, '/mini-games/list').toString(),
_buildServerUri(widget.config.serverBase, '/minigames').toString(),
_buildServerUri(widget.config.serverBase, '/easy-games/list').toString(),
_buildServerUri(widget.config.serverBase, '/easygames').toString(),
_buildServerUri(widget.config.serverBase, '/admin/api/mini-games').toString(),
];
HttpException? lastHttpError;
String body = '';
int statusCode = 0;
Map<String, dynamic>? decoded;
for (final candidate in candidates) {
final request = await client.getUrl(Uri.parse(candidate));
final response = await request.close().timeout(const Duration(seconds: 8));
body = await utf8.decoder.bind(response).join();
statusCode = response.statusCode;
if (response.statusCode == HttpStatus.ok) {
final rawDecoded = jsonDecode(body);
if (rawDecoded is Map<String, dynamic>) {
decoded = rawDecoded;
break;
}
if (rawDecoded is Map) {
decoded = rawDecoded.cast<String, dynamic>();
break;
}
throw HttpException('HTTP 200: invalid payload');
}
lastHttpError = HttpException('HTTP ${response.statusCode}: $body');
if (response.statusCode != HttpStatus.notFound) {
break;
}
}
if (decoded == null) {
throw lastHttpError ?? HttpException('HTTP $statusCode: $body');
}
final list = decoded['games'];
final items = <MiniGameRecord>[];
if (list is List) {
for (final entry in list) {
if (entry is Map) {
items.add(MiniGameRecord.fromJson(entry.cast<String, dynamic>(), widget.config.serverBase));
}
}
}
if (!mounted) {
return;
}
setState(() {
_games = items;
_gamesLoading = false;
_statusText = '小游戏大厅已同步';
});
} finally {
client.close(force: true);
}
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_gamesLoading = false;
_homeError = '无法拉取小游戏列表:$error';
_statusText = '无法访问小游戏服务';
});
}
}
Future<void> _openGame(MiniGameRecord game) async {
await _openUrl(game.playUrl, label: game.name);
}
Future<void> _openUrl(String rawUrl, {String? label}) async {
final normalized = _normalizeOpenTarget(rawUrl);
if (!_initialized) {
_queuedOpenUrl = normalized;
return;
}
await _controller.loadUrl(normalized);
if (!mounted) {
return;
}
setState(() {
_showHome = false;
_isLoading = true;
_currentUrl = normalized;
_currentLabel = (label == null || label.trim().isEmpty) ? '网页内容' : label.trim();
_statusText = '正在打开页面';
});
await windowManager.show();
await windowManager.focus();
}
Future<void> _goHome() async {
if (!_showHome) {
try {
await _controller.stop();
} catch (_) {}
}
if (!mounted) {
return;
}
setState(() {
_showHome = true;
_isLoading = false;
_canGoBack = false;
_canGoForward = false;
_currentUrl = '';
_currentLabel = '小游戏大厅';
_statusText = '小游戏大厅已就绪';
});
await _loadMiniGames();
}
Future<void> _refreshCurrent() async {
if (_showHome) {
await _loadMiniGames();
return;
}
try {
await _controller.reload();
} catch (_) {}
}
String _describeWebError(WebErrorStatus status) {
switch (status) {
case WebErrorStatus.WebErrorStatusTimeout:
return '请求超时';
case WebErrorStatus.WebErrorStatusConnectionAborted:
return '连接被中止';
case WebErrorStatus.WebErrorStatusConnectionReset:
return '连接被重置';
case WebErrorStatus.WebErrorStatusCannotConnect:
return '连接失败';
case WebErrorStatus.WebErrorStatusDisconnected:
return '网络已断开';
case WebErrorStatus.WebErrorStatusHostNameNotResolved:
return '无法解析主机名';
case WebErrorStatus.WebErrorStatusServerUnreachable:
return '服务器不可达';
case WebErrorStatus.WebErrorStatusErrorHTTPInvalidServerResponse:
return '服务器响应无效';
default:
return status.name;
}
}
void _handleBrowserMessage(Map<String, dynamic> message) {
final kind = (message['kind'] ?? '').toString();
switch (kind) {
case 'blocked-shortcut':
_showNotice('已禁用开发者快捷键');
break;
case 'js-error':
_showNotice('页面脚本异常:${message['message'] ?? '未知错误'}');
break;
case 'promise-error':
_showNotice('页面 Promise 异常:${message['message'] ?? '未知错误'}');
break;
case 'http-error':
final status = message['status']?.toString() ?? '--';
final url = message['url']?.toString() ?? '';
_showNotice('页面请求失败:HTTP $status${url.isEmpty ? '' : ' · $url'}');
break;
case 'request-error':
final url = message['url']?.toString() ?? '';
final detail = message['message']?.toString() ?? '网络请求异常';
_showNotice('页面请求异常:$detail${url.isEmpty ? '' : ' · $url'}');
break;
default:
break;
}
}
void _showNotice(String message) {
if (!mounted || _disposed) {
return;
}
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.hideCurrentSnackBar();
messenger?.showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 3),
),
);
}
@override
void dispose() {
_disposed = true;
_searchController.dispose();
_latencyTimer?.cancel();
_titleSubscription?.cancel();
_loadingSubscription?.cancel();
_urlSubscription?.cancel();
_historySubscription?.cancel();
_loadErrorSubscription?.cancel();
_webMessageSubscription?.cancel();
_controlServer?.close(force: true);
unawaited(_controller.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
final latencyText = _latencyMs == null ? '延迟 --' : '延迟 $_latencyMs ms';
return Scaffold(
body: Column(
children: [
Container(
height: 64,
decoration: const BoxDecoration(
color: Color(0xFF121826),
border: Border(bottom: BorderSide(color: Color(0x222D4B73))),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/bansonic icon.png',
width: 36,
height: 36,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kWindowTitle,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
Text(
_currentLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)),
),
],
),
),
Text(
latencyText,
style: const TextStyle(fontSize: 12, color: Color(0xFF8FB3D9)),
),
const SizedBox(width: 10),
IconButton(
tooltip: '刷新',
onPressed: _refreshCurrent,
icon: const Icon(Icons.refresh_rounded),
),
IconButton(
tooltip: '后退',
onPressed: (!_showHome && _canGoBack) ? () => _controller.goBack() : null,
icon: const Icon(Icons.arrow_back_rounded),
),
IconButton(
tooltip: '前进',
onPressed: (!_showHome && _canGoForward) ? () => _controller.goForward() : null,
icon: const Icon(Icons.arrow_forward_rounded),
),
IconButton(
tooltip: '回到主页',
onPressed: _showHome ? null : _goHome,
icon: const Icon(Icons.home_rounded),
),
],
),
),
Expanded(
child: Stack(
children: [
Positioned.fill(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _showHome ? _buildHomeView() : Webview(_controller),
),
),
if (_isLoading && !_showHome)
const Positioned.fill(
child: IgnorePointer(
child: Center(child: CircularProgressIndicator()),
),
),
],
),
),
],
),
);
}
Widget _buildHomeView() {
final filteredGames = _games.where((game) {
if (_typeFilter != 'all' && game.gameType != _typeFilter) {
return false;
}
if (_searchQuery.isEmpty) {
return true;
}
final haystack = '${game.id} ${game.name} ${game.publisher} ${game.description}'.toLowerCase();
return haystack.contains(_searchQuery);
}).toList();
return Container(
key: const ValueKey<String>('home-view'),
color: const Color(0xFF0B0D14),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFF151E2E),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x223F628D)),
),
child: Row(
children: [
const Icon(Icons.sports_esports_rounded, color: Color(0xFF71D4FF)),
const SizedBox(width: 10),
const Expanded(
child: Text(
'已连接小游戏大厅。这里展示服务端已发布的 H5 小游戏。',
style: TextStyle(fontSize: 13, color: Color(0xFFD6E5F7)),
),
),
FilledButton.tonalIcon(
onPressed: _gamesLoading ? null : _loadMiniGames,
icon: const Icon(Icons.refresh_rounded),
label: const Text('刷新'),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索 ID / 名称 / 发布者',
prefixIcon: const Icon(Icons.search_rounded),
filled: true,
fillColor: const Color(0xFF151E2E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0x223F628D)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0x223F628D)),
),
),
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF151E2E),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x223F628D)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _typeFilter,
dropdownColor: const Color(0xFF151E2E),
items: const [
DropdownMenuItem(value: 'all', child: Text('全部类型')),
DropdownMenuItem(value: 'official', child: Text('官方')),
DropdownMenuItem(value: 'community', child: Text('社区')),
],
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_typeFilter = value;
});
},
),
),
),
],
),
),
Expanded(
child: Builder(
builder: (context) {
if (_gamesLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_homeError.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_rounded, size: 56, color: Color(0xFF6E8BA9)),
const SizedBox(height: 16),
Text(_homeError, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFC7D6EA))),
const SizedBox(height: 16),
FilledButton(onPressed: _loadMiniGames, child: const Text('重新尝试')),
],
),
),
);
}
if (_games.isEmpty) {
return const Center(
child: Text('当前没有已发布的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))),
);
}
if (filteredGames.isEmpty) {
return const Center(
child: Text('没有符合条件的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: filteredGames.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final game = filteredGames[index];
return _MiniGameCard(
game: game,
onPlay: () => _openGame(game),
);
},
);
},
),
),
],
),
);
}
}
class _MiniGameCard extends StatelessWidget {
const _MiniGameCard({required this.game, required this.onPlay});
final MiniGameRecord game;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFF151E2E),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x223F628D)),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onPlay,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
color: const Color(0xFF20314A),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.extension_rounded, size: 34, color: Color(0xFF7ED3FF)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(game.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Text(
game.description.isEmpty ? '暂无简介' : game.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetaChip(label: 'ID ${game.id}'),
_MetaChip(label: game.gameType == 'community' ? '社区' : '官方'),
_MetaChip(label: game.publisher.isEmpty ? '发布者未填写' : game.publisher),
_MetaChip(label: game.publishDate.isEmpty ? '未设置发布时间' : game.publishDate),
],
),
],
),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('开玩'),
),
],
),
),
),
),
);
}
}
class _MetaChip extends StatelessWidget {
const _MetaChip({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF20314A),
borderRadius: BorderRadius.circular(999),
),
child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFFD6E5F7))),
);
}
}