704 lines
23 KiB
Dart
704 lines
23 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
|
import 'package:flutter/foundation.dart'
|
|
show
|
|
TargetPlatform,
|
|
debugPrint,
|
|
defaultTargetPlatform,
|
|
kIsWeb,
|
|
kReleaseMode;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.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 '../../../common/util/app_link_util.dart';
|
|
import '../../auth/application/auth_controller.dart';
|
|
import '../../auth/application/login_launcher.dart';
|
|
import '../../auth/data/auth_models.dart';
|
|
import '../../mall/application/mall_tab_config.dart';
|
|
import '../../mall/data/mall_auth_api.dart';
|
|
import '../application/mall_bridge_state.dart';
|
|
import '../application/mall_web_host_controller.dart';
|
|
import '../application/web_back_navigation.dart';
|
|
import '../application/web_navigation_policy.dart';
|
|
import '../application/web_reload_providers.dart';
|
|
import 'js_api.dart';
|
|
|
|
/// Process-lifetime resident mall WebView. The controller, bridge contract and
|
|
/// first-load ordering intentionally match the Purse implementation.
|
|
class MallWebHost extends ConsumerStatefulWidget {
|
|
const MallWebHost({
|
|
super.key,
|
|
required this.url,
|
|
required this.visible,
|
|
required this.onRequestExit,
|
|
});
|
|
|
|
final String url;
|
|
final bool visible;
|
|
final VoidCallback onRequestExit;
|
|
|
|
@override
|
|
ConsumerState<MallWebHost> createState() => _MallWebHostState();
|
|
}
|
|
|
|
class _MallWebHostState extends ConsumerState<MallWebHost>
|
|
with WidgetsBindingObserver {
|
|
static const _defaultQiyuStatusBarColor = Color(0xFF3B82F6);
|
|
static const _mallSystemUiOverlayStyle = SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: Brightness.dark,
|
|
statusBarBrightness: Brightness.light,
|
|
);
|
|
static const _webFilePickerChannel = MethodChannel(
|
|
'com.jinzhi.butler/web_file_picker',
|
|
);
|
|
|
|
late final DWebViewController _controller;
|
|
String? _loadedUrl;
|
|
String? _errorText;
|
|
int _progress = 0;
|
|
bool _hasFinishedFirstPage = false;
|
|
bool _hasDisplayedPage = false;
|
|
bool _isHandlingSystemBack = false;
|
|
bool _isHandlingLoginRequest = false;
|
|
bool _isShowingQiyuPage = false;
|
|
bool _hasEmbeddedQiyuFrame = false;
|
|
Color _qiyuStatusBarColor = _defaultQiyuStatusBarColor;
|
|
String? _qiyuUrlLoadedWithMallReferer;
|
|
final _pendingH5Events = <JsEvent>[];
|
|
Future<void>? _androidWebViewConfiguration;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
ref.listenManual<AsyncValue<JsEvent>>(webReloadPProvider, (_, next) {
|
|
next.whenData(_callH5Handler);
|
|
});
|
|
ref.listenManual<AsyncValue<AuthState>>(authProvider, (previous, next) {
|
|
final previousAuth = previous?.value;
|
|
final nextAuth = next.value;
|
|
final wasLogged = previousAuth is LoggedInAuthState;
|
|
final logged = nextAuth is LoggedInAuthState;
|
|
final tokenChanged =
|
|
wasLogged && logged && previousAuth.token != nextAuth.token;
|
|
if (wasLogged == logged && !tokenChanged) return;
|
|
if (logged) {
|
|
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
|
} else {
|
|
unawaited(_notifyH5LoggedOut());
|
|
}
|
|
});
|
|
_controller = _createController();
|
|
MallBridgeController.instance.attach(
|
|
activateTab: _switchTab,
|
|
goBack: _goBack,
|
|
);
|
|
MallWebHostController.instance.attachCacheClearer(_clearWebViewCache);
|
|
MallWebHostController.instance.attachSystemBackHandler(_handleSystemBack);
|
|
_configurePlatformWebView();
|
|
unawaited(_load(widget.url));
|
|
if (widget.visible) unawaited(_notifyH5MallEnteredForCurrentUrl());
|
|
}
|
|
|
|
DWebViewController _createController() {
|
|
late final DWebViewController controller;
|
|
controller = DWebViewController()
|
|
// ignore: discarded_futures
|
|
..setBackgroundColor(Colors.transparent)
|
|
// ignore: discarded_futures
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..addJavaScriptObject(
|
|
JsApi(
|
|
onClose: widget.onRequestExit,
|
|
onLoginRequested: () => unawaited(_requestMallLogin()),
|
|
onMallStateChanged: MallBridgeController.instance.update,
|
|
),
|
|
)
|
|
// Temporary compatibility with the pre-DSBridge Jinzhi H5 contract.
|
|
// ignore: discarded_futures
|
|
..addJavaScriptChannel(
|
|
'JinzhiBridge',
|
|
onMessageReceived: (message) {
|
|
if (message.message == 'close') widget.onRequestExit();
|
|
},
|
|
)
|
|
// ignore: discarded_futures
|
|
..setNavigationDelegate(
|
|
NavigationDelegate(
|
|
onNavigationRequest: _handleNavigationRequest,
|
|
onProgress: (value) {
|
|
if (mounted) setState(() => _progress = value);
|
|
},
|
|
onPageStarted: (url) {
|
|
_updateQiyuPageLayout(url);
|
|
if (!_hasFinishedFirstPage && mounted) {
|
|
setState(() {
|
|
_errorText = null;
|
|
_progress = 0;
|
|
});
|
|
}
|
|
},
|
|
onUrlChange: (change) => _updateQiyuPageLayout(change.url),
|
|
onPageFinished: (url) {
|
|
_hasFinishedFirstPage = true;
|
|
_hasDisplayedPage = true;
|
|
_updateQiyuPageLayout(url);
|
|
if (_isShowingQiyuPage) {
|
|
_applySystemUiOverlayStyle();
|
|
}
|
|
unawaited(_refreshEmbeddedQiyuPresence());
|
|
_flushPendingH5Events();
|
|
if (mounted) setState(() => _progress = 100);
|
|
},
|
|
onWebResourceError: (error) {
|
|
if (error.isForMainFrame != true || _hasFinishedFirstPage) return;
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorText = error.description;
|
|
_progress = 100;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
);
|
|
return controller;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
MallWebHostController.instance.setShowingQiyuPage(false);
|
|
SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle);
|
|
MallWebHostController.instance.detachCacheClearer();
|
|
MallWebHostController.instance.detachSystemBackHandler();
|
|
MallBridgeController.instance.detach();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant MallWebHost oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (MallWebHostController.cacheKeyFor(oldWidget.url) !=
|
|
MallWebHostController.cacheKeyFor(widget.url)) {
|
|
unawaited(_load(widget.url));
|
|
}
|
|
if (!oldWidget.visible && widget.visible) {
|
|
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
|
} else if (oldWidget.url != widget.url && widget.visible) {
|
|
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
|
}
|
|
}
|
|
|
|
Future<void> _switchTab(MallBridgeTab tab, bool reselect) async =>
|
|
_controller.callHandler('mall.switchTab', args: [tab.name, reselect]);
|
|
|
|
Future<void> _goBack() async => _controller.callHandler('mall.back');
|
|
|
|
Future<NavigationDecision> _handleNavigationRequest(
|
|
NavigationRequest request,
|
|
) async {
|
|
if (!kIsWeb &&
|
|
defaultTargetPlatform == TargetPlatform.android &&
|
|
isTrustedQiyuUrl(request.url)) {
|
|
return _openQiyuWithMallReferer(request.url);
|
|
}
|
|
if (!shouldOpenWebUrlExternally(request.url)) {
|
|
return NavigationDecision.navigate;
|
|
}
|
|
final target = Uri.tryParse(request.url);
|
|
if (target == null) return NavigationDecision.prevent;
|
|
final targetApp = AppLinkUtil.targetAppFromUrl(request.url);
|
|
final isHttpUrl = target.scheme == 'http' || target.scheme == 'https';
|
|
final launched = isHttpUrl && targetApp.isNotEmpty
|
|
? await AppLinkUtil.openExternal(target, packageName: targetApp)
|
|
: await launchUrl(
|
|
sanitizeSystemCommunicationUri(target),
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
if (launched && mounted) widget.onRequestExit();
|
|
return NavigationDecision.prevent;
|
|
}
|
|
|
|
Future<void> _handleSystemBack() async {
|
|
if (kIsWeb ||
|
|
defaultTargetPlatform != TargetPlatform.android ||
|
|
!_hasFinishedFirstPage ||
|
|
_errorText != null ||
|
|
_isHandlingSystemBack) {
|
|
return;
|
|
}
|
|
if (MallBridgeController.instance.state.value.canGoBack) {
|
|
await _goBack();
|
|
return;
|
|
}
|
|
_isHandlingSystemBack = true;
|
|
try {
|
|
await tryGoBackInWebHistory(
|
|
canGoBack: _controller.canGoBack,
|
|
goBack: _controller.goBack,
|
|
);
|
|
} finally {
|
|
_isHandlingSystemBack = false;
|
|
}
|
|
}
|
|
|
|
Future<NavigationDecision> _openQiyuWithMallReferer(String rawUrl) async {
|
|
final mallUri = Uri.tryParse(_loadedUrl ?? '');
|
|
final qiyuUri = Uri.tryParse(rawUrl);
|
|
if (!isQiyuClientEntryUrl(rawUrl) ||
|
|
_qiyuUrlLoadedWithMallReferer == rawUrl ||
|
|
mallUri == null ||
|
|
qiyuUri == null) {
|
|
return NavigationDecision.navigate;
|
|
}
|
|
_qiyuUrlLoadedWithMallReferer = rawUrl;
|
|
try {
|
|
await _controller.loadRequest(
|
|
qiyuUri,
|
|
headers: {'Referer': '${mallUri.origin}/'},
|
|
);
|
|
return NavigationDecision.prevent;
|
|
} catch (error) {
|
|
_qiyuUrlLoadedWithMallReferer = null;
|
|
debugPrint('[Mall] Failed to reload Qiyu with mall referrer: $error');
|
|
return NavigationDecision.navigate;
|
|
}
|
|
}
|
|
|
|
Future<void> _load(String raw) async {
|
|
final uri = Uri.tryParse(raw);
|
|
if (!_isAllowedMallUri(uri) || _loadedUrl == raw) return;
|
|
_loadedUrl = raw;
|
|
_qiyuUrlLoadedWithMallReferer = null;
|
|
_hasFinishedFirstPage = false;
|
|
_errorText = null;
|
|
_progress = 0;
|
|
_isShowingQiyuPage = false;
|
|
_hasEmbeddedQiyuFrame = false;
|
|
MallWebHostController.instance.setShowingQiyuPage(false);
|
|
MallWebHostController.instance.setQiyuKeyboardActive(false);
|
|
_resetQiyuStatusBarColor();
|
|
_applySystemUiOverlayStyle();
|
|
await _configureAndroidWebViewBeforeFirstLoad();
|
|
if (_loadedUrl != raw) return;
|
|
await _controller.loadRequest(
|
|
uri!,
|
|
headers: const {'Cache-Control': 'no-cache'},
|
|
);
|
|
}
|
|
|
|
bool _isAllowedMallUri(Uri? uri) {
|
|
if (uri == null || uri.host.isEmpty) return false;
|
|
if (uri.scheme == 'https') return true;
|
|
return !kReleaseMode &&
|
|
uri.scheme == 'http' &&
|
|
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
|
}
|
|
|
|
Future<void> _clearWebViewCache() => _controller.clearCache();
|
|
|
|
void _configurePlatformWebView() {
|
|
final platform = _controller.platform;
|
|
if (platform is WebKitWebViewController) {
|
|
unawaited(platform.setAllowsBackForwardNavigationGestures(true));
|
|
} else if (platform is AndroidWebViewController) {
|
|
_androidWebViewConfiguration ??= _configureAndroidWebView(platform);
|
|
}
|
|
}
|
|
|
|
Future<void> _configureAndroidWebViewBeforeFirstLoad() {
|
|
final platform = _controller.platform;
|
|
if (platform is! AndroidWebViewController) return Future<void>.value();
|
|
return _androidWebViewConfiguration ??= _configureAndroidWebView(platform);
|
|
}
|
|
|
|
Future<void> _configureAndroidWebView(
|
|
AndroidWebViewController controller,
|
|
) async {
|
|
await controller.setOnPlatformPermissionRequest(
|
|
_onAndroidWebPermissionRequest,
|
|
);
|
|
await controller.setOnShowFileSelector(_selectWebViewFiles);
|
|
try {
|
|
await AndroidWebViewCookieManager(
|
|
const PlatformWebViewCookieManagerCreationParams(),
|
|
).setAcceptThirdPartyCookies(controller, true);
|
|
} catch (_) {}
|
|
try {
|
|
final userAgent = await controller.getUserAgent();
|
|
if (userAgent == null) return;
|
|
final browserUserAgent = userAgent
|
|
.replaceAll(RegExp(r';\s*wv'), '')
|
|
.replaceAll(RegExp(r'\bVersion/4\.0\s*'), '')
|
|
.trim();
|
|
if (browserUserAgent != userAgent) {
|
|
await controller.setUserAgent(browserUserAgent);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _onAndroidWebPermissionRequest(
|
|
PlatformWebViewPermissionRequest request,
|
|
) {
|
|
unawaited(_grantAndroidWebPermissions(request));
|
|
}
|
|
|
|
Future<void> _grantAndroidWebPermissions(
|
|
PlatformWebViewPermissionRequest request,
|
|
) async {
|
|
if (!await _canGrantWebMediaPermission(request.types)) {
|
|
await request.deny();
|
|
return;
|
|
}
|
|
final permissions = <Permission>[
|
|
if (request.types.contains(WebViewPermissionResourceType.camera))
|
|
Permission.camera,
|
|
if (request.types.contains(WebViewPermissionResourceType.microphone))
|
|
Permission.microphone,
|
|
];
|
|
if (permissions.isEmpty) {
|
|
await request.deny();
|
|
return;
|
|
}
|
|
final statuses = await permissions.request();
|
|
if (statuses.values.every((status) => status.isGranted)) {
|
|
await request.grant();
|
|
} else {
|
|
await request.deny();
|
|
}
|
|
}
|
|
|
|
Future<bool> _canGrantWebMediaPermission(
|
|
Set<WebViewPermissionResourceType> types,
|
|
) async {
|
|
switch (await _detectWebMediaCaptureContext()) {
|
|
case _WebMediaCaptureContext.qiyu:
|
|
return true;
|
|
case _WebMediaCaptureContext.faceVerification:
|
|
return types.contains(WebViewPermissionResourceType.camera) &&
|
|
!types.contains(WebViewPermissionResourceType.microphone);
|
|
case _WebMediaCaptureContext.none:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _canGrantQiyuWebMediaPermission() async =>
|
|
await _detectWebMediaCaptureContext() == _WebMediaCaptureContext.qiyu;
|
|
|
|
Future<_WebMediaCaptureContext> _detectWebMediaCaptureContext() async {
|
|
if (_isShowingQiyuPage) return _WebMediaCaptureContext.qiyu;
|
|
const script = '''
|
|
(() => {
|
|
const isQiyu = Array.from(document.querySelectorAll('iframe[src]')).some((frame) => {
|
|
try {
|
|
const host = new URL(frame.src, document.baseURI).hostname.toLowerCase();
|
|
return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') ||
|
|
host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn');
|
|
} catch (_) { return false; }
|
|
});
|
|
if (isQiyu) return 'qiyu';
|
|
const isFaceVerification = Array.from(document.scripts).some((script) => {
|
|
try {
|
|
const url = new URL(script.src || '', document.baseURI);
|
|
return url.hostname === 'g.alicdn.com' &&
|
|
url.pathname.includes('/captcha-frontend/FeiLin/');
|
|
} catch (_) { return false; }
|
|
});
|
|
return isFaceVerification ? 'face_verification' : 'none';
|
|
})();
|
|
''';
|
|
try {
|
|
final result = await _controller.runJavaScriptReturningResult(script);
|
|
return switch (result.toString().replaceAll('"', '')) {
|
|
'qiyu' => _WebMediaCaptureContext.qiyu,
|
|
'face_verification' => _WebMediaCaptureContext.faceVerification,
|
|
_ => _WebMediaCaptureContext.none,
|
|
};
|
|
} catch (_) {
|
|
return _WebMediaCaptureContext.none;
|
|
}
|
|
}
|
|
|
|
Future<List<String>> _selectWebViewFiles(FileSelectorParams params) async {
|
|
final isQiyuMediaUpload =
|
|
_isImageOrVideoUpload(params.acceptTypes) &&
|
|
await _canGrantQiyuWebMediaPermission();
|
|
final captureEnabled = isQiyuMediaUpload
|
|
? await _requestQiyuUploadPermissions(params.acceptTypes)
|
|
: params.isCaptureEnabled;
|
|
try {
|
|
return (await _webFilePickerChannel
|
|
.invokeListMethod<String>('pickFiles', <String, dynamic>{
|
|
'acceptTypes': params.acceptTypes,
|
|
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
|
'captureEnabled': captureEnabled,
|
|
'isQiyuSession': isQiyuMediaUpload,
|
|
})) ??
|
|
const <String>[];
|
|
} on PlatformException {
|
|
return const <String>[];
|
|
}
|
|
}
|
|
|
|
bool _isImageOrVideoUpload(List<String> acceptTypes) =>
|
|
acceptTypes.isNotEmpty &&
|
|
acceptTypes.every(
|
|
(type) => type.startsWith('image/') || type.startsWith('video/'),
|
|
);
|
|
|
|
Future<bool> _requestQiyuUploadPermissions(List<String> acceptTypes) async {
|
|
final uploadsVideo = acceptTypes.any((type) => type.startsWith('video/'));
|
|
final statuses = await <Permission>[
|
|
Permission.camera,
|
|
if (uploadsVideo) Permission.microphone,
|
|
].request();
|
|
return statuses.values.every((status) => status.isGranted);
|
|
}
|
|
|
|
SystemUiOverlayStyle get _qiyuSystemUiOverlayStyle => SystemUiOverlayStyle(
|
|
statusBarColor: _qiyuStatusBarColor,
|
|
statusBarIconBrightness: Brightness.light,
|
|
statusBarBrightness: Brightness.dark,
|
|
);
|
|
|
|
void _applySystemUiOverlayStyle() {
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
_isShowingQiyuPage
|
|
? _qiyuSystemUiOverlayStyle
|
|
: _mallSystemUiOverlayStyle,
|
|
);
|
|
}
|
|
|
|
void _resetQiyuStatusBarColor() {
|
|
_qiyuStatusBarColor = _defaultQiyuStatusBarColor;
|
|
}
|
|
|
|
void _setQiyuPageLayout(bool isQiyuPage) {
|
|
if (_isShowingQiyuPage == isQiyuPage) return;
|
|
_isShowingQiyuPage = isQiyuPage;
|
|
MallWebHostController.instance.setShowingQiyuPage(isQiyuPage);
|
|
if (!isQiyuPage) _resetQiyuStatusBarColor();
|
|
_applySystemUiOverlayStyle();
|
|
if (mounted) setState(() {});
|
|
_syncQiyuKeyboardAvoidance();
|
|
}
|
|
|
|
void _updateQiyuPageLayout(String? rawUrl) =>
|
|
_setQiyuPageLayout(shouldApplyQiyuSafeArea(rawUrl ?? ''));
|
|
|
|
@override
|
|
void didChangeMetrics() {
|
|
super.didChangeMetrics();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _syncQiyuKeyboardAvoidance();
|
|
});
|
|
}
|
|
|
|
void _syncQiyuKeyboardAvoidance() {
|
|
if (!mounted) return;
|
|
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
|
MallWebHostController.instance.setQiyuKeyboardActive(
|
|
keyboardOpen && (_isShowingQiyuPage || _hasEmbeddedQiyuFrame),
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshEmbeddedQiyuPresence() async {
|
|
const script = '''
|
|
(() => Array.from(document.querySelectorAll('iframe[src]')).some((frame) => {
|
|
try {
|
|
const host = new URL(frame.src, document.baseURI).hostname.toLowerCase();
|
|
return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') ||
|
|
host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn');
|
|
} catch (_) { return false; }
|
|
}))();
|
|
''';
|
|
try {
|
|
final result = await _controller.runJavaScriptReturningResult(script);
|
|
if (!mounted) return;
|
|
_hasEmbeddedQiyuFrame = result.toString().replaceAll('"', '') == 'true';
|
|
_syncQiyuKeyboardAvoidance();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _retry() async {
|
|
final url = _loadedUrl;
|
|
if (url == null) return;
|
|
_loadedUrl = null;
|
|
await _load(url);
|
|
}
|
|
|
|
Future<void> _requestMallLogin() async {
|
|
if (_isLoggedIn) {
|
|
await _notifyH5MallEnteredForCurrentUrl();
|
|
return;
|
|
}
|
|
if (_isHandlingLoginRequest || !mounted) return;
|
|
_isHandlingLoginRequest = true;
|
|
try {
|
|
await LoginLauncher.start(context, ref);
|
|
} finally {
|
|
_isHandlingLoginRequest = false;
|
|
}
|
|
}
|
|
|
|
bool get _isLoggedIn => ref.read(authProvider).value is LoggedInAuthState;
|
|
|
|
Future<void> _notifyH5LoggedOut() async {
|
|
_isHandlingLoginRequest = false;
|
|
_callH5Handler(const JsEvent('logout'));
|
|
try {
|
|
await _controller.runJavaScript(
|
|
'window.__chartProLogout&&window.__chartProLogout()',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _notifyH5MallEnteredForCurrentUrl() async {
|
|
final rawUrl = MallWebHostController.instance.state.value.url ?? _loadedUrl;
|
|
final uri = rawUrl == null ? null : Uri.tryParse(rawUrl);
|
|
if (!_isAllowedMallUri(uri)) return;
|
|
final logged = _isLoggedIn;
|
|
final mallAuth = logged ? await _issueMallAuthToken() : null;
|
|
if (!mounted) return;
|
|
final arguments = buildEnterWebMallBridgeArguments(
|
|
config: ref.read(mallTabConfigProvider),
|
|
currentUri: uri!,
|
|
logged: logged,
|
|
isDark: Theme.of(context).brightness == Brightness.dark,
|
|
authToken: mallAuth?.authToken,
|
|
scene: mallAuth?.scene,
|
|
sourceApp: mallAuth?.sourceApp,
|
|
);
|
|
_callH5Handler(JsEvent('enterWebMall', [arguments]));
|
|
}
|
|
|
|
Future<AppMallAuthToken?> _issueMallAuthToken() async {
|
|
try {
|
|
return await ref.read(mallAuthApiProvider).issueAppMallAuthToken();
|
|
} catch (error) {
|
|
debugPrint('[Mall] Failed to issue H5 auth token: $error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void _callH5Handler(JsEvent event) {
|
|
if (!_hasFinishedFirstPage) {
|
|
_pendingH5Events.add(event);
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _sendH5Handler(event));
|
|
}
|
|
|
|
void _flushPendingH5Events() {
|
|
if (_pendingH5Events.isEmpty) return;
|
|
final events = List<JsEvent>.of(_pendingH5Events);
|
|
_pendingH5Events.clear();
|
|
for (final event in events) {
|
|
_sendH5Handler(event);
|
|
}
|
|
}
|
|
|
|
void _sendH5Handler(JsEvent event) {
|
|
try {
|
|
_controller.callHandler(event.method, args: event.args);
|
|
} catch (error) {
|
|
debugPrint('[Mall] H5 bridge event failed: ${event.method}; $error');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Qiyu already consumes CSS safe-area values. Adding Flutter's status-bar
|
|
// inset here shifts its own blue navigation bar down a second time.
|
|
const topInset = 0.0;
|
|
final bottomInset = qiyuWebViewBottomInset(
|
|
isShowingQiyuPage: _isShowingQiyuPage || _hasEmbeddedQiyuFrame,
|
|
platform: defaultTargetPlatform,
|
|
keyboardInset: MediaQuery.viewInsetsOf(context).bottom,
|
|
isWeb: kIsWeb,
|
|
);
|
|
final showLoading = shouldShowMallLoading(
|
|
hasError: _errorText != null,
|
|
hasDisplayedPage: _hasDisplayedPage,
|
|
progress: _progress,
|
|
);
|
|
final content = Stack(
|
|
children: [
|
|
if (_errorText == null)
|
|
Positioned(
|
|
top: topInset,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: bottomInset,
|
|
child: WebViewWidget(controller: _controller),
|
|
),
|
|
if (showLoading) const MallLoadingIndicator(),
|
|
if (_errorText != null)
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: () => unawaited(_retry()),
|
|
child: const Text('商城加载失败,点击重试'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
return _isShowingQiyuPage
|
|
? AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: _qiyuSystemUiOverlayStyle,
|
|
child: content,
|
|
)
|
|
: content;
|
|
}
|
|
}
|
|
|
|
enum _WebMediaCaptureContext { none, qiyu, faceVerification }
|
|
|
|
/// Keep Qiyu's Android WebView above the IME. Its delayed focus-scroll must
|
|
/// not stack with Android WebView's visual-viewport resize; regular mall pages
|
|
/// and iOS retain their native keyboard behavior.
|
|
double qiyuWebViewBottomInset({
|
|
required bool isShowingQiyuPage,
|
|
required TargetPlatform platform,
|
|
required double keyboardInset,
|
|
required bool isWeb,
|
|
}) {
|
|
if (isWeb ||
|
|
!isShowingQiyuPage ||
|
|
platform != TargetPlatform.android ||
|
|
keyboardInset <= 0) {
|
|
return 0;
|
|
}
|
|
return keyboardInset;
|
|
}
|
|
|
|
bool shouldShowMallLoading({
|
|
required bool hasError,
|
|
required bool hasDisplayedPage,
|
|
required int progress,
|
|
}) => !hasError && !hasDisplayedPage && progress < 100;
|
|
|
|
class MallLoadingIndicator extends StatelessWidget {
|
|
const MallLoadingIndicator({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = ShadTheme.of(context).colorScheme.primary;
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 28,
|
|
height: 28,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: color),
|
|
),
|
|
);
|
|
}
|
|
}
|