import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import 'config/app_config.dart'; final _homeUrl = AppConfig.homeUrl; const _shellBackground = Color(0xFFF8FBFF); const _shellAccent = Color(0xFF0089FF); const _shellSubText = Color(0xFF8E9AB0); const _resumeCoverDuration = Duration(milliseconds: 700); const _stopWebMediaScript = r''' (() => { try { window.__stopOpenIMVoicePlayback?.(); } catch (_) {} document.querySelectorAll('audio, video').forEach((media) => { try { media.pause(); media.currentTime = 0; } catch (_) {} }); })(); '''; void main() { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: _shellBackground, systemNavigationBarIconBrightness: Brightness.dark, ), ); runApp(const ImWebViewApp()); } class ImWebViewApp extends StatelessWidget { const ImWebViewApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '集中营', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)), scaffoldBackgroundColor: _shellBackground, useMaterial3: true, ), home: const H5ShellPage(), ); } } class H5ShellPage extends StatefulWidget { const H5ShellPage({super.key}); @override State createState() => _H5ShellPageState(); } class _H5ShellPageState extends State with WidgetsBindingObserver { late final WebViewController _controller; int _progress = 0; String? _loadError; bool _showShellCover = true; Timer? _shellCoverTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _controller = _buildController()..loadRequest(Uri.parse(_homeUrl)); } @override void dispose() { _shellCoverTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.inactive || state == AppLifecycleState.hidden || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { unawaited(_stopWebMedia()); } else if (state == AppLifecycleState.resumed) { _showTransientShellCover(); } } WebViewController _buildController() { PlatformWebViewControllerCreationParams params = const PlatformWebViewControllerCreationParams(); if (WebViewPlatform.instance is WebKitWebViewPlatform) { params = WebKitWebViewControllerCreationParams( allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const {}, ); } else if (WebViewPlatform.instance is AndroidWebViewPlatform) { params = AndroidWebViewControllerCreationParams .fromPlatformWebViewControllerCreationParams(params); } final controller = WebViewController.fromPlatformCreationParams( params, onPermissionRequest: (request) { unawaited(_handleWebViewPermissionRequest(request)); }, ) ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { setState(() => _progress = progress); } }, onPageStarted: (_) { if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } }, onPageFinished: (_) { if (mounted) { setState(() { _progress = 100; _showShellCover = false; }); } }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { if (mounted) { setState(() { _loadError = error.description; _showShellCover = false; }); } } }, onUrlChange: (_) { unawaited(_stopWebMedia()); }, onNavigationRequest: _handleNavigationRequest, ), ); final platformController = controller.platform; if (platformController is AndroidWebViewController) { AndroidWebViewController.enableDebugging(false); unawaited(platformController.setMediaPlaybackRequiresUserGesture(false)); unawaited(platformController.setGeolocationEnabled(true)); unawaited( platformController.setGeolocationPermissionsPromptCallbacks( onShowPrompt: (_) async { final allowed = await _requestPermission(Permission.locationWhenInUse); return GeolocationPermissionsResponse( allow: allowed, retain: allowed, ); }, ), ); } return controller; } void _showTransientShellCover() { _shellCoverTimer?.cancel(); if (mounted) { setState(() => _showShellCover = true); } _shellCoverTimer = Timer(_resumeCoverDuration, () { if (mounted) { setState(() => _showShellCover = _progress < 100); } }); } Future _runJavaScriptSafely(String source) async { try { await _controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } Future _stopWebMedia() { return _runJavaScriptSafely(_stopWebMediaScript); } Future _handleNavigationRequest( NavigationRequest request, ) async { unawaited(_stopWebMedia()); final uri = Uri.tryParse(request.url); if (uri == null) { return NavigationDecision.prevent; } const webSchemes = {'http', 'https', 'about', 'data'}; if (webSchemes.contains(uri.scheme)) { return NavigationDecision.navigate; } try { await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (_) { // Ignore unsupported custom schemes so the WebView does not navigate to // an error page. } return NavigationDecision.prevent; } Future _handleWebViewPermissionRequest( WebViewPermissionRequest request, ) async { final permissions = []; if (request.types.contains(WebViewPermissionResourceType.camera)) { permissions.add(Permission.camera); } if (request.types.contains(WebViewPermissionResourceType.microphone)) { permissions.add(Permission.microphone); } final allowed = permissions.isEmpty || await Future.wait(permissions.map(_requestPermission)) .then((results) => results.every((allowed) => allowed)); if (allowed) { await request.grant(); } else { await request.deny(); } } Future _requestPermission(Permission permission) async { final status = await permission.request(); return status.isGranted || status.isLimited; } Future _handleBackNavigation() async { await _stopWebMedia(); if (await _controller.canGoBack()) { await _controller.goBack(); } else { await SystemNavigator.pop(); } } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { unawaited(_handleBackNavigation()); } }, child: Scaffold( backgroundColor: _shellBackground, body: SafeArea( bottom: false, child: Stack( children: [ const Positioned.fill(child: _ShellFallback()), WebViewWidget(controller: _controller), if (_showShellCover) const Positioned.fill( child: IgnorePointer(child: _ShellFallback()), ), if (_progress < 100) LinearProgressIndicator( value: _progress == 0 ? null : _progress / 100, minHeight: 2, ), if (_loadError != null) _ErrorPanel( message: _loadError!, onRetry: () => _controller.loadRequest(Uri.parse(_homeUrl)), ), ], ), ), ), ); } } class _ErrorPanel extends StatelessWidget { const _ErrorPanel({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { return ColoredBox( color: Colors.white, child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off_rounded, size: 44), const SizedBox(height: 16), const Text( '页面加载失败', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 20), FilledButton( onPressed: onRetry, child: const Text('重新加载'), ), ], ), ), ), ); } } class _ShellFallback extends StatelessWidget { const _ShellFallback(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFFE7F4FF), _shellBackground, Colors.white, ], stops: [0, 0.44, 1], ), ), child: Stack( children: [ const Positioned( top: 44, right: -72, child: _SoftCircle(size: 190, opacity: 0.42), ), const Positioned( top: 210, left: -44, child: _SoftCircle(size: 160, opacity: 0.34), ), const Positioned( right: -54, bottom: 250, child: _SoftCircle(size: 220, opacity: 0.38), ), Center( child: Column( mainAxisSize: MainAxisSize.min, children: const [ _ShellMark(), SizedBox(height: 26), Text( '心有回响', style: TextStyle( color: _shellAccent, fontSize: 26, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0, ), ), SizedBox(height: 10), Text( '正在为你唤醒会话', style: TextStyle( color: _shellSubText, fontSize: 13, height: 1.2, letterSpacing: 0, ), ), SizedBox(height: 32), SizedBox( width: 110, child: LinearProgressIndicator( minHeight: 3, backgroundColor: Color(0xFFDCEEFF), color: _shellAccent, ), ), ], ), ), ], ), ); } } class _ShellMark extends StatelessWidget { const _ShellMark(); @override Widget build(BuildContext context) { return Container( width: 126, height: 126, decoration: BoxDecoration( shape: BoxShape.circle, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFA8DDFF), _shellAccent, Color(0xFF0066E8)], ), boxShadow: [ BoxShadow( color: _shellAccent.withValues(alpha: 0.2), blurRadius: 34, offset: const Offset(0, 18), ), ], ), child: const Icon( Icons.check_rounded, color: Colors.white, size: 82, ), ); } } class _SoftCircle extends StatelessWidget { const _SoftCircle({required this.size, required this.opacity}); final double size; final double opacity; @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFFBFE4FF).withValues(alpha: opacity), ), ); } }