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;
|
||||
});
|
||||
Reference in new issue
Block a user