feat: 对齐常驻商城与七鱼客服交互
- 同步商城 H5 登录态与原生 token - 完善商城/原生 tab 切换、动画及返回交互 - 修复七鱼客服键盘避让和状态栏恢复 - 调整开屏跳过按钮与登录入口交互
This commit is contained in:
1 parent
b4343ea528
commit
3b089e2205
37 files changed
+2886
-291
No files matched your search
@@ -3,6 +3,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_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.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.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
@@ -55,6 +58,16 @@
|
||||
android:name="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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
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 io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
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) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
|
||||
@@ -17,5 +34,140 @@ class MainActivity : FlutterActivity() {
|
||||
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>
|
||||
@@ -42,6 +42,10 @@
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于在商城客服中拍摄图片或视频并发送给客服。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>用于在商城客服中录制语音或视频并发送给客服。</string>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>用于衡量广告投放效果并提供更相关的内容。</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
||||
+9
-1
@@ -14,6 +14,7 @@ import '../common/theme/spacing.dart';
|
||||
import '../common/theme/theme_controller.dart';
|
||||
import '../features/ads/application/ad_config_foreground_refresher.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/services/device_header_service.dart';
|
||||
|
||||
@@ -54,7 +55,14 @@ class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
unawaited(_pushRegistrar.refresh());
|
||||
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh());
|
||||
unawaited(
|
||||
ref
|
||||
.read(adConfigForegroundRefresherProvider)
|
||||
.refresh(
|
||||
refreshMallTabConfig:
|
||||
!MallWebHostController.instance.state.value.visible,
|
||||
),
|
||||
);
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
unawaited(UpdateForegroundService.check(context, ref));
|
||||
|
||||
+676
-155
@@ -1,15 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../features/mall/application/mall_entry_controller.dart';
|
||||
import '../../features/mall/application/mall_tab_config.dart';
|
||||
import '../../features/web/application/mall_bridge_state.dart';
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../../features/web/presentation/mall_web_host.dart';
|
||||
import '../theme/app_icons.dart';
|
||||
import '../widgets/adaptive.dart';
|
||||
import '../widgets/app_toast.dart';
|
||||
import 'app_router.dart';
|
||||
|
||||
class TabShell extends ConsumerStatefulWidget {
|
||||
@@ -26,73 +31,184 @@ class TabShell extends ConsumerStatefulWidget {
|
||||
ConsumerState<TabShell> createState() => _TabShellState();
|
||||
}
|
||||
|
||||
class _TabShellState extends ConsumerState<TabShell> {
|
||||
class _TabShellState extends ConsumerState<TabShell>
|
||||
with TickerProviderStateMixin {
|
||||
static const double _compactHeaderThreshold = 34;
|
||||
static const double _scrollVisibilityThreshold = 16;
|
||||
static const double _topRevealEpsilon = 1;
|
||||
static const Curve _expandSpring = Cubic(0.32, -0.9, 0.28, 1);
|
||||
|
||||
bool _showCompactHeader = false;
|
||||
Brightness? _mallBrightness;
|
||||
bool _mallPresented = false;
|
||||
bool _mallActive = false;
|
||||
late final AnimationController _minimizeController;
|
||||
late final CurvedAnimation _minimizeFactor;
|
||||
late final AnimationController _mallTransition;
|
||||
late final CurvedAnimation _mallProgress;
|
||||
late final ProviderSubscription<MallTabConfig> _mallConfigSubscription;
|
||||
bool _userScrollSession = false;
|
||||
ScrollDirection _userScrollDirection = ScrollDirection.idle;
|
||||
double _directionAccum = 0;
|
||||
bool _barMinimized = false;
|
||||
|
||||
int get _index => widget.navigationShell.currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<MallTabConfig>(mallTabConfigProvider, (_, next) {
|
||||
if (!next.isVisible) return;
|
||||
final uri = next.attributedUri(
|
||||
MallWebHostController.instance.state.addListener(_syncMallHost);
|
||||
MallBridgeController.instance.state.addListener(_refreshMallDock);
|
||||
_minimizeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
reverseDuration: const Duration(milliseconds: 380),
|
||||
);
|
||||
_minimizeFactor = CurvedAnimation(
|
||||
parent: _minimizeController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: _expandSpring,
|
||||
);
|
||||
_mallTransition = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 520),
|
||||
reverseDuration: const Duration(milliseconds: 520),
|
||||
);
|
||||
_mallProgress = CurvedAnimation(
|
||||
parent: _mallTransition,
|
||||
curve: const Cubic(0.8, 0.05, 0.2, 0.95),
|
||||
reverseCurve: const Cubic(0.8, 0.05, 0.2, 0.95),
|
||||
);
|
||||
final initialMallState = MallWebHostController.instance.state.value;
|
||||
_mallPresented = initialMallState.url != null;
|
||||
_mallActive = initialMallState.visible;
|
||||
_mallTransition.value = _mallActive ? 1 : 0;
|
||||
_mallConfigSubscription = ref.listenManual<MallTabConfig>(
|
||||
mallTabConfigProvider,
|
||||
(_, next) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _preloadMall(next);
|
||||
});
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final brightness = Theme.of(context).brightness;
|
||||
if (_mallBrightness == brightness) return;
|
||||
_mallBrightness = brightness;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _preloadMall(ref.read(mallTabConfigProvider));
|
||||
});
|
||||
}
|
||||
|
||||
void _preloadMall(MallTabConfig config) {
|
||||
if (!config.isVisible) return;
|
||||
final uri = config.attributedUri(
|
||||
clickId: '',
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
if (uri != null) MallWebHostController.instance.preload(uri.toString());
|
||||
if (uri != null) MallWebHostController.instance.preload(uri);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
MallWebHostController.instance.state.removeListener(_syncMallHost);
|
||||
MallBridgeController.instance.state.removeListener(_refreshMallDock);
|
||||
_mallConfigSubscription.close();
|
||||
_minimizeFactor.dispose();
|
||||
_minimizeController.dispose();
|
||||
_mallProgress.dispose();
|
||||
_mallTransition.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncMallHost() {
|
||||
final state = MallWebHostController.instance.state.value;
|
||||
if (!mounted) return;
|
||||
final changed = _mallActive != state.visible;
|
||||
setState(() {
|
||||
_mallPresented |= state.url != null;
|
||||
_mallActive = state.visible;
|
||||
});
|
||||
if (changed) {
|
||||
if (state.visible) {
|
||||
_mallTransition.forward();
|
||||
} else {
|
||||
_mallTransition.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshMallDock() {
|
||||
if (mounted && _mallActive) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _openMall(MallTabConfig config) async {
|
||||
final result = await ref
|
||||
.read(mallEntryControllerProvider)
|
||||
.open(context, initialConfig: config);
|
||||
if (!mounted) return;
|
||||
if (result == MallEntryOpenResult.unavailable) {
|
||||
toastInfo(msg: '商城暂未开放');
|
||||
} else if (result == MallEntryOpenResult.failed) {
|
||||
toastInfo(msg: '商城打开失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.location != widget.location && _showCompactHeader) {
|
||||
_showCompactHeader = false;
|
||||
if (oldWidget.location != widget.location) {
|
||||
_expandBar(spring: false);
|
||||
_resetScrollTracking();
|
||||
if (_showCompactHeader) _showCompactHeader = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openMall(MallTabConfig config) async {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final actual = await ref
|
||||
.read(mallTabConfigProvider.notifier)
|
||||
.ensureLoaded();
|
||||
if (!actual.isVisible || !mounted) return;
|
||||
final clickId = await ref
|
||||
.read(mallTabConfigProvider.notifier)
|
||||
.recordClick();
|
||||
final uri = actual.attributedUri(clickId: clickId ?? '', isDark: isDark);
|
||||
if (uri == null) return;
|
||||
if (actual.opensInApp) {
|
||||
MallWebHostController.instance.open(uri.toString());
|
||||
return;
|
||||
}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final mall = ref.watch(mallTabConfigProvider);
|
||||
final isQiyuKeyboardActive =
|
||||
MallWebHostController.instance.state.value.isQiyuKeyboardActive;
|
||||
final header = _headerForPath(widget.location);
|
||||
final canPop = GoRouter.of(context).canPop();
|
||||
final isAndroid =
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
// The resident mall stays visually fixed for ordinary pages. Qiyu is
|
||||
// different: its WebView editor must receive the resized Android body
|
||||
// when the IME opens, otherwise its delayed focus-scroll ends below the
|
||||
// keyboard.
|
||||
resizeToAvoidBottomInset: _mallActive && isQiyuKeyboardActive,
|
||||
body: Stack(
|
||||
children: [
|
||||
PopScope(
|
||||
canPop: !MallWebHostController.instance.state.value.visible,
|
||||
AnimatedBuilder(
|
||||
animation: _mallProgress,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(
|
||||
-MediaQuery.sizeOf(context).width * _mallProgress.value,
|
||||
0,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
ignoring: _mallActive,
|
||||
child: PopScope(
|
||||
canPop: !isAndroid || !_mallActive,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop &&
|
||||
MallWebHostController.instance.state.value.visible) {
|
||||
MallWebHostController.instance.handleBack();
|
||||
if (!didPop && isAndroid && _mallActive) {
|
||||
MallWebHostController.instance.handleSystemBack();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBody: true,
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
@@ -153,76 +269,54 @@ class _TabShellState extends ConsumerState<TabShell> {
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
border: Border(
|
||||
top: BorderSide(color: cs.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 62,
|
||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.tabsMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
icon: i == _index
|
||||
? _filledIcons[i]
|
||||
: _outlineIcons[i],
|
||||
label: _labels[i],
|
||||
selected: i == _index,
|
||||
onTap: () =>
|
||||
widget.navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == _index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mallPresented &&
|
||||
MallWebHostController.instance.state.value.url != null)
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _mallProgress,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(
|
||||
MediaQuery.sizeOf(context).width *
|
||||
(1 - _mallProgress.value),
|
||||
0,
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_mallActive,
|
||||
child: MallWebHost(
|
||||
url: MallWebHostController.instance.state.value.url!,
|
||||
visible: _mallActive,
|
||||
onRequestExit: MallWebHostController.instance.dismiss,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mall.isVisible)
|
||||
if (!(_mallActive &&
|
||||
(MallBridgeController.instance.state.value.canGoBack ||
|
||||
MallWebHostController
|
||||
.instance
|
||||
.state
|
||||
.value
|
||||
.isShowingQiyuPage)))
|
||||
Positioned(
|
||||
bottom: 39,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: _MallDockButton(
|
||||
title: mall.title,
|
||||
badge: mall.badgeText,
|
||||
onTap: () => _openMall(mall),
|
||||
),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<MallWebHostState>(
|
||||
valueListenable: MallWebHostController.instance.state,
|
||||
builder: (context, state, _) => Positioned.fill(
|
||||
child: Offstage(
|
||||
offstage: !state.visible,
|
||||
child: MallWebHost(
|
||||
onExit: MallWebHostController.instance.dismiss,
|
||||
),
|
||||
bottom: 0,
|
||||
child: _MorphingNavigationDock(
|
||||
progress: _mallProgress,
|
||||
minimize: _minimizeFactor,
|
||||
currentIndex: _index,
|
||||
mall: mall,
|
||||
onTabTap: (index) => widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == _index,
|
||||
),
|
||||
onMallTap: () => _openMall(mall),
|
||||
),
|
||||
),
|
||||
if (!kReleaseMode)
|
||||
@@ -246,26 +340,260 @@ class _TabShellState extends ConsumerState<TabShell> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
void _resetScrollTracking() {
|
||||
_userScrollSession = false;
|
||||
_userScrollDirection = ScrollDirection.idle;
|
||||
_directionAccum = 0;
|
||||
}
|
||||
|
||||
void _expandBar({bool immediate = false, bool spring = true}) {
|
||||
if (!_barMinimized && _minimizeController.value == 0) return;
|
||||
_barMinimized = false;
|
||||
if (immediate) {
|
||||
_minimizeController.value = 0;
|
||||
return;
|
||||
}
|
||||
final alreadyReversing =
|
||||
_minimizeController.isAnimating &&
|
||||
_minimizeController.status == AnimationStatus.reverse;
|
||||
if (!alreadyReversing) {
|
||||
_minimizeFactor.reverseCurve = spring
|
||||
? _expandSpring
|
||||
: Curves.easeOutCubic;
|
||||
}
|
||||
unawaited(_minimizeController.reverse());
|
||||
}
|
||||
|
||||
void _minimizeBar() {
|
||||
if (_barMinimized && _minimizeController.value == 1) return;
|
||||
_barMinimized = true;
|
||||
unawaited(_minimizeController.forward());
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
final metrics = notification.metrics;
|
||||
if (metrics.axis != Axis.vertical) return false;
|
||||
|
||||
final next = notification.metrics.pixels > _compactHeaderThreshold;
|
||||
if (next != _showCompactHeader && mounted) {
|
||||
setState(() => _showCompactHeader = next);
|
||||
}
|
||||
|
||||
if (notification.depth != 0) return false;
|
||||
if (notification is UserScrollNotification) {
|
||||
_userScrollDirection = notification.direction;
|
||||
_userScrollSession = notification.direction != ScrollDirection.idle;
|
||||
return false;
|
||||
}
|
||||
if (notification is ScrollStartNotification) {
|
||||
if (notification.dragDetails != null) {
|
||||
_userScrollSession = true;
|
||||
_directionAccum = 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final atTop = metrics.pixels <= metrics.minScrollExtent + _topRevealEpsilon;
|
||||
if (atTop) {
|
||||
_directionAccum = 0;
|
||||
if (_barMinimized || _minimizeController.value > 0) _expandBar();
|
||||
return false;
|
||||
}
|
||||
if (notification is! ScrollUpdateNotification) return false;
|
||||
if (notification.dragDetails != null) _userScrollSession = true;
|
||||
if (!_userScrollSession && _userScrollDirection == ScrollDirection.idle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final delta = notification.scrollDelta;
|
||||
if (delta == null || delta == 0) return false;
|
||||
final direction = _userScrollDirection != ScrollDirection.idle
|
||||
? _userScrollDirection
|
||||
: delta > 0
|
||||
? ScrollDirection.reverse
|
||||
: ScrollDirection.forward;
|
||||
if (direction == ScrollDirection.reverse) {
|
||||
if (_directionAccum < 0) _directionAccum = 0;
|
||||
_directionAccum += delta.abs();
|
||||
if (_directionAccum >= _scrollVisibilityThreshold) {
|
||||
_directionAccum = 0;
|
||||
_minimizeBar();
|
||||
}
|
||||
} else if (direction == ScrollDirection.forward) {
|
||||
if (_directionAccum > 0) _directionAccum = 0;
|
||||
_directionAccum -= delta.abs();
|
||||
if (_directionAccum <= -_scrollVisibilityThreshold) {
|
||||
_directionAccum = 0;
|
||||
_expandBar();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MallDockButton extends StatelessWidget {
|
||||
const _MallDockButton({
|
||||
required this.title,
|
||||
required this.badge,
|
||||
required this.onTap,
|
||||
class _MorphingNavigationDock extends StatelessWidget {
|
||||
const _MorphingNavigationDock({
|
||||
required this.progress,
|
||||
required this.minimize,
|
||||
required this.currentIndex,
|
||||
required this.mall,
|
||||
required this.onTabTap,
|
||||
required this.onMallTap,
|
||||
});
|
||||
final String title;
|
||||
final String badge;
|
||||
|
||||
final Animation<double> progress;
|
||||
final Animation<double> minimize;
|
||||
final int currentIndex;
|
||||
final MallTabConfig mall;
|
||||
final ValueChanged<int> onTabTap;
|
||||
final VoidCallback onMallTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final bridge = MallBridgeController.instance.state.value;
|
||||
final nativeItems = [
|
||||
for (var index = 0; index < _labels.length; index++)
|
||||
_DockTabItem(
|
||||
label: _labels[index],
|
||||
outline: _outlineIcons[index],
|
||||
filled: _filledIcons[index],
|
||||
symbol: _nativeSymbols[index],
|
||||
),
|
||||
];
|
||||
final mallItems = [
|
||||
for (final item in _MallNavigationDock.tabs)
|
||||
_DockTabItem(label: item.$4, outline: item.$2, filled: item.$3),
|
||||
];
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([progress, minimize]),
|
||||
builder: (context, _) {
|
||||
final t = progress.value.clamp(0.0, 1.0);
|
||||
final minimized = minimize.value * (1 - t);
|
||||
return SizedBox(
|
||||
height: 84 + bottomInset,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const side = 16.0;
|
||||
const gap = 12.0;
|
||||
const circle = 64.0;
|
||||
final pill = mall.isVisible
|
||||
? constraints.maxWidth - side * 2 - gap - circle
|
||||
: constraints.maxWidth - side * 2;
|
||||
final leftWidth = mall.isVisible
|
||||
? lerpDouble(pill, circle, t)!
|
||||
: pill;
|
||||
final rightWidth = lerpDouble(circle, pill, t)!;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(side, 8, side, 10 + bottomInset),
|
||||
child: Transform.scale(
|
||||
scale: lerpDouble(1, 0.86, minimized)!,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Row(
|
||||
children: [
|
||||
_MallDockSurface(
|
||||
width: leftWidth,
|
||||
height: circle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
ignoring: t > 0.5,
|
||||
child: Opacity(
|
||||
opacity: (1 - t * 2).clamp(0.0, 1.0),
|
||||
child: _ExpandingDockTabStrip(
|
||||
items: nativeItems,
|
||||
selectedIndex: currentIndex,
|
||||
minimize: minimized,
|
||||
onTap: onTabTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: t < 0.5,
|
||||
child: Opacity(
|
||||
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
|
||||
child: IconButton(
|
||||
tooltip: '返回',
|
||||
onPressed:
|
||||
MallWebHostController.instance.dismiss,
|
||||
icon: Icon(
|
||||
AppIcons.arrowLeft,
|
||||
size: 30,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mall.isVisible) ...[
|
||||
const SizedBox(width: gap),
|
||||
_MallDockSurface(
|
||||
width: rightWidth,
|
||||
height: circle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
ignoring: t > 0.5,
|
||||
child: Opacity(
|
||||
opacity: (1 - t * 2).clamp(0.0, 1.0),
|
||||
child: _MallLauncherButton(
|
||||
mall: mall,
|
||||
onTap: onMallTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: t < 0.5,
|
||||
child: Opacity(
|
||||
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
|
||||
child: _ExpandingDockTabStrip(
|
||||
items: mallItems,
|
||||
selectedIndex: _MallNavigationDock.tabs
|
||||
.indexWhere(
|
||||
(item) =>
|
||||
item.$1 == bridge.activeTab,
|
||||
),
|
||||
onTap: (index) => MallBridgeController
|
||||
.instance
|
||||
.activate(
|
||||
_MallNavigationDock.tabs[index].$1,
|
||||
reselect:
|
||||
bridge.activeTab ==
|
||||
_MallNavigationDock
|
||||
.tabs[index]
|
||||
.$1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MallLauncherButton extends StatelessWidget {
|
||||
const _MallLauncherButton({required this.mall, required this.onTap});
|
||||
|
||||
final MallTabConfig mall;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -273,49 +601,28 @@ class _MallDockButton extends StatelessWidget {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: title,
|
||||
label: mall.title,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [cs.primary.withValues(alpha: 0.78), cs.primary],
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.shopping_bag_outlined,
|
||||
size: 18,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.shopping_bag,
|
||||
size: 25,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.primaryForeground,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (badge.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
badge,
|
||||
style: TextStyle(fontSize: 10, color: cs.foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -324,42 +631,249 @@ class _MallDockButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
required this.icon,
|
||||
class _DockTabItem {
|
||||
const _DockTabItem({
|
||||
required this.label,
|
||||
required this.outline,
|
||||
required this.filled,
|
||||
this.symbol,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData outline;
|
||||
final IconData filled;
|
||||
final AppBottomTabSymbol? symbol;
|
||||
}
|
||||
|
||||
class _ExpandingDockTabStrip extends StatelessWidget {
|
||||
const _ExpandingDockTabStrip({
|
||||
required this.items,
|
||||
required this.selectedIndex,
|
||||
required this.onTap,
|
||||
this.minimize = 0,
|
||||
});
|
||||
|
||||
static const double _inset = 6;
|
||||
static const double _selectedWidth = 92;
|
||||
|
||||
final List<_DockTabItem> items;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
final double minimize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safeIndex = selectedIndex.clamp(0, items.length - 1);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final trackWidth = constraints.maxWidth - _inset * 2;
|
||||
final selectedWidth = lerpDouble(
|
||||
_selectedWidth.clamp(52.0, trackWidth).toDouble(),
|
||||
52,
|
||||
minimize.clamp(0.0, 1.0),
|
||||
)!;
|
||||
final compactWidth = (trackWidth - selectedWidth) / (items.length - 1);
|
||||
final indicatorLeft = _inset + safeIndex * compactWidth;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: indicatorLeft,
|
||||
top: _inset,
|
||||
bottom: _inset,
|
||||
width: selectedWidth,
|
||||
child: _DockSelectionHighlight(
|
||||
key: ValueKey('dock-selection-${items[safeIndex].label}'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _inset),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: index == safeIndex ? selectedWidth : compactWidth,
|
||||
child: _ExpandingDockTab(
|
||||
item: items[index],
|
||||
selected: index == safeIndex,
|
||||
labelFactor: index == safeIndex
|
||||
? (1 - minimize).clamp(0.0, 1.0)
|
||||
: 0,
|
||||
onTap: () => onTap(index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DockSelectionHighlight extends StatelessWidget {
|
||||
const _DockSelectionHighlight({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
color: cs.primary.withValues(alpha: 0.14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandingDockTab extends StatelessWidget {
|
||||
const _ExpandingDockTab({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.labelFactor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final _DockTabItem item;
|
||||
final bool selected;
|
||||
final double labelFactor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final color = selected ? cs.foreground : cs.mutedForeground;
|
||||
return InkWell(
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: item.label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// `AnimatedContainer` changes a tab's width during a tab switch.
|
||||
// Derive text room from this animation frame's *actual* width,
|
||||
// not the target selected width, as Purse's tab button does.
|
||||
final maxLabelWidth = (constraints.maxWidth - 31)
|
||||
.clamp(0.0, 64.0)
|
||||
.toDouble();
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: color),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
if (item.symbol case final symbol?)
|
||||
AppBottomTabIcon(
|
||||
symbol: symbol,
|
||||
selected: selected,
|
||||
size: 24,
|
||||
unselectedColor: cs.mutedForeground,
|
||||
selectedColor: cs.primary,
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
selected ? item.filled : item.outline,
|
||||
size: 24,
|
||||
color: selected ? cs.primary : cs.mutedForeground,
|
||||
),
|
||||
if (selected)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: labelFactor,
|
||||
child: Opacity(
|
||||
opacity: labelFactor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 7),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxLabelWidth,
|
||||
),
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: color,
|
||||
letterSpacing: 0,
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class _MallNavigationDock {
|
||||
static const tabs = <(MallBridgeTab, IconData, IconData, String)>[
|
||||
(MallBridgeTab.home, Icons.shopping_bag_outlined, Icons.shopping_bag, '首页'),
|
||||
(MallBridgeTab.market, Icons.show_chart, Icons.insert_chart, '行情'),
|
||||
(
|
||||
MallBridgeTab.orders,
|
||||
Icons.receipt_long_outlined,
|
||||
Icons.receipt_long,
|
||||
'订单',
|
||||
),
|
||||
(MallBridgeTab.profile, Icons.person_outline, Icons.person, '我的'),
|
||||
];
|
||||
}
|
||||
|
||||
class _MallDockSurface extends StatelessWidget {
|
||||
const _MallDockSurface({this.width, this.height = 56, required this.child});
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
border: Border.all(color: cs.border.withValues(alpha: 0.8)),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +891,13 @@ const _outlineIcons = [
|
||||
AppIcons.meLine,
|
||||
];
|
||||
|
||||
const _nativeSymbols = [
|
||||
AppBottomTabSymbol.assets,
|
||||
AppBottomTabSymbol.market,
|
||||
AppBottomTabSymbol.news,
|
||||
AppBottomTabSymbol.profile,
|
||||
];
|
||||
|
||||
const _labels = ['资产', '行情', '资讯', '我的'];
|
||||
|
||||
_ShellHeader _headerForPath(String path) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
/// Clears only disposable presentation data. Credentials and user preferences
|
||||
@@ -14,6 +15,7 @@ class CacheClearService {
|
||||
|
||||
static Future<void> clear() async {
|
||||
await DefaultCacheManager().emptyCache();
|
||||
await MallWebHostController.instance.clear();
|
||||
await SplashAdControllerPlaceholder.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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
|
||||
class AppIcons {
|
||||
@@ -13,3 +17,183 @@ class AppIcons {
|
||||
static const arrowRight = FlutterRemix.arrow_right_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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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 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 (_) {}
|
||||
if (refreshMallTabConfig) {
|
||||
try {
|
||||
await _mall();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'js_api_result.dart';
|
||||
|
||||
class JsApi extends JavaScriptNamespaceInterface {
|
||||
JsApi({
|
||||
this.onClose,
|
||||
this.onLoginRequested,
|
||||
this.onMallStateChanged,
|
||||
Future<String> Function()? appVersionResolver,
|
||||
}) : _appVersionResolver = appVersionResolver ?? _readAppVersion;
|
||||
|
||||
final VoidCallback? onClose;
|
||||
final VoidCallback? onLoginRequested;
|
||||
final ValueChanged<Map<dynamic, dynamic>?>? onMallStateChanged;
|
||||
final Future<String> Function() _appVersionResolver;
|
||||
|
||||
@override
|
||||
void register() {
|
||||
registerFunction(close);
|
||||
registerFunction(getAppVersion);
|
||||
registerFunction(mallStateChanged);
|
||||
registerFunction(login);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void close(dynamic arg, CompletionHandler handler) {
|
||||
onClose?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void getAppVersion(dynamic arg, CompletionHandler handler) {
|
||||
unawaited(_completeAppVersion(handler));
|
||||
}
|
||||
|
||||
Future<void> _completeAppVersion(CompletionHandler handler) async {
|
||||
try {
|
||||
final version = await _appVersionResolver();
|
||||
handler.complete(
|
||||
JsApiResult(
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: <String, dynamic>{'version': version},
|
||||
).toMap(),
|
||||
);
|
||||
} catch (_) {
|
||||
handler.complete(
|
||||
const JsApiResult(code: 1, message: 'app version unavailable').toMap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _readAppVersion() async =>
|
||||
(await PackageInfo.fromPlatform()).version;
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void mallStateChanged(dynamic arg, CompletionHandler handler) {
|
||||
onMallStateChanged?.call(coerceMallStateMap(arg));
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void login(dynamic arg, CompletionHandler handler) {
|
||||
onLoginRequested?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic>? coerceMallStateMap(dynamic arg) {
|
||||
if (arg is Map) return arg;
|
||||
if (arg is String && arg.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(arg);
|
||||
if (decoded is Map) return decoded;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (arg is List && arg.isNotEmpty && arg.first is Map) {
|
||||
return arg.first as Map<dynamic, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class JsApiResult {
|
||||
const JsApiResult({required this.code, required this.message, this.data});
|
||||
|
||||
const JsApiResult.success() : code = 0, message = 'success', data = null;
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
Map<String, dynamic> toMap() => <String, dynamic>{
|
||||
'code': code,
|
||||
'message': message,
|
||||
if (data != null) 'data': data,
|
||||
};
|
||||
|
||||
String toJson() => jsonEncode(toMap());
|
||||
}
|
||||
@@ -1,130 +1,703 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show
|
||||
TargetPlatform,
|
||||
debugPrint,
|
||||
defaultTargetPlatform,
|
||||
kIsWeb,
|
||||
kReleaseMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
import '../../../common/util/app_link_util.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/application/login_launcher.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../mall/application/mall_tab_config.dart';
|
||||
import '../../mall/data/mall_auth_api.dart';
|
||||
import '../application/mall_bridge_state.dart';
|
||||
import '../application/mall_web_host_controller.dart';
|
||||
import '../application/web_back_navigation.dart';
|
||||
import '../application/web_navigation_policy.dart';
|
||||
import '../application/web_reload_providers.dart';
|
||||
import 'js_api.dart';
|
||||
|
||||
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
|
||||
class MallWebHost extends StatefulWidget {
|
||||
const MallWebHost({super.key, required this.onExit});
|
||||
final VoidCallback onExit;
|
||||
/// Process-lifetime resident mall WebView. The controller, bridge contract and
|
||||
/// first-load ordering intentionally match the Purse implementation.
|
||||
class MallWebHost extends ConsumerStatefulWidget {
|
||||
const MallWebHost({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.visible,
|
||||
required this.onRequestExit,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final bool visible;
|
||||
final VoidCallback onRequestExit;
|
||||
|
||||
@override
|
||||
State<MallWebHost> createState() => _MallWebHostState();
|
||||
ConsumerState<MallWebHost> createState() => _MallWebHostState();
|
||||
}
|
||||
|
||||
class _MallWebHostState extends State<MallWebHost> {
|
||||
final _host = MallWebHostController.instance;
|
||||
WebViewController? _controller;
|
||||
class _MallWebHostState extends ConsumerState<MallWebHost>
|
||||
with WidgetsBindingObserver {
|
||||
static const _defaultQiyuStatusBarColor = Color(0xFF3B82F6);
|
||||
static const _mallSystemUiOverlayStyle = SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
);
|
||||
static const _webFilePickerChannel = MethodChannel(
|
||||
'com.jinzhi.app/web_file_picker',
|
||||
);
|
||||
|
||||
late final DWebViewController _controller;
|
||||
String? _loadedUrl;
|
||||
String? _error;
|
||||
String? _errorText;
|
||||
int _progress = 0;
|
||||
bool _hasFinishedFirstPage = false;
|
||||
bool _hasDisplayedPage = false;
|
||||
bool _isHandlingSystemBack = false;
|
||||
bool _isHandlingLoginRequest = false;
|
||||
bool _isShowingQiyuPage = false;
|
||||
bool _hasEmbeddedQiyuFrame = false;
|
||||
Color _qiyuStatusBarColor = _defaultQiyuStatusBarColor;
|
||||
String? _qiyuUrlLoadedWithMallReferer;
|
||||
final _pendingH5Events = <JsEvent>[];
|
||||
Future<void>? _androidWebViewConfiguration;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_host.state.addListener(_sync);
|
||||
_host.attachBackHandler(_goBack);
|
||||
_sync();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ref.listenManual<AsyncValue<JsEvent>>(webReloadPProvider, (_, next) {
|
||||
next.whenData(_callH5Handler);
|
||||
});
|
||||
ref.listenManual<AsyncValue<AuthState>>(authProvider, (previous, next) {
|
||||
final previousAuth = previous?.valueOrNull;
|
||||
final nextAuth = next.valueOrNull;
|
||||
final wasLogged = previousAuth is LoggedInAuthState;
|
||||
final logged = nextAuth is LoggedInAuthState;
|
||||
final tokenChanged =
|
||||
wasLogged && logged && previousAuth.token != nextAuth.token;
|
||||
if (wasLogged == logged && !tokenChanged) return;
|
||||
if (logged) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
} else {
|
||||
unawaited(_notifyH5LoggedOut());
|
||||
}
|
||||
});
|
||||
_controller = _createController();
|
||||
MallBridgeController.instance.attach(
|
||||
activateTab: _switchTab,
|
||||
goBack: _goBack,
|
||||
);
|
||||
MallWebHostController.instance.attachCacheClearer(_clearWebViewCache);
|
||||
MallWebHostController.instance.attachSystemBackHandler(_handleSystemBack);
|
||||
_configurePlatformWebView();
|
||||
unawaited(_load(widget.url));
|
||||
if (widget.visible) unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
}
|
||||
|
||||
DWebViewController _createController() {
|
||||
late final DWebViewController controller;
|
||||
controller = DWebViewController()
|
||||
// ignore: discarded_futures
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
// ignore: discarded_futures
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptObject(
|
||||
JsApi(
|
||||
onClose: widget.onRequestExit,
|
||||
onLoginRequested: () => unawaited(_requestMallLogin()),
|
||||
onMallStateChanged: MallBridgeController.instance.update,
|
||||
),
|
||||
)
|
||||
// Temporary compatibility with the pre-DSBridge Jinzhi H5 contract.
|
||||
// ignore: discarded_futures
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onRequestExit();
|
||||
},
|
||||
)
|
||||
// ignore: discarded_futures
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _handleNavigationRequest,
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onPageStarted: (url) {
|
||||
_updateQiyuPageLayout(url);
|
||||
if (!_hasFinishedFirstPage && mounted) {
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
_progress = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
onUrlChange: (change) => _updateQiyuPageLayout(change.url),
|
||||
onPageFinished: (url) {
|
||||
_hasFinishedFirstPage = true;
|
||||
_hasDisplayedPage = true;
|
||||
_updateQiyuPageLayout(url);
|
||||
if (_isShowingQiyuPage) {
|
||||
_applySystemUiOverlayStyle();
|
||||
}
|
||||
unawaited(_refreshEmbeddedQiyuPresence());
|
||||
_flushPendingH5Events();
|
||||
if (mounted) setState(() => _progress = 100);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame != true || _hasFinishedFirstPage) return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorText = error.description;
|
||||
_progress = 100;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.state.removeListener(_sync);
|
||||
_host.detachBackHandler();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
MallWebHostController.instance.setShowingQiyuPage(false);
|
||||
SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle);
|
||||
MallWebHostController.instance.detachCacheClearer();
|
||||
MallWebHostController.instance.detachSystemBackHandler();
|
||||
MallBridgeController.instance.detach();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final url = _host.state.value.url;
|
||||
if (url != null && url != _loadedUrl) _load(url);
|
||||
if (mounted) setState(() {});
|
||||
@override
|
||||
void didUpdateWidget(covariant MallWebHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (MallWebHostController.cacheKeyFor(oldWidget.url) !=
|
||||
MallWebHostController.cacheKeyFor(widget.url)) {
|
||||
unawaited(_load(widget.url));
|
||||
}
|
||||
if (!oldWidget.visible && widget.visible) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
} else if (oldWidget.url != widget.url && widget.visible) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
}
|
||||
}
|
||||
|
||||
void _load(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
_error = '商城链接不可用';
|
||||
return;
|
||||
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);
|
||||
}
|
||||
_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') {
|
||||
if (!shouldOpenWebUrlExternally(request.url)) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
if (await canLaunchUrl(target)) {
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
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,
|
||||
);
|
||||
unawaited(_controller!.loadRequest(uri));
|
||||
if (launched && mounted) widget.onRequestExit();
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
final controller = _controller;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
Future<void> _handleSystemBack() async {
|
||||
if (kIsWeb ||
|
||||
defaultTargetPlatform != TargetPlatform.android ||
|
||||
!_hasFinishedFirstPage ||
|
||||
_errorText != null ||
|
||||
_isHandlingSystemBack) {
|
||||
return;
|
||||
}
|
||||
if (MallBridgeController.instance.state.value.canGoBack) {
|
||||
await _goBack();
|
||||
return;
|
||||
}
|
||||
_isHandlingSystemBack = true;
|
||||
try {
|
||||
await tryGoBackInWebHistory(
|
||||
canGoBack: _controller.canGoBack,
|
||||
goBack: _controller.goBack,
|
||||
);
|
||||
} finally {
|
||||
_isHandlingSystemBack = false;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
// 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 (_controller != null)
|
||||
Positioned.fill(child: WebViewWidget(controller: _controller!)),
|
||||
if (_error != null)
|
||||
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: 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('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_error == null && _progress < 100)
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
child: TextButton(
|
||||
onPressed: () => unawaited(_retry()),
|
||||
child: const Text('商城加载失败,点击重试'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
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
@@ -355,6 +355,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -872,6 +880,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1673,7 +1689,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||
|
||||
@@ -35,12 +35,15 @@ dependencies:
|
||||
shadcn_ui: ^0.53.6
|
||||
google_fonts: ^6.2.1
|
||||
flutter_remix: ^0.0.3
|
||||
not_static_icons: ^0.46.0
|
||||
shimmer: ^3.0.0
|
||||
# 网络图片与磁盘缓存
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
webview_flutter: 4.13.0
|
||||
webview_flutter_android: 4.12.0
|
||||
webview_flutter_wkwebview: 3.25.0
|
||||
dsbridge_flutter: ^1.2.0
|
||||
flutter_app_update: ^3.2.1
|
||||
android_intent_plus: ^6.0.0
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user