feat(android): add Flutter WebView app with Docker release builds

This commit is contained in:
2026-10-15 16:38:19 +08:00
parent 2745f2491b
commit 589937213f
43 changed files with 1608 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
import 'dart:async';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'config.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 全面屏:内容延伸到状态栏/导航栏之下,刘海与手势区交给 SafeArea 处理
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
runApp(const DshApp());
}
class DshApp extends StatelessWidget {
const DshApp({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
// 站点为深色主题:状态栏透明、图标反白
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
),
child: MaterialApp(
title: 'DSH 工作台',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(kThemeColorValue),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(kThemeColorValue),
),
home: const HomePage(),
),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// 原生下载通道(MainActivity 中用系统 DownloadManager 实现)
static const MethodChannel _downloadsChannel = MethodChannel(
'dsh.workbench/downloads',
);
static final Uri _siteUri = Uri.parse(kSiteUrl);
late final WebViewController _controller;
bool _loading = true;
String? _loadError;
@override
void initState() {
super.initState();
_controller = _buildController();
}
WebViewController _buildController() {
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(kThemeColorValue))
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) {
if (mounted) {
setState(() {
_loading = true;
_loadError = null;
});
}
},
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
onWebResourceError: (error) {
// 只把主文档加载失败视作页面错误;子资源错误(图片等)
// 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理
if (!(error.isForMainFrame ?? false)) return;
if (error.description.contains('ERR_ABORTED')) return;
if (mounted) {
setState(() {
_loading = false;
_loadError = error.description;
});
}
},
onNavigationRequest: _onNavigationRequest,
),
)
..loadRequest(Uri.parse(kSiteUrl));
// Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm)
final platform = controller.platform;
if (platform is AndroidWebViewController) {
platform
..setOnShowFileSelector(_onShowFileSelector)
..setOnJavaScriptAlertDialog(_onJavaScriptAlert)
..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm);
}
return controller;
}
/// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包),
/// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final uri = Uri.tryParse(request.url);
if (uri != null && _isPanelDownload(uri)) {
_startDownload(uri);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
bool _isPanelDownload(Uri uri) {
if (uri.host != _siteUri.host) return false;
// 工作区文件/目录 ZIP 下载
if (uri.path.endsWith('/api/ws/download')) return true;
// 配置备份包下载
if (uri.path.endsWith('/backups') &&
uri.queryParameters.containsKey('download')) {
return true;
}
return false;
}
Future<void> _startDownload(Uri uri) async {
try {
await _downloadsChannel.invokeMethod<bool>('download', {
'url': uri.toString(),
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')),
);
}
}
}
/// WebView 里的 <input type="file"> 上传(工作区上传/备份导入)。
/// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
/// 多约束时退化为不限类型,避免选择器异常。
Future<List<String>> _onShowFileSelector(FileSelectorParams params) async {
final typeGroups = <XTypeGroup>[];
final mimes = params.acceptTypes.where((t) => t.contains('/')).toList();
if (mimes.length == 1) {
typeGroups.add(XTypeGroup(mimeTypes: mimes));
} else {
final exts = params.acceptTypes
.where((t) => t.startsWith('.'))
.map((t) => t.toLowerCase())
.toList();
if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts));
}
if (params.mode == FileSelectorMode.openMultiple) {
final files = await openFiles(acceptedTypeGroups: typeGroups);
return files.map((f) => f.path).toList();
}
final file = await openFile(acceptedTypeGroups: typeGroups);
return file == null ? const <String>[] : [file.path];
}
/// 面板少量路径仍使用 window.alert / window.confirm,
/// 未注册回调时 WebView 会静默返回 false,必须在这里补上。
Future<void> _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: Text(request.message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('确定'),
),
],
),
);
}
Future<bool> _onJavaScriptConfirm(
JavaScriptConfirmDialogRequest request,
) async {
if (!mounted) return false;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(request.message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确定'),
),
],
),
);
return confirmed ?? false;
}
Future<void> _onPopInvoked(bool didPop, Object? result) async {
if (didPop) return;
// 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用
if (await _controller.canGoBack()) {
await _controller.goBack();
} else {
await SystemNavigator.pop();
}
}
void _retry() {
setState(() {
_loadError = null;
_loading = true;
});
unawaited(_controller.reload());
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: _onPopInvoked,
child: Scaffold(
backgroundColor: const Color(kThemeColorValue),
body: SafeArea(
child: Stack(
children: [
WebViewWidget(controller: _controller),
if (_loadError != null)
_ErrorView(message: _loadError!, onRetry: _retry)
else if (_loading)
const _LoadingView(),
],
),
),
),
);
}
}
class _LoadingView extends StatelessWidget {
const _LoadingView();
@override
Widget build(BuildContext context) {
return const ColoredBox(
color: Color(kThemeColorValue),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white70),
SizedBox(height: 16),
Text('正在加载…', style: TextStyle(color: Colors.white70)),
],
),
),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(kThemeColorValue),
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 56, color: Colors.white54),
const SizedBox(height: 16),
const Text(
'无法连接到服务器',
style: TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
),
);
}
}