5 Commits
Author SHA1 Message Date
jingyun 6997a65c96 fix:对齐金值,主要注销,一键登录等方面修改 2026-09-08 17:42:17 +08:00
jingyun e29ed0a2c8 fix:购买商品饰品 2026-09-08 14:27:32 +08:00
jingyun 1f9a8b67a2 fix:商城登录互通问题 2026-09-08 13:46:45 +08:00
jingyun 7b1852d9a6 fix:对齐金值的报名验证,商城入口逻辑对齐 2026-09-08 10:56:33 +08:00
jingyun 3b089e2205 feat: 对齐常驻商城与七鱼客服交互
- 同步商城 H5 登录态与原生 token
- 完善商城/原生 tab 切换、动画及返回交互
- 修复七鱼客服键盘避让和状态栏恢复
- 调整开屏跳过按钮与登录入口交互
2026-09-07 15:47:01 +08:00
79 changed files with 6973 additions and 690 deletions

No files matched your search

+4 -5
View File
@@ -33,7 +33,7 @@ fun requireSigningProperty(name: String): String {
}
android {
namespace = "com.jinzhi.app"
namespace = "com.jinzhi.butler"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -56,10 +56,9 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
// Temporary U-Verify test identity: the Yanting AppKey/login secret
// loaded from assets/env/.env is registered against this package.
applicationId = "com.yanting.read"
// Keep the registered Jinzhi application identity. One-key login and
// push providers validate the package-and-certificate pair.
applicationId = "com.jinzhi.butler"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
Binary file not shown.
+13
View File
@@ -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,21 +0,0 @@
package com.jinzhi.app
import com.umeng.commonsdk.UMConfigure
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
.setMethodCallHandler { call, result ->
if (call.method == "submitUmengPrivacyGranted") {
UMConfigure.submitPolicyGrantResult(applicationContext, true)
result.success(null)
} else {
result.notImplemented()
}
}
}
}
@@ -1,4 +1,4 @@
package com.jinzhi.app
package com.jinzhi.butler
import android.app.Activity
import android.content.pm.PackageManager
@@ -0,0 +1,173 @@
package com.jinzhi.butler
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.butler/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")
.setMethodCallHandler { call, result ->
if (call.method == "submitUmengPrivacyGranted") {
UMConfigure.submitPolicyGrantResult(applicationContext, true)
result.success(null)
} else {
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>
@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="26.9924" viewBox="0 0 20 26.9924" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#232;&#163;&#133;&#233;&#165;&#176;&#230;&#152;&#159;&#230;&#152;&#159;">
<path id="Vector" d="M7.7027 26.5261L8.41064 24.9055C9.02993 23.4731 10.1634 22.3247 11.5875 21.6867L13.5356 20.8223C14.1548 20.5473 14.1548 19.6467 13.5356 19.3709L11.6488 18.534C10.1882 17.879 9.03458 16.6885 8.42596 15.2079L7.70835 13.4808C7.65191 13.3389 7.55418 13.2173 7.42782 13.1315C7.30147 13.0458 7.15229 13 6.9996 13C6.84691 13 6.69773 13.0458 6.57137 13.1315C6.44501 13.2173 6.34729 13.3389 6.29085 13.4808L5.57404 15.2079C4.96542 16.6885 3.81184 17.879 2.35121 18.534L0.464436 19.3717C-0.154812 19.6459 -0.154812 20.5473 0.464436 20.8223L2.41168 21.6867C3.83568 22.3245 4.9691 23.4726 5.58855 24.9047L6.29649 26.5253C6.35471 26.6638 6.45248 26.7819 6.57756 26.865C6.70263 26.9481 6.84944 26.9924 6.9996 26.9924C7.14975 26.9924 7.29656 26.9481 7.42164 26.865C7.54671 26.7819 7.64448 26.6646 7.7027 26.5261Z" fill="white"/>
<path id="Vector_2" d="M16.4666 7.6867L16.7133 7.12119C17.1465 6.12115 17.9381 5.31928 18.9324 4.87314L19.6918 4.53443C19.7838 4.49211 19.8617 4.4243 19.9163 4.33903C19.971 4.25376 20 4.15462 20 4.05335C20 3.95208 19.971 3.85294 19.9163 3.76767C19.8617 3.6824 19.7838 3.61459 19.6918 3.57226L18.9754 3.25354C17.9551 2.79573 17.1494 1.9639 16.7243 0.929556L16.4706 0.319085C16.433 0.224921 16.3681 0.144177 16.2842 0.0872928C16.2003 0.0304083 16.1013 0 16 0C15.8986 0 15.7996 0.0304083 15.7157 0.0872928C15.6318 0.144177 15.5669 0.224921 15.5294 0.319085L15.2756 0.928557C14.8507 1.96328 14.045 2.7955 13.0245 3.25354L12.3072 3.57327C12.2154 3.61571 12.1378 3.68351 12.0833 3.76867C12.0289 3.85383 12 3.95278 12 4.05385C12 4.15491 12.0289 4.25387 12.0833 4.33903C12.1378 4.42419 12.2154 4.49199 12.3072 4.53443L13.0665 4.87214C14.0614 5.31832 14.8533 6.1206 15.2866 7.12119L15.5334 7.6867C15.7132 8.10034 16.2867 8.10034 16.4666 7.6867Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="72" height="64" viewBox="0 0 72 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M52 35.0088V49.0088C52 49.5393 51.7893 50.048 51.4142 50.4231C51.0391 50.7981 50.5304 51.0088 50 51.0088H22C21.4696 51.0088 20.9609 50.7981 20.5858 50.4231C20.2107 50.048 20 49.5393 20 49.0088V35.0088H52ZM41 13.0088C42.181 13.0086 43.3429 13.3072 44.3775 13.8768C45.4121 14.4464 46.2858 15.2685 46.9171 16.2666C47.5485 17.2647 47.9171 18.4063 47.9886 19.5851C48.0601 20.764 47.8322 21.9418 47.326 23.0088H54C54.5304 23.0088 55.0391 23.2196 55.4142 23.5946C55.7893 23.9697 56 24.4784 56 25.0088V31.0088C56 31.5393 55.7893 32.048 55.4142 32.4231C55.0391 32.7981 54.5304 33.0088 54 33.0088H18C17.4696 33.0088 16.9609 32.7981 16.5858 32.4231C16.2107 32.048 16 31.5393 16 31.0088V25.0088C16 24.4784 16.2107 23.9697 16.5858 23.5946C16.9609 23.2196 17.4696 23.0088 18 23.0088H24.674C23.9615 21.5122 23.8033 19.8113 24.2274 18.2088C24.6515 16.6064 25.6306 15.2065 26.9902 14.2583C28.3498 13.3102 30.0018 12.8752 31.6521 13.031C33.3023 13.1867 34.8438 13.923 36.002 15.1088C36.6522 14.4423 37.4296 13.9129 38.2881 13.5522C39.1466 13.1915 40.0688 13.0067 41 13.0088ZM31 17.0088C30.2294 17.0087 29.4883 17.3051 28.9303 17.8366C28.3723 18.3681 28.0403 19.094 28.003 19.8637C27.9657 20.6334 28.226 21.3879 28.73 21.9709C29.234 22.5538 29.943 22.9205 30.71 22.9948L31 23.0088H34V20.0088C34 19.2631 33.7222 18.5441 33.2208 17.992C32.7194 17.44 32.0303 17.0944 31.288 17.0228L31 17.0088ZM41 17.0088L40.71 17.0228C40.0178 17.0901 39.3704 17.3957 38.8786 17.8875C38.3869 18.3793 38.0812 19.0266 38.014 19.7188L38 20.0088V23.0088H41L41.288 22.9948C42.0301 22.9229 42.7187 22.5772 43.2198 22.0252C43.7209 21.4732 43.9985 20.7544 43.9985 20.0088C43.9985 19.2633 43.7209 18.5445 43.2198 17.9925C42.7187 17.4404 42.0301 17.0948 41.288 17.0228L41 17.0088Z" fill="black"/>
<path id="Vector_2" d="M7.36867 18.1835L7.954 16.8435C8.46603 15.6592 9.40317 14.7097 10.5807 14.1822L12.1913 13.4675C12.7033 13.2402 12.7033 12.4955 12.1913 12.2675L10.6313 11.5755C9.42367 11.034 8.46988 10.0497 7.96667 8.82555L7.37333 7.39755C7.32667 7.28024 7.24587 7.17964 7.1414 7.10877C7.03692 7.03789 6.91358 7 6.78733 7C6.66109 7 6.53775 7.03789 6.43327 7.10877C6.3288 7.17964 6.248 7.28024 6.20133 7.39755L5.60867 8.82555C5.10546 10.0497 4.15166 11.034 2.944 11.5755L1.384 12.2682C0.872 12.4949 0.872 13.2402 1.384 13.4675L2.994 14.1822C4.17138 14.7096 5.1085 15.6588 5.62067 16.8429L6.206 18.1829C6.25414 18.2973 6.33497 18.395 6.43839 18.4637C6.5418 18.5324 6.66319 18.569 6.78733 18.569C6.91148 18.569 7.03287 18.5324 7.13628 18.4637C7.23969 18.395 7.32053 18.298 7.36867 18.1835Z" fill="black"/>
<path id="Vector_3" d="M65.9606 51.2578L66.2899 50.5031C66.868 49.1686 67.9243 48.0985 69.2512 47.5031L70.2646 47.0512C70.3873 46.9947 70.4914 46.9042 70.5643 46.7904C70.6372 46.6766 70.6759 46.5443 70.6759 46.4092C70.6759 46.274 70.6372 46.1417 70.5643 46.0279C70.4914 45.9141 70.3873 45.8236 70.2646 45.7671L69.3086 45.3418C67.947 44.7309 66.8718 43.6208 66.3046 42.2405L65.9659 41.4258C65.9158 41.3002 65.8292 41.1924 65.7173 41.1165C65.6053 41.0406 65.4732 41 65.3379 41C65.2026 41 65.0705 41.0406 64.9585 41.1165C64.8466 41.1924 64.76 41.3002 64.7099 41.4258L64.3712 42.2391C63.8043 43.62 62.729 44.7306 61.3672 45.3418L60.4099 45.7685C60.2875 45.8251 60.1839 45.9156 60.1112 46.0292C60.0386 46.1429 60 46.2749 60 46.4098C60 46.5447 60.0386 46.6767 60.1112 46.7904C60.1839 46.904 60.2875 46.9945 60.4099 47.0512L61.4232 47.5018C62.7509 48.0972 63.8077 49.1679 64.3859 50.5031L64.7152 51.2578C64.9552 51.8098 65.7206 51.8098 65.9606 51.2578Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 1" d="M8.74286 16L0 0H18L8.74286 16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

+14 -6
View File
@@ -300,10 +300,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -355,10 +359,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -491,14 +499,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = F38D68G8T6;
DEVELOPMENT_TEAM = 3QMFZJWPLS;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -678,14 +686,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = F38D68G8T6;
DEVELOPMENT_TEAM = 3QMFZJWPLS;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -705,14 +713,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = F38D68G8T6;
DEVELOPMENT_TEAM = 3QMFZJWPLS;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+4
View File
@@ -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
View File
@@ -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));
+5 -1
View File
@@ -4,7 +4,11 @@ class AppConfig {
factory AppConfig.fromEnvironment() {
const url = String.fromEnvironment(
'JINZHI_API_BASE_URL',
defaultValue: 'https://yanting.api.hnsiderui.cn',
// defaultValue: 'http://localhost:8088',
// defaultValue: 'https://jince.api.xianlin88.top',
defaultValue: 'https://jinzhi.api.yqzhice.top',
// defaultValue: 'http://8.153.17.57',可测试ip
);
return const AppConfig(apiBaseUrl: url);
}
+1 -1
View File
@@ -3,7 +3,7 @@ class AppH5Urls {
static const baseUrl = String.fromEnvironment(
'JINZHI_H5_BASE_URL',
defaultValue: 'https://yanting.h5.hnsiderui.cn',
defaultValue: 'https://jinzhi.h5.yqzhice.top',
);
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
+6 -3
View File
@@ -6,9 +6,12 @@ class UmengConfig {
static String _androidAppKey =
'6a27b1f46f259537c7b616be'; // Default placeholder
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
static String _androidPushSecret = 'xxx'; // Default placeholder
static String _androidLoginSecret = '';
static String _iosLoginSecret = '';
static String _androidPushSecret =
'261fc7656f0c34f4cf122a3c2c81f704'; // Default placeholder
static String _androidLoginSecret =
'oNaEhNEwZGQHvngXI4kyxR90tavJRDHMyDDQhkQouty+SAUb66szdNQivqE99ZIQO+QyNabsyyTJ7CN8iA3vb8EgPwLF/iSUHMWpGVacHpukG1sqySo1cwbRmDpOFcdNzaO8PavGfqkkCse/oRPNFBW40LhCjSJaI/roAEVNGpepMXvufJtjnyB4OX6sav+isHU/Lsz84bcdwnzD6fFyDSyE+YqN9gjUoUOv8Ghcy3H1FXUMvw4iD9nD+tE4jODLXvvzlpVbHYknn6Y2UAj5dvGvNUszocXqGAr4j3z8naKPBhz7Liofog==';
static String _iosLoginSecret =
'vcutZmDFJ2HBdFk7herCz2M9dPHuoa303NPGrm8c9gkzu/Lr5njFa85y33w7htgp0cx9JCMbsk3ecrREhVd8yeq73kFFiVqgkk/BMzCQ6VG55gSATEoiRaLnqXkKvuv6X4kcnSqANMI07up5rTPdAtVQ5JGPh9ts7LUBzRZWQRnDvdX8pjPy8LKxXJKoNagz4sHd5m9MbX0o0uep2OaFFFBTOyI+g4vjK1oZkpaUnIKGU8cTH+wvL8qwp5BLtkc6XCXe5PXqClU=';
static Future<void> load() async {
await dotenv.load(
@@ -1,8 +1,27 @@
import 'package:dio/dio.dart';
import '../../models/pb/errors/errors.pb.dart' as kerrors;
class KratosErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final response = err.response;
final raw = response?.data;
if (response != null && raw is List<int> && raw.isNotEmpty) {
try {
final status = kerrors.Status.fromBuffer(raw);
if (status.code != 0 || status.reason.isNotEmpty) {
response.data = <String, dynamic>{
'code': status.code,
'reason': status.reason,
'message': status.message,
'metadata': status.metadata,
};
}
} catch (_) {
// A non-Kratos error body keeps its original representation.
}
}
handler.next(err);
}
}
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package: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();
}
+5 -1
View File
@@ -6,8 +6,12 @@ const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
const storageUserId = 'jinzhi_user_id';
const storageUserPhone = 'jinzhi_user_phone';
const storageUserCreatedAt = 'jinzhi_user_created_at';
const storagePushPermissionPrompted = 'push_permission_prompted';
const storageMallEntryPromoOpened = 'jinzhi_mall_entry_promo_opened';
const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
// Previous builds could persist credentials in SharedPreferences. These keys
// exist only for one-time migration into secure storage.
+184
View File
@@ -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,
),
};
}
+1 -1
View File
@@ -32,7 +32,7 @@ abstract final class UmengService {
// Keep the carrier-login launch path independent of UMeng Common's
// network initialization. Waiting for this future is what left the login
// overlay spinning forever in the base project; U-Verify is configured
// immediately below by initOneKeyLoginIfAllowed, as in Yanting.
// immediately below by initOneKeyLoginIfAllowed.
unawaited(
UmengCommonSdk.initCommon(
UmengConfig.androidAppKey,
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io' show Platform;
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart';
typedef AndroidIntentLauncher = Future<void> Function(AndroidIntent intent);
abstract final class AppLinkUtil {
static final _androidPackageNamePattern = RegExp(
r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$',
);
static String targetAppFromUrl(String urlString) {
final value = Uri.tryParse(urlString)?.queryParameters['targetApp']?.trim();
if (value == null || !_androidPackageNamePattern.hasMatch(value)) return '';
return value;
}
static Future<bool> openExternal(Uri uri, {String packageName = ''}) =>
AppLinkLauncher(
isAndroid: () => !kIsWeb && Platform.isAndroid,
launchAndroidIntent: (intent) => intent.launch(),
).openExternal(uri, packageName: packageName);
}
class AppLinkLauncher {
const AppLinkLauncher({
required this.isAndroid,
required this.launchAndroidIntent,
this.launchExternal,
});
final bool Function() isAndroid;
final AndroidIntentLauncher launchAndroidIntent;
final Future<bool> Function(Uri uri)? launchExternal;
Future<bool> openExternal(Uri uri, {String packageName = ''}) async {
if (uri.host.isEmpty ||
(uri.scheme.toLowerCase() != 'http' &&
uri.scheme.toLowerCase() != 'https')) {
return false;
}
if (isAndroid() && packageName.trim().isNotEmpty) {
try {
await launchAndroidIntent(
AndroidIntent(
action: 'action_view',
data: uri.toString(),
package: packageName.trim(),
),
);
return true;
} catch (_) {}
}
final launcher = launchExternal;
if (launcher != null) return launcher(uri);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@@ -16,17 +16,21 @@ class AdConfigForegroundRefresher {
final void Function() _banner;
final 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 (_) {}
}
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
@@ -218,7 +219,7 @@ class ProtoMessage {
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? String.fromCharCodes(value) : '';
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
}
ProtoMessage message(int tag) {
@@ -71,9 +71,29 @@ class _SplashAdDialogState extends State<SplashAdDialog> {
Positioned(
top: MediaQuery.paddingOf(context).top + 16,
right: 18,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
child: Text('跳过 ${remain}s'),
child: GestureDetector(
key: const ValueKey('splash-skip'),
onTap: () => Navigator.of(context).pop(SplashAdResult.skipped),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 7,
),
child: Text(
'跳过 ${remain > 0 ? remain : 0}s',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
],
@@ -1,91 +1,143 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/network/auth_interceptor.dart';
import '../../../common/network/dio_provider.dart';
import '../../../common/storage/secure_identity_storage.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/network/dio_provider.dart';
import '../data/auth_api.dart';
import '../data/auth_models.dart';
import '../data/one_key_login_api.dart';
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
final authProvider =
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
(ref) => AuthController(ref.read(dioProvider)),
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
return AuthController(
ref.read(dioProvider),
identityStorage: ref.read(secureIdentityStorageProvider),
preferences: StorageService.to.preferences,
);
});
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
AuthController(this._dio) : super(const AsyncLoading()) {
_bootstrap();
AuthController(
this._dio, {
required SecureIdentityStorage identityStorage,
required SharedPreferences preferences,
bool autoBootstrap = true,
}) : _identityStorage = identityStorage,
_preferences = preferences,
super(const AsyncLoading()) {
if (autoBootstrap) _bootstrap();
}
final Dio _dio;
final SecureIdentityStorage _identityStorage;
final SharedPreferences _preferences;
AuthApi get _api => AuthApi(_dio);
Future<void> retryBootstrap() => _bootstrap();
Future<void> _bootstrap() async {
final token = await loadJwt();
state = const AsyncLoading();
final token = await _identityStorage.readJwt();
if (token == null || token.isEmpty) {
state = const AsyncData(GuestAuthState());
return;
}
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone.isEmpty ? '13800000000' : phone,
nickname: '金值用户',
createdAt: DateTime(2026, 6, 18),
),
token: token,
),
);
try {
final user = await _api.getProfile();
await _persistProfile(user);
state = AsyncData(LoggedInAuthState(user: user, token: token));
} catch (error, stackTrace) {
if (_isUnauthorized(error)) {
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
return;
}
final cached = _canUseCachedProfile(error) ? _readCachedProfile() : null;
if (cached != null) {
await _persistProfile(cached);
state = AsyncData(LoggedInAuthState(user: cached, token: token));
} else {
state = AsyncError(error, stackTrace);
}
}
}
Future<void> sendCode(String phone) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
Future<void> sendCode(String phone) => _api.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);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: token,
),
);
final reply = await _api.verifySmsCode(phone, code);
await _persistLogin(reply);
}
/// Test environment reuses Yanting's one-key verification endpoint until
/// 金值 has its own account contract.
Future<void> verifyOneKeyLogin(String loginToken) async {
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
await saveJwt(reply.token);
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
final reply = await _api.verifyOneKeyLogin(loginToken);
await _persistLogin(reply);
}
Future<void> _persistLogin(AuthLoginResult reply) async {
await _identityStorage.saveJwt(reply.accessToken);
await _persistProfile(reply.user);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: reply.phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: reply.token,
),
LoggedInAuthState(user: reply.user, token: reply.accessToken),
);
}
Future<void> logout() async {
await clearJwt();
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
}
Future<void> deleteAccount([String? code]) async {
await logout();
Future<void> deleteAccount(String code) async {
final normalized = code.trim();
if (normalized.isEmpty) {
throw ArgumentError.value(code, 'code', 'must not be empty');
}
await _api.deleteAccount(normalized);
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
}
Future<void> _persistProfile(JinzhiUser user) async {
await _preferences.setString(storageUserId, user.id);
await _preferences.setString(storageUserPhone, user.phone);
await _preferences.setString(storageUserCreatedAt, user.createdAt);
await _preferences.remove(_legacyMockLoggedInKey);
}
JinzhiUser? _readCachedProfile() {
final id = _preferences.getString(storageUserId)?.trim() ?? '';
final phone =
(_preferences.getString(storageUserPhone) ??
_preferences.getString(_legacyMockLoggedInKey) ??
'')
.trim();
final createdAt =
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
if (id.isEmpty && phone.isEmpty) return null;
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
}
Future<void> _clearLocalSession() async {
await _identityStorage.clearJwt();
await _preferences.remove(storageUserId);
await _preferences.remove(storageUserPhone);
await _preferences.remove(storageUserCreatedAt);
await _preferences.remove(_legacyMockLoggedInKey);
}
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
}
bool _isUnauthorized(Object error) =>
error is DioException && error.response?.statusCode == 401;
bool _canUseCachedProfile(Object error) {
if (error is! DioException) return false;
final status = error.response?.statusCode;
return status == null || status == 408 || status == 429 || status >= 500;
}
@@ -24,6 +24,9 @@ abstract interface class OneKeyLoginGateway {
void setWidgetCallback(OneKeyLoginCallback? callback);
/// 仅在用户点按一键登录后调用,预热运营商授权页。
Future<Object?> accelerate();
Future<void> requestLogin(UMCustomModel uiConfig);
void closeAuthPage();
@@ -65,6 +68,11 @@ class UmengOneKeyLoginGateway implements OneKeyLoginGateway {
}
}
@override
Future<Object?> accelerate() {
return UmengVerifySdk.accelerateLoginPageWithTimeout(3);
}
@override
Future<void> requestLogin(UMCustomModel uiConfig) async {
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
@@ -137,15 +145,25 @@ class OneKeyLoginSdkSession {
return _result.future;
}
// Availability checking already pre-accelerates the carrier page. Start
// the token request directly here; the SDK performs its own preflight when
// needed and reports its timeout/error through the registered callback.
// 预热必须由用户显式点击触发;不在启动或隐私同意阶段调用它。
unawaited(_startNativeLogin(uiConfig));
return _result.future;
}
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
try {
// 与 jinzhi-flutter-app 保持一致。未预热时 SDK 会在此处隐式预热,
// 在部分 Android 机型上更容易超时或直接回调失败。
final preheat = await _gateway.accelerate();
if (kDebugMode && preheat is Map && preheat['ret'] != null) {
// `ret` 是运营商预热失败码;插件原生层未将它输出到 logcat。
// 预热失败不阻断接下来的 getLoginToken,SDK 会自行再试一次。
debugPrint(
'OneKeyLogin preheat failed: vendor=${preheat['vendor']}, '
'ret=${preheat['ret']}',
);
}
if (_terminal) return;
_gateway.setTokenCallback(_handleTokenResult);
_gateway.setWidgetCallback((Object? _) {});
_gateway.setUiCallback(_handleUiResult);
@@ -186,6 +204,15 @@ class OneKeyLoginSdkSession {
void _handleTokenResult(Object? result) {
final code = extractUmengLoginCode(result);
if (kDebugMode && !isUmengTokenSuccess(result)) {
final map = result is Map ? result : const <Object?, Object?>{};
// 本地插件原先丢弃了 onTokenFailed 的 ret;只记录诊断字段,绝不记录 token。
debugPrint(
'OneKeyLogin Android/iOS SDK result: code=$code, '
'msg=${map['msg']}, vendor=${map['vendorName']}, '
'carrierError=${map['carrierFailedResultData']}',
);
}
// On iOS, 700002 only means the user tapped the authorization page's
// login button. It is followed by a separate 600000 (or 600xxx) token
// callback, so completing the session here discards that callback.
+94
View File
@@ -0,0 +1,94 @@
import 'package:dio/dio.dart';
import '../../../common/network/dio_provider.dart';
import '../../../models/pb/jinzhi/v1/account.pb.dart' as pb;
import 'auth_models.dart';
/// 金值账户的 protobuf-over-HTTP 客户端。
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: pb.SendSmsCodeRequest(phone: phone).writeToBuffer(),
options: protobufOptions(),
);
return pb.SendSmsCodeReply.fromBuffer(
response.data ?? const <int>[],
).expiresIn;
}
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/verify',
data: pb.VerifySmsCodeRequest(phone: phone, code: code).writeToBuffer(),
options: protobufOptions(),
);
return AuthLoginResult.fromProto(
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
);
}
Future<AuthLoginResult> verifyOneKeyLogin(String loginToken) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/onekey/verify',
data: pb.VerifyOneKeyLoginRequest(loginToken: loginToken).writeToBuffer(),
options: protobufOptions(),
);
return AuthLoginResult.fromProto(
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
);
}
Future<JinzhiUser> getProfile() async {
final response = await _dio.get<List<int>>(
'/api/v1/me',
options: protobufOptions(),
);
final reply = pb.ProfileReply.fromBuffer(response.data ?? const <int>[]);
if (!reply.hasUser()) {
throw const FormatException('账号资料响应缺少 user');
}
return _userFromProto(reply.user);
}
Future<void> deleteAccount(String smsCode) async {
await _dio.delete<List<int>>(
'/api/v1/me',
data: pb.DeleteAccountRequest(smsCode: smsCode).writeToBuffer(),
options: protobufOptions(),
);
}
}
class AuthLoginResult {
const AuthLoginResult({
required this.accessToken,
required this.expiresIn,
required this.user,
});
final String accessToken;
final int expiresIn;
final JinzhiUser user;
factory AuthLoginResult.fromProto(pb.VerifySmsCodeReply reply) {
if (reply.accessToken.trim().isEmpty || !reply.hasUser()) {
throw const FormatException('登录响应缺少 access_token 或 user');
}
return AuthLoginResult(
accessToken: reply.accessToken.trim(),
expiresIn: reply.expiresIn,
user: _userFromProto(reply.user),
);
}
}
JinzhiUser _userFromProto(pb.UserInfo user) => JinzhiUser(
id: user.id.trim(),
phone: user.phone.trim(),
createdAt: user.createdAt.trim(),
);
+5 -3
View File
@@ -1,13 +1,15 @@
class JinzhiUser {
const JinzhiUser({
required this.id,
required this.phone,
required this.nickname,
required this.createdAt,
});
final String id;
final String phone;
final String nickname;
final DateTime createdAt;
final String createdAt;
String get nickname => '金值用户';
}
sealed class AuthState {
@@ -1,92 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../../common/network/dio_provider.dart';
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
class OneKeyLoginApi {
OneKeyLoginApi(this._dio);
final Dio _dio;
Future<OneKeyLoginReply> verify(String loginToken) async {
final bytes = utf8.encode(loginToken);
final request = Uint8List.fromList([
0x0A,
..._varint(bytes.length),
...bytes,
]);
final response = await _dio.post<List<int>>(
'/api/v1/auth/onekey/verify',
data: request,
options: protobufOptions(),
);
return _decode(Uint8List.fromList(response.data ?? const []));
}
OneKeyLoginReply _decode(Uint8List data) {
final fields = _fields(data);
final token = utf8.decode(fields[1] ?? const []);
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
return OneKeyLoginReply(
token: token,
phone: utf8.decode(user[2] ?? const []),
);
}
Map<int, List<int>> _fields(Uint8List data) {
final result = <int, List<int>>{};
var i = 0;
while (i < data.length) {
final tag = _readVarint(data, i);
i = tag.$2;
final wireType = tag.$1 & 7;
if (wireType == 0) {
i = _readVarint(data, i).$2;
continue;
}
if (wireType != 2) {
throw const FormatException('Unsupported protobuf field');
}
final length = _readVarint(data, i);
i = length.$2;
final end = i + length.$1;
if (end > data.length) {
throw const FormatException('Invalid protobuf payload');
}
result[tag.$1 >> 3] = data.sublist(i, end);
i = end;
}
return result;
}
(int, int) _readVarint(Uint8List data, int index) {
var value = 0;
var shift = 0;
while (index < data.length) {
final byte = data[index++];
value |= (byte & 0x7f) << shift;
if (byte & 0x80 == 0) return (value, index);
shift += 7;
}
throw const FormatException('Invalid protobuf varint');
}
List<int> _varint(int value) {
final result = <int>[];
while (value > 0x7f) {
result.add((value & 0x7f) | 0x80);
value >>= 7;
}
result.add(value);
return result;
}
}
class OneKeyLoginReply {
const OneKeyLoginReply({required this.token, required this.phone});
final String token;
final String phone;
}
@@ -31,7 +31,20 @@ class DeleteAccountPage extends ConsumerWidget {
),
error: (e, _) => Scaffold(
backgroundColor: cs.background,
body: Center(child: Text('加载失败:$e')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('账号状态加载失败'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () =>
ref.read(authProvider.notifier).retryBootstrap(),
child: const Text('重试'),
),
],
),
),
),
data: (authState) {
if (authState is! LoggedInAuthState) {
@@ -669,7 +682,9 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
// 键盘弹出时 sheet 跟随上移
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
@@ -715,11 +730,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'正在清除账户数据,请稍候',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
const SizedBox(height: 28),
Center(
@@ -737,11 +748,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
if (_error != null) ...[
const SizedBox(height: 10),
@@ -768,11 +775,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'验证码已发送至 ${_maskPhone(widget.phone)}',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
),
const SizedBox(height: 20),
_PinInput(
+11 -21
View File
@@ -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);
@@ -49,11 +47,19 @@ class LoginPage extends HookConsumerWidget {
Future<void> returnAfterLogin() async {
final target = next?.trim();
final router = GoRouter.of(context);
if (target == null || target.isEmpty) {
router.go(AppRoutes.profile);
if (target != null && target.isNotEmpty) {
router.go(target);
return;
}
router.go(target);
// The mall opens this page with no `next`. Pop back to its resident
// WebView so the auth-state listener can deliver `enterWebMall` after
// the app-mall token has been issued. Replacing the route here disposes
// that WebView before the asynchronous hand-off can finish.
if (context.canPop()) {
context.pop();
} else {
router.go(AppRoutes.profile);
}
}
void startCountdown() {
@@ -165,9 +171,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 +230,6 @@ class LoginPage extends HookConsumerWidget {
agreed: agreed.value,
onToggleAgreed: () => agreed.value = !agreed.value,
onSend: onSendTap,
oneKeyLoading: oneKeyLoading.value,
onOneKey: launchOneKey,
),
),
),
@@ -247,8 +248,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 +259,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 +303,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,53 @@
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, {
MallEntryRequest request = MallEntryRequest.home,
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;
}
config = await _ref
.read(mallTabConfigProvider.notifier)
.recordClick(request: request);
if (!config.isVisible || !context.mounted) {
return MallEntryOpenResult.unavailable;
}
final uri = config.attributedUri(
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));
@@ -0,0 +1,23 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/storage/storage_keys.dart';
class MallEntryPromoStore {
const MallEntryPromoStore(this._preferences);
final SharedPreferences _preferences;
bool get hasOpenedMall =>
_preferences.getBool(storageMallEntryPromoOpened) ?? false;
bool get hasSeenHomeBadge =>
_preferences.getBool(storageMallHomeBadgeSeen) ?? false;
Future<void> markMallOpened() async {
await _preferences.setBool(storageMallEntryPromoOpened, true);
}
Future<void> markHomeBadgeSeen() async {
await _preferences.setBool(storageMallHomeBadgeSeen, true);
}
}
+195 -135
View File
@@ -1,15 +1,40 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../common/network/dio_provider.dart';
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
/// 标识打开商城的宿主项目。
const mallSourceApp = 'jinzhi';
class MallEntryRequest {
const MallEntryRequest({
required this.sourceEntry,
required this.hookType,
this.entryScene,
this.entryMetal,
});
static const home = MallEntryRequest(
sourceEntry: 'mall_tab',
hookType: 'mall_tab',
);
static const quoteRetailGold = MallEntryRequest(
sourceEntry: 'market_quote',
hookType: 'quote_cta',
entryScene: 'retail',
entryMetal: 'au',
);
final String sourceEntry;
final String hookType;
final String? entryScene;
final String? entryMetal;
}
class MallTabConfig {
const MallTabConfig({
required this.enabled,
@@ -17,8 +42,15 @@ class MallTabConfig {
this.title = '商城',
this.adId = '',
this.trackingId = '',
this.clickId = '',
this.openMode = 'in_app',
this.badgeText = '',
this.entryPromoEnabled = false,
this.entryBadgeText = '',
this.entryTipTitle = '',
this.entryTipBody = '',
this.quoteCtaEnabled = false,
this.entryRequest = MallEntryRequest.home,
this.allowedHosts = const <String>{},
});
const MallTabConfig.disabled() : this(enabled: false, url: '');
@@ -28,50 +60,99 @@ class MallTabConfig {
final String title;
final String adId;
final String trackingId;
final String clickId;
final String openMode;
final String badgeText;
final bool entryPromoEnabled;
final String entryBadgeText;
final String entryTipTitle;
final String entryTipBody;
final bool quoteCtaEnabled;
final MallEntryRequest entryRequest;
final Set<String> allowedHosts;
bool get isVisible => enabled && initialUri != null;
bool get opensInApp => openMode == 'in_app';
bool get showsEntryBadge =>
isVisible && entryPromoEnabled && entryBadgeText.trim().isNotEmpty;
bool get showsEntryTip =>
isVisible &&
entryPromoEnabled &&
(entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty);
Uri? get initialUri => attributedUri(clickId: '');
String get from {
final value = Uri.tryParse(url.trim())?.queryParameters['from']?.trim();
return value == null || value.isEmpty ? mallSourceApp : value;
}
Uri? attributedUri({required String clickId, bool isDark = false}) {
Uri? get initialUri => attributedUri();
Uri? attributedUri({
String? clickId,
bool isDark = false,
MallEntryRequest? entry,
}) {
final uri = Uri.tryParse(url.trim());
if (!_isAllowed(uri)) return null;
final effectiveClickId = clickId ?? this.clickId;
final effectiveEntry = entry ?? entryRequest;
return uri!.replace(
queryParameters: {
...uri.queryParameters,
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
? uri.queryParameters['from']!
: mallSourceApp,
'source_app': mallSourceApp,
'source_entry': 'mall_tab',
'from': from,
'source_app': from,
'source_entry': effectiveEntry.sourceEntry,
'placement': 'mall_tab',
'hook_type': 'mall_tab',
'hook_type': effectiveEntry.hookType,
'entry_scene': ?effectiveEntry.entryScene,
'entry_metal': ?effectiveEntry.entryMetal,
'target_app': 'mall',
if (adId.isNotEmpty) 'ad_id': adId,
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
if (clickId.isNotEmpty) 'click_id': clickId,
if (clickId.isNotEmpty)
if (effectiveClickId.isNotEmpty) 'click_id': effectiveClickId,
if (effectiveClickId.isNotEmpty)
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
'isNight': isDark ? '1' : '0',
},
);
}
MallTabConfig withClick(
String value, {
MallEntryRequest request = MallEntryRequest.home,
}) => MallTabConfig(
enabled: enabled,
url: url,
title: title,
adId: adId,
trackingId: trackingId,
clickId: value,
openMode: openMode,
entryPromoEnabled: entryPromoEnabled,
entryBadgeText: entryBadgeText,
entryTipTitle: entryTipTitle,
entryTipBody: entryTipBody,
quoteCtaEnabled: quoteCtaEnabled,
entryRequest: request,
allowedHosts: allowedHosts,
);
static bool _isAllowed(Uri? uri) {
if (uri == null || uri.host.isEmpty) return false;
if (uri.scheme == 'https') return true;
if (uri.scheme.toLowerCase() == 'https') return true;
return !kReleaseMode &&
uri.scheme == 'http' &&
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
uri.scheme.toLowerCase() == 'http' &&
const {
'localhost',
'127.0.0.1',
'::1',
}.contains(uri.host.toLowerCase());
}
}
class MallTabConfigController extends StateNotifier<MallTabConfig> {
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
refresh();
MallTabConfigController(this._dio, {bool autoRefresh = true})
: super(const MallTabConfig.disabled()) {
if (autoRefresh) refresh();
}
final Dio _dio;
@@ -90,54 +171,51 @@ class MallTabConfigController extends StateNotifier<MallTabConfig> {
queryParameters: const {'placement': 'mall_tab'},
options: protobufOptions(),
);
final reply = _ProtoMessage(response.data ?? const []).message(2);
final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0;
final trackedUrl = reply.string(14);
final h5Url = reply.string(9);
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
if (!enabled || url == null) {
state = const MallTabConfig.disabled();
return;
}
state = MallTabConfig(
enabled: true,
url: url,
title: _nonEmpty(reply.string(4), '商城'),
adId: reply.string(1),
trackingId: reply.string(10),
openMode: _openMode(reply.integer(15)),
badgeText: reply.string(17).trim(),
);
await _report('ad_impression');
state = parseMallTabConfigResponse(response.data ?? const <int>[]);
if (!state.isVisible) return;
await _report(state, 'ad_impression');
} catch (_) {
state = const MallTabConfig.disabled();
}
}
Future<String?> recordClick() async {
if (!state.isVisible) return null;
final clickId = const Uuid().v4();
Future<MallTabConfig> recordClick({
MallEntryRequest request = MallEntryRequest.home,
}) async {
if (!state.isVisible) return state;
final clicked = state.withClick(const Uuid().v4(), request: request);
state = clicked;
try {
await _report('ad_click', clickId: clickId);
await _report(clicked, 'ad_click');
} catch (_) {
// 归因上报失败不能阻断商城入口。
}
return clickId;
return clicked;
}
Future<void> _report(String event, {String clickId = ''}) =>
_dio.post<List<int>>(
Future<void> _report(MallTabConfig config, String event) async {
final response = await _dio.post<List<int>>(
'/api/v1/ads/events',
data: _ProtoWriter()
.string(1, event)
.string(2, 'mall_tab')
.string(3, state.adId)
.string(4, state.trackingId)
.string(5, clickId)
.integer(6, DateTime.now().millisecondsSinceEpoch)
.toBytes(),
data: pb.ReportAdEventRequest(
eventName: event,
placement: 'mall_tab',
adId: config.adId,
trackingId: config.trackingId,
clickId: config.clickId,
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
attrs: <String, String>{
'source_app': config.from,
'source_entry': config.entryRequest.sourceEntry,
'hook_type': config.entryRequest.hookType,
'entry_scene': ?config.entryRequest.entryScene,
'entry_metal': ?config.entryRequest.entryMetal,
'open_mode': config.openMode,
},
).writeToBuffer(),
options: protobufOptions(),
);
pb.ReportAdEventReply.fromBuffer(response.data ?? const <int>[]);
}
}
final mallTabConfigProvider =
@@ -145,92 +223,74 @@ final mallTabConfigProvider =
(ref) => MallTabConfigController(ref.watch(dioProvider)),
);
Map<String, dynamic> buildEnterWebMallBridgeArguments({
required MallTabConfig config,
required bool logged,
Uri? currentUri,
bool isDark = false,
String? authToken,
String? scene,
String? sourceApp,
}) {
final arguments = <String, dynamic>{
...?currentUri?.queryParameters,
...?config.attributedUri(isDark: isDark)?.queryParameters,
'from': config.from,
'logged': logged,
};
final token = authToken?.trim() ?? '';
if (token.isNotEmpty) {
arguments['auth_token'] = token;
arguments['authToken'] = token;
}
final resolvedScene = scene?.trim() ?? '';
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
final resolvedSourceApp = sourceApp?.trim() ?? '';
if (resolvedSourceApp.isNotEmpty) {
arguments['from'] = resolvedSourceApp;
arguments['source_app'] = resolvedSourceApp;
}
return arguments;
}
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
final reply = pb.GetAdReply.fromBuffer(bytes);
if (!reply.enabled || !reply.hasAd()) {
return const MallTabConfig.disabled();
}
final creative = reply.ad;
final url = _firstAllowedUrl(creative.trackedUrl, creative.h5Url);
if (url == null) return const MallTabConfig.disabled();
final promotion = creative.hasMallPromotion() ? creative.mallPromotion : null;
return MallTabConfig(
enabled: true,
url: url,
title: _nonEmpty(creative.title, '商城'),
adId: creative.adId,
trackingId: creative.trackingId,
openMode: _openMode(creative.openMode),
entryPromoEnabled: creative.entryPromoEnabled,
entryBadgeText: creative.entryBadgeText.trim(),
entryTipTitle: creative.entryTipTitle.trim(),
entryTipBody: creative.entryTipBody.trim(),
quoteCtaEnabled: promotion?.quoteCtaEnabled ?? false,
allowedHosts: <String>{Uri.parse(url).host.toLowerCase()},
);
}
String _nonEmpty(String value, String fallback) =>
value.trim().isEmpty ? fallback : value.trim();
String? _validUrl(String raw) {
final uri = Uri.tryParse(raw.trim());
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
}
String _openMode(int value) => switch (value) {
1 => 'native_app',
2 => 'in_app',
String _openMode(pb.AdOpenMode value) => switch (value) {
pb.AdOpenMode.AD_OPEN_MODE_NATIVE_APP => 'native_app',
pb.AdOpenMode.AD_OPEN_MODE_IN_APP => 'in_app',
_ => 'external',
};
class _ProtoMessage {
_ProtoMessage(this._bytes);
final List<int> _bytes;
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>)
: '';
_ProtoMessage message(int wanted) => _ProtoMessage(
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
);
Map<int, Object> _fields() {
final values = <int, Object>{};
var index = 0;
int readVarint() {
var value = 0;
var shift = 0;
while (index < _bytes.length && shift < 64) {
final byte = _bytes[index++];
value |= (byte & 127) << shift;
if (byte < 128) return value;
shift += 7;
}
throw const FormatException('Invalid protobuf');
}
while (index < _bytes.length) {
final key = readVarint();
final tag = key >> 3;
switch (key & 7) {
case 0:
values[tag] = readVarint();
case 2:
final length = readVarint();
final end = index + length;
if (end > _bytes.length) {
throw const FormatException('Invalid protobuf');
}
values[tag] = _bytes.sublist(index, end);
index = end;
default:
throw const FormatException('Unsupported protobuf');
}
}
return values;
}
}
class _ProtoWriter {
final _bytes = BytesBuilder(copy: false);
_ProtoWriter string(int tag, String value) {
final data = value.codeUnits;
_varint((tag << 3) | 2);
_varint(data.length);
_bytes.add(data);
return this;
}
_ProtoWriter integer(int tag, int value) {
_varint(tag << 3);
_varint(value);
return this;
}
Uint8List toBytes() => _bytes.toBytes();
void _varint(int value) {
while (value > 127) {
_bytes.addByte((value & 127) | 128);
value >>= 7;
}
_bytes.addByte(value);
String? _firstAllowedUrl(String trackedUrl, String h5Url) {
for (final raw in <String>[trackedUrl, h5Url]) {
final uri = Uri.tryParse(raw.trim());
if (MallTabConfig._isAllowed(uri)) return uri.toString();
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:typed_data';
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',
// Dio serializes a List<int> as the UTF-8 text "[]". This endpoint
// expects an empty protobuf message, whose wire representation is zero
// bytes; Uint8List keeps the request body binary and untransformed.
data: Uint8List(0),
options: protobufOptions(),
);
final reply = ProtoMessage(response.data ?? const <int>[]);
final token = reply.string(1).trim();
final expiresIn = reply.integer(2);
final sourceApp = reply.string(3).trim();
final scene = reply.string(4).trim();
if (token.isEmpty || expiresIn <= 0 || sourceApp.isEmpty || scene.isEmpty) {
throw const FormatException('Incomplete mall auth token response');
}
return AppMallAuthToken(
authToken: token,
expiresIn: expiresIn,
sourceApp: sourceApp,
scene: scene,
);
}
}
final mallAuthApiProvider = Provider(
(ref) => MallAuthApi(ref.watch(dioProvider)),
);
@@ -0,0 +1,345 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
String _withChinesePunctuation(String value) {
const replacements = <String, String>{
':': ':',
',': ',',
';': ';',
'!': '!',
'?': '?',
'(': '(',
')': ')',
'[': '【',
']': '】',
'¥': '¥',
};
return value.split('').map((char) => replacements[char] ?? char).join();
}
class MallEntryPromoTooltip extends StatefulWidget {
const MallEntryPromoTooltip({
super.key,
required this.visible,
required this.title,
required this.body,
required this.onCardTap,
required this.child,
});
final bool visible;
final String title;
final String body;
final VoidCallback onCardTap;
final Widget child;
@override
State<MallEntryPromoTooltip> createState() => _MallEntryPromoTooltipState();
}
class _MallEntryPromoTooltipState extends State<MallEntryPromoTooltip> {
static const _cardWidth = 236.0;
static const _cardHeight = 115.0;
static const _anchorToCardBottom = 54.0;
static const _targetCenterToCardRight = 23.0;
static const _cardRightInset = 22.0;
final _targetKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _routeIsCurrent = true;
@override
void initState() {
super.initState();
_scheduleVisibilitySync();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true;
_scheduleVisibilitySync();
}
@override
void didUpdateWidget(covariant MallEntryPromoTooltip oldWidget) {
super.didUpdateWidget(oldWidget);
_scheduleVisibilitySync();
}
void _scheduleVisibilitySync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (widget.visible && _routeIsCurrent) {
_showOverlay();
} else {
_removeOverlay();
}
});
}
void _showOverlay() {
final current = _overlayEntry;
if (current != null) {
current.markNeedsBuild();
return;
}
final entry = OverlayEntry(builder: _buildOverlay);
_overlayEntry = entry;
Overlay.of(context, rootOverlay: true).insert(entry);
}
void _removeOverlay() {
final entry = _overlayEntry;
if (entry == null) return;
_overlayEntry = null;
entry
..remove()
..dispose();
}
@override
void dispose() {
_removeOverlay();
super.dispose();
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(key: _targetKey, child: widget.child);
}
Widget _buildOverlay(BuildContext overlayContext) {
final targetContext = _targetKey.currentContext;
final targetBox = targetContext?.findRenderObject();
if (targetContext == null ||
targetBox is! RenderBox ||
!targetBox.attached) {
return const SizedBox.shrink();
}
final overlayBox = Overlay.of(
targetContext,
rootOverlay: true,
).context.findRenderObject();
if (overlayBox is! RenderBox || !overlayBox.attached) {
return const SizedBox.shrink();
}
final targetCenter = targetBox.localToGlobal(
targetBox.size.center(Offset.zero),
ancestor: overlayBox,
);
final title = _withChinesePunctuation(widget.title.trim());
final body = _withChinesePunctuation(widget.body.trim());
return Positioned.fill(
child: LayoutBuilder(
key: const ValueKey('mall-entry-promo-pass-through-layer'),
builder: (context, constraints) {
final overlaySize = Size(constraints.maxWidth, constraints.maxHeight);
final cardWidth = math.min(
_cardWidth,
math.max(0.0, overlaySize.width - 40),
);
final desiredCardLeft =
targetCenter.dx + _targetCenterToCardRight - cardWidth;
final maximumCardLeft = math.max(
20.0,
overlaySize.width - cardWidth - _cardRightInset,
);
final cardLeft = desiredCardLeft
.clamp(20.0, maximumCardLeft)
.toDouble();
final desiredCardTop =
targetCenter.dy - _anchorToCardBottom - _cardHeight;
final minimumTop = MediaQuery.paddingOf(context).top + 16;
final maximumTop = math.max(
minimumTop,
overlaySize.height - _cardHeight - 20,
);
final cardTop = desiredCardTop
.clamp(minimumTop, maximumTop)
.toDouble();
final cardBottom = cardTop + _cardHeight;
final pointerLeft = (targetCenter.dx - 9)
.clamp(0.0, overlaySize.width - 18)
.toDouble();
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: cardLeft,
top: cardTop,
width: cardWidth,
height: _cardHeight,
child: Semantics(
button: true,
label: '进入商城',
child: GestureDetector(
key: const ValueKey('mall-entry-promo-card-action'),
behavior: HitTestBehavior.opaque,
onTap: widget.onCardTap,
child: DecoratedBox(
key: const ValueKey('mall-entry-promo-card'),
decoration: BoxDecoration(
color: const Color(0xE6FFFFFF),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
offset: Offset(0, 8),
blurRadius: 20,
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
'assets/navigation/mall_entry_promo_gift.svg',
key: const ValueKey('mall-entry-promo-gift'),
width: 45,
height: 40,
fit: BoxFit.fill,
),
const SizedBox(height: 4),
if (title.isNotEmpty)
SizedBox(
width: double.infinity,
height: 22,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
title,
maxLines: 1,
style: const TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.375,
),
),
),
),
if (title.isNotEmpty && body.isNotEmpty)
const SizedBox(height: 4),
if (body.isNotEmpty)
Text(
body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0x99000000),
fontSize: 12,
fontWeight: FontWeight.w400,
height: 17 / 12,
),
),
],
),
),
),
),
),
),
Positioned(
left: pointerLeft,
top: cardBottom - 1,
width: 18,
height: 16,
child: IgnorePointer(
child: SvgPicture.asset(
'assets/navigation/mall_entry_promo_pointer.svg',
key: const ValueKey('mall-entry-promo-pointer'),
fit: BoxFit.fill,
),
),
),
],
);
},
),
);
}
}
class MallEntryPromoBadge extends StatelessWidget {
const MallEntryPromoBadge({
super.key,
required this.visible,
required this.label,
required this.child,
this.top = -4,
this.right = -20,
});
final bool visible;
final String label;
final Widget child;
final double top;
final double right;
static const _singleCharacterWidth = 30.0;
static const _badgeHeight = 18.0;
@override
Widget build(BuildContext context) {
final text = label.trim();
if (!visible || text.isEmpty) return child;
final isSingleCharacter = text.runes.length == 1;
return Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
top: top,
right: isSingleCharacter ? right : right + 8,
child: IgnorePointer(
child: Badge(
key: const ValueKey('mall-entry-promo-badge'),
backgroundColor: const Color(0xFFE7473C),
textColor: Colors.white,
largeSize: isSingleCharacter ? _badgeHeight : null,
textStyle: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
height: 1,
),
padding: isSingleCharacter
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
label: isSingleCharacter
? SizedBox(
key: const ValueKey('mall-entry-promo-badge-label'),
width: _singleCharacterWidth,
height: _badgeHeight,
child: Center(
child: Text(
text,
maxLines: 1,
textAlign: TextAlign.center,
strutStyle: const StrutStyle(
height: 1,
leading: 0,
forceStrutHeight: true,
),
),
),
)
: Text(
text,
key: const ValueKey('mall-entry-promo-badge-label'),
maxLines: 1,
),
),
),
),
],
);
}
}
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/domain/money.dart';
import '../../../common/widgets/adaptive.dart';
import '../../mall/application/mall_entry_controller.dart';
import '../../mall/application/mall_tab_config.dart';
import '../application/exchange_calculator_controller.dart';
import '../application/market_controller.dart';
import '../data/market_models.dart';
@@ -93,6 +95,28 @@ class MarketPage extends ConsumerWidget {
),
),
const SizedBox(height: 12),
FilledButton.icon(
key: const ValueKey('market-mall-retail-cta'),
onPressed: () async {
final result = await ref
.read(mallEntryControllerProvider)
.open(
context,
request: MallEntryRequest.quoteRetailGold,
);
if (!context.mounted ||
(result != MallEntryOpenResult.unavailable &&
result != MallEntryOpenResult.failed)) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商城暂时无法打开,请稍后重试')),
);
},
icon: const Icon(Icons.shopping_bag_outlined),
label: const Text('买商城饰品(全款)'),
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -19,6 +19,8 @@ class ProfilePage extends ConsumerWidget {
final auth = ref.watch(authProvider);
final cs = ShadTheme.of(context).colorScheme;
final loggedIn = auth.valueOrNull is LoggedInAuthState;
final authLoading = auth.isLoading;
final authFailed = auth.hasError;
final currentUser = auth.valueOrNull is LoggedInAuthState
? (auth.valueOrNull as LoggedInAuthState).user
: null;
@@ -100,8 +102,22 @@ class ProfilePage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.tonal(
onPressed: () => LoginLauncher.start(context, ref),
child: Text(loggedIn ? '切换账号' : '去登录'),
onPressed: authLoading
? null
: authFailed
? () => ref
.read(authProvider.notifier)
.retryBootstrap()
: () => LoginLauncher.start(context, ref),
child: Text(
authLoading
? '正在恢复账号…'
: authFailed
? '账号恢复失败,点击重试'
: loggedIn
? '切换账号'
: '去登录',
),
),
const SizedBox(height: 10),
OutlinedButton(
@@ -49,32 +49,17 @@ class UpgradeInfo {
}
class UpdateApiClient {
UpdateApiClient(
this._dio, {
required String apiPathPrefix,
required String updatePackageName,
}) : _apiPathPrefix = _normalizePrefix(apiPathPrefix),
_updatePackageName = updatePackageName.trim();
UpdateApiClient(this._dio);
final Dio _dio;
final String _apiPathPrefix;
final String _updatePackageName;
Options get _options {
final options = protobufOptions();
if (_updatePackageName.isEmpty) return options;
return options.copyWith(
headers: {...?options.headers, 'pkg': _updatePackageName},
);
}
Future<UpgradeInfo> checkUpgrade() async {
final response = await _dio.post<List<int>>(
_path('/sys/upgrade'),
'/sys/upgrade',
// Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of
// JSON-encoding an empty Dart List as `[]` (Content-Length: 2).
data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest
options: _options,
options: protobufOptions(),
);
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
}
@@ -84,33 +69,18 @@ class UpdateApiClient {
required String source,
}) async {
await _dio.post<List<int>>(
_path('/active/upgrade/click'),
'/active/upgrade/click',
data: _ProtoWriter()
.string(1, buildId)
.string(2, source)
.toBytes(), // jinzhi.v1.UpgradeClickRequest
options: _options,
options: protobufOptions(),
);
}
String _path(String endpoint) => '$_apiPathPrefix$endpoint';
static String _normalizePrefix(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == '/') return '';
final withLeadingSlash = trimmed.startsWith('/') ? trimmed : '/$trimmed';
return withLeadingSlash.endsWith('/')
? withLeadingSlash.substring(0, withLeadingSlash.length - 1)
: withLeadingSlash;
}
}
final updateApiClientProvider = Provider<UpdateApiClient>(
(ref) => UpdateApiClient(
ref.watch(dioProvider),
apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix,
updatePackageName: ref.watch(appConfigProvider).updatePackageName,
),
(ref) => UpdateApiClient(ref.watch(dioProvider)),
);
class _ProtoFields {
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../../common/config/app_h5_urls.dart';
import '../../../common/push/notification_permission.dart';
@@ -32,6 +33,7 @@ class SettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = ShadTheme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final themeMode = ref.watch(themeModeControllerProvider);
final profileSettings = ref.watch(profileSettingsControllerProvider);
final authAsync = ref.watch(authProvider);
@@ -198,16 +200,28 @@ class SettingsPage extends HookConsumerWidget {
_NavRow(
icon: FlutterRemix.file_list_3_line,
label: '用户协议',
onTap: () => context.push(
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
onTap: () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(
AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.userProtocolUrl,
isDark,
),
),
mode: LaunchMode.inAppBrowserView,
),
),
Divider(height: 1, thickness: 1, color: cs.border),
_NavRow(
icon: FlutterRemix.shield_check_line,
label: '隐私政策',
onTap: () => context.push(
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
onTap: () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(
AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.privacyUrl,
isDark,
),
),
mode: LaunchMode.inAppBrowserView,
),
),
Divider(height: 1, thickness: 1, color: cs.border),
@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
enum MallBridgeTab { home, market, orders, profile }
class MallBridgeState {
const MallBridgeState({
this.ready = false,
this.activeTab = MallBridgeTab.home,
this.canGoBack = false,
});
final bool ready;
final MallBridgeTab activeTab;
final bool canGoBack;
}
class MallBridgeController {
MallBridgeController._();
static final instance = MallBridgeController._();
final state = ValueNotifier(const MallBridgeState());
Future<void> Function(MallBridgeTab tab, bool reselect)? _activateTab;
Future<void> Function()? _goBack;
void attach({
required Future<void> Function(MallBridgeTab tab, bool reselect)
activateTab,
required Future<void> Function() goBack,
}) {
_activateTab = activateTab;
_goBack = goBack;
}
void detach() {
_activateTab = null;
_goBack = null;
state.value = const MallBridgeState();
}
void update(Map<dynamic, dynamic>? raw) {
final tab = MallBridgeTab.values.firstWhere(
(item) => item.name == raw?['activeTab'],
orElse: () => MallBridgeTab.home,
);
state.value = MallBridgeState(
ready: _asBool(raw?['ready']),
activeTab: tab,
canGoBack: _asBool(raw?['canGoBack']),
);
}
Future<void> activate(MallBridgeTab tab, {bool reselect = false}) async {
final current = state.value;
state.value = MallBridgeState(
ready: current.ready,
activeTab: tab,
canGoBack: current.canGoBack,
);
await _activateTab?.call(tab, reselect);
}
Future<void> goBack() async => await _goBack?.call();
}
bool _asBool(Object? value) =>
value == true || value == 1 || value == '1' || value == 'true';
@@ -1,30 +1,121 @@
import 'package:flutter/foundation.dart';
class MallWebHostState {
const MallWebHostState({this.url, this.visible = false});
const MallWebHostState({
this.url,
this.visible = false,
this.isShowingQiyuPage = false,
this.isQiyuKeyboardActive = false,
});
final String? url;
final bool visible;
final bool isShowingQiyuPage;
final bool isQiyuKeyboardActive;
}
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。
/// Process-lifetime presentation state for the resident mall WebView.
class MallWebHostController {
MallWebHostController._();
static final instance = MallWebHostController._();
final state = ValueNotifier(const MallWebHostState());
Future<void> Function()? _backHandler;
void preload(String url) {
final old = state.value;
if (old.url == url) return;
state.value = MallWebHostState(url: url, visible: old.visible);
static final instance = MallWebHostController._();
final state = ValueNotifier(const MallWebHostState());
String? _pendingPreloadUrl;
Future<void> Function()? _clearCache;
Future<void> Function()? _handleSystemBack;
void open(Uri url) {
_pendingPreloadUrl = null;
state.value = MallWebHostState(url: url.toString(), visible: true);
}
void open(String url) =>
state.value = MallWebHostState(url: url, visible: true);
void dismiss() => state.value = MallWebHostState(url: state.value.url);
void clear() => state.value = const MallWebHostState();
void attachBackHandler(Future<void> Function() handler) =>
_backHandler = handler;
void detachBackHandler() => _backHandler = null;
Future<void> handleBack() => _backHandler?.call() ?? Future.value();
void preload(Uri url) {
final current = state.value;
if (current.visible) {
if (current.url == null ||
cacheKeyFor(current.url!) != cacheKeyFor(url.toString())) {
_pendingPreloadUrl = url.toString();
}
return;
}
if (current.url != null &&
cacheKeyFor(current.url!) == cacheKeyFor(url.toString())) {
return;
}
state.value = MallWebHostState(
url: url.toString(),
visible: current.visible,
);
}
void dismiss() {
final current = state.value;
final pending = _pendingPreloadUrl;
_pendingPreloadUrl = null;
state.value = MallWebHostState(url: pending ?? current.url);
}
void setShowingQiyuPage(bool isShowingQiyuPage) {
final current = state.value;
if (current.isShowingQiyuPage == isShowingQiyuPage) return;
state.value = MallWebHostState(
url: current.url,
visible: current.visible,
isShowingQiyuPage: isShowingQiyuPage,
isQiyuKeyboardActive: isShowingQiyuPage
? current.isQiyuKeyboardActive
: false,
);
}
void setQiyuKeyboardActive(bool active) {
final current = state.value;
if (current.isQiyuKeyboardActive == active) return;
state.value = MallWebHostState(
url: current.url,
visible: current.visible,
isShowingQiyuPage: current.isShowingQiyuPage,
isQiyuKeyboardActive: active,
);
}
void attachCacheClearer(Future<void> Function() clearer) =>
_clearCache = clearer;
void detachCacheClearer() => _clearCache = null;
Future<void> clear() async {
await _clearCache?.call();
_pendingPreloadUrl = null;
state.value = const MallWebHostState();
}
static Uri uriForTheme(Uri uri, {required bool isDark}) {
final query = Map<String, List<String>>.from(uri.queryParametersAll);
query['isNight'] = [isDark ? '1' : '0'];
return uri.replace(queryParameters: query);
}
void attachSystemBackHandler(Future<void> Function() handler) {
_handleSystemBack = handler;
}
void detachSystemBackHandler() => _handleSystemBack = null;
Future<void> handleSystemBack() =>
_handleSystemBack?.call() ?? Future<void>.value();
static String cacheKeyFor(String rawUrl) {
final uri = Uri.tryParse(rawUrl);
if (uri == null) return rawUrl;
return uri
.replace(
queryParameters: uri.queryParameters['isNight'] == null
? const <String, String>{}
: <String, String>{'isNight': uri.queryParameters['isNight']!},
fragment: '',
)
.toString();
}
}
@@ -0,0 +1,53 @@
typedef WebCanGoBack = Future<bool> Function();
typedef WebGoBack = Future<void> Function();
typedef WebRunJavaScriptReturningResult = Future<Object> Function(String code);
const visibleWebBackControlJavaScript = r'''
(() => {
const selectors = [
'.back-btn',
'[aria-label="返回"]',
'[aria-label="Back"]',
'[title="返回"]',
'[title="Back"]',
].join(',');
const candidates = Array.from(document.querySelectorAll(selectors));
const control = candidates.find((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
if (
rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 ||
rect.right <= 0 || rect.top >= window.innerHeight ||
rect.left >= window.innerWidth || style.display === 'none' ||
style.visibility === 'hidden' || style.pointerEvents === 'none' ||
Number(style.opacity || 1) <= 0
) return false;
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
const topElement = document.elementFromPoint(x, y);
return topElement !== null && (topElement === element || element.contains(topElement));
});
if (!control) return false;
control.click();
return true;
})()
''';
Future<bool> tryGoBackInWebHistory({
required WebCanGoBack canGoBack,
required WebGoBack goBack,
}) async {
if (!await canGoBack()) return false;
await goBack();
return true;
}
Future<bool> tryGoBackUsingVisibleWebControl({
required WebRunJavaScriptReturningResult runJavaScriptReturningResult,
}) async {
final result = await runJavaScriptReturningResult(
visibleWebBackControlJavaScript,
);
if (result is bool) return result;
return result.toString().replaceAll('"', '').trim().toLowerCase() == 'true';
}
@@ -0,0 +1,38 @@
/// Mall pages stay in the resident WebView unless H5 explicitly requests an
/// external handoff or the URI is handled by the operating system.
bool shouldOpenWebUrlExternally(String rawUrl) {
final uri = Uri.tryParse(rawUrl);
if (uri == null) return false;
return const {'tel', 'mailto', 'sms'}.contains(uri.scheme.toLowerCase()) ||
uri.queryParameters.containsKey('out');
}
Uri sanitizeSystemCommunicationUri(Uri uri) {
if (uri.scheme.toLowerCase() != 'tel') return uri;
final parameterMarker = uri.path.indexOf('&');
final phone = parameterMarker < 0
? uri.path
: uri.path.substring(0, parameterMarker);
return Uri(scheme: uri.scheme, path: phone);
}
bool isTrustedQiyuUrl(String rawUrl) {
final uri = Uri.tryParse(rawUrl);
if (uri == null ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
return false;
}
final host = uri.host.toLowerCase();
return host == 'qiyukf.com' ||
host.endsWith('.qiyukf.com') ||
host == 'qiyukf.cn' ||
host.endsWith('.qiyukf.cn');
}
bool shouldApplyQiyuSafeArea(String rawUrl) => isTrustedQiyuUrl(rawUrl);
bool isQiyuClientEntryUrl(String rawUrl) {
final path = Uri.tryParse(rawUrl)?.path;
return path == '/client' || (path?.startsWith('/client/') ?? false);
}
@@ -0,0 +1,28 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class JsEvent {
const JsEvent(this.method, [this.args]);
final String method;
final List<dynamic>? args;
}
class WebReloadController {
final _controller = StreamController<JsEvent>.broadcast();
Stream<JsEvent> get stream => _controller.stream;
void callEvent(JsEvent event) => _controller.add(event);
Future<void> dispose() => _controller.close();
}
final webReloadControllerProvider = Provider<WebReloadController>((ref) {
final controller = WebReloadController();
ref.onDispose(() => unawaited(controller.dispose()));
return controller;
});
final webReloadPProvider = StreamProvider.autoDispose<JsEvent>((ref) {
return ref.watch(webReloadControllerProvider).stream;
});
+87
View File
@@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:convert';
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'js_api_result.dart';
class JsApi extends JavaScriptNamespaceInterface {
JsApi({
this.onClose,
this.onLoginRequested,
this.onMallStateChanged,
Future<String> Function()? appVersionResolver,
}) : _appVersionResolver = appVersionResolver ?? _readAppVersion;
final VoidCallback? onClose;
final VoidCallback? onLoginRequested;
final ValueChanged<Map<dynamic, dynamic>?>? onMallStateChanged;
final Future<String> Function() _appVersionResolver;
@override
void register() {
registerFunction(close);
registerFunction(getAppVersion);
registerFunction(mallStateChanged);
registerFunction(login);
}
@pragma('vm:entry-point')
void close(dynamic arg, CompletionHandler handler) {
onClose?.call();
handler.complete(const JsApiResult.success().toMap());
}
@pragma('vm:entry-point')
void getAppVersion(dynamic arg, CompletionHandler handler) {
unawaited(_completeAppVersion(handler));
}
Future<void> _completeAppVersion(CompletionHandler handler) async {
try {
final version = await _appVersionResolver();
handler.complete(
JsApiResult(
code: 0,
message: 'success',
data: <String, dynamic>{'version': version},
).toMap(),
);
} catch (_) {
handler.complete(
const JsApiResult(code: 1, message: 'app version unavailable').toMap(),
);
}
}
static Future<String> _readAppVersion() async =>
(await PackageInfo.fromPlatform()).version;
@pragma('vm:entry-point')
void mallStateChanged(dynamic arg, CompletionHandler handler) {
onMallStateChanged?.call(coerceMallStateMap(arg));
handler.complete(const JsApiResult.success().toMap());
}
@pragma('vm:entry-point')
void login(dynamic arg, CompletionHandler handler) {
onLoginRequested?.call();
handler.complete(const JsApiResult.success().toMap());
}
}
Map<dynamic, dynamic>? coerceMallStateMap(dynamic arg) {
if (arg is Map) return arg;
if (arg is String && arg.isNotEmpty) {
try {
final decoded = jsonDecode(arg);
if (decoded is Map) return decoded;
} catch (_) {}
}
if (arg is List && arg.isNotEmpty && arg.first is Map) {
return arg.first as Map<dynamic, dynamic>;
}
return null;
}
@@ -0,0 +1,19 @@
import 'dart:convert';
class JsApiResult {
const JsApiResult({required this.code, required this.message, this.data});
const JsApiResult.success() : code = 0, message = 'success', data = null;
final int code;
final String message;
final Map<String, dynamic>? data;
Map<String, dynamic> toMap() => <String, dynamic>{
'code': code,
'message': message,
if (data != null) 'data': data,
};
String toJson() => jsonEncode(toMap());
}
+658 -85
View File
@@ -1,130 +1,703 @@
import 'dart:async';
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
import 'package:flutter/foundation.dart'
show
TargetPlatform,
debugPrint,
defaultTargetPlatform,
kIsWeb,
kReleaseMode;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import '../../../common/util/app_link_util.dart';
import '../../auth/application/auth_controller.dart';
import '../../auth/application/login_launcher.dart';
import '../../auth/data/auth_models.dart';
import '../../mall/application/mall_tab_config.dart';
import '../../mall/data/mall_auth_api.dart';
import '../application/mall_bridge_state.dart';
import '../application/mall_web_host_controller.dart';
import '../application/web_back_navigation.dart';
import '../application/web_navigation_policy.dart';
import '../application/web_reload_providers.dart';
import 'js_api.dart';
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
class MallWebHost extends StatefulWidget {
const MallWebHost({super.key, required this.onExit});
final VoidCallback onExit;
/// Process-lifetime resident mall WebView. The controller, bridge contract and
/// first-load ordering intentionally match the Purse implementation.
class MallWebHost extends ConsumerStatefulWidget {
const MallWebHost({
super.key,
required this.url,
required this.visible,
required this.onRequestExit,
});
final String url;
final bool visible;
final VoidCallback onRequestExit;
@override
State<MallWebHost> createState() => _MallWebHostState();
ConsumerState<MallWebHost> createState() => _MallWebHostState();
}
class _MallWebHostState extends State<MallWebHost> {
final _host = MallWebHostController.instance;
WebViewController? _controller;
class _MallWebHostState extends ConsumerState<MallWebHost>
with WidgetsBindingObserver {
static const _defaultQiyuStatusBarColor = Color(0xFF3B82F6);
static const _mallSystemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
);
static const _webFilePickerChannel = MethodChannel(
'com.jinzhi.butler/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(
config: ref.read(mallTabConfigProvider),
currentUri: uri!,
logged: logged,
isDark: Theme.of(context).brightness == Brightness.dark,
authToken: mallAuth?.authToken,
scene: mallAuth?.scene,
sourceApp: mallAuth?.sourceApp,
);
_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),
),
);
}
+23 -16
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../application/web_navigation_policy.dart';
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
/// layered above this page so ordinary H5 links never receive mall state.
class AppWebPage extends StatefulWidget {
@@ -10,12 +13,10 @@ class AppWebPage extends StatefulWidget {
super.key,
required this.initialUri,
this.hideTitle = false,
this.allowedHosts = const <String>{},
});
final Uri initialUri;
final bool hideTitle;
final Set<String> allowedHosts;
@override
State<AppWebPage> createState() => _AppWebPageState();
@@ -60,22 +61,18 @@ class _AppWebPageState extends State<AppWebPage> {
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final uri = Uri.tryParse(request.url);
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
if (_allowsInApp(uri)) return NavigationDecision.navigate;
if (uri != null && shouldOpenWebUrlExternally(request.url)) {
launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
bool _allowsInApp(Uri uri) {
final host = uri.host.toLowerCase();
if (host == widget.initialUri.host.toLowerCase()) return true;
return widget.allowedHosts.any(
(allowed) => host == allowed || host.endsWith('.$allowed'),
);
if (uri == null || !_isAllowedWebUri(uri)) {
return NavigationDecision.prevent;
}
// Align with jinzhi-flutter-app: a normal HTTPS navigation stays in this
// WebView even when its host changes. H5 opts into an external handoff
// through `out`, which is handled above.
return NavigationDecision.navigate;
}
bool _isHttp(Uri uri) =>
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
Future<bool> _onPop() async {
if (await _controller.canGoBack()) {
@@ -118,6 +115,16 @@ class _AppWebPageState extends State<AppWebPage> {
Uri? parseSafeWebUri(String? raw) {
final uri = Uri.tryParse(raw?.trim() ?? '');
if (uri == null || !uri.hasAuthority) return null;
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
return uri != null && _isAllowedWebUri(uri) ? uri : null;
}
bool _isAllowedWebUri(Uri uri) {
if (!uri.hasAuthority || uri.host.isEmpty) return false;
if (uri.scheme.toLowerCase() == 'https') return true;
if (kReleaseMode || uri.scheme.toLowerCase() != 'http') return false;
return const {
'localhost',
'127.0.0.1',
'::1',
}.contains(uri.host.toLowerCase());
}
+113
View File
@@ -0,0 +1,113 @@
//
// Generated code. Do not modify.
// source: errors/errors.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class Status extends $pb.GeneratedMessage {
factory Status({
$core.int? code,
$core.String? reason,
$core.String? message,
$core.Map<$core.String, $core.String>? metadata,
}) {
final $result = create();
if (code != null) {
$result.code = code;
}
if (reason != null) {
$result.reason = reason;
}
if (message != null) {
$result.message = message;
}
if (metadata != null) {
$result.metadata.addAll(metadata);
}
return $result;
}
Status._() : super();
factory Status.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory Status.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Status', package: const $pb.PackageName(_omitMessageNames ? '' : 'errors'), createEmptyInstance: create)
..a<$core.int>(1, _omitFieldNames ? '' : 'code', $pb.PbFieldType.O3)
..aOS(2, _omitFieldNames ? '' : 'reason')
..aOS(3, _omitFieldNames ? '' : 'message')
..m<$core.String, $core.String>(4, _omitFieldNames ? '' : 'metadata', entryClassName: 'Status.MetadataEntry', keyFieldType: $pb.PbFieldType.OS, valueFieldType: $pb.PbFieldType.OS, packageName: const $pb.PackageName('errors'))
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
Status clone() => Status()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
Status copyWith(void Function(Status) updates) => super.copyWith((message) => updates(message as Status)) as Status;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static Status create() => Status._();
Status createEmptyInstance() => create();
static $pb.PbList<Status> createRepeated() => $pb.PbList<Status>();
@$core.pragma('dart2js:noInline')
static Status getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<Status>(create);
static Status? _defaultInstance;
@$pb.TagNumber(1)
$core.int get code => $_getIZ(0);
@$pb.TagNumber(1)
set code($core.int v) { $_setSignedInt32(0, v); }
@$pb.TagNumber(1)
$core.bool hasCode() => $_has(0);
@$pb.TagNumber(1)
void clearCode() => clearField(1);
@$pb.TagNumber(2)
$core.String get reason => $_getSZ(1);
@$pb.TagNumber(2)
set reason($core.String v) { $_setString(1, v); }
@$pb.TagNumber(2)
$core.bool hasReason() => $_has(1);
@$pb.TagNumber(2)
void clearReason() => clearField(2);
@$pb.TagNumber(3)
$core.String get message => $_getSZ(2);
@$pb.TagNumber(3)
set message($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasMessage() => $_has(2);
@$pb.TagNumber(3)
void clearMessage() => clearField(3);
@$pb.TagNumber(4)
$core.Map<$core.String, $core.String> get metadata => $_getMap(3);
}
class Errors {
static final defaultCode = $pb.Extension<$core.int>(_omitMessageNames ? '' : 'google.protobuf.EnumOptions', _omitFieldNames ? '' : 'defaultCode', 1108, $pb.PbFieldType.O3);
static final code = $pb.Extension<$core.int>(_omitMessageNames ? '' : 'google.protobuf.EnumValueOptions', _omitFieldNames ? '' : 'code', 1109, $pb.PbFieldType.O3);
static void registerAllExtensions($pb.ExtensionRegistry registry) {
registry.add(defaultCode);
registry.add(code);
}
}
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
+11
View File
@@ -0,0 +1,11 @@
//
// Generated code. Do not modify.
// source: errors/errors.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
+44
View File
@@ -0,0 +1,44 @@
//
// Generated code. Do not modify.
// source: errors/errors.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use statusDescriptor instead')
const Status$json = {
'1': 'Status',
'2': [
{'1': 'code', '3': 1, '4': 1, '5': 5, '10': 'code'},
{'1': 'reason', '3': 2, '4': 1, '5': 9, '10': 'reason'},
{'1': 'message', '3': 3, '4': 1, '5': 9, '10': 'message'},
{'1': 'metadata', '3': 4, '4': 3, '5': 11, '6': '.errors.Status.MetadataEntry', '10': 'metadata'},
],
'3': [Status_MetadataEntry$json],
};
@$core.Deprecated('Use statusDescriptor instead')
const Status_MetadataEntry$json = {
'1': 'MetadataEntry',
'2': [
{'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'},
{'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'},
],
'7': {'7': true},
};
/// Descriptor for `Status`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List statusDescriptor = $convert.base64Decode(
'CgZTdGF0dXMSEgoEY29kZRgBIAEoBVIEY29kZRIWCgZyZWFzb24YAiABKAlSBnJlYXNvbhIYCg'
'dtZXNzYWdlGAMgASgJUgdtZXNzYWdlEjgKCG1ldGFkYXRhGAQgAygLMhwuZXJyb3JzLlN0YXR1'
'cy5NZXRhZGF0YUVudHJ5UghtZXRhZGF0YRo7Cg1NZXRhZGF0YUVudHJ5EhAKA2tleRgBIAEoCV'
'IDa2V5EhQKBXZhbHVlGAIgASgJUgV2YWx1ZToCOAE=');
+14
View File
@@ -0,0 +1,14 @@
//
// Generated code. Do not modify.
// source: errors/errors.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
export 'errors.pb.dart';
@@ -0,0 +1,58 @@
//
// Generated code. Do not modify.
// source: google/protobuf/empty.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
/// A generic empty message that you can re-use to avoid defining duplicated
/// empty messages in your APIs. A typical example is to use it as the request
/// or the response type of an API method. For instance:
///
/// service Foo {
/// rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
/// }
///
/// The JSON representation for `Empty` is empty JSON object `{}`.
class Empty extends $pb.GeneratedMessage {
factory Empty() => create();
Empty._() : super();
factory Empty.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory Empty.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Empty', package: const $pb.PackageName(_omitMessageNames ? '' : 'google.protobuf'), createEmptyInstance: create)
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
Empty clone() => Empty()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
Empty copyWith(void Function(Empty) updates) => super.copyWith((message) => updates(message as Empty)) as Empty;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static Empty create() => Empty._();
Empty createEmptyInstance() => create();
static $pb.PbList<Empty> createRepeated() => $pb.PbList<Empty>();
@$core.pragma('dart2js:noInline')
static Empty getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<Empty>(create);
static Empty? _defaultInstance;
}
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
File diff suppressed because it is too large. Load diff
+868
View File
@@ -0,0 +1,868 @@
//
// Generated code. Do not modify.
// source: jinzhi/v1/ads.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:async' as $async;
import 'dart:core' as $core;
import 'package:fixnum/fixnum.dart' as $fixnum;
import 'package:protobuf/protobuf.dart' as $pb;
import 'ads.pbenum.dart';
export 'ads.pbenum.dart';
class GetAdRequest extends $pb.GeneratedMessage {
factory GetAdRequest({
$core.String? placement,
}) {
final $result = create();
if (placement != null) {
$result.placement = placement;
}
return $result;
}
GetAdRequest._() : super();
factory GetAdRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory GetAdRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetAdRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'placement')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
GetAdRequest clone() => GetAdRequest()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
GetAdRequest copyWith(void Function(GetAdRequest) updates) => super.copyWith((message) => updates(message as GetAdRequest)) as GetAdRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetAdRequest create() => GetAdRequest._();
GetAdRequest createEmptyInstance() => create();
static $pb.PbList<GetAdRequest> createRepeated() => $pb.PbList<GetAdRequest>();
@$core.pragma('dart2js:noInline')
static GetAdRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetAdRequest>(create);
static GetAdRequest? _defaultInstance;
@$pb.TagNumber(1)
$core.String get placement => $_getSZ(0);
@$pb.TagNumber(1)
set placement($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasPlacement() => $_has(0);
@$pb.TagNumber(1)
void clearPlacement() => clearField(1);
}
class GetAdReply extends $pb.GeneratedMessage {
factory GetAdReply({
$core.bool? enabled,
AdCreative? ad,
$core.int? dailyCap,
}) {
final $result = create();
if (enabled != null) {
$result.enabled = enabled;
}
if (ad != null) {
$result.ad = ad;
}
if (dailyCap != null) {
$result.dailyCap = dailyCap;
}
return $result;
}
GetAdReply._() : super();
factory GetAdReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory GetAdReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetAdReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'enabled')
..aOM<AdCreative>(2, _omitFieldNames ? '' : 'ad', subBuilder: AdCreative.create)
..a<$core.int>(3, _omitFieldNames ? '' : 'dailyCap', $pb.PbFieldType.O3)
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
GetAdReply clone() => GetAdReply()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
GetAdReply copyWith(void Function(GetAdReply) updates) => super.copyWith((message) => updates(message as GetAdReply)) as GetAdReply;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetAdReply create() => GetAdReply._();
GetAdReply createEmptyInstance() => create();
static $pb.PbList<GetAdReply> createRepeated() => $pb.PbList<GetAdReply>();
@$core.pragma('dart2js:noInline')
static GetAdReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetAdReply>(create);
static GetAdReply? _defaultInstance;
@$pb.TagNumber(1)
$core.bool get enabled => $_getBF(0);
@$pb.TagNumber(1)
set enabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearEnabled() => clearField(1);
@$pb.TagNumber(2)
AdCreative get ad => $_getN(1);
@$pb.TagNumber(2)
set ad(AdCreative v) { setField(2, v); }
@$pb.TagNumber(2)
$core.bool hasAd() => $_has(1);
@$pb.TagNumber(2)
void clearAd() => clearField(2);
@$pb.TagNumber(2)
AdCreative ensureAd() => $_ensure(1);
@$pb.TagNumber(3)
$core.int get dailyCap => $_getIZ(2);
@$pb.TagNumber(3)
set dailyCap($core.int v) { $_setSignedInt32(2, v); }
@$pb.TagNumber(3)
$core.bool hasDailyCap() => $_has(2);
@$pb.TagNumber(3)
void clearDailyCap() => clearField(3);
}
class ListAdsRequest extends $pb.GeneratedMessage {
factory ListAdsRequest({
$core.String? placement,
}) {
final $result = create();
if (placement != null) {
$result.placement = placement;
}
return $result;
}
ListAdsRequest._() : super();
factory ListAdsRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ListAdsRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ListAdsRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'placement')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
ListAdsRequest clone() => ListAdsRequest()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
ListAdsRequest copyWith(void Function(ListAdsRequest) updates) => super.copyWith((message) => updates(message as ListAdsRequest)) as ListAdsRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ListAdsRequest create() => ListAdsRequest._();
ListAdsRequest createEmptyInstance() => create();
static $pb.PbList<ListAdsRequest> createRepeated() => $pb.PbList<ListAdsRequest>();
@$core.pragma('dart2js:noInline')
static ListAdsRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ListAdsRequest>(create);
static ListAdsRequest? _defaultInstance;
@$pb.TagNumber(1)
$core.String get placement => $_getSZ(0);
@$pb.TagNumber(1)
set placement($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasPlacement() => $_has(0);
@$pb.TagNumber(1)
void clearPlacement() => clearField(1);
}
class ListAdsReply extends $pb.GeneratedMessage {
factory ListAdsReply({
$core.bool? enabled,
$core.Iterable<AdCreative>? ads,
$core.String? trackingId,
}) {
final $result = create();
if (enabled != null) {
$result.enabled = enabled;
}
if (ads != null) {
$result.ads.addAll(ads);
}
if (trackingId != null) {
$result.trackingId = trackingId;
}
return $result;
}
ListAdsReply._() : super();
factory ListAdsReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ListAdsReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ListAdsReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'enabled')
..pc<AdCreative>(2, _omitFieldNames ? '' : 'ads', $pb.PbFieldType.PM, subBuilder: AdCreative.create)
..aOS(3, _omitFieldNames ? '' : 'trackingId')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
ListAdsReply clone() => ListAdsReply()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
ListAdsReply copyWith(void Function(ListAdsReply) updates) => super.copyWith((message) => updates(message as ListAdsReply)) as ListAdsReply;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ListAdsReply create() => ListAdsReply._();
ListAdsReply createEmptyInstance() => create();
static $pb.PbList<ListAdsReply> createRepeated() => $pb.PbList<ListAdsReply>();
@$core.pragma('dart2js:noInline')
static ListAdsReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ListAdsReply>(create);
static ListAdsReply? _defaultInstance;
@$pb.TagNumber(1)
$core.bool get enabled => $_getBF(0);
@$pb.TagNumber(1)
set enabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearEnabled() => clearField(1);
@$pb.TagNumber(2)
$core.List<AdCreative> get ads => $_getList(1);
@$pb.TagNumber(3)
$core.String get trackingId => $_getSZ(2);
@$pb.TagNumber(3)
set trackingId($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasTrackingId() => $_has(2);
@$pb.TagNumber(3)
void clearTrackingId() => clearField(3);
}
class AdCreative extends $pb.GeneratedMessage {
factory AdCreative({
$core.String? adId,
$core.String? placement,
$core.String? targetApp,
$core.String? title,
$core.String? subtitle,
$core.String? imageUrl,
MediaType? mediaType,
$core.String? ctaText,
$core.String? h5Url,
$core.String? trackingId,
$core.int? countdownSeconds,
$core.int? priority,
$fixnum.Int64? endAtMs,
$core.String? trackedUrl,
AdOpenMode? openMode,
$core.bool? entryPromoEnabled,
$core.String? entryBadgeText,
$core.String? entryTipTitle,
$core.String? entryTipBody,
MallPromotion? mallPromotion,
}) {
final $result = create();
if (adId != null) {
$result.adId = adId;
}
if (placement != null) {
$result.placement = placement;
}
if (targetApp != null) {
$result.targetApp = targetApp;
}
if (title != null) {
$result.title = title;
}
if (subtitle != null) {
$result.subtitle = subtitle;
}
if (imageUrl != null) {
$result.imageUrl = imageUrl;
}
if (mediaType != null) {
$result.mediaType = mediaType;
}
if (ctaText != null) {
$result.ctaText = ctaText;
}
if (h5Url != null) {
$result.h5Url = h5Url;
}
if (trackingId != null) {
$result.trackingId = trackingId;
}
if (countdownSeconds != null) {
$result.countdownSeconds = countdownSeconds;
}
if (priority != null) {
$result.priority = priority;
}
if (endAtMs != null) {
$result.endAtMs = endAtMs;
}
if (trackedUrl != null) {
$result.trackedUrl = trackedUrl;
}
if (openMode != null) {
$result.openMode = openMode;
}
if (entryPromoEnabled != null) {
$result.entryPromoEnabled = entryPromoEnabled;
}
if (entryBadgeText != null) {
$result.entryBadgeText = entryBadgeText;
}
if (entryTipTitle != null) {
$result.entryTipTitle = entryTipTitle;
}
if (entryTipBody != null) {
$result.entryTipBody = entryTipBody;
}
if (mallPromotion != null) {
$result.mallPromotion = mallPromotion;
}
return $result;
}
AdCreative._() : super();
factory AdCreative.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory AdCreative.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AdCreative', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'adId')
..aOS(2, _omitFieldNames ? '' : 'placement')
..aOS(3, _omitFieldNames ? '' : 'targetApp')
..aOS(4, _omitFieldNames ? '' : 'title')
..aOS(5, _omitFieldNames ? '' : 'subtitle')
..aOS(6, _omitFieldNames ? '' : 'imageUrl')
..e<MediaType>(7, _omitFieldNames ? '' : 'mediaType', $pb.PbFieldType.OE, defaultOrMaker: MediaType.MEDIA_TYPE_UNSPECIFIED, valueOf: MediaType.valueOf, enumValues: MediaType.values)
..aOS(8, _omitFieldNames ? '' : 'ctaText')
..aOS(9, _omitFieldNames ? '' : 'h5Url')
..aOS(10, _omitFieldNames ? '' : 'trackingId')
..a<$core.int>(11, _omitFieldNames ? '' : 'countdownSeconds', $pb.PbFieldType.O3)
..a<$core.int>(12, _omitFieldNames ? '' : 'priority', $pb.PbFieldType.O3)
..aInt64(13, _omitFieldNames ? '' : 'endAtMs')
..aOS(14, _omitFieldNames ? '' : 'trackedUrl')
..e<AdOpenMode>(15, _omitFieldNames ? '' : 'openMode', $pb.PbFieldType.OE, defaultOrMaker: AdOpenMode.AD_OPEN_MODE_UNSPECIFIED, valueOf: AdOpenMode.valueOf, enumValues: AdOpenMode.values)
..aOB(16, _omitFieldNames ? '' : 'entryPromoEnabled')
..aOS(17, _omitFieldNames ? '' : 'entryBadgeText')
..aOS(18, _omitFieldNames ? '' : 'entryTipTitle')
..aOS(19, _omitFieldNames ? '' : 'entryTipBody')
..aOM<MallPromotion>(20, _omitFieldNames ? '' : 'mallPromotion', subBuilder: MallPromotion.create)
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
AdCreative clone() => AdCreative()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
AdCreative copyWith(void Function(AdCreative) updates) => super.copyWith((message) => updates(message as AdCreative)) as AdCreative;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static AdCreative create() => AdCreative._();
AdCreative createEmptyInstance() => create();
static $pb.PbList<AdCreative> createRepeated() => $pb.PbList<AdCreative>();
@$core.pragma('dart2js:noInline')
static AdCreative getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AdCreative>(create);
static AdCreative? _defaultInstance;
@$pb.TagNumber(1)
$core.String get adId => $_getSZ(0);
@$pb.TagNumber(1)
set adId($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasAdId() => $_has(0);
@$pb.TagNumber(1)
void clearAdId() => clearField(1);
@$pb.TagNumber(2)
$core.String get placement => $_getSZ(1);
@$pb.TagNumber(2)
set placement($core.String v) { $_setString(1, v); }
@$pb.TagNumber(2)
$core.bool hasPlacement() => $_has(1);
@$pb.TagNumber(2)
void clearPlacement() => clearField(2);
@$pb.TagNumber(3)
$core.String get targetApp => $_getSZ(2);
@$pb.TagNumber(3)
set targetApp($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasTargetApp() => $_has(2);
@$pb.TagNumber(3)
void clearTargetApp() => clearField(3);
@$pb.TagNumber(4)
$core.String get title => $_getSZ(3);
@$pb.TagNumber(4)
set title($core.String v) { $_setString(3, v); }
@$pb.TagNumber(4)
$core.bool hasTitle() => $_has(3);
@$pb.TagNumber(4)
void clearTitle() => clearField(4);
@$pb.TagNumber(5)
$core.String get subtitle => $_getSZ(4);
@$pb.TagNumber(5)
set subtitle($core.String v) { $_setString(4, v); }
@$pb.TagNumber(5)
$core.bool hasSubtitle() => $_has(4);
@$pb.TagNumber(5)
void clearSubtitle() => clearField(5);
@$pb.TagNumber(6)
$core.String get imageUrl => $_getSZ(5);
@$pb.TagNumber(6)
set imageUrl($core.String v) { $_setString(5, v); }
@$pb.TagNumber(6)
$core.bool hasImageUrl() => $_has(5);
@$pb.TagNumber(6)
void clearImageUrl() => clearField(6);
@$pb.TagNumber(7)
MediaType get mediaType => $_getN(6);
@$pb.TagNumber(7)
set mediaType(MediaType v) { setField(7, v); }
@$pb.TagNumber(7)
$core.bool hasMediaType() => $_has(6);
@$pb.TagNumber(7)
void clearMediaType() => clearField(7);
@$pb.TagNumber(8)
$core.String get ctaText => $_getSZ(7);
@$pb.TagNumber(8)
set ctaText($core.String v) { $_setString(7, v); }
@$pb.TagNumber(8)
$core.bool hasCtaText() => $_has(7);
@$pb.TagNumber(8)
void clearCtaText() => clearField(8);
@$pb.TagNumber(9)
$core.String get h5Url => $_getSZ(8);
@$pb.TagNumber(9)
set h5Url($core.String v) { $_setString(8, v); }
@$pb.TagNumber(9)
$core.bool hasH5Url() => $_has(8);
@$pb.TagNumber(9)
void clearH5Url() => clearField(9);
@$pb.TagNumber(10)
$core.String get trackingId => $_getSZ(9);
@$pb.TagNumber(10)
set trackingId($core.String v) { $_setString(9, v); }
@$pb.TagNumber(10)
$core.bool hasTrackingId() => $_has(9);
@$pb.TagNumber(10)
void clearTrackingId() => clearField(10);
@$pb.TagNumber(11)
$core.int get countdownSeconds => $_getIZ(10);
@$pb.TagNumber(11)
set countdownSeconds($core.int v) { $_setSignedInt32(10, v); }
@$pb.TagNumber(11)
$core.bool hasCountdownSeconds() => $_has(10);
@$pb.TagNumber(11)
void clearCountdownSeconds() => clearField(11);
@$pb.TagNumber(12)
$core.int get priority => $_getIZ(11);
@$pb.TagNumber(12)
set priority($core.int v) { $_setSignedInt32(11, v); }
@$pb.TagNumber(12)
$core.bool hasPriority() => $_has(11);
@$pb.TagNumber(12)
void clearPriority() => clearField(12);
@$pb.TagNumber(13)
$fixnum.Int64 get endAtMs => $_getI64(12);
@$pb.TagNumber(13)
set endAtMs($fixnum.Int64 v) { $_setInt64(12, v); }
@$pb.TagNumber(13)
$core.bool hasEndAtMs() => $_has(12);
@$pb.TagNumber(13)
void clearEndAtMs() => clearField(13);
@$pb.TagNumber(14)
$core.String get trackedUrl => $_getSZ(13);
@$pb.TagNumber(14)
set trackedUrl($core.String v) { $_setString(13, v); }
@$pb.TagNumber(14)
$core.bool hasTrackedUrl() => $_has(13);
@$pb.TagNumber(14)
void clearTrackedUrl() => clearField(14);
@$pb.TagNumber(15)
AdOpenMode get openMode => $_getN(14);
@$pb.TagNumber(15)
set openMode(AdOpenMode v) { setField(15, v); }
@$pb.TagNumber(15)
$core.bool hasOpenMode() => $_has(14);
@$pb.TagNumber(15)
void clearOpenMode() => clearField(15);
@$pb.TagNumber(16)
$core.bool get entryPromoEnabled => $_getBF(15);
@$pb.TagNumber(16)
set entryPromoEnabled($core.bool v) { $_setBool(15, v); }
@$pb.TagNumber(16)
$core.bool hasEntryPromoEnabled() => $_has(15);
@$pb.TagNumber(16)
void clearEntryPromoEnabled() => clearField(16);
@$pb.TagNumber(17)
$core.String get entryBadgeText => $_getSZ(16);
@$pb.TagNumber(17)
set entryBadgeText($core.String v) { $_setString(16, v); }
@$pb.TagNumber(17)
$core.bool hasEntryBadgeText() => $_has(16);
@$pb.TagNumber(17)
void clearEntryBadgeText() => clearField(17);
@$pb.TagNumber(18)
$core.String get entryTipTitle => $_getSZ(17);
@$pb.TagNumber(18)
set entryTipTitle($core.String v) { $_setString(17, v); }
@$pb.TagNumber(18)
$core.bool hasEntryTipTitle() => $_has(17);
@$pb.TagNumber(18)
void clearEntryTipTitle() => clearField(18);
@$pb.TagNumber(19)
$core.String get entryTipBody => $_getSZ(18);
@$pb.TagNumber(19)
set entryTipBody($core.String v) { $_setString(18, v); }
@$pb.TagNumber(19)
$core.bool hasEntryTipBody() => $_has(18);
@$pb.TagNumber(19)
void clearEntryTipBody() => clearField(19);
@$pb.TagNumber(20)
MallPromotion get mallPromotion => $_getN(19);
@$pb.TagNumber(20)
set mallPromotion(MallPromotion v) { setField(20, v); }
@$pb.TagNumber(20)
$core.bool hasMallPromotion() => $_has(19);
@$pb.TagNumber(20)
void clearMallPromotion() => clearField(20);
@$pb.TagNumber(20)
MallPromotion ensureMallPromotion() => $_ensure(19);
}
/// MallPromotion is an optional, version-gated set of low-impact entry points
/// layered on top of the existing mall_tab creative. Older clients safely
/// ignore this message.
class MallPromotion extends $pb.GeneratedMessage {
factory MallPromotion({
$core.bool? quoteCtaEnabled,
}) {
final $result = create();
if (quoteCtaEnabled != null) {
$result.quoteCtaEnabled = quoteCtaEnabled;
}
return $result;
}
MallPromotion._() : super();
factory MallPromotion.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory MallPromotion.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'MallPromotion', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'quoteCtaEnabled')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
MallPromotion clone() => MallPromotion()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
MallPromotion copyWith(void Function(MallPromotion) updates) => super.copyWith((message) => updates(message as MallPromotion)) as MallPromotion;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static MallPromotion create() => MallPromotion._();
MallPromotion createEmptyInstance() => create();
static $pb.PbList<MallPromotion> createRepeated() => $pb.PbList<MallPromotion>();
@$core.pragma('dart2js:noInline')
static MallPromotion getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<MallPromotion>(create);
static MallPromotion? _defaultInstance;
@$pb.TagNumber(1)
$core.bool get quoteCtaEnabled => $_getBF(0);
@$pb.TagNumber(1)
set quoteCtaEnabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasQuoteCtaEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearQuoteCtaEnabled() => clearField(1);
}
class ReportAdEventRequest extends $pb.GeneratedMessage {
factory ReportAdEventRequest({
$core.String? eventName,
$core.String? placement,
$core.String? adId,
$core.String? trackingId,
$core.String? clickId,
$fixnum.Int64? clientTsMs,
$core.Map<$core.String, $core.String>? attrs,
$core.String? articleId,
}) {
final $result = create();
if (eventName != null) {
$result.eventName = eventName;
}
if (placement != null) {
$result.placement = placement;
}
if (adId != null) {
$result.adId = adId;
}
if (trackingId != null) {
$result.trackingId = trackingId;
}
if (clickId != null) {
$result.clickId = clickId;
}
if (clientTsMs != null) {
$result.clientTsMs = clientTsMs;
}
if (attrs != null) {
$result.attrs.addAll(attrs);
}
if (articleId != null) {
$result.articleId = articleId;
}
return $result;
}
ReportAdEventRequest._() : super();
factory ReportAdEventRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ReportAdEventRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ReportAdEventRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'eventName')
..aOS(2, _omitFieldNames ? '' : 'placement')
..aOS(3, _omitFieldNames ? '' : 'adId')
..aOS(4, _omitFieldNames ? '' : 'trackingId')
..aOS(5, _omitFieldNames ? '' : 'clickId')
..aInt64(6, _omitFieldNames ? '' : 'clientTsMs')
..m<$core.String, $core.String>(7, _omitFieldNames ? '' : 'attrs', entryClassName: 'ReportAdEventRequest.AttrsEntry', keyFieldType: $pb.PbFieldType.OS, valueFieldType: $pb.PbFieldType.OS, packageName: const $pb.PackageName('jinzhi.v1'))
..aOS(8, _omitFieldNames ? '' : 'articleId')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
ReportAdEventRequest clone() => ReportAdEventRequest()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
ReportAdEventRequest copyWith(void Function(ReportAdEventRequest) updates) => super.copyWith((message) => updates(message as ReportAdEventRequest)) as ReportAdEventRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ReportAdEventRequest create() => ReportAdEventRequest._();
ReportAdEventRequest createEmptyInstance() => create();
static $pb.PbList<ReportAdEventRequest> createRepeated() => $pb.PbList<ReportAdEventRequest>();
@$core.pragma('dart2js:noInline')
static ReportAdEventRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ReportAdEventRequest>(create);
static ReportAdEventRequest? _defaultInstance;
@$pb.TagNumber(1)
$core.String get eventName => $_getSZ(0);
@$pb.TagNumber(1)
set eventName($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasEventName() => $_has(0);
@$pb.TagNumber(1)
void clearEventName() => clearField(1);
@$pb.TagNumber(2)
$core.String get placement => $_getSZ(1);
@$pb.TagNumber(2)
set placement($core.String v) { $_setString(1, v); }
@$pb.TagNumber(2)
$core.bool hasPlacement() => $_has(1);
@$pb.TagNumber(2)
void clearPlacement() => clearField(2);
@$pb.TagNumber(3)
$core.String get adId => $_getSZ(2);
@$pb.TagNumber(3)
set adId($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasAdId() => $_has(2);
@$pb.TagNumber(3)
void clearAdId() => clearField(3);
@$pb.TagNumber(4)
$core.String get trackingId => $_getSZ(3);
@$pb.TagNumber(4)
set trackingId($core.String v) { $_setString(3, v); }
@$pb.TagNumber(4)
$core.bool hasTrackingId() => $_has(3);
@$pb.TagNumber(4)
void clearTrackingId() => clearField(4);
@$pb.TagNumber(5)
$core.String get clickId => $_getSZ(4);
@$pb.TagNumber(5)
set clickId($core.String v) { $_setString(4, v); }
@$pb.TagNumber(5)
$core.bool hasClickId() => $_has(4);
@$pb.TagNumber(5)
void clearClickId() => clearField(5);
@$pb.TagNumber(6)
$fixnum.Int64 get clientTsMs => $_getI64(5);
@$pb.TagNumber(6)
set clientTsMs($fixnum.Int64 v) { $_setInt64(5, v); }
@$pb.TagNumber(6)
$core.bool hasClientTsMs() => $_has(5);
@$pb.TagNumber(6)
void clearClientTsMs() => clearField(6);
@$pb.TagNumber(7)
$core.Map<$core.String, $core.String> get attrs => $_getMap(6);
@$pb.TagNumber(8)
$core.String get articleId => $_getSZ(7);
@$pb.TagNumber(8)
set articleId($core.String v) { $_setString(7, v); }
@$pb.TagNumber(8)
$core.bool hasArticleId() => $_has(7);
@$pb.TagNumber(8)
void clearArticleId() => clearField(8);
}
class ReportAdEventReply extends $pb.GeneratedMessage {
factory ReportAdEventReply({
$core.String? eventId,
$core.bool? accepted,
}) {
final $result = create();
if (eventId != null) {
$result.eventId = eventId;
}
if (accepted != null) {
$result.accepted = accepted;
}
return $result;
}
ReportAdEventReply._() : super();
factory ReportAdEventReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ReportAdEventReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ReportAdEventReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'eventId')
..aOB(2, _omitFieldNames ? '' : 'accepted')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
ReportAdEventReply clone() => ReportAdEventReply()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
ReportAdEventReply copyWith(void Function(ReportAdEventReply) updates) => super.copyWith((message) => updates(message as ReportAdEventReply)) as ReportAdEventReply;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ReportAdEventReply create() => ReportAdEventReply._();
ReportAdEventReply createEmptyInstance() => create();
static $pb.PbList<ReportAdEventReply> createRepeated() => $pb.PbList<ReportAdEventReply>();
@$core.pragma('dart2js:noInline')
static ReportAdEventReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ReportAdEventReply>(create);
static ReportAdEventReply? _defaultInstance;
@$pb.TagNumber(1)
$core.String get eventId => $_getSZ(0);
@$pb.TagNumber(1)
set eventId($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasEventId() => $_has(0);
@$pb.TagNumber(1)
void clearEventId() => clearField(1);
@$pb.TagNumber(2)
$core.bool get accepted => $_getBF(1);
@$pb.TagNumber(2)
set accepted($core.bool v) { $_setBool(1, v); }
@$pb.TagNumber(2)
$core.bool hasAccepted() => $_has(1);
@$pb.TagNumber(2)
void clearAccepted() => clearField(2);
}
class AdsServiceApi {
$pb.RpcClient _client;
AdsServiceApi(this._client);
$async.Future<GetAdReply> getAd($pb.ClientContext? ctx, GetAdRequest request) =>
_client.invoke<GetAdReply>(ctx, 'AdsService', 'GetAd', request, GetAdReply())
;
$async.Future<ListAdsReply> listAds($pb.ClientContext? ctx, ListAdsRequest request) =>
_client.invoke<ListAdsReply>(ctx, 'AdsService', 'ListAds', request, ListAdsReply())
;
$async.Future<ReportAdEventReply> reportAdEvent($pb.ClientContext? ctx, ReportAdEventRequest request) =>
_client.invoke<ReportAdEventReply>(ctx, 'AdsService', 'ReportAdEvent', request, ReportAdEventReply())
;
}
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
+55
View File
@@ -0,0 +1,55 @@
//
// Generated code. Do not modify.
// source: jinzhi/v1/ads.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class AdOpenMode extends $pb.ProtobufEnum {
static const AdOpenMode AD_OPEN_MODE_UNSPECIFIED = AdOpenMode._(0, _omitEnumNames ? '' : 'AD_OPEN_MODE_UNSPECIFIED');
static const AdOpenMode AD_OPEN_MODE_EXTERNAL = AdOpenMode._(1, _omitEnumNames ? '' : 'AD_OPEN_MODE_EXTERNAL');
static const AdOpenMode AD_OPEN_MODE_IN_APP = AdOpenMode._(2, _omitEnumNames ? '' : 'AD_OPEN_MODE_IN_APP');
static const AdOpenMode AD_OPEN_MODE_NATIVE_APP = AdOpenMode._(3, _omitEnumNames ? '' : 'AD_OPEN_MODE_NATIVE_APP');
static const $core.List<AdOpenMode> values = <AdOpenMode> [
AD_OPEN_MODE_UNSPECIFIED,
AD_OPEN_MODE_EXTERNAL,
AD_OPEN_MODE_IN_APP,
AD_OPEN_MODE_NATIVE_APP,
];
static final $core.Map<$core.int, AdOpenMode> _byValue = $pb.ProtobufEnum.initByValue(values);
static AdOpenMode? valueOf($core.int value) => _byValue[value];
const AdOpenMode._($core.int v, $core.String n) : super(v, n);
}
class MediaType extends $pb.ProtobufEnum {
static const MediaType MEDIA_TYPE_UNSPECIFIED = MediaType._(0, _omitEnumNames ? '' : 'MEDIA_TYPE_UNSPECIFIED');
static const MediaType MEDIA_TYPE_IMAGE = MediaType._(1, _omitEnumNames ? '' : 'MEDIA_TYPE_IMAGE');
static const MediaType MEDIA_TYPE_GIF = MediaType._(2, _omitEnumNames ? '' : 'MEDIA_TYPE_GIF');
static const MediaType MEDIA_TYPE_VIDEO = MediaType._(3, _omitEnumNames ? '' : 'MEDIA_TYPE_VIDEO');
static const $core.List<MediaType> values = <MediaType> [
MEDIA_TYPE_UNSPECIFIED,
MEDIA_TYPE_IMAGE,
MEDIA_TYPE_GIF,
MEDIA_TYPE_VIDEO,
];
static final $core.Map<$core.int, MediaType> _byValue = $pb.ProtobufEnum.initByValue(values);
static MediaType? valueOf($core.int value) => _byValue[value];
const MediaType._($core.int v, $core.String n) : super(v, n);
}
const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names');
+336
View File
@@ -0,0 +1,336 @@
//
// Generated code. Do not modify.
// source: jinzhi/v1/push.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
import 'push.pbenum.dart';
export 'push.pbenum.dart';
class RegisterPushDeviceRequest extends $pb.GeneratedMessage {
factory RegisterPushDeviceRequest({
$core.String? umengDeviceToken,
Platform? platform,
$core.String? appVersion,
$core.bool? permissionGranted,
}) {
final $result = create();
if (umengDeviceToken != null) {
$result.umengDeviceToken = umengDeviceToken;
}
if (platform != null) {
$result.platform = platform;
}
if (appVersion != null) {
$result.appVersion = appVersion;
}
if (permissionGranted != null) {
$result.permissionGranted = permissionGranted;
}
return $result;
}
RegisterPushDeviceRequest._() : super();
factory RegisterPushDeviceRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory RegisterPushDeviceRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RegisterPushDeviceRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'umengDeviceToken')
..e<Platform>(2, _omitFieldNames ? '' : 'platform', $pb.PbFieldType.OE, defaultOrMaker: Platform.PLATFORM_UNSPECIFIED, valueOf: Platform.valueOf, enumValues: Platform.values)
..aOS(3, _omitFieldNames ? '' : 'appVersion')
..aOB(4, _omitFieldNames ? '' : 'permissionGranted')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
RegisterPushDeviceRequest clone() => RegisterPushDeviceRequest()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
RegisterPushDeviceRequest copyWith(void Function(RegisterPushDeviceRequest) updates) => super.copyWith((message) => updates(message as RegisterPushDeviceRequest)) as RegisterPushDeviceRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static RegisterPushDeviceRequest create() => RegisterPushDeviceRequest._();
RegisterPushDeviceRequest createEmptyInstance() => create();
static $pb.PbList<RegisterPushDeviceRequest> createRepeated() => $pb.PbList<RegisterPushDeviceRequest>();
@$core.pragma('dart2js:noInline')
static RegisterPushDeviceRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RegisterPushDeviceRequest>(create);
static RegisterPushDeviceRequest? _defaultInstance;
@$pb.TagNumber(1)
$core.String get umengDeviceToken => $_getSZ(0);
@$pb.TagNumber(1)
set umengDeviceToken($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasUmengDeviceToken() => $_has(0);
@$pb.TagNumber(1)
void clearUmengDeviceToken() => clearField(1);
@$pb.TagNumber(2)
Platform get platform => $_getN(1);
@$pb.TagNumber(2)
set platform(Platform v) { setField(2, v); }
@$pb.TagNumber(2)
$core.bool hasPlatform() => $_has(1);
@$pb.TagNumber(2)
void clearPlatform() => clearField(2);
@$pb.TagNumber(3)
$core.String get appVersion => $_getSZ(2);
@$pb.TagNumber(3)
set appVersion($core.String v) { $_setString(2, v); }
@$pb.TagNumber(3)
$core.bool hasAppVersion() => $_has(2);
@$pb.TagNumber(3)
void clearAppVersion() => clearField(3);
@$pb.TagNumber(4)
$core.bool get permissionGranted => $_getBF(3);
@$pb.TagNumber(4)
set permissionGranted($core.bool v) { $_setBool(3, v); }
@$pb.TagNumber(4)
$core.bool hasPermissionGranted() => $_has(3);
@$pb.TagNumber(4)
void clearPermissionGranted() => clearField(4);
}
class RegisterPushDeviceReply extends $pb.GeneratedMessage {
factory RegisterPushDeviceReply({
$core.String? deviceRecordId,
$core.bool? firstRegistration,
}) {
final $result = create();
if (deviceRecordId != null) {
$result.deviceRecordId = deviceRecordId;
}
if (firstRegistration != null) {
$result.firstRegistration = firstRegistration;
}
return $result;
}
RegisterPushDeviceReply._() : super();
factory RegisterPushDeviceReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory RegisterPushDeviceReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RegisterPushDeviceReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'deviceRecordId')
..aOB(2, _omitFieldNames ? '' : 'firstRegistration')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
RegisterPushDeviceReply clone() => RegisterPushDeviceReply()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
RegisterPushDeviceReply copyWith(void Function(RegisterPushDeviceReply) updates) => super.copyWith((message) => updates(message as RegisterPushDeviceReply)) as RegisterPushDeviceReply;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static RegisterPushDeviceReply create() => RegisterPushDeviceReply._();
RegisterPushDeviceReply createEmptyInstance() => create();
static $pb.PbList<RegisterPushDeviceReply> createRepeated() => $pb.PbList<RegisterPushDeviceReply>();
@$core.pragma('dart2js:noInline')
static RegisterPushDeviceReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RegisterPushDeviceReply>(create);
static RegisterPushDeviceReply? _defaultInstance;
@$pb.TagNumber(1)
$core.String get deviceRecordId => $_getSZ(0);
@$pb.TagNumber(1)
set deviceRecordId($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasDeviceRecordId() => $_has(0);
@$pb.TagNumber(1)
void clearDeviceRecordId() => clearField(1);
@$pb.TagNumber(2)
$core.bool get firstRegistration => $_getBF(1);
@$pb.TagNumber(2)
set firstRegistration($core.bool v) { $_setBool(1, v); }
@$pb.TagNumber(2)
$core.bool hasFirstRegistration() => $_has(1);
@$pb.TagNumber(2)
void clearFirstRegistration() => clearField(2);
}
/// 成对的 *_set 字段保留 proto3 bool 的局部更新语义。
class UpdatePushPreferenceRequest extends $pb.GeneratedMessage {
factory UpdatePushPreferenceRequest({
$core.bool? watchlistUpdatesEnabled,
$core.bool? watchlistUpdatesEnabledSet,
$core.bool? recallPushEnabled,
$core.bool? recallPushEnabledSet,
}) {
final $result = create();
if (watchlistUpdatesEnabled != null) {
$result.watchlistUpdatesEnabled = watchlistUpdatesEnabled;
}
if (watchlistUpdatesEnabledSet != null) {
$result.watchlistUpdatesEnabledSet = watchlistUpdatesEnabledSet;
}
if (recallPushEnabled != null) {
$result.recallPushEnabled = recallPushEnabled;
}
if (recallPushEnabledSet != null) {
$result.recallPushEnabledSet = recallPushEnabledSet;
}
return $result;
}
UpdatePushPreferenceRequest._() : super();
factory UpdatePushPreferenceRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory UpdatePushPreferenceRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'UpdatePushPreferenceRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'watchlistUpdatesEnabled')
..aOB(2, _omitFieldNames ? '' : 'watchlistUpdatesEnabledSet')
..aOB(3, _omitFieldNames ? '' : 'recallPushEnabled')
..aOB(4, _omitFieldNames ? '' : 'recallPushEnabledSet')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
UpdatePushPreferenceRequest clone() => UpdatePushPreferenceRequest()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
UpdatePushPreferenceRequest copyWith(void Function(UpdatePushPreferenceRequest) updates) => super.copyWith((message) => updates(message as UpdatePushPreferenceRequest)) as UpdatePushPreferenceRequest;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static UpdatePushPreferenceRequest create() => UpdatePushPreferenceRequest._();
UpdatePushPreferenceRequest createEmptyInstance() => create();
static $pb.PbList<UpdatePushPreferenceRequest> createRepeated() => $pb.PbList<UpdatePushPreferenceRequest>();
@$core.pragma('dart2js:noInline')
static UpdatePushPreferenceRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<UpdatePushPreferenceRequest>(create);
static UpdatePushPreferenceRequest? _defaultInstance;
/// 沿用研值/研听契约名;在金值中表示“资产与关注内容更新”。
@$pb.TagNumber(1)
$core.bool get watchlistUpdatesEnabled => $_getBF(0);
@$pb.TagNumber(1)
set watchlistUpdatesEnabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasWatchlistUpdatesEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearWatchlistUpdatesEnabled() => clearField(1);
@$pb.TagNumber(2)
$core.bool get watchlistUpdatesEnabledSet => $_getBF(1);
@$pb.TagNumber(2)
set watchlistUpdatesEnabledSet($core.bool v) { $_setBool(1, v); }
@$pb.TagNumber(2)
$core.bool hasWatchlistUpdatesEnabledSet() => $_has(1);
@$pb.TagNumber(2)
void clearWatchlistUpdatesEnabledSet() => clearField(2);
@$pb.TagNumber(3)
$core.bool get recallPushEnabled => $_getBF(2);
@$pb.TagNumber(3)
set recallPushEnabled($core.bool v) { $_setBool(2, v); }
@$pb.TagNumber(3)
$core.bool hasRecallPushEnabled() => $_has(2);
@$pb.TagNumber(3)
void clearRecallPushEnabled() => clearField(3);
@$pb.TagNumber(4)
$core.bool get recallPushEnabledSet => $_getBF(3);
@$pb.TagNumber(4)
set recallPushEnabledSet($core.bool v) { $_setBool(3, v); }
@$pb.TagNumber(4)
$core.bool hasRecallPushEnabledSet() => $_has(3);
@$pb.TagNumber(4)
void clearRecallPushEnabledSet() => clearField(4);
}
class PushPreferenceReply extends $pb.GeneratedMessage {
factory PushPreferenceReply({
$core.bool? watchlistUpdatesEnabled,
$core.bool? recallPushEnabled,
}) {
final $result = create();
if (watchlistUpdatesEnabled != null) {
$result.watchlistUpdatesEnabled = watchlistUpdatesEnabled;
}
if (recallPushEnabled != null) {
$result.recallPushEnabled = recallPushEnabled;
}
return $result;
}
PushPreferenceReply._() : super();
factory PushPreferenceReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory PushPreferenceReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PushPreferenceReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
..aOB(1, _omitFieldNames ? '' : 'watchlistUpdatesEnabled')
..aOB(2, _omitFieldNames ? '' : 'recallPushEnabled')
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
PushPreferenceReply clone() => PushPreferenceReply()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
PushPreferenceReply copyWith(void Function(PushPreferenceReply) updates) => super.copyWith((message) => updates(message as PushPreferenceReply)) as PushPreferenceReply;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static PushPreferenceReply create() => PushPreferenceReply._();
PushPreferenceReply createEmptyInstance() => create();
static $pb.PbList<PushPreferenceReply> createRepeated() => $pb.PbList<PushPreferenceReply>();
@$core.pragma('dart2js:noInline')
static PushPreferenceReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PushPreferenceReply>(create);
static PushPreferenceReply? _defaultInstance;
@$pb.TagNumber(1)
$core.bool get watchlistUpdatesEnabled => $_getBF(0);
@$pb.TagNumber(1)
set watchlistUpdatesEnabled($core.bool v) { $_setBool(0, v); }
@$pb.TagNumber(1)
$core.bool hasWatchlistUpdatesEnabled() => $_has(0);
@$pb.TagNumber(1)
void clearWatchlistUpdatesEnabled() => clearField(1);
@$pb.TagNumber(2)
$core.bool get recallPushEnabled => $_getBF(1);
@$pb.TagNumber(2)
set recallPushEnabled($core.bool v) { $_setBool(1, v); }
@$pb.TagNumber(2)
$core.bool hasRecallPushEnabled() => $_has(1);
@$pb.TagNumber(2)
void clearRecallPushEnabled() => clearField(2);
}
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
+35
View File
@@ -0,0 +1,35 @@
//
// Generated code. Do not modify.
// source: jinzhi/v1/push.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
/// Platform 标识 U-Push token 所属原生平台。
class Platform extends $pb.ProtobufEnum {
static const Platform PLATFORM_UNSPECIFIED = Platform._(0, _omitEnumNames ? '' : 'PLATFORM_UNSPECIFIED');
static const Platform PLATFORM_IOS = Platform._(1, _omitEnumNames ? '' : 'PLATFORM_IOS');
static const Platform PLATFORM_ANDROID = Platform._(2, _omitEnumNames ? '' : 'PLATFORM_ANDROID');
static const $core.List<Platform> values = <Platform> [
PLATFORM_UNSPECIFIED,
PLATFORM_IOS,
PLATFORM_ANDROID,
];
static final $core.Map<$core.int, Platform> _byValue = $pb.ProtobufEnum.initByValue(values);
static Platform? valueOf($core.int value) => _byValue[value];
const Platform._($core.int v, $core.String n) : super(v, n);
}
const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names');
+18 -2
View File
@@ -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:
@@ -583,7 +591,7 @@ packages:
source: hosted
version: "0.1.3"
flutter_svg:
dependency: transitive
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
@@ -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
+6 -1
View File
@@ -2,7 +2,7 @@ name: jinzhi_flutter_app
description: "金值 - 贵金属资产管家"
publish_to: 'none'
version: 1.0.0+1
version: 1.1.0+1
environment:
sdk: ^3.9.2
@@ -35,12 +35,16 @@ dependencies:
shadcn_ui: ^0.53.6
google_fonts: ^6.2.1
flutter_remix: ^0.0.3
flutter_svg: ^2.2.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
@@ -109,3 +113,4 @@ flutter:
- shorebird.yaml
- assets/env/.env
- assets/launch/
- assets/navigation/
@@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/network/api_error_message.dart';
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
void main() {
test('decodes a protobuf error body before it reaches the UI', () async {
final dio = Dio()
..httpClientAdapter = _ErrorAdapter(
errors.Status(
code: 401,
reason: 'INVALID_CODE',
message: '验证码错误',
).writeToBuffer(),
)
..interceptors.add(KratosErrorInterceptor());
await expectLater(
dio.delete<void>(
'/api/v1/me',
options: Options(responseType: ResponseType.bytes),
),
throwsA(
isA<DioException>().having(
(error) => kratosDisplayMessage(error, fallback: '注销失败,请重试'),
'display message',
'验证码错误',
),
),
);
});
}
class _ErrorAdapter implements HttpClientAdapter {
_ErrorAdapter(this._body);
final List<int> _body;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async => ResponseBody.fromBytes(
_body,
401,
headers: {
'content-type': ['application/x-protobuf'],
},
);
@override
void close({bool force = false}) {}
}
+13
View File
@@ -0,0 +1,13 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
void main() {
test('protobuf strings are decoded as UTF-8', () {
final value = utf8.encode('商城');
final message = ProtoMessage(<int>[10, value.length, ...value]);
expect(message.string(1), '商城');
});
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/auth/data/auth_api.dart';
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
void main() {
test('keeps the verified one-key login contract', () async {
String? requestToken;
final dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
requestToken = pb.VerifyOneKeyLoginRequest.fromBuffer(
options.data as List<int>,
).loginToken;
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: pb.VerifySmsCodeReply(
accessToken: 'access-token',
user: pb.UserInfo(
id: 'user-1',
phone: '13800138000',
createdAt: '2026-09-08T00:00:00Z',
),
).writeToBuffer(),
),
);
},
),
);
final result = await AuthApi(dio).verifyOneKeyLogin('umeng-token');
expect(requestToken, 'umeng-token');
expect(result.accessToken, 'access-token');
expect(result.user.phone, '13800138000');
});
}
@@ -0,0 +1,216 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
import 'package:jinzhi_flutter_app/features/auth/application/auth_controller.dart';
import 'package:jinzhi_flutter_app/features/auth/data/auth_models.dart';
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('restores a token by loading the real profile', () async {
final fixture = await _Fixture.create();
await fixture.identity.saveJwt('token');
await fixture.controller.retryBootstrap();
final auth = fixture.controller.state.value! as LoggedInAuthState;
expect(auth.token, 'token');
expect(auth.user.id, 'user-1');
expect(auth.user.phone, '13800138000');
expect(fixture.preferences.getString(storageUserId), 'user-1');
});
test('deletes the server account before clearing local identity', () async {
final fixture = await _Fixture.create();
await fixture.identity.saveJwt('token');
await fixture.controller.retryBootstrap();
await fixture.controller.deleteAccount('123456');
expect(fixture.deletedWithCode, '123456');
expect(fixture.controller.state.value, isA<GuestAuthState>());
expect(await fixture.identity.readJwt(), isNull);
expect(fixture.preferences.getString(storageUserId), isNull);
});
test('keeps the local session when server deletion fails', () async {
final fixture = await _Fixture.create();
await fixture.identity.saveJwt('token');
await fixture.controller.retryBootstrap();
fixture.failDeletion = true;
await expectLater(
fixture.controller.deleteAccount('123456'),
throwsA(isA<DioException>()),
);
expect(fixture.controller.state.value, isA<LoggedInAuthState>());
expect(await fixture.identity.readJwt(), 'token');
expect(fixture.preferences.getString(storageUserId), 'user-1');
});
test('clears an unauthorized restored session', () async {
final fixture = await _Fixture.create();
await fixture.identity.saveJwt('expired');
fixture.profileUnauthorized = true;
await fixture.controller.retryBootstrap();
expect(fixture.controller.state.value, isA<GuestAuthState>());
expect(await fixture.identity.readJwt(), isNull);
});
test('keeps a valid token and cached profile on a network failure', () async {
final fixture = await _Fixture.create();
await fixture.identity.saveJwt('token');
await fixture.preferences.setString('jinzhi_mock_logged_in', '13900139000');
fixture.failProfile = true;
await fixture.controller.retryBootstrap();
final auth = fixture.controller.state.value! as LoggedInAuthState;
expect(auth.user.id, isEmpty);
expect(auth.user.phone, '13900139000');
expect(await fixture.identity.readJwt(), 'token');
expect(fixture.preferences.getString(storageUserPhone), '13900139000');
expect(fixture.preferences.containsKey('jinzhi_mock_logged_in'), isFalse);
});
test('keeps the verified one-key login state shape', () async {
final fixture = await _Fixture.create();
await fixture.controller.verifyOneKeyLogin('umeng-token');
final auth = fixture.controller.state.value! as LoggedInAuthState;
expect(auth.token, 'one-key-token');
expect(auth.user.phone, '13800138000');
expect(await fixture.identity.readJwt(), 'one-key-token');
});
}
class _Fixture {
_Fixture({
required this.preferences,
required this.identity,
required this.controller,
});
final SharedPreferences preferences;
final SecureIdentityStorage identity;
final AuthController controller;
bool failDeletion = false;
bool failProfile = false;
bool profileUnauthorized = false;
String? deletedWithCode;
static Future<_Fixture> create() async {
final preferences = await SharedPreferences.getInstance();
final identity = SecureIdentityStorage(
preferences,
secureStorage: _MemorySecureStorage(),
);
late _Fixture fixture;
final dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (options.path == '/api/v1/me' && options.method == 'GET') {
if (fixture.failProfile) {
handler.reject(DioException(requestOptions: options));
return;
}
if (fixture.profileUnauthorized) {
handler.reject(
DioException(
requestOptions: options,
response: Response<void>(
requestOptions: options,
statusCode: 401,
),
),
);
return;
}
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: pb.ProfileReply(
user: pb.UserInfo(
id: 'user-1',
phone: '13800138000',
createdAt: '2026-09-08T00:00:00Z',
),
).writeToBuffer(),
),
);
return;
}
if (options.path == '/api/v1/me' && options.method == 'DELETE') {
fixture.deletedWithCode = pb.DeleteAccountRequest.fromBuffer(
options.data as List<int>,
).smsCode;
if (fixture.failDeletion) {
handler.reject(DioException(requestOptions: options));
} else {
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: const <int>[],
),
);
}
return;
}
if (options.path == '/api/v1/auth/onekey/verify') {
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: pb.VerifySmsCodeReply(
accessToken: 'one-key-token',
user: pb.UserInfo(phone: '13800138000'),
).writeToBuffer(),
),
);
return;
}
handler.next(options);
},
),
);
final controller = AuthController(
dio,
identityStorage: identity,
preferences: preferences,
autoBootstrap: false,
);
fixture = _Fixture(
preferences: preferences,
identity: identity,
controller: controller,
);
return fixture;
}
}
class _MemorySecureStorage implements SecureKeyValueStorage {
final Map<String, String> _values = {};
@override
Future<void> delete(String key) async => _values.remove(key);
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async {
_values[key] = value;
}
}
@@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/data/mall_auth_api.dart';
void main() {
test('sends an empty protobuf body when issuing a mall auth token', () async {
Object? requestData;
String? contentType;
final dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
requestData = options.data;
contentType = options.contentType;
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: _validResponse(),
),
);
},
),
);
final token = await MallAuthApi(dio).issueAppMallAuthToken();
expect(requestData, isA<Uint8List>());
expect(requestData, isEmpty);
expect(contentType, 'application/x-protobuf');
expect(token.authToken, 'mall-token');
expect(token.expiresIn, 300);
expect(token.sourceApp, 'jinzhi');
expect(token.scene, 'web_mall');
});
test('rejects an incomplete mall auth token response', () async {
final dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: _stringField(1, 'mall-token'),
),
);
},
),
);
await expectLater(
MallAuthApi(dio).issueAppMallAuthToken(),
throwsFormatException,
);
});
}
List<int> _validResponse() => <int>[
..._stringField(1, 'mall-token'),
..._integerField(2, 300),
..._stringField(3, 'jinzhi'),
..._stringField(4, 'web_mall'),
];
List<int> _stringField(int field, String value) {
final bytes = utf8.encode(value);
return <int>[
..._varint((field << 3) | 2),
..._varint(bytes.length),
...bytes,
];
}
List<int> _integerField(int field, int value) => <int>[
..._varint(field << 3),
..._varint(value),
];
List<int> _varint(int value) {
final bytes = <int>[];
while (value > 0x7f) {
bytes.add((value & 0x7f) | 0x80);
value >>= 7;
}
bytes.add(value);
return bytes;
}
@@ -0,0 +1,42 @@
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(
config: const MallTabConfig(
enabled: true,
url: 'https://mall.example.com/',
adId: 'latest-ad',
trackingId: 'latest-tracking',
clickId: 'latest-click',
entryRequest: MallEntryRequest.quoteRetailGold,
),
currentUri: Uri.parse(
'https://mall.example.com/?from=jinzhi&source_app=jinzhi'
'&source_entry=market_quote&placement=mall_tab&hook_type=quote_cta'
'&entry_scene=retail&entry_metal=au'
'&tracking_id=tracking&click_id=click-1&ts=123&isNight=0',
),
logged: true,
isDark: true,
authToken: 'mall-token',
scene: 'web_mall',
sourceApp: 'jinzhi',
);
expect(arguments['from'], 'jinzhi');
expect(arguments['source_app'], 'jinzhi');
expect(arguments['source_entry'], 'market_quote');
expect(arguments['hook_type'], 'quote_cta');
expect(arguments['entry_scene'], 'retail');
expect(arguments['entry_metal'], 'au');
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,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('records each first-install mall promotion exposure once', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = MallEntryPromoStore(await SharedPreferences.getInstance());
expect(store.hasOpenedMall, isFalse);
expect(store.hasSeenHomeBadge, isFalse);
await store.markMallOpened();
await store.markHomeBadgeSeen();
expect(store.hasOpenedMall, isTrue);
expect(store.hasSeenHomeBadge, isTrue);
});
}
@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
void main() {
test('keeps click attribution and reports the full generated event', () async {
pb.ReportAdEventRequest? clickEvent;
final dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (options.path == '/api/v1/ads') {
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: pb.GetAdReply(
enabled: true,
ad: pb.AdCreative(
adId: 'ad-1',
h5Url: 'https://mall.example.com',
trackingId: 'tracking-1',
openMode: pb.AdOpenMode.AD_OPEN_MODE_IN_APP,
),
).writeToBuffer(),
),
);
return;
}
if (options.path == '/api/v1/ads/events') {
final event = pb.ReportAdEventRequest.fromBuffer(
options.data as List<int>,
);
if (event.eventName == 'ad_click') clickEvent = event;
handler.resolve(
Response<List<int>>(
requestOptions: options,
statusCode: 200,
data: pb.ReportAdEventReply().writeToBuffer(),
),
);
return;
}
handler.next(options);
},
),
);
final controller = MallTabConfigController(dio, autoRefresh: false);
await controller.refresh();
final clicked = await controller.recordClick(
request: MallEntryRequest.quoteRetailGold,
);
expect(clicked.clickId, isNotEmpty);
expect(controller.state.clickId, clicked.clickId);
expect(clickEvent?.clickId, clicked.clickId);
expect(clickEvent?.attrs['source_app'], 'jinzhi');
expect(clickEvent?.attrs['source_entry'], 'market_quote');
expect(clickEvent?.attrs['hook_type'], 'quote_cta');
expect(clickEvent?.attrs['entry_scene'], 'retail');
expect(clickEvent?.attrs['entry_metal'], 'au');
expect(clickEvent?.attrs['open_mode'], 'in_app');
});
}
@@ -0,0 +1,151 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
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);
});
test('adds retail quote context to the mall URL for H5', () {
const config = MallTabConfig(
enabled: true,
url: 'https://mall.example.com/home',
);
final uri = config.attributedUri(
clickId: 'quote-click',
entry: MallEntryRequest.quoteRetailGold,
)!;
expect(uri.queryParameters['source_entry'], 'market_quote');
expect(uri.queryParameters['hook_type'], 'quote_cta');
expect(uri.queryParameters['entry_scene'], 'retail');
expect(uri.queryParameters['entry_metal'], 'au');
expect(uri.queryParameters['placement'], 'mall_tab');
});
test('shows the first-install promo only for a configured mall entry', () {
const configured = MallTabConfig(
enabled: true,
url: 'https://mall.example.com',
entryPromoEnabled: true,
entryBadgeText: '新人礼',
entryTipTitle: '新人礼:¥360 铂金订购券',
entryTipBody: '点击这里进商城',
);
const disabled = MallTabConfig(
enabled: true,
url: 'https://mall.example.com',
entryPromoEnabled: false,
entryTipTitle: '新人礼',
);
expect(configured.showsEntryBadge, isTrue);
expect(configured.showsEntryTip, isTrue);
expect(disabled.showsEntryBadge, isFalse);
expect(disabled.showsEntryTip, isFalse);
});
test('parses the server-provided mall entry promotion fields', () {
final creative = <int>[
..._stringField(9, 'https://mall.example.com'),
..._boolField(16, true),
..._stringField(17, '新人礼'),
..._stringField(18, '新人礼:¥360 铂金订购券'),
..._stringField(19, '点击这里进商城'),
];
final config = parseMallTabConfigResponse(<int>[
..._boolField(1, true),
..._bytesField(2, creative),
]);
expect(config.showsEntryBadge, isTrue);
expect(config.entryBadgeText, '新人礼');
expect(config.entryTipTitle, '新人礼:¥360 铂金订购券');
expect(config.entryTipBody, '点击这里进商城');
});
test('parses generated mall promotion and open-mode fields', () {
final response = pb.GetAdReply(
enabled: true,
ad: pb.AdCreative(
adId: 'ad-1',
title: '会员商城',
h5Url: 'https://mall.example.com',
trackingId: 'tracking-1',
openMode: pb.AdOpenMode.AD_OPEN_MODE_NATIVE_APP,
mallPromotion: pb.MallPromotion(quoteCtaEnabled: true),
),
);
final config = parseMallTabConfigResponse(response.writeToBuffer());
expect(config.isVisible, isTrue);
expect(config.openMode, 'native_app');
expect(config.quoteCtaEnabled, isTrue);
expect(config.allowedHosts, {'mall.example.com'});
});
}
List<int> _stringField(int tag, String value) =>
_bytesField(tag, utf8.encode(value));
List<int> _bytesField(int tag, List<int> value) => <int>[
..._varint((tag << 3) | 2),
..._varint(value.length),
...value,
];
List<int> _boolField(int tag, bool value) => <int>[
..._varint(tag << 3),
value ? 1 : 0,
];
List<int> _varint(int value) {
final bytes = <int>[];
while (value > 127) {
bytes.add((value & 127) | 128);
value >>= 7;
}
bytes.add(value);
return bytes;
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/presentation/js_api.dart';
void main() {
test('close invokes the host callback and returns success', () {
var closeCalls = 0;
final handler = _FakeCompletionHandler();
final api = JsApi(onClose: () => closeCalls++);
api.close(null, handler);
expect(closeCalls, 1);
expect(handler.result, <String, dynamic>{'code': 0, 'message': 'success'});
});
test('coerces JSON mall state payloads', () {
expect(
coerceMallStateMap(
'{"ready":true,"activeTab":"market","canGoBack":false}',
),
<String, dynamic>{
'ready': true,
'activeTab': 'market',
'canGoBack': false,
},
);
});
}
class _FakeCompletionHandler implements CompletionHandler {
dynamic result;
@override
void complete([dynamic retValue]) => result = retValue;
@override
void setProgressData(dynamic value) {}
}
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/mall_bridge_state.dart';
void main() {
test('updates state and forwards tab interactions before ready', () async {
final bridge = MallBridgeController.instance;
bridge.detach();
addTearDown(bridge.detach);
MallBridgeTab? activated;
bool? reselected;
var backCalls = 0;
bridge.attach(
activateTab: (tab, reselect) async {
activated = tab;
reselected = reselect;
},
goBack: () async => backCalls++,
);
await bridge.activate(MallBridgeTab.orders);
expect(activated, MallBridgeTab.orders);
expect(bridge.state.value.activeTab, MallBridgeTab.orders);
bridge.update(const {
'ready': true,
'activeTab': 'orders',
'canGoBack': true,
});
await bridge.activate(MallBridgeTab.orders, reselect: true);
await bridge.goBack();
expect(bridge.state.value.activeTab, MallBridgeTab.orders);
expect(bridge.state.value.canGoBack, isTrue);
expect(activated, MallBridgeTab.orders);
expect(reselected, isTrue);
expect(backCalls, 1);
});
test('accepts numeric and string booleans from H5', () {
final bridge = MallBridgeController.instance;
bridge.detach();
addTearDown(bridge.detach);
bridge.update(const {
'ready': 1,
'activeTab': 'market',
'canGoBack': 'true',
});
expect(bridge.state.value.ready, isTrue);
expect(bridge.state.value.activeTab, MallBridgeTab.market);
expect(bridge.state.value.canGoBack, isTrue);
});
}
@@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/mall_web_host_controller.dart';
void main() {
final host = MallWebHostController.instance;
setUp(() async => host.clear());
tearDown(() async => host.clear());
test('opens and dismisses the resident full-screen mall host', () {
final uri = Uri.parse('https://mall.example.com/?isNight=0');
host.open(uri);
expect(host.state.value.url, uri.toString());
expect(host.state.value.visible, isTrue);
host.dismiss();
expect(host.state.value.url, uri.toString());
expect(host.state.value.visible, isFalse);
});
test('cache identity ignores attribution but includes theme', () {
const first =
'https://mall.example.com/path?isNight=0&click_id=a&tracking_id=t1';
const second =
'https://mall.example.com/path?isNight=0&click_id=b&tracking_id=t2';
const dark = 'https://mall.example.com/path?isNight=1&click_id=b';
expect(
MallWebHostController.cacheKeyFor(first),
MallWebHostController.cacheKeyFor(second),
);
expect(
MallWebHostController.cacheKeyFor(first),
isNot(MallWebHostController.cacheKeyFor(dark)),
);
});
test('defers a changed preload while the mall is visible', () {
final active = Uri.parse('https://mall.example.com/?isNight=0');
final refreshed = Uri.parse('https://mall.example.com/?isNight=1');
host.open(active);
host.preload(refreshed);
expect(host.state.value.url, active.toString());
expect(host.state.value.visible, isTrue);
host.dismiss();
expect(host.state.value.url, refreshed.toString());
expect(host.state.value.visible, isFalse);
});
test('themed URL replaces a stale isNight value', () {
final uri = MallWebHostController.uriForTheme(
Uri.parse('https://mall.example.com/?from=jinzhi&isNight=1'),
isDark: false,
);
expect(uri.queryParameters['from'], 'jinzhi');
expect(uri.queryParameters['isNight'], '0');
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/presentation/mall_web_host.dart';
void main() {
test('loading only covers the WebView before its first page', () {
expect(
shouldShowMallLoading(
hasError: false,
hasDisplayedPage: false,
progress: 0,
),
isTrue,
);
expect(
shouldShowMallLoading(
hasError: false,
hasDisplayedPage: true,
progress: 0,
),
isFalse,
);
expect(
shouldShowMallLoading(
hasError: true,
hasDisplayedPage: false,
progress: 0,
),
isFalse,
);
});
test('only Android Qiyu pages reserve keyboard space for the WebView', () {
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: true,
platform: TargetPlatform.android,
keyboardInset: 336,
isWeb: false,
),
336,
);
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: false,
platform: TargetPlatform.android,
keyboardInset: 336,
isWeb: false,
),
0,
);
expect(
qiyuWebViewBottomInset(
isShowingQiyuPage: true,
platform: TargetPlatform.iOS,
keyboardInset: 336,
isWeb: false,
),
0,
);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/web_back_navigation.dart';
void main() {
test('uses browser history only when it exists', () async {
var wentBack = false;
expect(
await tryGoBackInWebHistory(
canGoBack: () async => false,
goBack: () async => wentBack = true,
),
isFalse,
);
expect(wentBack, isFalse);
});
test('recognizes DSBridge JavaScript boolean results', () async {
expect(
await tryGoBackUsingVisibleWebControl(
runJavaScriptReturningResult: (_) async => 'true',
),
isTrue,
);
});
}
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/application/web_navigation_policy.dart';
void main() {
test('external handoff matches the resident mall policy', () {
expect(
shouldOpenWebUrlExternally('https://example.com/download?out=1'),
isTrue,
);
expect(shouldOpenWebUrlExternally('https://example.com/order/1'), isFalse);
expect(shouldOpenWebUrlExternally('tel:4000450268'), isTrue);
});
test('telephone handoff removes H5 parameters', () {
expect(
sanitizeSystemCommunicationUri(Uri.parse('tel:4000450268?out=1')),
Uri.parse('tel:4000450268'),
);
expect(
sanitizeSystemCommunicationUri(
Uri.parse('tel:4000450268&campaign=mall&out=1'),
),
Uri.parse('tel:4000450268'),
);
});
test('trusts only exact Qiyu domains and subdomains', () {
expect(isTrustedQiyuUrl('https://qiyukf.com/client'), isTrue);
expect(isTrustedQiyuUrl('https://service.qiyukf.cn/client'), isTrue);
expect(isTrustedQiyuUrl('https://qiyukf.com.evil.example/client'), isFalse);
});
}
+2 -1
View File
@@ -2,13 +2,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/features/web/presentation/web_page.dart';
void main() {
test('accepts only http and https URLs with a host', () {
test('accepts HTTPS and rejects unsafe or remote HTTP URLs', () {
expect(
parseSafeWebUri('https://h5.example.com/page')?.host,
'h5.example.com',
);
expect(parseSafeWebUri('mailto:test@example.com'), isNull);
expect(parseSafeWebUri('javascript:alert(1)'), isNull);
expect(parseSafeWebUri('http://example.com/page'), isNull);
expect(parseSafeWebUri(''), isNull);
});
}