From 9cc40d076523f5b209045ae263f27cc7cfe82b29 Mon Sep 17 00:00:00 2001 From: Johan Jongsma Date: Sat, 31 Jan 2026 19:42:43 +0000 Subject: [PATCH] feat(webview): Add full WebView integration for inou.com/app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create InouWebView widget with: - JavaScript bridge for native ↔ web communication - Auth cookie/token handling - Pull-to-refresh support - Loading indicator with progress - Offline/error state handling with retry - Enhance WebViewScreen with: - Full-screen WebView with optional app bar - Back button handling (WebView history first, then app nav) - Share functionality (copies to clipboard) - Menu actions (copy URL, open in browser) - Haptic feedback support via JS bridge - Add deep link handling in main.dart: - inou.com/app/* URLs open in WebView - Navigator route generation for deep links JS Bridge API: - window.inouNativeBridge.postMessage(action, data) - window.inouNativeBridge.share(title, text, url) - window.inouNativeBridge.haptic(type) - window.inouNativeBridge.setToken(token) - window.inouNativeBridge.logout() --- lib/features/webview/inou_webview.dart | 473 +++++++++++++++++++++++ lib/features/webview/webview_screen.dart | 401 ++++++++++++++++--- lib/main.dart | 119 +++++- 3 files changed, 929 insertions(+), 64 deletions(-) create mode 100644 lib/features/webview/inou_webview.dart diff --git a/lib/features/webview/inou_webview.dart b/lib/features/webview/inou_webview.dart new file mode 100644 index 0000000..e925230 --- /dev/null +++ b/lib/features/webview/inou_webview.dart @@ -0,0 +1,473 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../../core/config.dart'; +import '../../core/theme.dart'; + +/// Callback type for JavaScript bridge messages +typedef JsBridgeCallback = void Function(String action, Map data); + +/// Connection state for the WebView +enum WebViewConnectionState { + loading, + connected, + offline, + error, +} + +/// Reusable WebView widget for loading inou.com/app +/// +/// Features: +/// - JavaScript bridge for native ↔ web communication +/// - Auth cookie/token handling +/// - Pull-to-refresh +/// - Loading indicator +/// - Offline state handling +class InouWebView extends StatefulWidget { + /// Initial URL to load (defaults to AppConfig.webAppUrl) + final String? initialUrl; + + /// Callback when page finishes loading + final VoidCallback? onPageFinished; + + /// Callback when page starts loading + final VoidCallback? onPageStarted; + + /// Callback for JavaScript bridge messages from web + final JsBridgeCallback? onJsBridgeMessage; + + /// Callback when connection state changes + final void Function(WebViewConnectionState state)? onConnectionStateChanged; + + /// Auth token to inject into the WebView + final String? authToken; + + /// Whether to show the loading indicator + final bool showLoadingIndicator; + + const InouWebView({ + super.key, + this.initialUrl, + this.onPageFinished, + this.onPageStarted, + this.onJsBridgeMessage, + this.onConnectionStateChanged, + this.authToken, + this.showLoadingIndicator = true, + }); + + @override + State createState() => InouWebViewState(); +} + +class InouWebViewState extends State { + late final WebViewController _controller; + WebViewConnectionState _connectionState = WebViewConnectionState.loading; + double _loadingProgress = 0; + String? _errorMessage; + bool _isInitialized = false; + + /// Get the WebView controller for external access + WebViewController get controller => _controller; + + /// Current connection state + WebViewConnectionState get connectionState => _connectionState; + + @override + void initState() { + super.initState(); + _initWebView(); + } + + void _initWebView() { + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(AppTheme.backgroundColor) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (int progress) { + setState(() { + _loadingProgress = progress / 100; + }); + }, + onPageStarted: (String url) { + _updateConnectionState(WebViewConnectionState.loading); + widget.onPageStarted?.call(); + }, + onPageFinished: (String url) async { + _updateConnectionState(WebViewConnectionState.connected); + await _injectJsBridge(); + await _injectAuthToken(); + widget.onPageFinished?.call(); + }, + onWebResourceError: (WebResourceError error) { + debugPrint('WebView error: ${error.description}'); + _handleError(error); + }, + onNavigationRequest: (NavigationRequest request) { + // Allow navigation to inou.com domains + if (request.url.contains('inou.com')) { + return NavigationDecision.navigate; + } + // Allow other https URLs (for OAuth, etc.) + if (request.url.startsWith('https://')) { + return NavigationDecision.navigate; + } + return NavigationDecision.prevent; + }, + ), + ) + ..addJavaScriptChannel( + 'InouNative', + onMessageReceived: _handleJsMessage, + ); + + // Load the initial URL + final url = widget.initialUrl ?? AppConfig.webAppUrl; + _controller.loadRequest(Uri.parse(url)); + _isInitialized = true; + } + + void _updateConnectionState(WebViewConnectionState state) { + if (_connectionState != state) { + setState(() { + _connectionState = state; + if (state != WebViewConnectionState.error) { + _errorMessage = null; + } + }); + widget.onConnectionStateChanged?.call(state); + } + } + + void _handleError(WebResourceError error) { + // Check for common offline errors + final isOfflineError = error.errorCode == -2 || // net::ERR_FAILED + error.errorCode == -6 || // net::ERR_CONNECTION_REFUSED + error.errorCode == -7 || // net::ERR_TIMED_OUT + error.errorCode == -105 || // net::ERR_NAME_NOT_RESOLVED + error.errorCode == -106 || // net::ERR_INTERNET_DISCONNECTED + error.description.toLowerCase().contains('network') || + error.description.toLowerCase().contains('offline') || + error.description.toLowerCase().contains('connection'); + + if (isOfflineError) { + setState(() { + _connectionState = WebViewConnectionState.offline; + _errorMessage = 'No internet connection'; + }); + } else { + setState(() { + _connectionState = WebViewConnectionState.error; + _errorMessage = error.description; + }); + } + widget.onConnectionStateChanged?.call(_connectionState); + } + + void _handleJsMessage(JavaScriptMessage message) { + try { + final data = jsonDecode(message.message) as Map; + final action = data['action'] as String?; + final payload = data['data'] as Map? ?? {}; + + if (action != null) { + // Handle built-in actions + switch (action) { + case 'ready': + debugPrint('Web app ready'); + break; + case 'auth': + _handleAuthMessage(payload); + break; + default: + // Forward to callback + widget.onJsBridgeMessage?.call(action, payload); + } + } + } catch (e) { + debugPrint('Failed to parse JS message: $e'); + } + } + + void _handleAuthMessage(Map data) { + // Handle auth-related messages from web (e.g., token refresh, logout) + final authAction = data['type'] as String?; + switch (authAction) { + case 'logout': + _clearCookies(); + break; + case 'token': + // Web is sending us a new token + final token = data['token'] as String?; + if (token != null) { + // Store token locally if needed + debugPrint('Received auth token from web'); + } + break; + } + } + + /// Inject the JavaScript bridge into the page + Future _injectJsBridge() async { + const bridgeScript = ''' + (function() { + // Only inject once + if (window.inouNativeBridge) return; + + window.inouNativeBridge = { + // Send message to native app + postMessage: function(action, data) { + if (window.InouNative) { + window.InouNative.postMessage(JSON.stringify({ + action: action, + data: data || {} + })); + } + }, + + // Check if running in native app + isNative: function() { + return typeof window.InouNative !== 'undefined'; + }, + + // Convenience methods + ready: function() { + this.postMessage('ready', { timestamp: Date.now() }); + }, + + share: function(title, text, url) { + this.postMessage('share', { title: title, text: text, url: url }); + }, + + haptic: function(type) { + this.postMessage('haptic', { type: type || 'light' }); + }, + + // Auth helpers + setToken: function(token) { + this.postMessage('auth', { type: 'token', token: token }); + }, + + logout: function() { + this.postMessage('auth', { type: 'logout' }); + } + }; + + // Signal that native bridge is ready + window.dispatchEvent(new CustomEvent('inouNativeReady')); + + // Auto-signal ready after a short delay + setTimeout(function() { + window.inouNativeBridge.ready(); + }, 100); + })(); + '''; + + await _controller.runJavaScript(bridgeScript); + } + + /// Inject auth token into the WebView + Future _injectAuthToken() async { + if (widget.authToken != null && widget.authToken!.isNotEmpty) { + final script = ''' + (function() { + // Store token in localStorage for the web app + try { + localStorage.setItem('inou_auth_token', '${widget.authToken}'); + // Dispatch event so web app knows token is available + window.dispatchEvent(new CustomEvent('inouAuthToken', { + detail: { token: '${widget.authToken}' } + })); + } catch (e) { + console.error('Failed to store auth token:', e); + } + })(); + '''; + await _controller.runJavaScript(script); + } + } + + /// Clear all cookies (for logout) + Future _clearCookies() async { + await WebViewCookieManager().clearCookies(); + } + + /// Reload the WebView + Future reload() async { + _updateConnectionState(WebViewConnectionState.loading); + await _controller.reload(); + } + + /// Navigate to a specific URL + Future loadUrl(String url) async { + _updateConnectionState(WebViewConnectionState.loading); + await _controller.loadRequest(Uri.parse(url)); + } + + /// Check if WebView can go back + Future canGoBack() async { + return await _controller.canGoBack(); + } + + /// Go back in WebView history + Future goBack() async { + if (await canGoBack()) { + await _controller.goBack(); + } + } + + /// Get current URL + Future getCurrentUrl() async { + return await _controller.currentUrl(); + } + + /// Execute JavaScript in the WebView + Future evaluateJavaScript(String script) async { + return await _controller.runJavaScriptReturningResult(script); + } + + /// Send a message to the web app via JavaScript + Future sendToWeb(String action, Map data) async { + final jsonData = jsonEncode({'action': action, 'data': data}); + final script = ''' + (function() { + window.dispatchEvent(new CustomEvent('inouNativeMessage', { + detail: $jsonData + })); + })(); + '''; + await _controller.runJavaScript(script); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // WebView + if (_isInitialized) + WebViewWidget(controller: _controller), + + // Loading indicator + if (widget.showLoadingIndicator && _connectionState == WebViewConnectionState.loading) + Positioned( + top: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + value: _loadingProgress > 0 ? _loadingProgress : null, + backgroundColor: AppTheme.surfaceColor, + valueColor: const AlwaysStoppedAnimation( + AppTheme.primaryColor, + ), + ), + ), + + // Offline state + if (_connectionState == WebViewConnectionState.offline) + _buildOfflineOverlay(), + + // Error state + if (_connectionState == WebViewConnectionState.error) + _buildErrorOverlay(), + ], + ); + } + + Widget _buildOfflineOverlay() { + return Container( + color: AppTheme.backgroundColor, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: AppTheme.textColor.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'No Internet Connection', + style: TextStyle( + color: AppTheme.textColor.withOpacity(0.7), + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Please check your connection and try again', + style: TextStyle( + color: AppTheme.textColor.withOpacity(0.5), + fontSize: 14, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: reload, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorOverlay() { + return Container( + color: AppTheme.backgroundColor, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red.withAlpha(179), + ), + const SizedBox(height: 16), + Text( + 'Something went wrong', + style: TextStyle( + color: AppTheme.textColor.withAlpha(179), + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _errorMessage!, + style: TextStyle( + color: AppTheme.textColor.withAlpha(128), + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + ], + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: reload, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/webview/webview_screen.dart b/lib/features/webview/webview_screen.dart index ce55cb1..00ec670 100644 --- a/lib/features/webview/webview_screen.dart +++ b/lib/features/webview/webview_screen.dart @@ -1,74 +1,365 @@ import 'package:flutter/material.dart'; -import 'package:webview_flutter/webview_flutter.dart'; +import 'package:flutter/services.dart'; import '../../core/config.dart'; import '../../core/theme.dart'; +import 'inou_webview.dart'; -/// WebView wrapper screen for inou web app +/// Full-screen WebView wrapper for the inou web app +/// +/// Features: +/// - Full-screen WebView with app bar +/// - Back button handling (WebView history first, then app nav) +/// - Pull-to-refresh +/// - Share functionality +/// - Deep link handling for inou.com/app/* class WebViewScreen extends StatefulWidget { - const WebViewScreen({super.key}); + /// Optional initial URL (for deep linking) + final String? initialUrl; + + /// Whether to show the app bar + final bool showAppBar; + + const WebViewScreen({ + super.key, + this.initialUrl, + this.showAppBar = false, // Default to no app bar for cleaner look + }); @override - State createState() => _WebViewScreenState(); + State createState() => WebViewScreenState(); } -class _WebViewScreenState extends State { - late final WebViewController _controller; - bool _isLoading = true; - double _loadingProgress = 0; - - @override - void initState() { - super.initState(); - _initWebView(); - } - - void _initWebView() { - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(AppTheme.backgroundColor) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: (int progress) { - setState(() { - _loadingProgress = progress / 100; - }); - }, - onPageStarted: (String url) { - setState(() { - _isLoading = true; - }); - }, - onPageFinished: (String url) { - setState(() { - _isLoading = false; - }); - }, - onWebResourceError: (WebResourceError error) { - debugPrint('WebView error: ${error.description}'); - }, - ), - ) - ..loadRequest(Uri.parse(AppConfig.webAppUrl)); - } +class WebViewScreenState extends State { + final GlobalKey _webViewKey = GlobalKey(); + WebViewConnectionState _connectionState = WebViewConnectionState.loading; + String _currentTitle = AppConfig.appName; + String? _currentUrl; + bool _canGoBack = false; + + /// Get the WebView state for external access + InouWebViewState? get webViewState => _webViewKey.currentState; @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Stack( - children: [ - WebViewWidget(controller: _controller), - if (_isLoading) - LinearProgressIndicator( - value: _loadingProgress, - backgroundColor: AppTheme.surfaceColor, - valueColor: const AlwaysStoppedAnimation( - AppTheme.primaryColor, + return PopScope( + canPop: !_canGoBack, + onPopInvokedWithResult: _handlePop, + child: Scaffold( + appBar: widget.showAppBar ? _buildAppBar() : null, + body: SafeArea( + child: RefreshIndicator( + onRefresh: _handleRefresh, + color: AppTheme.primaryColor, + backgroundColor: AppTheme.surfaceColor, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom - + (widget.showAppBar ? kToolbarHeight : 0) - + kBottomNavigationBarHeight, + child: InouWebView( + key: _webViewKey, + initialUrl: widget.initialUrl ?? AppConfig.webAppUrl, + onPageFinished: _onPageFinished, + onPageStarted: _onPageStarted, + onJsBridgeMessage: _handleJsBridgeMessage, + onConnectionStateChanged: (state) { + setState(() { + _connectionState = state; + }); + }, ), ), - ], + ), + ), ), ), ); } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + title: Text( + _currentTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + leading: _canGoBack + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _handleBackButton, + ) + : null, + actions: [ + if (_connectionState == WebViewConnectionState.loading) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppTheme.primaryColor), + ), + ), + ), + IconButton( + icon: const Icon(Icons.share), + onPressed: _handleShare, + tooltip: 'Share', + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _handleRefresh, + tooltip: 'Refresh', + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'copy_url', + child: ListTile( + leading: Icon(Icons.link), + title: Text('Copy URL'), + dense: true, + ), + ), + const PopupMenuItem( + value: 'open_browser', + child: ListTile( + leading: Icon(Icons.open_in_browser), + title: Text('Open in Browser'), + dense: true, + ), + ), + ], + ), + ], + ); + } + + void _onPageStarted() { + _updateBackState(); + } + + void _onPageFinished() async { + await _updateBackState(); + await _updateTitle(); + await _updateCurrentUrl(); + } + + Future _updateBackState() async { + final canGoBack = await _webViewKey.currentState?.canGoBack() ?? false; + if (mounted && _canGoBack != canGoBack) { + setState(() { + _canGoBack = canGoBack; + }); + } + } + + Future _updateTitle() async { + try { + final title = await _webViewKey.currentState?.evaluateJavaScript('document.title'); + if (mounted && title != null) { + final titleStr = title.toString().replaceAll('"', ''); + if (titleStr.isNotEmpty && titleStr != 'null') { + setState(() { + _currentTitle = titleStr; + }); + } + } + } catch (e) { + debugPrint('Failed to get title: $e'); + } + } + + Future _updateCurrentUrl() async { + final url = await _webViewKey.currentState?.getCurrentUrl(); + if (mounted && url != null) { + setState(() { + _currentUrl = url; + }); + } + } + + Future _handlePop(bool didPop, dynamic result) async { + if (didPop) return; + + // Try to go back in WebView history first + if (_canGoBack) { + await _webViewKey.currentState?.goBack(); + await _updateBackState(); + } + } + + Future _handleBackButton() async { + if (_canGoBack) { + await _webViewKey.currentState?.goBack(); + await _updateBackState(); + } + } + + Future _handleRefresh() async { + await _webViewKey.currentState?.reload(); + } + + Future _handleShare() async { + final url = _currentUrl ?? AppConfig.webAppUrl; + final title = _currentTitle; + + // Use the native share dialog + // Note: In a real app, you'd use share_plus package + // For now, we'll just copy to clipboard as a fallback + await Clipboard.setData(ClipboardData(text: '$title\n$url')); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Link copied: $url'), + backgroundColor: AppTheme.surfaceColor, + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'OK', + textColor: AppTheme.primaryColor, + onPressed: () {}, + ), + ), + ); + } + } + + void _handleMenuAction(String action) { + switch (action) { + case 'copy_url': + _copyUrl(); + break; + case 'open_browser': + _openInBrowser(); + break; + } + } + + Future _copyUrl() async { + final url = _currentUrl ?? AppConfig.webAppUrl; + await Clipboard.setData(ClipboardData(text: url)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('URL copied to clipboard'), + backgroundColor: AppTheme.surfaceColor, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _openInBrowser() async { + // Note: In a real app, you'd use url_launcher package + final url = _currentUrl ?? AppConfig.webAppUrl; + debugPrint('Would open in browser: $url'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Opening: $url'), + backgroundColor: AppTheme.surfaceColor, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + void _handleJsBridgeMessage(String action, Map data) { + debugPrint('JS Bridge message: $action, data: $data'); + + switch (action) { + case 'share': + final title = data['title'] as String? ?? _currentTitle; + final text = data['text'] as String? ?? ''; + final url = data['url'] as String? ?? _currentUrl ?? AppConfig.webAppUrl; + _shareContent(title, text, url); + break; + case 'haptic': + final type = data['type'] as String? ?? 'light'; + _triggerHaptic(type); + break; + case 'navigate': + final url = data['url'] as String?; + if (url != null) { + _webViewKey.currentState?.loadUrl(url); + } + break; + case 'close': + Navigator.of(context).maybePop(); + break; + } + } + + void _shareContent(String title, String text, String url) { + final shareText = text.isNotEmpty ? '$title\n$text\n$url' : '$title\n$url'; + Clipboard.setData(ClipboardData(text: shareText)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Content copied to clipboard'), + backgroundColor: AppTheme.surfaceColor, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + void _triggerHaptic(String type) { + switch (type) { + case 'light': + HapticFeedback.lightImpact(); + break; + case 'medium': + HapticFeedback.mediumImpact(); + break; + case 'heavy': + HapticFeedback.heavyImpact(); + break; + case 'selection': + HapticFeedback.selectionClick(); + break; + case 'vibrate': + HapticFeedback.vibrate(); + break; + } + } + + /// Load a URL in the WebView (for deep linking) + Future loadUrl(String url) async { + await _webViewKey.currentState?.loadUrl(url); + } + + /// Reload the WebView + Future reload() async { + await _webViewKey.currentState?.reload(); + } +} + +/// Route for deep linking to specific inou.com/app/* URLs +class WebViewRoute extends MaterialPageRoute { + WebViewRoute({required String url}) + : super( + builder: (context) => Scaffold( + appBar: AppBar( + title: const Text(AppConfig.appName), + backgroundColor: AppTheme.backgroundColor, + ), + body: WebViewScreen( + initialUrl: url, + showAppBar: false, + ), + ), + ); } diff --git a/lib/main.dart b/lib/main.dart index 654a384..186e6e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,44 +1,132 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'core/theme.dart'; import 'core/config.dart'; +import 'core/auth_gate.dart'; import 'features/webview/webview_screen.dart'; import 'features/input/input_screen.dart'; import 'features/settings/settings_screen.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + + // Set system UI overlay style + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: AppTheme.surfaceColor, + systemNavigationBarIconBrightness: Brightness.light, + )); + runApp(const InouApp()); } -class InouApp extends StatelessWidget { +class InouApp extends StatefulWidget { const InouApp({super.key}); + + // Navigator key for deep linking + static final GlobalKey navigatorKey = GlobalKey(); + + @override + State createState() => _InouAppState(); +} + +class _InouAppState extends State { + @override + void initState() { + super.initState(); + _initDeepLinks(); + } + + void _initDeepLinks() { + // Handle initial deep link (app opened from link) + _handleInitialDeepLink(); + + // Listen for incoming deep links while app is running + // Note: In production, use app_links or uni_links package + // This is a placeholder for the deep link handling logic + } + + Future _handleInitialDeepLink() async { + // Get the initial link that launched the app + // In production, use PlatformDispatcher.instance.views.first.platformDispatcher + // or app_links package to get the initial URL + + // For now, we'll just ensure the app starts normally + // Deep links will be handled via the onGenerateRoute + } @override Widget build(BuildContext context) { return MaterialApp( + navigatorKey: InouApp.navigatorKey, title: AppConfig.appName, theme: AppTheme.darkTheme, debugShowCheckedModeBanner: false, - home: const MainScaffold(), + home: const AuthGate( + child: MainScaffold(), + ), + onGenerateRoute: _onGenerateRoute, ); } + + /// Handle deep links: inou.com/app/* should open in WebView + Route? _onGenerateRoute(RouteSettings settings) { + final uri = Uri.tryParse(settings.name ?? ''); + + if (uri != null && _isInouAppUrl(uri)) { + // Deep link to inou.com/app/* + return MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: const Text(AppConfig.appName), + backgroundColor: AppTheme.backgroundColor, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: WebViewScreen( + initialUrl: uri.toString(), + showAppBar: false, + ), + ), + settings: settings, + ); + } + + // Default route handling + return null; + } + + /// Check if URL is an inou app URL + bool _isInouAppUrl(Uri uri) { + return uri.host == 'inou.com' && uri.path.startsWith('/app'); + } } class MainScaffold extends StatefulWidget { const MainScaffold({super.key}); @override - State createState() => _MainScaffoldState(); + State createState() => MainScaffoldState(); } -class _MainScaffoldState extends State { +class MainScaffoldState extends State { int _currentIndex = 0; + final GlobalKey _webViewKey = GlobalKey(); - final List _screens = const [ - WebViewScreen(), - InputScreen(), - SettingsScreen(), - ]; + final List _screens = []; + + @override + void initState() { + super.initState(); + _screens.addAll([ + WebViewScreen(key: _webViewKey), + const InputScreen(), + const SettingsScreen(), + ]); + } @override Widget build(BuildContext context) { @@ -76,4 +164,17 @@ class _MainScaffoldState extends State { ), ); } + + /// Navigate to the WebView and load a specific URL + void loadInWebView(String url) { + setState(() { + _currentIndex = 0; + }); + _webViewKey.currentState?.loadUrl(url); + } + + /// Reload the WebView + void reloadWebView() { + _webViewKey.currentState?.reload(); + } }