From 33235f450b5a6130bb6bb522b6da901556fb92b2 Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Mon, 24 Aug 2026 19:07:31 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E7=AC=AC=E4=B8=80=E8=BD=AE?= =?UTF-8?q?=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 33 +- android/app/src/main/AndroidManifest.xml | 16 +- .../com/jinzhi/app/JinzhiApplication.kt | 51 ++ .../kotlin/com/jinzhi/app/MainActivity.kt | 18 +- android/build.gradle.kts | 14 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ ios/Podfile | 11 +- ios/Podfile.lock | 80 ++- ios/Runner.xcodeproj/project.pbxproj | 31 +- ios/Runner/Info.plist | 117 ++-- ios/Runner/Runner.entitlements | 8 + lib/app/app.dart | 54 +- lib/app/bootstrap.dart | 11 +- lib/common/config/app_config.dart | 2 +- lib/common/config/app_h5_urls.dart | 2 +- lib/common/config/umeng_config.dart | 7 + lib/common/privacy/umeng_privacy_consent.dart | 11 + lib/common/push/device_push_registrar.dart | 95 +++ lib/common/push/notification_permission.dart | 41 ++ lib/common/push/umeng_push_service.dart | 86 +++ lib/common/router/app_router.dart | 20 + lib/common/router/deep_link_service.dart | 43 ++ lib/common/router/route_error_page.dart | 28 + lib/common/router/tab_shell.dart | 316 ++++++--- lib/common/services/cache_clear_service.dart | 38 + .../services/device_header_service.dart | 6 + lib/common/storage/storage_keys.dart | 1 + lib/common/tracking/umeng_service.dart | 76 ++ .../widgets/notification_permission_tip.dart | 82 +++ .../ad_config_foreground_refresher.dart | 32 + .../ads/application/splash_ad_controller.dart | 128 ++++ .../ads/application/startup_ad_service.dart | 57 ++ lib/features/ads/data/ads_api_client.dart | 294 ++++++++ lib/features/ads/presentation/ad_media.dart | 122 ++++ .../ads/presentation/home_ad_banner.dart | 134 ++++ .../ads/presentation/splash_ad_dialog.dart | 82 +++ .../assets/presentation/assets_page.dart | 70 +- .../auth/application/auth_controller.dart | 27 +- .../auth/application/login_launcher.dart | 91 +++ .../application/one_key_login_service.dart | 218 ++++++ lib/features/auth/data/one_key_login_api.dart | 92 +++ .../auth/presentation/login_page.dart | 18 + .../mall/application/mall_tab_config.dart | 236 +++++++ .../profile/presentation/profile_page.dart | 9 +- .../application/update_controller.dart | 34 + .../update_foreground_service.dart | 36 + .../settings/application/update_service.dart | 93 +++ .../settings/data/update_api_client.dart | 146 ++++ .../presentation/privacy_consent_page.dart | 44 +- .../settings/presentation/settings_page.dart | 198 +++++- .../application/mall_web_host_controller.dart | 30 + .../web/presentation/mall_web_host.dart | 131 ++++ lib/features/web/presentation/web_page.dart | 123 ++++ pubspec.lock | 130 ++++ pubspec.yaml | 17 + test/features/web/web_page_test.dart | 14 + 56 files changed, 4357 insertions(+), 210 deletions(-) create mode 100644 android/app/src/main/kotlin/com/jinzhi/app/JinzhiApplication.kt create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 ios/Runner/Runner.entitlements create mode 100644 lib/common/privacy/umeng_privacy_consent.dart create mode 100644 lib/common/push/device_push_registrar.dart create mode 100644 lib/common/push/notification_permission.dart create mode 100644 lib/common/push/umeng_push_service.dart create mode 100644 lib/common/router/deep_link_service.dart create mode 100644 lib/common/router/route_error_page.dart create mode 100644 lib/common/services/cache_clear_service.dart create mode 100644 lib/common/tracking/umeng_service.dart create mode 100644 lib/common/widgets/notification_permission_tip.dart create mode 100644 lib/features/ads/application/ad_config_foreground_refresher.dart create mode 100644 lib/features/ads/application/splash_ad_controller.dart create mode 100644 lib/features/ads/application/startup_ad_service.dart create mode 100644 lib/features/ads/data/ads_api_client.dart create mode 100644 lib/features/ads/presentation/ad_media.dart create mode 100644 lib/features/ads/presentation/home_ad_banner.dart create mode 100644 lib/features/ads/presentation/splash_ad_dialog.dart create mode 100644 lib/features/auth/application/login_launcher.dart create mode 100644 lib/features/auth/application/one_key_login_service.dart create mode 100644 lib/features/auth/data/one_key_login_api.dart create mode 100644 lib/features/mall/application/mall_tab_config.dart create mode 100644 lib/features/settings/application/update_controller.dart create mode 100644 lib/features/settings/application/update_foreground_service.dart create mode 100644 lib/features/settings/application/update_service.dart create mode 100644 lib/features/settings/data/update_api_client.dart create mode 100644 lib/features/web/application/mall_web_host_controller.dart create mode 100644 lib/features/web/presentation/mall_web_host.dart create mode 100644 lib/features/web/presentation/web_page.dart create mode 100644 test/features/web/web_page_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ce70da5..94d2dbc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,6 +14,16 @@ if (!androidSigningPropertiesFile.isFile) { } androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) } +val umengProperties = Properties() +val umengPropertiesFile = rootProject.file("../assets/env/.env") +if (umengPropertiesFile.isFile) { + umengPropertiesFile.inputStream().use { umengProperties.load(it) } +} + +fun umengBuildValue(name: String): String = + System.getenv(name)?.takeIf { it.isNotBlank() } + ?: umengProperties.getProperty(name)?.trim().orEmpty() + fun requireSigningProperty(name: String): String { val value = androidSigningProperties.getProperty(name)?.trim() if (value.isNullOrBlank()) { @@ -47,7 +57,9 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.jinzhi.app" + // Temporary U-Verify test identity: the Yanting AppKey/login secret + // loaded from assets/env/.env is registered against this package. + applicationId = "com.yanting.read" // 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 @@ -55,15 +67,32 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName - val umengChannel = System.getenv("UMENG_CHANNEL") ?: "" + val umengChannel = umengBuildValue("UMENG_CHANNEL").ifBlank { "test" } manifestPlaceholders.putAll( mutableMapOf( "UMENG_CHANNEL" to umengChannel, + "UMENG_APPKEY" to umengBuildValue("UMENG_ANDROID_APP_KEY"), + "UMENG_MESSAGE_SECRET" to umengBuildValue("UMENG_ANDROID_PUSH_SECRET"), + "XIAOMI_APP_ID" to umengBuildValue("XIAOMI_APP_ID"), + "XIAOMI_APP_KEY" to umengBuildValue("XIAOMI_APP_KEY"), + "OPPO_APP_KEY" to umengBuildValue("OPPO_APP_KEY"), + "OPPO_APP_SECRET" to umengBuildValue("OPPO_APP_SECRET"), + "MEIZU_APP_ID" to umengBuildValue("MEIZU_APP_ID"), + "MEIZU_APP_KEY" to umengBuildValue("MEIZU_APP_KEY"), + "HUAWEI_APP_ID" to umengBuildValue("HUAWEI_APP_ID"), + "HONOR_APP_ID" to umengBuildValue("HONOR_APP_ID"), + "VIVO_APP_KEY" to umengBuildValue("VIVO_APP_KEY"), + "VIVO_APP_ID" to umengBuildValue("VIVO_APP_ID"), ) ) } buildTypes { + debug { + // U-Verify checks the app signing certificate. Use the same + // registered signing identity as release, like Yanting does. + signingConfig = signingConfigs.getByName("release") + } release { signingConfig = signingConfigs.getByName("release") proguardFiles( diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d5bccc..9c8e821 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,12 +1,14 @@ + + @@ -31,6 +33,12 @@ + + + + + + @@ -41,6 +49,12 @@ + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/ios/Podfile b/ios/Podfile index 620e46e..191b905 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,5 +39,14 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + # 对齐研听:permission_handler 的通知策略默认不编译,必须显式开启; + # 否则 Permission.notification.request() 是空实现,iOS 不会有系统通知项。 + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_NOTIFICATIONS=1', + ] + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e60e3ec..1ad6811 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,11 @@ PODS: + - app_tracking_transparency (0.0.1): + - Flutter - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_app_update (0.0.1): + - Flutter - flutter_native_splash (2.4.3): - Flutter - flutter_paid (0.0.1): @@ -16,36 +20,85 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.4.8): + - Flutter - platform_metadata (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - UMCCommonLog (2.0.2) + - UMCommon (7.6.4): + - UMDevice + - UMDevice (3.6.0) + - umeng_common_sdk (0.0.1): + - Flutter + - UMCommon + - UMDevice + - umeng_push_sdk (0.0.1): + - Flutter + - UMCCommonLog + - UMCommon + - UMDevice + - UMPush + - umeng_verify_sdk (2.1.0): + - Flutter + - UMVerify (>= 3.3.0) + - UMPush (4.1.4): + - UMCommon + - UMVerify (4.0.0): + - UMCommon - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: + - app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_paid (from `.symlinks/plugins/flutter_paid/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - platform_metadata (from `.symlinks/plugins/platform_metadata/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - umeng_common_sdk (from `.symlinks/plugins/umeng_common_sdk/ios`) + - umeng_push_sdk (from `.symlinks/plugins/umeng_push_sdk/ios`) + - umeng_verify_sdk (from `.symlinks/plugins/umeng_verify_sdk/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - UMCCommonLog + - UMCommon + - UMDevice + - UMPush + - UMVerify EXTERNAL SOURCES: + app_tracking_transparency: + :path: ".symlinks/plugins/app_tracking_transparency/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_app_update: + :path: ".symlinks/plugins/flutter_app_update/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_paid: @@ -58,29 +111,54 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" platform_metadata: :path: ".symlinks/plugins/platform_metadata/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + umeng_common_sdk: + :path: ".symlinks/plugins/umeng_common_sdk/ios" + umeng_push_sdk: + :path: ".symlinks/plugins/umeng_push_sdk/ios" + umeng_verify_sdk: + :path: ".symlinks/plugins/umeng_verify_sdk/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/darwin" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: + app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + UMCCommonLog: bea707e50c85cef4b0eb47cc5c7226bb843245ca + UMCommon: 001691e59cb7961ec21d3312687499c8516dddc6 + UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 + umeng_common_sdk: 095b63e6f83b71548725a7c03a6c17b23be6674b + umeng_push_sdk: 557ebb6834df105bd828b86eeb8ae0b80959ee79 + umeng_verify_sdk: 64a823bd4528bf46c40759c613d6ab34935b8293 + UMPush: e9e81f259799b2b238ee21cc152e4cea31909604 + UMVerify: d961ffe2e4b9f00ad578c373e420ad17f72a1f7c url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: c5e833ba06eec2784e07140760ce1599fc8e3bfc COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 99b7492..86e073a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */, + 90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -299,14 +300,10 @@ 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"; @@ -350,6 +347,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -473,6 +487,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -483,7 +498,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app; + PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -659,6 +674,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -669,7 +685,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app; + PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -685,6 +701,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -695,7 +712,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app; + PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 00af064..add996f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,60 +1,75 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - 金值 - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - 金值 - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 金值 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 金值 + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsInWebContent - + CFBundleURLSchemes + + jinzhi + - UIApplicationSupportsIndirectInputEvents + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIStatusBarHidden + NSAllowsArbitraryLoadsInWebContent - UIViewControllerBasedStatusBarAppearance - + NSUserTrackingUsageDescription + 用于衡量广告投放效果并提供更相关的内容。 + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..6a26dfe --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + $(APS_ENVIRONMENT) + + diff --git a/lib/app/app.dart b/lib/app/app.dart index 02c49df..56bf6c5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -5,15 +7,63 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../common/router/app_router.dart'; import '../common/lifecycle/refresh_coordinator.dart'; +import '../common/push/device_push_registrar.dart'; +import '../common/push/umeng_push_service.dart'; import '../common/theme/jinzhi_theme.dart'; 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 '../common/router/deep_link_service.dart'; +import '../common/services/device_header_service.dart'; -class JinzhiApp extends ConsumerWidget { +class JinzhiApp extends ConsumerStatefulWidget { const JinzhiApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _JinzhiAppState(); +} + +class _JinzhiAppState extends ConsumerState + with WidgetsBindingObserver { + late final DevicePushRegistrar _pushRegistrar; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _pushRegistrar = DevicePushRegistrar(ref.read)..start(); + UmengPushService.configureNotificationOpen(DeepLinkService.handle); + WidgetsBinding.instance.addPostFrameCallback((_) { + // 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页 + // 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。 + if (!DeviceHeaderService.to.iosStartupRequired) { + unawaited(UmengPushService.startIfAllowed()); + } + }); + } + + @override + void dispose() { + unawaited(_pushRegistrar.stop()); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_pushRegistrar.refresh()); + unawaited(ref.read(adConfigForegroundRefresherProvider).refresh()); + final context = rootNavigatorKey.currentContext; + if (context != null) { + unawaited(UpdateForegroundService.check(context, ref)); + } + } + } + + @override + Widget build(BuildContext context) { // Keep the app-wide foreground refresh coordinator alive. Features opt in // by registering only while their screen is visible. ref.watch(refreshCoordinatorProvider); diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index bd028e1..028b87c 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/widgets.dart'; import '../common/services/device_header_service.dart'; +import '../common/config/shorebird_service.dart'; +import '../common/config/umeng_config.dart'; import '../common/storage/secure_identity_storage.dart'; import '../common/storage/storage_service.dart'; import '../common/widgets/easy_refresh_localization.dart'; @@ -12,6 +14,7 @@ import 'app.dart'; Future bootstrap() async { WidgetsFlutterBinding.ensureInitialized(); configureEasyRefreshChineseLocalization(); + await UmengConfig.load(); await StorageService.instance; await SecureIdentityStorage( StorageService.to.preferences, @@ -25,9 +28,9 @@ Future bootstrap() async { await DeviceHeaderService.to.ensureInitialized(); } } - // if (!kIsWeb) { - // await ShorebirdService.instance.initCurrentPatch(); - // ShorebirdService.instance.checkForUpdateInBackground(); - // } + if (!kIsWeb) { + await ShorebirdService.instance.initCurrentPatch(); + ShorebirdService.instance.checkForUpdateInBackground(); + } return const JinzhiApp(); } diff --git a/lib/common/config/app_config.dart b/lib/common/config/app_config.dart index 118476e..29fca57 100644 --- a/lib/common/config/app_config.dart +++ b/lib/common/config/app_config.dart @@ -4,7 +4,7 @@ class AppConfig { factory AppConfig.fromEnvironment() { const url = String.fromEnvironment( 'JINZHI_API_BASE_URL', - defaultValue: 'https://jinzhi.api.local', + defaultValue: 'https://yanting.api.hnsiderui.cn', ); return const AppConfig(apiBaseUrl: url); } diff --git a/lib/common/config/app_h5_urls.dart b/lib/common/config/app_h5_urls.dart index 4dbb80c..f2273c6 100644 --- a/lib/common/config/app_h5_urls.dart +++ b/lib/common/config/app_h5_urls.dart @@ -3,7 +3,7 @@ class AppH5Urls { static const baseUrl = String.fromEnvironment( 'JINZHI_H5_BASE_URL', - defaultValue: 'https://jinzhi.h5.local', + defaultValue: 'https://yanting.h5.hnsiderui.cn', ); static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html'; diff --git a/lib/common/config/umeng_config.dart b/lib/common/config/umeng_config.dart index 35b1618..a748307 100644 --- a/lib/common/config/umeng_config.dart +++ b/lib/common/config/umeng_config.dart @@ -7,6 +7,8 @@ class UmengConfig { '6a27b1f46f259537c7b616be'; // Default placeholder static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder static String _androidPushSecret = 'xxx'; // Default placeholder + static String _androidLoginSecret = ''; + static String _iosLoginSecret = ''; static Future load() async { await dotenv.load( @@ -17,9 +19,14 @@ class UmengConfig { _iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey; _androidPushSecret = dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret; + _androidLoginSecret = + dotenv.env['UMENG_ANDROID_LOGIN_SECRET'] ?? _androidLoginSecret; + _iosLoginSecret = dotenv.env['UMENG_IOS_LOGIN_SECRET'] ?? _iosLoginSecret; } static String get androidAppKey => _androidAppKey; static String get iosAppKey => _iosAppKey; static String get androidPushSecret => _androidPushSecret; + static String get androidLoginSecret => _androidLoginSecret; + static String get iosLoginSecret => _iosLoginSecret; } diff --git a/lib/common/privacy/umeng_privacy_consent.dart b/lib/common/privacy/umeng_privacy_consent.dart new file mode 100644 index 0000000..e8e07ca --- /dev/null +++ b/lib/common/privacy/umeng_privacy_consent.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +abstract final class UmengPrivacyConsent { + static const _channel = MethodChannel('jinzhi/privacy'); + + static Future submitGranted() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + await _channel.invokeMethod('submitUmengPrivacyGranted'); + } +} diff --git a/lib/common/push/device_push_registrar.dart b/lib/common/push/device_push_registrar.dart new file mode 100644 index 0000000..b43dc23 --- /dev/null +++ b/lib/common/push/device_push_registrar.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../network/dio_provider.dart'; +import 'notification_permission.dart'; +import 'umeng_push_service.dart'; + +class DevicePushRegistrar { + DevicePushRegistrar(this._read); + final T Function(ProviderListenable) _read; + StreamSubscription? _subscription; + String? _lastToken; + bool? _lastPermission; + int _attempt = 0; + + void start() { + _subscription ??= UmengPushService.onDeviceToken.listen(_register); + UmengPushService.emitCachedToken(); + } + + Future stop() async => _subscription?.cancel(); + Future refresh() => UmengPushService.refreshDeviceToken(); + void forceRegisterCurrentToken() { + _lastToken = null; + _lastPermission = null; + UmengPushService.emitCachedToken(); + } + + Future _register(String token) async { + final permission = await NotificationPermission.isGranted(); + if (token == _lastToken && permission == _lastPermission) return; + try { + final package = await PackageInfo.fromPlatform(); + await _read(dioProvider).post>( + '/api/v1/push/devices', + data: _PushRegisterRequest( + token: token, + platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2, + version: package.version, + permission: permission, + ).toBytes(), + options: protobufOptions(), + ); + _lastToken = token; + _lastPermission = permission; + _attempt = 0; + } catch (_) { + if (_attempt++ >= 3) return; + await Future.delayed(Duration(seconds: 1 << (_attempt - 1))); + await _register(token); + } + } +} + +class _PushRegisterRequest { + const _PushRegisterRequest({ + required this.token, + required this.platform, + required this.version, + required this.permission, + }); + final String token; + final int platform; + final String version; + final bool permission; + Uint8List toBytes() { + final bytes = BytesBuilder(copy: false); + void putInt(int value) { + while (value > 0x7f) { + bytes.addByte((value & 0x7f) | 0x80); + value >>= 7; + } + bytes.addByte(value); + } + + void putString(int tag, String value) { + final raw = value.codeUnits; + putInt((tag << 3) | 2); + putInt(raw.length); + bytes.add(raw); + } + + putString(1, token); + putInt(2 << 3); + putInt(platform); + putString(3, version); + putInt(4 << 3); + putInt(permission ? 1 : 0); + return bytes.toBytes(); + } +} diff --git a/lib/common/push/notification_permission.dart b/lib/common/push/notification_permission.dart new file mode 100644 index 0000000..a3933da --- /dev/null +++ b/lib/common/push/notification_permission.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../storage/storage_keys.dart'; +import '../storage/storage_service.dart'; +import '../widgets/notification_permission_tip.dart'; + +abstract final class NotificationPermission { + static Future isGranted() async => + !kIsWeb && (await Permission.notification.status).isGranted; + + /// 对齐研听:Android 13+ 由应用解释后主动申请;iOS 由 U-Push 注册 APNs + /// 时触发系统授权,绝不展示 Android 的自定义顶部提示。 + static Future requestIfNeeded(BuildContext context) async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return isGranted(); + } + final android = await DeviceInfoPlugin().androidInfo; + if (android.version.sdkInt < 33 || + StorageService.to.getBool(storagePushPermissionPrompted)) { + return isGranted(); + } + if (await isGranted() || !context.mounted) return true; + await showNotificationPermissionTip(context); + await StorageService.to.setBool(storagePushPermissionPrompted, true); + return (await Permission.notification.request()).isGranted; + } + + /// 设置页不使用顶部说明;iOS/Android 都直接请求系统授权。 + static Future requestFromSettings() async { + if (kIsWeb) return false; + final current = await Permission.notification.status; + if (current.isGranted) return true; + await StorageService.to.setBool(storagePushPermissionPrompted, true); + return (await Permission.notification.request()).isGranted; + } + + static Future openSystemSettings() => openAppSettings(); +} diff --git a/lib/common/push/umeng_push_service.dart b/lib/common/push/umeng_push_service.dart new file mode 100644 index 0000000..42bea6a --- /dev/null +++ b/lib/common/push/umeng_push_service.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:umeng_common_sdk/umeng_common_sdk.dart'; +import 'package:umeng_push_sdk/umeng_push_sdk.dart'; + +import '../config/umeng_config.dart'; +import '../privacy/privacy_gate.dart'; +import '../services/device_header_service.dart'; +import '../storage/storage_keys.dart'; +import '../storage/storage_service.dart'; + +abstract final class UmengPushService { + static final _tokens = StreamController.broadcast(); + static Future? _starting; + static bool _started = false; + static void Function(String payload)? _onNotificationOpen; + static String? _pendingNotificationOpen; + + static Stream get onDeviceToken => _tokens.stream; + + static void configureNotificationOpen( + void Function(String payload) callback, + ) { + _onNotificationOpen = callback; + final pending = _pendingNotificationOpen; + if (pending != null) { + _pendingNotificationOpen = null; + callback(pending); + } + } + + static Future startIfAllowed() { + if (kIsWeb || + _started || + requiresAndroidPrivacyConsent( + isWeb: kIsWeb, + platform: defaultTargetPlatform, + privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed), + )) { + return Future.value(); + } + return _starting ??= _start().whenComplete(() => _starting = null); + } + + static Future _start() async { + await DeviceHeaderService.to.ensureInitialized(); + await UmengCommonSdk.initCommon( + UmengConfig.androidAppKey, + UmengConfig.iosAppKey, + DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test', + UmengConfig.androidPushSecret, + ); + UmengPushSdk.setTokenCallback(_emit); + UmengPushSdk.setNotificationCallback((_) {}, (payload) { + final callback = _onNotificationOpen; + if (callback == null) { + _pendingNotificationOpen = payload; + } else { + callback(payload); + } + }); + await UmengPushSdk.register( + UmengConfig.iosAppKey, + DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test', + ); + if (Platform.isAndroid) await UmengPushSdk.setPushEnable(true); + _started = true; + await refreshDeviceToken(); + } + + static Future refreshDeviceToken() async { + if (!_started || kIsWeb) return; + _emit(await UmengPushSdk.getRegisteredId()); + } + + static void emitCachedToken() => + _emit(StorageService.to.getString(storageDeviceToken)); + + static void _emit(String? token) { + if (token == null || token.isEmpty) return; + _tokens.add(token); + unawaited(StorageService.to.setString(storageDeviceToken, token)); + } +} diff --git a/lib/common/router/app_router.dart b/lib/common/router/app_router.dart index 446104a..76e132a 100644 --- a/lib/common/router/app_router.dart +++ b/lib/common/router/app_router.dart @@ -15,8 +15,10 @@ import '../../features/news/presentation/news_page.dart'; import '../../features/profile/presentation/profile_page.dart'; import '../../features/settings/presentation/settings_page.dart'; import '../../features/settings/presentation/privacy_consent_page.dart'; +import '../../features/web/presentation/web_page.dart'; import '../services/device_header_service.dart'; import 'tab_shell.dart'; +import 'route_error_page.dart'; abstract final class AppRoutes { static const assets = '/assets'; @@ -33,13 +35,18 @@ abstract final class AppRoutes { static const debugTypography = '/debug/typography'; static const fancyDioInspector = '/debug/fancy_dio_inspector'; static const privacyConsent = '/privacy-consent'; + static const web = '/web'; } +final rootNavigatorKey = GlobalKey(); + final routerProvider = Provider((ref) { final startupListenable = ValueNotifier(0); return GoRouter( + navigatorKey: rootNavigatorKey, initialLocation: AppRoutes.assets, refreshListenable: startupListenable, + errorBuilder: (_, state) => RouteErrorPage(error: state.error), redirect: (context, state) { final loc = state.matchedLocation; final onConsent = loc == AppRoutes.privacyConsent; @@ -131,6 +138,19 @@ final routerProvider = Provider((ref) { path: AppRoutes.privacyConsent, builder: (_, s) => const PrivacyConsentPage(), ), + GoRoute( + path: AppRoutes.web, + builder: (_, state) { + final uri = parseSafeWebUri(state.uri.queryParameters['url']); + if (uri == null) { + return const Scaffold(body: Center(child: Text('页面地址不可用'))); + } + return AppWebPage( + initialUri: uri, + hideTitle: state.uri.queryParameters['hideTitle'] == '1', + ); + }, + ), ], ); }); diff --git a/lib/common/router/deep_link_service.dart b/lib/common/router/deep_link_service.dart new file mode 100644 index 0000000..2278f6a --- /dev/null +++ b/lib/common/router/deep_link_service.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:go_router/go_router.dart'; + +import 'app_router.dart'; + +/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。 +abstract final class DeepLinkService { + static void handle(String raw) { + final target = _route(raw); + if (target != null) rootNavigatorKey.currentContext?.go(target); + } + + static String? _route(String raw) { + var source = raw.trim(); + try { + final json = jsonDecode(source); + if (json is Map) { + source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}'; + } + } catch (_) {} + final uri = Uri.tryParse(source); + if (uri == null) return null; + if (uri.scheme == 'jinzhi') { + final path = uri.path.isEmpty ? '/assets' : uri.path; + return _allowed(path) + ? '$path${uri.hasQuery ? '?${uri.query}' : ''}' + : null; + } + if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) { + return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}'; + } + return null; + } + + static bool _allowed(String path) => const { + '/assets', + '/market', + '/news', + '/profile', + '/settings', + }.contains(path); +} diff --git a/lib/common/router/route_error_page.dart b/lib/common/router/route_error_page.dart new file mode 100644 index 0000000..e02b663 --- /dev/null +++ b/lib/common/router/route_error_page.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class RouteErrorPage extends StatelessWidget { + const RouteErrorPage({super.key, required this.error}); + final Object? error; + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.explore_off_outlined, size: 40), + const SizedBox(height: 14), + const Text('页面不存在或已失效'), + const SizedBox(height: 12), + FilledButton( + onPressed: () => context.go('/assets'), + child: const Text('回到首页'), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart index 520a0ba..edece73 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -1,13 +1,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../features/mall/application/mall_tab_config.dart'; +import '../../features/web/application/mall_web_host_controller.dart'; +import '../../features/web/presentation/mall_web_host.dart'; import '../theme/app_icons.dart'; import '../widgets/adaptive.dart'; import 'app_router.dart'; -class TabShell extends StatefulWidget { +class TabShell extends ConsumerStatefulWidget { const TabShell({ super.key, required this.navigationShell, @@ -18,16 +23,29 @@ class TabShell extends StatefulWidget { final String location; @override - State createState() => _TabShellState(); + ConsumerState createState() => _TabShellState(); } -class _TabShellState extends State { +class _TabShellState extends ConsumerState { static const double _compactHeaderThreshold = 34; bool _showCompactHeader = false; int get _index => widget.navigationShell.currentIndex; + @override + void initState() { + super.initState(); + ref.listenManual(mallTabConfigProvider, (_, next) { + if (!next.isVisible) return; + final uri = next.attributedUri( + clickId: '', + isDark: Theme.of(context).brightness == Brightness.dark, + ); + if (uri != null) MallWebHostController.instance.preload(uri.toString()); + }); + } + @override void didUpdateWidget(covariant TabShell oldWidget) { super.didUpdateWidget(oldWidget); @@ -36,9 +54,28 @@ class _TabShellState extends State { } } + Future _openMall(MallTabConfig config) async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final actual = await ref + .read(mallTabConfigProvider.notifier) + .ensureLoaded(); + if (!actual.isVisible || !mounted) return; + final clickId = await ref + .read(mallTabConfigProvider.notifier) + .recordClick(); + final uri = actual.attributedUri(clickId: clickId ?? '', isDark: isDark); + if (uri == null) return; + if (actual.opensInApp) { + MallWebHostController.instance.open(uri.toString()); + return; + } + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; + final mall = ref.watch(mallTabConfigProvider); final header = _headerForPath(widget.location); final canPop = GoRouter.of(context).canPop(); @@ -47,111 +84,144 @@ class _TabShellState extends State { resizeToAvoidBottomInset: false, body: Stack( children: [ - Scaffold( - backgroundColor: cs.background, - resizeToAvoidBottomInset: false, - appBar: AppBar( + PopScope( + canPop: !MallWebHostController.instance.state.value.visible, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && + MallWebHostController.instance.state.value.visible) { + MallWebHostController.instance.handleBack(); + } + }, + child: Scaffold( backgroundColor: cs.background, - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - toolbarHeight: 32, - leading: canPop - ? IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.chevron_left, size: 20), - ) - : null, - title: AnimatedOpacity( - opacity: _showCompactHeader ? 1 : 0, - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - header.title, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: cs.foreground, - height: 1.1, - letterSpacing: 0, - ), - ), - if (header.subtitle != null) ...[ - const SizedBox(height: 2), + resizeToAvoidBottomInset: false, + appBar: AppBar( + backgroundColor: cs.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + toolbarHeight: 32, + leading: canPop + ? IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ) + : null, + title: AnimatedOpacity( + opacity: _showCompactHeader ? 1 : 0, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - header.subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, + header.title, textAlign: TextAlign.center, style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: cs.mutedForeground, + fontSize: 15, + fontWeight: FontWeight.w700, + color: cs.foreground, height: 1.1, letterSpacing: 0, ), ), + if (header.subtitle != null) ...[ + const SizedBox(height: 2), + Text( + header.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: cs.mutedForeground, + height: 1.1, + letterSpacing: 0, + ), + ), + ], ], - ], + ), ), ), - ), - body: ColoredBox( - color: cs.background, - child: NotificationListener( - onNotification: _handleScrollNotification, - child: widget.navigationShell, + body: ColoredBox( + color: cs.background, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: widget.navigationShell, + ), ), - ), - bottomNavigationBar: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: cs.background, - border: Border( - top: BorderSide(color: cs.border, width: 1), + bottomNavigationBar: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: cs.background, + border: Border( + top: BorderSide(color: cs.border, width: 1), + ), ), - ), - child: SizedBox( - height: 62, - // 平板:tab 行限宽 600 居中(demo .nav .tabs) - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: Adaptive.tabsMaxWidth, - ), - child: Row( - children: [ - for (var i = 0; i < _labels.length; i++) - Expanded( - child: _TabButton( - icon: i == _index - ? _filledIcons[i] - : _outlineIcons[i], - label: _labels[i], - selected: i == _index, - onTap: () => - widget.navigationShell.goBranch( - i, - initialLocation: i == _index, - ), + child: SizedBox( + height: 62, + // 平板:tab 行限宽 600 居中(demo .nav .tabs) + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: Adaptive.tabsMaxWidth, + ), + child: Row( + children: [ + for (var i = 0; i < _labels.length; i++) + Expanded( + child: _TabButton( + icon: i == _index + ? _filledIcons[i] + : _outlineIcons[i], + label: _labels[i], + selected: i == _index, + onTap: () => + widget.navigationShell.goBranch( + i, + initialLocation: i == _index, + ), + ), ), - ), - ], + ], + ), ), ), ), ), - ), - ], + ], + ), + ), + ), + ), + if (mall.isVisible) + Positioned( + bottom: 39, + left: 0, + right: 0, + child: Center( + child: _MallDockButton( + title: mall.title, + badge: mall.badgeText, + onTap: () => _openMall(mall), + ), + ), + ), + ValueListenableBuilder( + valueListenable: MallWebHostController.instance.state, + builder: (context, state, _) => Positioned.fill( + child: Offstage( + offstage: !state.visible, + child: MallWebHost( + onExit: MallWebHostController.instance.dismiss, + ), ), ), ), @@ -188,6 +258,72 @@ class _TabShellState extends State { } } +class _MallDockButton extends StatelessWidget { + const _MallDockButton({ + required this.title, + required this.badge, + required this.onTap, + }); + final String title; + final String badge; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Semantics( + button: true, + label: title, + child: Material( + color: cs.primary, + borderRadius: BorderRadius.circular(24), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.shopping_bag_outlined, + size: 18, + color: cs.primaryForeground, + ), + const SizedBox(width: 6), + Text( + title, + style: TextStyle( + color: cs.primaryForeground, + fontWeight: FontWeight.w700, + ), + ), + if (badge.isNotEmpty) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + color: cs.background, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badge, + style: TextStyle(fontSize: 10, color: cs.foreground), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + class _TabButton extends StatelessWidget { const _TabButton({ required this.icon, diff --git a/lib/common/services/cache_clear_service.dart b/lib/common/services/cache_clear_service.dart new file mode 100644 index 0000000..99b21fd --- /dev/null +++ b/lib/common/services/cache_clear_service.dart @@ -0,0 +1,38 @@ +import 'dart:io' show Platform; + +import 'package:android_intent_plus/android_intent.dart'; +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 '../storage/storage_service.dart'; + +/// Clears only disposable presentation data. Credentials and user preferences +/// deliberately remain outside this service. +class CacheClearService { + const CacheClearService._(); + + static Future clear() async { + await DefaultCacheManager().emptyCache(); + await SplashAdControllerPlaceholder.clear(); + } + + static bool get requiresSystemSettings => !kIsWeb && Platform.isAndroid; + + static Future openAndroidSystemSettings() async { + final packageName = (await PackageInfo.fromPlatform()).packageName; + await AndroidIntent( + action: 'android.settings.APPLICATION_DETAILS_SETTINGS', + data: 'package:$packageName', + ).launch(); + } +} + +/// CacheClearService is used outside Riverpod; the splash entry itself lives +/// in SharedPreferences and is removed here without touching user data. +abstract final class SplashAdControllerPlaceholder { + static Future clear() async { + await StorageService.to.preferences.remove('ads.splash.entry'); + await StorageService.to.preferences.remove('ads.splash.daily'); + } +} diff --git a/lib/common/services/device_header_service.dart b/lib/common/services/device_header_service.dart index 6b41375..440b5c9 100644 --- a/lib/common/services/device_header_service.dart +++ b/lib/common/services/device_header_service.dart @@ -185,6 +185,12 @@ class DeviceHeaderService { await StorageService.to.setBool(storagePrivacyAgreed, true); } + /// ATT 授权后写入 IDFA;后续请求通过 platformIdentityHeaders 自动带上。 + Future saveIosIdfa(String value) async { + if (value.trim().isEmpty) return; + await StorageService.to.setString(storageIosIdfa, value.trim()); + } + /// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备 /// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。 bool get privacyConsentRequired => requiresAndroidPrivacyConsent( diff --git a/lib/common/storage/storage_keys.dart b/lib/common/storage/storage_keys.dart index afb9710..6976afb 100644 --- a/lib/common/storage/storage_keys.dart +++ b/lib/common/storage/storage_keys.dart @@ -7,6 +7,7 @@ const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts'; const storageJinzhiPurityPresets = 'jinzhi_purity_presets'; const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode'; const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in'; +const storagePushPermissionPrompted = 'push_permission_prompted'; // Previous builds could persist credentials in SharedPreferences. These keys // exist only for one-time migration into secure storage. diff --git a/lib/common/tracking/umeng_service.dart b/lib/common/tracking/umeng_service.dart new file mode 100644 index 0000000..5126676 --- /dev/null +++ b/lib/common/tracking/umeng_service.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:app_tracking_transparency/app_tracking_transparency.dart'; +import 'package:flutter/foundation.dart'; +import 'package:umeng_common_sdk/umeng_common_sdk.dart'; +import 'package:umeng_verify_sdk/umeng_verify_sdk.dart'; + +import '../config/umeng_config.dart'; +import '../services/device_header_service.dart'; +import '../storage/storage_keys.dart'; +import '../storage/storage_service.dart'; + +/// 合规门禁后的友盟公共初始化入口;运营商认证只可由显式登录动作触发。 +abstract final class UmengService { + static Future? _initializing; + static bool _ready = false; + static Future? _oneKeyInitializing; + static bool _oneKeyReady = false; + + static Future initIfAllowed() { + if (_ready || kIsWeb) return Future.value(); + if (Platform.isAndroid && + !StorageService.to.getBool(storagePrivacyAgreed)) { + return Future.value(); + } + return _initializing ??= _init().whenComplete(() => _initializing = null); + } + + static Future _init() async { + await DeviceHeaderService.to.ensureInitialized(); + await UmengCommonSdk.initCommon( + UmengConfig.androidAppKey, + UmengConfig.iosAppKey, + DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default', + UmengConfig.androidPushSecret, + ); + _ready = true; + } + + /// 研听最新合规策略:禁止启动、隐私同意、推送注册等路径预取运营商认证。 + /// 该方法仅允许在用户明确点按登录后由 [OneKeyLoginService] 调用。 + static Future initOneKeyLoginIfAllowed() async { + if (_oneKeyReady || kIsWeb) return; + if (Platform.isAndroid && + !StorageService.to.getBool(storagePrivacyAgreed)) { + return; + } + await initIfAllowed(); + return _oneKeyInitializing ??= _initOneKey().whenComplete( + () => _oneKeyInitializing = null, + ); + } + + static Future _initOneKey() async { + if (Platform.isAndroid) UmengVerifySdk.register_android(); + await UmengVerifySdk.setVerifySDKInfo( + UmengConfig.androidLoginSecret, + UmengConfig.iosLoginSecret, + ); + _oneKeyReady = true; + } + + static Future requestTrackingAuthorizationIfNeeded() async { + if (kIsWeb || !Platform.isIOS) return; + try { + final status = + await AppTrackingTransparency.requestTrackingAuthorization(); + if (status != TrackingStatus.authorized) return; + final idfa = await AppTrackingTransparency.getAdvertisingIdentifier(); + await DeviceHeaderService.to.saveIosIdfa(idfa); + } catch (_) { + // ATT 拒绝/不可用不能阻断启动。 + } + } +} diff --git a/lib/common/widgets/notification_permission_tip.dart b/lib/common/widgets/notification_permission_tip.dart new file mode 100644 index 0000000..ca292d7 --- /dev/null +++ b/lib/common/widgets/notification_permission_tip.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// A short in-app explanation displayed immediately before the system prompt. +class NotificationPermissionTip extends StatelessWidget { + const NotificationPermissionTip({super.key}); + + @override + Widget build(BuildContext context) { + final colors = ShadTheme.of(context).colorScheme; + return IgnorePointer( + child: Align( + alignment: Alignment.topCenter, + child: Material( + type: MaterialType.transparency, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + decoration: BoxDecoration( + color: colors.foreground, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Icon( + Icons.notifications_outlined, + size: 20, + color: colors.background, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '通知权限使用说明', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.background, + ), + ), + const SizedBox(height: 2), + Text( + '用于向你传递重要服务提醒。', + style: TextStyle( + fontSize: 12, + color: colors.background, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +Future showNotificationPermissionTip(BuildContext context) async { + final overlay = Overlay.of(context, rootOverlay: true); + final entry = OverlayEntry(builder: (_) => const NotificationPermissionTip()); + overlay.insert(entry); + await Future.delayed(const Duration(milliseconds: 500)); + entry.remove(); +} diff --git a/lib/features/ads/application/ad_config_foreground_refresher.dart b/lib/features/ads/application/ad_config_foreground_refresher.dart new file mode 100644 index 0000000..a6aa257 --- /dev/null +++ b/lib/features/ads/application/ad_config_foreground_refresher.dart @@ -0,0 +1,32 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../mall/application/mall_tab_config.dart'; +import '../data/ads_api_client.dart'; +import 'splash_ad_controller.dart'; + +final adConfigForegroundRefresherProvider = Provider( + (ref) => AdConfigForegroundRefresher( + () => ref.invalidate(featuredAdsProvider(homeBannerAdPlacement)), + () => ref.read(splashAdControllerProvider).refresh(), + () => ref.read(mallTabConfigProvider.notifier).refresh(), + ), +); + +class AdConfigForegroundRefresher { + AdConfigForegroundRefresher(this._banner, this._splash, this._mall); + final void Function() _banner; + final Future Function() _splash, _mall; + Future? _running; + Future refresh() => + _running ??= _refresh().whenComplete(() => _running = null); + Future _refresh() async { + try { + _banner(); + } catch (_) {} + try { + await _splash(); + } catch (_) {} + try { + await _mall(); + } catch (_) {} + } +} diff --git a/lib/features/ads/application/splash_ad_controller.dart b/lib/features/ads/application/splash_ad_controller.dart new file mode 100644 index 0000000..ca5d0c1 --- /dev/null +++ b/lib/features/ads/application/splash_ad_controller.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:io' show File; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../common/storage/storage_service.dart'; +import '../data/ads_api_client.dart'; + +class SplashAdEntry { + const SplashAdEntry(this.ad, this.dailyCap, this.assetPath, this.cachedAtMs); + final AdCreative ad; + final int dailyCap; + final String? assetPath; + final int cachedAtMs; + bool get valid => + ad.isRenderable && + DateTime.now().millisecondsSinceEpoch < + cachedAtMs + const Duration(hours: 24).inMilliseconds && + (ad.endAtMs <= 0 || ad.endAtMs > DateTime.now().millisecondsSinceEpoch); + Map toJson() => { + 'ad': ad.toJson(), + 'dailyCap': dailyCap, + 'assetPath': assetPath, + 'cachedAtMs': cachedAtMs, + }; + factory SplashAdEntry.fromJson(Map map) => SplashAdEntry( + AdCreative.fromJson(Map.from(map['ad'] as Map)), + (map['dailyCap'] as num?)?.toInt() ?? 0, + map['assetPath'] as String?, + (map['cachedAtMs'] as num?)?.toInt() ?? 0, + ); +} + +class SplashAdController { + SplashAdController(this._api); + final AdsApiClient _api; + static const _entryKey = 'ads.splash.entry'; + static const _dailyKey = 'ads.splash.daily'; + Future coldLaunchDecision() async { + final raw = StorageService.to.preferences.getString(_entryKey); + if (raw == null) return null; + try { + final entry = SplashAdEntry.fromJson( + Map.from(jsonDecode(raw) as Map), + ); + if (!entry.valid || !_underCap(entry) || !await _ready(entry)) { + return null; + } + return entry; + } catch (_) { + await clearCache(); + return null; + } + } + + bool _underCap(SplashAdEntry entry) { + final value = StorageService.to.preferences.getString(_dailyKey); + final today = DateTime.now().toIso8601String().substring(0, 10); + if (value == null) return true; + final map = Map.from(jsonDecode(value) as Map); + return map['date'] != today || + entry.dailyCap <= 0 || + ((map['count'] as num?)?.toInt() ?? 0) < entry.dailyCap; + } + + Future _ready(SplashAdEntry entry) async => + entry.assetPath != null && await File(entry.assetPath!).exists(); + Future markShown(SplashAdEntry entry) async { + final today = DateTime.now().toIso8601String().substring(0, 10); + final raw = StorageService.to.preferences.getString(_dailyKey); + var count = 0; + if (raw != null) { + final m = Map.from(jsonDecode(raw) as Map); + if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0; + } + await StorageService.to.preferences.setString( + _dailyKey, + jsonEncode({'date': today, 'count': count + 1}), + ); + await _report(entry.ad, 'ad_impression'); + } + + Future refresh() async { + try { + final result = await _api.getAdResult(splashAdPlacement); + if (!result.enabled || result.ad == null) { + await _clearEntry(); + return; + } + final file = await DefaultCacheManager().getSingleFile( + result.ad!.imageUrl, + ); + if (!await file.exists()) return; + await StorageService.to.preferences.setString( + _entryKey, + jsonEncode( + SplashAdEntry( + result.ad!, + result.dailyCap, + file.path, + DateTime.now().millisecondsSinceEpoch, + ).toJson(), + ), + ); + } catch (_) {} + } + + Future _report(AdCreative ad, String event, {String? clickId}) async { + try { + await _api.report(ad, event, clickId: clickId); + } catch (_) {} + } + + Future skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip'); + Future clearCache() async { + await _clearEntry(); + await StorageService.to.preferences.remove(_dailyKey); + } + + Future _clearEntry() async { + await StorageService.to.preferences.remove(_entryKey); + } +} + +final splashAdControllerProvider = Provider( + (ref) => SplashAdController(ref.watch(adsApiClientProvider)), +); diff --git a/lib/features/ads/application/startup_ad_service.dart b/lib/features/ads/application/startup_ad_service.dart new file mode 100644 index 0000000..f77a779 --- /dev/null +++ b/lib/features/ads/application/startup_ad_service.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../common/router/app_router.dart'; +import '../data/ads_api_client.dart'; +import '../presentation/splash_ad_dialog.dart'; +import 'splash_ad_controller.dart'; + +final startupAdResolvedProvider = StateProvider((_) => false); + +abstract final class StartupAdService { + static Future? _running; + static Future showIfAvailable(BuildContext context, WidgetRef ref) => + _running ??= _show(context, ref); + static Future _show(BuildContext context, WidgetRef ref) async { + final controller = ref.read(splashAdControllerProvider); + try { + final entry = await controller.coldLaunchDecision(); + if (entry != null && context.mounted) { + final result = await showGeneralDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + barrierColor: Colors.black, + pageBuilder: (ctx, _, _) => SplashAdDialog( + entry: entry, + onShown: () => unawaited(controller.markShown(entry)), + onClick: () async { + final id = ref.read(adsApiClientProvider).newClickId(); + final uri = entry.ad.clickUri(id); + if (uri != null) { + unawaited( + ref + .read(adsApiClientProvider) + .report(entry.ad, 'ad_click', clickId: id), + ); + if (entry.ad.openMode == AdOpenMode.inApp) { + context.push( + '${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}', + ); + } else { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + }, + ), + ); + if (result == SplashAdResult.skipped) unawaited(controller.skip(entry)); + } + } finally { + ref.read(startupAdResolvedProvider.notifier).state = true; + unawaited(controller.refresh()); + } + } +} diff --git a/lib/features/ads/data/ads_api_client.dart b/lib/features/ads/data/ads_api_client.dart new file mode 100644 index 0000000..e6bd958 --- /dev/null +++ b/lib/features/ads/data/ads_api_client.dart @@ -0,0 +1,294 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../common/network/dio_provider.dart'; + +const splashAdPlacement = 'splash'; +const homeBannerAdPlacement = 'home_banner'; + +enum AdMediaType { image, gif, video, unsupported } + +enum AdOpenMode { external, inApp } + +class AdCreative { + const AdCreative({ + required this.adId, + required this.placement, + required this.targetApp, + required this.title, + required this.imageUrl, + required this.mediaType, + required this.h5Url, + required this.trackingId, + required this.countdownSeconds, + required this.endAtMs, + required this.trackedUrl, + required this.openMode, + }); + final String adId, + placement, + targetApp, + title, + imageUrl, + h5Url, + trackingId, + trackedUrl; + final AdMediaType mediaType; + final int countdownSeconds, endAtMs; + final AdOpenMode openMode; + Uri? get mediaUri => _http(imageUrl); + Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url); + bool get isRenderable => + adId.isNotEmpty && + mediaUri != null && + targetUri != null && + mediaType != AdMediaType.unsupported && + (endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch); + Uri? clickUri(String clickId) { + final uri = targetUri; + if (uri == null) return null; + return uri.replace( + queryParameters: { + ...uri.queryParameters, + 'ad_id': adId, + 'placement': placement, + 'tracking_id': trackingId, + 'click_id': clickId, + 'target_app': targetApp, + 'hook_type': placement, + 'ts': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + } + + Map toJson() => { + 'adId': adId, + 'placement': placement, + 'targetApp': targetApp, + 'title': title, + 'imageUrl': imageUrl, + 'mediaType': mediaType.index, + 'h5Url': h5Url, + 'trackingId': trackingId, + 'countdownSeconds': countdownSeconds, + 'endAtMs': endAtMs, + 'trackedUrl': trackedUrl, + 'openMode': openMode.index, + }; + factory AdCreative.fromJson(Map value) => AdCreative( + adId: '${value['adId'] ?? ''}', + placement: '${value['placement'] ?? ''}', + targetApp: '${value['targetApp'] ?? ''}', + title: '${value['title'] ?? ''}', + imageUrl: '${value['imageUrl'] ?? ''}', + mediaType: + AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp( + 0, + AdMediaType.values.length - 1, + ) ?? + AdMediaType.unsupported.index], + h5Url: '${value['h5Url'] ?? ''}', + trackingId: '${value['trackingId'] ?? ''}', + countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0, + endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0, + trackedUrl: '${value['trackedUrl'] ?? ''}', + openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index + ? AdOpenMode.inApp + : AdOpenMode.external, + ); + factory AdCreative.fromProto(ProtoMessage fields) => AdCreative( + adId: fields.string(1), + placement: fields.string(2), + targetApp: fields.string(3), + title: fields.string(4), + imageUrl: fields.string(6), + mediaType: switch (fields.integer(7)) { + 1 => AdMediaType.image, + 2 => AdMediaType.gif, + 3 => AdMediaType.video, + _ => AdMediaType.unsupported, + }, + h5Url: fields.string(9), + trackingId: fields.string(10), + countdownSeconds: fields.integer(11), + endAtMs: fields.integer(13), + trackedUrl: fields.string(14), + openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external, + ); +} + +class GetAdResult { + const GetAdResult(this.enabled, this.ad, this.dailyCap); + final bool enabled; + final AdCreative? ad; + final int dailyCap; +} + +class FeaturedAdsState { + const FeaturedAdsState(this.enabled, this.ads, {this.trackingId}); + final bool enabled; + final List ads; + final String? trackingId; + bool get hasAds => enabled && ads.isNotEmpty; +} + +class AdsApiClient { + AdsApiClient(this._dio); + final Dio _dio; + Future getAdResult(String placement) async { + final response = await _dio.get>( + '/api/v1/ads', + queryParameters: {'placement': placement}, + options: protobufOptions(), + ); + final root = ProtoMessage(response.data ?? const []); + final creative = root.message(2); + final ad = AdCreative.fromProto(creative); + return GetAdResult( + root.integer(1) != 0, + ad.isRenderable ? ad : null, + root.integer(3), + ); + } + + Future listAds(String placement) async { + final response = await _dio.get>( + '/api/v1/ads/list', + queryParameters: {'placement': placement}, + options: protobufOptions(), + ); + final root = ProtoMessage(response.data ?? const []); + final ads = root + .messages(2) + .map(AdCreative.fromProto) + .where((ad) => ad.isRenderable) + .toList(growable: false); + return FeaturedAdsState( + root.integer(1) != 0, + ads, + trackingId: root.string(3).isEmpty ? null : root.string(3), + ); + } + + Future report( + AdCreative ad, + String event, { + String? clickId, + String? trackingId, + }) => _dio.post>( + '/api/v1/ads/events', + data: _Writer() + .string(1, event) + .string(2, ad.placement) + .string(3, ad.adId) + .string(4, trackingId ?? ad.trackingId) + .string(5, clickId ?? '') + .integer(6, DateTime.now().millisecondsSinceEpoch) + .toBytes(), + options: protobufOptions(), + ); + String newClickId() => const Uuid().v4(); +} + +final adsApiClientProvider = Provider( + (ref) => AdsApiClient(ref.watch(dioProvider)), +); +final featuredAdsProvider = FutureProvider.autoDispose + .family( + (ref, placement) => ref.watch(adsApiClientProvider).listAds(placement), + ); + +Uri? _http(String raw) { + final uri = Uri.tryParse(raw.trim()); + return uri != null && + uri.host.isNotEmpty && + (uri.scheme == 'https' || uri.scheme == 'http') + ? uri + : null; +} + +class ProtoMessage { + ProtoMessage(this.bytes); + final List bytes; + Map>? _cache; + Map> get _fields => _cache ??= _read(); + int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0; + String string(int tag) { + final value = _fields[tag]?.lastOrNull; + return value is List ? String.fromCharCodes(value) : ''; + } + + ProtoMessage message(int tag) { + final value = _fields[tag]?.lastOrNull; + return ProtoMessage(value is List ? value : const []); + } + + List messages(int tag) => (_fields[tag] ?? const []) + .whereType>() + .map(ProtoMessage.new) + .toList(); + Map> _read() { + final out = >{}; + var i = 0; + int v() { + var n = 0, s = 0; + while (i < bytes.length) { + final b = bytes[i++]; + n |= (b & 127) << s; + if (b < 128) return n; + s += 7; + } + throw const FormatException('Invalid protobuf'); + } + + while (i < bytes.length) { + final key = v(), tag = key >> 3; + Object value; + if ((key & 7) == 0) { + value = v(); + } else if ((key & 7) == 2) { + final l = v(), end = i + l; + if (end > bytes.length) throw const FormatException('Invalid protobuf'); + value = bytes.sublist(i, end); + i = end; + } else { + throw const FormatException('Unsupported protobuf'); + } + (out[tag] ??= []).add(value); + } + return out; + } +} + +extension on List { + Object? get lastOrNull => isEmpty ? null : last; +} + +class _Writer { + final b = BytesBuilder(copy: false); + _Writer string(int t, String value) { + final d = value.codeUnits; + v((t << 3) | 2); + v(d.length); + b.add(d); + return this; + } + + _Writer integer(int t, int value) { + v(t << 3); + v(value); + return this; + } + + Uint8List toBytes() => b.toBytes(); + void v(int n) { + while (n > 127) { + b.addByte((n & 127) | 128); + n >>= 7; + } + b.addByte(n); + } +} diff --git a/lib/features/ads/presentation/ad_media.dart b/lib/features/ads/presentation/ad_media.dart new file mode 100644 index 0000000..fcdda7c --- /dev/null +++ b/lib/features/ads/presentation/ad_media.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:io' show File; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../data/ads_api_client.dart'; + +/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。 +class AdMedia extends StatefulWidget { + const AdMedia({ + super.key, + required this.ad, + this.assetPath, + this.fit = BoxFit.cover, + }); + final AdCreative ad; + final String? assetPath; + final BoxFit fit; + @override + State createState() => _AdMediaState(); +} + +class _AdMediaState extends State { + VideoPlayerController? player; + bool ready = false, failed = false; + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant AdMedia old) { + super.didUpdateWidget(old); + if (old.ad.adId != widget.ad.adId || old.assetPath != widget.assetPath) { + unawaited(player?.dispose() ?? Future.value()); + player = null; + ready = false; + failed = false; + _load(); + } + } + + void _load() { + if (widget.ad.mediaType != AdMediaType.video) return; + final uri = widget.ad.mediaUri; + if (uri == null) return; + final path = widget.assetPath; + final controller = !kIsWeb && path != null && path.isNotEmpty + ? VideoPlayerController.file(File(path)) + : VideoPlayerController.networkUrl(uri); + player = controller; + unawaited( + controller + .initialize() + .then((_) async { + await controller.setLooping(true); + await controller.setVolume(0); + await controller.play(); + if (mounted) setState(() => ready = true); + }) + .catchError((_) { + if (mounted) setState(() => failed = true); + }), + ); + } + + @override + void dispose() { + player?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.ad.mediaType != AdMediaType.video) { + final path = widget.assetPath; + if (!kIsWeb && path != null && path.isNotEmpty) { + return Image.file( + File(path), + fit: widget.fit, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, _, _) => _error(), + ); + } + return Image.network( + widget.ad.imageUrl, + fit: widget.fit, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, _, _) => _error(), + ); + } + final c = player; + if (failed) return _error(); + if (c == null || !ready) { + return const ColoredBox( + color: Color(0xFF161616), + child: Center(child: CircularProgressIndicator()), + ); + } + return FittedBox( + fit: widget.fit, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: c.value.size.width, + height: c.value.size.height, + child: VideoPlayer(c), + ), + ); + } + + Widget _error() => const ColoredBox( + color: Color(0xFF161616), + child: Center( + child: Icon(Icons.broken_image_outlined, color: Colors.white54), + ), + ); +} diff --git a/lib/features/ads/presentation/home_ad_banner.dart b/lib/features/ads/presentation/home_ad_banner.dart new file mode 100644 index 0000000..58edc66 --- /dev/null +++ b/lib/features/ads/presentation/home_ad_banner.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../common/router/app_router.dart'; +import '../data/ads_api_client.dart'; +import 'ad_media.dart'; + +class HomeAdBanner extends ConsumerStatefulWidget { + const HomeAdBanner({super.key}); + @override + ConsumerState createState() => _HomeAdBannerState(); +} + +class _HomeAdBannerState extends ConsumerState { + final pages = PageController(); + final impressed = {}; + Timer? timer; + int index = 0; + int _length = 0; + @override + void dispose() { + timer?.cancel(); + pages.dispose(); + super.dispose(); + } + + void _sync(List ads) { + if (_length == ads.length) return; + _length = ads.length; + timer?.cancel(); + if (ads.length > 1) { + timer = Timer.periodic(const Duration(seconds: 4), (_) { + if (pages.hasClients) { + pages.nextPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOut, + ); + } + }); + } + } + + void _impression(AdCreative ad, String? id) { + if (!impressed.add(ad.adId)) return; + unawaited( + ref + .read(adsApiClientProvider) + .report(ad, 'ad_impression', trackingId: id), + ); + } + + Future _open(AdCreative ad, String? tracking) async { + final client = ref.read(adsApiClientProvider); + final id = client.newClickId(); + final uri = ad.clickUri(id); + if (uri == null) return; + unawaited(client.report(ad, 'ad_click', clickId: id, trackingId: tracking)); + if (!mounted) return; + if (ad.openMode == AdOpenMode.inApp) { + context.push( + '${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}', + ); + } else { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final async = ref.watch(featuredAdsProvider(homeBannerAdPlacement)); + return async.maybeWhen( + data: (value) { + if (!value.hasAds) return const SizedBox.shrink(); + final ads = value.ads; + _sync(ads); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _impression(ads[index.clamp(0, ads.length - 1)], value.trackingId); + } + }); + return Column( + children: [ + AspectRatio( + aspectRatio: 750 / 200, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: PageView.builder( + controller: pages, + onPageChanged: (page) { + setState(() => index = page % ads.length); + _impression(ads[index], value.trackingId); + }, + itemBuilder: (_, page) { + final ad = ads[page % ads.length]; + return GestureDetector( + onTap: () => _open(ad, value.trackingId), + child: AdMedia(ad: ad), + ); + }, + ), + ), + ), + if (ads.length > 1) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + ads.length, + (i) => Container( + width: i == index ? 14 : 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: i == index + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ), + ], + ); + }, + orElse: () => const SizedBox.shrink(), + ); + } +} diff --git a/lib/features/ads/presentation/splash_ad_dialog.dart b/lib/features/ads/presentation/splash_ad_dialog.dart new file mode 100644 index 0000000..12bee71 --- /dev/null +++ b/lib/features/ads/presentation/splash_ad_dialog.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +import '../application/splash_ad_controller.dart'; +import 'ad_media.dart'; + +enum SplashAdResult { clicked, skipped, completed } + +class SplashAdDialog extends StatefulWidget { + const SplashAdDialog({ + super.key, + required this.entry, + required this.onClick, + required this.onShown, + }); + final SplashAdEntry entry; + final Future Function() onClick; + final VoidCallback onShown; + @override + State createState() => _SplashAdDialogState(); +} + +class _SplashAdDialogState extends State { + Timer? timer; + late int remain; + bool opening = false; + @override + void initState() { + super.initState(); + remain = widget.entry.ad.countdownSeconds.clamp(1, 5); + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onShown(); + }); + timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (remain <= 1) { + Navigator.of(context).pop(SplashAdResult.completed); + } else if (mounted) { + setState(() => remain--); + } + }); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + Future open() async { + if (opening) return; + opening = true; + timer?.cancel(); + await widget.onClick(); + if (mounted) Navigator.of(context).pop(SplashAdResult.clicked); + } + + @override + Widget build(BuildContext context) => Material( + color: Colors.black, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: open, + child: AdMedia( + ad: widget.entry.ad, + assetPath: widget.entry.assetPath, + ), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 16, + right: 18, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped), + child: Text('跳过 ${remain}s'), + ), + ), + ], + ), + ); +} diff --git a/lib/features/assets/presentation/assets_page.dart b/lib/features/assets/presentation/assets_page.dart index ff2226c..8301364 100644 --- a/lib/features/assets/presentation/assets_page.dart +++ b/lib/features/assets/presentation/assets_page.dart @@ -1,16 +1,80 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../common/domain/money.dart'; +import '../../../common/push/notification_permission.dart'; +import '../../../common/push/umeng_push_service.dart'; import '../../../common/widgets/adaptive.dart'; +import '../../ads/application/startup_ad_service.dart'; +import '../../ads/presentation/home_ad_banner.dart'; +import '../../settings/application/update_controller.dart'; +import '../../settings/application/update_service.dart'; import '../application/asset_portfolio_controller.dart'; -class AssetsPage extends ConsumerWidget { +class AssetsPage extends ConsumerStatefulWidget { const AssetsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AssetsPageState(); +} + +class _AssetsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_completeColdLaunch()); + }); + } + + Future _completeColdLaunch() async { + if (!mounted) return; + await StartupAdService.showIfAvailable(context, ref); + if (!mounted) return; + await _checkColdLaunchUpgrade(); + if (mounted) await _requestNotificationPermission(); + } + + Future _requestNotificationPermission() async { + if (!mounted) return; + if (defaultTargetPlatform == TargetPlatform.iOS) { + // 研听的 U-Push register 会请求 APNs;这里再以系统 API 兜底,避免 + // 旧安装已跳过欢迎页时永远不触发首次通知授权。iOS 不展示顶部说明。 + await NotificationPermission.requestFromSettings(); + } else { + await NotificationPermission.requestIfNeeded(context); + } + await UmengPushService.refreshDeviceToken(); + } + + Future _checkColdLaunchUpgrade() async { + if (!mounted || + !UpdateService.isSupported || + !ref.read(updateSessionGateProvider).takeColdLaunchSlot()) { + return; + } + try { + final upgrade = await ref + .read(updateControllerProvider.notifier) + .checkUpgrade(); + if (!mounted) return; + await UpdateService.showIfNeeded( + context, + ref, + upgrade, + source: UpgradeCheckSource.automatic, + ); + } catch (_) { + // Automatic update checks must never block the assets home page. + } + } + + @override + Widget build(BuildContext context) { final summary = ref.watch(assetPortfolioControllerProvider); final cs = ShadTheme.of(context).colorScheme; return SafeArea( @@ -71,6 +135,8 @@ class AssetsPage extends ConsumerWidget { ], ), const SizedBox(height: 12), + const HomeAdBanner(), + const SizedBox(height: 12), _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/auth/application/auth_controller.dart b/lib/features/auth/application/auth_controller.dart index cd0a6c9..35328e8 100644 --- a/lib/features/auth/application/auth_controller.dart +++ b/lib/features/auth/application/auth_controller.dart @@ -1,20 +1,25 @@ +import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../common/network/auth_interceptor.dart'; import '../../../common/storage/storage_keys.dart'; import '../../../common/storage/storage_service.dart'; +import '../../../common/network/dio_provider.dart'; import '../data/auth_models.dart'; +import '../data/one_key_login_api.dart'; final authProvider = StateNotifierProvider>( - (ref) => AuthController(), + (ref) => AuthController(ref.read(dioProvider)), ); class AuthController extends StateNotifier> { - AuthController() : super(const AsyncLoading()) { + AuthController(this._dio) : super(const AsyncLoading()) { _bootstrap(); } + final Dio _dio; + Future _bootstrap() async { final token = await loadJwt(); if (token == null || token.isEmpty) { @@ -54,6 +59,24 @@ class AuthController extends StateNotifier> { ); } + /// Test environment reuses Yanting's one-key verification endpoint until + /// 金值 has its own account contract. + Future verifyOneKeyLogin(String loginToken) async { + final reply = await OneKeyLoginApi(_dio).verify(loginToken); + await saveJwt(reply.token); + await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone); + state = AsyncData( + LoggedInAuthState( + user: JinzhiUser( + phone: reply.phone, + nickname: '金值用户', + createdAt: DateTime.now(), + ), + token: reply.token, + ), + ); + } + Future logout() async { await clearJwt(); await StorageService.to.setString(storageJinzhiMockLoggedIn, ''); diff --git a/lib/features/auth/application/login_launcher.dart b/lib/features/auth/application/login_launcher.dart new file mode 100644 index 0000000..28fb4a0 --- /dev/null +++ b/lib/features/auth/application/login_launcher.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../common/router/app_router.dart'; +import 'auth_controller.dart'; +import 'one_key_login_service.dart'; + +/// Shared login entry: launch the carrier-owned authorization page first and +/// use the Flutter SMS page only as a deliberate fallback. +abstract final class LoginLauncher { + static final _service = OneKeyLoginService(); + static bool _starting = false; + + static Future start( + BuildContext context, + WidgetRef ref, { + String? next, + }) async { + if (_starting) return; + _starting = true; + final dismissLoading = _showLoading(context); + try { + final observer = _OneKeyLoginLifecycleObserver(_service); + WidgetsBinding.instance.addObserver(observer); + late final OneKeyLoginOutcome outcome; + try { + outcome = await _service.start( + context: context, + verifyToken: ref.read(authProvider.notifier).verifyOneKeyLogin, + onNativeRequestDispatched: dismissLoading, + onAuthPageOpened: dismissLoading, + ); + } catch (_) { + outcome = OneKeyLoginOutcome.fallback; + } finally { + WidgetsBinding.instance.removeObserver(observer); + } + if (!context.mounted) return; + if (outcome == OneKeyLoginOutcome.success) { + context.go(next?.isNotEmpty == true ? next! : AppRoutes.profile); + return; + } + if (outcome == OneKeyLoginOutcome.fallback || + outcome == OneKeyLoginOutcome.unsupported) { + final target = next == null || next.isEmpty + ? AppRoutes.login + : '${AppRoutes.login}?next=${Uri.encodeComponent(next)}'; + if (GoRouterState.of(context).matchedLocation != AppRoutes.login) { + await context.push(target); + } + } + } finally { + dismissLoading(); + _starting = false; + } + } + + static VoidCallback _showLoading(BuildContext context) { + var visible = true; + unawaited( + showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (_) => const PopScope( + canPop: false, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + return () { + if (visible && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + visible = false; + }; + } +} + +class _OneKeyLoginLifecycleObserver with WidgetsBindingObserver { + _OneKeyLoginLifecycleObserver(this._service); + final OneKeyLoginService _service; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _service.onAppResumed(); + } +} diff --git a/lib/features/auth/application/one_key_login_service.dart b/lib/features/auth/application/one_key_login_service.dart new file mode 100644 index 0000000..5a43d21 --- /dev/null +++ b/lib/features/auth/application/one_key_login_service.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:umeng_verify_sdk/umeng_verify_sdk.dart'; + +import '../../../common/config/app_h5_urls.dart'; +import '../../../common/tracking/umeng_service.dart'; + +enum OneKeyLoginOutcome { success, fallback, cancelled, unsupported, busy } + +/// Runs only after an explicit user login action. Do not add an availability +/// probe or `accelerateLoginPage` outside this class: Xiaomi rejects that as +/// background carrier-auth prefetch. +class OneKeyLoginService { + Future? _operation; + bool _authPageOpened = false; + bool _terminal = false; + Timer? _resumeCancellation; + void Function(OneKeyLoginOutcome)? _completeCurrent; + + Future start({ + required BuildContext context, + required Future Function(String token) verifyToken, + VoidCallback? onNativeRequestDispatched, + VoidCallback? onAuthPageOpened, + }) { + if (_operation != null) return Future.value(OneKeyLoginOutcome.busy); + _terminal = false; + _authPageOpened = false; + final operation = _start( + context: context, + verifyToken: verifyToken, + onNativeRequestDispatched: onNativeRequestDispatched, + onAuthPageOpened: onAuthPageOpened, + ); + _operation = operation; + return operation.whenComplete(() { + _operation = null; + _completeCurrent = null; + }); + } + + Future _start({ + required BuildContext context, + required Future Function(String token) verifyToken, + VoidCallback? onNativeRequestDispatched, + VoidCallback? onAuthPageOpened, + }) async { + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { + return OneKeyLoginOutcome.unsupported; + } + final uiConfig = _uiConfig(context); + await UmengService.initOneKeyLoginIfAllowed(); + final completer = Completer(); + + void complete(OneKeyLoginOutcome outcome) { + if (_terminal) return; + _terminal = true; + _resumeCancellation?.cancel(); + _resumeCancellation = null; + _clearCallbacks(); + completer.complete(outcome); + } + + _completeCurrent = complete; + + void handleUiResult(Object? result) { + final code = _code(result); + if (code == '700000') complete(OneKeyLoginOutcome.cancelled); + if (code == '700001') complete(OneKeyLoginOutcome.fallback); + } + + void handleTokenResult(Object? result) { + final code = _code(result); + // iOS 700002 is only the native button click; token follows later. + if (Platform.isIOS && code == '700002') return; + // 600024 is Android environment availability, not a login result. + if (code == '600024') return; + if (code == '600001') { + if (!_authPageOpened) { + _authPageOpened = true; + onAuthPageOpened?.call(); + } + return; + } + if (code == '700000') { + complete(OneKeyLoginOutcome.cancelled); + return; + } + if (code == '700001') { + complete(OneKeyLoginOutcome.fallback); + return; + } + if (code != '600000' || result is! Map) { + complete(OneKeyLoginOutcome.fallback); + return; + } + final token = result['token']?.toString(); + if (token == null || token.isEmpty) { + complete(OneKeyLoginOutcome.fallback); + return; + } + unawaited(() async { + try { + await verifyToken(token); + complete(OneKeyLoginOutcome.success); + } catch (_) { + complete(OneKeyLoginOutcome.fallback); + } finally { + _closeNativePage(); + } + }()); + } + + if (Platform.isIOS) { + UmengVerifySdk.getLoginTokenCallback(handleTokenResult); + UmengVerifySdk.getWidgetEventCallback((_) {}); + } else { + UmengVerifySdk.setTokenResultCallback_android(handleTokenResult); + UmengVerifySdk.setUIClickCallback_android(handleUiResult); + } + try { + final request = UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig); + onNativeRequestDispatched?.call(); + unawaited( + request.catchError((Object _) { + complete(OneKeyLoginOutcome.unsupported); + }), + ); + return await completer.future.timeout( + const Duration(seconds: 20), + onTimeout: () => OneKeyLoginOutcome.fallback, + ); + } finally { + _clearCallbacks(); + } + } + + void onAppResumed() { + if (!Platform.isAndroid || + !_authPageOpened || + _terminal || + _resumeCancellation != null) { + return; + } + _resumeCancellation = Timer(const Duration(milliseconds: 500), () { + _resumeCancellation = null; + if (_terminal) return; + _completeCurrent?.call(OneKeyLoginOutcome.cancelled); + _closeNativePage(); + }); + } + + UMCustomModel _uiConfig(BuildContext context) { + const ink = Color(0xFF202124); + const muted = Color(0xFF8A8F98); + const background = Color(0xFFFFFFFF); + final model = UMCustomModel() + ..logoIsHidden = true + ..numberColor = ink.toARGB32() + ..numberFont = 30 + ..navTitle = ['', Colors.transparent.toARGB32(), 0] + ..navColor = background.toARGB32() + ..backgroundColor_ios = background.toARGB32() + ..sloganText = ['', background.toARGB32(), 0] + ..privacyPreText = '我已阅读并同意' + ..privacyOne = ['《隐私政策》', AppH5Urls.privacyUrl] + ..privacyTwo = ['《用户协议》', AppH5Urls.userProtocolUrl] + ..privacyColors = [muted.toARGB32(), ink.toARGB32()] + ..changeBtnTitle = ['其他手机号登录', ink.toARGB32(), 16] + ..loginBtnText = ['本机号码一键登录', Colors.white.toARGB32(), 16] + ..privacyFont = 12 + ..checkBoxIsChecked = false; + if (Platform.isAndroid) { + model.numberFrame = [-1, 156, -1, -1]; + model.loginBtnFrame = [-1, 283, -1, -1, 52]; + model.changeBtnFrame = [368, -1, -1, -1]; + model.privacyFrame = [-1, 427, -1, -1]; + } else { + final title = UMCustomWidget('title', UMCustomWidgetType.textView) + ..title = '金值' + ..titleFont = 26 + ..titleColor = ink.toARGB32() + ..width = MediaQuery.sizeOf(context).width.toInt() + ..height = 54 + ..top = 26 + ..textAlignment = UMCustomWidgetTextAlignmentType.center + ..isClickEnable = false + ..rootViewId = UMRootViewId.body; + model.customWidget = [title.toJsonMap()]; + model.privacyFont = 13; + model.checkBoxWH = 20; + model.checkBoxIsChecked = true; + } + return model; + } + + String _code(Object? result) => result is Map + ? (result['code'] ?? result['resultCode'] ?? '').toString() + : ''; + + void _clearCallbacks() { + UmengVerifySdk.getLoginTokenCallback(null); + UmengVerifySdk.getWidgetEventCallback(null); + UmengVerifySdk.setTokenResultCallback_android(null); + UmengVerifySdk.setUIClickCallback_android(null); + } + + void _closeNativePage() { + if (Platform.isIOS) { + unawaited(UmengVerifySdk.cancelLoginVCAnimated(true)); + } else { + UmengVerifySdk.quitLoginPage_android(); + } + } +} diff --git a/lib/features/auth/data/one_key_login_api.dart b/lib/features/auth/data/one_key_login_api.dart new file mode 100644 index 0000000..698ddce --- /dev/null +++ b/lib/features/auth/data/one_key_login_api.dart @@ -0,0 +1,92 @@ +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 verify(String loginToken) async { + final bytes = utf8.encode(loginToken); + final request = Uint8List.fromList([ + 0x0A, + ..._varint(bytes.length), + ...bytes, + ]); + final response = await _dio.post>( + '/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> _fields(Uint8List data) { + final result = >{}; + 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 _varint(int value) { + final result = []; + 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; +} diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index 4243850..a67b790 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -16,6 +16,7 @@ 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}); @@ -31,6 +32,7 @@ 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(null); final timer = useRef(null); @@ -163,6 +165,9 @@ class LoginPage extends HookConsumerWidget { ); } + Future launchOneKey() => + LoginLauncher.start(context, ref, next: next); + void onSendTap() { if (sending.value || countdown.value > 0) return; if (phoneDigits().length != 11) { @@ -222,6 +227,8 @@ class LoginPage extends HookConsumerWidget { agreed: agreed.value, onToggleAgreed: () => agreed.value = !agreed.value, onSend: onSendTap, + oneKeyLoading: oneKeyLoading.value, + onOneKey: launchOneKey, ), ), ), @@ -240,6 +247,8 @@ class _PhoneStep extends StatelessWidget { required this.onToggleAgreed, required this.error, required this.onSend, + required this.oneKeyLoading, + required this.onOneKey, }); final ShadColorScheme cs; @@ -251,6 +260,8 @@ class _PhoneStep extends StatelessWidget { final VoidCallback onToggleAgreed; final String? error; final VoidCallback onSend; + final bool oneKeyLoading; + final VoidCallback onOneKey; @override Widget build(BuildContext context) { @@ -295,6 +306,13 @@ 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), ], diff --git a/lib/features/mall/application/mall_tab_config.dart b/lib/features/mall/application/mall_tab_config.dart new file mode 100644 index 0000000..2475af7 --- /dev/null +++ b/lib/features/mall/application/mall_tab_config.dart @@ -0,0 +1,236 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../common/network/dio_provider.dart'; + +/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。 +const mallSourceApp = 'jinzhi'; + +class MallTabConfig { + const MallTabConfig({ + required this.enabled, + required this.url, + this.title = '商城', + this.adId = '', + this.trackingId = '', + this.openMode = 'in_app', + this.badgeText = '', + }); + + const MallTabConfig.disabled() : this(enabled: false, url: ''); + + final bool enabled; + final String url; + final String title; + final String adId; + final String trackingId; + final String openMode; + final String badgeText; + + bool get isVisible => enabled && initialUri != null; + bool get opensInApp => openMode == 'in_app'; + + Uri? get initialUri => attributedUri(clickId: ''); + + Uri? attributedUri({required String clickId, bool isDark = false}) { + final uri = Uri.tryParse(url.trim()); + if (!_isAllowed(uri)) return null; + return uri!.replace( + queryParameters: { + ...uri.queryParameters, + 'from': uri.queryParameters['from']?.trim().isNotEmpty == true + ? uri.queryParameters['from']! + : mallSourceApp, + 'source_app': mallSourceApp, + 'source_entry': 'mall_tab', + 'placement': 'mall_tab', + 'hook_type': 'mall_tab', + 'target_app': 'mall', + if (adId.isNotEmpty) 'ad_id': adId, + if (trackingId.isNotEmpty) 'tracking_id': trackingId, + if (clickId.isNotEmpty) 'click_id': clickId, + if (clickId.isNotEmpty) + 'ts': DateTime.now().millisecondsSinceEpoch.toString(), + 'isNight': isDark ? '1' : '0', + }, + ); + } + + static bool _isAllowed(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); + } +} + +class MallTabConfigController extends StateNotifier { + MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) { + refresh(); + } + + final Dio _dio; + Future? _loading; + + Future ensureLoaded() async { + if (state.isVisible) return state; + await (_loading ??= refresh().whenComplete(() => _loading = null)); + return state; + } + + Future refresh() async { + try { + final response = await _dio.get>( + '/api/v1/ads', + 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'); + } catch (_) { + state = const MallTabConfig.disabled(); + } + } + + Future recordClick() async { + if (!state.isVisible) return null; + final clickId = const Uuid().v4(); + try { + await _report('ad_click', clickId: clickId); + } catch (_) { + // 归因上报失败不能阻断商城入口。 + } + return clickId; + } + + Future _report(String event, {String clickId = ''}) => + _dio.post>( + '/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(), + options: protobufOptions(), + ); +} + +final mallTabConfigProvider = + StateNotifierProvider( + (ref) => MallTabConfigController(ref.watch(dioProvider)), + ); + +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', + _ => 'external', +}; + +class _ProtoMessage { + _ProtoMessage(this._bytes); + final List _bytes; + + int integer(int wanted) => + _fields()[wanted] is int ? _fields()[wanted]! as int : 0; + String string(int wanted) => _fields()[wanted] is List + ? String.fromCharCodes(_fields()[wanted]! as List) + : ''; + _ProtoMessage message(int wanted) => _ProtoMessage( + _fields()[wanted] is List ? _fields()[wanted]! as List : const [], + ); + + Map _fields() { + final values = {}; + 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); + } +} diff --git a/lib/features/profile/presentation/profile_page.dart b/lib/features/profile/presentation/profile_page.dart index 0cceb65..5bf049f 100644 --- a/lib/features/profile/presentation/profile_page.dart +++ b/lib/features/profile/presentation/profile_page.dart @@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../common/router/app_router.dart'; import '../../../common/widgets/adaptive.dart'; import '../../auth/application/auth_controller.dart'; +import '../../auth/application/login_launcher.dart'; import '../../auth/data/auth_models.dart'; import '../../profile/application/profile_settings_controller.dart'; @@ -58,10 +59,6 @@ class ProfilePage extends ConsumerWidget { ], ), ), - FilledButton( - onPressed: () => context.push(AppRoutes.settings), - child: const Text('设置'), - ), ], ), ), @@ -103,9 +100,7 @@ class ProfilePage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FilledButton.tonal( - onPressed: loggedIn - ? () => context.push(AppRoutes.login) - : () => context.push(AppRoutes.login), + onPressed: () => LoginLauncher.start(context, ref), child: Text(loggedIn ? '切换账号' : '去登录'), ), const SizedBox(height: 10), diff --git a/lib/features/settings/application/update_controller.dart b/lib/features/settings/application/update_controller.dart new file mode 100644 index 0000000..3564431 --- /dev/null +++ b/lib/features/settings/application/update_controller.dart @@ -0,0 +1,34 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../data/update_api_client.dart'; + +class UpdateSessionGate { + var _used = false; + bool takeColdLaunchSlot() => _used ? false : (_used = true); +} + +final updateSessionGateProvider = Provider( + (_) => UpdateSessionGate(), +); + +class UpdateController extends StateNotifier> { + UpdateController(this._read) : super(const AsyncData(null)); + final T Function(ProviderListenable) _read; + + Future checkUpgrade() async { + state = const AsyncLoading(); + try { + final result = await _read(updateApiClientProvider).checkUpgrade(); + state = AsyncData(result); + return result; + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + rethrow; + } + } +} + +final updateControllerProvider = + StateNotifierProvider>( + (ref) => UpdateController(ref.read), + ); diff --git a/lib/features/settings/application/update_foreground_service.dart b/lib/features/settings/application/update_foreground_service.dart new file mode 100644 index 0000000..b9e9009 --- /dev/null +++ b/lib/features/settings/application/update_foreground_service.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'update_controller.dart'; +import 'update_service.dart'; + +/// 前后台切换仅低频检查一次,网络失败或无新版本均静默结束。 +abstract final class UpdateForegroundService { + static DateTime? _lastCheck; + static Future? _running; + static Future check(BuildContext context, WidgetRef ref) { + final now = DateTime.now(); + if (_lastCheck != null && + now.difference(_lastCheck!) < const Duration(minutes: 30)) { + return Future.value(); + } + return _running ??= _run(context, ref).whenComplete(() => _running = null); + } + + static Future _run(BuildContext context, WidgetRef ref) async { + _lastCheck = DateTime.now(); + try { + final info = await ref + .read(updateControllerProvider.notifier) + .checkUpgrade(); + if (context.mounted) { + await UpdateService.showIfNeeded( + context, + ref, + info, + source: UpgradeCheckSource.automatic, + ); + } + } catch (_) {} + } +} diff --git a/lib/features/settings/application/update_service.dart b/lib/features/settings/application/update_service.dart new file mode 100644 index 0000000..73830b9 --- /dev/null +++ b/lib/features/settings/application/update_service.dart @@ -0,0 +1,93 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_app_update/flutter_app_update.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../data/update_api_client.dart'; + +enum UpgradeCheckSource { automatic, manual } + +class UpdateService { + static bool get isSupported => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + static Future showIfNeeded( + BuildContext context, + WidgetRef ref, + UpgradeInfo info, { + required UpgradeCheckSource source, + VoidCallback? onLatest, + }) async { + final shouldShow = source == UpgradeCheckSource.automatic + ? info.show == 1 && info.hasPublishedUpdate + : info.hasPublishedUpdate; + if (!shouldShow) { + onLatest?.call(); + return; + } + final uri = Uri.tryParse(info.url); + if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return; + final force = info.force == 1; + await showDialog( + context: context, + barrierDismissible: !force, + builder: (dialogContext) => PopScope( + canPop: !force, + child: AlertDialog( + title: Text( + '发现新版本${info.latestVersion.isEmpty ? '' : ' ${info.latestVersion}'}', + ), + content: Text(info.message.isEmpty ? '发现新版本,建议立即更新。' : info.message), + actions: [ + if (!force) + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('稍后再说'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(dialogContext); + final package = await PackageInfo.fromPlatform(); + try { + await ref + .read(updateApiClientProvider) + .reportClick( + buildId: info.versionCode.isEmpty + ? package.buildNumber + : info.versionCode, + source: source == UpgradeCheckSource.manual + ? 'manual' + : 'auto', + ); + } catch (_) {} + await _startUpdate(uri); + }, + child: Text(Platform.isIOS ? '前往更新' : '下载更新'), + ), + ], + ), + ), + ); + } + + static Future _startUpdate(Uri uri) async { + if (Platform.isIOS) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return; + } + await AzhonAppUpdate.update( + UpdateModel( + uri.toString(), + 'jinzhi-update.apk', + 'ic_launcher', + 'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC', + showNotification: true, + showBgdToast: false, + ), + ); + } +} diff --git a/lib/features/settings/data/update_api_client.dart b/lib/features/settings/data/update_api_client.dart new file mode 100644 index 0000000..6f001d1 --- /dev/null +++ b/lib/features/settings/data/update_api_client.dart @@ -0,0 +1,146 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../common/network/dio_provider.dart'; + +/// 金值 `jinzhi.v1.UpgradeReply` 的 HTTP protobuf 契约。 +class UpgradeInfo { + const UpgradeInfo({ + required this.show, + required this.force, + required this.message, + required this.url, + required this.isLatestVersion, + required this.latestVersion, + required this.versionCode, + }); + + final int show; + final int force; + final String message; + final String url; + final int isLatestVersion; + final String latestVersion; + final String versionCode; + + bool get hasPublishedUpdate => + isLatestVersion != 1 && + url.trim().isNotEmpty && + (latestVersion.trim().isNotEmpty || versionCode.trim().isNotEmpty); + + factory UpgradeInfo.fromProtobuf(List bytes) { + final fields = _ProtoReader(bytes).readFields(); + return UpgradeInfo( + show: fields.integer(1), + force: fields.integer(2), + message: fields.string(3), + url: fields.string(4), + isLatestVersion: fields.integer(5), + latestVersion: fields.string(6), + versionCode: fields.string(7), + ); + } +} + +class UpdateApiClient { + UpdateApiClient(this._dio); + final Dio _dio; + + Future checkUpgrade() async { + final response = await _dio.post>( + '/sys/upgrade', + data: const [], // jinzhi.v1.GetUpgradeRequest + options: protobufOptions(), + ); + return UpgradeInfo.fromProtobuf(response.data ?? const []); + } + + Future reportClick({ + required String buildId, + required String source, + }) async { + await _dio.post>( + '/active/upgrade/click', + data: _ProtoWriter() + .string(1, buildId) + .string(2, source) + .toBytes(), // jinzhi.v1.UpgradeClickRequest + options: protobufOptions(), + ); + } +} + +final updateApiClientProvider = Provider( + (ref) => UpdateApiClient(ref.watch(dioProvider)), +); + +class _ProtoFields { + final _values = {}; + int integer(int tag) => _values[tag] as int? ?? 0; + String string(int tag) => _values[tag] as String? ?? ''; +} + +class _ProtoReader { + _ProtoReader(this._bytes); + final List _bytes; + var _index = 0; + + _ProtoFields readFields() { + final result = _ProtoFields(); + while (_index < _bytes.length) { + final key = _varint(); + final tag = key >> 3; + switch (key & 7) { + case 0: + result._values[tag] = _varint(); + case 2: + final length = _varint(); + final end = _index + length; + if (end > _bytes.length) { + throw const FormatException('Invalid protobuf'); + } + result._values[tag] = String.fromCharCodes( + _bytes.sublist(_index, end), + ); + _index = end; + default: + throw const FormatException('Unsupported protobuf wire type'); + } + } + return result; + } + + int _varint() { + var value = 0; + var shift = 0; + while (_index < _bytes.length && shift < 64) { + final byte = _bytes[_index++]; + value |= (byte & 0x7f) << shift; + if (byte < 0x80) return value; + shift += 7; + } + throw const FormatException('Invalid protobuf varint'); + } +} + +class _ProtoWriter { + final _bytes = BytesBuilder(copy: false); + _ProtoWriter string(int tag, String value) { + final bytes = value.codeUnits; + _varint((tag << 3) | 2); + _varint(bytes.length); + _bytes.add(bytes); + return this; + } + + Uint8List toBytes() => _bytes.toBytes(); + void _varint(int value) { + while (value > 0x7f) { + _bytes.addByte((value & 0x7f) | 0x80); + value >>= 7; + } + _bytes.addByte(value); + } +} diff --git a/lib/features/settings/presentation/privacy_consent_page.dart b/lib/features/settings/presentation/privacy_consent_page.dart index 36e6571..c2924e6 100644 --- a/lib/features/settings/presentation/privacy_consent_page.dart +++ b/lib/features/settings/presentation/privacy_consent_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,9 +10,13 @@ import 'package:video_player/video_player.dart'; import '../../../common/config/app_h5_urls.dart'; import '../../../common/services/device_header_service.dart'; +import '../../../common/push/umeng_push_service.dart'; +import '../../../common/push/notification_permission.dart'; +import '../../../common/privacy/umeng_privacy_consent.dart'; import '../../../common/theme/jinzhi_theme.dart'; import '../../../common/widgets/adaptive.dart'; import '../../../common/router/app_router.dart'; +import '../../../common/tracking/umeng_service.dart'; class PrivacyConsentPage extends StatefulWidget { const PrivacyConsentPage({super.key}); @@ -47,12 +53,46 @@ class _PrivacyConsentPageState extends State { Future _agree() async { await DeviceHeaderService.to.markPrivacyAgreed(); await DeviceHeaderService.to.ensureInitialized(); - if (mounted) context.go(AppRoutes.assets); + if (mounted) { + context.go(AppRoutes.assets); + WidgetsBinding.instance.addPostFrameCallback((_) { + _completePostConsentInitialization(); + }); + } + } + + Future _completePostConsentInitialization() async { + // Consent navigation must never depend on an optional native SDK. The + // calls below still run in order, but failures are retried by normal app + // startup instead of trapping the user on the consent page. + try { + await UmengPrivacyConsent.submitGranted(); + await UmengService.initIfAllowed(); + await UmengPushService.startIfAllowed(); + } catch (_) {} } Future _enter() async { await DeviceHeaderService.to.markIosStartupSeen(); - if (mounted) context.go(AppRoutes.assets); + if (!mounted) return; + // 首屏跳转与可选 SDK 调用严格解耦。原生初始化、ATT 或网络问题都不得 + // 阻断“立即进入”。 + context.go(AppRoutes.assets); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_completeIosPostEnterInitialization()); + }); + } + + Future _completeIosPostEnterInitialization() async { + // iOS 不展示 Android 顶部说明;这里在可见首页后直接请求系统通知授权。 + // U-Push 注册保留,供 APNs token/友盟通道建立使用。 + try { + await NotificationPermission.requestFromSettings(); + } catch (_) {} + try { + await UmengService.requestTrackingAuthorizationIfNeeded(); + await UmengPushService.startIfAllowed(); + } catch (_) {} } @override diff --git a/lib/features/settings/presentation/settings_page.dart b/lib/features/settings/presentation/settings_page.dart index 75de0e3..2efdffe 100644 --- a/lib/features/settings/presentation/settings_page.dart +++ b/lib/features/settings/presentation/settings_page.dart @@ -1,14 +1,16 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_remix/flutter_remix.dart'; 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'; import '../../../common/router/app_router.dart'; +import '../../../common/services/cache_clear_service.dart'; import '../../../common/theme/app_text.dart'; import '../../../common/theme/spacing.dart'; import '../../../common/theme/theme_controller.dart'; @@ -20,6 +22,9 @@ import '../../auth/data/auth_models.dart'; import '../../profile/application/profile_settings_controller.dart'; import '../../profile/data/profile_models.dart'; import '../application/feedback_email_service.dart'; +import '../application/update_controller.dart'; +import '../application/update_service.dart'; +import '../data/update_api_client.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key}); @@ -31,6 +36,25 @@ class SettingsPage extends HookConsumerWidget { final profileSettings = ref.watch(profileSettingsControllerProvider); final authAsync = ref.watch(authProvider); final loggedIn = authAsync.valueOrNull is LoggedInAuthState; + final updateSupported = UpdateService.isSupported; + final upgrade = ref.watch(updateControllerProvider).valueOrNull; + final notificationEnabled = useState(false); + useEffect(() { + if (!updateSupported) return null; + Future(() async { + try { + await ref.read(updateControllerProvider.notifier).checkUpgrade(); + } catch (_) {} + }); + return null; + }, [updateSupported]); + useEffect(() { + Future( + () async => notificationEnabled.value = + await NotificationPermission.isGranted(), + ); + return null; + }, const []); return Scaffold( backgroundColor: cs.background, @@ -126,6 +150,36 @@ class SettingsPage extends HookConsumerWidget { trailing: '占位', onTap: () => _showWidgetSheet(context), ), + Divider(height: 1, thickness: 1, color: cs.border), + _SwitchRow( + title: '推送通知', + subtitle: notificationEnabled.value + ? '已允许接收服务提醒' + : '在系统设置中开启服务提醒', + value: notificationEnabled.value, + onChanged: (enabled) async { + if (enabled) { + final granted = + await NotificationPermission.requestFromSettings(); + notificationEnabled.value = granted; + if (!granted) { + await NotificationPermission.openSystemSettings(); + } + } else { + await NotificationPermission.openSystemSettings(); + notificationEnabled.value = + await NotificationPermission.isGranted(); + } + }, + ), + Divider(height: 1, thickness: 1, color: cs.border), + _NavRow( + icon: FlutterRemix.delete_bin_6_line, + label: '清除缓存', + trailing: '图片与网页缓存', + showArrow: true, + onTap: () => _confirmClearCache(context), + ), if (kDebugMode) ...[ Divider(height: 1, thickness: 1, color: cs.border), _NavRow( @@ -144,18 +198,16 @@ class SettingsPage extends HookConsumerWidget { _NavRow( icon: FlutterRemix.file_list_3_line, label: '用户协议', - onTap: () => launchUrlString( - AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl), - mode: LaunchMode.inAppBrowserView, + onTap: () => context.push( + '${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}', ), ), Divider(height: 1, thickness: 1, color: cs.border), _NavRow( icon: FlutterRemix.shield_check_line, label: '隐私政策', - onTap: () => launchUrlString( - AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl), - mode: LaunchMode.inAppBrowserView, + onTap: () => context.push( + '${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}', ), ), Divider(height: 1, thickness: 1, color: cs.border), @@ -192,7 +244,27 @@ class SettingsPage extends HookConsumerWidget { const _Section('关于'), _Card( children: [ - const _AboutRow(), + _AboutRow( + upgrade: upgrade, + enabled: updateSupported, + onTap: () async { + try { + final result = await ref + .read(updateControllerProvider.notifier) + .checkUpgrade(); + if (!context.mounted) return; + await UpdateService.showIfNeeded( + context, + ref, + result, + source: UpgradeCheckSource.manual, + onLatest: () => toastInfo(msg: '已经是最新版本'), + ); + } catch (_) { + if (context.mounted) toastInfo(msg: '检查更新失败'); + } + }, + ), Divider(height: 1, thickness: 1, color: cs.border), _NavRow( icon: FlutterRemix.mail_line, @@ -215,6 +287,38 @@ class SettingsPage extends HookConsumerWidget { ); } + Future _confirmClearCache(BuildContext context) async { + final systemSettings = CacheClearService.requiresSystemSettings; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('清除缓存'), + content: Text( + systemSettings + ? 'Android 需要在系统应用详情中完成缓存清理,是否立即前往?' + : '将清除图片和网页缓存。下次打开相关页面时需要重新加载。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(systemSettings ? '前往设置' : '确认清除'), + ), + ], + ), + ); + if (confirmed != true) return; + if (systemSettings) { + await CacheClearService.openAndroidSystemSettings(); + return; + } + await CacheClearService.clear(); + if (context.mounted) toastInfo(msg: '缓存已清除'); + } + Future _confirmLogout(BuildContext context, WidgetRef ref) async { final cs = ShadTheme.of(context).colorScheme; final ok = await showModalBottomSheet( @@ -788,7 +892,7 @@ class _ThemeSegmentCard extends StatelessWidget { Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final options = <(ThemeMode, String, IconData)>[ - (ThemeMode.system, '跟随系统', Icons.brightness_auto_outlined), + (ThemeMode.system, '系统主题', Icons.brightness_auto_outlined), (ThemeMode.light, '浅色', Icons.light_mode_outlined), (ThemeMode.dark, '深色', Icons.dark_mode_outlined), ]; @@ -948,6 +1052,7 @@ class _NavRow extends StatelessWidget { required this.label, this.trailing, this.onTap, + this.showArrow = false, this.destructive = false, }); @@ -955,6 +1060,7 @@ class _NavRow extends StatelessWidget { final String label; final String? trailing; final VoidCallback? onTap; + final bool showArrow; final bool destructive; @override @@ -981,7 +1087,7 @@ class _NavRow extends StatelessWidget { color: cs.mutedForeground, ), ), - if (onTap != null && trailing == null) + if (onTap != null && (trailing == null || showArrow)) Icon( FlutterRemix.arrow_right_s_line, size: 20, @@ -995,7 +1101,15 @@ class _NavRow extends StatelessWidget { } class _AboutRow extends StatelessWidget { - const _AboutRow(); + const _AboutRow({ + required this.upgrade, + required this.enabled, + required this.onTap, + }); + + final UpgradeInfo? upgrade; + final bool enabled; + final VoidCallback onTap; @override Widget build(BuildContext context) { @@ -1006,33 +1120,47 @@ class _AboutRow extends StatelessWidget { final v = snap.hasData ? 'v${snap.data!.version}+${snap.data!.buildNumber}' : ''; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), - child: Row( - children: [ - SizedBox( - width: 24, - child: Icon( - FlutterRemix.information_line, - size: 20, - color: cs.foreground, + final label = upgrade?.hasPublishedUpdate == true + ? '有新版 ${upgrade!.latestVersion}' + : v; + return InkWell( + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + child: Row( + children: [ + SizedBox( + width: 24, + child: Icon( + FlutterRemix.information_line, + size: 20, + color: cs.foreground, + ), ), - ), - const SizedBox(width: 13), - Expanded( - child: Text( - '金值', - style: TextStyle(fontSize: 15, color: cs.foreground), + const SizedBox(width: 13), + Expanded( + child: Text( + '金值', + style: TextStyle(fontSize: 15, color: cs.foreground), + ), ), - ), - Text( - v, - style: AppText.meta.copyWith( - fontSize: 13, - color: cs.mutedForeground, + Text( + label, + style: AppText.meta.copyWith( + fontSize: 13, + color: cs.mutedForeground, + ), ), - ), - ], + if (enabled) ...[ + const SizedBox(width: 6), + Icon( + FlutterRemix.arrow_right_s_line, + size: 20, + color: cs.mutedForeground, + ), + ], + ], + ), ), ); }, diff --git a/lib/features/web/application/mall_web_host_controller.dart b/lib/features/web/application/mall_web_host_controller.dart new file mode 100644 index 0000000..482e4ae --- /dev/null +++ b/lib/features/web/application/mall_web_host_controller.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; + +class MallWebHostState { + const MallWebHostState({this.url, this.visible = false}); + final String? url; + final bool visible; +} + +/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。 +class MallWebHostController { + MallWebHostController._(); + static final instance = MallWebHostController._(); + final state = ValueNotifier(const MallWebHostState()); + Future Function()? _backHandler; + + void preload(String url) { + final old = state.value; + if (old.url == url) return; + state.value = MallWebHostState(url: url, visible: old.visible); + } + + 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 Function() handler) => + _backHandler = handler; + void detachBackHandler() => _backHandler = null; + Future handleBack() => _backHandler?.call() ?? Future.value(); +} diff --git a/lib/features/web/presentation/mall_web_host.dart b/lib/features/web/presentation/mall_web_host.dart new file mode 100644 index 0000000..9b75851 --- /dev/null +++ b/lib/features/web/presentation/mall_web_host.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../application/mall_web_host_controller.dart'; + +/// 常驻在 Tab Shell 上层的商城 H5 宿主。 +class MallWebHost extends StatefulWidget { + const MallWebHost({super.key, required this.onExit}); + final VoidCallback onExit; + + @override + State createState() => _MallWebHostState(); +} + +class _MallWebHostState extends State { + final _host = MallWebHostController.instance; + WebViewController? _controller; + String? _loadedUrl; + String? _error; + int _progress = 0; + + @override + void initState() { + super.initState(); + _host.state.addListener(_sync); + _host.attachBackHandler(_goBack); + _sync(); + } + + @override + void dispose() { + _host.state.removeListener(_sync); + _host.detachBackHandler(); + super.dispose(); + } + + void _sync() { + final url = _host.state.value.url; + if (url != null && url != _loadedUrl) _load(url); + if (mounted) setState(() {}); + } + + void _load(String rawUrl) { + final uri = Uri.tryParse(rawUrl); + if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) { + _error = '商城链接不可用'; + return; + } + _error = null; + _progress = 0; + _loadedUrl = rawUrl; + _controller ??= WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'JinzhiBridge', + onMessageReceived: (message) { + if (message.message == 'close') widget.onExit(); + }, + ) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (value) { + if (mounted) setState(() => _progress = value); + }, + onWebResourceError: (error) { + if (error.isForMainFrame == false) return; + if (mounted) setState(() => _error = error.description); + }, + onNavigationRequest: (request) async { + final target = Uri.tryParse(request.url); + if (target == null || + target.scheme == 'http' || + target.scheme == 'https') { + return NavigationDecision.navigate; + } + if (await canLaunchUrl(target)) { + await launchUrl(target, mode: LaunchMode.externalApplication); + } + return NavigationDecision.prevent; + }, + ), + ); + unawaited(_controller!.loadRequest(uri)); + } + + Future _goBack() async { + final controller = _controller; + if (controller != null && await controller.canGoBack()) { + await controller.goBack(); + } + } + + @override + Widget build(BuildContext context) { + final current = _host.state.value; + if (current.url == null) return const SizedBox.shrink(); + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: Stack( + children: [ + if (_controller != null) + Positioned.fill(child: WebViewWidget(controller: _controller!)), + if (_error != null) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off_rounded, size: 32), + const SizedBox(height: 12), + const Text('商城加载失败'), + const SizedBox(height: 8), + TextButton( + onPressed: () => _load(_loadedUrl ?? ''), + child: const Text('重新加载'), + ), + ], + ), + ), + if (_error == null && _progress < 100) + const Align( + alignment: Alignment.topCenter, + child: LinearProgressIndicator(minHeight: 2), + ), + ], + ), + ); + } +} diff --git a/lib/features/web/presentation/web_page.dart b/lib/features/web/presentation/web_page.dart new file mode 100644 index 0000000..40fa3d8 --- /dev/null +++ b/lib/features/web/presentation/web_page.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.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 { + const AppWebPage({ + super.key, + required this.initialUri, + this.hideTitle = false, + this.allowedHosts = const {}, + }); + + final Uri initialUri; + final bool hideTitle; + final Set allowedHosts; + + @override + State createState() => _AppWebPageState(); +} + +class _AppWebPageState extends State { + late final WebViewController _controller; + var _progress = 0; + String? _error; + String _title = ''; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'JinzhiBridge', + onMessageReceived: (message) { + if (message.message == 'close' && mounted) context.pop(); + }, + ) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (progress) => setState(() => _progress = progress), + onPageFinished: (_) async { + final title = await _controller.getTitle(); + if (mounted && title != null && title.trim().isNotEmpty) { + setState(() => _title = title.trim()); + } + }, + onWebResourceError: (error) { + if (error.isForMainFrame == true && mounted) { + setState(() => _error = error.description); + } + }, + onNavigationRequest: _onNavigationRequest, + ), + ) + ..loadRequest(widget.initialUri); + } + + NavigationDecision _onNavigationRequest(NavigationRequest request) { + final uri = Uri.tryParse(request.url); + if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent; + if (_allowsInApp(uri)) return NavigationDecision.navigate; + 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'), + ); + } + + bool _isHttp(Uri uri) => + uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http'); + + Future _onPop() async { + if (await _controller.canGoBack()) { + await _controller.goBack(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final body = _error == null + ? Stack( + children: [ + WebViewWidget(controller: _controller), + if (_progress < 100) + LinearProgressIndicator(value: _progress / 100), + ], + ) + : Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('页面加载失败\n$_error', textAlign: TextAlign.center), + ), + ); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (!didPop && await _onPop() && context.mounted) context.pop(); + }, + child: Scaffold( + appBar: widget.hideTitle + ? null + : AppBar(title: Text(_title.isEmpty ? '加载中' : _title)), + body: body, + ), + ); + } +} + +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; +} diff --git a/pubspec.lock b/pubspec.lock index d4f2710..dd8e9e4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,6 +34,14 @@ packages: url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git" source: git version: "0.4.0" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "4d4bbd619d698cbee3c400ba89246ecb0f525146b0ae05389f62ede47abd5bec" + url: "https://pub.dev" + source: hosted + version: "6.1.0" android_oaid: dependency: "direct main" description: @@ -51,6 +59,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_tracking_transparency: + dependency: "direct main" + description: + name: app_tracking_transparency + sha256: "3a52669c0645ffd7efe9a460047616de3e3f2128338f9ec7cdb681628d36238e" + url: "https://pub.dev" + source: hosted + version: "2.0.7" archive: dependency: transitive description: @@ -416,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_app_update: + dependency: "direct main" + description: + name: flutter_app_update + sha256: "336a87c9b8f5c2f040ac314939f61a567e812a3ec3c066d8c99a524d95171d81" + url: "https://pub.dev" + source: hosted + version: "3.2.4" flutter_cache_manager: dependency: "direct main" description: @@ -952,6 +976,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" petitparser: dependency: transitive description: @@ -1366,6 +1438,32 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + umeng_common_sdk: + dependency: "direct main" + description: + name: umeng_common_sdk + sha256: "8374ced1ab1d89f596ff71c0eff044d5395337145c9a69a6be02f5d8a678e42c" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + umeng_push_sdk: + dependency: "direct main" + description: + path: "packages/umeng_push_sdk" + ref: main + resolved-ref: "51fd4ba38d57fdc8fb23e744aa65bf2368878b7f" + url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git" + source: git + version: "2.3.0" + umeng_verify_sdk: + dependency: "direct main" + description: + path: "packages/umeng_verify_sdk" + ref: main + resolved-ref: "51fd4ba38d57fdc8fb23e744aa65bf2368878b7f" + url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git" + source: git + version: "2.1.0" universal_image: dependency: transitive description: @@ -1566,6 +1664,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 + url: "https://pub.dev" + source: hosted + version: "4.12.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: "direct main" + description: + name: webview_flutter_wkwebview + sha256: e50cd97ad28e888f6a4f862a05eab917a635417f9b134df64c3abb78250c6446 + url: "https://pub.dev" + source: hosted + version: "3.25.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b75816a..50c5984 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,13 @@ dependencies: # 网络图片与磁盘缓存 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 + webview_flutter: 4.13.0 + webview_flutter_wkwebview: 3.25.0 + flutter_app_update: ^3.2.1 + android_intent_plus: ^6.0.0 + permission_handler: ^12.0.1 + umeng_common_sdk: 1.3.0 + app_tracking_transparency: ^2.0.6+1 flutter_markdown_plus: ^1.0.7 video_player: ^2.10.0 # 启动页视频背景 # 工具 @@ -64,6 +71,16 @@ dependencies: git: url: https://gitea.neuronlabs.art/zhouyu/platform_metadata.git ref: main + umeng_push_sdk: + git: + url: https://gitea.neuronlabs.art/third-party-project/flutter-packages.git + path: packages/umeng_push_sdk + ref: main + umeng_verify_sdk: + git: + url: https://gitea.neuronlabs.art/third-party-project/flutter-packages.git + path: packages/umeng_verify_sdk + ref: main flutter_dotenv: ^6.0.1 fluttertoast: ^9.0.0 diff --git a/test/features/web/web_page_test.dart b/test/features/web/web_page_test.dart new file mode 100644 index 0000000..55990d6 --- /dev/null +++ b/test/features/web/web_page_test.dart @@ -0,0 +1,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', () { + 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(''), isNull); + }); +}