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:
parent
62e39ab736
commit
9cc40d0765
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
119
lib/main.dart
119
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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue