feat(android): add Flutter WebView app with Docker release builds
This commit is contained in:
+324
@@ -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('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user