import 'dart:async'; import 'package:flutter/material.dart'; import 'package:stadtbote/tools/vhs_map_access.dart'; import 'package:webview_flutter/webview_flutter.dart'; class VhsMapPage extends StatefulWidget { const VhsMapPage({super.key, required this.uri}); final Uri uri; @override State createState() => _VhsMapPageState(); } class _VhsMapPageState extends State { static const _username = String.fromEnvironment('VHS_BASIC_AUTH_USERNAME'); static const _password = String.fromEnvironment('VHS_BASIC_AUTH_PASSWORD'); static const _loadError = 'Die VHS-Karte konnte nicht geladen werden. ' 'Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.'; WebViewController? _controller; String? _errorMessage; int _loadId = 0; int _progress = 0; bool _isLoading = true; @override void initState() { super.initState(); unawaited(_loadMap()); } bool _isCurrentLoad(int loadId) => mounted && _loadId == loadId; void _showError(int loadId, String message) { if (!_isCurrentLoad(loadId)) return; setState(() { _errorMessage ??= message; _isLoading = false; }); } Future _stopLoading(WebViewController? controller) async { try { await controller?.runJavaScript('window.stop();'); } catch (_) { // Beim Schließen kann die native WebView bereits entfernt worden sein. } } Future _loadMap() async { final loadId = ++_loadId; final previousController = _controller; setState(() { _controller = null; _errorMessage = null; _progress = 0; _isLoading = true; }); await _stopLoading(previousController); if (!_isCurrentLoad(loadId)) return; final access = VhsMapAccess(widget.uri); if (!access.isValid) { _showError( loadId, 'Für diesen Auftrag ist kein gültiger Kartenlink verfügbar.', ); return; } Uri documentUri = widget.uri; WebViewController? controller; try { controller = WebViewController(); await controller.setJavaScriptMode(JavaScriptMode.unrestricted); if (!_isCurrentLoad(loadId)) return; await controller.setNavigationDelegate( NavigationDelegate( onNavigationRequest: (request) { if (!_isCurrentLoad(loadId) || _errorMessage != null) { return NavigationDecision.prevent; } final target = Uri.tryParse(request.url); if (target == null || !access.allowsNavigation(target)) { if (request.isMainFrame) { _showError( loadId, 'Dieser Link kann in der VHS-Karte nicht geöffnet werden.', ); } return NavigationDecision.prevent; } if (request.isMainFrame) documentUri = target; return NavigationDecision.navigate; }, onPageStarted: (url) { if (!_isCurrentLoad(loadId) || _errorMessage != null) return; final target = Uri.tryParse(url); if (target == null || !access.allowsNavigation(target)) { unawaited(_stopLoading(controller)); _showError( loadId, 'Dieser Link kann in der VHS-Karte nicht geöffnet werden.', ); return; } documentUri = target; setState(() { _progress = 0; _isLoading = true; }); }, onProgress: (progress) { if (!_isCurrentLoad(loadId) || _errorMessage != null) return; setState(() => _progress = progress); }, onPageFinished: (_) { if (!_isCurrentLoad(loadId) || _errorMessage != null) return; setState(() => _isLoading = false); }, onWebResourceError: (error) { // Fehlende Kartenkacheln dürfen die gesamte Karte nicht verdecken. if (error.isForMainFrame == true || (error.isForMainFrame == null && error.url != null && Uri.tryParse(error.url!) == documentUri)) { _showError(loadId, _loadError); } }, onHttpError: (error) { // 401 kann vor der erfolgreichen HTTP-Anmeldung gemeldet werden. if (error.response?.statusCode == 401) return; // WebKit meldet für Dokumentfehler teilweise keine Request-URI. if (error.request == null || error.request!.uri == documentUri) { _showError(loadId, _loadError); } }, onHttpAuthRequest: (request) { if (!_isCurrentLoad(loadId) || _errorMessage != null || !access.allowsAuthentication(request.host)) { request.onCancel(); return; } if (_username.isEmpty || _password.isEmpty) { request.onCancel(); _showError( loadId, 'Der Zugang zur VHS-Karte ist in dieser App-Version nicht eingerichtet.', ); return; } if (!access.tryAuthentication(request.host)) { request.onCancel(); _showError( loadId, 'Die Anmeldung an der VHS-Karte ist fehlgeschlagen.', ); return; } request.onProceed( const WebViewCredential(user: _username, password: _password), ); }, ), ); if (!_isCurrentLoad(loadId)) return; setState(() => _controller = controller); await controller.loadRequest(widget.uri); } catch (_) { _showError(loadId, _loadError); } } @override void dispose() { _loadId++; unawaited(_stopLoading(_controller)); super.dispose(); } @override Widget build(BuildContext context) { final controller = _controller; final errorMessage = _errorMessage; return Scaffold( appBar: AppBar( title: const Text('VHS-Karte'), leading: IconButton( tooltip: 'Schließen', icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), actions: [ IconButton( tooltip: 'Neu laden', icon: const Icon(Icons.refresh), onPressed: _isLoading ? null : _loadMap, ), ], ), body: SafeArea( child: errorMessage != null ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48), const SizedBox(height: 16), Text(errorMessage, textAlign: TextAlign.center), const SizedBox(height: 16), FilledButton( onPressed: _loadMap, child: const Text('Erneut versuchen'), ), ], ), ), ) : Column( children: [ if (_isLoading) LinearProgressIndicator( value: _progress == 0 ? null : _progress / 100, semanticsLabel: 'VHS-Karte wird geladen', ), Expanded( child: controller == null ? const Center(child: CircularProgressIndicator()) : WebViewWidget( key: ValueKey(_loadId), controller: controller, ), ), ], ), ), ); } }