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

@@ -16,17 +16,21 @@ class AdConfigForegroundRefresher {
final void Function() _banner;
final Future<void> Function() _splash, _mall;
Future<void>? _running;
Future<void> refresh() =>
_running ??= _refresh().whenComplete(() => _running = null);
Future<void> _refresh() async {
Future<void> refresh({bool refreshMallTabConfig = true}) =>
_running ??= _refresh(
refreshMallTabConfig: refreshMallTabConfig,
).whenComplete(() => _running = null);
Future<void> _refresh({required bool refreshMallTabConfig}) async {
try {
_banner();
} catch (_) {}
try {
await _splash();
} catch (_) {}
try {
await _mall();
} catch (_) {}
if (refreshMallTabConfig) {
try {
await _mall();
} catch (_) {}
}
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
@@ -218,7 +219,7 @@ class ProtoMessage {
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? String.fromCharCodes(value) : '';
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
}
ProtoMessage message(int tag) {
@@ -71,9 +71,29 @@ class _SplashAdDialogState extends State<SplashAdDialog> {
Positioned(
top: MediaQuery.paddingOf(context).top + 16,
right: 18,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
child: Text('跳过 ${remain}s'),
child: GestureDetector(
key: const ValueKey('splash-skip'),
onTap: () => Navigator.of(context).pop(SplashAdResult.skipped),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 7,
),
child: Text(
'跳过 ${remain > 0 ? remain : 0}s',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
],
@@ -6,6 +6,7 @@ import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/network/dio_provider.dart';
import '../data/auth_models.dart';
import '../data/auth_api.dart';
import '../data/one_key_login_api.dart';
final authProvider =
@@ -40,21 +41,21 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
}
Future<void> sendCode(String phone) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await AuthApi(_dio).sendSmsCode(phone);
}
Future<void> verifyCode(String phone, String code) async {
final token = 'mock-jinzhi-token-$phone-$code';
await saveJwt(token);
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
final reply = await AuthApi(_dio).verifySmsCode(phone, code);
await saveJwt(reply.accessToken);
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone,
phone: reply.phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: token,
token: reply.accessToken,
),
);
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../common/network/dio_provider.dart';
/// 金值账号服务的 protobuf HTTP 契约。
///
/// 这个轻量编解码只覆盖短信登录需要的字段,避免把整套历史生成模型带入
/// 当前重建中的应用;字段号与金值 `account.proto` 保持一致。
class AuthApi {
const AuthApi(this._dio);
final Dio _dio;
Future<int> sendSmsCode(String phone) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/send',
data: _ProtoWriter.stringField(1, phone),
options: protobufOptions(),
);
return _ProtoMessage(response.data ?? const <int>[]).integer(1);
}
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/verify',
data: [
..._ProtoWriter.stringField(1, phone),
..._ProtoWriter.stringField(2, code),
],
options: protobufOptions(),
);
final reply = _ProtoMessage(response.data ?? const <int>[]);
final accessToken = reply.string(1).trim();
if (accessToken.isEmpty) {
throw const FormatException('登录响应缺少 access_token');
}
final user = _ProtoMessage(reply.bytes(3));
return AuthLoginResult(
accessToken: accessToken,
phone: user.string(2).trim().isEmpty ? phone : user.string(2).trim(),
);
}
}
class AuthLoginResult {
const AuthLoginResult({required this.accessToken, required this.phone});
final String accessToken;
final String phone;
}
class _ProtoWriter {
static List<int> stringField(int field, String value) => [
..._varint(field << 3 | 2),
..._varint(utf8.encode(value).length),
...utf8.encode(value),
];
static List<int> _varint(int value) {
final bytes = <int>[];
var remaining = value;
while (remaining > 0x7f) {
bytes.add(remaining & 0x7f | 0x80);
remaining >>= 7;
}
bytes.add(remaining);
return bytes;
}
}
class _ProtoMessage {
_ProtoMessage(List<int> bytes) : _fields = _parse(bytes);
final Map<int, Object> _fields;
int integer(int field) => _fields[field] is int ? _fields[field]! as int : 0;
String string(int field) => _fields[field] is List<int>
? utf8.decode(_fields[field]! as List<int>, allowMalformed: true)
: '';
List<int> bytes(int field) => _fields[field] is List<int>
? _fields[field]! as List<int>
: const <int>[];
static Map<int, Object> _parse(List<int> bytes) {
final fields = <int, Object>{};
var cursor = 0;
while (cursor < bytes.length) {
final tag = _readVarint(bytes, cursor);
if (tag == null) break;
cursor = tag.next;
final field = tag.value >> 3;
switch (tag.value & 7) {
case 0:
final value = _readVarint(bytes, cursor);
if (value == null) return fields;
fields[field] = value.value;
cursor = value.next;
case 2:
final length = _readVarint(bytes, cursor);
if (length == null || length.next + length.value > bytes.length) {
return fields;
}
fields[field] = bytes.sublist(
length.next,
length.next + length.value,
);
cursor = length.next + length.value;
default:
return fields;
}
}
return fields;
}
static _ProtoValue? _readVarint(List<int> bytes, int cursor) {
var value = 0;
var shift = 0;
while (cursor < bytes.length && shift <= 63) {
final byte = bytes[cursor++];
value |= (byte & 0x7f) << shift;
if (byte & 0x80 == 0) return _ProtoValue(value, cursor);
shift += 7;
}
return null;
}
}
class _ProtoValue {
const _ProtoValue(this.value, this.next);
final int value;
final int next;
}
@@ -16,7 +16,6 @@ import '../../../common/router/app_router.dart';
import '../../../common/theme/spacing.dart';
import '../../../common/widgets/circle_back_button.dart';
import '../application/auth_controller.dart';
import '../application/login_launcher.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key, this.next});
@@ -32,7 +31,6 @@ class LoginPage extends HookConsumerWidget {
final agreed = useState(false);
final sending = useState(false);
final verifying = useState(false);
final oneKeyLoading = useState(false);
final countdown = useState(0);
final error = useState<String?>(null);
final timer = useRef<Timer?>(null);
@@ -165,9 +163,6 @@ class LoginPage extends HookConsumerWidget {
);
}
Future<void> launchOneKey() =>
LoginLauncher.start(context, ref, next: next);
void onSendTap() {
if (sending.value || countdown.value > 0) return;
if (phoneDigits().length != 11) {
@@ -227,8 +222,6 @@ class LoginPage extends HookConsumerWidget {
agreed: agreed.value,
onToggleAgreed: () => agreed.value = !agreed.value,
onSend: onSendTap,
oneKeyLoading: oneKeyLoading.value,
onOneKey: launchOneKey,
),
),
),
@@ -247,8 +240,6 @@ class _PhoneStep extends StatelessWidget {
required this.onToggleAgreed,
required this.error,
required this.onSend,
required this.oneKeyLoading,
required this.onOneKey,
});
final ShadColorScheme cs;
@@ -260,8 +251,6 @@ class _PhoneStep extends StatelessWidget {
final VoidCallback onToggleAgreed;
final String? error;
final VoidCallback onSend;
final bool oneKeyLoading;
final VoidCallback onOneKey;
@override
Widget build(BuildContext context) {
@@ -306,13 +295,6 @@ class _PhoneStep extends StatelessWidget {
dim: !phoneReady,
onTap: onSend,
),
const SizedBox(height: 10),
_PrimaryButton(
label: '本机号码一键登录',
enabled: !oneKeyLoading,
loading: oneKeyLoading,
onTap: onOneKey,
),
const SizedBox(height: 18),
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
],
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../web/application/mall_web_host_controller.dart';
import 'mall_tab_config.dart';
enum MallEntryOpenResult { opened, unavailable, failed, alreadyOpening }
class MallEntryController {
MallEntryController(this._ref);
final Ref _ref;
bool _opening = false;
Future<MallEntryOpenResult> open(
BuildContext context, {
MallTabConfig? initialConfig,
}) async {
if (_opening) return MallEntryOpenResult.alreadyOpening;
_opening = true;
try {
var config = initialConfig;
if (config?.isVisible != true) {
config = await _ref.read(mallTabConfigProvider.notifier).ensureLoaded();
}
if (config?.isVisible != true || !context.mounted) {
return MallEntryOpenResult.unavailable;
}
final clickId = await _ref
.read(mallTabConfigProvider.notifier)
.recordClick();
if (clickId == null || !context.mounted) {
return MallEntryOpenResult.unavailable;
}
_ref
.read(mallClickAttributionProvider.notifier)
.state = MallClickAttribution(
adId: config!.adId,
trackingId: config.trackingId,
clickId: clickId,
);
final uri = config.attributedUri(
clickId: clickId,
isDark: Theme.of(context).brightness == Brightness.dark,
);
if (uri == null) return MallEntryOpenResult.unavailable;
// The bottom mall entry is always resident. Server open-mode metadata is
// still retained for attribution, while explicit links inside H5 decide
// their own external handoff through the WebView navigation policy.
MallWebHostController.instance.open(uri);
return MallEntryOpenResult.opened;
} catch (_) {
return MallEntryOpenResult.failed;
} finally {
_opening = false;
}
}
}
final mallEntryControllerProvider = Provider((ref) => MallEntryController(ref));
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
@@ -145,6 +146,62 @@ final mallTabConfigProvider =
(ref) => MallTabConfigController(ref.watch(dioProvider)),
);
class MallClickAttribution {
const MallClickAttribution({
required this.adId,
required this.trackingId,
required this.clickId,
});
final String adId;
final String trackingId;
final String clickId;
}
final mallClickAttributionProvider = StateProvider<MallClickAttribution?>(
(ref) => null,
);
Map<String, dynamic> buildEnterWebMallBridgeArguments({
required Uri currentUri,
required bool logged,
required bool isDark,
String? authToken,
String? scene,
String? sourceApp,
MallClickAttribution? attribution,
}) {
final query = currentUri.queryParameters;
final resolvedSourceApp = sourceApp?.trim().isNotEmpty == true
? sourceApp!.trim()
: mallSourceApp;
final arguments = <String, dynamic>{
...query,
'from': resolvedSourceApp,
'source_app': resolvedSourceApp,
'source_entry': query['source_entry'] ?? 'mall_tab',
'placement': query['placement'] ?? 'mall_tab',
'hook_type': query['hook_type'] ?? 'mall_tab',
'target_app': query['target_app'] ?? 'mall',
'logged': logged,
'isNight': isDark ? '1' : '0',
};
final adId = attribution?.adId.trim() ?? '';
if (adId.isNotEmpty) arguments['ad_id'] = adId;
final trackingId = attribution?.trackingId.trim() ?? '';
if (trackingId.isNotEmpty) arguments['tracking_id'] = trackingId;
final clickId = attribution?.clickId.trim() ?? '';
if (clickId.isNotEmpty) arguments['click_id'] = clickId;
final token = authToken?.trim() ?? '';
if (token.isNotEmpty) {
arguments['auth_token'] = token;
arguments['authToken'] = token;
}
final resolvedScene = scene?.trim() ?? '';
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
return arguments;
}
String _nonEmpty(String value, String fallback) =>
value.trim().isEmpty ? fallback : value.trim();
@@ -166,7 +223,7 @@ class _ProtoMessage {
int integer(int wanted) =>
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
String string(int wanted) => _fields()[wanted] is List<int>
? String.fromCharCodes(_fields()[wanted]! as List<int>)
? utf8.decode(_fields()[wanted]! as List<int>, allowMalformed: true)
: '';
_ProtoMessage message(int wanted) => _ProtoMessage(
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
+45
View File
@@ -0,0 +1,45 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/network/dio_provider.dart';
import '../../ads/data/ads_api_client.dart';
class AppMallAuthToken {
const AppMallAuthToken({
required this.authToken,
required this.expiresIn,
required this.sourceApp,
required this.scene,
});
final String authToken;
final int expiresIn;
final String sourceApp;
final String scene;
}
class MallAuthApi {
const MallAuthApi(this._dio);
final Dio _dio;
Future<AppMallAuthToken> issueAppMallAuthToken() async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/mall/app-token',
data: const <int>[],
options: protobufOptions(),
);
final reply = ProtoMessage(response.data ?? const <int>[]);
final token = reply.string(1).trim();
if (token.isEmpty) throw const FormatException('Missing mall auth token');
return AppMallAuthToken(
authToken: token,
expiresIn: reply.integer(2),
sourceApp: reply.string(3),
scene: reply.string(4),
);
}
}
final mallAuthApiProvider = Provider(
(ref) => MallAuthApi(ref.watch(dioProvider)),
);
@@ -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;
});
+87
View File
@@ -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());
}
+662 -89
View File
@@ -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),
),
);
}