import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; 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'; const _shellBackground = Color(0xFFF8FBFF); const _shellAccent = Color(0xFF0089FF); const _shellSubText = Color(0xFF8E9AB0); const _resumeCoverDuration = Duration(milliseconds: 700); const _noCacheHeaders = { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }; const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); const _shellWebViewCacheChannel = MethodChannel('io.openim.flutter.im_webview_app/webview_cache'); const _runtimeCachePurgeFallbackDuration = Duration(seconds: 2); const _purgeWebRuntimeCacheScript = r''' (() => { if (window.__openimShellRuntimeCachePurgeStarted) { return; } window.__openimShellRuntimeCachePurgeStarted = true; const targetUrl = () => { try { const target = new URL(window.location.href); target.searchParams.set('shell_cache_bust', Date.now().toString()); target.searchParams.set('shell_runtime_cache_purged', '1'); return target.toString(); } catch (_) { return window.location.href; } }; const notifyFlutter = (payload) => { const channel = window.OpenIMFlutterShell; if (channel && typeof channel.postMessage === 'function') { channel.postMessage(JSON.stringify(payload)); return true; } return false; }; const purgeRuntimeCache = async () => { const result = { cacheStorageKeys: 0, serviceWorkerRegistrations: 0, }; try { if ('caches' in window && caches.keys) { const keys = await caches.keys(); result.cacheStorageKeys = keys.length; await Promise.all(keys.map((key) => caches.delete(key))); } } catch (_) {} try { if ( navigator.serviceWorker && typeof navigator.serviceWorker.getRegistrations === 'function' ) { const registrations = await navigator.serviceWorker.getRegistrations(); result.serviceWorkerRegistrations = registrations.length; await Promise.all( registrations.map((registration) => registration.unregister()) ); } } catch (_) {} return result; }; purgeRuntimeCache() .then((result) => { const payload = { type: 'runtimeCachePurged', url: targetUrl(), result, }; if (!notifyFlutter(payload)) { window.location.replace(payload.url); } }) .catch(() => { const payload = { type: 'runtimeCachePurged', url: targetUrl(), }; if (!notifyFlutter(payload)) { window.location.replace(payload.url); } }); })(); '''; const _inspectH5SnapshotScript = r''' (() => { const toAbsoluteUrl = (value) => { try { return new URL(value, window.location.href).href; } catch (_) { return value || ''; } }; const shrinkAssetUrl = (value) => { const absolute = toAbsoluteUrl(value); const match = absolute.match(/\/assets\/[^?#]+/); return match ? match[0] : absolute; }; const scripts = Array.from(document.scripts) .map((script) => script.src) .filter(Boolean) .map(shrinkAssetUrl); const links = Array.from(document.querySelectorAll('link[href]')) .map((link) => link.href) .filter(Boolean) .map(shrinkAssetUrl); const bodyText = (document.body?.innerText || '') .replace(/\s+/g, ' ') .slice(0, 300); return JSON.stringify({ type: 'h5Snapshot', href: window.location.href, title: document.title, scripts, links, bodyText, userAgent: navigator.userAgent, }); })(); '''; const _shellMediaPermissionBridgeScript = r''' (() => { if (window.__openimShellMediaBridgeInstalled) { return; } const channel = window.OpenIMFlutterShell; const mediaDevices = navigator.mediaDevices; if (!channel || typeof channel.postMessage !== 'function') { return; } if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { return; } const originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices); const resultEventName = 'openim-shell-media-permission-result'; const createRequestId = () => { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(36).slice(2)}`; }; const createPermissionError = () => { try { return new DOMException('Permission denied', 'NotAllowedError'); } catch (_) { const error = new Error('Permission denied'); error.name = 'NotAllowedError'; return error; } }; const requestNativePermissions = (constraints) => new Promise((resolve) => { const requestId = createRequestId(); let timer; const cleanup = () => { if (timer) { window.clearTimeout(timer); } window.removeEventListener(resultEventName, listener); }; const listener = (event) => { const detail = event.detail; if (!detail || detail.requestId !== requestId) { return; } cleanup(); resolve(detail); }; timer = window.setTimeout(() => { cleanup(); resolve(undefined); }, 30000); window.addEventListener(resultEventName, listener); channel.postMessage( JSON.stringify({ type: 'requestMediaPermissions', requestId, audio: Boolean(constraints && constraints.audio), video: Boolean(constraints && constraints.video), }), ); }); const wrappedGetUserMedia = async (constraints) => { const result = await requestNativePermissions(constraints || {}); if (result && result.granted) { return originalGetUserMedia(constraints); } throw createPermissionError(); }; try { Object.defineProperty(mediaDevices, 'getUserMedia', { configurable: true, writable: true, value: wrappedGetUserMedia, }); } catch (_) { mediaDevices.getUserMedia = wrappedGetUserMedia; } window.__openimShellMediaBridgeInstalled = true; window.dispatchEvent(new CustomEvent('openim-shell-media-bridge-ready')); })(); '''; const _stopWebMediaScript = r''' (() => { try { window.__stopOpenIMVoicePlayback?.(); } catch (_) {} document.querySelectorAll('audio, video').forEach((media) => { try { media.pause(); media.currentTime = 0; } catch (_) {} }); })(); '''; Future main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: _shellBackground, systemNavigationBarIconBrightness: Brightness.dark, ), ); final shellBranding = await ShellBranding.load(); runApp(ImWebViewApp(shellBranding: shellBranding)); } class ShellBranding { const ShellBranding({ required this.appName, required this.appLogo, }); static const fallback = ShellBranding( appName: AppConfig.appName, appLogo: AppConfig.appLogo, ); final String appName; final String appLogo; static Future load() async { try { final data = await _shellBrandingChannel .invokeMapMethod('getShellBranding'); final appName = _trim(data?['appName']); final appLogo = _trim(data?['appLogo']); return ShellBranding( appName: appName.isNotEmpty ? appName : fallback.appName, appLogo: appLogo.isNotEmpty ? appLogo : fallback.appLogo, ); } catch (_) { return fallback; } } static String _trim(String? value) => value?.trim() ?? ''; } class ImWebViewApp extends StatelessWidget { const ImWebViewApp({ super.key, this.shellBranding = ShellBranding.fallback, }); final ShellBranding shellBranding; @override Widget build(BuildContext context) { return MaterialApp( title: shellBranding.appName, debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)), scaffoldBackgroundColor: _shellBackground, useMaterial3: true, ), home: H5ShellPage(shellBranding: shellBranding), ); } } class H5ShellPage extends StatefulWidget { const H5ShellPage({super.key, required this.shellBranding}); final ShellBranding shellBranding; @override State createState() => _H5ShellPageState(); } class _H5ShellPageState extends State with WidgetsBindingObserver { late final WebViewController _controller; int _progress = 0; String? _loadError; bool _showShellCover = true; bool _runtimeCachePurgeStarted = false; bool _runtimeCachePurgeReloaded = false; String? _h5SnapshotDebugText; Timer? _shellCoverTimer; Timer? _runtimeCachePurgeFallbackTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _controller = _buildController(); unawaited(_loadHome()); } @override void dispose() { _shellCoverTimer?.cancel(); _runtimeCachePurgeFallbackTimer?.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) ..addJavaScriptChannel( 'OpenIMFlutterShell', onMessageReceived: _handleShellBridgeMessage, ) ..setBackgroundColor(_shellBackground) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { if (mounted) { setState(() => _progress = progress); } }, onPageStarted: (_) { if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } }, onPageFinished: (_) { unawaited(_handlePageFinished()); }, 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) { assert(() { AndroidWebViewController.enableDebugging(true); return true; }()); 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 _handleShellBridgeMessage(JavaScriptMessage message) { Map? payload; String? type; try { final decoded = jsonDecode(message.message); if (decoded is Map) { payload = Map.from(decoded); type = payload['type'] as String?; } } catch (_) { type = message.message; } if (type == 'openAppSettings') { unawaited(openAppSettings()); } if (type == 'runtimeCachePurged' && payload != null) { debugPrint('[H5Shell] runtime cache purged: ${jsonEncode(payload)}'); unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?)); } if (type == 'requestMediaPermissions' && payload != null) { unawaited(_handleMediaPermissionBridgeRequest(payload)); } } Future _handleMediaPermissionBridgeRequest( Map payload, ) async { final requestId = payload['requestId'] as String?; if (requestId == null || requestId.isEmpty) { return; } final permissions = {}; if (payload['audio'] == true) { permissions.add(Permission.microphone); } if (payload['video'] == true) { permissions.add(Permission.camera); } final statusesByPermission = await _requestPermissions(permissions); final statuses = statusesByPermission.values; final granted = statuses.every( _isPermissionAllowed, ); final permanentlyDenied = statuses.any((status) => status.isPermanentlyDenied); final restricted = statuses.any((status) => status.isRestricted); final detail = jsonEncode({ 'requestId': requestId, 'granted': granted, 'permanentlyDenied': permanentlyDenied, 'restricted': restricted, }); final script = ''' (() => { window.dispatchEvent(new CustomEvent('openim-shell-media-permission-result', { detail: $detail })); })(); '''; await _runJavaScriptSafely(script); } 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. } } String _freshHomeUrl() { return AppConfig.homeUrl( appName: widget.shellBranding.appName, appLogo: widget.shellBranding.appLogo, ); } String _freshShellUrl(String? url) { final value = url?.trim(); if (value == null || value.isEmpty) { return _freshHomeUrl(); } try { return AppConfig.withFreshShellParams( value, appName: widget.shellBranding.appName, appLogo: widget.shellBranding.appLogo, ); } catch (_) { return _freshHomeUrl(); } } Future _handlePageFinished() async { final waitingForRuntimeCacheReload = await _purgeWebRuntimeCacheAndReloadIfNeeded(); if (waitingForRuntimeCacheReload) { return; } unawaited(_syncShellBranding()); unawaited(_injectShellMediaPermissionBridge()); unawaited(_logLoadedH5Snapshot()); if (mounted) { setState(() { _progress = 100; _showShellCover = false; }); } } Future _purgeWebRuntimeCacheAndReloadIfNeeded() async { if (_runtimeCachePurgeStarted) { return false; } _runtimeCachePurgeStarted = true; try { await _controller.runJavaScript(_purgeWebRuntimeCacheScript); } catch (_) { _runtimeCachePurgeStarted = false; return false; } _runtimeCachePurgeFallbackTimer?.cancel(); _runtimeCachePurgeFallbackTimer = Timer( _runtimeCachePurgeFallbackDuration, () { if (!_runtimeCachePurgeReloaded) { unawaited(_reloadAfterRuntimeCachePurge(null)); } }, ); return true; } Future _reloadAfterRuntimeCachePurge(String? url) async { if (_runtimeCachePurgeReloaded) { return; } _runtimeCachePurgeReloaded = true; _runtimeCachePurgeFallbackTimer?.cancel(); if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } String? currentUrl; if (url == null || url.trim().isEmpty) { try { currentUrl = await _controller.currentUrl(); } catch (_) {} } await _loadUrl(_freshShellUrl(url ?? currentUrl)); } Future _prepareWebViewForFreshLoad() async { await _configureAndroidNoCache(); try { await _controller.clearCache(); } catch (_) { // Some WebView implementations can reject cache operations during setup. } } Future _configureAndroidNoCache() async { final platformController = _controller.platform; if (platformController is! AndroidWebViewController) { return; } try { await _shellWebViewCacheChannel.invokeMethod( 'configureNoCache', {'webViewId': platformController.webViewIdentifier}, ); } catch (_) { // Older shells may not expose the native cache channel yet. } } Future _syncShellBranding() { final payload = jsonEncode({ 'name': widget.shellBranding.appName, 'logo': widget.shellBranding.appLogo, }); final script = ''' (() => { try { const brand = $payload; window.sessionStorage.setItem('OPENIM_FLUTTER_SHELL_BRAND', JSON.stringify(brand)); window.dispatchEvent(new CustomEvent('openim-shell-branding-updated', { detail: brand })); } catch (_) {} })(); '''; return _runJavaScriptSafely(script); } Future _injectShellMediaPermissionBridge() { return _runJavaScriptSafely(_shellMediaPermissionBridgeScript); } Future _stopWebMedia() { return _runJavaScriptSafely(_stopWebMediaScript); } Future _logLoadedH5Snapshot() async { try { final result = await _controller.runJavaScriptReturningResult( _inspectH5SnapshotScript, ); final snapshot = _decodeJavaScriptStringResult(result); debugPrint( '[H5Shell] loaded H5 snapshot: $snapshot', ); _updateH5SnapshotDebugText(snapshot); } catch (error) { debugPrint('[H5Shell] loaded H5 snapshot failed: $error'); } } void _updateH5SnapshotDebugText(String snapshot) { if (!kDebugMode || !mounted) { return; } setState(() { _h5SnapshotDebugText = _formatH5SnapshotDebugText(snapshot); }); } String _formatH5SnapshotDebugText(String snapshot) { try { final decoded = jsonDecode(snapshot); if (decoded is! Map) { return snapshot; } final href = (decoded['href'] as String? ?? '').trim(); final title = (decoded['title'] as String? ?? '').trim(); final bodyText = (decoded['bodyText'] as String? ?? '').trim(); final scripts = decoded['scripts']; final links = decoded['links']; final assets = [ if (scripts is List) ...scripts.whereType().where((value) => value.isNotEmpty), if (links is List) ...links.whereType().where((value) => value.isNotEmpty), ]; final importantAssets = assets .where( (asset) => asset.contains('/assets/index-') || asset.contains('LoginBrandHeader') || asset.contains('useLoginBranding'), ) .take(8) .join('\n'); return [ if (title.isNotEmpty) 'title: $title', if (href.isNotEmpty) 'url: $href', if (importantAssets.isNotEmpty) 'assets:\n$importantAssets', if (bodyText.isNotEmpty) 'text: $bodyText', ].join('\n'); } catch (_) { return snapshot; } } String _decodeJavaScriptStringResult(Object? result) { if (result == null) { return ''; } if (result is String) { try { final decoded = jsonDecode(result); if (decoded is String) { return decoded; } } catch (_) {} return result; } return result.toString(); } Future _loadHome() async { await _loadUrl(_freshHomeUrl()); } Future _loadUrl(String url) async { if (mounted) { setState(() { _loadError = null; _progress = 0; _showShellCover = true; }); } await _prepareWebViewForFreshLoad(); await _controller.loadRequest( Uri.parse(url), headers: _noCacheHeaders, ); } 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); } debugPrint( '[H5Shell] WebView media permission request: ' '${request.types.map((type) => type.name).join(', ')}', ); final statusesByPermission = await _requestPermissions(permissions); final allowed = statusesByPermission.values.every(_isPermissionAllowed); debugPrint( '[H5Shell] WebView media permission result: ' '${statusesByPermission.map((permission, status) => MapEntry(permission.toString(), status.toString()))}', ); if (allowed) { await request.grant(); } else { await request.deny(); } } bool _isPermissionAllowed(PermissionStatus status) { return status.isGranted || status.isLimited; } Future> _requestPermissions( Set permissions, ) async { if (permissions.isEmpty) { return const {}; } final pending = []; final statuses = {}; for (final permission in permissions) { final status = await permission.status; if (_isPermissionAllowed(status) || status.isPermanentlyDenied || status.isRestricted) { statuses[permission] = status; } else { pending.add(permission); } } if (pending.isNotEmpty) { statuses.addAll(await pending.request()); } return statuses; } Future _requestPermission(Permission permission) async { final statuses = await _requestPermissions({permission}); return statuses.values.every(_isPermissionAllowed); } 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: () => unawaited(_loadHome()), ), if (kDebugMode && _h5SnapshotDebugText != null) Positioned( left: 8, right: 8, bottom: 12, child: IgnorePointer( child: _H5SnapshotDebugPanel(text: _h5SnapshotDebugText!), ), ), ], ), ), ), ); } } class _H5SnapshotDebugPanel extends StatelessWidget { const _H5SnapshotDebugPanel({required this.text}); final String text; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.72), borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.all(8), child: Text( text, maxLines: 10, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 10, height: 1.25, letterSpacing: 0, ), ), ), ); } } 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), ), ); } }