feat(webview): Add full WebView integration for inou.com/app

- 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()
This commit is contained in:
Johan Jongsma 2026-01-31 19:42:43 +00:00
parent 62e39ab736
commit 9cc40d0765
3 changed files with 929 additions and 64 deletions

View File

@ -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<String, dynamic> 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<InouWebView> createState() => InouWebViewState();
}
class InouWebViewState extends State<InouWebView> {
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<String, dynamic>;
final action = data['action'] as String?;
final payload = data['data'] as Map<String, dynamic>? ?? {};
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<String, dynamic> 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<void> _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<void> _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<void> _clearCookies() async {
await WebViewCookieManager().clearCookies();
}
/// Reload the WebView
Future<void> reload() async {
_updateConnectionState(WebViewConnectionState.loading);
await _controller.reload();
}
/// Navigate to a specific URL
Future<void> loadUrl(String url) async {
_updateConnectionState(WebViewConnectionState.loading);
await _controller.loadRequest(Uri.parse(url));
}
/// Check if WebView can go back
Future<bool> canGoBack() async {
return await _controller.canGoBack();
}
/// Go back in WebView history
Future<void> goBack() async {
if (await canGoBack()) {
await _controller.goBack();
}
}
/// Get current URL
Future<String?> getCurrentUrl() async {
return await _controller.currentUrl();
}
/// Execute JavaScript in the WebView
Future<Object> evaluateJavaScript(String script) async {
return await _controller.runJavaScriptReturningResult(script);
}
/// Send a message to the web app via JavaScript
Future<void> sendToWeb(String action, Map<String, dynamic> 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<Color>(
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,
),
),
],
),
),
);
}
}

View File

@ -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<WebViewScreen> createState() => _WebViewScreenState();
State<WebViewScreen> createState() => WebViewScreenState();
}
class _WebViewScreenState extends State<WebViewScreen> {
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<WebViewScreen> {
final GlobalKey<InouWebViewState> _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<Color>(
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<Color>(AppTheme.primaryColor),
),
),
),
IconButton(
icon: const Icon(Icons.share),
onPressed: _handleShare,
tooltip: 'Share',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _handleRefresh,
tooltip: 'Refresh',
),
PopupMenuButton<String>(
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<void> _updateBackState() async {
final canGoBack = await _webViewKey.currentState?.canGoBack() ?? false;
if (mounted && _canGoBack != canGoBack) {
setState(() {
_canGoBack = canGoBack;
});
}
}
Future<void> _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<void> _updateCurrentUrl() async {
final url = await _webViewKey.currentState?.getCurrentUrl();
if (mounted && url != null) {
setState(() {
_currentUrl = url;
});
}
}
Future<void> _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<void> _handleBackButton() async {
if (_canGoBack) {
await _webViewKey.currentState?.goBack();
await _updateBackState();
}
}
Future<void> _handleRefresh() async {
await _webViewKey.currentState?.reload();
}
Future<void> _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<void> _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<void> _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<String, dynamic> 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<void> loadUrl(String url) async {
await _webViewKey.currentState?.loadUrl(url);
}
/// Reload the WebView
Future<void> reload() async {
await _webViewKey.currentState?.reload();
}
}
/// Route for deep linking to specific inou.com/app/* URLs
class WebViewRoute extends MaterialPageRoute<void> {
WebViewRoute({required String url})
: super(
builder: (context) => Scaffold(
appBar: AppBar(
title: const Text(AppConfig.appName),
backgroundColor: AppTheme.backgroundColor,
),
body: WebViewScreen(
initialUrl: url,
showAppBar: false,
),
),
);
}

View File

@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override
State<InouApp> createState() => _InouAppState();
}
class _InouAppState extends State<InouApp> {
@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<void> _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<dynamic>? _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<MainScaffold> createState() => _MainScaffoldState();
State<MainScaffold> createState() => MainScaffoldState();
}
class _MainScaffoldState extends State<MainScaffold> {
class MainScaffoldState extends State<MainScaffold> {
int _currentIndex = 0;
final GlobalKey<WebViewScreenState> _webViewKey = GlobalKey();
final List<Widget> _screens = const [
WebViewScreen(),
InputScreen(),
SettingsScreen(),
];
final List<Widget> _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<MainScaffold> {
),
);
}
/// 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();
}
}