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

+13
View File
@@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
@@ -55,6 +58,16 @@
android:name="UMENG_MESSAGE_SECRET" android:name="UMENG_MESSAGE_SECRET"
android:value="${UMENG_MESSAGE_SECRET}" /> android:value="${UMENG_MESSAGE_SECRET}" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/web_capture_paths" />
</provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -1,11 +1,28 @@
package com.jinzhi.app package com.jinzhi.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import com.umeng.commonsdk.UMConfigure import com.umeng.commonsdk.UMConfigure
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
companion object {
private const val WEB_FILE_PICKER_CHANNEL = "com.jinzhi.app/web_file_picker"
private const val WEB_FILE_PICKER_REQUEST_CODE = 9814
}
private var pendingFilePickerResult: MethodChannel.Result? = null
private var pendingCapturedFile: File? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy") MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
@@ -17,5 +34,140 @@ class MainActivity : FlutterActivity() {
result.notImplemented() result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WEB_FILE_PICKER_CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method != "pickFiles") {
result.notImplemented()
return@setMethodCallHandler
}
openWebFilePicker(call, result)
}
}
private fun openWebFilePicker(call: MethodCall, result: MethodChannel.Result) {
if (pendingFilePickerResult != null) {
result.error("picker_in_progress", "A file picker is already open.", null)
return
}
val acceptTypes = call.argument<List<String>>("acceptTypes")
.orEmpty().map(String::trim).filter(String::isNotEmpty).distinct()
val allowMultiple = call.argument<Boolean>("allowMultiple") ?: false
val captureEnabled = call.argument<Boolean>("captureEnabled") ?: false
val isQiyuSession = call.argument<Boolean>("isQiyuSession") ?: false
if (captureEnabled && !allowMultiple) {
captureIntentFor(acceptTypes)?.let { intent ->
pendingFilePickerResult = result
launchWebFilePicker(intent, result)
return
}
}
if (isQiyuSession && captureEnabled && isImageOrVideoRequest(acceptTypes)) {
val captureIntent = captureIntentFor(acceptTypes)
if (captureIntent != null) {
pendingFilePickerResult = result
val chooser = Intent.createChooser(
fileContentIntent(acceptTypes, allowMultiple),
"选择操作",
).putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(captureIntent))
launchWebFilePicker(chooser, result)
return
}
}
pendingFilePickerResult = result
launchWebFilePicker(
Intent.createChooser(fileContentIntent(acceptTypes, allowMultiple), "选择文件"),
result,
)
}
private fun fileContentIntent(acceptTypes: List<String>, allowMultiple: Boolean) =
Intent(Intent.ACTION_GET_CONTENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
.apply {
if (acceptTypes.size == 1) type = acceptTypes.first()
else {
type = "*/*"
if (acceptTypes.isNotEmpty()) {
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
}
}
}
private fun isImageOrVideoRequest(acceptTypes: List<String>): Boolean {
if (acceptTypes.isEmpty()) return false
val onlyImages = acceptTypes.all {
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
}
val onlyVideos = acceptTypes.all {
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
}
return onlyImages || onlyVideos
}
private fun captureIntentFor(acceptTypes: List<String>): Intent? {
val acceptsVideo = acceptTypes.any {
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
}
val acceptsImage = acceptTypes.isEmpty() || acceptTypes.any {
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
}
val action = when {
acceptsVideo && !acceptsImage -> MediaStore.ACTION_VIDEO_CAPTURE
acceptsImage -> MediaStore.ACTION_IMAGE_CAPTURE
else -> return null
}
val directory = File(cacheDir, "web_captures").apply { mkdirs() }
val suffix = if (action == MediaStore.ACTION_VIDEO_CAPTURE) ".mp4" else ".jpg"
val output = File.createTempFile("web_", suffix, directory)
val uri: Uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", output)
pendingCapturedFile = output
return Intent(action)
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
.apply { clipData = ClipData.newRawUri("web_capture", uri) }
}
private fun launchWebFilePicker(intent: Intent, result: MethodChannel.Result) {
try {
startActivityForResult(intent, WEB_FILE_PICKER_REQUEST_CODE)
} catch (_: ActivityNotFoundException) {
pendingFilePickerResult = null
pendingCapturedFile?.delete()
pendingCapturedFile = null
result.success(emptyList<String>())
}
}
@Deprecated("Deprecated in Android, retained for FlutterActivity compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != WEB_FILE_PICKER_REQUEST_CODE) return
val result = pendingFilePickerResult ?: return
pendingFilePickerResult = null
if (resultCode != Activity.RESULT_OK) {
pendingCapturedFile?.delete()
pendingCapturedFile = null
result.success(emptyList<String>())
return
}
val capturedFile = pendingCapturedFile
val hasCapturedFile = capturedFile?.length()?.let { it > 0L } == true
val uris = buildList {
if (hasCapturedFile) {
add(FileProvider.getUriForFile(
this@MainActivity, "$packageName.fileprovider", capturedFile!!,
).toString())
} else {
data?.data?.let { add(it.toString()) }
data?.clipData?.let { clip ->
for (index in 0 until clip.itemCount) add(clip.getItemAt(index).uri.toString())
}
}
}.distinct()
if (!hasCapturedFile) capturedFile?.delete()
pendingCapturedFile = null
result.success(uris)
} }
} }
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="web_captures" path="web_captures/" />
</paths>
+4
View File
@@ -42,6 +42,10 @@
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
<true/> <true/>
</dict> </dict>
<key>NSCameraUsageDescription</key>
<string>用于在商城客服中拍摄图片或视频并发送给客服。</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于在商城客服中录制语音或视频并发送给客服。</string>
<key>NSUserTrackingUsageDescription</key> <key>NSUserTrackingUsageDescription</key>
<string>用于衡量广告投放效果并提供更相关的内容。</string> <string>用于衡量广告投放效果并提供更相关的内容。</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
+9 -1
View File
@@ -14,6 +14,7 @@ import '../common/theme/spacing.dart';
import '../common/theme/theme_controller.dart'; import '../common/theme/theme_controller.dart';
import '../features/ads/application/ad_config_foreground_refresher.dart'; import '../features/ads/application/ad_config_foreground_refresher.dart';
import '../features/settings/application/update_foreground_service.dart'; import '../features/settings/application/update_foreground_service.dart';
import '../features/web/application/mall_web_host_controller.dart';
import '../common/router/deep_link_service.dart'; import '../common/router/deep_link_service.dart';
import '../common/services/device_header_service.dart'; import '../common/services/device_header_service.dart';
@@ -54,7 +55,14 @@ class _JinzhiAppState extends ConsumerState<JinzhiApp>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
unawaited(_pushRegistrar.refresh()); unawaited(_pushRegistrar.refresh());
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh()); unawaited(
ref
.read(adConfigForegroundRefresherProvider)
.refresh(
refreshMallTabConfig:
!MallWebHostController.instance.state.value.visible,
),
);
final context = rootNavigatorKey.currentContext; final context = rootNavigatorKey.currentContext;
if (context != null) { if (context != null) {
unawaited(UpdateForegroundService.check(context, ref)); unawaited(UpdateForegroundService.check(context, ref));
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../features/web/application/mall_web_host_controller.dart';
import '../storage/storage_service.dart'; import '../storage/storage_service.dart';
/// Clears only disposable presentation data. Credentials and user preferences /// Clears only disposable presentation data. Credentials and user preferences
@@ -14,6 +15,7 @@ class CacheClearService {
static Future<void> clear() async { static Future<void> clear() async {
await DefaultCacheManager().emptyCache(); await DefaultCacheManager().emptyCache();
await MallWebHostController.instance.clear();
await SplashAdControllerPlaceholder.clear(); await SplashAdControllerPlaceholder.clear();
} }
+184
View File
@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart'; import 'package:flutter_remix/flutter_remix.dart';
import 'package:not_static_icons/not_static_icons.dart' as nsi;
// flutter_remix 0.0.3: icon class is FlutterRemix // flutter_remix 0.0.3: icon class is FlutterRemix
class AppIcons { class AppIcons {
@@ -13,3 +17,183 @@ class AppIcons {
static const arrowRight = FlutterRemix.arrow_right_s_line; static const arrowRight = FlutterRemix.arrow_right_s_line;
static const arrowLeft = FlutterRemix.arrow_left_s_line; static const arrowLeft = FlutterRemix.arrow_left_s_line;
} }
enum AppBottomTabSymbol { assets, market, news, profile }
class AppBottomTabIcon extends StatefulWidget {
const AppBottomTabIcon({
required this.symbol,
required this.selected,
required this.unselectedColor,
required this.selectedColor,
this.size = 24,
super.key,
});
final AppBottomTabSymbol symbol;
final bool selected;
final Color unselectedColor;
final Color selectedColor;
final double size;
@override
State<AppBottomTabIcon> createState() => _AppBottomTabIconState();
}
class _AppBottomTabIconState extends State<AppBottomTabIcon>
with TickerProviderStateMixin {
static const _iconMotionDuration = Duration(milliseconds: 420);
static const _selectionDuration = Duration(milliseconds: 140);
static const _popDuration = Duration(milliseconds: 260);
static const _scalePeak = 1.06;
final nsi.AnimatedIconController _iconController =
nsi.AnimatedIconController();
late final AnimationController _selectionController = AnimationController(
vsync: this,
duration: _selectionDuration,
reverseDuration: _selectionDuration,
value: widget.selected ? 1 : 0,
);
late final AnimationController _popController = AnimationController(
vsync: this,
duration: _popDuration,
);
late final CurvedAnimation _colorProgress = CurvedAnimation(
parent: _selectionController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
);
late final Animation<double> _scaleAnimation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 1.0,
end: _scalePeak,
).chain(CurveTween(curve: Curves.easeOutBack)),
weight: 55,
),
TweenSequenceItem(
tween: Tween(
begin: _scalePeak,
end: 1.0,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: 45,
),
]).animate(_popController);
bool get _reduceMotion => MediaQuery.disableAnimationsOf(context);
@override
void initState() {
super.initState();
if (widget.selected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_reduceMotion) _iconController.animate();
});
}
}
@override
void didUpdateWidget(covariant AppBottomTabIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selected == oldWidget.selected) return;
if (widget.selected) {
if (_reduceMotion) {
_selectionController.value = 1;
_popController.value = 0;
} else {
unawaited(_selectionController.forward(from: 0));
unawaited(_popController.forward(from: 0));
_iconController.animate();
}
} else {
if (_reduceMotion) {
_selectionController.value = 0;
} else {
unawaited(_selectionController.reverse());
}
_popController.value = 0;
_iconController.reset();
}
}
@override
void dispose() {
_colorProgress.dispose();
_selectionController.dispose();
_popController.dispose();
_iconController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final reduceMotion = _reduceMotion;
return SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: Listenable.merge([_selectionController, _popController]),
builder: (context, _) {
final color = Color.lerp(
widget.unselectedColor,
widget.selectedColor,
reduceMotion ? (widget.selected ? 1 : 0) : _colorProgress.value,
)!;
return Transform.scale(
scale: reduceMotion ? 1 : _scaleAnimation.value,
child: _buildBottomTabIcon(
widget.symbol,
size: widget.size,
color: color,
controller: _iconController,
),
);
},
),
);
}
}
Widget _buildBottomTabIcon(
AppBottomTabSymbol symbol, {
required double size,
required Color color,
required nsi.AnimatedIconController controller,
}) {
const strokeWidth = 2.05;
const duration = _AppBottomTabIconState._iconMotionDuration;
return switch (symbol) {
AppBottomTabSymbol.assets => nsi.CoinsIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.market => nsi.ChartCandlestickIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.news => nsi.BookOpenTextIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.profile => nsi.CircleUserIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
};
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io' show Platform;
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart';
typedef AndroidIntentLauncher = Future<void> Function(AndroidIntent intent);
abstract final class AppLinkUtil {
static final _androidPackageNamePattern = RegExp(
r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$',
);
static String targetAppFromUrl(String urlString) {
final value = Uri.tryParse(urlString)?.queryParameters['targetApp']?.trim();
if (value == null || !_androidPackageNamePattern.hasMatch(value)) return '';
return value;
}
static Future<bool> openExternal(Uri uri, {String packageName = ''}) =>
AppLinkLauncher(
isAndroid: () => !kIsWeb && Platform.isAndroid,
launchAndroidIntent: (intent) => intent.launch(),
).openExternal(uri, packageName: packageName);
}
class AppLinkLauncher {
const AppLinkLauncher({
required this.isAndroid,
required this.launchAndroidIntent,
this.launchExternal,
});
final bool Function() isAndroid;
final AndroidIntentLauncher launchAndroidIntent;
final Future<bool> Function(Uri uri)? launchExternal;
Future<bool> openExternal(Uri uri, {String packageName = ''}) async {
if (uri.host.isEmpty ||
(uri.scheme.toLowerCase() != 'http' &&
uri.scheme.toLowerCase() != 'https')) {
return false;
}
if (isAndroid() && packageName.trim().isNotEmpty) {
try {
await launchAndroidIntent(
AndroidIntent(
action: 'action_view',
data: uri.toString(),
package: packageName.trim(),
),
);
return true;
} catch (_) {}
}
final launcher = launchExternal;
if (launcher != null) return launcher(uri);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@@ -16,17 +16,21 @@ class AdConfigForegroundRefresher {
final void Function() _banner; final void Function() _banner;
final Future<void> Function() _splash, _mall; final Future<void> Function() _splash, _mall;
Future<void>? _running; Future<void>? _running;
Future<void> refresh() => Future<void> refresh({bool refreshMallTabConfig = true}) =>
_running ??= _refresh().whenComplete(() => _running = null); _running ??= _refresh(
Future<void> _refresh() async { refreshMallTabConfig: refreshMallTabConfig,
).whenComplete(() => _running = null);
Future<void> _refresh({required bool refreshMallTabConfig}) async {
try { try {
_banner(); _banner();
} catch (_) {} } catch (_) {}
try { try {
await _splash(); await _splash();
} catch (_) {} } catch (_) {}
try { if (refreshMallTabConfig) {
await _mall(); try {
} catch (_) {} await _mall();
} catch (_) {}
}
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -218,7 +219,7 @@ class ProtoMessage {
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0; int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) { String string(int tag) {
final value = _fields[tag]?.lastOrNull; 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) { ProtoMessage message(int tag) {
@@ -71,9 +71,29 @@ class _SplashAdDialogState extends State<SplashAdDialog> {
Positioned( Positioned(
top: MediaQuery.paddingOf(context).top + 16, top: MediaQuery.paddingOf(context).top + 16,
right: 18, right: 18,
child: FilledButton( child: GestureDetector(
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped), key: const ValueKey('splash-skip'),
child: Text('跳过 ${remain}s'), 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/storage/storage_service.dart';
import '../../../common/network/dio_provider.dart'; import '../../../common/network/dio_provider.dart';
import '../data/auth_models.dart'; import '../data/auth_models.dart';
import '../data/auth_api.dart';
import '../data/one_key_login_api.dart'; import '../data/one_key_login_api.dart';
final authProvider = final authProvider =
@@ -40,21 +41,21 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
} }
Future<void> sendCode(String phone) async { 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 { Future<void> verifyCode(String phone, String code) async {
final token = 'mock-jinzhi-token-$phone-$code'; final reply = await AuthApi(_dio).verifySmsCode(phone, code);
await saveJwt(token); await saveJwt(reply.accessToken);
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone); await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
state = AsyncData( state = AsyncData(
LoggedInAuthState( LoggedInAuthState(
user: JinzhiUser( user: JinzhiUser(
phone: phone, phone: reply.phone,
nickname: '金值用户', nickname: '金值用户',
createdAt: DateTime.now(), 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/theme/spacing.dart';
import '../../../common/widgets/circle_back_button.dart'; import '../../../common/widgets/circle_back_button.dart';
import '../application/auth_controller.dart'; import '../application/auth_controller.dart';
import '../application/login_launcher.dart';
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {
const LoginPage({super.key, this.next}); const LoginPage({super.key, this.next});
@@ -32,7 +31,6 @@ class LoginPage extends HookConsumerWidget {
final agreed = useState(false); final agreed = useState(false);
final sending = useState(false); final sending = useState(false);
final verifying = useState(false); final verifying = useState(false);
final oneKeyLoading = useState(false);
final countdown = useState(0); final countdown = useState(0);
final error = useState<String?>(null); final error = useState<String?>(null);
final timer = useRef<Timer?>(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() { void onSendTap() {
if (sending.value || countdown.value > 0) return; if (sending.value || countdown.value > 0) return;
if (phoneDigits().length != 11) { if (phoneDigits().length != 11) {
@@ -227,8 +222,6 @@ class LoginPage extends HookConsumerWidget {
agreed: agreed.value, agreed: agreed.value,
onToggleAgreed: () => agreed.value = !agreed.value, onToggleAgreed: () => agreed.value = !agreed.value,
onSend: onSendTap, onSend: onSendTap,
oneKeyLoading: oneKeyLoading.value,
onOneKey: launchOneKey,
), ),
), ),
), ),
@@ -247,8 +240,6 @@ class _PhoneStep extends StatelessWidget {
required this.onToggleAgreed, required this.onToggleAgreed,
required this.error, required this.error,
required this.onSend, required this.onSend,
required this.oneKeyLoading,
required this.onOneKey,
}); });
final ShadColorScheme cs; final ShadColorScheme cs;
@@ -260,8 +251,6 @@ class _PhoneStep extends StatelessWidget {
final VoidCallback onToggleAgreed; final VoidCallback onToggleAgreed;
final String? error; final String? error;
final VoidCallback onSend; final VoidCallback onSend;
final bool oneKeyLoading;
final VoidCallback onOneKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -306,13 +295,6 @@ class _PhoneStep extends StatelessWidget {
dim: !phoneReady, dim: !phoneReady,
onTap: onSend, onTap: onSend,
), ),
const SizedBox(height: 10),
_PrimaryButton(
label: '本机号码一键登录',
enabled: !oneKeyLoading,
loading: oneKeyLoading,
onTap: onOneKey,
),
const SizedBox(height: 18), const SizedBox(height: 18),
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs), _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 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -145,6 +146,62 @@ final mallTabConfigProvider =
(ref) => MallTabConfigController(ref.watch(dioProvider)), (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) => String _nonEmpty(String value, String fallback) =>
value.trim().isEmpty ? fallback : value.trim(); value.trim().isEmpty ? fallback : value.trim();
@@ -166,7 +223,7 @@ class _ProtoMessage {
int integer(int wanted) => int integer(int wanted) =>
_fields()[wanted] is int ? _fields()[wanted]! as int : 0; _fields()[wanted] is int ? _fields()[wanted]! as int : 0;
String string(int wanted) => _fields()[wanted] is List<int> 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( _ProtoMessage message(int wanted) => _ProtoMessage(
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [], _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'; import 'package:flutter/foundation.dart';
class MallWebHostState { 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 String? url;
final bool visible; final bool visible;
final bool isShowingQiyuPage;
final bool isQiyuKeyboardActive;
} }
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。 /// Process-lifetime presentation state for the resident mall WebView.
class MallWebHostController { class MallWebHostController {
MallWebHostController._(); MallWebHostController._();
static final instance = MallWebHostController._();
final state = ValueNotifier(const MallWebHostState());
Future<void> Function()? _backHandler;
void preload(String url) { static final instance = MallWebHostController._();
final old = state.value;
if (old.url == url) return; final state = ValueNotifier(const MallWebHostState());
state.value = MallWebHostState(url: url, visible: old.visible); 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) => void preload(Uri url) {
state.value = MallWebHostState(url: url, visible: true); final current = state.value;
void dismiss() => state.value = MallWebHostState(url: state.value.url); if (current.visible) {
void clear() => state.value = const MallWebHostState(); if (current.url == null ||
void attachBackHandler(Future<void> Function() handler) => cacheKeyFor(current.url!) != cacheKeyFor(url.toString())) {
_backHandler = handler; _pendingPreloadUrl = url.toString();
void detachBackHandler() => _backHandler = null; }
Future<void> handleBack() => _backHandler?.call() ?? Future.value(); 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 '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/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:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.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/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 宿主。 /// Process-lifetime resident mall WebView. The controller, bridge contract and
class MallWebHost extends StatefulWidget { /// first-load ordering intentionally match the Purse implementation.
const MallWebHost({super.key, required this.onExit}); class MallWebHost extends ConsumerStatefulWidget {
final VoidCallback onExit; const MallWebHost({
super.key,
required this.url,
required this.visible,
required this.onRequestExit,
});
final String url;
final bool visible;
final VoidCallback onRequestExit;
@override @override
State<MallWebHost> createState() => _MallWebHostState(); ConsumerState<MallWebHost> createState() => _MallWebHostState();
} }
class _MallWebHostState extends State<MallWebHost> { class _MallWebHostState extends ConsumerState<MallWebHost>
final _host = MallWebHostController.instance; with WidgetsBindingObserver {
WebViewController? _controller; 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? _loadedUrl;
String? _error; String? _errorText;
int _progress = 0; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_host.state.addListener(_sync); WidgetsBinding.instance.addObserver(this);
_host.attachBackHandler(_goBack); ref.listenManual<AsyncValue<JsEvent>>(webReloadPProvider, (_, next) {
_sync(); 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 @override
void dispose() { void dispose() {
_host.state.removeListener(_sync); WidgetsBinding.instance.removeObserver(this);
_host.detachBackHandler(); MallWebHostController.instance.setShowingQiyuPage(false);
SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle);
MallWebHostController.instance.detachCacheClearer();
MallWebHostController.instance.detachSystemBackHandler();
MallBridgeController.instance.detach();
super.dispose(); super.dispose();
} }
void _sync() { @override
final url = _host.state.value.url; void didUpdateWidget(covariant MallWebHost oldWidget) {
if (url != null && url != _loadedUrl) _load(url); super.didUpdateWidget(oldWidget);
if (mounted) setState(() {}); 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) { Future<void> _switchTab(MallBridgeTab tab, bool reselect) async =>
final uri = Uri.tryParse(rawUrl); _controller.callHandler('mall.switchTab', args: [tab.name, reselect]);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
_error = '商城链接不可用'; 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; return;
} }
_error = null; if (MallBridgeController.instance.state.value.canGoBack) {
_progress = 0; await _goBack();
_loadedUrl = rawUrl; return;
_controller ??= WebViewController() }
..setJavaScriptMode(JavaScriptMode.unrestricted) _isHandlingSystemBack = true;
..addJavaScriptChannel( try {
'JinzhiBridge', await tryGoBackInWebHistory(
onMessageReceived: (message) { canGoBack: _controller.canGoBack,
if (message.message == 'close') widget.onExit(); goBack: _controller.goBack,
},
)
..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;
},
),
); );
unawaited(_controller!.loadRequest(uri)); } finally {
_isHandlingSystemBack = false;
}
} }
Future<void> _goBack() async { Future<NavigationDecision> _openQiyuWithMallReferer(String rawUrl) async {
final controller = _controller; final mallUri = Uri.tryParse(_loadedUrl ?? '');
if (controller != null && await controller.canGoBack()) { final qiyuUri = Uri.tryParse(rawUrl);
await controller.goBack(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final current = _host.state.value; // Qiyu already consumes CSS safe-area values. Adding Flutter's status-bar
if (current.url == null) return const SizedBox.shrink(); // inset here shifts its own blue navigation bar down a second time.
return ColoredBox( const topInset = 0.0;
color: Theme.of(context).scaffoldBackgroundColor, final bottomInset = qiyuWebViewBottomInset(
child: Stack( isShowingQiyuPage: _isShowingQiyuPage || _hasEmbeddedQiyuFrame,
children: [ platform: defaultTargetPlatform,
if (_controller != null) keyboardInset: MediaQuery.viewInsetsOf(context).bottom,
Positioned.fill(child: WebViewWidget(controller: _controller!)), isWeb: kIsWeb,
if (_error != null) );
Center( final showLoading = shouldShowMallLoading(
child: Column( hasError: _errorText != null,
mainAxisSize: MainAxisSize.min, hasDisplayedPage: _hasDisplayedPage,
children: [ progress: _progress,
const Icon(Icons.wifi_off_rounded, size: 32), );
const SizedBox(height: 12), final content = Stack(
const Text('商城加载失败'), children: [
const SizedBox(height: 8), if (_errorText == null)
TextButton( Positioned(
onPressed: () => _load(_loadedUrl ?? ''), top: topInset,
child: const Text('重新加载'), 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),
), ),
); );
} }
+17 -1
View File
@@ -355,6 +355,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
dsbridge_flutter:
dependency: "direct main"
description:
name: dsbridge_flutter
sha256: "887ee2b50f43be294815af33f9a8499f35ce7e7bbb8eeb2234a0e1984d1254e0"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
easy_refresh: easy_refresh:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -872,6 +880,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
not_static_icons:
dependency: "direct main"
description:
name: not_static_icons
sha256: "6150cea030b323eb0d18f9fe7a03d3693dd8a7ba18151fa8e560f099fd3e1db8"
url: "https://pub.dev"
source: hosted
version: "0.46.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@@ -1673,7 +1689,7 @@ packages:
source: hosted source: hosted
version: "4.13.0" version: "4.13.0"
webview_flutter_android: webview_flutter_android:
dependency: transitive dependency: "direct main"
description: description:
name: webview_flutter_android name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
+3
View File
@@ -35,12 +35,15 @@ dependencies:
shadcn_ui: ^0.53.6 shadcn_ui: ^0.53.6
google_fonts: ^6.2.1 google_fonts: ^6.2.1
flutter_remix: ^0.0.3 flutter_remix: ^0.0.3
not_static_icons: ^0.46.0
shimmer: ^3.0.0 shimmer: ^3.0.0
# 网络图片与磁盘缓存 # 网络图片与磁盘缓存
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
webview_flutter: 4.13.0 webview_flutter: 4.13.0
webview_flutter_android: 4.12.0
webview_flutter_wkwebview: 3.25.0 webview_flutter_wkwebview: 3.25.0
dsbridge_flutter: ^1.2.0
flutter_app_update: ^3.2.1 flutter_app_update: ^3.2.1
android_intent_plus: ^6.0.0 android_intent_plus: ^6.0.0
permission_handler: ^12.0.1 permission_handler: ^12.0.1
+13
View File
@@ -0,0 +1,13 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
void main() {
test('protobuf strings are decoded as UTF-8', () {
final value = utf8.encode('商城');
final message = ProtoMessage(<int>[10, value.length, ...value]);
expect(message.string(1), '商城');
});
}
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
void main() {
test('preserves Jinzhi attribution while syncing H5 login', () {
final arguments = buildEnterWebMallBridgeArguments(
currentUri: Uri.parse(
'https://mall.example.com/?from=jinzhi&source_app=jinzhi'
'&source_entry=mall_tab&placement=mall_tab&hook_type=mall_tab'
'&tracking_id=tracking&click_id=click-1&ts=123&isNight=0',
),
logged: true,
isDark: true,
authToken: 'mall-token',
scene: 'web_mall',
sourceApp: 'jinzhi',
attribution: const MallClickAttribution(
adId: 'latest-ad',
trackingId: 'latest-tracking',
clickId: 'latest-click',
),
);
expect(arguments['from'], 'jinzhi');
expect(arguments['source_app'], 'jinzhi');
expect(arguments['ad_id'], 'latest-ad');
expect(arguments['tracking_id'], 'latest-tracking');
expect(arguments['click_id'], 'latest-click');
expect(arguments['isNight'], '1');
expect(arguments['auth_token'], 'mall-token');
expect(arguments['authToken'], 'mall-token');
expect(arguments['scene'], 'web_mall');
});
}
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
void main() {
test('builds the resident mall URL with Jinzhi attribution and theme', () {
const config = MallTabConfig(
enabled: true,
url: 'https://mall.example.com/home?campaign=gold',
adId: 'ad-1',
trackingId: 'tracking-1',
);
final uri = config.attributedUri(clickId: 'click-1', isDark: true)!;
expect(uri.queryParameters['campaign'], 'gold');
expect(uri.queryParameters['source_app'], 'jinzhi');
expect(uri.queryParameters['source_entry'], 'mall_tab');
expect(uri.queryParameters['ad_id'], 'ad-1');
expect(uri.queryParameters['tracking_id'], 'tracking-1');
expect(uri.queryParameters['click_id'], 'click-1');
expect(uri.queryParameters['isNight'], '1');
});
test('retains server open-mode metadata', () {
const resident = MallTabConfig(
enabled: true,
url: 'https://mall.example.com',
);
const nativeApp = MallTabConfig(
enabled: true,
url: 'https://mall.example.com',
openMode: 'native_app',
);
const external = MallTabConfig(
enabled: true,
url: 'https://mall.example.com',
openMode: 'external',
);
expect(resident.opensInApp, isTrue);
expect(nativeApp.opensInApp, isFalse);
expect(external.opensInApp, isFalse);
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/presentation/js_api.dart';
void main() {
test('close invokes the host callback and returns success', () {
var closeCalls = 0;
final handler = _FakeCompletionHandler();
final api = JsApi(onClose: () => closeCalls++);
api.close(null, handler);
expect(closeCalls, 1);
expect(handler.result, <String, dynamic>{'code': 0, 'message': 'success'});
});
test('coerces JSON mall state payloads', () {
expect(
coerceMallStateMap(
'{"ready":true,"activeTab":"market","canGoBack":false}',
),
<String, dynamic>{
'ready': true,
'activeTab': 'market',
'canGoBack': false,
},
);
});
}
class _FakeCompletionHandler implements CompletionHandler {
dynamic result;
@override
void complete([dynamic retValue]) => result = retValue;
@override
void setProgressData(dynamic value) {}
}
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/mall_bridge_state.dart';
void main() {
test('updates state and forwards tab interactions before ready', () async {
final bridge = MallBridgeController.instance;
bridge.detach();
addTearDown(bridge.detach);
MallBridgeTab? activated;
bool? reselected;
var backCalls = 0;
bridge.attach(
activateTab: (tab, reselect) async {
activated = tab;
reselected = reselect;
},
goBack: () async => backCalls++,
);
await bridge.activate(MallBridgeTab.orders);
expect(activated, MallBridgeTab.orders);
expect(bridge.state.value.activeTab, MallBridgeTab.orders);
bridge.update(const {
'ready': true,
'activeTab': 'orders',
'canGoBack': true,
});
await bridge.activate(MallBridgeTab.orders, reselect: true);
await bridge.goBack();
expect(bridge.state.value.activeTab, MallBridgeTab.orders);
expect(bridge.state.value.canGoBack, isTrue);
expect(activated, MallBridgeTab.orders);
expect(reselected, isTrue);
expect(backCalls, 1);
});
test('accepts numeric and string booleans from H5', () {
final bridge = MallBridgeController.instance;
bridge.detach();
addTearDown(bridge.detach);
bridge.update(const {
'ready': 1,
'activeTab': 'market',
'canGoBack': 'true',
});
expect(bridge.state.value.ready, isTrue);
expect(bridge.state.value.activeTab, MallBridgeTab.market);
expect(bridge.state.value.canGoBack, isTrue);
});
}
@@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/mall_web_host_controller.dart';
void main() {
final host = MallWebHostController.instance;
setUp(() async => host.clear());
tearDown(() async => host.clear());
test('opens and dismisses the resident full-screen mall host', () {
final uri = Uri.parse('https://mall.example.com/?isNight=0');
host.open(uri);
expect(host.state.value.url, uri.toString());
expect(host.state.value.visible, isTrue);
host.dismiss();
expect(host.state.value.url, uri.toString());
expect(host.state.value.visible, isFalse);
});
test('cache identity ignores attribution but includes theme', () {
const first =
'https://mall.example.com/path?isNight=0&click_id=a&tracking_id=t1';
const second =
'https://mall.example.com/path?isNight=0&click_id=b&tracking_id=t2';
const dark = 'https://mall.example.com/path?isNight=1&click_id=b';
expect(
MallWebHostController.cacheKeyFor(first),
MallWebHostController.cacheKeyFor(second),
);
expect(
MallWebHostController.cacheKeyFor(first),
isNot(MallWebHostController.cacheKeyFor(dark)),
);
});
test('defers a changed preload while the mall is visible', () {
final active = Uri.parse('https://mall.example.com/?isNight=0');
final refreshed = Uri.parse('https://mall.example.com/?isNight=1');
host.open(active);
host.preload(refreshed);
expect(host.state.value.url, active.toString());
expect(host.state.value.visible, isTrue);
host.dismiss();
expect(host.state.value.url, refreshed.toString());
expect(host.state.value.visible, isFalse);
});
test('themed URL replaces a stale isNight value', () {
final uri = MallWebHostController.uriForTheme(
Uri.parse('https://mall.example.com/?from=jinzhi&isNight=1'),
isDark: false,
);
expect(uri.queryParameters['from'], 'jinzhi');
expect(uri.queryParameters['isNight'], '0');
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/presentation/mall_web_host.dart';
void main() {
test('loading only covers the WebView before its first page', () {
expect(
shouldShowMallLoading(
hasError: false,
hasDisplayedPage: false,
progress: 0,
),
isTrue,
);
expect(
shouldShowMallLoading(
hasError: false,
hasDisplayedPage: true,
progress: 0,
),
isFalse,
);
expect(
shouldShowMallLoading(
hasError: true,
hasDisplayedPage: false,
progress: 0,
),
isFalse,
);
});
test('only Android Qiyu pages reserve keyboard space for the WebView', () {
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: true,
platform: TargetPlatform.android,
keyboardInset: 336,
isWeb: false,
),
336,
);
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: false,
platform: TargetPlatform.android,
keyboardInset: 336,
isWeb: false,
),
0,
);
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: true,
platform: TargetPlatform.iOS,
keyboardInset: 336,
isWeb: false,
),
0,
);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/web_back_navigation.dart';
void main() {
test('uses browser history only when it exists', () async {
var wentBack = false;
expect(
await tryGoBackInWebHistory(
canGoBack: () async => false,
goBack: () async => wentBack = true,
),
isFalse,
);
expect(wentBack, isFalse);
});
test('recognizes DSBridge JavaScript boolean results', () async {
expect(
await tryGoBackUsingVisibleWebControl(
runJavaScriptReturningResult: (_) async => 'true',
),
isTrue,
);
});
}
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/web_navigation_policy.dart';
void main() {
test('external handoff matches the resident mall policy', () {
expect(
shouldOpenWebUrlExternally('https://example.com/download?out=1'),
isTrue,
);
expect(shouldOpenWebUrlExternally('https://example.com/order/1'), isFalse);
expect(shouldOpenWebUrlExternally('tel:4000450268'), isTrue);
});
test('telephone handoff removes H5 parameters', () {
expect(
sanitizeSystemCommunicationUri(Uri.parse('tel:4000450268?out=1')),
Uri.parse('tel:4000450268'),
);
expect(
sanitizeSystemCommunicationUri(
Uri.parse('tel:4000450268&campaign=mall&out=1'),
),
Uri.parse('tel:4000450268'),
);
});
test('trusts only exact Qiyu domains and subdomains', () {
expect(isTrustedQiyuUrl('https://qiyukf.com/client'), isTrue);
expect(isTrustedQiyuUrl('https://service.qiyukf.cn/client'), isTrue);
expect(isTrustedQiyuUrl('https://qiyukf.com.evil.example/client'), isFalse);
});
}