feat: 对齐常驻商城与七鱼客服交互
- 同步商城 H5 登录态与原生 token - 完善商城/原生 tab 切换、动画及返回交互 - 修复七鱼客服键盘避让和状态栏恢复 - 调整开屏跳过按钮与登录入口交互
This commit is contained in:
1 parent
b4343ea528
commit
3b089e2205
37 files changed
+2970
-375
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum MallBridgeTab { home, market, orders, profile }
|
||||
|
||||
class MallBridgeState {
|
||||
const MallBridgeState({
|
||||
this.ready = false,
|
||||
this.activeTab = MallBridgeTab.home,
|
||||
this.canGoBack = false,
|
||||
});
|
||||
|
||||
final bool ready;
|
||||
final MallBridgeTab activeTab;
|
||||
final bool canGoBack;
|
||||
}
|
||||
|
||||
class MallBridgeController {
|
||||
MallBridgeController._();
|
||||
|
||||
static final instance = MallBridgeController._();
|
||||
|
||||
final state = ValueNotifier(const MallBridgeState());
|
||||
Future<void> Function(MallBridgeTab tab, bool reselect)? _activateTab;
|
||||
Future<void> Function()? _goBack;
|
||||
|
||||
void attach({
|
||||
required Future<void> Function(MallBridgeTab tab, bool reselect)
|
||||
activateTab,
|
||||
required Future<void> Function() goBack,
|
||||
}) {
|
||||
_activateTab = activateTab;
|
||||
_goBack = goBack;
|
||||
}
|
||||
|
||||
void detach() {
|
||||
_activateTab = null;
|
||||
_goBack = null;
|
||||
state.value = const MallBridgeState();
|
||||
}
|
||||
|
||||
void update(Map<dynamic, dynamic>? raw) {
|
||||
final tab = MallBridgeTab.values.firstWhere(
|
||||
(item) => item.name == raw?['activeTab'],
|
||||
orElse: () => MallBridgeTab.home,
|
||||
);
|
||||
state.value = MallBridgeState(
|
||||
ready: _asBool(raw?['ready']),
|
||||
activeTab: tab,
|
||||
canGoBack: _asBool(raw?['canGoBack']),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> activate(MallBridgeTab tab, {bool reselect = false}) async {
|
||||
final current = state.value;
|
||||
state.value = MallBridgeState(
|
||||
ready: current.ready,
|
||||
activeTab: tab,
|
||||
canGoBack: current.canGoBack,
|
||||
);
|
||||
await _activateTab?.call(tab, reselect);
|
||||
}
|
||||
|
||||
Future<void> goBack() async => await _goBack?.call();
|
||||
}
|
||||
|
||||
bool _asBool(Object? value) =>
|
||||
value == true || value == 1 || value == '1' || value == 'true';
|
||||
@@ -1,30 +1,121 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MallWebHostState {
|
||||
const MallWebHostState({this.url, this.visible = false});
|
||||
const MallWebHostState({
|
||||
this.url,
|
||||
this.visible = false,
|
||||
this.isShowingQiyuPage = false,
|
||||
this.isQiyuKeyboardActive = false,
|
||||
});
|
||||
|
||||
final String? url;
|
||||
final bool visible;
|
||||
final bool isShowingQiyuPage;
|
||||
final bool isQiyuKeyboardActive;
|
||||
}
|
||||
|
||||
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。
|
||||
/// Process-lifetime presentation state for the resident mall WebView.
|
||||
class MallWebHostController {
|
||||
MallWebHostController._();
|
||||
static final instance = MallWebHostController._();
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
Future<void> Function()? _backHandler;
|
||||
|
||||
void preload(String url) {
|
||||
final old = state.value;
|
||||
if (old.url == url) return;
|
||||
state.value = MallWebHostState(url: url, visible: old.visible);
|
||||
static final instance = MallWebHostController._();
|
||||
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
String? _pendingPreloadUrl;
|
||||
Future<void> Function()? _clearCache;
|
||||
Future<void> Function()? _handleSystemBack;
|
||||
|
||||
void open(Uri url) {
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = MallWebHostState(url: url.toString(), visible: true);
|
||||
}
|
||||
|
||||
void open(String url) =>
|
||||
state.value = MallWebHostState(url: url, visible: true);
|
||||
void dismiss() => state.value = MallWebHostState(url: state.value.url);
|
||||
void clear() => state.value = const MallWebHostState();
|
||||
void attachBackHandler(Future<void> Function() handler) =>
|
||||
_backHandler = handler;
|
||||
void detachBackHandler() => _backHandler = null;
|
||||
Future<void> handleBack() => _backHandler?.call() ?? Future.value();
|
||||
void preload(Uri url) {
|
||||
final current = state.value;
|
||||
if (current.visible) {
|
||||
if (current.url == null ||
|
||||
cacheKeyFor(current.url!) != cacheKeyFor(url.toString())) {
|
||||
_pendingPreloadUrl = url.toString();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.url != null &&
|
||||
cacheKeyFor(current.url!) == cacheKeyFor(url.toString())) {
|
||||
return;
|
||||
}
|
||||
state.value = MallWebHostState(
|
||||
url: url.toString(),
|
||||
visible: current.visible,
|
||||
);
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
final current = state.value;
|
||||
final pending = _pendingPreloadUrl;
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = MallWebHostState(url: pending ?? current.url);
|
||||
}
|
||||
|
||||
void setShowingQiyuPage(bool isShowingQiyuPage) {
|
||||
final current = state.value;
|
||||
if (current.isShowingQiyuPage == isShowingQiyuPage) return;
|
||||
state.value = MallWebHostState(
|
||||
url: current.url,
|
||||
visible: current.visible,
|
||||
isShowingQiyuPage: isShowingQiyuPage,
|
||||
isQiyuKeyboardActive: isShowingQiyuPage
|
||||
? current.isQiyuKeyboardActive
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
void setQiyuKeyboardActive(bool active) {
|
||||
final current = state.value;
|
||||
if (current.isQiyuKeyboardActive == active) return;
|
||||
state.value = MallWebHostState(
|
||||
url: current.url,
|
||||
visible: current.visible,
|
||||
isShowingQiyuPage: current.isShowingQiyuPage,
|
||||
isQiyuKeyboardActive: active,
|
||||
);
|
||||
}
|
||||
|
||||
void attachCacheClearer(Future<void> Function() clearer) =>
|
||||
_clearCache = clearer;
|
||||
|
||||
void detachCacheClearer() => _clearCache = null;
|
||||
|
||||
Future<void> clear() async {
|
||||
await _clearCache?.call();
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = const MallWebHostState();
|
||||
}
|
||||
|
||||
static Uri uriForTheme(Uri uri, {required bool isDark}) {
|
||||
final query = Map<String, List<String>>.from(uri.queryParametersAll);
|
||||
query['isNight'] = [isDark ? '1' : '0'];
|
||||
return uri.replace(queryParameters: query);
|
||||
}
|
||||
|
||||
void attachSystemBackHandler(Future<void> Function() handler) {
|
||||
_handleSystemBack = handler;
|
||||
}
|
||||
|
||||
void detachSystemBackHandler() => _handleSystemBack = null;
|
||||
|
||||
Future<void> handleSystemBack() =>
|
||||
_handleSystemBack?.call() ?? Future<void>.value();
|
||||
|
||||
static String cacheKeyFor(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null) return rawUrl;
|
||||
return uri
|
||||
.replace(
|
||||
queryParameters: uri.queryParameters['isNight'] == null
|
||||
? const <String, String>{}
|
||||
: <String, String>{'isNight': uri.queryParameters['isNight']!},
|
||||
fragment: '',
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
typedef WebCanGoBack = Future<bool> Function();
|
||||
typedef WebGoBack = Future<void> Function();
|
||||
typedef WebRunJavaScriptReturningResult = Future<Object> Function(String code);
|
||||
|
||||
const visibleWebBackControlJavaScript = r'''
|
||||
(() => {
|
||||
const selectors = [
|
||||
'.back-btn',
|
||||
'[aria-label="返回"]',
|
||||
'[aria-label="Back"]',
|
||||
'[title="返回"]',
|
||||
'[title="Back"]',
|
||||
].join(',');
|
||||
const candidates = Array.from(document.querySelectorAll(selectors));
|
||||
const control = candidates.find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
if (
|
||||
rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 ||
|
||||
rect.right <= 0 || rect.top >= window.innerHeight ||
|
||||
rect.left >= window.innerWidth || style.display === 'none' ||
|
||||
style.visibility === 'hidden' || style.pointerEvents === 'none' ||
|
||||
Number(style.opacity || 1) <= 0
|
||||
) return false;
|
||||
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
|
||||
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
|
||||
const topElement = document.elementFromPoint(x, y);
|
||||
return topElement !== null && (topElement === element || element.contains(topElement));
|
||||
});
|
||||
if (!control) return false;
|
||||
control.click();
|
||||
return true;
|
||||
})()
|
||||
''';
|
||||
|
||||
Future<bool> tryGoBackInWebHistory({
|
||||
required WebCanGoBack canGoBack,
|
||||
required WebGoBack goBack,
|
||||
}) async {
|
||||
if (!await canGoBack()) return false;
|
||||
await goBack();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> tryGoBackUsingVisibleWebControl({
|
||||
required WebRunJavaScriptReturningResult runJavaScriptReturningResult,
|
||||
}) async {
|
||||
final result = await runJavaScriptReturningResult(
|
||||
visibleWebBackControlJavaScript,
|
||||
);
|
||||
if (result is bool) return result;
|
||||
return result.toString().replaceAll('"', '').trim().toLowerCase() == 'true';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Mall pages stay in the resident WebView unless H5 explicitly requests an
|
||||
/// external handoff or the URI is handled by the operating system.
|
||||
bool shouldOpenWebUrlExternally(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null) return false;
|
||||
return const {'tel', 'mailto', 'sms'}.contains(uri.scheme.toLowerCase()) ||
|
||||
uri.queryParameters.containsKey('out');
|
||||
}
|
||||
|
||||
Uri sanitizeSystemCommunicationUri(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'tel') return uri;
|
||||
final parameterMarker = uri.path.indexOf('&');
|
||||
final phone = parameterMarker < 0
|
||||
? uri.path
|
||||
: uri.path.substring(0, parameterMarker);
|
||||
return Uri(scheme: uri.scheme, path: phone);
|
||||
}
|
||||
|
||||
bool isTrustedQiyuUrl(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null ||
|
||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||
uri.host.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final host = uri.host.toLowerCase();
|
||||
return host == 'qiyukf.com' ||
|
||||
host.endsWith('.qiyukf.com') ||
|
||||
host == 'qiyukf.cn' ||
|
||||
host.endsWith('.qiyukf.cn');
|
||||
}
|
||||
|
||||
bool shouldApplyQiyuSafeArea(String rawUrl) => isTrustedQiyuUrl(rawUrl);
|
||||
|
||||
bool isQiyuClientEntryUrl(String rawUrl) {
|
||||
final path = Uri.tryParse(rawUrl)?.path;
|
||||
return path == '/client' || (path?.startsWith('/client/') ?? false);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class JsEvent {
|
||||
const JsEvent(this.method, [this.args]);
|
||||
|
||||
final String method;
|
||||
final List<dynamic>? args;
|
||||
}
|
||||
|
||||
class WebReloadController {
|
||||
final _controller = StreamController<JsEvent>.broadcast();
|
||||
|
||||
Stream<JsEvent> get stream => _controller.stream;
|
||||
void callEvent(JsEvent event) => _controller.add(event);
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
final webReloadControllerProvider = Provider<WebReloadController>((ref) {
|
||||
final controller = WebReloadController();
|
||||
ref.onDispose(() => unawaited(controller.dispose()));
|
||||
return controller;
|
||||
});
|
||||
|
||||
final webReloadPProvider = StreamProvider.autoDispose<JsEvent>((ref) {
|
||||
return ref.watch(webReloadControllerProvider).stream;
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'js_api_result.dart';
|
||||
|
||||
class JsApi extends JavaScriptNamespaceInterface {
|
||||
JsApi({
|
||||
this.onClose,
|
||||
this.onLoginRequested,
|
||||
this.onMallStateChanged,
|
||||
Future<String> Function()? appVersionResolver,
|
||||
}) : _appVersionResolver = appVersionResolver ?? _readAppVersion;
|
||||
|
||||
final VoidCallback? onClose;
|
||||
final VoidCallback? onLoginRequested;
|
||||
final ValueChanged<Map<dynamic, dynamic>?>? onMallStateChanged;
|
||||
final Future<String> Function() _appVersionResolver;
|
||||
|
||||
@override
|
||||
void register() {
|
||||
registerFunction(close);
|
||||
registerFunction(getAppVersion);
|
||||
registerFunction(mallStateChanged);
|
||||
registerFunction(login);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void close(dynamic arg, CompletionHandler handler) {
|
||||
onClose?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void getAppVersion(dynamic arg, CompletionHandler handler) {
|
||||
unawaited(_completeAppVersion(handler));
|
||||
}
|
||||
|
||||
Future<void> _completeAppVersion(CompletionHandler handler) async {
|
||||
try {
|
||||
final version = await _appVersionResolver();
|
||||
handler.complete(
|
||||
JsApiResult(
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: <String, dynamic>{'version': version},
|
||||
).toMap(),
|
||||
);
|
||||
} catch (_) {
|
||||
handler.complete(
|
||||
const JsApiResult(code: 1, message: 'app version unavailable').toMap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _readAppVersion() async =>
|
||||
(await PackageInfo.fromPlatform()).version;
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void mallStateChanged(dynamic arg, CompletionHandler handler) {
|
||||
onMallStateChanged?.call(coerceMallStateMap(arg));
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void login(dynamic arg, CompletionHandler handler) {
|
||||
onLoginRequested?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic>? coerceMallStateMap(dynamic arg) {
|
||||
if (arg is Map) return arg;
|
||||
if (arg is String && arg.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(arg);
|
||||
if (decoded is Map) return decoded;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (arg is List && arg.isNotEmpty && arg.first is Map) {
|
||||
return arg.first as Map<dynamic, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class JsApiResult {
|
||||
const JsApiResult({required this.code, required this.message, this.data});
|
||||
|
||||
const JsApiResult.success() : code = 0, message = 'success', data = null;
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
Map<String, dynamic> toMap() => <String, dynamic>{
|
||||
'code': code,
|
||||
'message': message,
|
||||
if (data != null) 'data': data,
|
||||
};
|
||||
|
||||
String toJson() => jsonEncode(toMap());
|
||||
}
|
||||
@@ -1,130 +1,703 @@
|
||||
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';
|
||||
|
||||
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
|
||||
class MallWebHost extends StatefulWidget {
|
||||
const MallWebHost({super.key, required this.onExit});
|
||||
final VoidCallback onExit;
|
||||
/// 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
|
||||
State<MallWebHost> createState() => _MallWebHostState();
|
||||
ConsumerState<MallWebHost> createState() => _MallWebHostState();
|
||||
}
|
||||
|
||||
class _MallWebHostState extends State<MallWebHost> {
|
||||
final _host = MallWebHostController.instance;
|
||||
WebViewController? _controller;
|
||||
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.app/web_file_picker',
|
||||
);
|
||||
|
||||
late final DWebViewController _controller;
|
||||
String? _loadedUrl;
|
||||
String? _error;
|
||||
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();
|
||||
_host.state.addListener(_sync);
|
||||
_host.attachBackHandler(_goBack);
|
||||
_sync();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ref.listenManual<AsyncValue<JsEvent>>(webReloadPProvider, (_, next) {
|
||||
next.whenData(_callH5Handler);
|
||||
});
|
||||
ref.listenManual<AsyncValue<AuthState>>(authProvider, (previous, next) {
|
||||
final previousAuth = previous?.valueOrNull;
|
||||
final nextAuth = next.valueOrNull;
|
||||
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() {
|
||||
_host.state.removeListener(_sync);
|
||||
_host.detachBackHandler();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
MallWebHostController.instance.setShowingQiyuPage(false);
|
||||
SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle);
|
||||
MallWebHostController.instance.detachCacheClearer();
|
||||
MallWebHostController.instance.detachSystemBackHandler();
|
||||
MallBridgeController.instance.detach();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final url = _host.state.value.url;
|
||||
if (url != null && url != _loadedUrl) _load(url);
|
||||
if (mounted) setState(() {});
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
void _load(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
_error = '商城链接不可用';
|
||||
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;
|
||||
}
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_loadedUrl = rawUrl;
|
||||
_controller ??= WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onExit();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == false) return;
|
||||
if (mounted) setState(() => _error = error.description);
|
||||
},
|
||||
onNavigationRequest: (request) async {
|
||||
final target = Uri.tryParse(request.url);
|
||||
if (target == null ||
|
||||
target.scheme == 'http' ||
|
||||
target.scheme == 'https') {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
if (await canLaunchUrl(target)) {
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
if (MallBridgeController.instance.state.value.canGoBack) {
|
||||
await _goBack();
|
||||
return;
|
||||
}
|
||||
_isHandlingSystemBack = true;
|
||||
try {
|
||||
await tryGoBackInWebHistory(
|
||||
canGoBack: _controller.canGoBack,
|
||||
goBack: _controller.goBack,
|
||||
);
|
||||
unawaited(_controller!.loadRequest(uri));
|
||||
} finally {
|
||||
_isHandlingSystemBack = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
final controller = _controller;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
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).valueOrNull 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(
|
||||
currentUri: uri!,
|
||||
logged: logged,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
authToken: mallAuth?.authToken,
|
||||
scene: mallAuth?.scene,
|
||||
sourceApp: mallAuth?.sourceApp,
|
||||
attribution: ref.read(mallClickAttributionProvider),
|
||||
);
|
||||
_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) {
|
||||
final current = _host.state.value;
|
||||
if (current.url == null) return const SizedBox.shrink();
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_controller != null)
|
||||
Positioned.fill(child: WebViewWidget(controller: _controller!)),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
const Text('商城加载失败'),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => _load(_loadedUrl ?? ''),
|
||||
child: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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('商城加载失败,点击重试'),
|
||||
),
|
||||
if (_error == null && _progress < 100)
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user