feat: 对齐常驻商城与七鱼客服交互

- 同步商城 H5 登录态与原生 token
- 完善商城/原生 tab 切换、动画及返回交互
- 修复七鱼客服键盘避让和状态栏恢复
- 调整开屏跳过按钮与登录入口交互
This commit is contained in:
jingyun committed 2026-09-07 15:47:01 +08:00
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;
});