From 681688dfae3484bdbeb0031fd0c8ae22e9bc5718 Mon Sep 17 00:00:00 2001 From: "Guangfei.Zhao" Date: Mon, 17 Aug 2026 15:29:55 +0800 Subject: [PATCH] app scaffold --- .fvmrc | 3 + .gitignore | 73 + .gitlab-ci.yml | 140 ++ README.md | 171 +++ SCAFFOLD-NOTES.md | 222 +++ analysis_options.yaml | 63 + app/.gitignore | 45 + app/.metadata | 33 + app/README.md | 17 + app/analysis_options.yaml | 1 + app/android/.gitignore | 14 + app/android/app/build.gradle.kts | 91 ++ app/android/app/src/debug/AndroidManifest.xml | 7 + app/android/app/src/main/AndroidManifest.xml | 48 + .../kotlin/com/conti/retail/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../main/res/xml/network_security_config.xml | 17 + .../app/src/profile/AndroidManifest.xml | 7 + app/android/build.gradle.kts | 24 + app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + app/android/settings.gradle.kts | 26 + app/env/dev.json | 7 + app/env/prod.json | 7 + app/env/uat.json | 7 + app/integration_test/app_test.dart | 33 + app/ios/.gitignore | 34 + app/ios/FLAVORS.md | 76 + app/ios/Flutter/AppFrameworkInfo.plist | 24 + app/ios/Flutter/Debug.xcconfig | 1 + app/ios/Flutter/Dev.xcconfig | 10 + app/ios/Flutter/Prod.xcconfig | 10 + app/ios/Flutter/Release.xcconfig | 1 + app/ios/Flutter/Uat.xcconfig | 10 + app/ios/Runner.xcodeproj/project.pbxproj | 644 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + app/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + app/ios/Runner/Base.lproj/Main.storyboard | 26 + app/ios/Runner/Info.plist | 70 + app/ios/Runner/Runner-Bridging-Header.h | 1 + app/ios/Runner/SceneDelegate.swift | 6 + app/ios/RunnerTests/RunnerTests.swift | 12 + app/l10n.yaml | 8 + app/lib/bootstrap.dart | 148 ++ app/lib/l10n/app_localizations.dart | 130 ++ app/lib/l10n/app_localizations_zh.dart | 13 + app/lib/l10n/app_zh.arb | 7 + app/lib/main_dev.dart | 14 + app/lib/main_prod.dart | 14 + app/lib/main_uat.dart | 14 + app/lib/src/app_widget.dart | 58 + app/lib/src/device_id.dart | 29 + app/lib/src/error_observer.dart | 47 + app/lib/src/h5_launch_repository.dart | 36 + app/lib/src/session_observer.dart | 82 ++ app/pubspec.yaml | 52 + app/test/device_id_test.dart | 40 + devtools_options.yaml | 3 + docs/01-project-structure.md | 266 ++++ docs/02-layering.md | 234 ++++ docs/03-state-management.md | 227 +++ docs/04-routing.md | 198 +++ docs/05-networking.md | 447 ++++++ docs/06-local-storage.md | 323 +++++ docs/07-native-integration.md | 347 +++++ docs/08-build-flavors.md | 301 ++++ docs/09-testing.md | 254 ++++ docs/10-webview-h5.md | 361 +++++ docs/11-store-context-and-session.md | 302 ++++ docs/12-error-and-api-contract.md | 370 +++++ docs/13-observability-analytics.md | 442 ++++++ docs/14-conventions-and-ci-gates.md | 289 ++++ docs/README.md | 48 + packages/core_analytics/analysis_options.yaml | 1 + .../core_analytics/lib/core_analytics.dart | 10 + .../core_analytics/lib/src/analytics.dart | 30 + .../lib/src/analytics_event.dart | 148 ++ .../lib/src/noop_analytics.dart | 61 + .../core_analytics/lib/src/providers.dart | 17 + packages/core_analytics/pubspec.yaml | 19 + .../test/core_analytics_test.dart | 77 + packages/core_auth/analysis_options.yaml | 1 + packages/core_auth/lib/core_auth.dart | 13 + packages/core_auth/lib/src/models.dart | 166 +++ .../core_auth/lib/src/session_notifier.dart | 301 ++++ packages/core_auth/lib/src/session_ports.dart | 105 ++ .../core_auth/lib/src/token_refresher.dart | 87 ++ packages/core_auth/lib/src/token_storage.dart | 64 + packages/core_auth/pubspec.yaml | 31 + packages/core_auth/test/core_auth_test.dart | 231 +++ .../core_foundation/analysis_options.yaml | 1 + .../core_foundation/lib/core_foundation.dart | 10 + .../core_foundation/lib/src/env/app_env.dart | 120 ++ .../lib/src/error/api_code.dart | 37 + .../lib/src/error/app_exception.dart | 145 ++ .../lib/src/error/network_error_kind.dart | 17 + packages/core_foundation/pubspec.yaml | 18 + .../test/app_exception_test.dart | 55 + packages/core_logging/analysis_options.yaml | 1 + packages/core_logging/lib/core_logging.dart | 19 + packages/core_logging/lib/src/app_logger.dart | 42 + .../lib/src/crash_breadcrumb_observer.dart | 38 + .../core_logging/lib/src/crash_reporter.dart | 56 + packages/core_logging/lib/src/log_buffer.dart | 57 + .../lib/src/logger_app_logger.dart | 124 ++ packages/core_logging/lib/src/providers.dart | 28 + packages/core_logging/lib/src/scrubber.dart | 85 ++ .../lib/src/sentry_crash_reporter.dart | 71 + .../core_logging/lib/src/sentry_scrubber.dart | 43 + .../core_logging/lib/src/test_exception.dart | 17 + packages/core_logging/pubspec.yaml | 21 + .../core_logging/test/core_logging_test.dart | 137 ++ packages/core_network/analysis_options.yaml | 1 + packages/core_network/lib/core_network.dart | 14 + packages/core_network/lib/src/api_client.dart | 99 ++ .../lib/src/api_result_interceptor.dart | 58 + .../lib/src/auth_interceptor.dart | 73 + .../lib/src/error_mapping_interceptor.dart | 58 + .../lib/src/header_interceptor.dart | 46 + packages/core_network/lib/src/paging.dart | 62 + packages/core_network/lib/src/ports.dart | 45 + packages/core_network/lib/src/providers.dart | 62 + packages/core_network/pubspec.yaml | 24 + .../core_network/test/core_network_test.dart | 121 ++ packages/core_router/analysis_options.yaml | 1 + packages/core_router/lib/core_router.dart | 19 + packages/core_router/lib/src/app_router.dart | 90 ++ .../core_router/lib/src/menu_route_map.dart | 35 + packages/core_router/lib/src/pages.dart | 52 + packages/core_router/lib/src/ports.dart | 51 + packages/core_router/lib/src/redirect.dart | 54 + packages/core_router/lib/src/route_paths.dart | 41 + packages/core_router/pubspec.yaml | 24 + .../core_router/test/core_router_test.dart | 102 ++ packages/core_storage/analysis_options.yaml | 1 + packages/core_storage/lib/core_storage.dart | 11 + packages/core_storage/lib/src/prefs.dart | 68 + packages/core_storage/lib/src/providers.dart | 9 + packages/core_storage/pubspec.yaml | 27 + packages/core_ui/analysis_options.yaml | 1 + packages/core_ui/lib/core_ui.dart | 9 + .../lib/src/error/error_presenter.dart | 97 ++ .../core_ui/lib/src/error/error_view.dart | 239 ++++ packages/core_ui/lib/src/theme/app_theme.dart | 28 + packages/core_ui/pubspec.yaml | 19 + packages/core_ui/test/core_ui_test.dart | 133 ++ packages/core_webview/analysis_options.yaml | 1 + packages/core_webview/lib/core_webview.dart | 22 + packages/core_webview/lib/src/bridge.dart | 161 +++ .../core_webview/lib/src/bridge_shim.dart | 44 + packages/core_webview/lib/src/h5_launch.dart | 49 + .../core_webview/lib/src/page_watchdog.dart | 43 + packages/core_webview/lib/src/url_guard.dart | 54 + .../core_webview/lib/src/webview_session.dart | 98 ++ packages/core_webview/pubspec.yaml | 24 + .../core_webview/test/core_webview_test.dart | 163 +++ packages/feature_auth/analysis_options.yaml | 1 + packages/feature_auth/lib/feature_auth.dart | 11 + .../lib/src/data/auth_repository.dart | 125 ++ .../src/presentation/login_controller.dart | 35 + .../lib/src/presentation/login_page.dart | 95 ++ .../src/presentation/store_picker_page.dart | 100 ++ packages/feature_auth/lib/src/routes.dart | 28 + packages/feature_auth/pubspec.yaml | 30 + .../feature_auth/test/login_page_test.dart | 89 ++ packages/feature_home/analysis_options.yaml | 1 + packages/feature_home/lib/feature_home.dart | 9 + .../lib/src/data/home_models.dart | 36 + .../lib/src/data/home_repository.dart | 73 + .../lib/src/presentation/home_page.dart | 235 ++++ .../lib/src/presentation/home_providers.dart | 84 ++ packages/feature_home/lib/src/routes.dart | 19 + packages/feature_home/pubspec.yaml | 32 + .../feature_home/test/home_page_test.dart | 158 +++ packages/native_scan/.gitignore | 33 + packages/native_scan/.metadata | 33 + packages/native_scan/CHANGELOG.md | 3 + packages/native_scan/LICENSE | 1 + packages/native_scan/README.md | 15 + packages/native_scan/analysis_options.yaml | 1 + packages/native_scan/android/.gitignore | 9 + packages/native_scan/android/build.gradle.kts | 77 + .../native_scan/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../com/conti/native_scan/NativeScanPlugin.kt | 28 + .../kotlin/com/conti/native_scan/ScanApi.g.kt | 445 ++++++ packages/native_scan/example/.gitignore | 45 + packages/native_scan/example/README.md | 17 + .../native_scan/example/analysis_options.yaml | 1 + .../native_scan/example/android/.gitignore | 14 + .../example/android/app/build.gradle.kts | 45 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 + .../conti/native_scan_example/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + .../example/android/build.gradle.kts | 24 + .../example/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../example/android/settings.gradle.kts | 26 + .../plugin_integration_test.dart | 19 + packages/native_scan/example/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + .../example/ios/Flutter/Debug.xcconfig | 1 + .../example/ios/Flutter/Release.xcconfig | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 648 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../example/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + .../native_scan/example/ios/Runner/Info.plist | 70 + .../ios/Runner/Runner-Bridging-Header.h | 1 + .../example/ios/Runner/SceneDelegate.swift | 6 + .../example/ios/RunnerTests/RunnerTests.swift | 29 + packages/native_scan/example/lib/main.dart | 70 + packages/native_scan/example/pubspec.yaml | 22 + .../native_scan/example/test/widget_test.dart | 17 + packages/native_scan/ios/.gitignore | 38 + packages/native_scan/ios/native_scan.podspec | 29 + .../native_scan/ios/native_scan/Package.swift | 36 + .../native_scan/NativeScanPlugin.swift | 21 + .../Sources/native_scan/PrivacyInfo.xcprivacy | 14 + .../Sources/native_scan/ScanApi.g.swift | 432 ++++++ packages/native_scan/lib/native_scan.dart | 98 ++ .../lib/src/generated/scan_api.g.dart | 335 +++++ packages/native_scan/pigeons/scan_api.dart | 93 ++ packages/native_scan/pubspec.yaml | 32 + .../native_scan/test/native_scan_test.dart | 60 + pubspec.lock | 1238 +++++++++++++++++ pubspec.yaml | 69 + 301 files changed, 18414 insertions(+) create mode 100644 .fvmrc create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 README.md create mode 100644 SCAFFOLD-NOTES.md create mode 100644 analysis_options.yaml create mode 100644 app/.gitignore create mode 100644 app/.metadata create mode 100644 app/README.md create mode 100644 app/analysis_options.yaml create mode 100644 app/android/.gitignore create mode 100644 app/android/app/build.gradle.kts create mode 100644 app/android/app/src/debug/AndroidManifest.xml create mode 100644 app/android/app/src/main/AndroidManifest.xml create mode 100644 app/android/app/src/main/kotlin/com/conti/retail/MainActivity.kt create mode 100644 app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/values-night/styles.xml create mode 100644 app/android/app/src/main/res/values/styles.xml create mode 100644 app/android/app/src/main/res/xml/network_security_config.xml create mode 100644 app/android/app/src/profile/AndroidManifest.xml create mode 100644 app/android/build.gradle.kts create mode 100644 app/android/gradle.properties create mode 100644 app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 app/android/settings.gradle.kts create mode 100644 app/env/dev.json create mode 100644 app/env/prod.json create mode 100644 app/env/uat.json create mode 100644 app/integration_test/app_test.dart create mode 100644 app/ios/.gitignore create mode 100644 app/ios/FLAVORS.md create mode 100644 app/ios/Flutter/AppFrameworkInfo.plist create mode 100644 app/ios/Flutter/Debug.xcconfig create mode 100644 app/ios/Flutter/Dev.xcconfig create mode 100644 app/ios/Flutter/Prod.xcconfig create mode 100644 app/ios/Flutter/Release.xcconfig create mode 100644 app/ios/Flutter/Uat.xcconfig create mode 100644 app/ios/Runner.xcodeproj/project.pbxproj create mode 100644 app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 app/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 app/ios/Runner/AppDelegate.swift create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 app/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 app/ios/Runner/Base.lproj/Main.storyboard create mode 100644 app/ios/Runner/Info.plist create mode 100644 app/ios/Runner/Runner-Bridging-Header.h create mode 100644 app/ios/Runner/SceneDelegate.swift create mode 100644 app/ios/RunnerTests/RunnerTests.swift create mode 100644 app/l10n.yaml create mode 100644 app/lib/bootstrap.dart create mode 100644 app/lib/l10n/app_localizations.dart create mode 100644 app/lib/l10n/app_localizations_zh.dart create mode 100644 app/lib/l10n/app_zh.arb create mode 100644 app/lib/main_dev.dart create mode 100644 app/lib/main_prod.dart create mode 100644 app/lib/main_uat.dart create mode 100644 app/lib/src/app_widget.dart create mode 100644 app/lib/src/device_id.dart create mode 100644 app/lib/src/error_observer.dart create mode 100644 app/lib/src/h5_launch_repository.dart create mode 100644 app/lib/src/session_observer.dart create mode 100644 app/pubspec.yaml create mode 100644 app/test/device_id_test.dart create mode 100644 devtools_options.yaml create mode 100644 docs/01-project-structure.md create mode 100644 docs/02-layering.md create mode 100644 docs/03-state-management.md create mode 100644 docs/04-routing.md create mode 100644 docs/05-networking.md create mode 100644 docs/06-local-storage.md create mode 100644 docs/07-native-integration.md create mode 100644 docs/08-build-flavors.md create mode 100644 docs/09-testing.md create mode 100644 docs/10-webview-h5.md create mode 100644 docs/11-store-context-and-session.md create mode 100644 docs/12-error-and-api-contract.md create mode 100644 docs/13-observability-analytics.md create mode 100644 docs/14-conventions-and-ci-gates.md create mode 100644 docs/README.md create mode 100644 packages/core_analytics/analysis_options.yaml create mode 100644 packages/core_analytics/lib/core_analytics.dart create mode 100644 packages/core_analytics/lib/src/analytics.dart create mode 100644 packages/core_analytics/lib/src/analytics_event.dart create mode 100644 packages/core_analytics/lib/src/noop_analytics.dart create mode 100644 packages/core_analytics/lib/src/providers.dart create mode 100644 packages/core_analytics/pubspec.yaml create mode 100644 packages/core_analytics/test/core_analytics_test.dart create mode 100644 packages/core_auth/analysis_options.yaml create mode 100644 packages/core_auth/lib/core_auth.dart create mode 100644 packages/core_auth/lib/src/models.dart create mode 100644 packages/core_auth/lib/src/session_notifier.dart create mode 100644 packages/core_auth/lib/src/session_ports.dart create mode 100644 packages/core_auth/lib/src/token_refresher.dart create mode 100644 packages/core_auth/lib/src/token_storage.dart create mode 100644 packages/core_auth/pubspec.yaml create mode 100644 packages/core_auth/test/core_auth_test.dart create mode 100644 packages/core_foundation/analysis_options.yaml create mode 100644 packages/core_foundation/lib/core_foundation.dart create mode 100644 packages/core_foundation/lib/src/env/app_env.dart create mode 100644 packages/core_foundation/lib/src/error/api_code.dart create mode 100644 packages/core_foundation/lib/src/error/app_exception.dart create mode 100644 packages/core_foundation/lib/src/error/network_error_kind.dart create mode 100644 packages/core_foundation/pubspec.yaml create mode 100644 packages/core_foundation/test/app_exception_test.dart create mode 100644 packages/core_logging/analysis_options.yaml create mode 100644 packages/core_logging/lib/core_logging.dart create mode 100644 packages/core_logging/lib/src/app_logger.dart create mode 100644 packages/core_logging/lib/src/crash_breadcrumb_observer.dart create mode 100644 packages/core_logging/lib/src/crash_reporter.dart create mode 100644 packages/core_logging/lib/src/log_buffer.dart create mode 100644 packages/core_logging/lib/src/logger_app_logger.dart create mode 100644 packages/core_logging/lib/src/providers.dart create mode 100644 packages/core_logging/lib/src/scrubber.dart create mode 100644 packages/core_logging/lib/src/sentry_crash_reporter.dart create mode 100644 packages/core_logging/lib/src/sentry_scrubber.dart create mode 100644 packages/core_logging/lib/src/test_exception.dart create mode 100644 packages/core_logging/pubspec.yaml create mode 100644 packages/core_logging/test/core_logging_test.dart create mode 100644 packages/core_network/analysis_options.yaml create mode 100644 packages/core_network/lib/core_network.dart create mode 100644 packages/core_network/lib/src/api_client.dart create mode 100644 packages/core_network/lib/src/api_result_interceptor.dart create mode 100644 packages/core_network/lib/src/auth_interceptor.dart create mode 100644 packages/core_network/lib/src/error_mapping_interceptor.dart create mode 100644 packages/core_network/lib/src/header_interceptor.dart create mode 100644 packages/core_network/lib/src/paging.dart create mode 100644 packages/core_network/lib/src/ports.dart create mode 100644 packages/core_network/lib/src/providers.dart create mode 100644 packages/core_network/pubspec.yaml create mode 100644 packages/core_network/test/core_network_test.dart create mode 100644 packages/core_router/analysis_options.yaml create mode 100644 packages/core_router/lib/core_router.dart create mode 100644 packages/core_router/lib/src/app_router.dart create mode 100644 packages/core_router/lib/src/menu_route_map.dart create mode 100644 packages/core_router/lib/src/pages.dart create mode 100644 packages/core_router/lib/src/ports.dart create mode 100644 packages/core_router/lib/src/redirect.dart create mode 100644 packages/core_router/lib/src/route_paths.dart create mode 100644 packages/core_router/pubspec.yaml create mode 100644 packages/core_router/test/core_router_test.dart create mode 100644 packages/core_storage/analysis_options.yaml create mode 100644 packages/core_storage/lib/core_storage.dart create mode 100644 packages/core_storage/lib/src/prefs.dart create mode 100644 packages/core_storage/lib/src/providers.dart create mode 100644 packages/core_storage/pubspec.yaml create mode 100644 packages/core_ui/analysis_options.yaml create mode 100644 packages/core_ui/lib/core_ui.dart create mode 100644 packages/core_ui/lib/src/error/error_presenter.dart create mode 100644 packages/core_ui/lib/src/error/error_view.dart create mode 100644 packages/core_ui/lib/src/theme/app_theme.dart create mode 100644 packages/core_ui/pubspec.yaml create mode 100644 packages/core_ui/test/core_ui_test.dart create mode 100644 packages/core_webview/analysis_options.yaml create mode 100644 packages/core_webview/lib/core_webview.dart create mode 100644 packages/core_webview/lib/src/bridge.dart create mode 100644 packages/core_webview/lib/src/bridge_shim.dart create mode 100644 packages/core_webview/lib/src/h5_launch.dart create mode 100644 packages/core_webview/lib/src/page_watchdog.dart create mode 100644 packages/core_webview/lib/src/url_guard.dart create mode 100644 packages/core_webview/lib/src/webview_session.dart create mode 100644 packages/core_webview/pubspec.yaml create mode 100644 packages/core_webview/test/core_webview_test.dart create mode 100644 packages/feature_auth/analysis_options.yaml create mode 100644 packages/feature_auth/lib/feature_auth.dart create mode 100644 packages/feature_auth/lib/src/data/auth_repository.dart create mode 100644 packages/feature_auth/lib/src/presentation/login_controller.dart create mode 100644 packages/feature_auth/lib/src/presentation/login_page.dart create mode 100644 packages/feature_auth/lib/src/presentation/store_picker_page.dart create mode 100644 packages/feature_auth/lib/src/routes.dart create mode 100644 packages/feature_auth/pubspec.yaml create mode 100644 packages/feature_auth/test/login_page_test.dart create mode 100644 packages/feature_home/analysis_options.yaml create mode 100644 packages/feature_home/lib/feature_home.dart create mode 100644 packages/feature_home/lib/src/data/home_models.dart create mode 100644 packages/feature_home/lib/src/data/home_repository.dart create mode 100644 packages/feature_home/lib/src/presentation/home_page.dart create mode 100644 packages/feature_home/lib/src/presentation/home_providers.dart create mode 100644 packages/feature_home/lib/src/routes.dart create mode 100644 packages/feature_home/pubspec.yaml create mode 100644 packages/feature_home/test/home_page_test.dart create mode 100644 packages/native_scan/.gitignore create mode 100644 packages/native_scan/.metadata create mode 100644 packages/native_scan/CHANGELOG.md create mode 100644 packages/native_scan/LICENSE create mode 100644 packages/native_scan/README.md create mode 100644 packages/native_scan/analysis_options.yaml create mode 100644 packages/native_scan/android/.gitignore create mode 100644 packages/native_scan/android/build.gradle.kts create mode 100644 packages/native_scan/android/settings.gradle.kts create mode 100644 packages/native_scan/android/src/main/AndroidManifest.xml create mode 100644 packages/native_scan/android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt create mode 100644 packages/native_scan/android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt create mode 100644 packages/native_scan/example/.gitignore create mode 100644 packages/native_scan/example/README.md create mode 100644 packages/native_scan/example/analysis_options.yaml create mode 100644 packages/native_scan/example/android/.gitignore create mode 100644 packages/native_scan/example/android/app/build.gradle.kts create mode 100644 packages/native_scan/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/native_scan/example/android/app/src/main/AndroidManifest.xml create mode 100644 packages/native_scan/example/android/app/src/main/kotlin/com/conti/native_scan_example/MainActivity.kt create mode 100644 packages/native_scan/example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 packages/native_scan/example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/native_scan/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/native_scan/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/native_scan/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/native_scan/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/native_scan/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/native_scan/example/android/app/src/main/res/values-night/styles.xml create mode 100644 packages/native_scan/example/android/app/src/main/res/values/styles.xml create mode 100644 packages/native_scan/example/android/app/src/profile/AndroidManifest.xml create mode 100644 packages/native_scan/example/android/build.gradle.kts create mode 100644 packages/native_scan/example/android/gradle.properties create mode 100644 packages/native_scan/example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/native_scan/example/android/settings.gradle.kts create mode 100644 packages/native_scan/example/integration_test/plugin_integration_test.dart create mode 100644 packages/native_scan/example/ios/.gitignore create mode 100644 packages/native_scan/example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 packages/native_scan/example/ios/Flutter/Debug.xcconfig create mode 100644 packages/native_scan/example/ios/Flutter/Release.xcconfig create mode 100644 packages/native_scan/example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/native_scan/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/native_scan/example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/native_scan/example/ios/Runner/AppDelegate.swift create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 packages/native_scan/example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/native_scan/example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 packages/native_scan/example/ios/Runner/Info.plist create mode 100644 packages/native_scan/example/ios/Runner/Runner-Bridging-Header.h create mode 100644 packages/native_scan/example/ios/Runner/SceneDelegate.swift create mode 100644 packages/native_scan/example/ios/RunnerTests/RunnerTests.swift create mode 100644 packages/native_scan/example/lib/main.dart create mode 100644 packages/native_scan/example/pubspec.yaml create mode 100644 packages/native_scan/example/test/widget_test.dart create mode 100644 packages/native_scan/ios/.gitignore create mode 100644 packages/native_scan/ios/native_scan.podspec create mode 100644 packages/native_scan/ios/native_scan/Package.swift create mode 100644 packages/native_scan/ios/native_scan/Sources/native_scan/NativeScanPlugin.swift create mode 100644 packages/native_scan/ios/native_scan/Sources/native_scan/PrivacyInfo.xcprivacy create mode 100644 packages/native_scan/ios/native_scan/Sources/native_scan/ScanApi.g.swift create mode 100644 packages/native_scan/lib/native_scan.dart create mode 100644 packages/native_scan/lib/src/generated/scan_api.g.dart create mode 100644 packages/native_scan/pigeons/scan_api.dart create mode 100644 packages/native_scan/pubspec.yaml create mode 100644 packages/native_scan/test/native_scan_test.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..e16bb0b --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.44.9" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8374e2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# --------------------------------------------------------------------------- +# 产物策略来源:conti-docs/14-conventions-and-ci-gates.md §四 +# +# *.g.dart / *.freezed.dart 不提交(CI 会跑 melos run gen 重新生成) +# Pigeon 产物 提交(评审时能看到跨语言接口变更) +# 根 pubspec.lock 提交;各子包 pubspec.lock 不提交 +# --------------------------------------------------------------------------- + +# --- 代码生成产物 --- +**/*.g.dart +**/*.freezed.dart +**/*.mocks.dart + +# Pigeon 产物必须入库:上面的 **/*.g.dart 会误伤 native_* 的生成文件, +# 这里显式反选回来。生成的 Kotlin/Swift 用的是 .g.kt / .g.swift,不受影响。 +!packages/native_*/lib/src/generated/** + +# --- 依赖锁 --- +# 根 lock 提交(保证所有人和 CI 拿到同一套版本),子包 lock 不提交。 +app/pubspec.lock +packages/*/pubspec.lock +# Melos 8 + Pub Workspace 下不再生成 pubspec_overrides.yaml,保留兜底规则。 +**/pubspec_overrides.yaml + +# --- 签名与密钥:任何情况下都不能进仓库 --- +android/key.properties +app/android/key.properties +**/*.jks +**/*.keystore +**/*.p12 +**/*.mobileprovision +**/*.cer +.env +.env.* + +# --- Dart / Flutter --- +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +coverage/ +doc/api/ + +# --- FVM --- +.fvm/ + +# --- Android --- +app/android/.gradle/ +app/android/local.properties +app/android/app/debug/ +app/android/app/profile/ +app/android/app/release/ +**/GeneratedPluginRegistrant.java + +# --- iOS --- +app/ios/Pods/ +app/ios/.symlinks/ +app/ios/Flutter/Flutter.framework/ +app/ios/Flutter/Flutter.podspec +app/ios/Flutter/Generated.xcconfig +app/ios/Flutter/flutter_export_environment.sh +**/Podfile.lock +**/*.xcworkspace/xcuserdata/ +**/xcuserdata/ + +# --- 编辑器 --- +.idea/ +*.iml +.vscode/* +!.vscode/launch.json +!.vscode/settings.json +.DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..85cd0d2 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,140 @@ +# CI 门禁。来源:conti-docs/14-conventions-and-ci-gates.md + 08-build-flavors.md +# +# --------------------------------------------------------------------------- +# 门禁的立法目的(14 §一):**约定只有被机器强制才叫约定**。下面每一条都对应 +# 一条人工评审记不住、也不该由人来记的规则。 +# +# TODO(ops): 镜像名和 mac runner 的 tag 都是占位符,两项文档自己就列为待确认: +# - Flutter 镜像:需要一个预装 Flutter 3.44.9 的内网镜像(公网 ghcr 拉不动) +# - iOS 构建:需要一台 mac runner,目前团队没有 +# --------------------------------------------------------------------------- + +stages: + - verify + - test + - build + +default: + image: $FLUTTER_IMAGE # TODO(ops): 形如 registry.internal/flutter:3.44.9 + tags: + - docker + before_script: + - dart pub global activate melos 8.2.2 + - export PATH="$PATH:$HOME/.pub-cache/bin" + - melos bootstrap + +variables: + # 产物不入库,每一步 CI 都得自己生成一遍(14 §四)。 + PUB_CACHE: "$CI_PROJECT_DIR/.pub-cache" + +cache: + key: + files: + - pubspec.lock + paths: + - .pub-cache + +# --------------------------------------------------------------------------- +# verify:不跑测试就能发现的问题,全部拦在这里,因为它最快 +# --------------------------------------------------------------------------- +format: + stage: verify + script: + - melos run format + # 格式化不是审美问题:不统一的话每个 PR 的 diff 里都混着大段无关的换行改动, + # 评审会开始跳过 diff。 + +analyze: + stage: verify + script: + - melos run gen + - melos run analyze + # --fatal-infos 在 melos 脚本里。不加等于 lint 形同虚设——绝大部分 riverpod_lint + # 规则报的是 info 级别。 + +pigeon-consistency: + stage: verify + script: + - melos run gen:pigeon + # Pigeon 产物是入库的。改了 pigeons/*.dart 却忘了重新生成,是一个编译期 + # 完全看不出来、运行时才炸 MissingPluginException 的经典事故。 + - git diff --exit-code || (echo "Pigeon 产物与 pigeons/ 不一致,请本地跑 melos run gen:pigeon 后提交" && exit 1) + +# --------------------------------------------------------------------------- +# test +# --------------------------------------------------------------------------- +unit-test: + stage: test + script: + - melos run gen + - melos run test + coverage: '/lines\.*: \d+\.\d+\%/' + artifacts: + when: always + paths: + - packages/*/coverage/lcov.info + expire_in: 1 week + +# 覆盖率门禁单独一个 job:它会失败得比较频繁,混在 unit-test 里会让人分不清 +# 是"测试挂了"还是"覆盖率不够"。 +coverage-gate: + stage: test + needs: [unit-test] + allow_failure: true # TODO: 骨架期先不卡人,等业务代码进来再改成 false + script: + - dart pub global activate coverde + - melos run coverage + +# --------------------------------------------------------------------------- +# build:dev/uat 按分支出包,prod 只认 tag +# --------------------------------------------------------------------------- +.android-build: &android-build + stage: build + script: + - melos run gen + - cd app + - > + flutter build apk + --flavor $FLAVOR + -t lib/main_$FLAVOR.dart + --dart-define-from-file=env/$FLAVOR.json + $EXTRA_ARGS + artifacts: + paths: + - app/build/app/outputs/flutter-apk/*.apk + expire_in: 1 month + +android-dev: + <<: *android-build + variables: + FLAVOR: dev + rules: + - if: $CI_COMMIT_BRANCH == "develop" + +android-uat: + <<: *android-build + variables: + FLAVOR: uat + rules: + - if: $CI_COMMIT_BRANCH == "main" + +android-prod: + <<: *android-build + variables: + FLAVOR: prod + # release 包必须混淆 + 拆符号表,否则 Sentry 上的堆栈是一堆十六进制地址。 + # --split-debug-info 的产物要留着,sentry_dart_plugin 靠它还原堆栈。 + EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols + after_script: + # SENTRY_AUTH_TOKEN 只从 CI 变量读,仓库里任何文件都不许出现它。 + - cd app && dart run sentry_dart_plugin + artifacts: + paths: + - app/build/app/outputs/flutter-apk/*.apk + - app/build/symbols + expire_in: 1 year + rules: + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ + +# TODO(ops): iOS 构建需要 mac runner。配置步骤见 app/ios/FLAVORS.md, +# 在 Scheme 建好并进 git 之前,这个 job 加了也跑不通。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..266aacf --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# conti-retail-app + +大陆马门店 App。Flutter + Melos 单仓多包。 + +架构约束全部来自相邻仓库 `conti-docs/` 的 01–14 号文档。**本仓库对这些文档的 +偏离,逐条记在 [SCAFFOLD-NOTES.md](SCAFFOLD-NOTES.md)**——那份文件是回写文档的 +依据,改动之前先看一眼。 + +--- + +## 怎么跑起来 + +三步。不要跳过第二步。 + +```bash +# 0. 工具链(一次性) +fvm use 3.44.9 # 或者保证 PATH 上的 flutter 就是 3.44.9 +dart pub global activate melos 8.2.2 + +# 1. 装依赖 +melos bootstrap + +# 2. 生成代码(riverpod 的 *.g.dart 不入库,不跑这步全仓库编译不过) +melos run gen + +# 3. 跑起来 +cd app +flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json +``` + +`--flavor` / `-t` / `--dart-define-from-file` **三个参数缺一不可**: + +- 少了 `--dart-define-from-file`,`AppEnv.fromDartDefine` 会在启动瞬间抛错。 + 这是故意的——带着空 baseUrl 跑起来,问题会在第一个请求 404 时才暴露。 +- 少了 `-t`,Flutter 会去找 `lib/main.dart`,本仓库没有这个文件。 + +VS Code / Android Studio 用户建议把三条 flavor 配进 `launch.json`。 + +## 环境与工具链版本 + +跑通时的实测版本,与 `pubspec.lock` 一致: + +| | 版本 | 备注 | +|---|---|---| +| Flutter | 3.44.9 | `.fvmrc` 里锁着 | +| Dart | 3.12.2 | **Flutter 自带的那个**,见下面的坑 | +| Melos | 8.2.2 | 配置内联在根 `pubspec.yaml`,没有 `melos.yaml` | +| flutter_riverpod | 3.3.2 | | +| riverpod_annotation | 4.0.3 | | +| riverpod_generator | 4.0.4 | | +| build_runner | 2.15.1 | | +| analyzer | 12.1.0 | | + +> **riverpod 的版本被 `flutter_test` 卡着。** `flutter_test` pin 了 +> `test_api 0.7.11`,往上升 riverpod 会拉起不兼容的 `analyzer`,解析直接失败。 +> 想升级先确认这条链路,不要只看 pub.dev 上的最新版。 + +### 坑:机器上有两个 Dart SDK + +如果你的 PATH 上装了独立的 Dart SDK(`dart --version` 不等于 3.12.2),那么 + +```bash +dart run build_runner build # ← 会用错 SDK,报一堆看不懂的解析错误 +``` + +要显式用 Flutter 自带的那个: + +```bash +export FDART="$(dirname "$(which flutter)")/cache/dart-sdk/bin/dart" +$FDART run build_runner build +# fvm 用户:~/fvm/versions/stable/bin/cache/dart-sdk/bin/dart +``` + +`melos run gen` 内部走的是 `dart run`,所以同样受影响。最省事的做法是让 PATH +上只有 Flutter 自带的 Dart。 + +### 坑:melos 命令 + +pub cache 里只有 `melos.bat`,git-bash 下直接敲 `melos` 可能找不到。用: + +```bash +dart pub global run melos:melos +``` + +## 常用命令 + +```bash +melos run gen # 代码生成(riverpod) +melos run gen:watch # 开发期常驻 +melos run gen:pigeon # native_* 的 Pigeon 产物(产物入库) +melos run analyze # flutter analyze --fatal-infos,含 riverpod_lint +melos run format # dart format --set-exit-if-changed +melos run test # flutter test --coverage +``` + +`analyze` 带 `--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则 +报的是 info 级。 + +`riverpod_lint` 由 `flutter analyze` 直接执行(顶层 `plugins:` 映射),**不再 +需要 `custom_lint`**,也没有 `dart run custom_lint` 这一步。文档 03/14 写的还是 +旧方案,见 SCAFFOLD-NOTES §B。 + +## 包结构与依赖规则 + +``` +app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合 +packages/ + core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖 + core_logging/ AppLogger / CrashReporter / 脱敏 + core_analytics/ Analytics 接口 + 事件常量表 + core_storage/ Prefs(KV)。Drift 暂缓,见 SCAFFOLD-NOTES + core_auth/ AppSession 四态 / TokenStorage / SessionNotifier + core_network/ dio + 4 个拦截器 + ApiClient + core_router/ goRouterProvider / menuRouteMap / go_router re-export + core_ui/ 主题 / AsyncValueView / ErrorPresenter + core_webview/ UrlGuard / JSBridge / WebViewSession + feature_auth/ 登录、选店 + feature_home/ 工作台 + native_scan/ Pigeon 扫码接口(原生实现待补) +``` + +依赖规则(01): + +- `feature_* → core_* / native_*`。**feature 之间禁止互相依赖**——两个 feature + 要共享东西,说明那个东西属于某个 core_*。 +- `core_* → core_*` 只允许三条边:`core_network → core_auth`、 + `core_router → core_auth`、`core_webview → core_auth`。外加所有包都可以依赖 + 叶子包 `core_foundation`。 +- `native_* ` 只依赖 Flutter SDK 和 Pigeon 产物,**一个 core_* 都不依赖**。 +- feature 的 pubspec 里**不出现 `go_router`**:路由类型由 `core_router` + re-export。 + +### 这些规则靠 `--fatal-infos` 守,不是靠编译器(实测结论) + +直觉上会以为「没在 pubspec 里声明就 import 不到」。**在 Pub Workspace 里这是错的**: +所有成员包共用根目录一份 `.dart_tool/package_config.json`,任何成员都能解析到 +任何其他成员。实测在 `feature_home` 里 import `feature_auth` 而不声明依赖: + +``` +flutter test → All tests passed! ← 编译通过,跑得起来 +flutter analyze → info: depend_on_referenced_packages +``` + +只有一条 **info**。所以: + +> **`melos run analyze` 的 `--fatal-infos` 是这套包边界唯一的强制点。** +> 谁把它从 CI 里拿掉,或者在某个包里 ignore 掉 +> `depend_on_referenced_packages`,边界当天就失效,而且没有任何别的信号。 + +想自己复现:在 `feature_home/lib/src/` 下扔一个 import `feature_auth` 的文件, +`flutter test` 是绿的,`flutter analyze --fatal-infos` 是红的。 + +### 那些 `throw UnimplementedError('必须在 bootstrap 里 override')` + +依赖规则会挡住一些**合理**的调用(比如 core_auth 想发 HTTP、core_webview 想 +换票)。这些地方一律用依赖反转解决:包内声明 `abstract interface` + 一个会抛错 +的 provider,实现落在 `app/lib/bootstrap.dart` 里 override 进去。 + +新增一个这样的端口时,**必须同时在 bootstrap 里接上**——它是运行期才炸的, +编译器帮不了你。 + +## 待办 / 阻塞项 + +- **iOS flavor 未配置**:Scheme 和 Build Configuration 只能在 Xcode 里建, + 步骤见 [`app/ios/FLAVORS.md`](app/ios/FLAVORS.md)。目前没有 Mac 构建机。 +- **native_scan 没有原生实现**:Pigeon 接口和 Dart 侧齐了,Kotlin/Swift 侧是 + 模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。 +- **神策 SDK 未采购**:`analyticsProvider` 是 `NoopAnalytics`。接入时注意必须在 + 用户同意隐私政策之后再初始化。 +- **env/*.json 里全是占位域名**,`SENTRY_DSN` 全空,待运维确认。 +- **CI 镜像名未定**,`.gitlab-ci.yml` 里标了 TODO(ops)。 diff --git a/SCAFFOLD-NOTES.md b/SCAFFOLD-NOTES.md new file mode 100644 index 0000000..8975200 --- /dev/null +++ b/SCAFFOLD-NOTES.md @@ -0,0 +1,222 @@ +# 脚手架落地记录:偏离文档的地方 + +这份文件是给 `conti-docs` 的**回写清单**。搭这个骨架的过程中,文档里有一部分 +内容无法照抄——有的是版本过期,有的是两篇文档互相矛盾,有的是照抄会直接编译 +不过。每一条的裁决都记在下面,代码里对应位置也留了注释。 + +**本次没有改动 `conti-docs` 仓库的任何文件。** 更新 01 / 03 / 07 / 12 / 14 四篇 +文档时以这份清单为准。 + +日期:2026-08-17。工具链:Flutter 3.44.9 / Dart 3.12.2 / Melos 8.2.2。 + +--- + +## A. 版本纠错 + +文档 03 写的 riverpod 版本已经过期,但**不能直接升到 pub.dev 上的最新版**: + +| 包 | 文档 | 最新 | 本仓库实际 | 为什么不是最新 | +|---|---|---|---|---| +| `riverpod_annotation` | `^3.4.2` | 4.0.6 | **4.0.3** | 见下 | +| `riverpod_generator` | `^3.4.2` | 4.0.8 | **4.0.4** | 见下 | +| `flutter_riverpod` | `^3.4.2` | 3.4.2 | **3.3.2** | 见下 | + +**约束来自 `flutter_test`**:它 pin 了 `test_api 0.7.11`,而 riverpod 4.0.6+ 要 +`analyzer 13.x`,两者的解析结果冲突。往上升之前先确认这条链路,光看 pub.dev 的 +最新版会浪费半天。 + +`build_runner 2.15.1` / `analyzer 12.1.0` 是被上面这条连带定死的。 + +其余 22 个包与文档一致。 + +## B. `riverpod_lint` 不再走 `custom_lint` + +文档 03/14 要求装 `custom_lint`、在 `analysis_options.yaml` 里写 +`analyzer: plugins: - custom_lint`、CI 里单独跑 `dart run custom_lint`。 + +**实测 `riverpod_lint 3.1.8` 的依赖里没有 `custom_lint`**,它依赖 +`analysis_server_plugin`。官方 changelog: + +> 3.1.0 — `riverpod_lint` is no-longer implemented using `custom_lint`, but +> instead `analysis_server_plugin` + +因此本仓库: + +- `analysis_options.yaml` 用**顶层** `plugins:` 映射,不是 `analyzer.plugins`; +- 所有 `dev_dependencies` 里没有 `custom_lint`; +- CI 里没有 `dart run custom_lint` 这一步,lint 由 `flutter analyze` 直接执行。 + +**顺带解决了 01 和 14 关于「custom_lint 装根还是装每个包」的长期矛盾**——这个 +问题不存在了。 + +## C. 新增了一个文档里没有的包:`core_foundation` + +这是本次唯一的结构性增补。理由是文档现有的依赖规则**自相矛盾**,不加就无法 +同时满足: + +- 文档 12 把 `sealed AppException` 放在 `core_network`。但这个体系里有 + `StorageException`(`core_storage` 要用)和 `UnauthorizedException` + (`core_auth` 要用),而 01 明令 `core_storage`/`core_auth` **不许**依赖 + `core_network`。 +- `AppEnv` 被 `core_network`(baseUrl)、`core_auth`(TokenRefresher)、 + `core_webview`(域名白名单)、`core_logging`(日志级别)同时需要。放进任何 + 一个现有 `core_*` 都会造出非法依赖边或循环。 + +方案:`packages/core_foundation` 是一个**叶子包**,只依赖 `flutter_riverpod`, +内容是 `AppEnv` + `AppException` 体系 + `NetworkErrorKind` + `ApiCode` + +`NativeErrorCode`。所有 `core_*` / `feature_*` 都可以依赖它;`native_*` **不** +依赖(保住 01 那条「native_* 只依赖 Flutter SDK 和 Pigeon 产物」)。 + +叶子包不可能形成循环,01「core_* 之间不互相依赖」的立法目的(防循环)不受损。 + +`PageQuery` / `PageResult` 按文档 02 留在 `core_network`。 + +## D. `native_*` 无法抛 `AppException` + +文档 07 写「原生异常不外泄,统一转成 AppException 体系」,但 `native_*` 不许 +依赖任何 `core_*`(含 `core_foundation`)。 + +裁决:`native_scan` 抛包内自定义的 `NativeScanException`(纯 Dart 无依赖), +**映射到 `NativeException` 的动作放在调用方**(feature_scan / core_webview 的 +bridge handler)。文档 07 的 `NativeCapabilityException` / +`UnsupportedPlatformException` 收敛成文档 12 的 `NativeException(code, message)`。 + +## E. 逐条冲突裁决 + +| # | 冲突 | 裁决 | +|---|---|---| +| 1 | `BusinessException` 构造签名:05 用具名,12 用位置参数 | 按 **12**(定义类的那篇),05 的拦截器代码相应调整 | +| 2 | 05 的 `HttpException` 不在 12 的 sealed 体系里,且与 `dart:io` 同名 | 改用 12 的 `ServerException` | +| 3 | `NetworkErrorKind` 被 12 的 `present()` 用了但从未声明 | 在 core_foundation 声明;`ErrorMappingInterceptor` 负责填 | +| 4 | `UnauthorizedException` / `RequestCancelledException` / `StorageException` 无构造函数,父类却要求位置参数 | 各补 `const` 构造 + 默认文案 | +| 5 | **`PreconditionException` 不在 12 的 `present()` switch 里** | 补分支。sealed 穷尽,不补**编译不过** | +| 6 | `AppException.bridgeCode` 被 core_webview 用了但未定义 | 在 `AppException` 上加 getter,子类 override。码表待与 F6 对齐 | +| 7 | 05 的拦截器顺序正文与附录不一致 | 按**附录**:Header → Log → Auth → ApiResult → ErrorMapping | +| 8 | 06 有两个 `readAccessToken` 实现 | 用带 try/catch 兜底那版:读失败按未登录处理 + `clear()`,绝不让异常逃进启动流程 | +| 9 | `currentStoreIdProvider` 返回非空且会 throw(11),但 05 的 `HeaderInterceptor` 写 `if (storeId != null)` | 提供**两个** provider:`currentStoreIdProvider`(非空,业务用)和 `currentStoreIdOrNullProvider`(可空,基础设施用) | +| 10 | melos 脚本名 `pigeon`(01)vs `gen:pigeon`(14) | 统一 `gen:pigeon` | +| 11 | `analyze` 脚本 01 无 `--fatal-infos`,14 的 CI 有 | 脚本里加上 | +| 12 | melos 脚本里 `flutter` vs `fvm flutter` | 用裸 `flutter`(按 01),版本靠 `.fvmrc` + PATH 保证 | +| 13 | 02 的 `PageResult.hasMore` 计算逻辑有误 | 用后端返回的 `hasMore`,不在客户端算 | +| 14 | 02 正文说 `data/repository_impl/`,示例写 `data/repository/` | 统一 `data/repository/` | +| 15 | `Analytics` 接口:13 正文说 `login()`/`logout()`,代码块用 `identify()`/`reset()` | 按代码块 | + +## F. 本次范围调整 + +- **Drift / 本地数据库暂缓**。骨架阶段没有任何业务表,`core_storage` 现在只有 + KV 一档(`Prefs`)。文档 06 的表结构、迁移策略、`{storeId, orderId}` 联合主键 + 规则仍然有效,等第一张业务表落地时按 06 建。 + > 顺带:melos 8.x 和 `drift_dev` 有一个 `cli_util` 的版本冲突,接 Drift 时会 + > 撞上,先有个心理准备。 +- **原生 Kotlin/Swift 实现不做**。`native_scan` 的 Pigeon 接口和 Dart 侧齐了, + 原生侧是 `flutter create` 的模板 + TODO。 +- **不做 lefthook**、不做 `git init` / 首次提交(按需求)。 +- **i18n 只做结构预留**:`app/l10n.yaml` + 一个 `app_zh.arb`,文案还写在 Widget + 里。理由见 conti-docs README 的待补充清单——等 30 个页面都写死中文再抽,成本 + 是现在的几十倍。 + +## G. 依赖反转("端口")模式 + +**这是本次落地里最值得回写文档的一条。** 文档里有若干处伪代码违反了 01 自己 +定的依赖规则: + +| 文档处 | 想做的事 | 为什么不行 | +|---|---|---| +| 11 `SessionNotifier` | 调 `authRepository.login()` | core_auth 不许依赖 core_network / feature_* | +| 11 切店第 6 步 | `goRouterProvider.go('/home')` | core_auth 不许依赖 core_router | +| 11 切店级联 | 清 WebView cookie | core_auth 不许依赖 core_webview | +| 05 `HeaderInterceptor` | 读 `deviceIdProvider` | core_network 不许依赖 core_storage | +| 05 `ApiResultInterceptor` | 收一个 `AppLogger` | core_network 不许依赖 core_logging | +| 10 H5 换票 | 发 `/h5/launch` 请求 | core_webview 不许依赖 core_network | +| 04 未知菜单编码 | 上报埋点 | core_router 不许依赖 core_analytics | + +统一解法:**包内只声明"我需要什么",不声明"谁来满足"**。 + +```dart +// core_auth/lib/src/session_ports.dart +abstract interface class SessionRemote { Future fetchCurrentUser(); ... } + +final Provider sessionRemoteProvider = Provider( + (Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'), +); +``` + +```dart +// app/lib/bootstrap.dart —— 唯一知道所有包的地方 +sessionRemoteProvider.overrideWith((Ref ref) => ref.watch(authRepositoryProvider)), +``` + +现有的端口文件: + +- `core_auth/lib/src/session_ports.dart` — `SessionRemote` / `SessionScopedStore` + / `SessionObserver` +- `core_network/lib/src/ports.dart` — `ClientInfo` / `ApiLogSink` +- `core_router/lib/src/ports.dart` — `appRoutesProvider` / + `navigatorObserversProvider` / `RouteReporter` +- `core_webview/lib/src/h5_launch.dart` — `H5LaunchRepository` + +代价要说清楚:**这些 override 是运行期才炸的,编译器不管**。新增一个端口就要 +同时在 `bootstrap()` 里接上。默认值给不给有讲究——`appEnvProvider` / +`sessionRemoteProvider` 故意不给(忘了接必须立刻炸),`apiLogSinkProvider` / +`sessionObserversProvider` 给空实现(忘了接只是没日志,不影响功能)。 + +切店后回工作台那一条,实现成了 `goRouterProvider` 里的一个 `ref.listen`: + +```dart +if (before is SessionActive && after is SessionActive && + before.store.storeId != after.store.storeId) { + router.go(AppRoutes.home); // go 而不是 push:替换整个栈 +} +``` + +方向反过来了(core_router 观察 core_auth,而不是 core_auth 调 core_router), +符合允许的依赖边。 + +## H. Pub Workspace 让「包边界靠编译器强制」这句话不成立 + +**这条建议直接回写进 01。** 01 的立论是「违反分包规则的代码根本写不出来, +因为 pubspec 里没声明就 import 不到」。在 Pub Workspace 下**这是错的**:所有 +成员包共用根目录一份 `.dart_tool/package_config.json`,任何成员都能解析到任何 +其他成员,不管 pubspec 里写没写。 + +实测(在 `feature_home/lib/src/` 放一个 import `feature_auth` 的文件, +`feature_home/pubspec.yaml` 里**不**声明这条依赖): + +``` +flutter test → All tests passed! 编译通过,跑得起来 +flutter analyze --fatal-infos → info: depend_on_referenced_packages ✗ +``` + +只有一条 **info**级 lint。结论: + +> **`--fatal-infos` 是这套包边界唯一的强制点。** 它没了,边界当天失效,且 +> 没有任何别的信号——不会编译失败,测试还是绿的。 + +所以 §E 第 11 条(`analyze` 脚本要不要加 `--fatal-infos`)不是风格问题,是 +**这套架构成不成立的问题**,01 和 14 都应该按这个高度重写那一段。相应地, +`depend_on_referenced_packages` 不允许在任何包的 `analysis_options.yaml` 里被 +ignore,建议在 14 的 CI 门禁一节里单列。 + +(改用 path dependency 而不是 workspace 可以拿回编译期强制,代价是失去统一 +版本解析——不建议为此推翻 workspace,把 `--fatal-infos` 守住即可。) + +## I. 写代码时踩到的坑(Riverpod 3 / flutter_test) + +不属于文档偏离,但会反复咬人,记在这里: + +1. **`AsyncValue.valueOrNull` 没了**,用 `.value`。 +2. **`copyWithPrevious` 是 internal**,用了会报 `invalid_use_of_internal_member`。 +3. **`Override` 类型不公开导出**。`overrides: [...]` 不要写类型参数。 +4. **riverpod_generator 4.x 只剥 `Notifier` 后缀**:`SessionNotifier` → + `sessionProvider`,但 `LoginController` → `loginControllerProvider`。 +5. **所有 provider 默认 autoDispose**。测试里 `container.read` 之后没有监听者, + 状态立刻被回收;要 `container.listen(p, (_, _) {})` 顶住。 +6. **`ProviderObserver` 是 `base` 类**,子类必须标 `final` / `base` / `sealed`。 +7. **`testWidgets` 跑在 fake async 区里**:里面 `await` 一个真实的 Future + (比如 `container.refresh(p.future)`)**永远不会完成**,会挂到 10 分钟超时。 + 碰真 provider 生命周期必须包一层 `await tester.runAsync(() async { ... })`。 +8. **`ProviderContainer` + 异步 provider**:同步 `read` 拿到的是 loading 态, + 要先 `await container.read(p.future)`。 +9. Pigeon 生成的 `@HostApi` 是**具体类**不是接口,测试替身要 `extends` 不是 + `implements`。 +10. 局部变量别叫 `fail`——会遮蔽 `flutter_test` 的 `fail()`。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..b7da340 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,63 @@ +# 根级共享 lint 配置。 +# 来源:conti-docs/14-conventions-and-ci-gates.md §二 +# 每个子包的 analysis_options.yaml 只写一行 include 指向本文件,禁止在子包里 +# 放宽规则——放宽必须改这里,让所有人一起讨论。 + +include: package:flutter_lints/flutter.yaml + +# --------------------------------------------------------------------------- +# 偏离文档说明(riverpod_lint 3.1.0 起的破坏性变更) +# +# 文档 03/14 写的是 `analyzer: plugins: - custom_lint` + CI 里单独跑 +# `dart run custom_lint`。但 riverpod_lint 3.1.0 已经从 custom_lint 迁移到 +# analysis_server_plugin: +# > `riverpod_lint` is no-longer implemented using `custom_lint`, +# > but instead `analysis_server_plugin`. —— riverpod_lint CHANGELOG 3.1.0 +# 实测 riverpod_lint 3.1.8 的依赖里已无 custom_lint。 +# +# 因此:插件改在下面的顶层 plugins: 段声明,规则由 `dart analyze` / +# `flutter analyze` 直接执行,CI 里不再需要独立的 custom_lint 步骤。 +# 详见根目录 SCAFFOLD-NOTES.md §B。 +# --------------------------------------------------------------------------- +plugins: + riverpod_lint: ^3.1.8 + +analyzer: + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + errors: + # freezed / riverpod 生成器会在非法位置放注解,这是生成器的正常行为 + invalid_annotation_target: ignore + # 下面三条从 warning 提到 error:它们几乎总是真实缺陷 + unused_import: error + dead_code: error + unawaited_futures: error + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/generated/**" + +formatter: + page_width: 100 + +linter: + rules: + # --- 可读性 --- + - always_declare_return_types + - prefer_single_quotes + - require_trailing_commas + - directives_ordering + - sort_pub_dependencies + # --- 正确性(这几条是真正拦过线上事故的) --- + - avoid_dynamic_calls + - avoid_slow_async_io + - cancel_subscriptions + - close_sinks + - discarded_futures + - unawaited_futures + - no_adjacent_strings_in_list + - test_types_in_equals + - throw_in_finally + - unnecessary_statements diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..db0a0fe --- /dev/null +++ b/app/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6b182d2c7585eba26d4edce0f97630effd256c33" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: android + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: ios + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..9259a7d --- /dev/null +++ b/app/README.md @@ -0,0 +1,17 @@ +# retail + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..5e2133e --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1 @@ +include: ../analysis_options.yaml diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..bf203ee --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,91 @@ +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// 签名配置从 key.properties 读,**这个文件和 keystore 都不进 git**(见 08 / 14)。 +// 缺文件时不报错、退回 debug 签名,这样新同事 clone 下来就能 `flutter run`。 +val keystoreProperties = Properties().apply { + val f = rootProject.file("key.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + +android { + namespace = "com.conti.retail" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.conti.retail" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + // ------------------------------------------------------------------------ + // flavor 三件套(08)。要点: + // 1. 用 applicationIdSuffix 而不是覆盖 applicationId——后者一改,Firebase / + // 推送 / 应用市场的包名对应关系全部要重配一遍; + // 2. app_name 走 resValue,让三个环境在桌面上一眼能分清。装了三个图标都叫 + // "大陆马门店"的时候,测试提的 bug 会有一半定位不到环境; + // 3. prod 没有后缀,就是正式包名。 + // ------------------------------------------------------------------------ + flavorDimensions += "env" + productFlavors { + create("dev") { + dimension = "env" + applicationIdSuffix = ".dev" + resValue("string", "app_name", "马店(开发)") + } + create("uat") { + dimension = "env" + applicationIdSuffix = ".uat" + resValue("string", "app_name", "马店(测试)") + } + create("prod") { + dimension = "env" + resValue("string", "app_name", "大陆马门店") + } + } + + signingConfigs { + if (keystoreProperties.isNotEmpty()) { + create("release") { + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + } + } + } + + buildTypes { + release { + signingConfig = if (keystoreProperties.isNotEmpty()) { + signingConfigs.getByName("release") + } else { + // TODO(ops): 正式密钥到位前用 debug 签名,**不能这样发版**。 + signingConfigs.getByName("debug") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a581cee --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/conti/retail/MainActivity.kt b/app/android/app/src/main/kotlin/com/conti/retail/MainActivity.kt new file mode 100644 index 0000000..6b5c5bd --- /dev/null +++ b/app/android/app/src/main/kotlin/com/conti/retail/MainActivity.kt @@ -0,0 +1,5 @@ +package com.conti.retail + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/xml/network_security_config.xml b/app/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..81901ca --- /dev/null +++ b/app/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/app/env/dev.json b/app/env/dev.json new file mode 100644 index 0000000..03b85cb --- /dev/null +++ b/app/env/dev.json @@ -0,0 +1,7 @@ +{ + "_comment": "dev 环境。构建时必须带 --dart-define-from-file=env/dev.json,否则 AppEnv.fromDartDefine 会直接抛错。所有地址都是占位符,TODO(ops) 待运维确认真实域名。", + "API_BASE_URL": "https://api-dev.example.com", + "ENABLE_LOG": true, + "SENTRY_DSN": "", + "H5_ALLOWED_HOSTS": "h5-dev.example.com" +} diff --git a/app/env/prod.json b/app/env/prod.json new file mode 100644 index 0000000..169e015 --- /dev/null +++ b/app/env/prod.json @@ -0,0 +1,7 @@ +{ + "_comment": "prod 环境。ENABLE_LOG 必须为 false(13:生产不写控制台、不落日志文件)。TODO(ops) 待运维确认真实域名与 Sentry DSN。", + "API_BASE_URL": "https://api.example.com", + "ENABLE_LOG": false, + "SENTRY_DSN": "", + "H5_ALLOWED_HOSTS": "h5.example.com" +} diff --git a/app/env/uat.json b/app/env/uat.json new file mode 100644 index 0000000..cc13586 --- /dev/null +++ b/app/env/uat.json @@ -0,0 +1,7 @@ +{ + "_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。", + "API_BASE_URL": "https://api-uat.example.com", + "ENABLE_LOG": true, + "SENTRY_DSN": "", + "H5_ALLOWED_HOSTS": "h5-uat.example.com" +} diff --git a/app/integration_test/app_test.dart b/app/integration_test/app_test.dart new file mode 100644 index 0000000..bb52132 --- /dev/null +++ b/app/integration_test/app_test.dart @@ -0,0 +1,33 @@ +// 端到端骨架。来源:conti-docs/09-testing-strategy.md §集成测试。 +// +// --------------------------------------------------------------------------- +// 09 的原则:**集成测试只覆盖"跨层出问题就没人发现"的主干链路**,不覆盖分支。 +// 现在能跑通的只有第一段(启动 → 停在登录页),后面几段等真实后端环境和测试 +// 账号到位后逐段打开——测试打桩到"点了按钮什么都没验证"是负资产。 +// +// 跑法: +// flutter test integration_test/app_test.dart \ +// --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json +// --------------------------------------------------------------------------- + +import 'package:app/bootstrap.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('冷启动后停在登录页', (WidgetTester tester) async { + await bootstrap(AppEnv.fromDartDefine(flavor: 'dev')); + await tester.pumpAndSettle(); + + // 没有 token → SessionUnauthenticated → redirect 到 /login(04)。 + expect(find.byKey(const Key('login_username')), findsOneWidget); + }); + + // TODO(09): 登录 → 选店 → 工作台 → 采购下单 → 支付回跳。 + // 需要 UAT 环境的常驻测试账号和一家固定测试门店;账号一变整条链路就红, + // 所以在账号方案定下来之前不写死。 +} diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/FLAVORS.md b/app/ios/FLAVORS.md new file mode 100644 index 0000000..57264c4 --- /dev/null +++ b/app/ios/FLAVORS.md @@ -0,0 +1,76 @@ +# iOS flavor 手工配置步骤 + +**这一步必须在 macOS + Xcode 上做,命令行做不到。** Xcode 的 Build +Configuration 和 Scheme 存在 `Runner.xcodeproj/project.pbxproj` 里,那是一份 +Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开时被改乱,出的问题 +("某个 target 的某个配置莫名其妙丢了")极难排查。 + +所以这个仓库只提供三份 `.xcconfig`(`ios/Flutter/{Dev,Uat,Prod}.xcconfig`), +剩下的连线由第一个拿到 Mac 的人做一次,之后进 git。 + +> 08-build-flavors.md 已经把 iOS 侧列为**头号阻塞项**——目前团队没有可用的 +> Mac 构建机,Apple 开发者账号也未确认。在那之前 iOS 只能跑默认配置。 + +## 步骤 + +1. 打开 `app/ios/Runner.xcworkspace`(不是 `.xcodeproj`)。 + +2. 选中项目 → **Info** → **Configurations**。此时应该有 `Debug` / `Release` / + `Profile` 三条。对每一条点 `+` → **Duplicate ... Configuration**,复制成: + + | 原 | 复制为 | + |---|---| + | Debug | `Debug-dev`、`Debug-uat`、`Debug-prod` | + | Release | `Release-dev`、`Release-uat`、`Release-prod` | + | Profile | `Profile-dev`、`Profile-uat`、`Profile-prod` | + + 一共 9 条。**名字必须完全是 `<原名>-`**:`flutter run --flavor dev` + 就是靠这个命名约定找配置的,写成 `Debug-Dev` 都不行。 + + 做完之后把原来的 `Debug` / `Release` / `Profile` 删掉。 + +3. 每条配置的 Runner target 那一列,选对应的 xcconfig: + `*-dev` → `Flutter/Dev.xcconfig`,`*-uat` → `Flutter/Uat.xcconfig`, + `*-prod` → `Flutter/Prod.xcconfig`。 + + > 注意:Flutter 生成的 `Debug.xcconfig` / `Release.xcconfig` 里 + > `#include "Generated.xcconfig"` 不能丢。三份 flavor 配置需要在开头补上 + > `#include "Debug.xcconfig"`(或 `Release.xcconfig`)——`flutter build` 靠 + > `Generated.xcconfig` 传 `FLUTTER_TARGET` 等参数,丢了会构建失败且报错 + > 信息完全指不到这里。 + +4. **Product → Scheme → Manage Schemes**,把 `Runner` 复制成 `dev` / `uat` / + `prod` 三个 Scheme(名字就是 flavor 名),各自 Edit Scheme: + - Run → Build Configuration → `Debug-` + - Profile → `Profile-` + - Archive → `Release-` + - 三个 Scheme 都要勾 **Shared**,否则不进 git,只有你自己的机器上有。 + +5. `Runner/Info.plist` 里把 + ```xml + CFBundleDisplayName + Retail + ``` + 改成 + ```xml + CFBundleDisplayName + $(APP_DISPLAY_NAME) + ``` + **改完必须先做完第 3 步**:`APP_DISPLAY_NAME` 只在三份 flavor xcconfig 里 + 定义,没接上就是空的桌面名。 + +6. 验证: + ```bash + cd app + flutter build ios --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json + ``` + 三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。 + +## 已知的坑 + +- **CocoaPods 和 flavor 无关**:`Podfile` 不需要改,pod 是按 target 装的, + 不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则 + 会报 `Unable to find a configuration named 'Debug-dev'`。 +- **`--flavor` 和 `-t` 必须同时给**。只给 `--flavor dev` 会用默认的 + `lib/main.dart`——这个文件在本仓库里**不存在**(入口是 `main_dev.dart`), + 报错信息是找不到文件,和 flavor 看不出关系。 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Dev.xcconfig b/app/ios/Flutter/Dev.xcconfig new file mode 100644 index 0000000..1db6ec6 --- /dev/null +++ b/app/ios/Flutter/Dev.xcconfig @@ -0,0 +1,10 @@ +// dev 环境。由 Xcode 里名为 "Debug-dev" / "Release-dev" / "Profile-dev" +// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。 +// +// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。 + +BUNDLE_ID_SUFFIX=.dev +APP_DISPLAY_NAME=马店(开发) + +// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。 +PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX) diff --git a/app/ios/Flutter/Prod.xcconfig b/app/ios/Flutter/Prod.xcconfig new file mode 100644 index 0000000..46c592b --- /dev/null +++ b/app/ios/Flutter/Prod.xcconfig @@ -0,0 +1,10 @@ +// prod 环境。由 Xcode 里名为 "Debug-prod" / "Release-prod" / "Profile-prod" +// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。 +// +// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。 + +BUNDLE_ID_SUFFIX= +APP_DISPLAY_NAME=大陆马门店 + +// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。 +PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX) diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Uat.xcconfig b/app/ios/Flutter/Uat.xcconfig new file mode 100644 index 0000000..b60f830 --- /dev/null +++ b/app/ios/Flutter/Uat.xcconfig @@ -0,0 +1,10 @@ +// uat 环境。由 Xcode 里名为 "Debug-uat" / "Release-uat" / "Profile-uat" +// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。 +// +// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。 + +BUNDLE_ID_SUFFIX=.uat +APP_DISPLAY_NAME=马店(测试) + +// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。 +PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX) diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..70cffd3 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..33e30f5 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Retail + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + retail + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/l10n.yaml b/app/l10n.yaml new file mode 100644 index 0000000..dc3c97b --- /dev/null +++ b/app/l10n.yaml @@ -0,0 +1,8 @@ +# 16-i18n.md 还没写,这份配置是**结构预留**:等 arb 方案定了,把散在 Widget +# 里的中文文案迁进 lib/l10n/app_zh.arb,代码侧只多一个 import。 +# +# 首版就留好的理由(见 conti-docs/README 待补充清单):等 30 个页面都写死中文 +# 再回来抽,成本是现在的几十倍。 +arb-dir: lib/l10n +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart diff --git a/app/lib/bootstrap.dart b/app/lib/bootstrap.dart new file mode 100644 index 0000000..950c22f --- /dev/null +++ b/app/lib/bootstrap.dart @@ -0,0 +1,148 @@ +/// 三个入口共用的启动编排。 +/// +/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。 +library; + +import 'package:app/src/app_widget.dart'; +import 'package:app/src/device_id.dart'; +import 'package:app/src/error_observer.dart'; +import 'package:app/src/h5_launch_repository.dart'; +import 'package:app/src/session_observer.dart'; +import 'package:core_analytics/core_analytics.dart'; +import 'package:core_auth/core_auth.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_logging/core_logging.dart'; +import 'package:core_network/core_network.dart'; +import 'package:core_router/core_router.dart'; +import 'package:core_storage/core_storage.dart'; +import 'package:core_webview/core_webview.dart'; +import 'package:feature_auth/feature_auth.dart'; +import 'package:feature_home/feature_home.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。 +const String _appVersion = '1.0.0+1'; + +/// 全部三个 main_*.dart 都只调这一个函数。 +/// +/// --------------------------------------------------------------------------- +/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要 +/// 什么(端口 + provider),谁来满足它在这里决定——这就是那些 +/// `throw UnimplementedError('必须在 bootstrap 里 override')` 的兑现点。 +/// +/// 每加一个 override 就等于在编译期之外多了一个"忘了接就炸"的风险,所以下面 +/// 每一条都标注了它兑现的是哪个端口。 +/// --------------------------------------------------------------------------- +Future bootstrap(AppEnv env) async { + WidgetsFlutterBinding.ensureInitialized(); + // 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。 + AppEnv.install(env); + + final LogBuffer buffer = LogBuffer(); + final AppLogger logger = LoggerAppLogger(env: env, buffer: buffer); + + // dsn 为空(dev 默认)时连 SDK 都不初始化:开发期的噪音不该混进线上数据。 + final bool sentryEnabled = env.sentryDsn.isNotEmpty; + final CrashReporter reporter = sentryEnabled + ? const SentryCrashReporter() + : const NoopCrashReporter(); + + final Prefs prefs = Prefs(); + final ClientInfo clientInfo = ClientInfo( + appVersion: _appVersion, + deviceId: await loadOrCreateDeviceId(prefs), + ); + + // 12 §五:默认的红屏在 release 里是白屏加一行英文,用户只会以为 App 坏了。 + ErrorWidget.builder = (FlutterErrorDetails details) => env.isProd + ? const Material(child: Center(child: Text('页面出了点问题,请退出重试'))) + : ErrorWidget(details.exception); + + Widget buildApp() { + return ProviderScope( + observers: [ErrorObserver(logger: logger, reporter: reporter)], + overrides: [ + // --- 基础设施:值已经在上面造好了,直接注入 ----------------------- + appEnvProvider.overrideWithValue(env), // core_foundation + appLoggerProvider.overrideWithValue(logger), // core_logging + logBufferProvider.overrideWithValue(buffer), // core_logging(和 beforeSend 同一实例) + crashReporterProvider.overrideWithValue(reporter), // core_logging + prefsProvider.overrideWithValue(prefs), // core_storage + clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口 + // --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 -------- + sessionRemoteProvider.overrideWith( + (Ref ref) => ref.watch(authRepositoryProvider), // core_auth ← feature_auth + ), + h5LaunchRepositoryProvider.overrideWith( + (Ref ref) => ApiH5LaunchRepository(ref.watch(apiClientProvider)), // core_webview ← app + ), + + // --- 会话级联的参与者 --------------------------------------------- + // 登出/切店时要被清掉的东西在这里登记。**漏登记 = 上一个用户的数据 + // 留在设备上**,而门店设备是共用的(11)。 + sessionScopedStoresProvider.overrideWith( + (Ref ref) => [ref.watch(webViewSessionProvider)], + ), + sessionObserversProvider.overrideWith( + (Ref ref) => [ + AppSessionObserver( + analytics: ref.watch(analyticsProvider), + reporter: ref.watch(crashReporterProvider), + ), + ], + ), + + // --- 路由聚合:core_router 不认识任何 feature,在这里拼 ------------- + appRoutesProvider.overrideWith( + (Ref ref) => [...buildAuthRoutes(), ...buildHomeRoutes()], + ), + navigatorObserversProvider.overrideWith( + (Ref ref) => [ + CrashBreadcrumbObserver(ref.watch(crashReporterProvider)), + ], + ), + routeReporterProvider.overrideWith( + (Ref ref) => AppRouteReporter( + logger: ref.watch(appLoggerProvider), + reporter: ref.watch(crashReporterProvider), + ), + ), + + // --- 日志出口:core_network 只知道"往这里写字符串" ------------------- + apiLogSinkProvider.overrideWith((Ref ref) { + final AppLogger sink = ref.watch(appLoggerProvider); + return (String message) => sink.d(message); + }), + + // TODO(analytics): 神策采购未落地,暂用 NoopAnalytics(core_analytics 的默认值)。 + // 接入时在这里 override,且必须在**用户同意隐私政策之后**才初始化 SDK(13)。 + ], + child: const ContiApp(), + ); + } + + if (!sentryEnabled) { + runApp(buildApp()); + return; + } + + await SentryFlutter.init( + (SentryFlutterOptions options) { + options.dsn = env.sentryDsn; + options.environment = env.flavor.name; + options.release = 'conti-retail-app@$_appVersion'; + options.tracesSampleRate = env.isProd ? 0.1 : 1.0; + // 合规红线:不自动带用户 IP / 请求头 / cookie。 + options.sendDefaultPii = false; + options.beforeBreadcrumb = scrubBreadcrumb; + options.beforeSend = buildScrubEvent(buffer); + options.debug = false; + }, + // 用 appRunner 而不是自己写 FlutterError.onError:SentryFlutter 已经在 + // 里面装好了 Flutter / PlatformDispatcher / Zone 三层钩子,再手写一遍 + // 会**每个异常上报两次**(13)。 + appRunner: () => runApp(buildApp()), + ); +} diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..5a7cf69 --- /dev/null +++ b/app/lib/l10n/app_localizations.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('zh')]; + + /// App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。 + /// + /// In zh, this message translates to: + /// **'大陆马门店'** + String get appTitle; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..348eced --- /dev/null +++ b/app/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,13 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => '大陆马门店'; +} diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb new file mode 100644 index 0000000..8040dff --- /dev/null +++ b/app/lib/l10n/app_zh.arb @@ -0,0 +1,7 @@ +{ + "@@locale": "zh", + "appTitle": "大陆马门店", + "@appTitle": { + "description": "App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。" + } +} diff --git a/app/lib/main_dev.dart b/app/lib/main_dev.dart new file mode 100644 index 0000000..9040d16 --- /dev/null +++ b/app/lib/main_dev.dart @@ -0,0 +1,14 @@ +/// dev 环境入口。 +/// +/// 跑法(08): +/// ``` +/// flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json +/// ``` +/// flavor 名写死在这里而不是从 dart-define 读——"用 dev 的入口配了别的环境的 +/// json"这种事故必须在代码里看得见。 +library; + +import 'package:app/bootstrap.dart'; +import 'package:core_foundation/core_foundation.dart'; + +Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev')); diff --git a/app/lib/main_prod.dart b/app/lib/main_prod.dart new file mode 100644 index 0000000..6f3b7b4 --- /dev/null +++ b/app/lib/main_prod.dart @@ -0,0 +1,14 @@ +/// prod 环境入口。 +/// +/// 跑法(08): +/// ``` +/// flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json +/// ``` +/// flavor 名写死在这里而不是从 dart-define 读——"用 prod 的入口配了别的环境的 +/// json"这种事故必须在代码里看得见。 +library; + +import 'package:app/bootstrap.dart'; +import 'package:core_foundation/core_foundation.dart'; + +Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod')); diff --git a/app/lib/main_uat.dart b/app/lib/main_uat.dart new file mode 100644 index 0000000..c08e309 --- /dev/null +++ b/app/lib/main_uat.dart @@ -0,0 +1,14 @@ +/// uat 环境入口。 +/// +/// 跑法(08): +/// ``` +/// flutter run --flavor uat -t lib/main_uat.dart --dart-define-from-file=env/uat.json +/// ``` +/// flavor 名写死在这里而不是从 dart-define 读——"用 uat 的入口配了别的环境的 +/// json"这种事故必须在代码里看得见。 +library; + +import 'package:app/bootstrap.dart'; +import 'package:core_foundation/core_foundation.dart'; + +Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat')); diff --git a/app/lib/src/app_widget.dart b/app/lib/src/app_widget.dart new file mode 100644 index 0000000..5452457 --- /dev/null +++ b/app/lib/src/app_widget.dart @@ -0,0 +1,58 @@ +/// 根 Widget。 +library; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_router/core_router.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 应用根。 +/// +/// 壳工程只做组装:路由来自 core_router,主题来自 core_ui,页面来自 feature_*。 +/// **这里不应该出现任何业务逻辑**——一旦出现,它就没有能承载它的包了。 +class ContiApp extends ConsumerWidget { + /// 构造。 + const ContiApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final GoRouter router = ref.watch(goRouterProvider); + + // ------------------------------------------------------------------ + // 会话指纹:用户 + 门店。11 §切店级联的最后一步——把整棵页面子树按这个 + // key 重建,扔掉所有 StatefulWidget 里攒着的门店维度状态。 + // + // 光 invalidate provider 是不够的:翻页页码、已勾选的行、输入框里半截的 + // 单号都活在 State 里,provider 层看不见它们。切完店留着上一家店的选中 + // 状态,会直接变成"给 A 店的单据提交到 B 店"。 + // ------------------------------------------------------------------ + final String sessionKey = ref.watch( + sessionProvider.select( + (AsyncValue value) => switch (value.value) { + SessionActive(:final UserContext user, :final StoreContext store) => + 'u${user.userId}-s${store.storeId}', + _ => 'anonymous', + }, + ), + ); + + return MaterialApp.router( + title: '大陆马门店', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + routerConfig: router, + // 16-i18n.md 还没写,但结构先留着:首版之后再补代价高得多。 + // 文案暂时直接写在 Widget 里,等 arb 方案定了统一迁移(见 lib/l10n/)。 + localizationsDelegates: const >[ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('zh', 'CN')], + builder: (BuildContext context, Widget? child) => + KeyedSubtree(key: ValueKey(sessionKey), child: child ?? const SizedBox.shrink()), + ); + } +} diff --git a/app/lib/src/device_id.dart b/app/lib/src/device_id.dart new file mode 100644 index 0000000..3a3c479 --- /dev/null +++ b/app/lib/src/device_id.dart @@ -0,0 +1,29 @@ +/// 安装级匿名设备 ID。 +library; + +import 'dart:convert'; +import 'dart:math'; + +import 'package:core_storage/core_storage.dart'; + +/// 首次安装时生成、之后一直复用的随机 ID。 +/// +/// --------------------------------------------------------------------------- +/// **绝不是 IMEI / IDFA / MAC / AndroidID**。这几个是设备唯一标识,采集它们是 +/// 合规红线(05 / 07 的隐私清单),而且 Android 10+ / iOS 早就限制了读取。 +/// +/// 这里的语义是"这次安装":卸载重装换一个新 ID 是**预期行为**,不需要跨安装 +/// 追踪——它的用途只有一个,把同一台设备的日志串起来排查问题。 +/// --------------------------------------------------------------------------- +Future loadOrCreateDeviceId(Prefs prefs) async { + const String key = 'device_id'; + final String? existing = await prefs.getString(key); + if (existing != null && existing.isNotEmpty) { + return existing; + } + final Random random = Random.secure(); + final List bytes = List.generate(16, (int _) => random.nextInt(256)); + final String created = base64Url.encode(bytes).replaceAll('=', ''); + await prefs.setString(key, created); + return created; +} diff --git a/app/lib/src/error_observer.dart b/app/lib/src/error_observer.dart new file mode 100644 index 0000000..de026dd --- /dev/null +++ b/app/lib/src/error_observer.dart @@ -0,0 +1,47 @@ +/// Provider 层的全局错误出口。来源:conti-docs/12-error-and-api-contract.md §五。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_logging/core_logging.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 所有 provider 抛出的异常都会经过这里。 +/// +/// --------------------------------------------------------------------------- +/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只 +/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。 +/// +/// 三类要**主动排除**,否则线上告警会被噪音淹没: +/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程 +/// 分支(余额不足、单据已关闭),不是缺陷; +/// - [UnauthorizedException]:登录过期,SessionNotifier 已经在处理了; +/// - [RequestCancelledException]:用户切走了页面,请求被主动取消。 +/// --------------------------------------------------------------------------- +final class ErrorObserver extends ProviderObserver { + /// 构造。 + ErrorObserver({required this.logger, required this.reporter}); + + /// 日志出口。 + final AppLogger logger; + + /// 崩溃上报出口。 + final CrashReporter reporter; + + @override + void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) { + final String name = context.provider.name ?? context.provider.runtimeType.toString(); + + if (ErrorPresenter.isSilent(error)) { + return; + } + if (error is BusinessException) { + // 记一条 info 就够:需要它来复盘"用户为什么走不下去",但它不是缺陷。 + logger.i('业务拒绝 $name: ${error.code} ${error.message}'); + return; + } + + logger.e('provider 失败 $name', error: error, stackTrace: stackTrace); + reporter.report(error, stackTrace, extra: {'provider': name}); + } +} diff --git a/app/lib/src/h5_launch_repository.dart b/app/lib/src/h5_launch_repository.dart new file mode 100644 index 0000000..21529c0 --- /dev/null +++ b/app/lib/src/h5_launch_repository.dart @@ -0,0 +1,36 @@ +/// `/api/v1/h5/launch` 的实现。 +/// +/// 接口声明在 core_webview(`h5_launch.dart`),实现必须落在能依赖 +/// core_network 的地方——core_webview 不允许依赖 core_network(01)。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_network/core_network.dart'; +import 'package:core_webview/core_webview.dart'; + +/// 用 target 编码换一份带票据的 H5 URL。 +class ApiH5LaunchRepository implements H5LaunchRepository { + /// 构造。 + const ApiH5LaunchRepository(this._api); + + final ApiClient _api; + + @override + Future launch(String target) async { + // 只传 target 编码,不传 URL:URL 由后端从服务端会话上下文拼(见 10)。 + final Map data = await _api.post>( + '/api/v1/h5/launch', + data: {'target': target}, + ); + final Object? url = data['url']; + final Object? title = data['title']; + if (url is! String || title is! String) { + throw const ServerException('H5 启动信息不完整'); + } + return H5LaunchInfo( + url: url, + title: title, + ttl: Duration(seconds: (data['ttlSeconds'] as num?)?.toInt() ?? 300), + ); + } +} diff --git a/app/lib/src/session_observer.dart b/app/lib/src/session_observer.dart new file mode 100644 index 0000000..a65d2c8 --- /dev/null +++ b/app/lib/src/session_observer.dart @@ -0,0 +1,82 @@ +/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。 +/// +/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`) +/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。 +library; + +import 'package:core_analytics/core_analytics.dart'; +import 'package:core_auth/core_auth.dart'; +import 'package:core_logging/core_logging.dart'; +import 'package:core_router/core_router.dart'; + +/// 把会话变化广播给埋点和崩溃上报。 +class AppSessionObserver implements SessionObserver { + /// 构造。 + const AppSessionObserver({required this.analytics, required this.reporter}); + + /// 埋点。 + final Analytics analytics; + + /// 崩溃上报。 + final CrashReporter reporter; + + @override + void onUserIdentified(UserContext user) { + analytics.identify(user.userId); + analytics.registerSuperProperties({ + AnalyticsSuperProperty.roleCode: user.roleCode, + }); + // 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是 + // 我们自己也不往里塞 PII。 + reporter.setUser(user.userId); + } + + @override + void onStoreChanged(StoreContext store) { + // 运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。 + analytics.registerSuperProperties({ + AnalyticsSuperProperty.storeId: store.storeId, + }); + reporter.setTag('storeId', '${store.storeId}'); + } + + @override + void onSessionEnded(LogoutReason reason) { + // 被动登出没有对应的接口调用,后端看不见,必须客户端报(13)。 + analytics.track(AnalyticsEvent.logout, {AnalyticsParam.reason: reason.name}); + // 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。 + analytics.reset(); + reporter.clearUser(); + } + + @override + void onSessionRestoreFailed(String stage) { + analytics.track(AnalyticsEvent.sessionRestoreFailed, { + AnalyticsParam.stage: stage, + }); + } +} + +/// 路由未命中时上报。 +class AppRouteReporter implements RouteReporter { + /// 构造。 + const AppRouteReporter({required this.logger, required this.reporter}); + + /// 日志。 + final AppLogger logger; + + /// 崩溃上报。 + final CrashReporter reporter; + + @override + void onRouteNotFound(String location) { + // 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。 + final String path = Uri.tryParse(location)?.path ?? location; + logger.w('路由未命中: $path'); + reporter.report( + StateError('route not found'), + StackTrace.current, + extra: {AnalyticsParam.path: path}, + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..69ef8e4 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,52 @@ +name: app +description: Conti Retail App 壳工程。只做组装:环境注入、启动编排、feature 路由聚合。 +publish_to: none +version: 1.0.0+1 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + core_analytics: ^0.1.0 + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + core_logging: ^0.1.0 + core_network: ^0.1.0 + core_router: ^0.1.0 + core_storage: ^0.1.0 + core_ui: ^0.1.0 + core_webview: ^0.1.0 + feature_auth: ^0.1.0 + feature_home: ^0.1.0 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_riverpod: ^3.3.2 + intl: any + native_scan: ^0.1.0 + sentry_flutter: ^9.26.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + sentry_dart_plugin: ^3.4.0 + # 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。 + shared_preferences_platform_interface: ^2.4.2 + +flutter: + uses-material-design: true + generate: true + +# release 构建后由 CI 调用 `dart run sentry_dart_plugin` 上传符号表。 +# 见 08 §release 构建 和 13 §崩溃上报。 +sentry: + upload_debug_symbols: true + upload_source_maps: false + project: conti-retail-app + org: continental + # auth_token 只从 CI 变量 SENTRY_AUTH_TOKEN 读,绝不写进仓库 diff --git a/app/test/device_id_test.dart b/app/test/device_id_test.dart new file mode 100644 index 0000000..4509751 --- /dev/null +++ b/app/test/device_id_test.dart @@ -0,0 +1,40 @@ +// deviceId 是这个壳工程里唯一有合规约束的一段逻辑,所以它有测试: +// 它必须是**本端随机生成**的,不能是任何设备唯一标识(IMEI / IDFA / MAC / +// AndroidID)。这条断言防的不是今天的代码,是将来某个人为了"提高准确率" +// 把它换成 device_info_plus 的某个字段。 + +import 'package:app/src/device_id.dart'; +import 'package:core_storage/core_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + }); + + test('首次调用生成并落盘,之后一直复用同一个值', () async { + final Prefs prefs = Prefs(); + + final String first = await loadOrCreateDeviceId(prefs); + expect(first, isNotEmpty); + + // 换一个 Prefs 实例读,模拟下次冷启动。 + final String second = await loadOrCreateDeviceId(Prefs()); + expect(second, first); + }); + + test('两台设备(两份存储)拿到的是不同的随机值', () async { + final String a = await loadOrCreateDeviceId(Prefs()); + + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + final String b = await loadOrCreateDeviceId(Prefs()); + + // 撞了就说明它不是随机的——大概率是有人换成了设备标识。 + expect(b, isNot(a)); + // 128 bit 的 base64url,去掉 padding 后 22 个字符。 + expect(a.length, 22); + expect(a, isNot(contains('='))); + }); +} diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/docs/01-project-structure.md b/docs/01-project-structure.md new file mode 100644 index 0000000..6589b0d --- /dev/null +++ b/docs/01-project-structure.md @@ -0,0 +1,266 @@ +# 01. 工程结构 / 分包策略 + +## 决策 + +使用 **[Melos](https://melos.invertase.dev/) monorepo**,按 **feature** 拆分成独立 Dart package,而不是单一 Flutter package 内部用文件夹分层。 + +## 包结构总览 + +``` +conti-app/ + pubspec.yaml # 根 workspace 配置(melos 8.x 不再有独立 melos.yaml,见下文) + .fvmrc # 锁定 Flutter SDK 版本 + analysis_options.yaml # 全仓库共享 lint 规则 + app/ # 壳工程:唯一的 Flutter application,负责路由汇总、DI 装配、编译出 ipa/apk + packages/ + core_ui/ # 通用组件、主题、设计 token + core_network/ # dio 封装、拦截器、统一异常、ApiResult 解包 + core_storage/ # 本地存储抽象(Drift + shared_preferences 封装) + core_auth/ # 登录态、token 管理、secure storage、门店上下文 + core_router/ # 路由聚合、公共 route guard、动态菜单映射 + core_webview/ # F6 H5 容器 + JSBridge(见 10-webview-h5.md) + core_analytics/ # 埋点统一 API(见 13-observability-analytics.md) + core_logging/ # 日志规范、脱敏、崩溃上报接入 + feature_auth/ # 登录、验证码、用户协议与隐私政策 + feature_home/ # 首页工作台:动态菜单、待办、预警、公告、促销位 + feature_store_mgmt/ # 店铺管理:基础信息、服务信息、执照、人员管理 + feature_sales/ # 销售流程:客户查询、历史工单、商机(H5 承载的部分走 core_webview) + feature_purchase/ # 采购:产品查询、购物车、结算、订单、收货 + feature_inventory/ # 库存:明细、安全库存、盘点、DOT + feature_analytics/ # 经营分析:对账单、核销收入、返利、报表 + feature_profile/ # 个人中心:地址、热线、客服 + feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务) + native_scan/ # 原生插件包:扫码能力(android/ios 两端实现) + native_media/ # 相机、相册、文件选择/上传 + native_device/ # 拨号、设备信息、权限申请 +``` + +包清单按 [PRD](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。 + +## 依赖规则(编译期强制边界,是这套结构的核心价值) + +- `app` 可以依赖所有 `core_*` 和 `feature_*`。 +- `feature_*` **只能**依赖 `core_*` 和 `native_*`,**不能**相互依赖(`feature_purchase` 的 `pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。 +- `core_*` 可以依赖 `native_*`(`core_webview` 的 JSBridge 需要调起扫码/相机/上传)。 +- `native_*` 只依赖 Flutter SDK 和 [Pigeon](https://pub.dev/packages/pigeon) 生成的代码,不依赖任何 `core_*` / `feature_*`——保证原生插件包可以脱离业务单独编译、单独测试(详见 [07-native-integration.md](./07-native-integration.md))。 + +`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审: + +| 允许的依赖 | 原因 | +|---|---| +| `core_network` → `core_auth` | 取 token 附加到请求头、401 时触发刷新 | +| `core_router` → `core_auth` | 路由 `redirect` 里判断登录态(见 [04-routing.md](./04-routing.md)) | +| `core_webview` → `core_auth` | H5 换票需要当前登录态与门店上下文(见 [10-webview-h5.md](./10-webview-h5.md)) | + +两条容易踩的反向约束,必须记住: + +- **`core_auth` 不依赖 `core_network`**。`core_auth` 要发 refresh 请求,如果依赖 `core_network` 就和上表第一行构成循环依赖。做法是:`core_auth` 直接依赖 `dio` 包,内部自建一个**不挂任何拦截器的裸 `Dio` 实例**专门用于刷新——这同时也避免了"刷新请求本身被 `AuthInterceptor` 拦截 → 401 → 再刷新"的递归(见 [05-networking.md](./05-networking.md))。 +- **`core_auth` 不依赖 `core_storage`**。token / refresh token 走 `flutter_secure_storage`,这个依赖**归 `core_auth` 独占**;`core_storage` 只负责 Drift 和 `shared_preferences`(见 [06-local-storage.md](./06-local-storage.md))。这样划分是为了不让 `core_*` 之间再多一条依赖边。 + +`feature_*` 不直接依赖 `go_router`,路由相关类型由 `core_router` 统一 re-export(`export 'package:go_router/go_router.dart';`),这样将来换路由库时只有 `core_router` 一个包要改。 + +这些规则由 Dart 的包依赖机制**物理强制**:`feature_a` 根本 import 不到 `feature_b` 的任何符号,不是靠代码规范或 review 口头约束。 + +## Feature 间通信怎么处理 + +这是最容易被绕开、也是这套边界能否守住的关键点,必须写清楚合法方式: + +1. **路由跳转 + 可序列化参数**(多数场景)——比如从 `feature_home` 跳到 `feature_purchase`,通过 `core_router` 声明的路径 + query/extra 参数传递,不直接引用对方的 Dart 类型。 +2. **通过 `core_*` 定义的抽象接口 + DI 注册实现**——真正需要跨 feature 拿数据或发通知的场景(比如切换门店后要清空购物车),在某个 `core_*` 包里定义接口,各 feature 各自实现并在 `app` 层注册,调用方只依赖 `core_*` 里的抽象类型(门店切换的级联失效见 [11-store-context-and-session.md](./11-store-context-and-session.md))。 + +**不允许**的做法:任何 `feature_*` 在 `pubspec.yaml` 里直接 path dependency 另一个 `feature_*`,哪怕只是想复用一个 widget——这种情况应该把这个 widget 提到 `core_ui`。 + +## 命名规范 + +- `core_xxx`:基础设施层,不含具体业务逻辑。 +- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。 +- `native_xxx`:原生能力插件包,首版含 `android/`、`ios/` 两套原生实现目录(OHOS 不在首版范围,见 [07-native-integration.md](./07-native-integration.md))。 + +## SDK 版本基线 + +| 项 | 版本 | 说明 | +|---|---|---| +| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` | +| Dart | 随 Flutter 3.44.9 附带(3.12.x) | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.12.0` 对整个 3.12.x 都成立 | + +**为什么不跟最新 stable(3.47.0 / Dart 3.13.0,2026-08-12 发布)**:鸿蒙(OpenHarmony)的 Flutter 分支适配落后于官方 stable 一段时间,虽然 OHOS 不在首版范围(见 [07-native-integration.md](./07-native-integration.md) 的「OHOS 后续演进」),但 SDK 基线要为后续接 OHOS 留出兼容窗口,所以刻意停在 3.44.9 而不是追最新。这条约束在决定升级 Flutter 版本时必须重新评估,不要因为"新版本有新特性"就单方面升。 + +**为什么必须用 FVM 锁**:monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。 + +```json +// .fvmrc +{ "flutter": "3.44.9" } +``` + +## Melos 配置示例(8.x,基于 Dart Pub Workspaces) + +Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/tools/pub/workspaces)** 机制,不再有独立的 `melos.yaml` 文件,配置写进根目录 `pubspec.yaml`;每个子包的 `pubspec.yaml` 需要加 `resolution: workspace`。 + +两个不同的 SDK 下限,别搞混: + +- **Pub Workspaces 机制本身**要求 Dart SDK ≥ **3.6.0**。 +- **melos 8.2.2 这个工具**自己要求 Dart SDK **^3.9.0**。 + +我们的基线(Dart 3.12.x)两条都满足。 + +根目录 `pubspec.yaml`: + +```yaml +name: conti_app +publish_to: none +environment: + sdk: ^3.12.0 + +workspace: + - app + - packages/core_ui + - packages/core_network + - packages/core_storage + - packages/core_auth + - packages/core_router + - packages/core_webview + - packages/core_analytics + - packages/core_logging + - packages/feature_auth + - packages/feature_home + - packages/feature_purchase + - packages/native_scan + # ... 其余包按实际建包进度追加 + +dev_dependencies: + melos: ^8.2.2 + +melos: + scripts: + analyze: + run: melos exec --fail-fast -- flutter analyze + test: + # --dir-exists=test 跳过还没有测试目录的包(如新建的 native_*), + # 否则批量命令会因为「找不到 test 目录」整体失败 + run: melos exec --dir-exists=test --fail-fast -- flutter test + format: + run: melos exec -- dart format --set-exit-if-changed . + gen: + # 代码生成:riverpod_generator / drift_dev / json_serializable + run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs + pigeon: + # 原生接口生成,见 07-native-integration.md + run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/ +``` + +每个子包(比如 `packages/feature_purchase/pubspec.yaml`): + +```yaml +name: feature_purchase +resolution: workspace + +dependencies: + core_ui: + path: ../core_ui + core_network: + path: ../core_network + core_router: + path: ../core_router +``` + +## 共享 lint 配置 + +根目录一份 `analysis_options.yaml`,各子包 include 它,不允许各包自己维护一套规则(选型与具体规则见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md)): + +```yaml +# packages/feature_purchase/analysis_options.yaml +include: ../../analysis_options.yaml +``` + +用到 `custom_lint`(`riverpod_lint` 依赖它)的包,需要各自在 `dev_dependencies` 里加 `custom_lint`,并在自己的 `analysis_options.yaml` 里启用 `custom_lint` 插件——`custom_lint` 是按包运行的,不能只在根目录配一次(见 [03-state-management.md](./03-state-management.md))。 + +## 新增 feature 包的标准脚手架 + +``` +feature_xxx/ + pubspec.yaml # resolution: workspace + 依赖 core_ui / core_network / core_router 等,不依赖其他 feature + lib/ + feature_xxx.dart # 唯一对外导出文件(barrel file):只暴露路由注册函数和必要的 public widget + src/ + presentation/ + domain/ # 可选,见下方分层规范文档 + data/ + test/ +``` + +`src/` 目录下的内容视为包内私有实现,只有 `feature_xxx.dart` 这一个文件是对外契约——这条靠 code review 检查,Dart 语言本身没有强制的 package-private 关键字。`domain/` 目录的取舍规则详见 [02-layering.md](./02-layering.md)。 + +## 版本管理 + +不发布到 pub.dev,全部用 melos 的 path dependency,包版本号跟随 `app` 的整体版本号统一管理(fixed versioning),不做 melos 的 independent versioning——没有对外发布需求,独立版本号只会增加维护负担。 + +## 附录:Melos 是什么,日常怎么用 + +给没接触过 Dart 多包仓库工具的同学看的入门说明。 + +### 要解决的问题 + +Dart 官方的包管理工具 `pub` 天生只认"一个 `pubspec.yaml` = 一个包"。如果要在同一个 git 仓库里维护多个互相依赖的私有包(比如 `app` 依赖 `feature_scan`,`feature_scan` 依赖 `core_network`),原生 pub 只支持手动在每个包的 `pubspec.yaml` 里写 `path: ../../packages/core_network` 这种相对路径依赖——能跑,但没有任何批量操作能力:想给所有包统一跑一次 `flutter analyze`、`flutter test`,或者统一升级某个第三方库版本,都得一个包一个包手动进去执行。 + +**Melos 就是给这种多包仓库提供批量管理能力的工具**,类似 JS 生态里的 [Lerna](https://lerna.js.org/)/Nx,只不过是 Dart/Flutter 版本。它不改变 Dart 语言或 pub 本身的机制,只是在多个包外面包一层"批处理脚本 + 配置"。 + +### 核心概念 + +1. **根目录 `pubspec.yaml` 里的 `workspace:` 字段 + `melos:` 配置块**:8.x 版本不再有独立的 `melos.yaml` 文件(7.0 之前是独立文件,现已合并进 Dart 官方原生的 Pub Workspaces 机制)。`workspace:` 列出所有子包路径,`melos:` 块下的 `scripts:` 定义可复用脚本(见上文示例)。 +2. **`melos bootstrap`**(简写 `melos bs`):一键解析 workspace 内所有包之间的依赖关系。在 8.x 的 Pub Workspaces 模式下,它的效果约等于"在仓库根目录跑一次 `flutter pub get` + 校验各包 `resolution: workspace` 配置是否正确"——包间链接由 pub 原生的 workspace 机制完成,**不再生成 `pubspec_overrides.yaml`**(那是 7.0 之前的实现方式)。**新人拉下代码后第一步永远是跑这个命令**。 +3. **`melos exec`**:在每一个包目录下依次/并行执行同一条命令,比如 `melos exec -- flutter test` 就是把所有包都跑一遍测试,替代手动 `cd packages/feature_purchase && flutter test && cd ../feature_inventory && ...`。常用过滤参数:`--scope`(只跑匹配名字的包)、`--dir-exists=test`(只跑有测试目录的包)、`--fail-fast`(有一个包失败就停)。 +4. **`melos run `**:调用根目录 `pubspec.yaml` 里 `melos: scripts:` 下预定义的脚本别名(比如上文的 `melos run test`),团队里统一敲固定命令,不用记 `exec` 的完整写法。 + +### 日常开发流程(拿本仓库举例) + +```bash +# 0. 一次性:安装 fvm 并装上基线版本的 Flutter +dart pub global activate fvm +fvm install # 读 .fvmrc,装 3.44.9 +fvm flutter --version + +# 1. 第一次拉代码,或者别人加了新包/新依赖之后 +melos bootstrap + +# 2. 正常改代码,比如在 feature_purchase 里改一个页面 +cd packages/feature_purchase +flutter run # 这一步跟平时写单个 Flutter 项目完全一样,感觉不到 melos 的存在 + +# 3. 改了带注解的代码(Riverpod / Drift / json_serializable)之后 +melos run gen + +# 4. 提交前,跑一遍全仓库检查 +melos run analyze +melos run test + +# 5. 新增了 feature 包,或者某个包新增了对另一个包的依赖之后 +melos bootstrap # 重新解析依赖关系 +``` + +**关键体感**:平时在某一个包里写代码、`flutter run`、热重载,跟没有 melos 时完全一样——melos 只在"跨包操作"(装依赖、批量测试、批量分析)时才会用到,不侵入日常单包开发的手感。 + +### 常见的坑 + +- 加了新包,或改了某个包的依赖之后忘记跑 `melos bootstrap`,会出现"明明加了依赖但 import 不到"的报错——看到这个报错先跑一遍 bootstrap 再排查。 +- 8.x 基于 Pub Workspaces 后,正常的包间链接**不再**依赖 `pubspec_overrides.yaml`(这是 7.0 之前版本的机制);只有配置了额外的 `dependencyOverridePaths`(用于覆盖外部第三方依赖,不是本仓库包之间的常规场景)时才会生成这个文件。如果看到这个文件出现却不记得配置过覆盖路径,说明配置可能有误,需要检查。 + +### 安装 + +```bash +dart pub global activate melos +``` + +全局命令,装一次即可,不需要每个项目单独安装。 + +## 参考链接 + +- [Melos 官方文档](https://melos.invertase.dev/) +- [melos | Dart package (pub.dev)](https://pub.dev/packages/melos) +- [Melos changelog](https://pub.dev/packages/melos/changelog) +- [Melos Configuration overview](https://melos.invertase.dev/configuration/overview) +- [Dart Pub Workspaces 官方文档](https://dart.dev/tools/pub/workspaces) +- [FVM(Flutter Version Management)](https://fvm.app/) +- [Pigeon | Dart package](https://pub.dev/packages/pigeon) +- [Drift | Dart package](https://pub.dev/packages/drift) +- [Lerna(JS 生态对标工具)](https://lerna.js.org/) + diff --git a/docs/02-layering.md b/docs/02-layering.md new file mode 100644 index 0000000..df475e8 --- /dev/null +++ b/docs/02-layering.md @@ -0,0 +1,234 @@ +# 02. 分层架构规范 + +## 决策 + +每个 `feature_*` 包内部采用简化版分层,`domain` 层**可选**,不强制每个 feature 都有: + +``` +feature_xxx/ + lib/ + feature_xxx.dart # 对外唯一导出文件 + src/ + presentation/ # widgets + Riverpod provider/notifier + domain/ # 可选:entity + repository 接口 + use case + data/ # repository 实现 + remote/local datasource + test/ +``` + +## 各层职责 + +- **presentation**:widgets、Riverpod `Notifier`/`Provider`。只处理 UI 状态和用户交互,不直接调用 `data` 层的具体实现类,通过依赖注入拿到抽象类型。 +- **domain**(可选):`entity` 定义业务模型,`repository` 接口声明数据契约,`use case` 封装跨 repository 协调或多步骤业务规则。 +- **data**:`repository` 接口的具体实现,内部再拆 `remote_datasource`(走 `core_network`)和 `local_datasource`(走 `core_storage`)。 + +## 何时可以跳过 domain 层 + +判断标准: + +- **可以跳过**:功能是简单 CRUD、没有跨 repository 协调、没有多步骤业务规则——`presentation` 直接依赖 `data` 层定义的 repository 接口即可,`repository` 接口挪到 `data` 层里声明。 +- **必须要有**:涉及多步骤业务规则(如支付的多步校验)、需要协调多个 repository、包含状态机或需要独立于 UI 单元测试的核心业务逻辑——`repository` 接口放在 `domain`,`data` 层依赖 `domain` 反向实现接口。 + +## Repository 接口的位置规则 + +- 有 `domain` 层:接口定义在 `domain/repository/`,`data/repository_impl/` 实现它,`presentation` 只依赖 `domain` 里的抽象类型。 +- 无 `domain` 层:接口直接定义在 `data/repository/`,同文件或同目录下给出实现类,`presentation` 依赖这个接口类型。 + +两种情况下,`presentation` 都不允许直接依赖 `data` 层的具体实现类(如 `XxxRepositoryImpl`),只依赖接口——这条不因为是否跳过 domain 层而改变。 + +## 跨层依赖规则 + +``` +presentation → domain(或直接 → data 的接口,若跳过 domain) +domain → 不依赖 presentation / data +data → 依赖 domain 的接口(若有),依赖 core_network / core_storage +``` + +`domain` 层禁止 import 的东西,不只是 Flutter SDK: + +- `package:flutter/...`(UI 框架) +- `package:dio/...`(网络库) +- `package:drift/...`(数据库) +- 任何做 IO 的第三方库 + +`domain` 只允许 `dart:core`/`dart:async` 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 `dio`,测试就得处理它的初始化和平台依赖。 + +## 数据模型与 JSON 序列化 + +**决策**:DTO 用 [json_serializable](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。 + +```yaml +dependencies: + json_annotation: ^4.9.0 + +dev_dependencies: + json_serializable: ^6.9.0 + build_runner: ^2.15.2 +``` + +- **为什么不上 freezed**:freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator`、`drift_dev`、`json_serializable`、`pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./09-testing.md) 里不选 `mockito` 的理由)。 +- **DTO 与 entity 是否分两套类型**:默认**不分**,`data` 层的 DTO 直接当 `domain` 的 entity 用,只在下面两种情况才拆两套并写转换函数: + 1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。 + 2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。 + + 拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。 +- 有 `domain` 层的 feature 如果拆了两套类型,转换函数放在 `data` 层(`domain` 不能知道 JSON 长什么样)。 + +## 后端统一响应包装在哪一层解开 + +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](../../conti-backend/docs/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**: + +- `code == 0` → 把 `data` 取出来交给 repository,repository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。 +- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`。 +- `traceId` 无论成功失败都记录进日志。 + +完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。 + +## 分页的统一约定 + +PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名: + +```dart +// core_network 里定义的通用分页类型 +class PageQuery { + const PageQuery({required this.page, this.size = 20}); + final int page; // 从 1 开始 + final int size; +} + +class PageResult { + const PageResult({required this.items, required this.total, required this.page}); + final List items; + final int total; + final int page; + bool get hasMore => items.length + (page - 1) * items.length < total; +} + +// feature 侧 +abstract class PurchaseOrderRepository { + Future> fetchOrders(PageQuery query); +} +``` + +具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。 + +## 附录:分层架构是什么,为什么要分层 + +给还没接触过这套分层习惯的同学看的入门说明。 + +> 下面示例里的 `feature_payment` / `feature_store` 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 [01-project-structure.md](./01-project-structure.md))。 + +### 要解决的问题 + +如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题: + +1. **没法单独测试业务逻辑**:想验证"支付金额校验规则对不对",得连 widget 一起跑测试,跑得慢还容易因为 UI 变了导致业务逻辑测试跟着挂。 +2. **换底层实现要动 UI 代码**:比如把网络库从 `dio` 换掉,或者把本地存储从 `shared_preferences` 换成 `Drift`,如果 UI 直接依赖具体实现类,改动会散落得到处都是。 + +**分层的本质**:把"业务规则"和"业务规则的具体实现方式(用什么网络库、存什么数据库)"分开,中间用抽象接口隔开。这就是 [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 这套思想的核心,我们只取最简化的三层版本,不套用它完整的同心圆规则。 + +### 依赖方向是关键 + +三层最重要的不是"分了几层",而是**依赖只能单向流动**: + +``` +presentation ──依赖──> domain ──定义接口,不依赖任何人 + ▲ + │ 实现接口(依赖倒置) + data +``` + +`domain` 不 import `data`,也不 import `presentation`——它甚至不知道 `data` 层是用 `dio` 还是别的什么网络库实现的,只定义"我需要一个能拿到 `PaymentOrder` 的东西"(接口),至于这个东西具体怎么实现,由 `data` 层负责,这就是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。好处是:`domain` 层的业务规则可以完全脱离网络、脱离 UI 单独写单元测试。 + +### 示例一:有 domain 层(`feature_payment`,支付确认——多步骤业务规则) + +```dart +// domain/entity/payment_order.dart +class PaymentOrder { + final String orderId; + final int amountCents; + final PaymentStatus status; + const PaymentOrder({required this.orderId, required this.amountCents, required this.status}); +} + +// domain/repository/payment_repository.dart +abstract class PaymentRepository { + Future fetchOrder(String orderId); + Future confirmPayment(String orderId, String pinToken); +} + +// domain/use_case/confirm_payment_use_case.dart +class ConfirmPaymentUseCase { + final PaymentRepository _repository; + ConfirmPaymentUseCase(this._repository); + + Future call(String orderId, String pinToken) async { + final order = await _repository.fetchOrder(orderId); + if (order.status != PaymentStatus.pending) { + throw StateError('订单状态不允许支付: ${order.status}'); + } + if (order.amountCents <= 0) { + throw ArgumentError('订单金额非法'); + } + await _repository.confirmPayment(orderId, pinToken); + } +} + +// data/repository/payment_repository_impl.dart +class PaymentRepositoryImpl implements PaymentRepository { + final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md + PaymentRepositoryImpl(this._api); + + @override + Future fetchOrder(String orderId) async { + // 注意:返回的已经是 ApiResult 里的 data 部分—— + // { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开, + // repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」) + final json = await _api.get>('/api/v1/orders/$orderId'); + return PaymentOrder( + orderId: json['orderId'] as String, + amountCents: json['amountCents'] as int, + status: PaymentStatus.values.byName(json['status'] as String), + ); + } + + @override + Future confirmPayment(String orderId, String pinToken) => + _api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken}); +} +``` + +`ConfirmPaymentUseCase` 的多步校验规则可以直接用假的 `PaymentRepository` 实现来做单元测试,完全不需要启动 Flutter engine 或起一个 mock server。 + +### 示例二:跳过 domain 层(`feature_store`,门店列表——简单 CRUD) + +```dart +// data/repository/store_repository.dart +abstract class StoreRepository { + Future> fetchNearbyStores(double lat, double lng); +} + +class StoreRepositoryImpl implements StoreRepository { + final ApiClient _api; + StoreRepositoryImpl(this._api); + + @override + Future> fetchNearbyStores(double lat, double lng) async { + // 同上:拿到的是解开 ApiResult 包装之后的 data + final list = await _api.get>( + '/api/v1/stores', + query: {'lat': lat, 'lng': lng}, + ); + return list.map((e) => Store.fromJson(e as Map)).toList(); + } +} +``` + +没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `data` 层,`presentation` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录和 use case 模板代码。 + +## 参考链接 + +- [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt) +- [The Clean Architecture(Uncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle) +- [json_serializable | Dart package](https://pub.dev/packages/json_serializable) +- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns) diff --git a/docs/03-state-management.md b/docs/03-state-management.md new file mode 100644 index 0000000..493f101 --- /dev/null +++ b/docs/03-state-management.md @@ -0,0 +1,227 @@ +# 03. 状态管理方案 + +## 决策 + +使用 **[Riverpod](https://riverpod.dev/)**(`flutter_riverpod` + `riverpod_generator` 代码生成),不使用 Bloc/Provider/GetX。 + +版本基线:`flutter_riverpod: ^3.4.2`(当前 stable,2026-08 快照,需在实际开工时用 `flutter pub outdated` 复核)。 + +## 依赖 + +```yaml +dependencies: + flutter_riverpod: ^3.4.2 + riverpod_annotation: ^3.4.2 + +dev_dependencies: + riverpod_generator: ^3.4.2 + build_runner: ^2.15.2 + custom_lint: ^0.8.1 + riverpod_lint: ^3.1.8 +``` + +> `custom_lint` 的版本必须是 `^0.8.x`:`riverpod_lint 3.x` 依赖的是 `custom_lint 0.8.x`,写成 `^0.6.0` 会直接 `pub get` 解析失败。`custom_lint` 的版本约束比较严,每次升 `riverpod_lint` 都要顺带核一下它要求的 `custom_lint` 版本。 + +## 使用规则 + +- 所有跨 widget 共享的状态、依赖注入,统一通过 Riverpod provider 暴露,不额外引入 `get_it`/`provider` 等其他 DI 方案。 +- 优先使用 `riverpod_generator` 的注解写法(`@riverpod`),不手写裸 `Provider`/`StateNotifierProvider` 模板代码。 +- `Notifier`/`AsyncNotifier` 用于承载可变的 feature 状态;无状态的计算/依赖注入用普通 `Provider`。 +- `domain`/`data` 层的 repository 实现通过 provider 注入到 `presentation` 层,`presentation` 只依赖 provider 暴露的接口类型(见 [02-layering.md](./02-layering.md))。 +- 每个 `feature_*` 包各自维护自己的 provider,不跨包直接引用另一个 feature 的 provider(同 [01-project-structure.md](./01-project-structure.md) 的 feature 隔离规则);跨 feature 共享的 provider 定义在对应的 `core_*` 包里。 + +## Riverpod 3 的自动重试:全局关掉 + +Riverpod 3 起,**provider 抛异常后会自动重试**,默认策略是指数退避(200ms 起,翻倍到 6.4s 封顶)。这个默认行为在本项目里弊大于利,有三个具体问题: + +1. **和 401 刷新打架**:access token 过期时,`core_network` 的 `AuthInterceptor` 已经在做刷新 + 重放(见 [05-networking.md](./05-networking.md))。provider 层再自动重试一轮,等于同一个失败被两套机制各重试一次,日志里会出现莫名其妙的重复请求。更糟的是后端 refresh token 是**一次性轮换**的(见 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md)),并发刷新会被判定为重放攻击,导致该用户所有 refresh token 被撤销、被强制登出。 +2. **错误提示会闪**:UI 拿到 `AsyncError` 弹了错误提示,200ms 后自动重试又切回 `AsyncLoading`,用户看到的是提示一闪而过。 +3. **测试 flaky**:单测里断言 `AsyncError` 时,后台还挂着一个待重试的定时器,测试跑完 container 被 dispose 会报 pending timer,或者断言时机不对直接读到 `AsyncLoading`。 + +**决策**:在 `ProviderScope` 上全局关闭 retry,需要重试的地方显式打开。 + +```dart +// app/lib/main.dart +void main() { + runApp( + ProviderScope( + // 全局关掉自动重试:返回 null 表示"不重试" + retry: (retryCount, error) => null, + child: const ContiApp(), + ), + ); +} +``` + +单个 provider 确实需要重试时(比如首页 tile 这种失败了自己悄悄重试一次比弹错更好的场景),在该 provider 上单独开: + +```dart +@Riverpod(retry: _homeTileRetry) +Future> homeTiles(Ref ref) async { /* ... */ } + +// 只重试一次,且只对网络类错误重试;业务错误(BusinessException)重试没有意义 +Duration? _homeTileRetry(int retryCount, Object error) { + if (retryCount >= 1) return null; + if (error is! NetworkException) return null; + return const Duration(milliseconds: 500); +} +``` + +规则:**重试只对"重试一次可能就好了"的错误有意义**——超时、连接失败。业务错误码(后端返回 `code != 0`)、401、参数错误重试多少次都是同样的结果,只是在浪费用户的时间和流量。 + +## 缓存生命周期:默认 autoDispose,长驻要写理由 + +`@riverpod` 注解生成的 provider **默认是 autoDispose 的**(没有 listener 时自动销毁并释放状态)。这个默认值保持不变,原因是门店切换的场景下(见下一节)"用完就销毁"能省掉一大堆手动清理。 + +要改成长驻的写 `@Riverpod(keepAlive: true)`,并且**必须在注释里写清为什么**。目前认可的长驻场景只有三类: + +- 全局单例依赖(`Dio` 实例、`Database` 实例、`SharedPreferences`)——本来就该活到进程结束。 +- 全局会话状态(登录态、当前门店上下文,见 [11-store-context-and-session.md](./11-store-context-and-session.md))。 +- 明确要跨页面保留的数据(比如工作台数据,用户从子页面返回时不希望再 loading 一次)。 + +除此之外一律 autoDispose。列表页数据尤其不要 keepAlive——门店切了、权限变了,长驻的旧数据会直接显示成错的。 + +需要"短时间内返回不重新加载、但也不永久长驻"的,用 `ref.keepAlive()` + 定时器的写法,别直接 `keepAlive: true`: + +```dart +@riverpod +Future> storeList(Ref ref) async { + final link = ref.keepAlive(); + final timer = Timer(const Duration(minutes: 5), link.close); // 5 分钟后允许被回收 + ref.onDispose(timer.cancel); + return ref.watch(storeRepositoryProvider).fetchStores(); +} +``` + +## 门店切换 / 登出时的批量失效 + +PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。 + +统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。 + +```dart +@riverpod +Future> purchaseOrders(Ref ref) async { + // watch 而不是 read:门店一变,这个 provider 自动重建 + final storeId = ref.watch(currentStoreIdProvider); + return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId); +} +``` + +这条规则要写进 code review checklist:**任何请求带 storeId 的 provider,storeId 必须来自 `ref.watch(currentStoreIdProvider)`,不允许从别处传参或 `ref.read`**。`ref.read` 拿到的是快照,门店变了不会触发重建,这正是最容易漏的地方。 + +依赖图管不到的部分(Drift 本地缓存、H5 会话、导航栈)需要显式清理,完整清单见 [11-store-context-and-session.md](./11-store-context-and-session.md)。 + +## 测试 + +- `Notifier`/`AsyncNotifier` 的单元测试用 **`ProviderContainer.test()`** 直接实例化,不依赖 widget tree——这是 Riverpod 3 新增的测试专用构造,自带 `addTearDown(container.dispose)`,不需要再手写。 +- Widget 测试中用 `ProviderScope(overrides: [...])` 注入 mock 依赖。 +- 测试里如果某个 provider 单独开了 retry,断言错误状态前记得覆盖掉,否则会遇到 pending timer(详见 [09-testing.md](./09-testing.md))。 + +## 附录:Riverpod 是什么,日常怎么用 + +给还没接触过 Riverpod 的同学看的入门说明。 + +### 要解决的问题 + +Flutter 官方最早推荐的状态管理方式是 [`InheritedWidget`](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)——通过 widget 树往下传数据。写法繁琐,社区后来做了一层封装叫 [`Provider`](https://pub.dev/packages/provider),但 `Provider` 本质还是绑定在 widget 树上:拿依赖必须要有 `BuildContext`,写错了会在运行时才报错(比如 `ProviderNotFoundException`),而且没法很方便地在 widget 树之外(比如后台任务、单元测试)读取状态。 + +**Riverpod** 是 `Provider` 的原作者 Remi Rousselet 重新设计的下一代方案:把状态容器从 widget 树里剥离出来,变成一套独立的依赖图,`BuildContext` 不再是拿依赖的必要条件,错误也从运行时提前到**编译期**发现(比如 provider 类型不匹配会直接编译报错,而不是运行时崩溃)。 + +### 核心概念 + +1. **`Provider`**:声明一个"如何创建某个值"的配方,值可以是同步的、异步的(`Future`/`Stream`)、也可以是可变的状态。 +2. **`Notifier` / `AsyncNotifier`**:承载**可变**状态的载体,通过方法修改状态(类似过去 `StateNotifier` 的角色,3.x 里统一成 `Notifier`)。 +3. **`ref.watch(xxxProvider)`**:在 widget 或另一个 provider 里订阅某个 provider,值变化时自动触发重建/重新计算。 +4. **`ref.read(xxxProvider)`**:只读取一次当前值,不订阅变化(一般用在按钮点击等一次性事件回调里)。 +5. **`@riverpod` 注解 + 代码生成**:手写 `Provider`/`NotifierProvider` 样板代码容易出错(尤其是泛型),项目统一用 `riverpod_generator` 的注解写法,跑 `build_runner` 自动生成对应的 provider。 + +### 使用示例(`feature_store`:拉取附近门店列表) + +```dart +// presentation/store_list_notifier.dart +part 'store_list_notifier.g.dart'; + +@riverpod +class StoreListNotifier extends _$StoreListNotifier { + @override + Future> build() async { + final repository = ref.watch(storeRepositoryProvider); + final position = ref.watch(currentPositionProvider); // 定位也是一个 provider,不是 notifier 的字段 + return repository.fetchNearbyStores(position.lat, position.lng); + } + + Future refresh() async { + // 让 Riverpod 重跑 build(),而不是自己去调 build() + ref.invalidateSelf(); + await future; // 等这一轮重建完成,方便下拉刷新的 RefreshIndicator 收起动画 + } +} +``` + +> **不要写成 `state = await AsyncValue.guard(() => build())`。** `build()` 里有 `ref.watch`,只有 Riverpod 自己在重建流程中调用它才能正确重建订阅关系;手动调用会让旧的订阅残留、新的订阅重复注册。需要重跑 `build()` 就用 `ref.invalidateSelf()`。 +> +> 只想改一部分状态、不想重跑整个 `build()` 时,才用 `AsyncValue.guard`,而且里面调的是 repository 而不是 `build()`: +> +> ```dart +> Future loadMore() async { +> final current = state.valueOrNull ?? const []; +> state = await AsyncValue.guard(() async { +> final next = await ref.read(storeRepositoryProvider).fetchNearbyStores(/* ... */); +> return [...current, ...next]; +> }); +> } +> ``` + +```dart +// presentation/store_list_page.dart +class StoreListPage extends ConsumerWidget { + const StoreListPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final storesAsync = ref.watch(storeListNotifierProvider); + + return storesAsync.when( + data: (stores) => ListView.builder( + itemCount: stores.length, + itemBuilder: (_, i) => ListTile(title: Text(stores[i].name)), + ), + loading: () => const CircularProgressIndicator(), + error: (err, _) => Text('加载失败: $err'), + ); + } +} +``` + +`storeRepositoryProvider` 定义在 `data` 层(见 [02-layering.md](./02-layering.md) 的跳过 domain 层示例),`StoreListNotifier` 通过 `ref.watch` 拿到接口类型,不关心具体实现——这就是 Riverpod 承担依赖注入职责的地方,不需要额外的 `get_it`。 + +### 测试示例 + +```dart +test('刷新后状态应更新为最新门店列表', () async { + // ProviderContainer.test() 是 Riverpod 3 的测试专用构造, + // 自动注册 tearDown 做 dispose,不用再写 addTearDown(container.dispose) + final container = ProviderContainer.test( + overrides: [ + storeRepositoryProvider.overrideWithValue(FakeStoreRepository()), + ], + ); + + final stores = await container.read(storeListNotifierProvider.future); + expect(stores, isNotEmpty); +}); +``` + +`ProviderContainer` 让整个依赖图脱离 widget 树单独运行,`overrides` 直接替换掉真实的 repository,这也是"编译期安全 + 好测试"这条评价的具体体现。 + +## 参考链接 + +- [Riverpod 官方文档](https://riverpod.dev/) +- [Riverpod 3 迁移指南](https://riverpod.dev/docs/whats_new) +- [Riverpod: Automatic retry](https://riverpod.dev/docs/whats_new#automatic-retry) +- [riverpod_generator | Dart package](https://pub.dev/packages/riverpod_generator) +- [flutter_riverpod | Dart package](https://pub.dev/packages/flutter_riverpod) +- [riverpod_lint | Dart package](https://pub.dev/packages/riverpod_lint) +- [InheritedWidget(Flutter 官方文档)](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget) +- [provider | Dart package](https://pub.dev/packages/provider) diff --git a/docs/04-routing.md b/docs/04-routing.md new file mode 100644 index 0000000..6d1a151 --- /dev/null +++ b/docs/04-routing.md @@ -0,0 +1,198 @@ +# 04. 路由方案 + +## 决策 + +使用 **[go_router](https://pub.dev/packages/go_router)**(`^17.5.0`,2026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 `ShellRoute`,不使用 `Navigator 1.0` 命令式 push/pop 作为主路由方式。 + +## 依赖 + +```yaml +dependencies: + go_router: ^17.5.0 +``` + +## 路由注册规则 + +- 每个 `feature_*` 包在自己的 `feature_xxx.dart`(对外唯一导出文件)里暴露一个 `List buildXxxRoutes()` 函数,只声明属于自己的路由,不感知其他 feature。 +- `core_router` 包负责把所有 feature 的路由函数聚合成最终的 `GoRouter` 实例,是唯一知道"全部路由长什么样"的地方。 +- 路径命名统一用 `kebab-case`,前缀按业务域分组,例如 `/store/:storeId`、`/payment/confirm`。 +- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。 +- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。 +- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。 +- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。 + +## `GoRouter` 实例不能因为登录态变化被重建 + +这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。 + +`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。 + +**正确写法**:`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`。 + +```dart +// packages/core_router/lib/src/app_router.dart +final rootNavigatorKey = GlobalKey(); + +final goRouterProvider = Provider((ref) { + final router = GoRouter( + navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它 + initialLocation: '/home', + observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13 + redirect: (context, state) { + // read 不是 watch:这里只要当前值,订阅由下面的 listen 负责 + final auth = ref.read(authStateProvider); + final loggingIn = state.matchedLocation == '/login'; + + if (!auth.isLoggedIn) { + if (loggingIn) return null; + // 带上原目标,登录成功后回跳 + return '/login?from=${Uri.encodeComponent(state.uri.toString())}'; + } + if (loggingIn) { + final from = state.uri.queryParameters['from']; + return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from); + } + return null; + }, + errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()), + routes: [ + GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell), + branches: [ + StatefulShellBranch(routes: buildHomeRoutes()), + StatefulShellBranch(routes: buildPurchaseRoutes()), + StatefulShellBranch(routes: buildProfileRoutes()), + ], + ), + ], + ); + + // 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留 + ref.listen(authStateProvider, (_, __) => router.refresh()); + ref.onDispose(router.dispose); + return router; +}); +``` + +要点: + +- `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(`Provider` 的 `create` 已经跑完了,`watch` 在回调里语义也不对)。 +- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。 +- 用 `ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。 + +### `errorBuilder` 是必须的 + +不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。 + +## 后端动态菜单 → 本地路由的映射 + +PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。 + +约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER`、`INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。 + +```dart +// packages/core_router/lib/src/menu_route_map.dart +const menuRouteMap = { + 'PURCHASE_ORDER': '/purchase/orders', + 'INVENTORY_CHECK': '/inventory/check', + 'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表 + // ... +}; + +/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级 +String? resolveMenuRoute(String code) => menuRouteMap[code]; +``` + +**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。 + +- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。 +- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。 + +`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。 + +## H5 页面的路由约定 + +PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为: + +``` +/webview?target=&title=<可选标题> +``` + +**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview` 拿 `target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。 + +理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。 + +即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。 + +## 门店切换后的路由重置 + +PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。 + +**规则:切换门店成功后,清空导航栈回工作台。** + +```dart +// 门店切换成功的回调里 +ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈 +``` + +`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。 + +## 参考链接 + +- [go_router 官方文档](https://pub.dev/packages/go_router) +- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html) +- [go_router: Navigation(go vs push)](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html) +- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html) + +## 附录:go_router 是什么,日常怎么用 + +给还没接触过声明式路由的同学看的入门说明。 + +### 要解决的问题 + +`Navigator 1.0` 的命令式写法(`Navigator.push(context, MaterialPageRoute(...))`)在页面不多的时候很直观,但规模上来后有几个明显问题: + +1. **深链接(deep link)/ Web URL 支持差**:命令式 push 本质是"从当前页面跳到下一个页面",很难直接根据一个 URL 字符串恢复出正确的页面栈——比如从推送通知直接打开"门店详情页",命令式写法需要手动拼一串 `push` 调用重建整个栈。 +2. **没有统一的登录拦截点**:每个需要登录态的页面都要自己在 `initState` 里判断要不要跳转到登录页,逻辑散落在各处。 +3. **底部导航这种"多个 tab 各自维护自己的页面栈"的场景很难优雅表达**。 + +**go_router** 是 Flutter 官方团队维护的声明式路由方案:路由表是一份**声明式配置**(一棵 `GoRoute` 树),当前 URL 决定当前应该显示什么页面栈,而不是"一步步 push 出来的"。因为路由是声明式的、和 URL 强绑定,深链接、Web 浏览器前进/后退、登录拦截都能用同一套机制解决。 + +### 核心概念 + +1. **`GoRoute`**:一条路由规则,`path` 是路径模板(支持 `:id` 这种参数),`builder`/`pageBuilder` 返回对应页面。 +2. **`ShellRoute` / `StatefulShellRoute`**:包一层常驻 UI(比如带底部导航栏的外壳),内部嵌套的子路由切换时,外壳本身不重建;`StatefulShellRoute` 还能让每个 tab 各自保留自己的页面栈(切 tab 不丢失之前的浏览位置)。 +3. **`GoRouterState`**:在 `builder` 里能拿到当前路由的 path 参数(`state.pathParameters`)、query 参数(`state.uri.queryParameters`)、`extra` 对象。 +4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。 +5. **`context.go()` / `context.push()`**:`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。 + +### 使用示例(底部导航 + 门店详情页) + +> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。 + +```dart +// packages/feature_store_mgmt/lib/feature_store_mgmt.dart +List buildStoreRoutes() => [ + GoRoute( + path: '/store', + builder: (context, state) => const StoreListPage(), + routes: [ + GoRoute( + path: ':storeId', // 完整路径 /store/:storeId + builder: (context, state) { + final storeId = state.pathParameters['storeId']!; + return StoreDetailPage(storeId: storeId); + }, + ), + ], + ), +]; +``` + +```dart +// 从任意页面跳转到门店详情 +context.push('/store/${store.id}'); +``` + +`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。 diff --git a/docs/05-networking.md b/docs/05-networking.md new file mode 100644 index 0000000..2ee1b08 --- /dev/null +++ b/docs/05-networking.md @@ -0,0 +1,447 @@ +# 05. 网络层设计 + +## 决策 + +使用 **[dio](https://pub.dev/packages/dio)**(`^5.11.0`,2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、响应解包、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。 + +`feature_*` 的 repository **不直接依赖 `Dio`,而是依赖 `core_network` 暴露的 `ApiClient`**——原因见下文「为什么要在 `Dio` 外面再包一层 `ApiClient`」。 + +## 依赖 + +```yaml +dependencies: + dio: ^5.11.0 + uuid: ^4.5.1 # 生成客户端 traceId +``` + +## 使用规则 + +- `core_network` 暴露一个单例 `Dio` 实例和基于它的 `ApiClient`(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。 +- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时串行刷新)→ `ApiResultInterceptor`(解开后端统一响应包装)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。 +- 业务代码只捕获 `AppException` 及其子类(如 `NetworkException`、`UnauthorizedException`、`BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。 +- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md))。 +- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 provider 的 `ref.onDispose` 里调用 `cancel()`。 + +## 后端契约:统一响应包装 + +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](../../conti-backend/docs/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。 + +```dart +// packages/core_network/lib/src/api_result_interceptor.dart +class ApiResultInterceptor extends Interceptor { + ApiResultInterceptor(this._logger); + final AppLogger _logger; + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final body = response.data; + // 非 JSON 对象响应(如文件下载)不走解包 + if (body is! Map || !body.containsKey('code')) { + return handler.next(response); + } + + // 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛 + final code = (body['code'] as num?)?.toInt(); + final traceId = body['traceId'] as String?; + _logger.d('[api] ${response.requestOptions.uri} code=$code traceId=$traceId'); + + if (code == 0) { + // 把外层包装剥掉,repository 的 fromJson 只需要认识 data 的结构 + response.data = body['data']; + return handler.next(response); + } + + // code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层 + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: BusinessException( + code: code ?? -1, + message: (body['message'] as String?) ?? '请求失败', + traceId: traceId, + ), + ), + true, // callFollowingErrorInterceptor + ); + } +} +``` + +**`traceId` 必须留存**:backend 06/08 明确指望"用户报一个 traceId,后端就能在日志里定位这次请求"。所以 + +- 每条 API 日志都带 `traceId`(成功失败都带)。 +- 错误提示 UI 上要能看到 traceId(不用显眼,可以放在"详情"里或长按复制),具体展示形式见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。 +- 崩溃/错误上报时把 traceId 作为 tag 带上(见 [13-observability-analytics.md](./13-observability-analytics.md))。 + +## 统一请求头 + +```dart +// packages/core_network/lib/src/header_interceptor.dart +@override +void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final env = _ref.read(appEnvProvider); + options.headers.addAll({ + 'X-Trace-Id': const Uuid().v4(), // 客户端生成,便于端到端串联 + 'X-App-Version': env.appVersion, // 如 1.4.0+142 + 'X-Platform': Platform.isIOS ? 'ios' : 'android', + 'X-Device-Id': _ref.read(deviceIdProvider), // 安装级匿名 ID,不是 IMEI/IDFA + }); + // 当前门店上下文;未登录/未选门店时不带 + final storeId = _ref.read(currentStoreIdProvider.select((s) => s)); + if (storeId != null) options.headers['X-Store-Id'] = '$storeId'; + handler.next(options); +} +``` + +- `X-Store-Id` 是**冗余信息**:access token 的 claims 里已经有 `storeId`(backend 04),后端以 token 为准。带这个头只是为了日志排查时能一眼看出客户端当时认为自己在哪个门店——如果两者不一致,说明切换门店后 token 没换,是个 bug 信号。 +- **`X-Trace-Id` 需要和后端对齐一次**:backend 06 说 traceId 由后端入口 filter 生成。约定是**后端优先复用请求头里的 `X-Trace-Id`,没有才自己生成**,否则客户端日志和服务端日志会各用一套 ID 对不上。这条挂在待确认项里。 +- 不采集 IMEI/IDFA/MAC 等设备唯一标识,`deviceId` 用首次安装时生成并存本地的随机 UUID,避免踩合规红线(见 [07-native-integration.md](./07-native-integration.md) 的隐私清单部分)。 + +## Token 刷新:必须串行,失败即登出 + +这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 **refresh token 轮换策略**强耦合。 + +按 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md): + +- refresh token 是**一次性**的,每次换 access token 都会签发新的、旧的立刻 `revokedAt`。 +- **旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销**。 + +由此推出三条客户端硬性约束: + +1. **绝对不能并发刷新。** 两个请求同时 401、同时拿同一个旧 refresh token 去换,第二个必然被判为重放 → 用户被全设备强制登出。这就是刷新队列存在的真正原因,不是为了"省一次请求"。 +2. **刷新失败不能重试。** 失败意味着 refresh token 已过期/已撤销/已被重放,再试一次结果一样。直接登出跳登录页。 +3. **刷新请求本身不能走带 `AuthInterceptor` 的那个 `Dio`**,否则刷新接口返回 401 时会再次触发刷新,无限递归。`core_auth` 内部自建一个**裸 `Dio`**(不装任何拦截器)专门发刷新请求——这也是 [01-project-structure.md](./01-project-structure.md) 里 "`core_auth` 不依赖 `core_network`" 这条规则的由来。 + +```dart +// packages/core_network/lib/src/auth_interceptor.dart +class AuthInterceptor extends Interceptor { + AuthInterceptor(this._ref); + final Ref _ref; + + /// 同一时刻最多一个刷新在跑;其他 401 请求 await 同一个 Future + Future? _refreshing; + + static const _retriedKey = 'x-retried'; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = _ref.read(authStateProvider).accessToken; + if (token != null) options.headers['Authorization'] = 'Bearer $token'; + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode != 401) return handler.next(err); + + // 一次性重试标记:带着新 token 重放后又 401,说明不是 token 的问题,别再刷了 + if (err.requestOptions.extra[_retriedKey] == true) { + _ref.read(authStateProvider.notifier).logout(); + return handler.next(err); + } + + try { + // 用一个共享的 Future 天然实现串行:先到的发起刷新,后到的复用同一个 Future + _refreshing ??= _ref.read(authRepositoryProvider).refreshToken(); + await _refreshing; + } catch (e) { + // 刷新失败 = refresh token 已失效,不重试,直接登出 + _ref.read(authStateProvider.notifier).logout(); + return handler.next(err); + } finally { + _refreshing = null; + } + + // 刷新成功,用新 token 重放原请求 + try { + final options = err.requestOptions + ..extra[_retriedKey] = true + ..headers['Authorization'] = + 'Bearer ${_ref.read(authStateProvider).accessToken}'; + handler.resolve(await _ref.read(dioProvider).fetch(options)); + } on DioException catch (e) { + handler.next(e); + } + } +} +``` + +> 对比:常见的"`bool _isRefreshing` + `List` 队列"写法有个致命缺陷——`catch` 分支里如果忘了对队列里的 `Completer` 调 `completeError` 并清空,所有排队的请求会**永久挂起**(`await completer.future` 永不返回),表现是 UI 一直转圈、用户只能杀进程。用共享 `Future` 的写法从结构上就不存在这个问题:刷新失败时 `await _refreshing` 对每个等待者都会抛异常,各自走各自的 `catch`,没有需要手动清理的队列。 + +`core_auth` 侧的刷新实现: + +```dart +// packages/core_auth/lib/src/token_refresher.dart +class TokenRefresher { + // 裸 Dio:不装任何拦截器,避免刷新请求自己再触发一轮刷新 + final _bare = Dio(BaseOptions( + baseUrl: AppEnv.current.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + )); + + Future refresh(String refreshToken) async { + final res = await _bare.post('/api/v1/auth/refresh', data: {'refreshToken': refreshToken}); + final data = res.data['data'] as Map; // 裸 Dio 没有解包拦截器,手动取 + // 后端轮换:新的 refreshToken 必须立刻覆盖存储,旧的已经作废了 + return TokenPair( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String, + ); + } +} +``` + +**新的 refresh token 一定要写回 secure storage**(见 [06-local-storage.md](./06-local-storage.md))。写回失败或写回前进程被杀,下次启动用旧 token 就会触发重放判定——所以写回要在"通知 `authState` 更新"之前完成。 + +## 异常归一化 + +```dart +// packages/core_network/lib/src/error_mapping_interceptor.dart +class ErrorMappingInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 已经是 AppException 的(比如 ApiResultInterceptor 抛的 BusinessException)直接放行, + // 不要二次包装成 NetworkException + if (err.error is AppException) return handler.next(err); + + final mapped = switch (err.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => NetworkException('网络超时,请检查网络后重试'), + DioExceptionType.cancel => RequestCancelledException(), + DioExceptionType.badResponse when err.response?.statusCode == 401 => + UnauthorizedException(), + DioExceptionType.badResponse => HttpException( + statusCode: err.response?.statusCode ?? -1, + message: '服务异常(${err.response?.statusCode})', + ), + _ => NetworkException('网络异常,请稍后重试'), + }; + handler.next(DioException( + requestOptions: err.requestOptions, + response: err.response, + error: mapped, + )); + } +} +``` + +### 为什么要在 `Dio` 外面再包一层 `ApiClient` + +拦截器**没有办法让 `dio.get()` 抛出 `AppException`**。dio 的错误通道只认 `DioException`,`handler.reject(...)` 传进去的必须是 `DioException`,我们的 `AppException` 只能挂在它的 `error` 字段上。也就是说,如果 repository 直接调 `dio.get()`,业务层写 + +```dart +try { ... } on UnauthorizedException { ... } // ❌ 永远进不来 +``` + +是**捕获不到的**——实际抛出来的仍然是 `DioException`。 + +解决办法是在 `core_network` 的出口把 `DioException.error` 拆出来重抛: + +```dart +// packages/core_network/lib/src/api_client.dart +class ApiClient { + ApiClient(this._dio); + final Dio _dio; + + Future get(String path, {Map? query, CancelToken? cancelToken}) => + _run(() => _dio.get(path, queryParameters: query, cancelToken: cancelToken)); + + Future post(String path, {Object? data, CancelToken? cancelToken}) => + _run(() => _dio.post(path, data: data, cancelToken: cancelToken)); + + Future _run(Future> Function() send) async { + try { + final res = await send(); + return res.data as T; + } on DioException catch (e, st) { + final error = e.error; + // 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛 + if (error is AppException) Error.throwWithStackTrace(error, st); + Error.throwWithStackTrace(NetworkException('网络异常,请稍后重试'), st); + } + } +} +``` + +**规则:repository 一律注入 `ApiClient`,不注入 `Dio`。** 只有 `core_network` 内部和 `core_auth` 的裸 Dio 会直接碰 `Dio` 类型。这样上面那段 `on UnauthorizedException` 才真的成立。 + +`Error.throwWithStackTrace` 保留原始堆栈,否则上报到崩溃平台的堆栈会全部指向 `_run` 这一行,等于没有堆栈。 + +## 超时、重试与幂等 + +```dart +BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 30), // 上传单独放宽,见下文 +) +``` + +**默认不做自动重试。** 理由和 [03-state-management.md](./03-state-management.md) 里全局关掉 Riverpod retry 是同一条:多层重试叠加会让一次用户操作变成难以预测的 N 次请求,日志也没法看。需要重试的地方显式写、并且必须满足: + +- **只重试 GET**,或后端明确支持幂等键(`Idempotency-Key` 头)的 POST。 +- 只对超时/连接失败重试,业务错误码和 4xx 不重试。 +- 最多 1 次。 + +`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](../../conti-backend/docs/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。 + +## `CancelToken` 与 provider 生命周期 + +```dart +@riverpod +Future> purchaseOrders(Ref ref) async { + final cancelToken = CancelToken(); + ref.onDispose(cancelToken.cancel); // 页面销毁 / 门店切换导致 provider 重建时自动中断 + + final storeId = ref.watch(currentStoreIdProvider); + return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId, cancelToken: cancelToken); +} +``` + +被取消的请求会抛 `RequestCancelledException`。**UI 层必须把它当"什么都不做"处理,不能弹错误提示**——用户主动离开页面时看到"请求失败"是很糟的体验。这条在 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的错误展示规则里统一约定。 + +## 文件与图片上传 + +PRD §7.4(H5 桥接的图片选择/上传)和施工照片场景都要用到。 + +```dart +// packages/core_network/lib/src/api_client.dart +Future upload( + String path, { + required List files, + Map? fields, + void Function(int sent, int total)? onProgress, + CancelToken? cancelToken, +}) async { + final formData = FormData.fromMap({ + ...?fields, + 'files': [ + for (final f in files) + await MultipartFile.fromFile(f.path, filename: p.basename(f.path)), + ], + }); + return _run(() => _dio.post( + path, + data: formData, + cancelToken: cancelToken, + onSendProgress: onProgress, + // 上传单独放宽超时,用全局的 30s 传几张原图会超 + options: Options(sendTimeout: const Duration(minutes: 3)), + )); +} +``` + +约定: + +- **上传前必须压缩**。门店员工用手机直接拍的照片通常 3–8 MB,原图上传在门店 WiFi 环境下大概率超时。统一压到长边 1600px、JPEG 质量 80,超过 2 MB 再降一档。 +- **进度必须可见**:多图上传要有整体进度,否则用户会以为卡死反复点。 +- **失败要能单张重传**,不能因为第 5 张失败就让前 4 张重来。所以 UI 上传状态按单张维护。 +- `FormData` **不可重用**:dio 的 `FormData` 是流,重试必须重新构造一个,直接复用会报 stream already listened。 + +## 传输安全 + +- **全环境强制 HTTPS**,包括 dev。Android 侧在 `network_security_config.xml` 里关掉明文流量(`cleartextTrafficPermitted="false"`),iOS 不放开 ATS 例外。这样"某个环境不小心配了 http 的 baseUrl"会在开发阶段就直接失败,而不是上线后才发现。 +- **证书 pinning:首版不做。** 取舍如下——pinning 能防中间人抓包,但代价是证书轮换时必须发新版 App,否则全线不可用;而门店 App 走的是公司自有域名 + 标准 CA,主要威胁模型是"员工手机装了抓包工具看接口",这个用 pinning 挡的收益不高。如果后续有合规要求再加,届时用**双证书 pin(当前 + 备用)** 并且 pin 到中间 CA 而不是叶子证书,留出轮换空间。 +- 日志脱敏:`LogInterceptor` 只在 dev/staging 开启,且 `Authorization` 头、密码、手机号在打日志前替换成掩码。这条同样适用于上报到崩溃平台的面包屑(见 [13-observability-analytics.md](./13-observability-analytics.md))。 + +## 待确认项 + +- `X-Trace-Id` 由客户端生成、后端复用——需与后端确认入口 filter 的实现。 +- 分页参数字段名(backend 06 的「待补充」里也挂着这一项,见 [02-layering.md](./02-layering.md))。 +- 错误码表(backend 06 待补充),拿到后补进 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的映射表。 +- 上传接口的大小上限、允许的文件类型、是否走对象存储直传。 + +## 参考链接 + +- [dio 官方文档](https://pub.dev/packages/dio) +- [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors) +- [Dio CancelToken](https://pub.dev/packages/dio#cancellation) +- [Android network security config](https://developer.android.com/privacy-and-security/security-config) + +## 附录:dio 是什么,日常怎么用 + +给还没接触过这套网络层封装方式的同学看的入门说明。 + +### 要解决的问题 + +Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求: + +1. **每个请求都要带 token**,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。 +2. **统一的错误处理**:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 `try/catch` 判断状态码。 +3. **请求/响应日志**:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。 + +`http` 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。**dio** 内置了 [`Interceptor`](https://pub.dev/packages/dio#interceptors) 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。 + +### 核心概念 + +1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions`(`baseUrl`、`connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。 +2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机。**注意 dio 的执行顺序**:三个时机都是按注册顺序**正向**执行的,不是"请求正向、响应反向"的洋葱模型——这一点和很多人的直觉不同,配置拦截器顺序时要留意。 +3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。 +4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。 + +### 拦截器链的组装 + +```dart +// packages/core_network/lib/src/dio_client.dart +final dioProvider = Provider((ref) { + final env = ref.watch(appEnvProvider); + final dio = Dio(BaseOptions( + baseUrl: env.apiBaseUrl, // 见 08-build-flavors.md + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 30), + )); + + dio.interceptors.addAll([ + HeaderInterceptor(ref), + if (env.enableLog) LogInterceptor(responseBody: false), + AuthInterceptor(ref), + ApiResultInterceptor(ref.watch(loggerProvider)), + ErrorMappingInterceptor(), + ]); + + return dio; +}); + +final apiClientProvider = Provider((ref) => ApiClient(ref.watch(dioProvider))); +``` + +顺序的理由:`AuthInterceptor` 必须排在 `ErrorMappingInterceptor` 前面,才能在 401 被归一化成 `UnauthorizedException` **之前**先尝试刷新 token;`ApiResultInterceptor` 排在 `ErrorMappingInterceptor` 前面,是因为它抛出的 `BusinessException` 需要能被后者识别并放行(后者第一行就是判断 `err.error is AppException`)。 + +### 业务层看到的样子 + +```dart +// data/repository/purchase_repository_impl.dart +class PurchaseRepositoryImpl implements PurchaseRepository { + PurchaseRepositoryImpl(this._api); + final ApiClient _api; + + @override + Future> fetchOrders(int storeId, {CancelToken? cancelToken}) async { + // 返回的已经是 ApiResult 里的 data,外层包装由拦截器解开 + final list = await _api.get>( + '/api/v1/purchase/orders', + query: {'storeId': storeId}, + cancelToken: cancelToken, + ); + return list.map((e) => PurchaseOrder.fromJson(e as Map)).toList(); + } +} +``` + +```dart +// presentation 层 +try { + final orders = await repository.fetchOrders(storeId); +} on UnauthorizedException { + // 已经被 AuthInterceptor 处理过登出,这里一般只需要静默 +} on BusinessException catch (e) { + showToast('${e.message}(${e.traceId})'); +} on RequestCancelledException { + // 用户主动离开,什么都不做 +} on AppException catch (e) { + showToast(e.message); +} +``` diff --git a/docs/06-local-storage.md b/docs/06-local-storage.md new file mode 100644 index 0000000..943b07f --- /dev/null +++ b/docs/06-local-storage.md @@ -0,0 +1,323 @@ +# 06. 本地存储方案 + +## 决策 + +按数据类型分三档存储,`feature_*` 不直接依赖底层存储库: + +| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 | +|---|---|---|---| +| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` | +| 敏感数据(token、refresh token) | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** | +| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` | + +> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。 + +## 依赖 + +```yaml +# core_storage +dependencies: + drift: ^2.34.3 + drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包 + path_provider: ^2.1.6 + shared_preferences: ^2.5.5 + +dev_dependencies: + drift_dev: ^2.34.5 + build_runner: ^2.15.2 + +# core_auth +dependencies: + flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文 +``` + +> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider`,`driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。 + + +## 使用规则 + +- 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。 +- 每个 feature 拥有自己的表(`Table` 类)和 DAO(`DriftAccessor`),表名加 feature 前缀(如 `store_cache`、`payment_history`)避免命名冲突,但都注册进同一个 `AppDatabase`。 +- feature 的 `data` 层 `local_datasource` 只依赖自己的 DAO 类型,不直接操作 `AppDatabase` 或访问其他 feature 的表。 +- token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。 +- 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。 + +## 所有业务缓存表必须带 `storeId` + +PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。 + +**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且 + +- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询; +- `storeId` 建索引; +- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。 + +```dart +class PurchaseOrderCache extends Table { + IntColumn get storeId => integer()(); + TextColumn get orderId => text()(); + TextColumn get payload => text()(); + DateTimeColumn get cachedAt => dateTime()(); + + @override + Set get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离 +} +``` + +不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。 + +## 登出 / 切换门店的清理策略 + +| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 | +|---|---|---|---|---| +| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md)) | +| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage | +| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 | + +**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。 + +```dart +// packages/core_storage/lib/src/app_database.dart +extension StoreScopedCleanup on AppDatabase { + /// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态 + Future clearBusinessCache() => transaction(() async { + for (final table in allTables.where(_isBusinessCache)) { + await delete(table).go(); + } + }); +} +``` + +**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。 + +**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。 + +## 缓存 TTL + +Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。 + +| 数据 | TTL | 过期后行为 | +|---|---|---| +| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" | +| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading | +| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 | +| 经营/财务分析数据 | 不缓存 | — | + +每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。 + +**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。 + +## Migration 必须被验证,不能只靠"写了" + +"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。 + +Drift 官方提供了 schema 快照 + 验证工具链,纳入流程: + +```bash +# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库) +fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/ + +# 2. 生成迁移测试的辅助代码 +fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/ +``` + +```dart +// packages/core_storage/test/migration_test.dart +void main() { + late SchemaVerifier verifier; + setUpAll(() => verifier = SchemaVerifier(GeneratedHelper())); + + test('从 v1 到最新版本的迁移都能跑通', () async { + for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) { + final connection = await verifier.startAt(from); + final db = AppDatabase.forTesting(connection); + await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion); + await db.close(); + } + }); +} +``` + +规则: + +- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。 +- 迁移测试进 `melos run test`,CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 +- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。 + +## flutter_secure_storage 11.0.0 的升级风险 + +`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**(RSA OAEP + AES-GCM)。这意味着: + +- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。 +- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。 +- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。 + +读取失败的兜底必须写: + +```dart +Future readAccessToken() async { + try { + return await _storage.read(key: _kAccessToken); + } catch (e, st) { + // 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页 + _logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st); + await clear(); + return null; + } +} +``` + +**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。 + +## 数据库在后台 isolate 打开 + +大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter` 的 `driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可: + +```dart +// packages/core_storage/lib/src/connection.dart +QueryExecutor openConnection() => driftDatabase( + name: 'conti_app', + native: const DriftNativeOptions( + databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份 + ), + ); +``` + +iOS 上数据库文件放 `Application Support` 而不是 `Documents`:`Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。 + + +## 参考链接 + +- [Drift 官方文档](https://drift.simonbinder.eu/) +- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter) +- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/) +- [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage) +- [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences) + +## 附录:Drift 是什么,日常怎么用 + +给还没接触过这套本地数据库封装方式的同学看的入门说明。 + +### 要解决的问题 + +Flutter 生态里直接操作本地 SQLite 最常见的是 [`sqflite`](https://pub.dev/packages/sqflite),但它是纯 SQL 字符串拼接: + +```dart +// sqflite 写法,容易手滑打错字段名/表名,编译期完全发现不了 +await db.rawQuery('SELECT * FROM stroe WHERE nmae = ?', [name]); +``` + +字段名、表名全靠字符串,拼错了只有运行时才报错;查询结果是 `Map`,还得手动转成业务对象;数据变化了想让 UI 自动刷新,也得自己手写一套通知机制。 + +**Drift** 在 `sqflite`(或更底层的 `sqlite3`)之上加了一层代码生成:用 Dart 类定义表结构,`build_runner` 生成类型安全的查询代码,写错字段名/类型在编译期就会报错;查询结果直接是强类型的 Dart 对象;还内置了 `.watch()` 方法,数据变化时自动推送新结果,天然适合配合 Riverpod 的 `StreamProvider`/`AsyncNotifier` 做响应式 UI。 + +### 核心概念 + +1. **`Table` 类**:用 Dart 代码声明表结构(字段名、类型、约束),而不是手写 `CREATE TABLE` 语句。 +2. **`DriftAccessor`(DAO)**:给一组相关表写查询/增删改方法的地方,业务代码只调用 DAO 方法,不直接写 SQL。 +3. **`.watch()` vs `.get()`**:`.get()` 是一次性查询,`.watch()` 返回一个 `Stream`,只要底层数据变化(哪怕是另一个页面改的)就会自动推送新结果——不需要手动刷新。 +4. **`schemaVersion` + `onUpgrade`**:数据库版本号和迁移回调,改表结构时递增版本号并在 `onUpgrade` 里写迁移逻辑(加字段、建索引等),保证已安装用户的本地数据不会因为升级直接报错或丢失。 + +### 使用示例(`feature_store`:门店列表本地缓存) + +```dart +// packages/core_storage/lib/src/tables/store_table.dart +class StoreCache extends Table { + TextColumn get id => text()(); + TextColumn get name => text()(); + RealColumn get lat => real()(); + RealColumn get lng => real()(); + DateTimeColumn get cachedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} +``` + +```dart +// packages/core_storage/lib/src/daos/store_dao.dart +part 'store_dao.g.dart'; + +@DriftAccessor(tables: [StoreCache]) +class StoreDao extends DatabaseAccessor with _$StoreDaoMixin { + StoreDao(super.db); + + Future upsertAll(List stores) => + batch((b) => b.insertAllOnConflictUpdate(storeCache, stores)); + + Stream> watchAll() => select(storeCache).watch(); +} +``` + +```dart +// packages/core_storage/lib/src/app_database.dart +@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(openConnection()); + AppDatabase.forTesting(super.connection); // 迁移测试用 + + static const latestSchemaVersion = 2; + + @override + int get schemaVersion => latestSchemaVersion; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.addColumn(storeCache, storeCache.cachedAt); + } + }, + ); +} +``` + +> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。 + +```dart +// feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase +class StoreLocalDataSource { + final StoreDao _dao; + StoreLocalDataSource(this._dao); + + Stream> watchCachedStores() => + _dao.watchAll().map((rows) => rows.map(Store.fromCacheRow).toList()); +} +``` + +配合 Riverpod 做响应式 UI(离线也能展示上次缓存的门店列表,等网络数据回来再刷新): + +```dart +@riverpod +Stream> cachedStores(Ref ref) { + final localDataSource = ref.watch(storeLocalDataSourceProvider); + return localDataSource.watchCachedStores(); +} +``` + +### secure storage 使用示例(token 存取) + +```dart +// packages/core_auth/lib/src/token_storage.dart +class TokenStorage { + final FlutterSecureStorage _storage; + TokenStorage(this._storage); + + Future saveTokens({required String accessToken, required String refreshToken}) => + Future.wait([ + _storage.write(key: 'access_token', value: accessToken), + _storage.write(key: 'refresh_token', value: refreshToken), + ]); + + Future readAccessToken() => _storage.read(key: 'access_token'); + + Future clear() => _storage.deleteAll(); +} +``` + +`TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。 + +## 待确认项 + +- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。 +- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。 diff --git a/docs/07-native-integration.md b/docs/07-native-integration.md new file mode 100644 index 0000000..8ba96fc --- /dev/null +++ b/docs/07-native-integration.md @@ -0,0 +1,347 @@ +# 07. 原生能力集成方式 + +## 决策 + +原生能力(扫码、支付、蓝牙等)统一封装成独立的 `native_*` Dart package(结构见 [01-project-structure.md](./01-project-structure.md)),跨语言接口用 **[Pigeon](https://pub.dev/packages/pigeon)**(`^27.3.0`,2026-08 快照)生成,不手写裸 `MethodChannel`/`invokeMethod` 字符串调用。 + +## 依赖 + +```yaml +dev_dependencies: + pigeon: ^27.3.0 +``` + +## 包结构规则 + +``` +native_scan/ + pubspec.yaml # 必须有 flutter: plugin: platforms: 声明,见下文 + pigeons/ + scan_api.dart # 接口 schema 定义,唯一手写的源文件 + lib/ + native_scan.dart # 对外导出:封装好的公共 API 类(调用方只调这个) + src/ + generated/ # pigeon 生成的 Dart 端代码,不手动修改 + android/ + src/main/kotlin/.../ScanApi.g.kt # pigeon 生成 + src/main/kotlin/.../ScanApiImpl.kt # 手写:生成的 Kotlin host API 接口的实现 + src/main/kotlin/.../NativeScanPlugin.kt # 手写:插件注册入口 + ios/ + Classes/ScanApi.g.swift # pigeon 生成 + Classes/ScanApiImpl.swift # 手写:生成的 Swift host API 协议的实现 + Classes/NativeScanPlugin.swift # 手写:插件注册入口 +``` + +首版只有 `android/` 和 `ios/`(OHOS 不在首版范围,见文末「OHOS 后续演进」)。 + +### `pubspec.yaml` 必须声明 plugin platforms + +这是最容易漏、漏了最难排查的一条:**`native_*` 包如果没有 `flutter: plugin:` 声明,`android/`、`ios/` 下的原生代码根本不会被编译进宿主 App**。表现是 Dart 侧调用直接抛 `MissingPluginException`,而代码看上去哪里都没问题。 + +```yaml +# packages/native_scan/pubspec.yaml +name: native_scan +resolution: workspace + +environment: + sdk: ^3.12.0 + flutter: '>=3.44.0' + +flutter: + plugin: + platforms: + android: + package: com.conti.native_scan + pluginClass: NativeScanPlugin + ios: + pluginClass: NativeScanPlugin +``` + +`pluginClass` 指向的类需要实现 `FlutterPlugin`(Android)/ `FlutterPlugin` 协议(iOS),在 `onAttachedToEngine` 里把 `ScanApiImpl` 注册到 pigeon 生成的 `setUp` 方法上: + +```kotlin +// android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt +class NativeScanPlugin : FlutterPlugin, ActivityAware { + private var impl: ScanApiImpl? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + impl = ScanApiImpl() + ScanHostApi.setUp(binding.binaryMessenger, impl) // pigeon 生成的注册方法 + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + ScanHostApi.setUp(binding.binaryMessenger, null) + impl = null + } + + // 扫码需要 Activity(起 CameraX 预览页),通过 ActivityAware 拿 + override fun onAttachedToActivity(binding: ActivityPluginBinding) { impl?.activity = binding.activity } + override fun onDetachedFromActivity() { impl?.activity = null } + override fun onReattachedToActivityForConfigChanges(b: ActivityPluginBinding) = onAttachedToActivity(b) + override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity() +} +``` + +> `ActivityAware` 不能省。扫码、相册选择、拨号这类能力都需要 `Activity`(起页面、申请权限、收 `onActivityResult`),只在 `onAttachedToEngine` 里拿 `applicationContext` 是不够的。而且 `onDetachedFromActivity` 里必须把引用置空,否则横竖屏切换或后台回收后会持有已销毁的 Activity,导致内存泄漏和崩溃。 + +## 扫码的归属:App 原生实现 + +**扫码由 App 原生实现(`native_scan`),不是 F6 的功能。** + +`native_scan` 同时服务两个调用方: + +``` +feature_scan(App 内的扫码页:扫码入库、扫码查件) + ↘ + native_scan → 原生相机 + 解码 + ↗ +core_webview 的 JSBridge(H5 页面调起扫码,见 10-webview-h5.md) +``` + +这也是 [01-project-structure.md](./01-project-structure.md) 里"`core_*` 允许依赖 `native_*`"这条例外存在的原因——如果只允许 `feature_* → native_*`,`core_webview` 的 JSBridge 就没法调起扫码,只能退化成"复制一份扫码实现"或者"让 core_webview 反向依赖 feature_scan",两条都不可接受。 + +> **与 PRD 的已知冲突**:PRD §11.5 和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。以本文档为准(扫码是 App 做的),**PRD 需要回头修订**。 + +### 待确认:VIN 码与车牌识别的技术路径 + +这是一个**还没解决的能力缺口**,必须在开工前定下来。 + +PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**。 + +| 需求 | 能力 | 候选方案 | +|---|---|---| +| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode Scanning(Android)/ Vision(iOS),或 `mobile_scanner` | +| VIN 条码 | Barcode(Code 39) | 同上 | +| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision;VIN 有第 9 位校验码,可用来过滤误识别 | +| 车牌 | 专用 OCR | MLKit/Vision 通用 OCR 准确率偏低;或接第三方车牌识别 SDK(如车牌识别专用商用 SDK) | + +**建议路径**:条码走 MLKit/Vision(免费、离线、成熟);VIN 印刷字符用通用 OCR + VIN 校验位过滤,先验证准确率;**车牌单独做一次技术验证**,通用 OCR 达不到可用准确率就要评估采购商用 SDK(涉及成本、离线授权、包体积、以及是否上传图片到第三方服务器的合规问题)。 + +在验证结论出来之前,**`native_scan` 的 Pigeon schema 要预留 `ScanMode` 参数**(`barcode` / `vin` / `plate`),避免后面加识别类型时要改接口签名。 + +## 权限与合规 + +`native_*` 涉及的运行时权限: + +| 能力 | Android 权限 | iOS `Info.plist` key | +|---|---|---| +| 扫码 / 拍照 | `CAMERA` | `NSCameraUsageDescription` | +| 相册选择 | `READ_MEDIA_IMAGES`(API 33+) | `NSPhotoLibraryUsageDescription` | +| 保存图片 | `WRITE_EXTERNAL_STORAGE`(API ≤ 28) | `NSPhotoLibraryAddUsageDescription` | +| 拨号 | 无需权限(`ACTION_DIAL` 不需要 `CALL_PHONE`) | 无(`tel:` scheme) | + +规则: + +- **权限申请必须在用到的那一刻发起,不在启动时批量申请。** 启动就要相机权限是应用商店审核和用户流失的双重风险。 +- **被拒绝后要有引导**:拒绝一次 → 说明为什么需要 + 再次申请;选了"不再询问" → 提示并提供跳转系统设置的入口。不能只是 toast 一句"没有权限"然后什么也做不了。 +- iOS 用途说明文案要写具体("用于扫描商品条码入库"),写"需要相机权限"这种会被审核打回。 +- 拨号用 `ACTION_DIAL` / `tel:` **拉起拨号盘让用户自己按拨出**,不用 `CALL_PHONE` 直接拨号——后者要额外的危险权限,还容易被审核质疑。 + +### iOS 隐私清单 `PrivacyInfo.xcprivacy`(上架强制) + +苹果自 2024 年起强制要求 App 及其使用的三方 SDK 提供隐私清单,**没有会直接被拒**。每个 `native_*` 包如果访问了需要声明的 API,要在 `ios/Resources/PrivacyInfo.xcprivacy` 里声明: + +```xml +NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + C617.1 + + +``` + +同时确认三方依赖(相机/图片压缩/崩溃上报 SDK)是否自带隐私清单——不带的需要我们在主 App 里替它声明,或者换一个带的。这条要在**首次提交 TestFlight 前**验证,别留到上架当天。 + +**我们不采集设备唯一标识**(IMEI/IDFA/MAC),所以不需要声明 `NSPrivacyTracking`(见 [05-networking.md](./05-networking.md) 的 `X-Device-Id` 约定)。 + +## Pigeon 的工程化 + +生成命令不写在 README 里让人手敲,而是把配置写进 schema、动作做成 melos script。 + +```dart +// native_scan/pigeons/scan_api.dart +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/scan_api.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'), + swiftOut: 'ios/Classes/ScanApi.g.swift', + swiftOptions: SwiftOptions(), + dartPackageName: 'native_scan', +)) +library; + +@HostApi() +abstract class ScanHostApi { /* ... */ } +``` + +配置写进 `@ConfigurePigeon` 之后,生成命令就退化成一行,不会出现"某人生成时路径敲错,生成物落到别的目录": + +```bash +fvm dart run pigeon --input pigeons/scan_api.dart +``` + +melos script(见 [01-project-structure.md](./01-project-structure.md)): + +```yaml +pigeon: + run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/ +``` + +生成产物的处理: + +- `*.g.dart` / `*.g.kt` / `*.g.swift` **入 git**(同 riverpod/drift 的生成物,理由见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 +- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。 +- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。 + +## OHOS 后续演进 + +鸿蒙(OpenHarmony)**不在首版范围**,但基线决策是为它留了口子的,这里记录清楚,避免后面接的时候重新走一遍弯路。 + +接 OHOS 需要处理三件事: + +1. **SDK 分支不同**:OHOS 用的是 OpenHarmony 社区维护的 Flutter 分支,版本落后于官方 stable 一段时间。这正是 [01-project-structure.md](./01-project-structure.md) 里 SDK 基线刻意停在 **3.44.9** 而不追 3.47.0 的原因——基线跑太前,OHOS 分支跟不上就接不进来。 +2. **Pigeon 没有 ArkTS 生成器**:Pigeon 官方只生成 Kotlin/Java、Swift/Objective-C、C++、GObject,**没有 ArkTS/OHOS**。所以 OHOS 侧的 channel 代码只能**手写**,需要人工保证方法名、参数结构与 Pigeon 生成的 Dart 端编解码格式一致——这是一份实打实的额外维护成本,接 OHOS 时要预留出来。 +3. **`native_*` 包要加 `ohos:` 平台声明**,并新增 `ohos/` 目录。 + +在此之前,`native_*` 的公共 API 类里遇到不支持的平台,一律抛明确的 `UnsupportedPlatformException`,不静默返回空值或占位假数据——静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。 + + +## 使用规则 + +- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和两端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。 +- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。 +- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。 +- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。 +- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。 + +## 待确认项 + +- **车牌识别的技术路径**(通用 OCR 是否够用,还是要采购商用 SDK)——见上文,开工前必须有结论。 +- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证。 +- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。 + +## 参考链接 + +- [Pigeon 官方文档](https://pub.dev/packages/pigeon) +- [Flutter 平台通道官方文档](https://docs.flutter.dev/platform-integration/platform-channels) +- [编写 Flutter plugin package](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin-platforms) +- [Apple: 隐私清单文件](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files) +- [Android 运行时权限最佳实践](https://developer.android.com/training/permissions/requesting) + +## 附录:Pigeon 是什么,日常怎么用 + +给还没接触过跨语言原生集成的同学看的入门说明。 + +### 要解决的问题 + +Flutter 原生的 [`MethodChannel`](https://docs.flutter.dev/platform-integration/platform-channels) 机制本质是"字符串方法名 + 弱类型参数"的消息传递: + +```dart +// 手写 MethodChannel,容易出的问题: +final result = await MethodChannel('scan_channel').invokeMethod('startScan', {'timeout': 5000}); +// 1. 'startScan' 是字符串,原生那边方法名打错了,运行时才报 "not implemented" +// 2. 参数是 Map,字段名/类型对不上,运行时才崩,编译期完全看不出来 +// 3. 返回值类型是 dynamic,还要自己强转、自己判断 null +``` + +三个问题的共性是:**Dart 和原生代码之间没有共享的类型系统**,接口的一致性完全靠开发者手动保证、runtime 才能发现错误。 + +**Pigeon** 用一个 Dart 文件定义"接口 schema"(有哪些方法、参数和返回值类型),然后生成 Dart 端 + Android(Kotlin) + iOS(Swift) 三端的强类型桩代码——方法名、参数、返回类型三端保持一致,改了 schema 忘记同步实现,编译期就会报错(生成的原生接口是抽象类/协议,没实现完整会编译不过),彻底消灭"方法名打错""参数字段对不上"这类只有运行时才发现的问题。 + +### 核心概念 + +1. **Schema 文件**(`pigeons/xxx_api.dart`):用普通 Dart 类和注解描述接口,不是真的可执行代码,只是给 pigeon 生成器读的"接口契约"。 +2. **`@HostApi()`**:声明一个"Dart 调用原生"的接口,pigeon 生成 Dart 端可直接调用的类,以及原生端需要实现的抽象类/协议。 +3. **`@FlutterApi()`**:声明一个"原生调用 Dart"的接口(方向相反),用于原生侧主动推送事件(比如蓝牙扫描持续上报发现的设备)。 +4. **生成命令**:`dart run pigeon --input pigeons/xxx_api.dart` 会同时生成 Dart、Kotlin、Swift 三份代码,开发者只需要去实现原生那两个抽象类/协议里的方法体。 + +### 使用示例(`native_scan`:扫码能力) + +```dart +// native_scan/pigeons/scan_api.dart —— 唯一手写的 schema 文件 +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/scan_api.g.dart', + kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'), + swiftOut: 'ios/Classes/ScanApi.g.swift', + dartPackageName: 'native_scan', +)) +library; + +@HostApi() +abstract class ScanHostApi { + @async + ScanResult startScan(ScanOptions options); + void stopScan(); +} + +/// 预留识别类型,避免后面加车牌/VIN 识别时改接口签名 +enum ScanMode { barcode, vin, plate } + +class ScanOptions { + ScanOptions({required this.mode, required this.timeoutMs}); + final ScanMode mode; + final int timeoutMs; +} + +class ScanResult { + ScanResult({required this.value, required this.format}); + final String value; + final String format; // QR_CODE / CODE_39 / OCR_TEXT ... +} +``` + +```bash +# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径 +fvm dart run pigeon --input pigeons/scan_api.dart +``` + +Android 端实现生成的抽象类(`ScanApiImpl.kt`,非生成代码,是需要手写的实现): + +```kotlin +class ScanApiImpl : ScanHostApi { + var activity: Activity? = null // 由 NativeScanPlugin 的 ActivityAware 回调注入 + + override fun startScan(options: ScanOptions, callback: (Result) -> Unit) { + val act = activity ?: return callback(Result.failure( + FlutterError("NO_ACTIVITY", "扫码需要前台 Activity", null))) + // 调用具体的扫码 SDK,拿到结果后: + callback(Result.success(ScanResult(value = "123456", format = "QR_CODE"))) + } + + override fun stopScan() { + // 停止扫码 SDK + } +} +``` + +Dart 端对外的公共 API(`native_scan.dart`,调用方唯一能用的入口): + +```dart +class NativeScan { + final ScanHostApi _api = ScanHostApi(); + + Future startScan({ + ScanMode mode = ScanMode.barcode, + Duration timeout = const Duration(seconds: 30), + }) async { + try { + return await _api.startScan( + ScanOptions(mode: mode, timeoutMs: timeout.inMilliseconds), + ); + } on PlatformException catch (e, st) { + // 原生异常不外泄,统一转成 05 里的 AppException 体系 + Error.throwWithStackTrace( + NativeCapabilityException('扫码失败: ${e.message}', code: e.code), st); + } + } + + Future stopScan() => _api.stopScan(); +} +``` + +`feature_scan` 和 `core_webview` 的 JSBridge 都只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装,将来换扫码 SDK 或补 OHOS 实现,调用方一行都不用动。 diff --git a/docs/08-build-flavors.md b/docs/08-build-flavors.md new file mode 100644 index 0000000..2714bc4 --- /dev/null +++ b/docs/08-build-flavors.md @@ -0,0 +1,301 @@ +# 08. 多环境构建 + +## 决策 + +App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CI,但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。 + +> ⚠️ **Android 可以复用与后端共用的 Linux Runner,iOS 不行。** `flutter build ipa` 必须跑在 macOS 上,这是首版发版前必须先解决的工程阻塞项,详见下文「iOS 构建链路:当前不成立,必须先解决」。 + + +## Flavor 划分规则 + +| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 | +|---|---|---|---| +| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(渠道待定,见下文) | +| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(渠道待定,见下文) | +| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / 各安卓应用市场 | + +## 使用规则 + +- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。 +- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。 +- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。 +- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。 +- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 + +## 用 `applicationIdSuffix` 而不是覆盖 `applicationId` + +```gradle +productFlavors { + dev { dimension "env"; applicationIdSuffix ".dev"; versionNameSuffix "-dev" } + uat { dimension "env"; applicationIdSuffix ".uat"; versionNameSuffix "-uat" } + prod { dimension "env" } // 用 defaultConfig 的 applicationId,不加后缀 +} +``` + +理由:直接覆盖 `applicationId` 会让 `applicationId` 和 **Kotlin 源码的 package 名脱钩**。Android 的 `R` 类、`BuildConfig` 类是按 `namespace`(源码 package)生成的,而 `applicationId` 只影响安装标识——两者写成不同的值本身合法,但很多三方 SDK(推送、地图、统计)的初始化会隐式假设它们一致,配错的表现是"dev 包能跑,uat 包某个 SDK 静默失效"。用 `applicationIdSuffix` 只在末尾加后缀,`namespace` 保持不变,从结构上避免这类问题。 + +对应地 iOS 侧 xcconfig 里也用 `PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail$(BUNDLE_ID_SUFFIX)`,`BUNDLE_ID_SUFFIX` 按 Build Configuration 取 `.dev` / `.uat` / 空。 + +## Release 构建必须开混淆和符号剥离 + +```bash +fvm flutter build appbundle \ + --flavor prod --target lib/main_prod.dart \ + --dart-define-from-file=env/prod.json \ + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG +``` + +- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。 +- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。 +- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。 +- 同时把 `build/symbols/` 按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。 +- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。 + +## 版本号规则 + +| 字段 | 来源 | 示例 | +|---|---|---| +| `versionName` | git tag(去掉 `v` 前缀) | tag `v1.4.0` → `1.4.0` | +| `versionCode` / `CFBundleVersion` | CI pipeline ID(单调递增) | `$CI_PIPELINE_ID` → `48213` | + +要点: + +- `versionCode` **必须单调递增且永不重复**——Google Play 和 App Store Connect 都会拒绝重复或回退的版本号,而这个错误只在上传那一刻才暴露,很容易卡在发版当天。用 `CI_PIPELINE_ID` 天然满足递增,比手工维护数字可靠。 +- `pubspec.yaml` 里的 `version:` 在 CI 构建时被 `--build-name` / `--build-number` 覆盖,仓库里的值只作为本地开发的占位,不作为发版依据。 +- dev/uat 包的 `versionName` 带 `-dev`/`-uat` 后缀,测试反馈时一眼能看出装的是哪个环境的包。 + +## Android 签名与 keystore 注入 + +keystore **不入 git**(包括 dev 的)。CI 里通过变量注入: + +```yaml +# GitLab CI 变量(类型选 File,masked) +# ANDROID_KEYSTORE_BASE64 - keystore 文件的 base64 +# ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD +before_script: + - echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore + - | + cat > android/key.properties < bootstrap(AppEnv env) async { + runApp(ProviderScope( + overrides: [appEnvProvider.overrideWithValue(env)], + child: const App(), + )); +} +``` + +```dart +// lib/main_dev.dart +void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev')); +``` + +构建命令: + +```bash +fvm flutter build apk \ + --flavor dev \ + --target lib/main_dev.dart \ + --dart-define-from-file=env/dev.json +``` + +GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上): + +```yaml +.flutter_base: &flutter_base + image: ghcr.io/cirruslabs/flutter:3.44.9 # 与 .fvmrc 保持一致 + before_script: + - dart pub global activate melos + - melos bootstrap + +build_android_dev: + <<: *flutter_base + stage: build + script: + - melos run analyze + - melos run test + - flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json + artifacts: + paths: [build/app/outputs/flutter-apk/app-dev-release.apk] + rules: + - if: '$CI_COMMIT_BRANCH == "develop"' + +build_android_prod: + <<: *flutter_base + stage: build + script: + - flutter build appbundle --flavor prod --target lib/main_prod.dart + --dart-define-from-file=env/prod.json + --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG + artifacts: + paths: + - build/app/outputs/bundle/prodRelease/ + - build/symbols/ # 符号表必须归档,丢了就没法解混淆崩溃堆栈 + expire_in: 1 year + rules: + - if: '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包 + +build_ios_prod: + stage: build + tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」 + script: + - fvm flutter build ipa --flavor prod --target lib/main_prod.dart + --dart-define-from-file=env/prod.json + --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG + rules: + - if: '$CI_COMMIT_TAG' +``` diff --git a/docs/09-testing.md b/docs/09-testing.md new file mode 100644 index 0000000..b0f4fdd --- /dev/null +++ b/docs/09-testing.md @@ -0,0 +1,254 @@ +# 09. 测试策略 + +## 决策 + +采用三层测试金字塔,覆盖顺序从多到少:**单元测试**(domain 业务规则 + Riverpod `Notifier`)> **Widget 测试**(关键页面的 loading/data/error 状态)> **集成测试**(仅覆盖 1-2 条黄金路径,如登录→下单→支付)。Mock 框架统一用 **[mocktail](https://pub.dev/packages/mocktail)**(`^1.0.5`),不用 `mockito`——避免再引入一套 `build_runner` codegen 目标(项目里 `riverpod_generator`/`drift_dev`/`pigeon` 已经用了 codegen,`mocktail` 不需要生成代码,减少构建链路复杂度)。 + +## 依赖 + +```yaml +dev_dependencies: + mocktail: ^1.0.5 + test: any # 纯 Dart 单元测试 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter +``` + +## 分层测试规则 + +- **domain 层(有 domain 的 feature)**:use case 用纯 Dart 单元测试,mock 掉 `repository` 接口,覆盖多步骤业务规则的分支(如 [02-layering.md](./02-layering.md) 里 `ConfirmPaymentUseCase` 的状态校验、金额校验)。 +- **data 层**:repository 实现用单元测试,mock 掉 `ApiClient`(见 [05-networking.md](./05-networking.md)),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(`ApiResultInterceptor`/`AuthInterceptor`/`ErrorMappingInterceptor`)单独测,用 `DioAdapter` 造假响应——**401 刷新的串行逻辑必须有测试**,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)。 +- **presentation 层(Notifier)**:用 `ProviderContainer.test()` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。 +- **Widget 测试**:只覆盖有实际业务分支的页面(比如列表的 loading/data/error 三态渲染是否正确),纯展示型 widget(无状态分支)不强制要求。 +- **集成测试**:只覆盖黄金路径(1-2 条最核心的用户旅程),跑在真实/模拟设备上,验证跨 feature 的路由跳转和端到端流程;不追求覆盖所有页面组合,避免集成测试维护成本超过收益。 +- 每个 `feature_*` 包的 `test/` 目录结构镜像 `lib/src/`(如 `test/domain/`、`test/data/`、`test/presentation/`),单元测试和 Widget 测试都通过 `melos run test`(见 [01-project-structure.md](./01-project-structure.md))统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次 `analyze`/`test` 都触发)。 + +## mocktail 的 `registerFallbackValue` + +**用 `any()` 匹配自定义类型的参数时,必须先 `registerFallbackValue`**,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。 + +```dart +class FakePageQuery extends Fake implements PageQuery {} +class FakeCancelToken extends Fake implements CancelToken {} + +void main() { + setUpAll(() { + // 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效 + registerFallbackValue(FakePageQuery()); + registerFallbackValue(FakeCancelToken()); + }); + + test('...', () { + when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1)); + }); +} +``` + +`int`/`String`/`bool`/`double` 这些基础类型不需要注册。约定:`registerFallbackValue` 统一写在包的 `test/helpers/fallbacks.dart` 里,各测试文件的 `setUpAll` 调用同一个 `registerAllFallbacks()`,避免每个文件各注册一遍、漏一个就挂。 + +## 测试里必须关掉 Riverpod 的自动重试 + +Riverpod 3 的 provider 失败后会自动重试(见 [03-state-management.md](./03-state-management.md))。虽然我们在 `ProviderScope` 上全局关掉了,但**测试不走 `main.dart`,`ProviderContainer` 默认仍带着重试策略**。后果是断言 `AsyncError` 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。 + +统一在测试辅助里建 container: + +```dart +// test/helpers/container.dart +ProviderContainer makeContainer({List overrides = const []}) => + ProviderContainer.test( + retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致 + overrides: overrides, + ); +``` + +所有测试用 `makeContainer()`,不直接 `ProviderContainer.test(...)`——这样将来全局策略变了只改一处。 + +## 覆盖率门禁 + +```yaml +# 根 pubspec.yaml 的 melos: scripts: +test: + run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage +coverage: + run: | + dart pub global run coverde value -i coverage/lcov.info --min-coverage 60 +``` + +阈值定 **60%**,只卡**整体**、不卡单文件。理由: + +- 卡单文件会逼着大家给 `*.g.dart`、纯展示 widget、`toString()` 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。 +- 60% 不是终点,是**不允许倒退的地板**。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。 +- 生成产物(`**/*.g.dart`)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。 + +覆盖率报告作为 CI artifact 上传,PR 上能看到(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 + +## 集成测试在 CI 的运行环境 + +| 平台 | Runner | 说明 | +|---|---|---| +| Android | 现有 **Linux** runner + Android Emulator | 可行。用 `avdmanager` 起一个无头模拟器(`-no-window -gpu swiftshader`),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径 | +| iOS | 需要 **mac runner** | 与 [08-build-flavors.md](./08-build-flavors.md) 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试**手工在本机跑**,并在发版 checklist 里列为必做项 | + +集成测试的触发时机:**不进每次 push 的流水线**,只在合入 `develop`/`main` 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。 + +集成测试连的是 **UAT 后端**,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。 + +## JSBridge 的测试策略 + +`core_webview` 的 JSBridge(见 [10-webview-h5.md](./10-webview-h5.md))分两块测,**不要试图在 CI 里跑真实 H5 页面**: + +1. **协议编解码 → 纯 Dart 单元测试**。`{id, method, params}` 的解析、未知 `method` 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。 +2. **原生能力调用 → mock 掉 `native_*` 的公共 API 类**。验证"H5 发来 `scan` 请求 → 调了 `NativeScan.startScan` → 回包格式正确",不真的起相机。 +3. **端到端联调 → 走契约用例,不进 `melos run test`**。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。 + +## Golden 测试:`core_ui` 做,业务页面不做 + +**结论**:只对 `core_ui` 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,`feature_*` 的业务页面不写。 + +理由: + +- `core_ui` 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。 +- 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 `--update-goldens` 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。 +- golden 图片对**渲染环境敏感**(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试**只在 Linux runner 上跑**,本地开发时用 `--tags golden` 排除掉。 + +字体要显式加载,不然 golden 里全是方块: + +```dart +setUpAll(() async { + await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装 +}); +``` + + +## 参考链接 + +- [Flutter 官方测试文档](https://docs.flutter.dev/testing) +- [mocktail | Dart package](https://pub.dev/packages/mocktail) +- [mocktail: registerFallbackValue](https://pub.dev/packages/mocktail#how-it-works) +- [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests) +- [Flutter: golden 文件测试](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html) + +## 附录:分层怎么测,日常怎么写 + +给还没接触过这套测试分层习惯的同学看的入门说明。 + +### 为什么要分层测 + +不同层次的代码,"测试成本"和"能捕获的问题"是不对称的:domain 层的一条业务规则用纯 Dart 单元测试几毫秒就能跑完,覆盖所有分支;同样的规则如果只写在集成测试里验证,跑一次要几十秒甚至更久(要真的启动 App、走完整个页面流程),而且大部分时间花在跟这条业务规则无关的 UI 渲染上。**金字塔的意思是:能在下层用低成本测试覆盖的逻辑,就不要指望上层的少量集成测试兜底**——集成测试数量少,只用来确认"各层拼在一起没有断裂",不负责覆盖业务规则细节。 + +### 这不是 Flutter 独有的能力 + +原生 iOS([XCTest](https://developer.apple.com/documentation/xctest),2013 年至今)和 Android(JUnit + [Espresso](https://developer.android.com/training/testing/espresso)/[Robolectric](http://robolectric.org/))的单元测试、UI 自动化测试工具链其实比这里用的这套还要成熟。真正决定"业务逻辑好不好单独测"的是**架构**,不是工具:传统 MVC/MVP 项目里业务逻辑和 `ViewController`/`Activity` 强耦合(网络回调直接写在 `viewDidLoad`/`onCreate` 里),想测一条规则得连带整个页面生命周期一起启动测试环境,成本高、写起来别扭。domain 层纯 Dart、UI 状态与业务逻辑分离,本质是分层架构把业务逻辑从 UI 里解耦的结果——同样的分层思路(Clean Architecture + MVVM)搬到原生 iOS/Android 上,一样能达到这种测试体验。 + +### 单元测试示例:domain use case + +```dart +class MockPaymentRepository extends Mock implements PaymentRepository {} + +void main() { + late MockPaymentRepository repository; + late ConfirmPaymentUseCase useCase; + + setUp(() { + repository = MockPaymentRepository(); + useCase = ConfirmPaymentUseCase(repository); + }); + + test('订单状态非 pending 时应抛出 StateError', () async { + when(() => repository.fetchOrder('order1')).thenAnswer( + (_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.paid), + ); + + expect(() => useCase.call('order1', 'pin'), throwsA(isA())); + }); + + test('校验通过时应调用 confirmPayment', () async { + when(() => repository.fetchOrder('order1')).thenAnswer( + (_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.pending), + ); + when(() => repository.confirmPayment('order1', 'pin')).thenAnswer((_) async {}); + + await useCase.call('order1', 'pin'); + + verify(() => repository.confirmPayment('order1', 'pin')).called(1); + }); +} +``` + +### 单元测试示例:Riverpod Notifier + +```dart +void main() { + test('刷新失败时状态应变为 AsyncError', () async { + final repository = MockStoreRepository(); + when(() => repository.fetchNearbyStores(any(), any())) + .thenThrow(NetworkException('超时')); + + // makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null): + // 自动 dispose + 关掉自动重试,否则这条断言会 flaky + final container = makeContainer( + overrides: [storeRepositoryProvider.overrideWithValue(repository)], + ); + + await container.read(storeListNotifierProvider.future).catchError((_) {}); + final state = container.read(storeListNotifierProvider); + + expect(state, isA()); + }); +} +``` + +### Widget 测试示例:门店列表三态 + +```dart +void main() { + testWidgets('加载失败时应展示错误文案', (tester) async { + final repository = MockStoreRepository(); + when(() => repository.fetchNearbyStores(any(), any())) + .thenThrow(NetworkException('网络异常')); + + await tester.pumpWidget(ProviderScope( + overrides: [storeRepositoryProvider.overrideWithValue(repository)], + child: const MaterialApp(home: StoreListPage()), + )); + await tester.pumpAndSettle(); + + expect(find.textContaining('加载失败'), findsOneWidget); + }); +} +``` + +### 集成测试示例:黄金路径骨架 + +```dart +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('登录 -> 浏览门店 -> 完成支付', (tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('login_username')), 'test_user'); + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('store_item_0'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('confirm_payment'))); + await tester.pumpAndSettle(); + + expect(find.text('支付成功'), findsOneWidget); + }); +} +``` + +集成测试用真实的(或半真实的、通过测试环境后端的)依赖跑通整条链路,不 mock 掉 repository——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。 + +## 待确认项 + +- 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。 +- 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。 diff --git a/docs/10-webview-h5.md b/docs/10-webview-h5.md new file mode 100644 index 0000000..e825b2d --- /dev/null +++ b/docs/10-webview-h5.md @@ -0,0 +1,361 @@ +# 10. Embedded H5 容器与 JSBridge + +## 为什么单独一篇 + +PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。 + +**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。 + +## 决策 + +| 项 | 决策 | +|---|---| +| WebView 库 | **[webview_flutter](https://pub.dev/packages/webview_flutter) `^4.14.1`** | +| 归属包 | `core_webview`(依赖 `core_auth`、`native_scan`/`native_media`/`native_device`) | +| 桥接通道 | 单一 JavaScript Channel `ContiBridge`,统一 `{id, method, params}` 协议 | +| URL 来源 | 只接受 App Backend 换票后下发的 URL,**路由里不传裸 URL** | + +### 为什么选 webview_flutter 而不是 flutter_inappwebview + +| | webview_flutter | flutter_inappwebview | +|---|---|---| +| 维护方 | **Flutter 官方(flutter.dev)** | 社区个人维护 | +| 最新 stable | `4.14.1`,一个月前发布,持续更新 | `6.1.5`,**距今约 22 个月**,新特性都在 `6.2.0-beta` | +| 能力覆盖 | 基础能力齐全,高级能力走平台特定 controller | 更丰富(拦截请求、Cookie 精细管理、下载) | +| 我们实际需要的 | JS Channel、导航拦截、文件选择、Cookie 清理 | 同 | + +`flutter_inappwebview` 能力更全,但**它的 stable 版本已经近两年没发布**,新功能和 bugfix 都压在 beta 上。对一个要跑核心交易链路、生命周期以年计的 App 来说,这是不能接受的维护风险——真出问题时我们只能自己 fork。 + +`webview_flutter` 的能力缺口(Android 的 ``)有官方解法,用平台特定 controller 即可: + +```dart +if (controller.platform is AndroidWebViewController) { + await AndroidWebViewController.enableDebugging(env.enableLog); + (controller.platform as AndroidWebViewController) + .setOnShowFileSelector(_onShowFileSelector); // 交给 native_media 处理 +} +``` + +**如果后续发现 F6 页面用到了 `webview_flutter` 确实做不了的能力**(比如需要拦截并改写请求),再评估切换;届时因为所有 WebView 交互都收在 `core_webview` 一个包里,切换代价是可控的。这也是不让 `feature_*` 直接依赖 WebView 库的原因。 + +## H5 启动流程 + +对应 PRD §7.2: + +``` +用户点击功能入口(feature_* 或工作台菜单) + ↓ +context.push('/webview?target=QUOTE_ORDER') ← 路由里只有 target,没有 URL + ↓ +core_webview: POST /api/v1/h5/launch { target } + ↓ +App Backend: 校验登录态 / 门店上下文 / 角色权限 + → 经 F6 Integration Adapter 取票据 + ↓ +返回 { url, ticket, expiresIn, title } + ↓ +core_webview: 域名白名单校验 → WebViewController.loadRequest(url) +``` + +```dart +// packages/core_webview/lib/src/h5_launch_repository.dart +class H5LaunchInfo { + final String url; // 已由后端拼好票据和上下文参数 + final String title; + final Duration ttl; // 票据有效期,用于判断是否需要换票 +} +``` + +**启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。** 客户端拼参数意味着 `userId`/`storeId`/`roleCode` 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 `QUOTE_ORDER`"。 + +客户端唯一负责传的是 `traceId`——请求 `/h5/launch` 时带的 `X-Trace-Id`(见 [05-networking.md](./05-networking.md)),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。 + +## 域名白名单 + +```dart +// packages/core_webview/lib/src/url_guard.dart +class UrlGuard { + const UrlGuard(this._allowedHosts); + final Set _allowedHosts; // 来自 env/{flavor}.json,各环境不同 + + bool isAllowed(Uri uri) { + if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD §7.6) + final host = uri.host.toLowerCase(); + return _allowedHosts.any((allowed) => + host == allowed || host.endsWith('.$allowed')); + } +} +``` + +白名单在**三个位置**都要生效,缺一不可: + +1. **首次加载前**:后端返回的 URL 校验一次(防后端配置错误)。 +2. **导航拦截**(`NavigationDelegate.onNavigationRequest`):H5 内部跳转到非白名单域名一律 `NavigationDecision.prevent`,并记一条埋点。 +3. **JSBridge 消息处理时**:每条消息都校验当前页面的 host(见下文「来源校验」)。 + +`endsWith('.$allowed')` 而不是 `contains`:`contains` 会让 `f6.example.com.evil.com` 通过校验,这是白名单实现里最经典的一个洞。 + +非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是**弹确认框后用系统浏览器打开**,避免用户点了没反应以为坏了。 + +## JSBridge 协议 + +### 通道与消息格式 + +只开**一个** JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。 + +```dart +controller.addJavaScriptChannel( + 'ContiBridge', + onMessageReceived: (message) => _bridge.handle(message.message), +); +``` + +H5 侧调用: + +```js +// 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」) +const result = await window.ContiBridge.call('scan', { mode: 'barcode' }); +``` + +**请求**(H5 → App): + +```json +{ "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } } +``` + +**回包**(App → H5): + +```json +{ "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } } +{ "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } } +``` + +**主动事件**(App → H5,无 `id`): + +```json +{ "event": "storeChanged", "payload": { "storeId": 7 } } +``` + +约定: + +- `id` 由 **H5 侧生成**并原样回传,App 不生成——这样 H5 侧的 Promise 映射表完全由它自己管理。 +- **所有回包都是异步的**,即使是同步能力(如 `getStoreContext`)。统一异步避免 H5 侧写两套调用方式。 +- `error.code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,`message` 只用于展示。 +- 未知 `method` 返回 `{ code: "UNSUPPORTED_METHOD" }` 而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。 + +### 能力清单(PRD §7.4) + +| method | 说明 | 底层 | 备注 | +|---|---|---|---| +| `scan` | 打开扫码 | `native_scan` | `params.mode`: `barcode`/`vin`/`plate`(见 [07](./07-native-integration.md)) | +| `camera` | 打开相机拍照 | `native_media` | 返回压缩后的本地路径 | +| `pickImage` | 打开相册 | `native_media` | 支持多选,`params.maxCount` | +| `uploadFile` | 上传图片/文件 | `core_network` | 带进度事件,见下文 | +| `dial` | 调起拨号 | `native_device` | `ACTION_DIAL`/`tel:`,不直接拨出 | +| `closePage` | 关闭当前 H5 页 | `core_router` | 等价于 `context.pop()` | +| `goBack` | H5 内返回上一页 | WebView | 无历史时降级为 `closePage` | +| `refresh` | 刷新页面 | WebView | | +| `getAuthState` | 获取登录态 / 触发换票 | `core_auth` | **不返回 token 明文**,见安全约定 | +| `getStoreContext` | 获取当前门店上下文 | `core_auth` | 返回 `storeId`/`storeCode`/`orgId`/`roleCode` | +| `toast` / `dialog` / `loading` | 弹出提示 | `core_ui` | 用原生控件,保证与 App 其他页面视觉一致 | +| `navigate` | 跳转 App 原生页面 | `core_router` | `params.route` 必须是**预定义的路由白名单**,不接受任意路径 | +| `setTitle` | 设置导航栏标题 | `core_ui` | 与自动的 `title` 同步互补 | + +`navigate` 的路由白名单和 `04-routing.md` 的「后端动态菜单 → 本地路由」用同一张 `menuRouteMap`——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。 + +### 来源校验(PRD §7.6) + +**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验: + +```dart +Future handle(String raw) async { + // 1. 当前页面必须在白名单内 + final current = await _controller.currentUrl(); + if (current == null || !_urlGuard.isAllowed(Uri.parse(current))) { + _logger.w('[bridge] 拒绝来自非白名单页面的调用: $current'); + return; // 静默丢弃,不回包——不给探测者任何反馈 + } + + // 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩 + final Map req; + try { + req = jsonDecode(raw) as Map; + } catch (_) { + return _logger.w('[bridge] 无法解析的消息'); + } + + final id = req['id'] as String?; + final method = req['method'] as String?; + if (id == null || method == null) return; + + final handler = _handlers[method]; + if (handler == null) { + return _reply(id, error: const BridgeError('UNSUPPORTED_METHOD', '当前 App 版本不支持该能力')); + } + + try { + _reply(id, data: await handler(req['params'] as Map? ?? const {})); + } on AppException catch (e) { + _reply(id, error: BridgeError(e.bridgeCode, e.message)); + } catch (e, st) { + _logger.e('[bridge] $method 未预期异常', error: e, stackTrace: st); + _reply(id, error: const BridgeError('INTERNAL_ERROR', '操作失败,请重试')); + } +} +``` + +> `currentUrl()` 返回的是**主 frame** 的 URL,所以这个校验能挡住"整页被导航到恶意站点后调 bridge",但挡不住"白名单页面内的恶意 iframe"。后者的正确解法是不让 F6 页面嵌不受信的 iframe(协议层面约定),以及在导航拦截里限制 iframe 加载的域名。这个限制要在与 F6 的接口评审里明确。 + +### 其他安全约定 + +- **`getAuthState` 不返回 token 明文**(PRD §7.6:"H5 页面不得直接保存 APP 明文 Token")。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。 +- **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。 +- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD §7.6),原始信息只进日志。 + +### JS 侧胶水 + +`window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。App 在 `onPageFinished` 时注入一段封装,把它包成 Promise: + +```dart +const _bridgeShim = r''' +(function () { + if (window.__contiBridgeReady) return; + const pending = new Map(); + window.__contiBridgeCallback = function (resp) { + const p = pending.get(resp.id); + if (!p) return; + pending.delete(resp.id); + resp.ok ? p.resolve(resp.data) : p.reject(resp.error); + }; + window.__contiBridgeEvent = function (evt) { + window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload })); + }; + const raw = window.ContiBridge; + window.ContiBridge = { + call: function (method, params) { + const id = String(Date.now()) + Math.random().toString(36).slice(2); + return new Promise(function (resolve, reject) { + pending.set(id, { resolve: resolve, reject: reject }); + raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} })); + }); + }, + }; + window.__contiBridgeReady = true; +})(); +'''; +``` + +**注入时机是 `onPageFinished`,不是 `onPageStarted`**——`onPageStarted` 时 H5 的脚本可能还没执行完,重复注入或时序错乱。同时 `__contiBridgeReady` 做幂等保护,因为 SPA 内部路由变化可能触发多次回调。 + +H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 `window.__contiBridgeReady` 或监听一个 `conti:ready` 事件。**这条要写进给 F6 的接入文档**。 + +## 生命周期管理(PRD §7.5) + +| 场景 | 处理 | +|---|---| +| **标题同步** | `onPageFinished` 后读 `document.title` 写入导航栏;`setTitle` bridge 调用优先级更高 | +| **返回 vs 关闭** | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 `goBack()`,无历史则退出容器。关闭:直接退出容器,不管 H5 历史 | +| **Android 物理返回键** | 与「返回」按钮同语义。**必须拦截**,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 | +| **缓存策略** | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 `Cache-Control`)。**不做 App 侧的离线包**——首版没有这个必要,且离线包会引入版本管理复杂度 | +| **票据过期** | 见下文 | +| **白屏/超时** | 见下文 | +| **上传中断** | 见下文 | +| **门店切换 / 登出** | 见下文 | + +### 票据过期后重新换票 + +票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径: + +1. **H5 主动发现**:F6 页面收到票据失效的响应,调 `getAuthState` 请求刷新 → App 重新调 `/h5/launch` 拿新 URL → `loadRequest` 新 URL。 +2. **App 预判**:进入前台时若距离上次换票已超过 `ttl * 0.8`,主动换票并 reload。 + +**不要在票据过期时静默 reload**——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。 + +### 白屏、超时、网络失败兜底 + +WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理: + +```dart +NavigationDelegate( + onPageStarted: (_) => _startWatchdog(const Duration(seconds: 15)), + onPageFinished: (_) { _cancelWatchdog(); _injectShim(); }, + onWebResourceError: (error) { + // 只处理主文档的错误,子资源(某张图、某个 JS)失败不该整页报错 + if (!error.isForMainFrame!) return; + _showErrorState(error); + }, + onHttpError: (error) { + if (error.response?.statusCode == 404) _showErrorState(...); + }, +) +``` + +- **15 秒看门狗**:`onPageStarted` 后 15 秒还没 `onPageFinished` 就展示"加载超时,请重试"。WebView 在某些网络状况下既不成功也不报错,只有超时能兜住。 +- 错误页给「重试」和「返回」两个按钮,重试重新走完整的换票流程(票据可能已经过期了),不是简单 `reload()`。 +- 每次白屏/超时都**上报埋点**(`h5_failed`,带 `target`、错误码、耗时、`traceId`),见 [13-observability-analytics.md](./13-observability-analytics.md)。**这一类失败后端完全看不到**——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。 + +### 上传中断与重新提交 + +`uploadFile` 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定: + +- 上传期间**拦截返回和关闭**,弹确认框「上传未完成,确定要离开吗?」。 +- 上传进度通过主动事件推给 H5(`{ event: "uploadProgress", payload: { taskId, sent, total } }`),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。 +- 上传失败的回包里带 `taskId`,H5 可以用同一个 `taskId` 重试,避免重复上传已成功的部分。 +- 具体上传实现(压缩、超时、单张重传)复用 [05-networking.md](./05-networking.md) 的 `ApiClient.upload`,`core_webview` 不自己写一套。 + +### 门店切换与登出时的会话失效 + +PRD §7.5 的默认策略是硬要求: + +- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。** +- **用户退出登录后,所有 H5 会话必须同步失效。** + +```dart +// packages/core_webview/lib/src/webview_session.dart +class WebViewSession { + /// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md) + Future invalidateAll({required bool clearCookies}) async { + for (final controller in _openControllers) { + await controller.loadRequest(Uri.parse('about:blank')); // 先停掉页面,防止在途请求继续 + } + if (clearCookies) { + await WebViewCookieManager().clearCookies(); + await _controller.clearLocalStorage(); + await _controller.clearCache(); + } + _openControllers.clear(); + } +} +``` + +区别: + +- **门店切换**:关闭已打开的 H5 页并提示"门店已切换,请重新进入",**不清 Cookie**(用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。 +- **登出**:关闭所有 H5 页 + **清 Cookie / LocalStorage / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——同一台门店共用设备上这是真实会发生的。 + +清理动作**必须等待完成**再让新用户登录,不能 fire-and-forget。 + +## 与 F6 的接口对齐清单 + +以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发: + +1. `ContiBridge` 的 12 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。 +2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。 +3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。 +4. F6 静态资源的 `Cache-Control` 策略。 +5. `error.code` 枚举表(App 侧定义,F6 侧按 code 分支)。 +6. H5 内部跳转是否会离开白名单域名。 + +## 待确认项 + +- 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。 +- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](../../conti-backend/docs/05-integration-layer.md)),需要与后端一起定。 +- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。 + +## 参考链接 + +- [webview_flutter | Dart package](https://pub.dev/packages/webview_flutter) +- [webview_flutter: JavaScript Channel](https://pub.dev/packages/webview_flutter#javascript-channels) +- [AndroidWebViewController.setOnShowFileSelector](https://pub.dev/documentation/webview_flutter_android/latest/webview_flutter_android/AndroidWebViewController/setOnShowFileSelector.html) +- [OWASP MASVS:WebView 安全](https://mas.owasp.org/MASVS/) +- [PRD §7 Embedded H5 接入规范](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) diff --git a/docs/11-store-context-and-session.md b/docs/11-store-context-and-session.md new file mode 100644 index 0000000..441b760 --- /dev/null +++ b/docs/11-store-context-and-session.md @@ -0,0 +1,302 @@ +# 11. 门店上下文与会话管理 + +## 为什么单独一篇 + +门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD §11.4:「当前门店影响所有业务数据」)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。 + +更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。 + +## 会话状态模型 + +``` +AppSession +├── AuthState 登录态(token 生命周期,归 core_auth) +├── UserContext 用户上下文(PRD §6.4.1) +└── StoreContext 门店上下文(PRD §6.4.2) +``` + +```dart +// packages/core_auth/lib/src/model/app_session.dart +sealed class AppSession {} + +/// 冷启动读本地态期间,UI 停在 splash +class SessionLoading extends AppSession {} + +class SessionUnauthenticated extends AppSession { + final LogoutReason? reason; // 主动登出 / token 失效 / 被踢,用于登录页提示文案 +} + +/// 已登录但还没确定门店(多门店用户需要选,或门店列表拉取失败) +class SessionAwaitingStore extends AppSession { + final UserContext user; +} + +class SessionActive extends AppSession { + final UserContext user; + final StoreContext store; +} +``` + +**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD §11.3 要求「门店上下文缺失时引导重新选择门店」,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。 + +```dart +final class UserContext { + final String userId, employeeId, phone, roleCode, channel; + final Set permissions; // 权限集 +} + +final class StoreContext { + final int storeId; + final String storeCode, storeName; + final int orgId; + final String? parentStoreId; // 所属总店,无则为分店/独立店 + final List menus; // 当前门店可访问菜单,见 04-routing.md 的 menuRouteMap +} +``` + +`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD §6.4.2 明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。 + +## 唯一真相源 + +```dart +@riverpod +class SessionNotifier extends _$SessionNotifier { + @override + Future build() async { ... } +} + +/// 全 App 读 storeId 的唯一入口 +@riverpod +int currentStoreId(Ref ref) { + final session = ref.watch(sessionNotifierProvider).valueOrNull; + return switch (session) { + SessionActive(:final store) => store.storeId, + _ => throw StateError('在没有门店上下文时访问了 currentStoreId'), + }; +} +``` + +规则(与 [03-state-management.md](./03-state-management.md) 一致): + +- **任何请求里带 storeId 的 provider,必须 `ref.watch(currentStoreIdProvider)` 拿它**,不能 `ref.read`,也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护"哪些 provider 要手动 invalidate"的清单——那份清单一定会漏。 +- `currentStoreId` 在非 `SessionActive` 时**抛异常而不是返回 0 或 null**。能读到这个 provider 说明 UI 已经渲染到了业务页面,此时没有门店上下文是路由守卫的 bug,应该在开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。 + +## 登录流程 + +PRD §10.1:「登录成功后必须立即获取门店上下文」。 + +``` +输入手机号 + 验证码(或账号密码) + ↓ +POST /api/v1/auth/login → { accessToken, refreshToken, user } + ↓ +写入 secure storage(core_auth 独占,见 06) + ↓ +GET /api/v1/stores/accessible → 门店列表 + ↓ + ┌────┴────┬──────────────┐ + 0 个 1 个 多个 + ↓ ↓ ↓ +"无门店权限" 直接选中 上次门店仍在列表 → 选中 + 提示 + 登出 否则 → 门店选择页 + ↓ +POST /api/v1/stores/{id}/switch → StoreContext(含菜单) + ↓ + SessionActive → 跳首页 +``` + +几个容易做错的点: + +- **`stores/accessible` 失败不等于登录失败**。token 已经拿到了,此时应该进 `SessionAwaitingStore` 并展示一个可重试的页面,而不是回登录页让用户重新发一遍验证码。 +- **"上次门店"只是一个提示,不是权限依据**。它存在 `shared_preferences`(非敏感,见 [06-local-storage.md](./06-local-storage.md)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。 +- **0 个门店时必须登出**,不能停在一个空白首页。PRD §10.1/§10.2 把"用户无门店权限"列为登录异常流程。 + +## 切换门店:级联失效清单 + +这是本篇的核心。PRD §11.4:「购物车、待办、预警、订单和 H5 页面上下文必须同步切换」。 + +**顺序是有意义的**,不能随便调: + +```dart +Future switchStore(int targetStoreId) async { + // ── 0. 前置:有未完成的写操作就拦住 ────────────────── + if (ref.read(pendingWriteProvider).isNotEmpty) { + throw const PreconditionException('有未完成的操作,请稍后再试'); // 本地判定,不编后端错误码,见 12 + } + + // ── 1. 先让 UI 进入切换中,挡住用户继续操作 ────────── + state = const AsyncLoading(); + + // ── 2. 服务端切换(失败则整个流程中止,本地状态不动)── + final newStore = await _repo.switchStore(targetStoreId); + + // ── 3. 关闭 H5 会话(不清 Cookie,见 10)───────────── + await ref.read(webViewSessionProvider).invalidateAll(clearCookies: false); + + // ── 4. 清本地业务缓存(事务内,见 06)──────────────── + await ref.read(appDatabaseProvider).clearBusinessCache(); + + // ── 5. 落新的门店上下文 → 依赖 currentStoreId 的 provider 自动失效 ── + state = AsyncData(SessionActive(user: _user, store: newStore)); + + // ── 6. 路由清栈回首页(见 04)──────────────────────── + ref.read(goRouterProvider).go('/home'); + + // ── 7. 记住这次选择,供下次冷启动预选 ──────────────── + await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId); + + // ── 8. 同步观测上下文(见 13)──────────────────────── + ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}'); + ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId}); + // 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13 +} +``` + +| 步 | 为什么必须在这个位置 | +|---|---| +| 2 在 3/4 之前 | 服务端切换失败(网络断、权限被回收)时**本地必须原样不动**。反过来先清缓存再请求,一旦失败用户就停在一个"门店没变但数据全没了"的状态 | +| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 | +| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 | +| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 | +| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 | + +**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。 + +**关于购物车**:PRD 要求切店后购物车同步切换。购物车走 `clearBusinessCache()` 一起清(它是门店维度的业务数据)。如果后续产品要求"每个门店各自保留购物车",那就改成按 `storeId` 分区保留而不是清空——表结构已经带 `storeId`,改动只在这一处。 + +## 登出:清理清单 + +PRD §10.4:「清理 Token、门店上下文、本地用户信息和缓存」+「关闭所有已打开的 F6 H5 会话」。 + +```dart +Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { + // 1. 通知服务端撤销 refresh token(尽力而为,失败不阻断本地登出) + if (reason == LogoutReason.userInitiated) { + await _repo.revokeSession().timeout(const Duration(seconds: 3)).catchError((_) {}); + } + + // 2. H5 会话 + Cookie/LocalStorage/Cache 全清(见 10) + await ref.read(webViewSessionProvider).invalidateAll(clearCookies: true); + + // 3. 本地数据 + await ref.read(appDatabaseProvider).clearAllUserData(); // Drift 业务表 + await ref.read(secureStorageProvider).deleteAll(); // token + await ref.read(prefsProvider).clearUserScoped(); // 只清用户相关的 key + + // 4. 状态置为未登录 → 路由守卫自动跳登录页 + state = AsyncData(SessionUnauthenticated(reason: reason)); + + // 5. 断开观测/埋点的用户关联(门店设备是共用的,不断开会让下一个人的数据串到上一个人身上) + ref.read(analyticsProvider) + ..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反 + ..reset(); + ref.read(crashReporterProvider).setUser(''); + + // 6. 兜底:清掉所有 provider 缓存 + ref.invalidate(...); // 或在 ProviderScope 层重建,见下文 +} +``` + +要点: + +- **第 1 步失败不能阻断登出**。网络不通时用户点登出必须能退出去,否则用户体验是"这个 App 退不出来"。服务端 token 会自然过期,不撤销的代价可以接受。加 3 秒超时。 +- **`prefs.clearUserScoped()` 而不是 `prefs.clear()`**。`shared_preferences` 里还有"是否同意过协议""夜间模式偏好""是否看过新手引导"这类设备级配置,全清会导致下一个用户看一遍新手引导。约定:用户相关的 key 统一加 `u_` 前缀,`clearUserScoped()` 按前缀删。 +- **必须等第 2/3 步完成再切状态**。fire-and-forget 会出现"新用户已经登录进首页了,上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上这不是理论问题。 +- **`clearCookies: true` 在登出时是硬要求**。不清的话下一个人打开 H5 会直接落进上一个人的 F6 会话——这是本项目最有可能出现的一个真实安全事故。 + +### 登出兜底:为什么还要一步 provider 清理 + +`ref.invalidate` 一个个点名会漏。更稳的做法是让整个业务 provider 树挂在一个 key 上重建: + +```dart +// main.dart +ProviderScope( + retry: (_, __) => null, + child: Consumer(builder: (context, ref, _) { + final sessionKey = ref.watch(sessionKeyProvider); // 每次登录/登出自增 + return KeyedSubtree(key: ValueKey(sessionKey), child: const ContiApp()); + }), +) +``` + +**注意这只重建 widget 树,不重建 provider(provider 挂在 `ProviderScope` 上,在 `KeyedSubtree` 外面)。** 真正让业务 provider 全部失效的是"它们都直接或间接 `ref.watch(currentStoreIdProvider)` / `sessionNotifierProvider`"这条规则 —— 状态一变,`autoDispose` 的 provider 自然重算,`keepAlive` 的少数几个(见 03 的三类白名单)**必须在登出时显式 invalidate**,清单就是那三类,是有限且可维护的。 + +## 与 refresh token 轮换的配合 + +后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系: + +1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为重放,**撤销该用户所有设备的会话**——用户会在自己毫无操作的情况下被全端踢下线。 +2. **刷新失败立即登出,不重试**。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。 + +```dart +// TokenRefresher 刷新失败 → 通知会话层 +void _onRefreshFailed() { + ref.read(sessionNotifierProvider.notifier).logout(reason: LogoutReason.tokenExpired); +} +``` + +`LogoutReason.tokenExpired` 让登录页能显示「登录已过期,请重新登录」而不是一个没有解释的空登录页。用户在别处被踢(`sessionRevoked`)时提示文案也不同。 + +## 冷启动恢复 + +``` +App 启动 → SessionLoading(splash) + ↓ +读 secure storage 的 token + ↓ + 没有 → SessionUnauthenticated + 有 → GET /api/v1/auth/me + /stores/accessible + ↓ + ┌───┴────────────────┬─────────────────┐ + 成功 401 网络失败 + ↓ ↓ ↓ +预选 last_store_id → 登出 进首页 + 用本地缓存渲染 + → SessionActive (见 12 的降级约定) +``` + +- **secure storage 读失败要当作未登录处理**,不能让异常冒到启动流程里(见 [06-local-storage.md](./06-local-storage.md) 关于 `flutter_secure_storage 11.0.0` 的说明)。启动崩溃是最难排查也最致命的一类问题。 +- **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token 就先按已登录处理,用缓存渲染首页,顶部提示"数据可能不是最新"。真正无效的 token 会在第一个业务请求返回 401 时被发现,那时再登出。 +- splash 有**最长等待时间**(3 秒)。超时就按"网络失败"分支走,不能无限转圈。 + +## 回到前台时的一致性校验 + +App 从后台回来时,服务端的门店权限可能已经变了(管理员回收了权限、门店被停用)。 + +```dart +// 冷时间超过 5 分钟才校验,避免频繁切前后台打接口 +if (elapsedSinceBackground > const Duration(minutes: 5)) { + final stores = await _repo.fetchAccessibleStores(); + if (!stores.any((s) => s.storeId == currentStoreId)) { + // 当前门店已不可访问 + await switchStore(stores.first.storeId); // 或引导重选 + showToast('您对当前门店的权限已变更,已切换到 ${stores.first.storeName}'); + } +} +``` + +不做这个校验的后果是:用户带着一个已失效的 storeId 继续操作,每个请求都被后端拒绝,界面上表现为"什么都点不动但也不说为什么"。 + +## 埋点 + +会话相关事件大部分**由后端从自己的接口日志出**(登录、切店都是接口调用),客户端不重复报(见 [13-observability-analytics.md](./13-observability-analytics.md) 的分工原则)。客户端只补后端看不到的两件事: + +| 事件 | 谁报 | 关键字段 | +|---|---|---| +| 登录成功/失败、门店切换 | **后端** | 接口日志即可,客户端不重复上报 | +| `logout` | **客户端** | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出往往没有对应的接口调用**——token 刷新失败是客户端本地判定的,后端只看到一个失败的刷新请求,看不到"用户因此被踢了出去" | +| `session_restore_failed` | **客户端** | 失败阶段(读 storage / me / stores)。冷启动恢复失败在读 secure storage 这一步时**完全不产生网络请求**,后端无从知晓 | + +`logout` 的 `reason` 分布是最有价值的一个指标——如果 `tokenExpired` 占比异常高,说明刷新逻辑有问题(很可能就是并发刷新触发了后端的重放撤销)。这个指标只能由客户端提供。 + +## 待确认项 + +- `/api/v1/stores/accessible` 与 `/api/v1/stores/{id}/switch` 的接口契约,以及切换是否需要服务端记录(影响多端一致性)。 +- 切店时"未完成写操作"的判定粒度:是全局阻止,还是只阻止发起写操作的那个 feature。 +- 购物车是否需要按门店分别保留(当前决策:清空)。 +- 前台一致性校验的触发阈值(当前定 5 分钟)需要跑一个迭代后按实际接口压力调整。 + +## 参考链接 + +- [PRD §6.4 上下文定义 / §10.4 退出登录 / §11.4 门店切换](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) +- [backend/04-security-auth.md:refresh token 轮换](../../conti-backend/docs/04-security-auth.md) +- [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests) diff --git a/docs/12-error-and-api-contract.md b/docs/12-error-and-api-contract.md new file mode 100644 index 0000000..b5fba52 --- /dev/null +++ b/docs/12-error-and-api-contract.md @@ -0,0 +1,370 @@ +# 12. 错误处理与 API 契约 + +## 为什么单独一篇 + +[05-networking.md](./05-networking.md) 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。 + +这一篇负责三件事:**客户端侧的 `ApiResult` 契约**、**`AppException` 体系全貌**、**错误到 UI 的映射规则(含降级)**。 + +## 一、`ApiResult` 客户端契约 + +后端所有接口统一返回(见 [backend/06-api-design.md](../../conti-backend/docs/06-api-design.md)): + +```json +{ "code": 0, "message": "success", "data": { ... }, "traceId": "a1b2c3..." } +``` + +**`code` 是数字,`0` 表示成功。** 客户端契约(在 `core_network` 的 `ApiResultInterceptor` 里实现,见 05): + +| 情况 | 客户端行为 | +|---|---| +| HTTP 2xx + `code == 0` | 解包,业务层只拿到 `data` | +| HTTP 2xx + `code != 0` | 抛 `BusinessException(code, message, traceId)` | +| HTTP 4xx/5xx + body 是 `ApiResult` | 同上,按 `code` 抛 `BusinessException` | +| HTTP 4xx/5xx + body 不是 `ApiResult`(网关、CDN、Nginx 返回的 HTML) | 抛 `ServerException(statusCode, traceId: null)` | +| 连接失败 / 超时 | 抛 `NetworkException` | + +**第四行是最容易漏的。** 请求不一定能到达后端——网关 502、Nginx 413(上传超限)、运营商劫持返回的 HTML 页面,都不会带 `ApiResult` 结构。直接 `jsonDecode` 会抛 `FormatException`,业务层完全接不住。所以解包前必须判断 body 是不是 `Map` 且含 `code` 字段。 + +### 数字错误码的代价,以及怎么消化它 + +数字码在日志和监控里聚合方便(可以直接 `group by code` 出趋势),但**它不自解释**:日志里一条 `code=10403` 不看码表完全不知道是什么。所以配套要求: + +1. **必须有一份双方共享、和代码一起维护的码表**,不能只存在于某个人的 Excel 里。 +2. **客户端不允许出现字面量数字**。所有用到的码定义成命名常量,`if (e.code == ApiCode.forbidden)` 而不是 `if (e.code == 10403)`。 +3. **日志里 code 和 message 一起打**,因为 `message` 是唯一能让人在不查码表时看懂的东西。 + +### 分段方案(建议,待后端确认) + +`backend/06-api-design.md` 的待补充项里「按 domain 分段还是全局统一编码」还没定。建议 **5 位数字,前 2 位是域段**: + +| 段 | 域 | 例 | +|---|---|---| +| `0` | 成功 | `0` | +| `10xxx` | 平台通用 | `10001` 参数错误、`10401` 未登录、`10403` 无权限、`10500` 系统错误 | +| `11xxx` | 认证与门店 | `11001` 门店不可访问、`11002` 无门店权限 | +| `20xxx` | 采购 | | +| `21xxx` | 库存 | | +| `3xxxx` | F6 / Mini 透传类错误 | 后端做过转换,不透传供应商原始码 | + +分段的价值是**看到码的前两位就知道该找谁**。全局连续编号(1、2、3…)在多域并行开发时必然撞号。 + +### `data` 为 `null` 的语义 + +`code == 0` 但 `data == null` 是合法的(后端 `ApiResult.ok(Unit)`)。约定: + +```dart +Future → data 可以为 null,忽略 +Future → data 为 null 时抛 ServerException('响应缺少 data'),不返回 null +Future → 显式声明可空时才允许 null +``` + +不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 `Null check operator used on a null value`,排查时完全看不出是接口问题。 + +### 完整码表还没定 + +分段方案(上表)只是骨架,**具体的码表还没和后端对齐**。在它定下来之前: + +- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。这条策略让客户端在码表缺席时也能正常工作。 +- **客户端只对一小组"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组必须尽可能小: + +```dart +// packages/core_network/lib/src/error/api_code.dart +abstract final class ApiCode { + static const ok = 0; + + static const invalidParam = 10001; // → 表单内联报错,不弹 Toast + static const unauthorized = 10401; // → 触发刷新 / 登出 + static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文 + static const internalError = 10500; // → 展示 traceId + + static const storeNotAccessible = 11001; // → 引导重选门店 +} +``` + +**这份清单要和后端一起确认**,是本文档最重要的待确认项。清单之外的 code 一律走默认展示。 + +## 二、`AppException` 体系 + +```dart +// packages/core_network/lib/src/error/app_exception.dart +sealed class AppException implements Exception { + const AppException(this.message, {this.traceId}); + final String message; + final String? traceId; +} + +/// 网络不通、超时、DNS 失败——用户重试可能就好了 +final class NetworkException extends AppException { + const NetworkException(super.message, {this.kind}); + final NetworkErrorKind? kind; // connectTimeout / receiveTimeout / noConnection +} + +/// 后端返回了 code != 0,message 可直接展示 +final class BusinessException extends AppException { + const BusinessException(this.code, super.message, {super.traceId}); + final int code; +} + +/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好 +final class ServerException extends AppException { + const ServerException(super.message, {this.statusCode, super.traceId}); + final int? statusCode; +} + +/// token 失效且刷新失败,已触发登出 +final class UnauthorizedException extends AppException {} + +/// 请求被 CancelToken 取消(页面销毁、用户主动退出) +final class RequestCancelledException extends AppException {} + +/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),message 可直接展示 +/// 不复用 BusinessException:后者的 code 来自后端错误码表,纯本地的判定没有、也不该编一个 code +final class PreconditionException extends AppException { + const PreconditionException(super.message); +} + +/// 本地存储 / 数据库错误 +final class StorageException extends AppException {} + +/// 原生能力错误(权限拒绝、设备不支持),见 07 +final class NativeException extends AppException { + const NativeException(this.code, super.message); + final String code; // PERMISSION_DENIED / UNAVAILABLE / CANCELLED +} +``` + +`sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型,所有映射点编译报错,逼着人去处理,而不是悄悄落进 `default` 分支变成"未知错误"。 + +**`RequestCancelledException` 必须被 UI 静默处理**(见 05)。用户返回上一页时在途请求被取消,弹一个"请求已取消"的 Toast 是纯粹的噪音。 + +## 三、错误 → UI 映射 + +### 三种展示形态,按"用户当时在干什么"选 + +| 形态 | 适用 | 例子 | +|---|---|---| +| **整页错误态** | 用户在等这个页面的主数据,没数据页面就是空的 | 订单列表加载失败 | +| **局部错误态** | 页面有多块数据,一块失败不影响其他 | 首页某个 tile 失败 | +| **Toast / SnackBar** | 用户主动触发了一个动作,失败了要立刻知道 | 提交订单失败、下拉刷新失败 | +| **表单内联** | 参数校验类错误,要指到具体字段 | `ApiCode.invalidParam` | + +**不要用 Dialog 报错**,除非错误需要用户做决定("登录已过期,是否重新登录")。Dialog 阻断操作,而大部分错误用户能做的只有"知道了"。 + +### 统一的错误文案映射 + +```dart +// packages/core_ui/lib/src/error/error_presenter.dart +({String title, String? detail, bool retryable, bool showTraceId}) present(AppException e) => + switch (e) { + NetworkException(kind: NetworkErrorKind.noConnection) => + (title: '网络未连接', detail: '请检查网络后重试', retryable: true, showTraceId: false), + NetworkException() => + (title: '网络不太稳定', detail: '请稍后重试', retryable: true, showTraceId: false), + ServerException() => + (title: '系统繁忙', detail: '请稍后重试', retryable: true, showTraceId: true), + BusinessException(:final message) => + (title: message, detail: null, retryable: false, showTraceId: false), + StorageException() => + (title: '本地数据异常', detail: '请重启 App', retryable: false, showTraceId: false), + NativeException(code: 'PERMISSION_DENIED', :final message) => + (title: message, detail: '可在系统设置中开启', retryable: false, showTraceId: false), + NativeException(:final message) => + (title: message, detail: null, retryable: false, showTraceId: false), + UnauthorizedException() || RequestCancelledException() => + (title: '', detail: null, retryable: false, showTraceId: false), // 不展示 + }; +``` + +要点: + +- **`BusinessException` 的 `retryable` 是 `false`**。业务错误(比如"库存不足""订单已支付")重试没有意义,给一个重试按钮只会让用户反复点。 +- **`NetworkException` 不展示 traceId**。请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。 +- `ServerException` 展示 traceId——这正是 `traceId` 存在的意义(见 backend/06 附录)。 + +### traceId 怎么展示 + +**`traceId` 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。** 后端侧它本来就有(`TraceIdFilter` 写 MDC + 落 ELK,见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以: + +- **绝大多数错误不展示它**,只有 `ServerException`(5xx / 系统错误)才展示——那正是需要研发介入的场景。 +- 无条件写进本地日志和错误上报(见 [13-observability-analytics.md](./13-observability-analytics.md)),这部分不依赖 UI。 + +不要把 `traceId` 直接印在主文案里(用户看到一串乱码只会更慌)。约定: + +``` + 系统繁忙 + 请稍后重试 + + [ 重试 ] 问题反馈 › +``` + +「问题反馈」展开后显示 `traceId` 并提供**一键复制**。客服话术是"请点击问题反馈,把那串编号发给我"。 + +同时 traceId **无条件写进本地日志**(不管展不展示),见 [13-observability-analytics.md](./13-observability-analytics.md)。 + +### 通用错误 Widget + +`core_ui` 提供,所有 feature 复用,不各写一套: + +```dart +// 整页 +AsyncValueView( + value: ref.watch(orderListProvider), + onRetry: () => ref.invalidate(orderListProvider), + data: (orders) => OrderList(orders), +) + +// 局部(tile 级降级) +TileErrorView(error: e, onRetry: ...) // 尺寸自适应,不撑破布局 +``` + +`AsyncValueView` 内部统一处理:loading 骨架屏、error → `present()` → 错误态、`RequestCancelledException` 静默、空数据 → 空态图。**每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理不一致的根源。** + +## 四、降级:局部失败不能拖垮整页 + +PRD §21.1「首页支持部分失败降级」、§21.2「Mini 某一服务失败应仅影响对应模块」「F6 异常不得导致主 APP 全部不可用」。 + +### 首页的降级模型 + +首页由多块数据组成(门店信息、菜单、待办、预警、公告、促销位),它们来自**不同的后端聚合**,失败是独立的。 + +**做法:每块数据一个独立 provider,页面不做 `Future.wait`。** + +```dart +// ❌ 错的:任何一块失败,整个首页变成错误态 +@riverpod +Future homeData(Ref ref) async { + final (menus, todos, alerts) = await ( + ref.watch(menuProvider.future), + ref.watch(todoProvider.future), + ref.watch(alertProvider.future), + ).wait; + return HomeData(menus, todos, alerts); +} + +// ✅ 对的:各自独立,各自渲染,各自重试 +class HomePage extends ConsumerWidget { + Widget build(context, ref) => ListView(children: [ + const StoreHeader(), + MenuSection(), // 内部 watch(menuProvider) + TodoSection(), // 内部 watch(todoProvider) + AlertSection(), + ]); +} +``` + +`Future.wait` 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD §21.1。 + +**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。 + +### 降级的粒度约定 + +| 数据 | 失败时 | +|---|---| +| 门店上下文、菜单 | **整页错误态 + 重试**(没有它首页无意义) | +| 待办、预警、公告、促销位 | **该区块显示局部错误态**,其余正常 | +| 首页各 tile 的数字/角标 | **降级为不显示角标**,不显示错误 UI——一个角标加载失败不值得占用用户注意力 | +| H5 页面 | 容器内错误页,不影响 App 其他部分(见 [10-webview-h5.md](./10-webview-h5.md)) | + +### 有缓存时优先展示缓存 + +网络失败但本地有缓存(见 [06-local-storage.md](./06-local-storage.md))时,**展示缓存 + 顶部提示条**,比展示一个错误页好得多——门店里网络不稳是常态。 + +```dart +// 顶部一条细提示条,不遮挡内容 +if (state.isFromCache) StaleDataBanner(updatedAt: state.cachedAt, onRefresh: ...) +``` + +前提是缓存**必须带时间戳并显示**("更新于 10 分钟前")。展示旧数据却不告诉用户是旧的,比展示错误更危险——尤其是库存和价格。 + +## 五、兜底:没被 catch 的异常 + +```dart +// main.dart +void main() { + runZonedGuarded(() { + WidgetsFlutterBinding.ensureInitialized(); + + // widget 构建/布局/绘制期的错误 + FlutterError.onError = (details) { + FlutterError.presentError(details); // 保留控制台输出 + reporter.recordFlutterError(details); + }; + + // 平台层/异步的未捕获错误(Flutter 3.3+) + PlatformDispatcher.instance.onError = (error, stack) { + reporter.recordError(error, stack, fatal: true); + return true; + }; + + runApp(ProviderScope( + retry: (_, __) => null, // 全局关掉自动重试,见 03 + observers: [ErrorObserver()], + child: const ContiApp(), + )); + }, (error, stack) => reporter.recordError(error, stack, fatal: true)); +} +``` + +另外在 Riverpod 侧加一个全局观察者,把所有 provider 抛出的错误上报(即使 UI 已经优雅处理了): + +```dart +class ErrorObserver extends ProviderObserver { + @override + void providerDidFail(context, error, stackTrace) { + if (error is RequestCancelledException) return; // 取消不是错误 + reporter.recordError(error, stackTrace, fatal: false, context: {'provider': ...}); + } +} +``` + +**"UI 优雅处理了"和"不需要上报"是两回事。** 用户看到一个漂亮的错误页,我们仍然需要知道有多少人看到了它。上报细节见 [13-observability-analytics.md](./13-observability-analytics.md)。 + +### release 模式的错误页 + +```dart +ErrorWidget.builder = (details) => const AppCrashView(); // 不显示红屏 +``` + +默认的红色错误屏在 release 下也会出现(虽然是灰色的)。换成一个统一的"页面出错了,请返回重试"视图。 + +## 六、错误处理的反模式 + +这几条在 review 时直接打回: + +```dart +// ❌ 吞掉异常 +try { await repo.submit(); } catch (_) {} + +// ❌ 用 catch-all 把所有错误变成同一句话,丢掉了 BusinessException 的 message +try { ... } catch (e) { showToast('操作失败'); } + +// ❌ 在 repository / use case 里弹 UI +class OrderRepository { + Future submit() async { + try { ... } catch (e) { showToast(...); } // data 层不能碰 UI,见 02 + } +} + +// ❌ 用 message 内容做判断 +if (e.message.contains('库存')) { ... } // 后端改一个字就失效 + +// ❌ 写裸数字错误码 +if (e.code == 10403) { ... } // 用 ApiCode.forbidden +``` + +正确做法:异常一路向上抛到 `Notifier`,由 `AsyncValue` 承载,UI 层统一映射。需要分支时用 `ApiCode` 常量,不用 `message`、不用字面量数字。 + +## 待确认项 + +- **错误码表(最高优先级)**:需要和后端一起把上面的分段方案落成完整码表,特别是 `ApiCode` 里那组需要特殊 UX 的码。这一项不定,客户端只能全部走默认文案。同时 `backend/06-api-design.md` 的「待补充」里也挂着这一条。 +- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。 +- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。 + +## 参考链接 + +- [backend/06-api-design.md:`ApiResult` 与全局异常处理](../../conti-backend/docs/06-api-design.md) +- [backend/08-observability.md:traceId 全链路](../../conti-backend/docs/08-observability.md) +- [Flutter: Handling errors](https://docs.flutter.dev/testing/errors) +- [Riverpod: ProviderObserver](https://pub.dev/documentation/riverpod/latest/riverpod/ProviderObserver-class.html) +- [Dart 3 patterns: switch expressions](https://dart.dev/language/patterns) diff --git a/docs/13-observability-analytics.md b/docs/13-observability-analytics.md new file mode 100644 index 0000000..497b1b7 --- /dev/null +++ b/docs/13-observability-analytics.md @@ -0,0 +1,442 @@ +# 13. 可观测性与埋点 + +## 为什么单独一篇 + +PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。 + +这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。 + +## 决策 + +| 项 | 决策 | +|---|---| +| 崩溃上报 | **[sentry_flutter](https://pub.dev/packages/sentry_flutter) `^9.26.0`**(官方 verified publisher),配套 [sentry_dart_plugin](https://pub.dev/packages/sentry_dart_plugin) `^3.4.0` 上传符号表 | +| Sentry 部署形态 | **自建优先**(`sentry.io` SaaS 是跨境上报),**待确认** | +| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 | +| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过,本项目待正式确认) | +| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 | +| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃上报的自定义字段 | + +## 一、崩溃上报:Sentry + +### 为什么不是 Bugly + +Bugly 是团队过往项目用过的方案,国内可达性没问题,本来是很自然的默认选项。**否掉它的理由只有一条,但这一条是决定性的:Bugly 没有上传 Dart 符号表的能力。** + +- Flutter App 的**绝大多数异常是 Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败),不是原生崩溃。Bugly 只能把它们当"自定义异常"收下,存成一段字符串堆栈。 +- [08-build-flavors.md](./08-build-flavors.md) 要求 release 必须 `--obfuscate --split-debug-info`。两者相加的结果是:**线上占比最大的那一半崩溃,在 Bugly 后台是一串读不出来的混淆符号**,只能人工把堆栈拷出来跑 `flutter symbolize` 还原。 + +Bugly 在原生侧(Java/Kotlin 异常、SIGSEGV、ANR、iOS crash)确实做得好,能自动符号化。但它强的正好是我们占比小的那一半。 + +其余差别一并记录在此,作为决策存档: + +| | 腾讯 Bugly | Sentry | +|---|---|---| +| **Dart 异常堆栈还原** | **做不到** | **做得到**(`sentry_dart_plugin` 自动上传) | +| Flutter 官方 SDK | **没有**,只有 Android/iOS 原生 SDK,pub.dev 上只有 `flutter_bugly` 1.1.1、`bugly_pro_flutter` 0.4.21 两个 unverified 社区插件 | **有**,官方维护、13 天前刚发版 | +| 原生崩溃 | 强项,自动符号化 | 支持,mapping/dSYM 由同一个插件上传 | +| 接入成本 | 要自己写 `native_crash`(Pigeon + 几十行 Kotlin/Swift) | `pubspec.yaml` 加两行 | +| 国内可达性 | 无问题 | **自建无问题;SaaS 是跨境上报**,见下 | +| 运维成本 | 无 | 自建的话有(存储、升级、告警) | +| 账号/合同 | 本项目**没有**现成的,要新申请 | 同样要新建 | + +代价是运维:Sentry 这条路把"接入成本"换成了"部署成本"。这是这次选型唯一真正付出的东西。 + +注意最后一行:**本项目在两边都没有既有账号或合同**,所以"沿用现成的"这个通常最有分量的理由,在这次选型里不成立——两条路的启动成本都要从零算。 + +**连带影响:不再需要 `native_crash` 这个包。** [07-native-integration.md](./07-native-integration.md) 里的 Pigeon 包只剩 `native_scan` / `native_media`。 + +### 唯一还没定的:自建还是 SaaS + +这一条**必须在开工前定**,它决定的不只是可达性,还有合规: + +- **自建(推荐)**:崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。 +- **`sentry.io` SaaS**:零运维,但崩溃报告里带着 `userId`、`storeId`、面包屑和日志片段,属于**数据出境**,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。 + +需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。**在结论出来之前,`SENTRY_DSN` 走 `--dart-define-from-file`(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。** + +### 依赖与初始化 + +```yaml +dependencies: + sentry_flutter: ^9.26.0 + +dev_dependencies: + sentry_dart_plugin: ^3.4.0 +``` + +```dart +// main.dart —— 崩溃上报必须在最早期初始化,晚一步就漏掉启动期崩溃 +await SentryFlutter.init( + (options) { + options.dsn = env.sentryDsn; // 来自 --dart-define-from-file,见 08 + options.environment = env.flavorName; // dev / uat / prod 分开看,否则测试数据污染线上崩溃率 + options.release = '${env.appVersion}+${env.buildNumber}'; // 必须和符号表归档对得上 + options.tracesSampleRate = 0.0; // 首版不开性能追踪,见下 + options.sendDefaultPii = false; // 关键:默认不采集 IP / 请求头 / 用户信息 + options.beforeBreadcrumb = scrubBreadcrumb; // 见「脱敏」 + options.beforeSend = scrubEvent; + }, + appRunner: () => runApp(ProviderScope( + retry: (_, __) => null, + observers: [ErrorObserver()], // 见 12 + child: const ContiApp(), + )), +); +``` + +**`appRunner` 不是可选写法。** 传了它,Sentry 会自己接管 `FlutterError.onError` 和 `PlatformDispatcher.instance.onError` 并把 `runApp` 放进受保护的 error zone;**这时候再手写一遍这两个回调,结果是同一个异常上报两次**,线上崩溃数直接翻倍,是这个 SDK 最常见的接入错误。 + +业务代码仍然只依赖 `core_logging` 暴露的 `CrashReporter` 接口,不直接 import `sentry_flutter`: + +```dart +// packages/core_logging/lib/src/crash_reporter.dart +abstract interface class CrashReporter { + void setUser(String userId); + void setTag(String key, String value); + void leaveBreadcrumb(String message); + void report(Object error, StackTrace? stack, {Map extra = const {}}); +} +``` + +这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。 + +### 符号表:唯一必须打通的一步 + +`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去: + +```yaml +# pubspec.yaml +sentry: + upload_debug_symbols: true + upload_source_maps: false # 不做 Web + project: conti-retail-app + org: continental + # auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库 +``` + +```bash +# CI:build 之后立刻跑,见 08 +fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \ + --dart-define-from-file=env/prod.json \ + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG +fvm dart run sentry_dart_plugin +``` + +三条硬约束: + +- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。 +- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。 +- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。 + +### 必须关掉的默认行为 + +Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开: + +| 项 | 结论 | +|---|---| +| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 | +| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 | +| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 | +| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 | +| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 | + +### 用户与门店上下文 + +会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)): + +```dart +CrashReporter.instance + ..setUser(session.user.userId) // 只传 ID,不传手机号/姓名 + ..setTag('storeId', '${session.store.storeId}') + ..setTag('roleCode', session.user.roleCode) + ..setTag('flavor', env.flavorName); +``` + +**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 + +`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。 + +### 崩溃前的页面路径 + +崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报: + +```dart +class NavigationObserver extends NavigatorObserver { + NavigationObserver(this._reporter); + final CrashReporter _reporter; + + @override + void didPush(Route route, Route? previous) => + _reporter.leaveBreadcrumb('nav: ${route.settings.name}'); +} +``` + +注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。 + +### 上报什么、不上报什么 + +- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。 +- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。 + +### 验证接入真的成功了 + +崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以: + +- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。 +- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。 +- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。 + +## 二、日志规范 + +### `AppLogger` + +```dart +// packages/core_logging/lib/src/app_logger.dart +abstract interface class AppLogger { + void d(String message, {Map? data}); + void i(String message, {Map? data}); + void w(String message, {Object? error, StackTrace? stackTrace}); + void e(String message, {Object? error, StackTrace? stackTrace}); +} +``` + +各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 + +### 级别与环境 + +| 环境 | 级别 | 输出 | +|---|---|---| +| dev | `debug` | 控制台,带颜色和调用栈 | +| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | +| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | + +**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 + +**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。 + +### 脱敏(PRD §21.3「敏感字段脱敏」) + +```dart +// packages/core_logging/lib/src/scrubber.dart +const _sensitiveKeys = { + 'token', 'accessToken', 'refreshToken', 'ticket', 'password', + 'code', // 短信验证码 + 'phone', 'mobile', 'idCard', 'bankCard', +}; + +String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; +``` + +规则: + +- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。 +- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。 +- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。 +- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。 + +**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现: + +```dart +// packages/core_logging/lib/src/sentry_scrubber.dart +Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) { + if (crumb == null) return null; + // SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token + final url = crumb.data?['url']; + if (url is String) { + final u = Uri.tryParse(url); + crumb.data?['url'] = u == null ? '' : u.replace(query: '').toString(); + } + return crumb; +} +``` + +**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。 + +### 不采集什么 + +出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3: + +| 项 | 结论 | +|---|---| +| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 | +| 精确位置 | 不采集。App 没有需要精确位置的功能 | +| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` | +| 通讯录、短信 | 不申请权限 | +| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) | + +这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。 + +## 三、埋点:以后端为主 + +**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。** + +理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。 + +**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。 + +### Metabase 不是神策的替代品 + +后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层: + +| | 神策 | Metabase | +|---|---|---| +| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 | +| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 | +| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 | + +Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。 + +所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。 + +### 分工 + +| PRD §22.1 事件 | 谁来出 | 说明 | +|---|---|---| +| 登录成功/失败 | **后端** | 登录本身就是接口调用 | +| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 | +| 门店切换 | **后端** | 切换接口 | +| 采购下单 | **后端** | | +| 入库成功 | **后端** | | +| 待办点击 | **后端** | 点击后会请求详情接口 | +| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 | +| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** | +| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 | + +**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。** + +### 客户端事件表 + +按上面的判据筛下来,客户端只需要这几个: + +| 事件 | 触发 | 关键参数 | +|---|---|---| +| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` | +| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` | +| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` | +| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) | +| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) | +| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` | +| `app_cold_start` | 冷启动完成 | `durationMs` | +| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) | +| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 | + +两条说明: + +- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。 +- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。 + +### 实现约定:神策 SDK,外面包一层 + +客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。 + +#### 神策不是"开箱即用",这些活一样要干 + +先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**: + +- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。 +- **`core_analytics` 的接口封装**(见下)。 +- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 +- **私有化部署的运维**(如果走私有化)。 + +**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。 + +#### 依赖与封装 + +```yaml +dependencies: + sensors_analytics_flutter_plugin: ^4.2.3 +``` + +业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策: + +```dart +// packages/core_analytics/lib/src/analytics.dart +abstract interface class Analytics { + void track(String event, [Map params = const {}]); + void registerSuperProperties(Map props); // 公共属性,注册一次全局附加 + void identify(String userId); // 登录成功后调 + void reset(); // 登出时调 +} +``` + +理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补)。 + +接入约定: + +- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 +- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。 +- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。 +- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。 + +#### 全埋点(AutoTrack):只开启动/退出,其余关掉 + +神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论: + +| 类型 | 结论 | +|---|---| +| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 | +| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 | +| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) | + +“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。 + +#### H5 内部的埋点不归我们 + +F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 12 项)里也没有埋点这一项。 + +### 命名约定 + +`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。 + +事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。 + +## 四、性能指标 + +| 指标 | 怎么测 | 目标 | +|---|---|---| +| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s | +| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s | +| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) | +| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s | +| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — | + +**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。 + +## 五、和后端审计日志的分工 + +`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。 + +| | 客户端埋点 | 服务端日志/审计 | +|---|---|---| +| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 | +| 可信度 | 参考 | 权威 | +| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 | + +## 待确认项 + +- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。 +- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。 +- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。 +- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。 +- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。 +- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。 +- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。 +- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。 +- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。 +- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。 + +## 参考链接 + +- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter) +- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM) +- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/) +- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/) +- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin) +- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300) +- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205) +- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成) +- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate) +- [logger | Dart package](https://pub.dev/packages/logger) +- [backend/08-observability.md:traceId 与审计日志](../../conti-backend/docs/08-observability.md) +- [PRD §21.4 可观测性 / §22.1 埋点](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) diff --git a/docs/14-conventions-and-ci-gates.md b/docs/14-conventions-and-ci-gates.md new file mode 100644 index 0000000..c1fe715 --- /dev/null +++ b/docs/14-conventions-and-ci-gates.md @@ -0,0 +1,289 @@ +# 14. 工程规范与 CI 门禁 + +## 为什么单独一篇 + +这一篇是**建项目当天就要用上**的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。 + +## 一、SDK 版本锁定 + +``` +# .fvmrc(仓库根目录,入库) +{ "flutter": "3.44.9" } +``` + +所有人用 [FVM](https://fvm.app/) 装同一个版本,命令统一走 `fvm flutter ...`。理由见 [01-project-structure.md](./01-project-structure.md):monorepo 里 SDK 版本不一致会导致 `.dart_tool` 反复重建、生成代码差异、以及"我这跑得好好的"这类无法复现的问题。CI 也用 FVM 装同一版本,保证本地和 CI 完全一致。 + +`flutter --version` 的实测 Dart 版本要写进 README,因为 `environment.sdk` 的约束以它为准。 + +## 二、静态分析 + +### 选 `flutter_lints`,不选 `very_good_analysis` + +| | `flutter_lints` 6.0.0 | `very_good_analysis` 10.3.0 | +|---|---|---| +| 维护方 | **Flutter 官方** | Very Good Ventures | +| 规则数量 | 适中,只收官方认为普遍适用的 | 非常多,包含大量风格约束 | +| 跟随 SDK | 随 Flutter 版本同步更新 | 独立节奏 | + +`very_good_analysis` 更严格,但它在一个新项目上开箱会产生**成百上千条 warning**,其中很大一部分是纯风格问题(比如强制所有 public API 写文档注释、强制 `final` 局部变量)。团队的第一反应必然是批量 `// ignore:` 或者在 `analysis_options.yaml` 里关掉一半规则——最后既没享受到严格的好处,还多了一层配置负担。 + +**结论:以 `flutter_lints` 为底,手动加一小组"能抓真 bug"的规则,而不是"管风格"的规则。** + +### 根级共享配置 + +```yaml +# analysis_options.yaml(仓库根目录) +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true # 禁止 dynamic 隐式转型——最容易藏 bug 的一条 + strict-raw-types: true # 禁止裸 List/Map,逼着写类型参数 + strict-inference: true + errors: + invalid_annotation_target: ignore # json_serializable + 注解组合会误报 + # 下面几条从 warning 提到 error,即 CI 直接失败 + unused_import: error + dead_code: error + unawaited_futures: error + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/generated/**" # pigeon 生成产物,见 07 + plugins: + - custom_lint # riverpod_lint,见 03 + +formatter: + page_width: 100 + +linter: + rules: + # —— 能抓真 bug 的 —— + - always_declare_return_types + - avoid_dynamic_calls + - avoid_slow_async_io + - cancel_subscriptions # StreamSubscription 忘了 cancel 是常见内存泄漏 + - close_sinks + - discarded_futures # 忘了 await 的异步调用 + - unawaited_futures + - no_adjacent_strings_in_list # 少写一个逗号导致字符串被拼接 + - test_types_in_equals + - throw_in_finally + - unnecessary_statements + # —— 团队约定 —— + - prefer_single_quotes + - require_trailing_commas # 配合 formatter,diff 更干净 + - directives_ordering + - sort_pub_dependencies +``` + +各包的 `analysis_options.yaml` 只写一行继承,不允许在包级关规则(要关就在根上关,让所有人都看得见): + +```yaml +# packages/feature_xxx/analysis_options.yaml +include: ../../analysis_options.yaml +``` + +### `strict-casts` 值得单独说 + +它是这份配置里**唯一一条会真的挡住线上 bug** 的开关。没有它,`jsonDecode(...)` 返回的 `dynamic` 可以隐式赋给任何类型,类型错误要到运行时才炸;开了之后必须显式 `as Map`,写的人会被迫想一下"这里到底是什么类型"。 + +代价是接手 JSON 解析时要多写一些 `as`。这个代价值得付。 + +### `custom_lint` 在 workspace 下的接法 + +`riverpod_lint`(见 [03-state-management.md](./03-state-management.md))通过 `custom_lint` 插件运行。在 pub workspace 下: + +- `custom_lint` 和 `riverpod_lint` 加在**根 `pubspec.yaml` 的 `dev_dependencies`**(workspace 共享)。 +- 检查命令是 `dart run custom_lint`,**它不包含在 `flutter analyze` 里**——两条命令都要跑,CI 里是两个独立步骤。这一点很多人不知道,结果 riverpod_lint 装了但从来没生效过。 + +## 三、格式化 + +```bash +dart format --set-exit-if-changed --line-length 100 . +``` + +- **行宽 100,不是默认的 80。** Dart 3.9 起可以写在 `analysis_options.yaml` 的 `formatter: page_width:` 里(上面已配),命令行参数是给 CI 用的双保险。80 在 Flutter 的 widget 嵌套下换行过于频繁,一个三层嵌套的 `Column` 就能占满整屏。100 是一个在宽屏和可读性之间比较平衡的值。 +- **不允许手动排版**。`dart format` 的结果就是唯一正确的结果,不接受"我觉得这样更好看"。省下的是每次 review 里关于换行的争论。 +- CI 用 `--set-exit-if-changed` 卡死。 + +## 四、生成产物是否入库 + +**这是一个必须明确的二选一,模糊处理会导致仓库里一半入库一半不入库。** + +| 类型 | 结论 | 理由 | +|---|---|---| +| `*.g.dart`(riverpod / json_serializable / drift) | **不入库** | 这类文件改动频繁且巨大,几乎每个 PR 都会产生冲突,而冲突的正确解法永远是"重新生成"——那入库就没有意义。加进 `.gitignore` | +| pigeon 生成产物(Dart + Kotlin + Swift) | **入库** | 见 [07-native-integration.md](./07-native-integration.md)。原生侧的 Kotlin/Swift 文件要被 Gradle/Xcode 编译,而**这两条工具链不会跑 `build_runner`**。不入库的话原生构建直接失败 | +| `pubspec.lock` | 根目录**入库**,各 package 的**不入库** | workspace 模式下只有根 lock 生效 | + +不入库 `.g.dart` 的代价是:**新克隆仓库后必须先跑一次生成,否则 IDE 满屏报错**。所以: + +```yaml +# 根 pubspec.yaml 的 melos scripts +gen: + run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs +gen:watch: + run: melos exec --depends-on=build_runner -- dart run build_runner watch --delete-conflicting-outputs +``` + +README 的"第一次跑起来"步骤必须是:`fvm flutter pub get` → `melos run gen` → `fvm flutter run`。**少写这一步,每个新人入职第一天都会卡住。** + +CI 在 analyze 之前必须先 `melos run gen`。 + +**pigeon 产物入库需要一道防腐**:CI 里重新生成后 `git diff --exit-code`,确保有人改了 schema 但忘了提交生成结果时流水线会红(见 07)。 + +## 五、分支与提交 + +### 分支 + +``` +main ← 生产,只接受来自 release/* 和 hotfix/* 的合并,打 tag 出包 +develop ← 集成,日常合并目标 +feature/-<短描述> +fix/-<短描述> +release/ +hotfix/ +``` + +`main`/`develop` **保护分支,禁止直接 push**,只能通过 MR 合入。 + +### 提交信息 + +用 [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(feature_purchase): 支持采购单批量提交 +fix(core_network): 修复 401 并发刷新导致全端登出 +docs(05): 补充上传失败重传约定 +chore(deps): 升级 drift 到 2.34.5 +``` + +`scope` 用**包名**(`feature_purchase`、`core_network`)或文档编号。monorepo 里没有 scope 的提交信息基本等于没有信息——`fix: 修复崩溃` 在半年后完全无法定位。 + +不引入自动化的 changelog 生成(首版没这个需求),但格式先立住,将来要加成本为零。 + +### MR 规范 + +- MR 标题同 commit 规范。 +- 描述里必须有:**改了什么**、**为什么**、**怎么验证的**。 +- **一个 MR 只做一件事。** 顺手格式化半个仓库的 MR 直接打回——它会让 review 变成不可能。 +- 至少 1 人 approve。涉及 `core_*` 的改动需要 2 人(这些包被所有 feature 依赖,改错影响面最大)。 + +## 六、CI 门禁 + +```yaml +# .gitlab-ci.yml(App 部分,与 08-build-flavors.md 的构建 job 拼在一起) +stages: [setup, verify, test, build] + +.flutter_base: &flutter_base + image: <内部 flutter 镜像,预装 FVM 3.44.9> + before_script: + - fvm flutter --version + - dart pub global activate melos 8.2.2 + - melos bootstrap + - melos run gen # 生成产物不入库,必须先生成 + cache: + key: "$CI_COMMIT_REF_SLUG" + paths: [.dart_tool/, .pub-cache/] + +format: + <<: *flutter_base + stage: verify + script: dart format --set-exit-if-changed --line-length 100 . + +analyze: + <<: *flutter_base + stage: verify + script: + - melos exec -- fvm flutter analyze --fatal-infos + - dart run custom_lint # riverpod_lint,analyze 不含它 + +pigeon_check: + <<: *flutter_base + stage: verify + script: + - melos run gen:pigeon + - git diff --exit-code || (echo "pigeon 生成产物未提交" && exit 1) + +test: + <<: *flutter_base + stage: test + script: + - melos run test + - melos run coverage # 阈值 60%,见 09 + coverage: '/lines\.*: \d+\.\d+\%/' + artifacts: + paths: [coverage/] + reports: { coverage_report: { coverage_format: cobertura, path: coverage/cobertura.xml } } +``` + +### 门禁清单 + +| 检查 | 卡点 | 说明 | +|---|---|---| +| `dart format` | **阻断** | | +| `flutter analyze --fatal-infos` | **阻断** | `--fatal-infos` 让 info 级别也算失败,否则 lint 规则形同虚设 | +| `dart run custom_lint` | **阻断** | | +| pigeon 产物一致性 | **阻断** | | +| 单元测试 + Widget 测试 | **阻断** | | +| 覆盖率 ≥ 60% | **阻断** | 见 [09-testing.md](./09-testing.md) | +| 集成测试 | **不卡 MR**,只在合入 develop/main 时跑 | 慢,见 09 | +| Android release 构建 | 只在 tag 上跑 | 见 [08-build-flavors.md](./08-build-flavors.md) | + +`--fatal-infos` 值得强调:不加这个参数,`flutter analyze` 对 info 级别的问题只是打印一下就返回 0,CI 永远绿。半年后仓库里会积累几百条 info,然后没人再看 analyze 的输出。 + +### 关于 `melos bootstrap` 的缓存 + +`.pub-cache` 必须缓存,否则每次 CI 都要重新下载所有依赖,一个 monorepo 下来是几分钟。缓存 key 用 `$CI_COMMIT_REF_SLUG`(按分支),并配一个按 `pubspec.yaml` 哈希的 fallback key。 + +## 七、本地钩子(可选但推荐) + +```yaml +# lefthook.yml +pre-commit: + parallel: true + commands: + format: + glob: "*.dart" + run: dart format --line-length 100 {staged_files} && git add {staged_files} + analyze: + glob: "*.dart" + run: fvm flutter analyze --fatal-infos {staged_files} +``` + +**只跑 format 和 analyze,不跑测试。** pre-commit 跑测试会让每次提交等几十秒,人的第一反应是 `--no-verify`,钩子就废了。测试留给 CI。 + +钩子是**建议不是强制**——CI 才是真正的门禁。钩子的价值只是让人少推一次红色流水线。 + +## 八、目录与命名速查 + +| 项 | 约定 | +|---|---| +| 包名 / 目录 / 文件 | `snake_case` | +| 类 / enum | `UpperCamelCase` | +| 变量 / 方法 | `lowerCamelCase`,私有加 `_` | +| 常量 | `lowerCamelCase`(Dart 惯例,不是 `SCREAMING_CASE`) | +| 文件名 | 与主类名对应:`OrderListPage` → `order_list_page.dart` | +| provider | `xxxProvider`,由 `@riverpod` 生成,不手写 | +| 测试文件 | `<被测文件>_test.dart`,目录镜像 `lib/src/` | +| 包的公共 API | 只从 `lib/.dart` 导出,`lib/src/` 下的一律视为私有(见 01) | + +import 顺序由 `directives_ordering` 强制:`dart:` → `package:`(外部)→ `package:`(本仓库)→ 相对路径。 + +**包内用相对路径 import,跨包用 `package:`。** 混用会导致同一个类被 Dart 认为是两个不同的类型(典型症状:`type 'X' is not a subtype of type 'X'`),这个错误看起来完全不可理喻,实际就是 import 路径不一致。 + +## 待确认项 + +- GitLab Runner 上是否已有可用的 Flutter 镜像,还是需要自建(与 [08-build-flavors.md](./08-build-flavors.md) 的 runner 问题一起解决)。 +- JIRA(或其他)issue key 的格式,用于分支和提交信息里的 ``。 +- 是否引入 lefthook(需要每个人本地 `lefthook install` 一次)。 + +## 参考链接 + +- [flutter_lints | Dart package](https://pub.dev/packages/flutter_lints) +- [Dart: Customizing static analysis](https://dart.dev/tools/analysis) +- [Dart linter rules 全量列表](https://dart.dev/tools/linter-rules) +- [FVM 官方文档](https://fvm.app/) +- [Conventional Commits](https://www.conventionalcommits.org/) +- [Melos 官方文档](https://melos.invertase.dev/) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..941322a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ +# App 架构决策文档 + +Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读。 + +**这些文档是本仓库代码的约束来源。** 代码和文档不一致时,以文档为准改代码。 + +首版范围为 **Android / iOS**,鸿蒙 OHOS 不在首版内(但 SDK 基线锁 3.44.9 是为后续 OHOS 适配留窗口,见 01 和 07)。 + +| 文档 | 内容 | +| --- | --- | +| [01-project-structure.md](./01-project-structure.md) | 工程结构 / 分包策略(Melos monorepo) | +| [02-layering.md](./02-layering.md) | 分层架构规范(presentation/domain/data) | +| [03-state-management.md](./03-state-management.md) | 状态管理方案(Riverpod) | +| [04-routing.md](./04-routing.md) | 路由方案(go_router) | +| [05-networking.md](./05-networking.md) | 网络层设计(dio) | +| [06-local-storage.md](./06-local-storage.md) | 本地存储方案(Drift / secure storage / shared_preferences) | +| [07-native-integration.md](./07-native-integration.md) | 原生能力集成方式(Pigeon) | +| [08-build-flavors.md](./08-build-flavors.md) | 多环境构建(dev/uat/prod flavor) | +| [09-testing.md](./09-testing.md) | 测试策略(单元/Widget/集成测试) | +| [10-webview-h5.md](./10-webview-h5.md) | Embedded H5 容器与 JSBridge(PRD §7 核心链路) | +| [11-store-context-and-session.md](./11-store-context-and-session.md) | 门店上下文与会话管理(切店级联失效、登出清理) | +| [12-error-and-api-contract.md](./12-error-and-api-contract.md) | 错误处理与 API 契约(`ApiResult`、异常体系、降级) | +| [13-observability-analytics.md](./13-observability-analytics.md) | 可观测性与埋点(Sentry 崩溃上报、神策客户端埋点、日志脱敏) | +| [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md) | 工程规范与 CI 门禁(lint、格式化、分支、流水线卡点) | + +## 这份副本从哪来、怎么更新 + +原件在 **[`conti-docs`](../../conti-docs/)** 仓库的根目录,那里是唯一事实来源。 +这里是一份副本,目的是让在本仓库里干活的人(和 AI)不用切仓库就能查到约束。 + +代价是会漂移。**改文档去 `conti-docs` 改,然后同步过来**,不要只改这一边: + +```bash +cp ../../conti-docs/[0-9][0-9]-*.md ./ +# 复制完把跨仓链接改回来(原件里是 ./backend/ 和 ./Architecture-Diagram/) +sed -i 's|](\./backend/|](../../conti-backend/docs/|g' ./[0-9][0-9]-*.md +sed -i 's|](\./Architecture-Diagram/|](../../conti-docs/Architecture-Diagram/|g' ./[0-9][0-9]-*.md +``` + +跨仓链接(架构图、后端文档)按 `Continental-App/` 下三个仓库平级摆放来写相对路径。 +换了目录结构这些链接就断了,重跑一次上面的 `sed` 即可。 + +## 相关 + +- **后端架构决策**:[`conti-backend/docs/`](../../conti-backend/docs/)(Kotlin + Spring Boot,01-12) +- **架构图与 PRD**:[`conti-docs/Architecture-Diagram/`](../../conti-docs/Architecture-Diagram/) +- **PRD 与架构文档已知不一致的三处**、**未决阻塞项**(iOS 构建链路、错误码表、Sentry 自建还是 SaaS 等): + 见 [`conti-docs/README.md`](../../conti-docs/README.md) diff --git a/packages/core_analytics/analysis_options.yaml b/packages/core_analytics/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_analytics/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_analytics/lib/core_analytics.dart b/packages/core_analytics/lib/core_analytics.dart new file mode 100644 index 0000000..b66025a --- /dev/null +++ b/packages/core_analytics/lib/core_analytics.dart @@ -0,0 +1,10 @@ +/// 客户端埋点。来源:conti-docs/13-observability-analytics.md §三。 +/// +/// 边界约定:业务代码只用 [Analytics] 接口 + `AnalyticsEvent` 常量, +/// **不直接 import 神策 SDK,也不在调用处写事件名字面量**。 +library; + +export 'src/analytics.dart'; +export 'src/analytics_event.dart'; +export 'src/noop_analytics.dart'; +export 'src/providers.dart'; diff --git a/packages/core_analytics/lib/src/analytics.dart b/packages/core_analytics/lib/src/analytics.dart new file mode 100644 index 0000000..b87f3e9 --- /dev/null +++ b/packages/core_analytics/lib/src/analytics.dart @@ -0,0 +1,30 @@ +/// 埋点门面。来源:13 §三「实现约定:神策 SDK,外面包一层」。 +library; + +/// 业务代码唯一可见的埋点接口。 +/// +/// 理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 +/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案 +/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。 +/// +/// 硬约束:**埋点失败绝不能影响业务**。实现类的每个方法内部都要 try-catch +/// 兜住,任何异常只记日志不外抛。 +abstract interface class Analytics { + /// 上报一个事件。[event] 必须取自 `AnalyticsEvent` 常量,不允许字面量。 + void track(String event, [Map params = const {}]); + + /// 注册超级属性(公共属性),注册一次后全局附加。 + /// + /// `storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点 + /// 自己传一定会漏。**门店切换后必须重新注册**(见 11 的级联清单)。 + void registerSuperProperties(Map props); + + /// 登录成功后用后端的 `userId` 关联匿名 ID。 + void identify(String userId); + + /// 登出时断开关联。 + /// + /// 不调的话,同一台设备上换人登录的数据会串到一起——**门店设备是共用的, + /// 这个场景一定会发生**。 + void reset(); +} diff --git a/packages/core_analytics/lib/src/analytics_event.dart b/packages/core_analytics/lib/src/analytics_event.dart new file mode 100644 index 0000000..669339a --- /dev/null +++ b/packages/core_analytics/lib/src/analytics_event.dart @@ -0,0 +1,148 @@ +/// 客户端事件名与参数名常量表。来源:13 §三「客户端事件表」「命名约定」。 +/// +/// **客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由 +/// 客户端上报。** 按这条筛下来只剩下面这几个——其余 6 类 PRD §22.1 事件 +/// (登录、首页曝光、门店切换、采购下单、入库、待办点击)全部由后端从请求 +/// 日志出,客户端不重复报。 +/// +/// 命名:`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式 +/// (`succeeded` / `failed`),动作类用现在式(`clicked`)。 +/// +/// **事件名和参数名一旦上线不再改**——改名意味着历史数据断裂,运营报表要 +/// 重做。要加维度就加参数。 +library; + +/// 事件名常量。调用处禁止写字符串字面量:拼写错误编译期发现不了, +/// 在报表里表现为"这个事件怎么没数据"。 +abstract final class AnalyticsEvent { + /// 扫码成功。参数:[AnalyticsParam.mode]、[AnalyticsParam.durationMs]。 + static const String scanSucceeded = 'scan_succeeded'; + + /// 扫码失败。参数:[AnalyticsParam.mode]、[AnalyticsParam.failReason]。 + /// + /// 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到。 + static const String scanFailed = 'scan_failed'; + + /// H5 页关闭。参数:[AnalyticsParam.target]、[AnalyticsParam.stayDurationMs]。 + static const String h5Closed = 'h5_closed'; + + /// H5 白屏 / 超时 / 加载失败。 + /// + /// 参数:[AnalyticsParam.target]、[AnalyticsParam.errorCode]、 + /// [AnalyticsParam.elapsedMs]、[AnalyticsParam.traceId]。 + /// 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败 + /// 后端看不到**。 + static const String h5Failed = 'h5_failed'; + + /// H5 首屏完成。 + /// + /// 参数:[AnalyticsParam.target]、[AnalyticsParam.ticketMs]、 + /// [AnalyticsParam.loadMs]。 + /// **耗时必须拆成两段**:合成一个数字的话,慢了不知道该找 App Backend / + /// F6 / 还是网络——这是这个 App 里最长的一条跨系统链路。 + static const String h5FirstPaint = 'h5_first_paint'; + + /// 客服入口点击。参数:[AnalyticsParam.channel]。 + static const String supportClicked = 'support_clicked'; + + /// 请求失败。 + /// + /// 参数:[AnalyticsParam.path]、[AnalyticsParam.code]、 + /// [AnalyticsParam.httpStatus]、[AnalyticsParam.traceId]。 + /// 虽然后端也能看到失败,但**后端看不到"请求根本没发出去"和"响应没收到"**: + /// 超时、连接失败、DNS 失败、运营商劫持。门店网络不稳时这类占大头。 + static const String apiFailed = 'api_failed'; + + /// 冷启动完成。参数:[AnalyticsParam.durationMs]。 + static const String appColdStart = 'app_cold_start'; + + /// 登出。参数:[AnalyticsParam.reason]。 + /// + /// **被动登出没有对应的接口调用**(见 11),所以这一条必须客户端报。 + static const String logout = 'logout'; + + /// 冷启动恢复会话失败。参数:[AnalyticsParam.stage]。 + /// + /// 卡在读 secure storage 时不产生任何网络请求。 + static const String sessionRestoreFailed = 'session_restore_failed'; + + /// 后端下发了本端路由表里没有的菜单编码(04 §菜单编码到路由的映射)。 + /// + /// 参数:[AnalyticsParam.code]。 + /// 这条事件是**新功能灰度期唯一的可见信号**:后端配了菜单、App 还没发版, + /// 客户端的处理是隐藏该入口——不报的话,现场表现为"菜单配了但看不见", + /// 而两边都以为是对方的问题。后端从请求日志里看不到"客户端没渲染"。 + static const String menuCodeUnsupported = 'menu_code_unsupported'; +} + +/// 事件参数名常量。同样禁止字面量。 +abstract final class AnalyticsParam { + /// 扫码模式:`barcode` / `vin` / `plate`。 + static const String mode = 'mode'; + + /// 耗时(毫秒)。 + static const String durationMs = 'durationMs'; + + /// 失败原因。 + static const String failReason = 'failReason'; + + /// H5 业务标识(不是 URL——URL 的 query 里带票据)。 + static const String target = 'target'; + + /// H5 页面停留时长(毫秒)。 + static const String stayDurationMs = 'stayDurationMs'; + + /// 错误码。 + static const String errorCode = 'errorCode'; + + /// 从开始到失败经过的时间(毫秒)。 + static const String elapsedMs = 'elapsedMs'; + + /// 换票耗时(毫秒)。 + static const String ticketMs = 'ticketMs'; + + /// 页面加载耗时(毫秒)。 + static const String loadMs = 'loadMs'; + + /// 链路 ID,与后端 ELK 对齐(见 05)。 + static const String traceId = 'traceId'; + + /// 客服渠道:`hotline` / `dealer` / `o2o`。 + static const String channel = 'channel'; + + /// 接口路径(不含 query)。 + static const String path = 'path'; + + /// 业务错误码。 + static const String code = 'code'; + + /// HTTP 状态码。 + static const String httpStatus = 'httpStatus'; + + /// 登出原因:`userInitiated` / `tokenExpired` / `sessionRevoked`。 + static const String reason = 'reason'; + + /// 会话恢复失败的阶段:`storage` / `me` / `stores`。 + static const String stage = 'stage'; +} + +/// 超级属性(公共属性)的 key。 +/// +/// 这五个由 `registerSuperProperties` 注册一次全局附加,**不在每个调用点 +/// 手写**。门店切换后必须重新注册。 +abstract final class AnalyticsSuperProperty { + /// 当前门店 ID。 + static const String storeId = 'storeId'; + + /// 当前角色码。 + static const String roleCode = 'roleCode'; + + /// 环境:dev / uat / prod。 + static const String flavor = 'flavor'; + + /// 版本名。 + static const String appVersion = 'appVersion'; + + /// 构建号。 + static const String buildNumber = 'buildNumber'; +} diff --git a/packages/core_analytics/lib/src/noop_analytics.dart b/packages/core_analytics/lib/src/noop_analytics.dart new file mode 100644 index 0000000..10742fe --- /dev/null +++ b/packages/core_analytics/lib/src/noop_analytics.dart @@ -0,0 +1,61 @@ +/// 埋点的空实现与调试实现。 +library; + +import 'analytics.dart'; + +/// 什么都不做的埋点实现。 +/// +/// **神策的采购尚未落地**(见 13 待确认项:公司有没有在用的神策服务)。 +/// 接口先定、事件方案先做,这两块工作量与最终用什么 SDK 无关;真接上 +/// `sensors_analytics_flutter_plugin` 时只新增一个实现类并改 +/// `analyticsProvider` 的 override,调用点一行不动。 +class NoopAnalytics implements Analytics { + /// 创建一个什么都不做的埋点实现。 + const NoopAnalytics(); + + @override + void track(String event, [Map params = const {}]) {} + + @override + void registerSuperProperties(Map props) {} + + @override + void identify(String userId) {} + + @override + void reset() {} +} + +/// 把事件收集到内存里,供测试断言和 dev 下人工核对。 +/// +/// **不要在 prod 用**:它只涨不清,且事件参数里可能有业务信息。 +class RecordingAnalytics implements Analytics { + /// 创建一个记录型埋点实现。 + RecordingAnalytics(); + + /// 已记录的事件,按调用顺序。 + final List<({String event, Map params})> events = + <({String event, Map params})>[]; + + /// 当前已注册的超级属性合集。 + final Map superProperties = {}; + + /// 最后一次 [identify] 传入的 userId;[reset] 后为 null。 + String? currentUserId; + + @override + void track(String event, [Map params = const {}]) => + events.add((event: event, params: Map.of(params))); + + @override + void registerSuperProperties(Map props) => superProperties.addAll(props); + + @override + void identify(String userId) => currentUserId = userId; + + @override + void reset() { + currentUserId = null; + superProperties.clear(); + } +} diff --git a/packages/core_analytics/lib/src/providers.dart b/packages/core_analytics/lib/src/providers.dart new file mode 100644 index 0000000..da10016 --- /dev/null +++ b/packages/core_analytics/lib/src/providers.dart @@ -0,0 +1,17 @@ +/// core_analytics 的 Riverpod 接线。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'analytics.dart'; +import 'noop_analytics.dart'; + +/// 全局埋点入口。 +/// +/// 默认 [NoopAnalytics]。接入神策后由 `app/` 的 `bootstrap()` override—— +/// 且必须在**用户同意隐私政策之后**才初始化 SDK(见 13 待确认项), +/// 所以 override 的时机由 `feature_auth` 的协议弹窗流程决定,不能无条件放在 +/// 启动最早期。 +final Provider analyticsProvider = Provider( + (Ref ref) => const NoopAnalytics(), +); diff --git a/packages/core_analytics/pubspec.yaml b/packages/core_analytics/pubspec.yaml new file mode 100644 index 0000000..04e8268 --- /dev/null +++ b/packages/core_analytics/pubspec.yaml @@ -0,0 +1,19 @@ +name: core_analytics +description: 埋点接口与事件常量。具体 SDK(神策)待定,当前提供 Noop 实现。 +publish_to: none +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_analytics/test/core_analytics_test.dart b/packages/core_analytics/test/core_analytics_test.dart new file mode 100644 index 0000000..e4be045 --- /dev/null +++ b/packages/core_analytics/test/core_analytics_test.dart @@ -0,0 +1,77 @@ +import 'package:core_analytics/core_analytics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnalyticsEvent 命名约定', () { + // 13 §三:snake_case,结果类过去式,动作类现在式。事件名一旦上线不再改, + // 所以这条测试的作用是在「加新事件」时挡住拼写风格漂移。 + const List all = [ + AnalyticsEvent.scanSucceeded, + AnalyticsEvent.scanFailed, + AnalyticsEvent.h5Closed, + AnalyticsEvent.h5Failed, + AnalyticsEvent.h5FirstPaint, + AnalyticsEvent.supportClicked, + AnalyticsEvent.apiFailed, + AnalyticsEvent.appColdStart, + AnalyticsEvent.logout, + AnalyticsEvent.sessionRestoreFailed, + ]; + + test('全部是 snake_case', () { + for (final String e in all) { + expect( + RegExp(r'^[a-z][a-z0-9]*(_[a-z0-9]+)*$').hasMatch(e), + isTrue, + reason: '$e 不是 snake_case', + ); + } + }); + + test('没有重名', () { + expect(all.toSet().length, all.length); + }); + + test('客户端事件表就是这 10 条', () { + // 多一条少一条都要先回到 13 §三的判据:这件事会不会产生一次后端请求? + expect(all.length, 10); + }); + }); + + group('NoopAnalytics', () { + test('所有方法都不抛异常', () { + const Analytics analytics = NoopAnalytics(); + expect( + () => analytics + ..track(AnalyticsEvent.appColdStart) + ..registerSuperProperties({'storeId': 1}) + ..identify('u1') + ..reset(), + returnsNormally, + ); + }); + }); + + group('RecordingAnalytics', () { + test('记录事件与参数副本', () { + final RecordingAnalytics analytics = RecordingAnalytics(); + final Map params = {AnalyticsParam.mode: 'vin'}; + analytics.track(AnalyticsEvent.scanSucceeded, params); + params[AnalyticsParam.mode] = 'plate'; + + expect(analytics.events.single.event, AnalyticsEvent.scanSucceeded); + // 存的是副本,调用方后续修改不会污染已记录的事件。 + expect(analytics.events.single.params[AnalyticsParam.mode], 'vin'); + }); + + test('reset 同时清掉用户与超级属性', () { + final RecordingAnalytics analytics = RecordingAnalytics() + ..identify('u1') + ..registerSuperProperties({AnalyticsSuperProperty.storeId: 7}) + ..reset(); + + expect(analytics.currentUserId, isNull); + expect(analytics.superProperties, isEmpty); + }); + }); +} diff --git a/packages/core_auth/analysis_options.yaml b/packages/core_auth/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_auth/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_auth/lib/core_auth.dart b/packages/core_auth/lib/core_auth.dart new file mode 100644 index 0000000..5115784 --- /dev/null +++ b/packages/core_auth/lib/core_auth.dart @@ -0,0 +1,13 @@ +/// 会话与门店上下文。来源:conti-docs/11-store-context-and-session.md。 +/// +/// 依赖约束(01,编译期强制):本包**没有任何 `core_*` 出边**。 +/// 11 的伪代码里那些跨包调用全部通过 `src/session_ports.dart` 的接口反转, +/// 由 app 层组装。`core_auth ↛ core_network` 尤其重要——否则 `AuthInterceptor` +/// 和刷新逻辑会互相纠缠成环,这也是 [TokenRefresher] 用裸 Dio 的原因。 +library; + +export 'src/models.dart'; +export 'src/session_notifier.dart'; +export 'src/session_ports.dart'; +export 'src/token_refresher.dart'; +export 'src/token_storage.dart'; diff --git a/packages/core_auth/lib/src/models.dart b/packages/core_auth/lib/src/models.dart new file mode 100644 index 0000000..48bec33 --- /dev/null +++ b/packages/core_auth/lib/src/models.dart @@ -0,0 +1,166 @@ +/// 会话与上下文的数据模型。来源:conti-docs/11-store-context-and-session.md。 +library; + +import 'package:flutter/foundation.dart'; + +/// 用户上下文(PRD §6.4.1)。 +/// +/// 只放**跨门店恒定**的信息。菜单不在这里——同一个人在 A 店是店长、在 B 店是 +/// 店员,菜单是门店维度的,见 [StoreContext.menus]。 +@immutable +class UserContext { + /// 构造。 + const UserContext({ + required this.userId, + required this.employeeId, + required this.phone, + required this.roleCode, + required this.channel, + this.permissions = const {}, + }); + + /// 用户唯一标识。 + final String userId; + + /// 员工工号。 + final String employeeId; + + /// 手机号。**打日志前必须脱敏**(见 core_logging 的 `maskPhone`)。 + final String phone; + + /// 角色编码。 + final String roleCode; + + /// 渠道。 + final String channel; + + /// 权限集。 + final Set permissions; +} + +/// 菜单项。路由映射见 04 的 `menuRouteMap`。 +@immutable +class MenuItem { + /// 构造。 + const MenuItem({required this.code, required this.name, this.children = const []}); + + /// 后端下发的稳定编码,客户端据此查本地路由表。 + final String code; + + /// 展示名。 + final String name; + + /// 子菜单。 + final List children; +} + +/// 门店上下文(PRD §6.4.2)。 +@immutable +class StoreContext { + /// 构造。 + const StoreContext({ + required this.storeId, + required this.storeCode, + required this.storeName, + required this.orgId, + this.parentStoreId, + this.menus = const [], + }); + + /// 门店 ID。全 App 只能通过 `currentStoreIdProvider` 读它。 + final int storeId; + + /// 门店编码。 + final String storeCode; + + /// 门店名。 + final String storeName; + + /// 组织 ID。 + final int orgId; + + /// 所属总店;为空表示分店 / 独立店。 + final String? parentStoreId; + + /// 当前门店可访问的菜单。 + final List menus; +} + +/// 登出原因。决定登录页展示什么提示文案,也是 `logout` 埋点的关键字段。 +enum LogoutReason { + /// 用户主动点了退出。 + userInitiated, + + /// refresh token 失效(过期 / 被撤销 / 被判定重放)。 + tokenExpired, + + /// 在别处登录被踢。 + sessionRevoked, +} + +/// 会话状态。 +/// +/// **四个状态,不是一个 `bool isLoggedIn`**(11 §会话状态模型):冷启动读本地态 +/// 期间、以及"已登录但还没确定门店"这两种情况用布尔值表达不了,而后者需要跳到 +/// 一个既不是登录页也不是首页的独立页面。 +/// +/// `sealed` 让 04 的路由 redirect 能穷举分支——漏一个状态编译器就报错。 +sealed class AppSession { + /// 构造。 + const AppSession(); +} + +/// 冷启动读本地态期间,UI 停在 splash。 +final class SessionLoading extends AppSession { + /// 构造。 + const SessionLoading(); +} + +/// 未登录。 +final class SessionUnauthenticated extends AppSession { + /// 构造。 + const SessionUnauthenticated({this.reason}); + + /// 为空表示从未登录过(首次安装),非空则是被登出的原因。 + final LogoutReason? reason; +} + +/// 已登录但还没确定门店:多门店用户需要选,或门店列表拉取失败需要重试。 +final class SessionAwaitingStore extends AppSession { + /// 构造。 + const SessionAwaitingStore({required this.user, this.candidates = const []}); + + /// 已确定的用户上下文。 + final UserContext user; + + /// 可选门店列表。拉取失败时为空,此时页面应展示重试而不是"无门店权限"。 + final List candidates; +} + +/// 完整会话。只有这个状态下业务页面才允许渲染。 +final class SessionActive extends AppSession { + /// 构造。 + const SessionActive({required this.user, required this.store}); + + /// 用户上下文。 + final UserContext user; + + /// 当前门店上下文。 + final StoreContext store; +} + +/// 一对 token。 +/// +/// 后端采用**一次性 refresh token + 重放即全量撤销**,所以刷新拿到的新 +/// refreshToken 必须立刻覆盖存储,旧的已经作废了(见 05 / backend 04)。 +@immutable +class TokenPair { + /// 构造。 + const TokenPair({required this.accessToken, required this.refreshToken}); + + /// 访问令牌。 + final String accessToken; + + /// 刷新令牌。**一次性**,用过即废。 + final String refreshToken; +} diff --git a/packages/core_auth/lib/src/session_notifier.dart b/packages/core_auth/lib/src/session_notifier.dart new file mode 100644 index 0000000..ba1c5a5 --- /dev/null +++ b/packages/core_auth/lib/src/session_notifier.dart @@ -0,0 +1,301 @@ +/// 会话唯一真相源。来源:conti-docs/11-store-context-and-session.md。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'models.dart'; +import 'session_ports.dart'; +import 'token_refresher.dart'; +import 'token_storage.dart'; + +part 'session_notifier.g.dart'; + +/// 全 App 唯一的会话状态持有者。 +@Riverpod(keepAlive: true) +class SessionNotifier extends _$SessionNotifier { + /// 冷启动恢复(11 §冷启动恢复)。 + /// + /// 三条约定: + /// - 读 secure storage 失败当作未登录([TokenStorage] 内部已兜底)。 + /// - **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token + /// 就先按已登录处理;真正无效的 token 会在第一个业务请求返回 401 时被发现。 + /// - 门店列表拉取失败 → [SessionAwaitingStore],不是回登录页。 + @override + Future build() async { + final String? token = await ref.read(tokenStorageProvider).readAccessToken(); + if (token == null || token.isEmpty) { + return const SessionUnauthenticated(); + } + + final SessionRemote remote = ref.read(sessionRemoteProvider); + + final UserContext user; + try { + user = await remote.fetchCurrentUser(); + } on UnauthorizedException { + _notifyRestoreFailed('me'); + await ref.read(tokenStorageProvider).clear(); + return const SessionUnauthenticated(reason: LogoutReason.tokenExpired); + } + _notifyUser(user); + + final List stores; + try { + stores = await remote.fetchAccessibleStores(); + } on Object { + _notifyRestoreFailed('stores'); + // 拿不到门店列表就停在选店页并允许重试,而不是登出。 + return SessionAwaitingStore(user: user); + } + + return _resolveStore(user, stores); + } + + /// 登录成功后调用:写 token → 拉门店 → 定上下文(11 §登录流程)。 + /// + /// 具体的账号密码 / 验证码请求由 `feature_auth` 发,这里只接手 token 之后的 + /// 部分——**「登录成功后必须立即获取门店上下文」**(PRD §10.1)。 + Future onLoggedIn({required TokenPair tokens, required UserContext user}) async { + state = const AsyncLoading(); + await ref.read(tokenStorageProvider).save(tokens); + _notifyUser(user); + + state = await AsyncValue.guard(() async { + final List stores = await ref + .read(sessionRemoteProvider) + .fetchAccessibleStores(); + + // 0 个门店必须登出,不能停在空白首页(PRD §10.1/§10.2 把「用户无门店权限」 + // 列为登录异常流程)。 + if (stores.isEmpty) { + await logout(reason: LogoutReason.userInitiated); + throw const BusinessException(-1, '您当前没有可访问的门店,请联系管理员'); + } + + return _resolveStore(user, stores); + }); + } + + /// 切换门店。 + /// + /// **下面每一步的顺序都是有意义的,改动前先读 11 §切换门店的那张表。** + Future switchStore(int targetStoreId) async { + final AppSession? current = state.value; + if (current is! SessionActive && current is! SessionAwaitingStore) { + throw const PreconditionException('当前不在可切换门店的状态'); + } + + // ── 0. 前置:有未完成的写操作就拦住 ──────────────────────────────── + // 默认阻止而不是静默取消:取消一个已经发出去的下单请求,客户端不知道 + // 服务端到底成没成。本地判定,不编后端错误码(12)。 + if (ref.read(pendingWritesProvider).isNotEmpty) { + throw const PreconditionException('有未完成的操作,请稍后再试'); + } + + final UserContext user = switch (current) { + SessionActive(:final UserContext user) => user, + SessionAwaitingStore(:final UserContext user) => user, + _ => throw const PreconditionException('当前不在可切换门店的状态'), + }; + + // ── 1. 先让 UI 进入切换中,挡住用户继续操作 ──────────────────────── + state = const AsyncLoading(); + + // ── 2. 服务端切换。失败则整个流程中止,本地状态原样不动 ────────────── + // 反过来先清缓存再请求,一旦失败用户就停在"门店没变但数据全没了"的状态。 + final StoreContext newStore; + try { + newStore = await ref.read(sessionRemoteProvider).switchStore(targetStoreId); + } on Object { + // 本地状态原样回滚。不用 AsyncError.copyWithPrevious——它在 Riverpod 3 里 + // 是 internal;而且这里本来就该回到"什么都没发生",错误由 rethrow 交给 + // 调用方(切店页)自己展示。 + state = AsyncData(current!); + rethrow; + } + + // ── 3 & 4. 关 H5 会话(不清 Cookie)+ 清本地业务缓存 ──────────────── + // 必须在第 5 步之前:H5 页面里可能有在途请求,先停掉,否则旧门店的 H5 + // 请求会带着新门店的票据回来;缓存清理和新上下文之间也不能有窗口期, + // 否则新数据可能刚写进去就被一起删掉。 + for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) { + await store.onStoreChanged(); + } + + // ── 5. 落新上下文 → 依赖 currentStoreId 的 provider 自动失效 ───────── + state = AsyncData(SessionActive(user: user, store: newStore)); + + // ── 6. 路由清栈回首页 ─────────────────────────────────────────────── + // core_auth 不能依赖 core_router(01)。反过来由 core_router 监听本 + // provider 做 refresh + 清栈,见 core_router 的 goRouterProvider。 + + // ── 7 & 8. 记住这次选择 + 同步观测上下文 ──────────────────────────── + // 第 8 步漏掉的表现是切店后的崩溃和埋点还挂在旧门店名下,很隐蔽。 + for (final SessionObserver observer in ref.read(sessionObserversProvider)) { + observer.onStoreChanged(newStore); + } + } + + /// 登出(11 §登出:清理清单)。 + Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { + // ── 1. 通知服务端撤销 refresh token ───────────────────────────────── + // 尽力而为。网络不通时用户点登出必须能退出去,否则体验是"这个 App 退不出来"。 + // 服务端 token 会自然过期,不撤销的代价可以接受。 + if (reason == LogoutReason.userInitiated) { + try { + await ref.read(sessionRemoteProvider).revokeSession().timeout(const Duration(seconds: 3)); + } on Object { + // 故意吞掉。 + } + } + + // ── 2 & 3. 清 H5 会话(含 Cookie)+ 清本地数据 ─────────────────────── + // **必须等这两步完成再切状态**:fire-and-forget 会出现"新用户已经进首页了, + // 上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上 + // 这不是理论问题。 + for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) { + try { + await store.onSessionEnded(); + } on Object { + // 某一处清理失败不能卡住登出,继续清剩下的。 + } + } + await ref.read(tokenStorageProvider).clear(); + + // ── 4. 状态置未登录 → 路由守卫自动跳登录页 ────────────────────────── + state = AsyncData(SessionUnauthenticated(reason: reason)); + + // ── 5. 断开观测/埋点的用户关联 ────────────────────────────────────── + // 门店设备是共用的,不断开会让下一个人的数据串到上一个人身上。 + for (final SessionObserver observer in ref.read(sessionObserversProvider)) { + observer.onSessionEnded(reason); + } + + // ── 6. 兜底 provider 清理 ─────────────────────────────────────────── + // 不逐个 ref.invalidate 点名(一定会漏)。业务 provider 全部直接或间接 + // watch 本 provider 或 currentStoreIdProvider,状态一变 autoDispose 的自然 + // 重算;少数 keepAlive 的白名单由 app 层在 sessionScopedStores 里处理。 + } + + /// 门店列表拉取失败后的重试入口,给 [SessionAwaitingStore] 页面用。 + Future retryStoreLoad() async { + final AppSession? current = state.value; + if (current is! SessionAwaitingStore) return; + + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final List stores = await ref + .read(sessionRemoteProvider) + .fetchAccessibleStores(); + return _resolveStore(current.user, stores); + }); + } + + /// 按 11 §登录流程的三分支决定落到哪个状态。 + Future _resolveStore(UserContext user, List stores) async { + if (stores.isEmpty) { + return SessionAwaitingStore(user: user); + } + + // "上次门店"只是一个提示,不是权限依据:**必须先确认它在后端返回的可访问 + // 列表里**,用户的门店权限可能已经被管理员回收了。 + // 上次门店 ID 由 app 层通过 lastStoreIdProvider 从 prefs 读入(core_auth + // 不能依赖 core_storage)。 + final int? lastStoreId = ref.read(lastStoreIdProvider); + final StoreContext? preselected = stores + .where((StoreContext s) => s.storeId == lastStoreId) + .firstOrNull; + + final StoreContext? target = preselected ?? (stores.length == 1 ? stores.first : null); + if (target == null) { + // 多门店且没有可用的上次选择 → 选店页。 + return SessionAwaitingStore(user: user, candidates: stores); + } + + // 走一次服务端 switch 才能拿到菜单(列表接口不下发菜单)。 + final StoreContext store = await ref.read(sessionRemoteProvider).switchStore(target.storeId); + for (final SessionObserver observer in ref.read(sessionObserversProvider)) { + observer.onStoreChanged(store); + } + return SessionActive(user: user, store: store); + } + + void _notifyUser(UserContext user) { + for (final SessionObserver observer in ref.read(sessionObserversProvider)) { + observer.onUserIdentified(user); + } + } + + void _notifyRestoreFailed(String stage) { + for (final SessionObserver observer in ref.read(sessionObserversProvider)) { + observer.onSessionRestoreFailed(stage); + } + } +} + +/// [TokenStorage] 的注入点。测试里 override 成假实现。 +@Riverpod(keepAlive: true) +TokenStorage tokenStorage(Ref ref) => TokenStorage(); + +/// [TokenRefresher] 的注入点。 +/// +/// **必须 keepAlive**:串行刷新靠的是实例内部那个共享的在途 Future,实例被 +/// 回收重建就等于失去了串行保证,而并发刷新会触发后端的重放撤销(11)。 +@Riverpod(keepAlive: true) +TokenRefresher tokenRefresher(Ref ref) => TokenRefresher(storage: ref.watch(tokenStorageProvider)); + +/// 上次选中的门店 ID,用于冷启动/登录时预选。 +/// +/// 值来自 `shared_preferences`(非敏感),由 app 层 override——core_auth 不能 +/// 依赖 core_storage。默认 null 表示不预选。 +final Provider lastStoreIdProvider = Provider((Ref ref) => null); + +/// 未完成的写操作。切店的前置检查读它(11 §步骤 0)。 +/// +/// 由发起写操作的 feature 自己 add/remove,值是一个能在日志里认出来的标识。 +@Riverpod(keepAlive: true) +class PendingWrites extends _$PendingWrites { + @override + Set build() => const {}; + + /// 登记一个在途写操作。 + void add(String tag) => state = {...state, tag}; + + /// 注销。**必须放在 finally 里**,否则一次失败的下单会永久挡住切店。 + void remove(String tag) => state = {...state}..remove(tag); +} + +/// 全 App 读 storeId 的唯一入口。 +/// +/// **任何请求里带 storeId 的 provider 必须 `ref.watch` 它**,不能 `ref.read`, +/// 也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护 +/// "哪些 provider 要手动 invalidate"的清单——那份清单一定会漏(11 / 03)。 +/// +/// 非 [SessionActive] 时**抛异常而不是返回 0 或 null**:能读到这个 provider +/// 说明 UI 已经渲染到业务页面了,此时没有门店上下文是路由守卫的 bug,应该在 +/// 开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。 +@riverpod +int currentStoreId(Ref ref) { + final int? storeId = ref.watch(currentStoreIdOrNullProvider); + if (storeId == null) { + throw StateError('在没有门店上下文时访问了 currentStoreId'); + } + return storeId; +} + +/// 可空版本。 +/// +/// **只给合法地在选店之前就要运行的基础设施用**——目前只有 core_network 的 +/// `HeaderInterceptor`(登录、拉门店列表这些请求本身就发生在有门店之前)。 +/// 业务代码一律用 [currentStoreIdProvider],用可空版会把"忘了选店"变成 +/// 一个安静的空数据页。 +@riverpod +int? currentStoreIdOrNull(Ref ref) { + final AppSession? session = ref.watch(sessionProvider).value; + return switch (session) { + SessionActive(:final StoreContext store) => store.storeId, + _ => null, + }; +} diff --git a/packages/core_auth/lib/src/session_ports.dart b/packages/core_auth/lib/src/session_ports.dart new file mode 100644 index 0000000..dd0515a --- /dev/null +++ b/packages/core_auth/lib/src/session_ports.dart @@ -0,0 +1,105 @@ +/// core_auth 的对外端口。 +/// +/// --------------------------------------------------------------------------- +/// 为什么需要这个文件 +/// +/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了 +/// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、 +/// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、 +/// `crashReporterProvider`(core_logging)、`analyticsProvider`(core_analytics)。 +/// +/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外 +/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点, +/// 循环依赖立刻出现。 +/// +/// 裁决:**依赖反转**。core_auth 只声明"级联时需要有人做这些事",具体由谁做、 +/// 怎么做,在 app 层组装时 override 进来。级联顺序(11 §切换门店的核心价值) +/// 仍然完整保留在 SessionNotifier 里。 +/// +/// 见根目录 SCAFFOLD-NOTES.md。 +/// --------------------------------------------------------------------------- +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'models.dart'; + +/// 会话相关的服务端调用。 +/// +/// 实现放在 `feature_auth`(它同时依赖 core_auth 和 core_network), +/// 在 bootstrap 里 override 进来。core_auth 自己**不能**依赖 core_network。 +abstract interface class SessionRemote { + /// 冷启动恢复:用本地 token 换用户上下文。token 无效时抛 `UnauthorizedException`。 + Future fetchCurrentUser(); + + /// 拉可访问门店列表。 + /// + /// **失败不等于登录失败**(11 §登录流程):token 已经拿到了,应该进 + /// [SessionAwaitingStore] 展示可重试的页面,而不是回登录页让用户重发验证码。 + Future> fetchAccessibleStores(); + + /// 服务端切店,返回带菜单的新门店上下文。 + Future switchStore(int storeId); + + /// 撤销 refresh token。**尽力而为**,失败不阻断本地登出。 + Future revokeSession(); +} + +/// 会话结束 / 门店切换时需要被级联清理的一方。 +/// +/// 由持有用户态数据的包各自实现(core_webview 的 H5 会话、core_storage 的 +/// 缓存与 prefs),在 app 层注册进 [sessionScopedStoresProvider]。 +/// +/// **新增一处用户态存储时,实现这个接口并注册**,就自动进入了级联清理清单—— +/// 比维护一份"哪些东西要清"的文档清单可靠,那份清单一定会漏。 +abstract interface class SessionScopedStore { + /// 出问题时能在日志里认出是谁。 + String get debugName; + + /// 切店:清门店维度的数据。 + /// + /// H5 会话在这里要 `invalidateAll(clearCookies: false)`——**不清 Cookie**, + /// 用户还是同一个人,清了等于让 H5 重新登录一次(10 / 11)。 + Future onStoreChanged(); + + /// 登出:清全部用户数据。 + /// + /// H5 会话在这里必须 `clearCookies: true`。不清的话下一个人打开 H5 会直接 + /// 落进上一个人的 F6 会话——门店设备是共用的,这是本项目最可能出现的 + /// 真实安全事故(11)。 + Future onSessionEnded(); +} + +/// 会话变化时需要同步的观测 / 埋点侧。 +/// +/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics` +/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度 +/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。 +abstract interface class SessionObserver { + /// 门店上下文已更新。 + void onStoreChanged(StoreContext store); + + /// 用户已确定。 + void onUserIdentified(UserContext user); + + /// 已登出。实现里必须**先报事件再 reset**,顺序不能反(11 步骤 5)。 + void onSessionEnded(LogoutReason reason); + + /// 冷启动恢复失败。失败发生在读 secure storage 这一步时**完全不产生网络请求**, + /// 后端无从知晓,只能客户端报(13 / 11 §埋点)。 + void onSessionRestoreFailed(String stage); +} + +/// [SessionRemote] 的注入点。必须在 bootstrap 里 override。 +final Provider sessionRemoteProvider = Provider( + (Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'), +); + +/// 级联清理的注册表。默认空——app 层负责把各包的实现装进来。 +final Provider> sessionScopedStoresProvider = + Provider>((Ref ref) => const []); + +/// 观测侧的注册表。默认空,测试里不用管。 +final Provider> sessionObserversProvider = Provider>( + (Ref ref) => const [], +); diff --git a/packages/core_auth/lib/src/token_refresher.dart b/packages/core_auth/lib/src/token_refresher.dart new file mode 100644 index 0000000..4869405 --- /dev/null +++ b/packages/core_auth/lib/src/token_refresher.dart @@ -0,0 +1,87 @@ +/// token 刷新。来源:conti-docs/05-networking.md §core_auth 侧的刷新实现。 +library; + +import 'dart:async'; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; + +import 'models.dart'; +import 'token_storage.dart'; + +/// 刷新 access token。 +/// +/// 两条硬约束来自后端的**一次性 refresh token + 重放即全量撤销**机制 +/// (见 11 §与 refresh token 轮换的配合): +/// +/// 1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为 +/// 重放,撤销该用户**所有设备**的会话——用户会在自己毫无操作的情况下被全端 +/// 踢下线。这里用一个共享的 Future 保证同一时刻只有一个在途刷新,后来者 +/// 等同一个结果。 +/// 2. **失败立即登出,不重试**。失败意味着 refresh token 已失效,重试只会再 +/// 触发一次重放判定。所以这里没有任何 retry 逻辑,这是有意的。 +class TokenRefresher { + /// [dio] 仅供测试注入。 + /// + /// 生产走下面那个**裸 Dio**:不装任何拦截器。装了 AuthInterceptor 的话, + /// 刷新请求本身返回 401 会再触发一轮刷新,无限递归。 + TokenRefresher({required this.storage, Dio? dio}) + : _bare = + dio ?? + Dio( + BaseOptions( + baseUrl: AppEnv.current.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + + /// token 存取。 + final TokenStorage storage; + final Dio _bare; + + Future? _inFlight; + + /// 刷新一次。并发调用共享同一个在途请求。 + /// + /// 成功时新 token **已经写回 secure storage** 才返回——写回必须在通知会话层 + /// 之前完成(05)。 + Future refresh() { + return _inFlight ??= _refresh().whenComplete(() => _inFlight = null); + } + + Future _refresh() async { + final String? refreshToken = await storage.readRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + throw const UnauthorizedException(); + } + + final Response> res; + try { + res = await _bare.post>( + '/api/v1/auth/refresh', + data: {'refreshToken': refreshToken}, + ); + } on DioException catch (e) { + // 网络问题和 token 失效在这里不区分:两种情况都不能重试(见上), + // 上层一律按登出处理。 + throw UnauthorizedException('登录已过期(${e.type.name})'); + } + + // 裸 Dio 没有 ApiResultInterceptor,得手动解包一层 data。 + final Object? payload = res.data?['data']; + if (payload is! Map) { + throw const UnauthorizedException('刷新响应格式异常'); + } + + final Object? access = payload['accessToken']; + final Object? refresh = payload['refreshToken']; + if (access is! String || refresh is! String) { + throw const UnauthorizedException('刷新响应缺少 token'); + } + + final TokenPair pair = TokenPair(accessToken: access, refreshToken: refresh); + await storage.save(pair); + return pair; + } +} diff --git a/packages/core_auth/lib/src/token_storage.dart b/packages/core_auth/lib/src/token_storage.dart new file mode 100644 index 0000000..a6df1e1 --- /dev/null +++ b/packages/core_auth/lib/src/token_storage.dart @@ -0,0 +1,64 @@ +/// token 的唯一存取点。来源:conti-docs/06-local-storage.md §secure storage。 +library; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'models.dart'; + +/// 包装 secure storage 的 token 读写。 +/// +/// **全仓库唯一直接持有 [FlutterSecureStorage] 实例的类**(06 §secure storage +/// 使用示例)。其他包只能通过 core_auth 暴露的 provider 间接读写 token—— +/// 这条约束是"token 到底存在哪、有几份拷贝"这个问题永远只有一个答案的前提。 +class TokenStorage { + /// [storage] 仅供测试注入。 + TokenStorage({FlutterSecureStorage? storage, this.onReadFailure}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const String _kAccessToken = 'access_token'; + static const String _kRefreshToken = 'refresh_token'; + + final FlutterSecureStorage _storage; + + /// 读失败时的回调。由 app 层接到 core_logging 上——core_auth 不依赖 core_logging。 + final void Function(Object error, StackTrace stackTrace)? onReadFailure; + + /// 写入一对 token。 + /// + /// 刷新拿到新 token 时也走这里:后端轮换机制下旧 refreshToken 立即作废, + /// **必须在通知会话层之前完成写回**,否则进程被杀后下次启动会用旧 token + /// 触发重放判定,导致全端被踢(05 / 11)。 + Future save(TokenPair pair) async { + await _storage.write(key: _kAccessToken, value: pair.accessToken); + await _storage.write(key: _kRefreshToken, value: pair.refreshToken); + } + + /// 读 access token。 + Future readAccessToken() => _read(_kAccessToken); + + /// 读 refresh token。 + Future readRefreshToken() => _read(_kRefreshToken); + + /// 清空。 + Future clear() => _storage.deleteAll(); + + /// 读失败一律按未登录处理:清空 + 返回 null。 + /// + /// **绝对不能让 secure storage 的异常向上冒到启动流程**(06)——那会变成 + /// "升级后一打开就白屏",比让用户重新登录一次严重得多。 + /// `flutter_secure_storage 11.0.0` 改了 Android 侧的默认加密实现,旧版本 + /// 写入的数据在升级后确实有读不出来的风险,这个兜底不是防御性编程。 + Future _read(String key) async { + try { + return await _storage.read(key: key); + } on Object catch (e, st) { + onReadFailure?.call(e, st); + try { + await clear(); + } on Object { + // 连清都清不掉就只能放弃,但绝不抛出去。 + } + return null; + } + } +} diff --git a/packages/core_auth/pubspec.yaml b/packages/core_auth/pubspec.yaml new file mode 100644 index 0000000..822d321 --- /dev/null +++ b/packages/core_auth/pubspec.yaml @@ -0,0 +1,31 @@ +name: core_auth +description: 会话与门店上下文。token 存取、刷新串行化、登出/切店级联。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# --------------------------------------------------------------------------- +# 依赖约束(01 §依赖约束,硬规则): +# core_auth ↛ core_network —— 否则 AuthInterceptor 与刷新逻辑会互相纠缠成环 +# core_auth ↛ core_storage —— 清缓存通过下面的 SessionScopedStore 接口反转依赖 +# 因此本包里的 token 刷新用的是一个**不带任何拦截器的裸 Dio**。 +# --------------------------------------------------------------------------- +environment: + sdk: ^3.12.0 + +dependencies: + core_foundation: ^0.1.0 + dio: ^5.11.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + flutter_secure_storage: ^11.0.0 + riverpod_annotation: ^4.0.3 + +dev_dependencies: + build_runner: ^2.4.13 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.5 + riverpod_generator: ^4.0.4 diff --git a/packages/core_auth/test/core_auth_test.dart b/packages/core_auth/test/core_auth_test.dart new file mode 100644 index 0000000..dd03a5f --- /dev/null +++ b/packages/core_auth/test/core_auth_test.dart @@ -0,0 +1,231 @@ +// core_auth 的两个高价值断言: +// 1. 刷新必须串行——并发刷新会触发后端的重放判定,把用户全端踢下线(11)。 +// 2. 切店的级联顺序——服务端失败时本地必须原样不动(11 的那张表)。 +// 其余都是数据搬运,不写用例。 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 内存版 secure storage。 +class _FakeTokenStorage implements TokenStorage { + TokenPair? saved = const TokenPair(accessToken: 'a', refreshToken: 'r0'); + int clearCount = 0; + + @override + void Function(Object, StackTrace)? get onReadFailure => null; + + @override + Future readAccessToken() async => saved?.accessToken; + + @override + Future readRefreshToken() async => saved?.refreshToken; + + @override + Future save(TokenPair pair) async => saved = pair; + + @override + Future clear() async { + clearCount++; + saved = null; + } +} + +class _FakeRemote implements SessionRemote { + _FakeRemote({this.stores = const []}); + + List stores; + bool switchFails = false; + int switchCalls = 0; + + @override + Future fetchCurrentUser() async => const UserContext( + userId: 'u1', + employeeId: 'e1', + phone: '13800001111', + roleCode: 'manager', + channel: 'app', + ); + + @override + Future> fetchAccessibleStores() async => stores; + + @override + Future switchStore(int storeId) async { + switchCalls++; + if (switchFails) throw const NetworkException('网络不通'); + return stores.firstWhere((StoreContext s) => s.storeId == storeId); + } + + @override + Future revokeSession() async {} +} + +class _RecordingStore implements SessionScopedStore { + final List calls = []; + + @override + String get debugName => 'recording'; + + @override + Future onStoreChanged() async => calls.add('store'); + + @override + Future onSessionEnded() async => calls.add('session'); +} + +StoreContext _store(int id) => + StoreContext(storeId: id, storeCode: 'S$id', storeName: '门店$id', orgId: 1); + +void main() { + group('TokenRefresher', () { + setUpAll(() { + AppEnv.resetForTest(); + AppEnv.install( + const AppEnv( + flavor: AppFlavor.dev, + apiBaseUrl: 'https://example.test', + enableLog: false, + sentryDsn: '', + h5AllowedHosts: {}, + ), + ); + }); + + test('并发刷新只发一个请求——多发一个就会被后端判定为 refresh token 重放', () async { + int requests = 0; + final Dio dio = Dio() + ..httpClientAdapter = _StubAdapter(() { + requests++; + return { + 'data': {'accessToken': 'a1', 'refreshToken': 'r1'}, + }; + }); + + final _FakeTokenStorage storage = _FakeTokenStorage(); + final TokenRefresher refresher = TokenRefresher(storage: storage, dio: dio); + + final List results = await Future.wait(>[ + refresher.refresh(), + refresher.refresh(), + refresher.refresh(), + ]); + + expect(requests, 1); + expect(results.every((TokenPair p) => p.accessToken == 'a1'), isTrue); + // 新 token 必须在返回前就写回,否则进程被杀会留下一个作废的 refreshToken。 + expect(storage.saved?.refreshToken, 'r1'); + }); + + test('没有 refresh token 时直接抛 UnauthorizedException,不发请求', () async { + final _FakeTokenStorage storage = _FakeTokenStorage()..saved = null; + final TokenRefresher refresher = TokenRefresher(storage: storage, dio: Dio()); + + await expectLater(refresher.refresh(), throwsA(isA())); + }); + }); + + group('SessionNotifier.switchStore', () { + late _FakeTokenStorage storage; + late _FakeRemote remote; + late _RecordingStore scoped; + + ProviderContainer makeContainer() { + storage = _FakeTokenStorage(); + remote = _FakeRemote(stores: [_store(1), _store(2)]); + scoped = _RecordingStore(); + return ProviderContainer( + overrides: [ + tokenStorageProvider.overrideWithValue(storage), + sessionRemoteProvider.overrideWithValue(remote), + sessionScopedStoresProvider.overrideWithValue([scoped]), + lastStoreIdProvider.overrideWithValue(1), + ], + ); + } + + test('服务端切换失败时本地状态原样不动,级联清理一次都不能跑', () async { + final ProviderContainer container = makeContainer(); + addTearDown(container.dispose); + + final AppSession initial = await container.read(sessionProvider.future); + expect(initial, isA()); + scoped.calls.clear(); + + remote.switchFails = true; + await expectLater( + container.read(sessionProvider.notifier).switchStore(2), + throwsA(isA()), + ); + + // 先清缓存再请求的写法,会让用户停在"门店没变但数据全没了"的状态。 + expect(scoped.calls, isEmpty); + expect((container.read(sessionProvider).value! as SessionActive).store.storeId, 1); + }); + + test('有未完成的写操作时拒绝切店', () async { + final ProviderContainer container = makeContainer(); + addTearDown(container.dispose); + await container.read(sessionProvider.future); + + container.read(pendingWritesProvider.notifier).add('submitOrder'); + + await expectLater( + container.read(sessionProvider.notifier).switchStore(2), + throwsA(isA()), + ); + expect(remote.switchCalls, 1); // 只有冷启动那次 + }); + + test('登出会清 token 并跑完整的级联清理', () async { + final ProviderContainer container = makeContainer(); + addTearDown(container.dispose); + await container.read(sessionProvider.future); + scoped.calls.clear(); + + await container.read(sessionProvider.notifier).logout(); + + expect(scoped.calls, ['session']); + expect(storage.saved, isNull); + expect( + container.read(sessionProvider).value, + isA().having( + (SessionUnauthenticated s) => s.reason, + 'reason', + LogoutReason.userInitiated, + ), + ); + }); + }); +} + +/// 固定返回一份 JSON 的 adapter,省掉起 http server。 +class _StubAdapter implements HttpClientAdapter { + _StubAdapter(this.body); + + final Map Function() body; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final Map payload = body(); + return ResponseBody.fromString( + jsonEncode(payload), + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} diff --git a/packages/core_foundation/analysis_options.yaml b/packages/core_foundation/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_foundation/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_foundation/lib/core_foundation.dart b/packages/core_foundation/lib/core_foundation.dart new file mode 100644 index 0000000..626eacf --- /dev/null +++ b/packages/core_foundation/lib/core_foundation.dart @@ -0,0 +1,10 @@ +/// 全仓库的依赖叶子包。 +/// +/// 这个包**不依赖仓库内任何其他包**,因此可以被所有 `core_*` / `feature_*` +/// 安全依赖而不产生循环。`native_*` 除外——它们按 01 的规定不依赖任何 core 包。 +library; + +export 'src/env/app_env.dart'; +export 'src/error/api_code.dart'; +export 'src/error/app_exception.dart'; +export 'src/error/network_error_kind.dart'; diff --git a/packages/core_foundation/lib/src/env/app_env.dart b/packages/core_foundation/lib/src/env/app_env.dart new file mode 100644 index 0000000..b4efcae --- /dev/null +++ b/packages/core_foundation/lib/src/env/app_env.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 构建变体。与 `--flavor` 参数、Android productFlavors、iOS Scheme 一一对应。 +/// +/// 见 conti-docs/08-build-flavors.md。 +enum AppFlavor { + dev, + uat, + prod; + + static AppFlavor parse(String name) => switch (name) { + 'dev' => AppFlavor.dev, + 'uat' => AppFlavor.uat, + 'prod' => AppFlavor.prod, + _ => throw ArgumentError.value(name, 'name', '未知 flavor,只允许 dev / uat / prod'), + }; +} + +/// 运行时环境配置。 +/// +/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现 +/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。 +/// +/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个 +/// 参数会得到空的 baseUrl,[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来—— +/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。 +class AppEnv { + const AppEnv({ + required this.flavor, + required this.apiBaseUrl, + required this.enableLog, + required this.sentryDsn, + required this.h5AllowedHosts, + }); + + /// 从 `--dart-define-from-file` 注入的值构造。 + /// + /// [flavor] 由各自的入口文件(main_dev.dart / main_uat.dart / main_prod.dart) + /// 硬编码传入,而不是从 dart-define 读——这样"用 dev 的入口配了 prod 的 json" + /// 这种事故在代码里看得见。 + factory AppEnv.fromDartDefine({required String flavor}) { + const apiBaseUrl = String.fromEnvironment('API_BASE_URL'); + const enableLog = bool.fromEnvironment('ENABLE_LOG'); + const sentryDsn = String.fromEnvironment('SENTRY_DSN'); + const h5AllowedHosts = String.fromEnvironment('H5_ALLOWED_HOSTS'); + + if (apiBaseUrl.isEmpty) { + throw StateError( + 'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json,' + '见 README「怎么跑起来」。', + ); + } + + return AppEnv( + flavor: AppFlavor.parse(flavor), + apiBaseUrl: apiBaseUrl, + enableLog: enableLog, + sentryDsn: sentryDsn, + h5AllowedHosts: _parseHosts(h5AllowedHosts), + ); + } + + static Set _parseHosts(String raw) => raw + .split(',') + .map((String e) => e.trim().toLowerCase()) + .where((String e) => e.isNotEmpty) + .toSet(); + + final AppFlavor flavor; + + /// 形如 `https://api.conti.com`,**不带**结尾斜杠、**不带** `/api/v1` 前缀。 + final String apiBaseUrl; + + /// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。 + final bool enableLog; + + /// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。 + final String sentryDsn; + + /// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。 + final Set h5AllowedHosts; + + bool get isProd => flavor == AppFlavor.prod; + + bool get flavorSuffixVisible => flavor != AppFlavor.prod; + + /// 全局单例。 + /// + /// 优先用 [appEnvProvider] 注入(可测试、可 override)。这个静态入口只服务于 + /// **拿不到 Ref 的地方**——目前唯一的使用点是 core_auth 的 TokenRefresher, + /// 它必须用一个不带任何拦截器的裸 Dio,无法从 provider 树里取配置。 + /// + /// 声明成 `late`(而非 `late final`)是为了让 [resetForTest] 能真的重置; + /// 运行期的"只赋值一次"由 [install] 的 [_initialized] 标志保证。 + static late AppEnv current; + + static bool _initialized = false; + + /// 由 bootstrap() 在 runApp 之前调用一次。重复调用会抛错。 + static void install(AppEnv env) { + if (_initialized) { + throw StateError('AppEnv 已经初始化过了,不允许在运行期替换环境配置。'); + } + _initialized = true; + current = env; + } + + /// 仅供测试重置。 + @visibleForTesting + static void resetForTest() => _initialized = false; +} + +/// 环境配置的注入点。 +/// +/// 必须在 bootstrap() 的 ProviderScope 里 override,否则读取时直接抛错—— +/// 给一个默认值会让"忘了注入"变成一个安静的线上事故。 +final Provider appEnvProvider = Provider( + (Ref ref) => throw UnimplementedError('appEnvProvider 必须在 bootstrap() 里 overrideWithValue'), +); diff --git a/packages/core_foundation/lib/src/error/api_code.dart b/packages/core_foundation/lib/src/error/api_code.dart new file mode 100644 index 0000000..9cedd40 --- /dev/null +++ b/packages/core_foundation/lib/src/error/api_code.dart @@ -0,0 +1,37 @@ +/// 后端业务错误码常量。 +/// +/// **客户端不允许出现字面量数字**(见 12 §一):`if (e.code == ApiCode.forbidden)` +/// 而不是 `if (e.code == 10403)`。 +/// +/// --------------------------------------------------------------------------- +/// ⚠️ 完整码表尚未与后端对齐(12「待确认项」里的最高优先级项)。 +/// +/// 在码表定下来之前,客户端的策略是:**默认直接展示后端返回的 `message`**, +/// 只对下面这一小组「需要特殊 UX 而不只是提示文案」的码做分支。这一组必须 +/// 保持尽可能小——每加一个都意味着客户端和后端之间多一处硬编码耦合。 +/// +/// 分段约定(5 位,前 2 位是域段): +/// 0 成功 +/// 10xxx 平台通用 +/// 11xxx 认证与门店 +/// 20xxx 采购 21xxx 库存 +/// 3xxxx F6 / Mini 透传类 +/// --------------------------------------------------------------------------- +abstract final class ApiCode { + static const int ok = 0; + + /// → 表单内联报错,不弹 Toast + static const int invalidParam = 10001; + + /// → 触发刷新 / 登出 + static const int unauthorized = 10401; + + /// → 权限变更,可能要重拉门店上下文 + static const int forbidden = 10403; + + /// → 展示 traceId + static const int internalError = 10500; + + /// → 引导重选门店 + static const int storeNotAccessible = 11001; +} diff --git a/packages/core_foundation/lib/src/error/app_exception.dart b/packages/core_foundation/lib/src/error/app_exception.dart new file mode 100644 index 0000000..444d851 --- /dev/null +++ b/packages/core_foundation/lib/src/error/app_exception.dart @@ -0,0 +1,145 @@ +import 'network_error_kind.dart'; + +/// App 内部统一的异常体系(文档 12 §二)。 +/// +/// `sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型, +/// 所有映射点会编译报错,逼着人去处理,而不是悄悄落进 `default` 变成"未知错误"。 +/// +/// --------------------------------------------------------------------------- +/// 与文档的偏差(见根目录 SCAFFOLD-NOTES.md §C / §E): +/// +/// 1. 本体系文档里放在 `core_network`,但 `StorageException` 属于 core_storage、 +/// `UnauthorizedException` 要被 core_auth 使用,而 01 明令禁止 +/// `core_auth → core_network`。放在 core_network 无法同时满足依赖规则, +/// 因此下沉到叶子包 core_foundation。 +/// 2. 文档里 `UnauthorizedException` / `RequestCancelledException` / +/// `StorageException` 都写成了空类体,但父类要求一个位置参数 `message`, +/// 照抄编译不过。这里补上带默认文案的 const 构造。 +/// 3. `bridgeCode` 被 10 的 JSBridge 用到但从未定义,这里补上。 +/// --------------------------------------------------------------------------- +sealed class AppException implements Exception { + const AppException(this.message, {this.traceId}); + + /// 可直接展示给用户的文案。不要往里塞堆栈或英文技术描述。 + final String message; + + /// 服务端链路 ID。只有 [ServerException] 会展示它,但所有异常都会把它写进日志。 + final String? traceId; + + /// 透传给 H5 的稳定字符串错误码(见 10 §JSBridge)。 + /// + /// **一旦发布就不能改**——H5 侧按它分支。新增能力时只能加新值。 + String get bridgeCode; + + @override + String toString() { + final String trace = traceId == null ? '' : ' traceId=$traceId'; + return '$runtimeType($bridgeCode): $message$trace'; + } +} + +/// 网络不通、超时、DNS 失败——用户重试可能就好了。 +final class NetworkException extends AppException { + const NetworkException(super.message, {this.kind}); + + final NetworkErrorKind? kind; + + @override + String get bridgeCode => 'NETWORK_ERROR'; +} + +/// 后端返回了 `code != 0`,[message] 可直接展示。 +/// +/// 构造签名按文档 12(位置参数),05 里那份具名参数的写法是笔误。 +final class BusinessException extends AppException { + const BusinessException(this.code, super.message, {super.traceId}); + + final int code; + + @override + String get bridgeCode => 'BUSINESS_ERROR'; +} + +/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好。 +/// +/// 文档 05 里叫 `HttpException`,与 `dart:io` 同名,统一用 12 的 `ServerException`。 +final class ServerException extends AppException { + const ServerException(super.message, {this.statusCode, super.traceId}); + + final int? statusCode; + + @override + String get bridgeCode => 'SERVER_ERROR'; +} + +/// token 失效且刷新失败,已触发登出。 +/// +/// UI 不展示它——登出流程本身会把用户送回登录页,再弹一个 Toast 是噪音。 +final class UnauthorizedException extends AppException { + const UnauthorizedException([super.message = '登录已过期']); + + @override + String get bridgeCode => 'UNAUTHORIZED'; +} + +/// 请求被 CancelToken 取消(页面销毁、用户主动退出)。 +/// +/// **必须被 UI 静默处理**(见 05 / 12)。用户返回上一页时在途请求被取消, +/// 弹一个"请求已取消"是纯粹的噪音。 +final class RequestCancelledException extends AppException { + const RequestCancelledException([super.message = '请求已取消']); + + @override + String get bridgeCode => 'CANCELLED'; +} + +/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),[message] 可直接展示。 +/// +/// 不复用 [BusinessException]:后者的 code 来自后端错误码表,纯本地的判定 +/// 没有、也不该编一个 code。 +final class PreconditionException extends AppException { + const PreconditionException(super.message); + + @override + String get bridgeCode => 'PRECONDITION_FAILED'; +} + +/// 本地存储 / 数据库错误。 +final class StorageException extends AppException { + const StorageException([super.message = '本地数据异常']); + + @override + String get bridgeCode => 'STORAGE_ERROR'; +} + +/// 原生能力错误(权限拒绝、设备不支持),见 07。 +/// +/// 注意 `native_*` 包**不能**抛这个类型——它们不允许依赖任何 core 包。 +/// native 侧抛自己的包内异常,由调用方(feature / core_webview)转成这里的类型。 +final class NativeException extends AppException { + const NativeException(this.code, super.message); + + /// 取值见 [NativeErrorCode]。 + final String code; + + @override + String get bridgeCode => code; +} + +/// [NativeException.code] 的取值。同时也是透传给 H5 的 bridge code。 +abstract final class NativeErrorCode { + /// 用户拒绝了权限 + static const String permissionDenied = 'PERMISSION_DENIED'; + + /// 设备没有这个硬件 / 系统不支持 + static const String unavailable = 'UNAVAILABLE'; + + /// 用户主动取消(如扫码页返回) + static const String cancelled = 'CANCELLED'; + + /// 当前平台没有实现这个能力(如鸿蒙上的某些能力) + /// + /// 文档 07 里单独有一个 `UnsupportedPlatformException`,收敛到这里, + /// 避免为一种情况多开一个异常类型。 + static const String unsupportedPlatform = 'UNSUPPORTED_PLATFORM'; +} diff --git a/packages/core_foundation/lib/src/error/network_error_kind.dart b/packages/core_foundation/lib/src/error/network_error_kind.dart new file mode 100644 index 0000000..b58f5e2 --- /dev/null +++ b/packages/core_foundation/lib/src/error/network_error_kind.dart @@ -0,0 +1,17 @@ +/// 网络层失败的细分原因。 +/// +/// 文档 12 的 `present()` 里用到了这个枚举,但 05 / 12 都没有声明它—— +/// 这里补上。由 core_network 的 ErrorMappingInterceptor 负责从 DioExceptionType 映射。 +enum NetworkErrorKind { + /// 连不上服务器(建连超时) + connectTimeout, + + /// 连上了但服务器迟迟不返回 + receiveTimeout, + + /// 请求体发送超时(大文件上传常见) + sendTimeout, + + /// 设备根本没有网络 / DNS 解析失败 + noConnection, +} diff --git a/packages/core_foundation/pubspec.yaml b/packages/core_foundation/pubspec.yaml new file mode 100644 index 0000000..7f1e5ae --- /dev/null +++ b/packages/core_foundation/pubspec.yaml @@ -0,0 +1,18 @@ +name: core_foundation +description: 全仓库的依赖叶子包:运行环境(AppEnv)、错误契约(AppException 体系)、API 错误码常量。 +publish_to: none +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_foundation/test/app_exception_test.dart b/packages/core_foundation/test/app_exception_test.dart new file mode 100644 index 0000000..d2e4f4d --- /dev/null +++ b/packages/core_foundation/test/app_exception_test.dart @@ -0,0 +1,55 @@ +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AppException', () { + test('每个子类都有稳定且互不冲突的 bridgeCode', () { + const List all = [ + NetworkException('x'), + BusinessException(1, 'x'), + ServerException('x'), + UnauthorizedException(), + RequestCancelledException(), + PreconditionException('x'), + StorageException(), + NativeException(NativeErrorCode.permissionDenied, 'x'), + ]; + + final Set codes = all.map((AppException e) => e.bridgeCode).toSet(); + expect(codes.length, all.length, reason: 'bridgeCode 是 H5 的分支依据,不能有重复'); + for (final String code in codes) { + expect(code, matches(RegExp(r'^[A-Z][A-Z_]+$')), reason: '必须是大写下划线常量风格'); + } + }); + + test('toString 带上 traceId 但不带堆栈', () { + const AppException e = ServerException('系统繁忙', statusCode: 502, traceId: 'abc123'); + expect(e.toString(), contains('abc123')); + expect(e.toString(), contains('系统繁忙')); + }); + }); + + group('AppFlavor', () { + test('只接受三个已知 flavor', () { + expect(AppFlavor.parse('dev'), AppFlavor.dev); + expect(AppFlavor.parse('uat'), AppFlavor.uat); + expect(AppFlavor.parse('prod'), AppFlavor.prod); + expect(() => AppFlavor.parse('staging'), throwsArgumentError); + }); + }); + + group('AppEnv', () { + test('isProd / flavorSuffixVisible 按 flavor 判定', () { + const AppEnv env = AppEnv( + flavor: AppFlavor.dev, + apiBaseUrl: 'https://api-dev.conti.com', + enableLog: true, + sentryDsn: '', + h5AllowedHosts: {'f6.conti.com'}, + ); + expect(env.isProd, isFalse); + expect(env.flavorSuffixVisible, isTrue); + expect(env.h5AllowedHosts, contains('f6.conti.com')); + }); + }); +} diff --git a/packages/core_logging/analysis_options.yaml b/packages/core_logging/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_logging/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_logging/lib/core_logging.dart b/packages/core_logging/lib/core_logging.dart new file mode 100644 index 0000000..1e4053d --- /dev/null +++ b/packages/core_logging/lib/core_logging.dart @@ -0,0 +1,19 @@ +/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。 +/// +/// 边界约定: +/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import +/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`; +/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到 +/// Sentry SDK。 +library; + +export 'src/app_logger.dart'; +export 'src/crash_breadcrumb_observer.dart'; +export 'src/crash_reporter.dart'; +export 'src/log_buffer.dart'; +export 'src/logger_app_logger.dart'; +export 'src/providers.dart'; +export 'src/scrubber.dart'; +export 'src/sentry_crash_reporter.dart'; +export 'src/sentry_scrubber.dart'; +export 'src/test_exception.dart'; diff --git a/packages/core_logging/lib/src/app_logger.dart b/packages/core_logging/lib/src/app_logger.dart new file mode 100644 index 0000000..7c38948 --- /dev/null +++ b/packages/core_logging/lib/src/app_logger.dart @@ -0,0 +1,42 @@ +/// 日志门面。来源:13 §二。 +library; + +/// 全仓库唯一允许调用的日志入口。 +/// +/// **禁止直接用 `logger` 包、`print`、`debugPrint`**: +/// - 换实现只改一处; +/// - `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 +abstract interface class AppLogger { + /// 调试信息。只在 dev 输出。 + void d(String message, {Map? data}); + + /// 关键流程节点。uat 及以上输出。 + void i(String message, {Map? data}); + + /// 可恢复的异常状况。 + void w(String message, {Object? error, StackTrace? stackTrace}); + + /// 错误。prod 下也会进环形缓冲并随崩溃上报。 + void e(String message, {Object? error, StackTrace? stackTrace}); +} + +/// 测试和早期启动阶段用的空实现。 +/// +/// 启动最早期(`AppEnv` 还没装好)就可能有日志调用,那时不能因为 +/// logger 未初始化而抛异常。 +class NoopAppLogger implements AppLogger { + /// 创建一个什么都不做的 logger。 + const NoopAppLogger(); + + @override + void d(String message, {Map? data}) {} + + @override + void i(String message, {Map? data}) {} + + @override + void w(String message, {Object? error, StackTrace? stackTrace}) {} + + @override + void e(String message, {Object? error, StackTrace? stackTrace}) {} +} diff --git a/packages/core_logging/lib/src/crash_breadcrumb_observer.dart b/packages/core_logging/lib/src/crash_breadcrumb_observer.dart new file mode 100644 index 0000000..df849c6 --- /dev/null +++ b/packages/core_logging/lib/src/crash_breadcrumb_observer.dart @@ -0,0 +1,38 @@ +/// 路由面包屑观察者。来源:13 §一「崩溃前的页面路径」。 +library; + +import 'package:flutter/widgets.dart'; + +import 'crash_reporter.dart'; + +/// 把路由变化写进 Sentry 面包屑。 +/// +/// **记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据 +/// (见 10 与本包的 `scrubber.dart`)。 +/// +/// 挂载位置:`app/` 在构造 GoRouter 时通过 `navigatorObserversProvider` +/// 注入。`core_router` 不依赖 `core_logging`(那不在 01 允许的三条 core 间 +/// 依赖里),所以这个类住在这里而不是路由包。详见 SCAFFOLD-NOTES.md §G。 +class CrashBreadcrumbObserver extends NavigatorObserver { + /// [reporter] 通常是 `SentryCrashReporter`。 + CrashBreadcrumbObserver(this._reporter); + + final CrashReporter _reporter; + + @override + void didPush(Route route, Route? previousRoute) => _leave('push', route); + + @override + void didPop(Route route, Route? previousRoute) => _leave('pop', route); + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (newRoute != null) _leave('replace', newRoute); + } + + void _leave(String action, Route route) { + // name 为空时用 '',绝不回退到 route.settings.arguments 或 + // 完整 location——那两个都可能带业务参数。 + _reporter.leaveBreadcrumb('nav: $action ${route.settings.name ?? ''}'); + } +} diff --git a/packages/core_logging/lib/src/crash_reporter.dart b/packages/core_logging/lib/src/crash_reporter.dart new file mode 100644 index 0000000..495eec4 --- /dev/null +++ b/packages/core_logging/lib/src/crash_reporter.dart @@ -0,0 +1,56 @@ +/// 崩溃上报门面。来源:13 §一。 +library; + +/// 业务代码唯一可见的崩溃上报接口。 +/// +/// 这一层**不是**为了"将来可能换 Sentry"——是为了测试里能直接 mock 掉、 +/// 不必真的初始化 SDK,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖 +/// (见 01 的依赖规则)。 +abstract interface class CrashReporter { + /// 只传后端的 `userId`,**不传手机号 / 姓名**(13 §一「用户与门店上下文」)。 + void setUser(String userId); + + /// 设置一个自定义维度。`storeId` 一定要带——门店设备型号和网络环境高度 + /// 集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 + void setTag(String key, String value); + + /// 清空用户与门店维度。登出时调用,否则共用设备上会串号。 + void clearUser(); + + /// 面包屑。记路由名而不是完整 URL——URL 的 query 里带着票据。 + void leaveBreadcrumb(String message); + + /// 上报一个异常。 + /// + /// [extra] 用于带 `traceId` 这类能直接关联到后端 ELK 的字段。 + void report( + Object error, + StackTrace? stack, { + Map extra = const {}, + }); +} + +/// 测试与未接入环境用的空实现。 +class NoopCrashReporter implements CrashReporter { + /// 创建一个什么都不做的上报器。 + const NoopCrashReporter(); + + @override + void setUser(String userId) {} + + @override + void setTag(String key, String value) {} + + @override + void clearUser() {} + + @override + void leaveBreadcrumb(String message) {} + + @override + void report( + Object error, + StackTrace? stack, { + Map extra = const {}, + }) {} +} diff --git a/packages/core_logging/lib/src/log_buffer.dart b/packages/core_logging/lib/src/log_buffer.dart new file mode 100644 index 0000000..3ac4c22 --- /dev/null +++ b/packages/core_logging/lib/src/log_buffer.dart @@ -0,0 +1,57 @@ +/// 内存环形缓冲——崩溃时的"黑匣子"。来源:13 §二「级别与环境」。 +library; + +/// 一条已经脱敏、可以直接外发的日志。 +class LogRecord { + /// 构造一条日志记录。[message] 必须是脱敏之后的内容。 + const LogRecord({required this.level, required this.message, required this.timestamp}); + + /// 级别缩写:`D` / `I` / `W` / `E`。 + final String level; + + /// 已脱敏的消息体。 + final String message; + + /// 记录时刻(本地时区)。 + final DateTime timestamp; + + @override + String toString() => '${timestamp.toIso8601String()} [$level] $message'; +} + +/// 固定容量的环形缓冲。 +/// +/// 只在内存里,**不落磁盘**——日志文件会成为新的泄漏面,且门店设备上 +/// `logcat` 与外部存储都不是可信边界。App 退出即消失。 +class LogBuffer { + /// [capacity] 默认 500,与 13 §二的表格一致。 + LogBuffer({this.capacity = 500}) : assert(capacity > 0, 'capacity 必须为正'); + + /// 最多保留的条数。 + final int capacity; + + final List _records = []; + + /// 追加一条,超出容量时丢弃最旧的。 + void add(LogRecord record) { + _records.add(record); + if (_records.length > capacity) { + _records.removeRange(0, _records.length - capacity); + } + } + + /// 取最近 [count] 条,按时间正序。 + /// + /// 崩溃上报时实际带走的是 30 条而不是全部 500 条——单个 Sentry 事件的 + /// 体积有上限,超了整条事件会被丢弃,那比少带日志更糟。 + List recent([int count = 30]) { + if (_records.length <= count) return List.unmodifiable(_records); + return List.unmodifiable(_records.sublist(_records.length - count)); + } + + /// 当前条数。 + int get length => _records.length; + + /// 清空。登出时调用,避免上一个账号的日志被下一个账号的崩溃带走。 + void clear() => _records.clear(); +} diff --git a/packages/core_logging/lib/src/logger_app_logger.dart b/packages/core_logging/lib/src/logger_app_logger.dart new file mode 100644 index 0000000..3fab85e --- /dev/null +++ b/packages/core_logging/lib/src/logger_app_logger.dart @@ -0,0 +1,124 @@ +/// `AppLogger` 的 logger 包实现。来源:13 §二。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:logger/logger.dart'; + +import 'app_logger.dart'; +import 'log_buffer.dart'; +import 'scrubber.dart'; + +/// 日志级别,按严重程度递增。 +enum LogSeverity { + /// 调试。 + debug('D'), + + /// 常规信息。 + info('I'), + + /// 警告。 + warning('W'), + + /// 错误。 + error('E'); + + const LogSeverity(this.tag); + + /// 出现在环形缓冲文本里的单字母标记。 + final String tag; +} + +/// 生产可用的 [AppLogger]。 +/// +/// 三条硬约束(13 §二): +/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上 +/// 这不是理论风险; +/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发; +/// 3. 级别按 flavor 分档:dev=debug / uat=info / prod=warning。 +class LoggerAppLogger implements AppLogger { + /// 按 [env] 推导级别和控制台开关。 + /// + /// [buffer] 由调用方持有并同时交给 `CrashReporter`,两边必须是同一个实例, + /// 否则崩溃上报里带的是一份空日志。 + LoggerAppLogger({required AppEnv env, required this.buffer, Logger? logger}) + : _minSeverity = _severityFor(env.flavor), + // enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。 + _console = env.enableLog && !env.isProd, + _logger = + logger ?? + Logger( + printer: PrettyPrinter( + methodCount: 0, + errorMethodCount: 8, + colors: true, + printEmojis: false, + ), + // 级别过滤由本类统一做,交给 logger 会多一层不一致的语义。 + filter: ProductionFilter(), + level: Level.all, + ); + + static LogSeverity _severityFor(AppFlavor flavor) => switch (flavor) { + AppFlavor.dev => LogSeverity.debug, + AppFlavor.uat => LogSeverity.info, + AppFlavor.prod => LogSeverity.warning, + }; + + /// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。 + final LogBuffer buffer; + + final LogSeverity _minSeverity; + final bool _console; + final Logger _logger; + + @override + void d(String message, {Map? data}) => + _log(LogSeverity.debug, message, data: data); + + @override + void i(String message, {Map? data}) => + _log(LogSeverity.info, message, data: data); + + @override + void w(String message, {Object? error, StackTrace? stackTrace}) => + _log(LogSeverity.warning, message, error: error, stackTrace: stackTrace); + + @override + void e(String message, {Object? error, StackTrace? stackTrace}) => + _log(LogSeverity.error, message, error: error, stackTrace: stackTrace); + + void _log( + LogSeverity severity, + String message, { + Map? data, + Object? error, + StackTrace? stackTrace, + }) { + if (severity.index < _minSeverity.index) return; + + final String line = data == null || data.isEmpty ? message : '$message ${scrubMap(data)}'; + + buffer.add( + LogRecord( + level: severity.tag, + // error 的 toString 无法结构化脱敏,所以约定反过来:仓库内的异常类型 + // (见 core_foundation 的 AppException)只带 code / traceId,不带任何 + // 凭据或用户输入原文。三方异常同理,发现例外要在这里显式拦。 + message: error == null ? line : '$line | error=$error', + timestamp: DateTime.now(), + ), + ); + + if (!_console) return; + switch (severity) { + case LogSeverity.debug: + _logger.d(line); + case LogSeverity.info: + _logger.i(line); + case LogSeverity.warning: + _logger.w(line, error: error, stackTrace: stackTrace); + case LogSeverity.error: + _logger.e(line, error: error, stackTrace: stackTrace); + } + } +} diff --git a/packages/core_logging/lib/src/providers.dart b/packages/core_logging/lib/src/providers.dart new file mode 100644 index 0000000..88810c0 --- /dev/null +++ b/packages/core_logging/lib/src/providers.dart @@ -0,0 +1,28 @@ +/// core_logging 的 Riverpod 接线。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app_logger.dart'; +import 'crash_reporter.dart'; +import 'log_buffer.dart'; + +/// 环形缓冲的唯一实例。 +/// +/// `AppLogger` 往里写、`beforeSend` 从里读,两边必须是同一个对象, +/// 否则崩溃上报里带的是一份空日志。 +final Provider logBufferProvider = Provider((Ref ref) => LogBuffer()); + +/// 全局日志入口。 +/// +/// 默认是 [NoopAppLogger]——真实实现需要 `AppEnv`,由 `app/` 的 `bootstrap()` +/// 在拿到环境配置后 override。这样保证:即使某个测试忘了 override, +/// 也只是没有日志,而不是启动就抛异常。 +final Provider appLoggerProvider = Provider( + (Ref ref) => const NoopAppLogger(), +); + +/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。 +final Provider crashReporterProvider = Provider( + (Ref ref) => const NoopCrashReporter(), +); diff --git a/packages/core_logging/lib/src/scrubber.dart b/packages/core_logging/lib/src/scrubber.dart new file mode 100644 index 0000000..206f56c --- /dev/null +++ b/packages/core_logging/lib/src/scrubber.dart @@ -0,0 +1,85 @@ +/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。 +/// +/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及 +/// 网络层的日志拦截器都必须走这里,不允许各写各的。 +library; + +/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。 +/// +/// `code` 在这里指短信验证码。业务错误码字段名统一叫 `bizCode` / `errorCode`, +/// 不会被误伤——见 12 的 `ApiResult` 契约。 +const Set sensitiveKeys = { + 'token', + 'accessToken', + 'refreshToken', + 'authorization', + 'ticket', + 'password', + 'code', + 'phone', + 'mobile', + 'idCard', + 'bankCard', +}; + +/// 需要走掩码而不是整体抹掉的 key——保留首尾便于人工比对。 +const Set _maskedKeys = {'phone', 'mobile'}; + +/// 统一的替换文案。出现在日志里时应当一眼看出是被脱敏了,而不是"值为空"。 +const String redacted = ''; + +/// 手机号掩码:`13812345678` → `138****5678`。 +/// +/// 长度不足 11 位时不做部分保留——短号段保留首三位仍可能足以定位到人。 +String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; + +/// 递归脱敏一个结构化日志载荷。 +/// +/// 只对 Map 的 **key** 做判断——裸字符串没有可靠的判据,不做猜测式匹配。 +/// 因此约定:凭据只能以键值对的形式进日志,不允许拼进自由文本。 +/// Map / List 之外的类型原样返回;调用方拿到的是新对象,原始 Map 不被修改。 +Object? scrubValue(Object? value) { + if (value is Map) { + return { + for (final MapEntry e in value.entries) + '${e.key}': _scrubEntry('${e.key}', e.value), + }; + } + if (value is List) { + return value.map(scrubValue).toList(); + } + return value; +} + +/// [scrubValue] 的 Map 入口,返回类型收窄,供调用方直接塞进日志。 +Map scrubMap(Map data) => + scrubValue(data)! as Map; + +Object? _scrubEntry(String key, Object? value) { + final String lower = key.toLowerCase(); + if (_maskedKeys.any((String k) => lower == k.toLowerCase())) { + return value is String ? maskPhone(value) : redacted; + } + if (sensitiveKeys.any((String k) => lower.contains(k.toLowerCase()))) { + return redacted; + } + return scrubValue(value); +} + +/// 去掉 URL 的 query 和 fragment。 +/// +/// H5 的 URL 上带着 `ticket`(见 10),整条打出去等于打 token。 +/// 解析失败时**整条抹掉**而不是原样返回——解析不出来的 URL 更可疑,不是更安全。 +/// +/// 实现注意:13 §二给的 `uri.replace(query: '')` 会留下一个空的 `?`(连同 +/// `fragment: ''` 会得到 `...?#`),所以这里直接重建 Uri。 +String scrubUrl(String url) { + final Uri? uri = Uri.tryParse(url); + if (uri == null) return redacted; + return Uri( + scheme: uri.hasScheme ? uri.scheme : null, + host: uri.host.isEmpty ? null : uri.host, + port: uri.hasPort ? uri.port : null, + path: uri.path, + ).toString(); +} diff --git a/packages/core_logging/lib/src/sentry_crash_reporter.dart b/packages/core_logging/lib/src/sentry_crash_reporter.dart new file mode 100644 index 0000000..706f464 --- /dev/null +++ b/packages/core_logging/lib/src/sentry_crash_reporter.dart @@ -0,0 +1,71 @@ +/// `CrashReporter` 的 Sentry 实现。来源:13 §一。 +/// +/// **本文件是全仓库唯一允许 `import 'package:sentry_flutter/...'` 的地方** +/// (另加 app/ 里的 `SentryFlutter.init`)。 +library; + +import 'dart:async'; + +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'crash_reporter.dart'; + +/// 把 [CrashReporter] 转接到 Sentry SDK。 +/// +/// 所有方法都是"发射后不管":上报本身绝不能阻塞或影响业务流程, +/// 因此统一 `unawaited` 并吞掉自身异常。 +class SentryCrashReporter implements CrashReporter { + /// 创建一个转接到全局 `Sentry` 的上报器。 + const SentryCrashReporter(); + + @override + void setUser(String userId) => _configure((Scope scope) async { + // 只传 ID。手机号、姓名、IP 一律不带(sendDefaultPii 也已关闭)。 + await scope.setUser(SentryUser(id: userId)); + }); + + @override + void setTag(String key, String value) => _configure((Scope scope) => scope.setTag(key, value)); + + @override + void clearUser() => _configure((Scope scope) async { + await scope.setUser(null); + await scope.removeTag('storeId'); + await scope.removeTag('roleCode'); + }); + + @override + void leaveBreadcrumb(String message) { + unawaited(Sentry.addBreadcrumb(Breadcrumb(message: message)).catchError((_) {})); + } + + @override + void report( + Object error, + StackTrace? stack, { + Map extra = const {}, + }) { + unawaited( + Sentry.captureException( + error, + stackTrace: stack, + withScope: (Scope scope) async { + if (extra.isNotEmpty) { + // traceId 放这里——一条崩溃能直接关联到后端 ELK 里的那次请求。 + await scope.setContexts('app_extra', extra); + } + }, + ).then((_) {}).catchError((_) {}), + ); + } + + void _configure(FutureOr Function(Scope) callback) { + unawaited(() async { + try { + await Sentry.configureScope(callback); + } on Object { + // 上报链路自身的异常不能外溢到业务流程。 + } + }()); + } +} diff --git a/packages/core_logging/lib/src/sentry_scrubber.dart b/packages/core_logging/lib/src/sentry_scrubber.dart new file mode 100644 index 0000000..f42eae7 --- /dev/null +++ b/packages/core_logging/lib/src/sentry_scrubber.dart @@ -0,0 +1,43 @@ +/// Sentry 的两个脱敏钩子。来源:13 §二「脱敏」。 +/// +/// 这两个钩子是 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` +/// 的实现,**一个都不能漏**:Sentry 默认会自动记录所有 HTTP 请求作为面包屑, +/// 我们在 05/10 里辛苦保证的"URL 不落日志"会被这条默认行为绕过去—— +/// 它不走我们的 `AppLogger`。 +library; + +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'log_buffer.dart'; +import 'scrubber.dart'; + +/// 面包屑脱敏:剥掉 URL 的 query(里面可能带 `ticket` / `token`)。 +Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) { + if (crumb == null) return null; + final Object? url = crumb.data?['url']; + if (url is String) { + crumb.data?['url'] = scrubUrl(url); + } + return crumb; +} + +/// 事件脱敏 + 挂载"黑匣子"。 +/// +/// 返回的 `beforeSend` 闭包做两件事: +/// 1. 把环形缓冲里**最近 30 条**日志作为 context 附上——不是全部 500 条, +/// 单个事件有体积上限,超了整条事件会被丢弃; +/// 2. 剥掉请求 URL 的 query。 +BeforeSendCallback buildScrubEvent(LogBuffer buffer) { + return (SentryEvent event, Hint hint) { + final SentryRequest? request = event.request; + final String? url = request?.url; + if (request != null && url != null) { + request.url = scrubUrl(url); + } + + return event + ..contexts['app_logs'] = { + 'recent': buffer.recent().map((LogRecord r) => r.toString()).toList(growable: false), + }; + }; +} diff --git a/packages/core_logging/lib/src/test_exception.dart b/packages/core_logging/lib/src/test_exception.dart new file mode 100644 index 0000000..d54e2fa --- /dev/null +++ b/packages/core_logging/lib/src/test_exception.dart @@ -0,0 +1,17 @@ +/// 上报链路的自检入口。来源:13 §一「验证接入真的成功了」。 +library; + +import 'package:core_foundation/core_foundation.dart'; + +/// 崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 +/// App 很稳定。所以每次发版前在 dev 上调一次这个函数,确认 Android 和 iOS +/// 都能在 Sentry 里看到,**并且堆栈是可读的 Dart 文件名行号而不是 `_x12`**。 +/// +/// 只在 dev flavor 下可调;uat/prod 调用会直接抛 [StateError], +/// 这样误留在代码里的调用会在测试环境就暴露,而不是污染线上崩溃率。 +Never throwTestException(AppEnv env) { + if (env.flavor != AppFlavor.dev) { + throw StateError('throwTestException 只允许在 dev flavor 调用'); + } + throw Exception('Sentry 接入自检:这是一条人为触发的测试异常'); +} diff --git a/packages/core_logging/pubspec.yaml b/packages/core_logging/pubspec.yaml new file mode 100644 index 0000000..77d8cbc --- /dev/null +++ b/packages/core_logging/pubspec.yaml @@ -0,0 +1,21 @@ +name: core_logging +description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。 +publish_to: none +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + logger: ^2.7.0 + sentry_flutter: ^9.26.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_logging/test/core_logging_test.dart b/packages/core_logging/test/core_logging_test.dart new file mode 100644 index 0000000..eeef0df --- /dev/null +++ b/packages/core_logging/test/core_logging_test.dart @@ -0,0 +1,137 @@ +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_logging/core_logging.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('scrubber', () { + test('敏感 key 整体抹掉', () { + final Map out = scrubMap({ + 'accessToken': 'eyJhbGciOi...', + 'Authorization': 'Bearer abc', + 'ticket': 'T-123', + 'path': '/api/v1/stores', + }); + + expect(out['accessToken'], redacted); + expect(out['Authorization'], redacted); + expect(out['ticket'], redacted); + expect(out['path'], '/api/v1/stores'); + }); + + test('手机号走掩码而不是整体抹掉', () { + expect(scrubMap({'phone': '13812345678'})['phone'], '138****5678'); + expect(maskPhone('123'), '***'); + }); + + test('嵌套结构递归处理', () { + final Map out = scrubMap({ + 'user': {'userId': 'u1', 'password': 'p'}, + 'list': [ + {'refreshToken': 'r'}, + ], + }); + + final Map user = out['user']! as Map; + expect(user['userId'], 'u1'); + expect(user['password'], redacted); + final List list = out['list']! as List; + expect((list.first! as Map)['refreshToken'], redacted); + }); + + test('原始 Map 不被修改', () { + final Map src = {'token': 'x'}; + scrubMap(src); + expect(src['token'], 'x'); + }); + + test('URL 去掉 query 和 fragment', () { + expect(scrubUrl('https://h5.example.com/p?ticket=abc&x=1#frag'), 'https://h5.example.com/p'); + }); + + test('URL 解析失败时整条抹掉,而不是原样返回', () { + expect(scrubUrl('http://[bad'), redacted); + }); + }); + + group('LogBuffer', () { + test('超出容量丢最旧的', () { + final LogBuffer buffer = LogBuffer(capacity: 3); + for (int i = 0; i < 5; i++) { + buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026))); + } + expect(buffer.length, 3); + expect(buffer.recent().map((LogRecord r) => r.message), ['2', '3', '4']); + }); + + test('recent 默认只取 30 条', () { + final LogBuffer buffer = LogBuffer(); + for (int i = 0; i < 500; i++) { + buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026))); + } + expect(buffer.length, 500); + expect(buffer.recent().length, 30); + expect(buffer.recent().first.message, '470'); + }); + }); + + group('LoggerAppLogger', () { + AppEnv envOf(AppFlavor flavor) => AppEnv( + flavor: flavor, + apiBaseUrl: 'https://example.com', + enableLog: true, + sentryDsn: '', + h5AllowedHosts: const {}, + ); + + test('prod 只收 warning 及以上', () { + final LogBuffer buffer = LogBuffer(); + final AppLogger log = LoggerAppLogger(env: envOf(AppFlavor.prod), buffer: buffer); + + log + ..d('debug') + ..i('info') + ..w('warn') + ..e('error'); + + expect(buffer.recent().map((LogRecord r) => r.message), ['warn', 'error']); + }); + + test('dev 收全部级别', () { + final LogBuffer buffer = LogBuffer(); + LoggerAppLogger(env: envOf(AppFlavor.dev), buffer: buffer) + ..d('debug') + ..i('info'); + + expect(buffer.length, 2); + }); + + test('结构化 data 进缓冲前已脱敏', () { + final LogBuffer buffer = LogBuffer(); + LoggerAppLogger( + env: envOf(AppFlavor.uat), + buffer: buffer, + ).i('login', data: {'phone': '13812345678'}); + + expect(buffer.recent().single.message, contains('138****5678')); + expect(buffer.recent().single.message, isNot(contains('13812345678'))); + }); + }); + + group('throwTestException', () { + AppEnv envOf(AppFlavor flavor) => AppEnv( + flavor: flavor, + apiBaseUrl: 'https://example.com', + enableLog: false, + sentryDsn: '', + h5AllowedHosts: const {}, + ); + + test('dev 抛测试异常', () { + expect(() => throwTestException(envOf(AppFlavor.dev)), throwsA(isA())); + }); + + test('prod 拒绝调用', () { + expect(() => throwTestException(envOf(AppFlavor.prod)), throwsA(isA())); + }); + }); +} diff --git a/packages/core_network/analysis_options.yaml b/packages/core_network/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_network/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_network/lib/core_network.dart b/packages/core_network/lib/core_network.dart new file mode 100644 index 0000000..ddda8a5 --- /dev/null +++ b/packages/core_network/lib/core_network.dart @@ -0,0 +1,14 @@ +/// 网络层。来源:conti-docs/05-networking.md。 +/// +/// 对外只暴露 [ApiClient] 和 `apiClientProvider`——**repository 一律注入 +/// ApiClient,不注入 Dio**(05)。`dioProvider` 也导出,但只给需要直接持有 +/// Dio 的极少数场景(目前没有)。 +/// +/// 依赖约束(01):`core_network → core_auth` 是允许的三条 core 互依例外之一; +/// 其余跨包需要(日志、设备信息)走 `src/ports.dart` 的接口反转。 +library; + +export 'src/api_client.dart'; +export 'src/paging.dart'; +export 'src/ports.dart'; +export 'src/providers.dart'; diff --git a/packages/core_network/lib/src/api_client.dart b/packages/core_network/lib/src/api_client.dart new file mode 100644 index 0000000..6203e2f --- /dev/null +++ b/packages/core_network/lib/src/api_client.dart @@ -0,0 +1,99 @@ +/// 仓库唯一对外的 HTTP 出口。来源:conti-docs/05-networking.md。 +library; + +import 'dart:io'; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; + +/// 包在 [Dio] 外面的一层。 +/// +/// --------------------------------------------------------------------------- +/// 为什么需要它:**拦截器没有办法让 `dio.get()` 抛出 [AppException]**。 +/// dio 的错误通道只认 [DioException],我们的 [AppException] 只能挂在它的 +/// `error` 字段上。repository 直接调 `dio.get()` 的话,业务层写 +/// +/// try { ... } on UnauthorizedException { ... } +/// +/// 永远进不来——实际抛出来的仍然是 [DioException]。 +/// +/// 所以在出口处把 `DioException.error` 拆出来重抛。 +/// +/// **规则:repository 一律注入 [ApiClient],不注入 [Dio]。** 全仓库只有 +/// core_network 内部和 core_auth 的裸 Dio 会直接碰 [Dio] 类型。 +/// --------------------------------------------------------------------------- +class ApiClient { + /// [dio] 由 `dioProvider` 提供。 + ApiClient(this._dio); + + final Dio _dio; + + /// GET。 + Future get(String path, {Map? query, CancelToken? cancelToken}) => + _run(() => _dio.get(path, queryParameters: query, cancelToken: cancelToken)); + + /// POST。 + Future post(String path, {Object? data, CancelToken? cancelToken}) => + _run(() => _dio.post(path, data: data, cancelToken: cancelToken)); + + /// PUT。 + Future put(String path, {Object? data, CancelToken? cancelToken}) => + _run(() => _dio.put(path, data: data, cancelToken: cancelToken)); + + /// DELETE。 + Future delete(String path, {Object? data, CancelToken? cancelToken}) => + _run(() => _dio.delete(path, data: data, cancelToken: cancelToken)); + + /// 多文件上传。 + /// + /// 约定(05 §文件与图片上传): + /// - **上传前必须压缩**。门店员工直接拍的照片通常 3–8 MB,原图在门店 WiFi 下 + /// 大概率超时。统一压到长边 1600px / JPEG 80,超 2 MB 再降一档。 + /// - **进度必须可见**,否则用户会以为卡死反复点。 + /// - **失败要能单张重传**,所以 UI 的上传状态按单张维护,别整批重来。 + /// - [FormData] **不可重用**:它是流,重试必须重新构造,复用会报 + /// stream already listened——所以这个方法每次调用都自己建一个。 + Future upload( + String path, { + required List files, + Map? fields, + void Function(int sent, int total)? onProgress, + CancelToken? cancelToken, + }) async { + final FormData formData = FormData.fromMap({ + ...?fields, + 'files': [ + for (final File f in files) + await MultipartFile.fromFile(f.path, filename: p.basename(f.path)), + ], + }); + + return _run( + () => _dio.post( + path, + data: formData, + cancelToken: cancelToken, + onSendProgress: onProgress, + // 上传单独放宽:用全局的 30s 传几张原图会超。 + options: Options(sendTimeout: const Duration(minutes: 3)), + ), + ); + } + + Future _run(Future> Function() send) async { + try { + final Response res = await send(); + return res.data as T; + } on DioException catch (e, st) { + final Object? error = e.error; + // 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛。 + // Error.throwWithStackTrace 保留原始堆栈——否则上报到崩溃平台的堆栈会 + // 全部指向下面这一行,等于没有堆栈。 + if (error is AppException) { + Error.throwWithStackTrace(error, st); + } + Error.throwWithStackTrace(const NetworkException('网络异常,请稍后重试'), st); + } + } +} diff --git a/packages/core_network/lib/src/api_result_interceptor.dart b/packages/core_network/lib/src/api_result_interceptor.dart new file mode 100644 index 0000000..b35ad82 --- /dev/null +++ b/packages/core_network/lib/src/api_result_interceptor.dart @@ -0,0 +1,58 @@ +/// 后端统一响应包装的解包。来源:conti-docs/05-networking.md §后端契约。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; + +import 'ports.dart'; + +/// 把 `ApiResult { code, message, data, traceId }` 剥成里层的 `data`。 +/// +/// **解包只在这里做一次**,repository 拿到的 `response.data` 已经是 `data` 本身。 +class ApiResultInterceptor extends Interceptor { + /// [log] 见 [apiLogSinkProvider]。 + ApiResultInterceptor(this._log); + + final ApiLogSink _log; + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final Object? body = response.data; + + // 非 JSON 对象响应(如文件下载)不走解包。 + if (body is! Map || !body.containsKey('code')) { + handler.next(response); + return; + } + + // 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛。 + final int? code = (body['code'] as num?)?.toInt(); + final String? traceId = body['traceId'] as String?; + + // traceId 必须留存:用户报一个 traceId,后端就能在日志里定位这次请求 + // (backend 06/08)。成功失败都要打。URL 不带 query——H5 那类 URL 里有 ticket。 + final Uri uri = response.requestOptions.uri; + _log('[api] ${uri.origin}${uri.path} code=$code traceId=$traceId'); + + if (code == ApiCode.ok) { + response.data = body['data']; + handler.next(response); + return; + } + + // code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层。 + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: BusinessException( + code ?? -1, + (body['message'] as String?) ?? '请求失败', + traceId: traceId, + ), + ), + // callFollowingErrorInterceptor:让 ErrorMappingInterceptor 有机会放行它。 + true, + ); + } +} diff --git a/packages/core_network/lib/src/auth_interceptor.dart b/packages/core_network/lib/src/auth_interceptor.dart new file mode 100644 index 0000000..809c28f --- /dev/null +++ b/packages/core_network/lib/src/auth_interceptor.dart @@ -0,0 +1,73 @@ +/// 鉴权与 401 刷新。来源:conti-docs/05-networking.md §Token 刷新:必须串行,失败即登出。 +library; + +import 'package:core_auth/core_auth.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'providers.dart'; + +/// 附加 token;401 时刷新一次并重放原请求。 +/// +/// 串行化本身**不在这里**——它在 core_auth 的 [TokenRefresher] 里(共享在途 +/// Future)。这样即使将来多了一个走刷新的调用方,串行保证也只有一份实现。 +/// +/// 三条硬约束(后端 refresh token 一次性 + 重放即全量撤销): +/// 1. 绝不并发刷新,否则用户被全设备强制登出; +/// 2. 刷新失败不重试,直接登出; +/// 3. 刷新请求本身走裸 Dio,不经过本拦截器,否则无限递归。 +class AuthInterceptor extends Interceptor { + /// [ref] 用来读 token 与会话。 + AuthInterceptor(this._ref); + + final Ref _ref; + + /// 一次性重试标记。带着新 token 重放后又 401,说明不是 token 的问题,别再刷了。 + static const String _retriedKey = 'x-retried'; + + @override + Future onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final String? token = await _ref.read(tokenStorageProvider).readAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + Future onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode != 401) { + handler.next(err); + return; + } + + if (err.requestOptions.extra[_retriedKey] == true) { + await _logout(); + handler.next(err); + return; + } + + final TokenPair refreshed; + try { + refreshed = await _ref.read(tokenRefresherProvider).refresh(); + } on Object { + // 刷新失败 = refresh token 已失效。不重试——再试一次只会再触发一次 + // 重放判定,把用户的其他设备也一起踢掉。 + await _logout(); + handler.next(err); + return; + } + + try { + final RequestOptions options = err.requestOptions + ..extra[_retriedKey] = true + ..headers['Authorization'] = 'Bearer ${refreshed.accessToken}'; + handler.resolve(await _ref.read(dioProvider).fetch(options)); + } on DioException catch (e) { + handler.next(e); + } + } + + Future _logout() => + _ref.read(sessionProvider.notifier).logout(reason: LogoutReason.tokenExpired); +} diff --git a/packages/core_network/lib/src/error_mapping_interceptor.dart b/packages/core_network/lib/src/error_mapping_interceptor.dart new file mode 100644 index 0000000..76d7f48 --- /dev/null +++ b/packages/core_network/lib/src/error_mapping_interceptor.dart @@ -0,0 +1,58 @@ +/// 异常归一化。来源:conti-docs/05-networking.md §异常归一化。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; + +/// 把 [DioException] 统一转成 [AppException] 体系。 +/// +/// **必须排在拦截器链的最后**:它把所有还没被归一化的错误兜底成 [NetworkException], +/// 排在前面会把 [ApiResultInterceptor] 抛的 [BusinessException] 提前吃掉。 +class ErrorMappingInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 已经是 AppException 的直接放行,不要二次包装。 + if (err.error is AppException) { + handler.next(err); + return; + } + + final AppException mapped = switch (err.type) { + DioExceptionType.connectionTimeout => const NetworkException( + '网络超时,请检查网络后重试', + kind: NetworkErrorKind.connectTimeout, + ), + DioExceptionType.sendTimeout => const NetworkException( + '网络超时,请检查网络后重试', + kind: NetworkErrorKind.sendTimeout, + ), + DioExceptionType.receiveTimeout => const NetworkException( + '网络超时,请检查网络后重试', + kind: NetworkErrorKind.receiveTimeout, + ), + DioExceptionType.connectionError => const NetworkException( + '网络不可用,请检查网络后重试', + kind: NetworkErrorKind.noConnection, + ), + // 必须静默处理:用户返回上一页时在途请求被取消,弹提示是纯粹的噪音(12)。 + DioExceptionType.cancel => const RequestCancelledException(), + // 401 走到这里说明 AuthInterceptor 已经刷新失败并登出了,UI 不再重复提示。 + DioExceptionType.badResponse when err.response?.statusCode == 401 => + const UnauthorizedException(), + DioExceptionType.badResponse => ServerException( + '服务异常(${err.response?.statusCode})', + statusCode: err.response?.statusCode, + ), + _ => const NetworkException('网络异常,请稍后重试'), + }; + + handler.next( + DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: mapped, + ), + ); + } +} diff --git a/packages/core_network/lib/src/header_interceptor.dart b/packages/core_network/lib/src/header_interceptor.dart new file mode 100644 index 0000000..1687513 --- /dev/null +++ b/packages/core_network/lib/src/header_interceptor.dart @@ -0,0 +1,46 @@ +/// 统一请求头。来源:conti-docs/05-networking.md §统一请求头。 +library; + +import 'dart:io'; + +import 'package:core_auth/core_auth.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import 'ports.dart'; + +/// 给每个请求补上追踪、版本、平台、设备和门店头。 +class HeaderInterceptor extends Interceptor { + /// [ref] 用来读环境和会话。 + HeaderInterceptor(this._ref); + + final Ref _ref; + static const Uuid _uuid = Uuid(); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final ClientInfo info = _ref.read(clientInfoProvider); + options.headers.addAll({ + // 客户端生成,便于端到端串联。 + // **待与后端确认**:backend 06 说 traceId 由后端入口 filter 生成,约定是 + // 后端优先复用这个头、没有才自己生成,否则两边日志各用一套 ID 对不上。 + 'X-Trace-Id': _uuid.v4(), + 'X-App-Version': info.appVersion, + 'X-Platform': Platform.isIOS ? 'ios' : 'android', + 'X-Device-Id': info.deviceId, + }); + + // 用可空版:登录、拉门店列表这些请求本身就发生在选店之前, + // 用会 throw 的 currentStoreIdProvider 会直接把登录流程打死。 + final int? storeId = _ref.read(currentStoreIdOrNullProvider); + if (storeId != null) { + // 冗余信息:access token 的 claims 里已经有 storeId,后端以 token 为准。 + // 带这个头只为排查时能一眼看出客户端当时认为自己在哪个门店——两者不一致 + // 就说明切店后 token 没换,是个 bug 信号。 + options.headers['X-Store-Id'] = '$storeId'; + } + + handler.next(options); + } +} diff --git a/packages/core_network/lib/src/paging.dart b/packages/core_network/lib/src/paging.dart new file mode 100644 index 0000000..652bfae --- /dev/null +++ b/packages/core_network/lib/src/paging.dart @@ -0,0 +1,62 @@ +/// 分页契约。来源:conti-docs/02-layering.md。 +library; + +import 'package:flutter/foundation.dart'; + +/// 分页请求参数。 +@immutable +class PageQuery { + /// [page] 从 1 开始。 + const PageQuery({required this.page, this.size = 20}); + + /// 页码,从 1 开始。 + final int page; + + /// 每页条数。 + final int size; + + /// 转成 query 参数。**字段名待与后端对齐**(backend 06 的分页约定还没定)。 + Map toQuery() => {'page': page, 'size': size}; +} + +/// 分页结果。 +@immutable +class PageResult { + /// 构造。 + const PageResult({ + required this.items, + required this.total, + required this.page, + required this.hasMore, + }); + + /// 从 `{items, total, page, hasMore}` 结构解析。 + factory PageResult.fromJson( + Map json, + T Function(Map) itemFromJson, + ) { + final List raw = (json['items'] as List?) ?? const []; + return PageResult( + items: raw.map((dynamic e) => itemFromJson(e as Map)).toList(), + total: (json['total'] as num?)?.toInt() ?? 0, + page: (json['page'] as num?)?.toInt() ?? 1, + hasMore: json['hasMore'] as bool? ?? false, + ); + } + + /// 当前页数据。 + final List items; + + /// 总条数。 + final int total; + + /// 当前页码。 + final int page; + + /// 是否还有下一页。 + /// + /// **用后端下发的字段,不在客户端算**。02 里那份 + /// `items.length + (page - 1) * items.length < total` 的推算在最后一页 + /// 条数不满时会算错;README 的已解决项里后端已确认下发这个字段。 + final bool hasMore; +} diff --git a/packages/core_network/lib/src/ports.dart b/packages/core_network/lib/src/ports.dart new file mode 100644 index 0000000..2088b09 --- /dev/null +++ b/packages/core_network/lib/src/ports.dart @@ -0,0 +1,45 @@ +/// core_network 的对外端口。 +/// +/// 和 core_auth 的 `session_ports.dart` 是同一套思路:05 的伪代码里 +/// `HeaderInterceptor` 读了 `deviceIdProvider`、`ApiResultInterceptor` 收了一个 +/// `AppLogger`,但 01 只允许 `core_network → core_auth` 这一条 core 出边, +/// core_logging 不在其中。所以这里只声明"需要什么",由 app 层接上去。 +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 请求头里那几个和环境无关、只有运行时才知道的值。 +@immutable +class ClientInfo { + /// 构造。 + const ClientInfo({required this.appVersion, required this.deviceId}); + + /// 形如 `1.4.0+142`。 + final String appVersion; + + /// **安装级匿名 ID**:首次安装时生成的随机 UUID,存本地。 + /// + /// 绝不是 IMEI / IDFA / MAC / AndroidID——采集这些是合规红线(05 / 07 隐私清单)。 + final String deviceId; +} + +/// [ClientInfo] 的注入点。必须在 bootstrap 里 override。 +/// +/// 不给默认值:带着 `appVersion: 'unknown'` 上线,问题会表现为线上日志里 +/// 版本分布全糊成一团,等发现时已经排查了很久。 +final Provider clientInfoProvider = Provider( + (Ref ref) => throw UnimplementedError('clientInfoProvider 必须在 bootstrap() 里 override'), +); + +/// API 日志出口。 +typedef ApiLogSink = void Function(String message); + +/// [ApiLogSink] 的注入点。默认丢弃——测试里不用管。 +/// +/// app 层把它接到 core_logging 的 `AppLogger.d` 上。**接的时候注意 message 已经 +/// 是脱敏过的**:这里输出的只有 URL(不含 query)、code、traceId,不含请求体, +/// 也绝不含 `Authorization`(13 §脱敏)。 +final Provider apiLogSinkProvider = Provider( + (Ref ref) => (String message) {}, +); diff --git a/packages/core_network/lib/src/providers.dart b/packages/core_network/lib/src/providers.dart new file mode 100644 index 0000000..9461275 --- /dev/null +++ b/packages/core_network/lib/src/providers.dart @@ -0,0 +1,62 @@ +/// 拦截器链的组装。来源:conti-docs/05-networking.md §附录·拦截器链的组装。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_client.dart'; +import 'api_result_interceptor.dart'; +import 'auth_interceptor.dart'; +import 'error_mapping_interceptor.dart'; +import 'header_interceptor.dart'; +import 'ports.dart'; + +/// 全 App 唯一的 [Dio] 实例。 +/// +/// --------------------------------------------------------------------------- +/// **拦截器顺序不能改。** dio 的三个时机(onRequest / onResponse / onError) +/// 都是按注册顺序**正向**执行的,不是洋葱模型——这一点和很多人的直觉不同。 +/// +/// HeaderInterceptor 补 trace / 版本 / 平台 / 门店头 +/// LogInterceptor 仅 enableLog;prod 绝不能开(13) +/// AuthInterceptor 必须在 ErrorMapping 之前,才能在 401 被归一化成 +/// UnauthorizedException **之前**先尝试刷新 +/// ApiResultInterceptor 必须在 ErrorMapping 之前,它抛的 BusinessException +/// 需要能被后者识别并放行 +/// ErrorMappingInterceptor 兜底,必须最后 +/// +/// 顺序取自 05 的附录(正文那份和附录不一致,以附录为准,见 SCAFFOLD-NOTES)。 +/// --------------------------------------------------------------------------- +final Provider dioProvider = Provider((Ref ref) { + final AppEnv env = ref.watch(appEnvProvider); + + final Dio dio = Dio( + BaseOptions( + baseUrl: env.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + // 上传单独放宽到 3 分钟,见 ApiClient.upload。 + sendTimeout: const Duration(seconds: 30), + ), + ); + + dio.interceptors.addAll([ + HeaderInterceptor(ref), + // responseBody: false —— 响应体里可能有手机号、地址这类个人信息, + // 而且日志只在 dev/uat 开。requestHeader 里的 Authorization 由 + // LogInterceptor 原样打印,所以 prod 必须关掉整条(env.enableLog == false)。 + if (env.enableLog) LogInterceptor(responseBody: false), + AuthInterceptor(ref), + ApiResultInterceptor(ref.watch(apiLogSinkProvider)), + ErrorMappingInterceptor(), + ]); + + ref.onDispose(dio.close); + return dio; +}); + +/// repository 唯一该注入的东西。 +final Provider apiClientProvider = Provider( + (Ref ref) => ApiClient(ref.watch(dioProvider)), +); diff --git a/packages/core_network/pubspec.yaml b/packages/core_network/pubspec.yaml new file mode 100644 index 0000000..53d165e --- /dev/null +++ b/packages/core_network/pubspec.yaml @@ -0,0 +1,24 @@ +name: core_network +description: dio 封装。拦截器链、ApiResult 解包、错误映射,以及唯一对外的 ApiClient。 +publish_to: none +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + dio: ^5.11.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + path: ^1.9.1 + uuid: ^4.5.1 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.5 diff --git a/packages/core_network/test/core_network_test.dart b/packages/core_network/test/core_network_test.dart new file mode 100644 index 0000000..59bfb81 --- /dev/null +++ b/packages/core_network/test/core_network_test.dart @@ -0,0 +1,121 @@ +// core_network 的高价值断言: +// 1. ApiClient 真的把 AppException 从 DioException 的壳里抛出来了—— +// 这条不成立的话,业务层所有 `on BusinessException` 都是死代码。 +// 2. code != 0 不会以"成功响应"的形态流到业务层。 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_network/core_network.dart'; +import 'package:core_network/src/api_result_interceptor.dart'; +import 'package:core_network/src/error_mapping_interceptor.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 直接吐一段 JSON 的 adapter,省掉起 http server。 +class _StubAdapter implements HttpClientAdapter { + _StubAdapter(this.status, this.body); + + int status; + Map body; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => ResponseBody.fromString( + jsonEncode(body), + status, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + + @override + void close({bool force = false}) {} +} + +void main() { + late _StubAdapter adapter; + late ApiClient api; + + setUp(() { + adapter = _StubAdapter(200, {}); + final Dio dio = Dio(BaseOptions(baseUrl: 'https://example.test')) + ..httpClientAdapter = adapter + ..interceptors.addAll([ + ApiResultInterceptor((String _) {}), + ErrorMappingInterceptor(), + ]); + api = ApiClient(dio); + }); + + test('code == 0 时外层包装被剥掉,repository 拿到的就是 data 本身', () async { + adapter.body = { + 'code': 0, + 'message': 'ok', + 'traceId': 't-1', + 'data': {'storeId': 7}, + }; + + final Map data = await api.get>('/x'); + expect(data, {'storeId': 7}); + }); + + test('code != 0 抛 BusinessException 而不是当成功返回,且 traceId 留存', () async { + adapter.body = { + 'code': 40001, + 'message': '门店不可访问', + 'traceId': 't-2', + 'data': null, + }; + + await expectLater( + api.get>('/x'), + throwsA( + isA() + .having((BusinessException e) => e.code, 'code', 40001) + .having((BusinessException e) => e.message, 'message', '门店不可访问') + // 用户报一个 traceId 后端就能定位这次请求,丢了就没法排查。 + .having((BusinessException e) => e.traceId, 'traceId', 't-2'), + ), + ); + }); + + test('5xx 归一化成 ServerException——业务层只认 AppException 体系', () async { + adapter + ..status = 500 + ..body = {'error': 'boom'}; + + await expectLater( + api.get>('/x'), + throwsA( + isA().having((ServerException e) => e.statusCode, 'statusCode', 500), + ), + ); + }); + + test('取消抛 RequestCancelledException,UI 必须静默处理', () async { + final CancelToken token = CancelToken(); + final Future> future = api.get>( + '/x', + cancelToken: token, + ); + token.cancel(); + + await expectLater(future, throwsA(isA())); + }); + + test('非 ApiResult 结构(文件下载等)原样透传,不被误解包', () async { + adapter.body = { + 'items': [1, 2], + }; + + final Map data = await api.get>('/x'); + expect(data, { + 'items': [1, 2], + }); + }); +} diff --git a/packages/core_router/analysis_options.yaml b/packages/core_router/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_router/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_router/lib/core_router.dart b/packages/core_router/lib/core_router.dart new file mode 100644 index 0000000..8240fe7 --- /dev/null +++ b/packages/core_router/lib/core_router.dart @@ -0,0 +1,19 @@ +/// 路由聚合层。来源:conti-docs/04-routing.md。 +/// +/// --------------------------------------------------------------------------- +/// **本包 re-export `go_router`**,`feature_*` 一律 `import 'package:core_router/core_router.dart'` +/// 拿 `GoRoute` / `context.go` 等类型,pubspec 里**不写 go_router**。 +/// +/// 这样将来换路由库(或 go_router 出 breaking change)时,改动收敛在这一个包 +/// 里,而不是 20 个 feature 的 import 语句。 +/// --------------------------------------------------------------------------- +library; + +export 'package:go_router/go_router.dart'; + +export 'src/app_router.dart'; +export 'src/menu_route_map.dart'; +export 'src/pages.dart'; +export 'src/ports.dart'; +export 'src/redirect.dart'; +export 'src/route_paths.dart'; diff --git a/packages/core_router/lib/src/app_router.dart b/packages/core_router/lib/src/app_router.dart new file mode 100644 index 0000000..2a0c2d1 --- /dev/null +++ b/packages/core_router/lib/src/app_router.dart @@ -0,0 +1,90 @@ +/// GoRouter 实例。来源:conti-docs/04-routing.md。 +library; + +import 'package:core_auth/core_auth.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'pages.dart'; +import 'ports.dart'; +import 'redirect.dart'; +import 'route_paths.dart'; + +/// 全局 Navigator key。顶层 dialog / 无 context 跳转需要它。 +final GlobalKey rootNavigatorKey = GlobalKey(); + +/// 全 App 唯一的 [GoRouter]。 +/// +/// --------------------------------------------------------------------------- +/// **`GoRouter` 实例不能因为登录态变化被重建。** 重建会丢掉整个导航栈——用户 +/// 在三级页面上 token 刷新了一下,就被弹回首页。 +/// +/// 所以: +/// - `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(watch 会让这个 +/// Provider 本身重建)。 +/// - 订阅由外面的 `ref.listen` 负责,变化时调 `router.refresh()` 只重跑一次 +/// `redirect`,导航栈保留。 +/// - 用 `ref.listen` 而不是 `refreshListenable`:登录态本身是 Riverpod +/// provider,用 `refreshListenable` 还要额外包一层 `ChangeNotifier`。 +/// - `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`, +/// 不 dispose 在热重载和测试里会泄漏。 +/// +/// 路由表本身**不在这里写死**:feature 路由由 `appRoutesProvider` 注入, +/// 否则 core_router 就得依赖每一个 feature_*,违反 01 的分层。 +/// --------------------------------------------------------------------------- +final Provider goRouterProvider = Provider((Ref ref) { + final GoRouter router = GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: AppRoutes.splash, + observers: ref.read(navigatorObserversProvider), + redirect: (BuildContext context, GoRouterState state) => + _redirect(ref, state.matchedLocation, state.uri), + errorBuilder: (BuildContext context, GoRouterState state) { + // 上报:能直接暴露出后端下发了 App 不支持的菜单。 + ref.read(routeReporterProvider).onRouteNotFound(state.uri.toString()); + return RouteNotFoundPage(location: state.uri.toString()); + }, + routes: [ + GoRoute( + path: AppRoutes.splash, + builder: (BuildContext context, GoRouterState state) => const SplashPage(), + ), + ...ref.read(appRoutesProvider), + ], + ); + + AppSession? previous = ref.read(sessionProvider).value; + ref.listen>(sessionProvider, ( + AsyncValue? _, + AsyncValue next, + ) { + final AppSession? before = previous; + final AppSession? after = next.value; + previous = after; + + // 11 §切店级联的第 6 步:切店成功后清空导航栈回工作台。 + // + // 这一步文档写在 SessionNotifier.switchStore 里,但 core_auth 不能依赖 + // core_router(不是 01 允许的那三条边),所以反过来由这里监听落地。 + // 理由见 04 §门店切换后的路由重置:用户在 A 门店的 + // /purchase/orders/123 切到 B 门店,这个订单在 B 门店可能不存在, + // 或者更糟——存在但是另一张单。 + if (before is SessionActive && + after is SessionActive && + before.store.storeId != after.store.storeId) { + // go 而不是 push:替换整个栈。 + router.go(AppRoutes.home); + return; + } + router.refresh(); + }); + + ref.onDispose(router.dispose); + return router; +}); + +String? _redirect(Ref ref, String matchedLocation, Uri uri) { + // read 不是 watch:这里只要当前值,订阅由上面的 listen 负责。 + return resolveRedirect(ref.read(sessionProvider).value, matchedLocation, uri); +} diff --git a/packages/core_router/lib/src/menu_route_map.dart b/packages/core_router/lib/src/menu_route_map.dart new file mode 100644 index 0000000..b6ab83c --- /dev/null +++ b/packages/core_router/lib/src/menu_route_map.dart @@ -0,0 +1,35 @@ +/// 后端动态菜单 code → 本地路由的映射。来源:conti-docs/04-routing.md。 +library; + +import 'route_paths.dart'; + +/// 菜单 code → 路由路径。 +/// +/// --------------------------------------------------------------------------- +/// PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但 +/// **路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发),所以中间 +/// 需要这张表。 +/// +/// **`code` 一旦定义就不能改含义**——改了等于老版本 App 跳错页面。新增功能 +/// 只能加新 code。这条要在后端接口评审时对齐。 +/// +/// TODO(backend): 目前只有 04 举例的三条,完整菜单 code 表待后端下发后补齐。 +/// --------------------------------------------------------------------------- +const Map menuRouteMap = { + 'PURCHASE_ORDER': '/purchase/orders', + 'INVENTORY_CHECK': '/inventory/check', + // H5 承载的功能也走这张表,形态是 /webview?target=,不是裸 URL。 + 'QUOTE_ORDER': '${AppRoutes.webview}?target=QUOTE_ORDER', +}; + +/// 解析菜单 code。未知 code 返回 null。 +/// +/// --------------------------------------------------------------------------- +/// **未知 code 的处理:隐藏该菜单项 + 上报 `menu_code_unsupported`(带 code 和 +/// App 版本)。调用方负责这两件事**——本函数是纯的,没有上报通道。 +/// +/// 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全 +/// 不需要这个新功能的门店是骚扰。隐藏 + 上报能让我们从数据上看到"有多少用户 +/// 因为版本旧看不到新功能",需要推升级时再针对性推。 +/// --------------------------------------------------------------------------- +String? resolveMenuRoute(String code) => menuRouteMap[code]; diff --git a/packages/core_router/lib/src/pages.dart b/packages/core_router/lib/src/pages.dart new file mode 100644 index 0000000..acc26f8 --- /dev/null +++ b/packages/core_router/lib/src/pages.dart @@ -0,0 +1,52 @@ +/// 路由兜底页与启动页。来源:conti-docs/04-routing.md §errorBuilder 是必须的。 +library; + +import 'package:flutter/material.dart'; + +/// 未注册路径的兜底页。 +/// +/// --------------------------------------------------------------------------- +/// 不写 `errorBuilder`,go_router 会显示一个英文的默认错误页——对门店一线员工 +/// 来说等于崩溃。 +/// +/// 触发场景:深链接拼错、后端下发了 App 还不认识的菜单 code、H5 回跳的 URL +/// 有问题。所以文案指向"升级 App"而不是"稍后重试"。 +/// --------------------------------------------------------------------------- +class RouteNotFoundPage extends StatelessWidget { + /// [location] 只用于开发期排查,不展示给用户。 + const RouteNotFoundPage({required this.location, super.key}); + + /// 出问题的路径。 + final String location; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('页面不存在')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('页面不存在', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text('请检查是否需要升级 App', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ), + ); +} + +/// 会话恢复期间的占位页。 +/// +/// 冷启动要先读 token、拉用户、拉门店(11),这段时间还不知道该去登录页还是 +/// 工作台。停在这里,由 `redirect` 在会话就绪后把用户送走。 +class SplashPage extends StatelessWidget { + /// 构造。 + const SplashPage({super.key}); + + @override + Widget build(BuildContext context) => + const Scaffold(body: Center(child: CircularProgressIndicator())); +} diff --git a/packages/core_router/lib/src/ports.dart b/packages/core_router/lib/src/ports.dart new file mode 100644 index 0000000..5977f95 --- /dev/null +++ b/packages/core_router/lib/src/ports.dart @@ -0,0 +1,51 @@ +/// core_router 向外部索取的东西。 +/// +/// --------------------------------------------------------------------------- +/// 依赖反转(同 core_auth/src/session_ports.dart、core_network/src/ports.dart)。 +/// +/// 01 规定 `core_router` 只能依赖 `core_auth`。但它需要两样东西是别处的: +/// - **各 feature 的路由**(在 `feature_*` 里,core_* 不能依赖 feature_*) +/// - **上报通道**(在 core_logging / core_analytics 里,不是允许的依赖边) +/// +/// 所以在这里声明接口和 provider,由 `app/bootstrap.dart` 统一 override。 +/// --------------------------------------------------------------------------- +library; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 全 App 的路由表。 +/// +/// core_router 自己只提供 `/splash`;`/login`、`/home` 等由各 feature 的 +/// `buildXxxRoutes()` 产出,在 `app/` 里拼好后 override 进来。 +/// +/// **默认是空表**——不 override 的话除了启动页什么都打不开,会立刻在开发期 +/// 暴露出来,比默默跑起来一个没有页面的 App 好。 +final Provider> appRoutesProvider = Provider>( + (Ref ref) => const [], +); + +/// 导航监听器。`app/` 里注入面包屑上报的那个(13)。 +final Provider> navigatorObserversProvider = + Provider>((Ref ref) => const []); + +/// 路由异常上报。 +abstract interface class RouteReporter { + /// 命中 `errorBuilder`(未注册的路径)。 + /// + /// 这个上报很有价值:能直接暴露出后端下发了 App 不支持的菜单。 + void onRouteNotFound(String location); +} + +class _NoopRouteReporter implements RouteReporter { + const _NoopRouteReporter(); + + @override + void onRouteNotFound(String location) {} +} + +/// 由 `app/` override 成真实实现。 +final Provider routeReporterProvider = Provider( + (Ref ref) => const _NoopRouteReporter(), +); diff --git a/packages/core_router/lib/src/redirect.dart b/packages/core_router/lib/src/redirect.dart new file mode 100644 index 0000000..e535890 --- /dev/null +++ b/packages/core_router/lib/src/redirect.dart @@ -0,0 +1,54 @@ +/// 登录态 → 目标路径的纯函数。来源:conti-docs/04-routing.md。 +/// +/// 单独拎出来是为了能不启 [GoRouter]、不启 `ProviderContainer` 就测—— +/// 这段分支是全 App 唯一决定"用户能不能进业务页"的地方,值得有直接的断言。 +library; + +import 'package:core_auth/core_auth.dart'; + +import 'route_paths.dart'; + +/// 返回需要强制跳转的路径;返回 null 表示放行当前路径。 +/// +/// [session] 为 null 表示 `sessionProvider` 还没产出第一个值(冷启动瞬间)。 +String? resolveRedirect(AppSession? session, String matchedLocation, Uri uri) { + final bool atSplash = matchedLocation == AppRoutes.splash; + final bool atLogin = matchedLocation == AppRoutes.login; + final bool atStorePicker = matchedLocation == AppRoutes.storePicker; + + if (session == null) { + return atSplash ? null : AppRoutes.splash; + } + + switch (session) { + // 会话还没恢复完(冷启动读 token → 拉用户 → 拉门店):停在启动页。 + // 这时候放行到任何页面都是错的——业务页面会立刻用一个还不存在的门店 ID + // 去发请求。 + case SessionLoading(): + return atSplash ? null : AppRoutes.splash; + + case SessionUnauthenticated(): + if (atLogin) { + return null; + } + // 带上原目标,登录成功后回跳。启动页不值得回跳。 + return atSplash + ? AppRoutes.login + : '${AppRoutes.login}?from=${Uri.encodeComponent(uri.toString())}'; + + // 已登录但还没选店。**不能放行到业务页**:没有门店上下文, + // currentStoreIdProvider 会直接 throw(11)。 + case SessionAwaitingStore(): + return atStorePicker ? null : AppRoutes.storePicker; + + case SessionActive(): + if (atSplash || atStorePicker) { + return AppRoutes.home; + } + if (atLogin) { + final String? from = uri.queryParameters['from']; + return (from == null || from.isEmpty) ? AppRoutes.home : Uri.decodeComponent(from); + } + return null; + } +} diff --git a/packages/core_router/lib/src/route_paths.dart b/packages/core_router/lib/src/route_paths.dart new file mode 100644 index 0000000..6b99420 --- /dev/null +++ b/packages/core_router/lib/src/route_paths.dart @@ -0,0 +1,41 @@ +/// 全 App 的路由路径常量。来源:conti-docs/04-routing.md。 +library; + +/// 路由路径。 +/// +/// 集中定义的理由:`redirect` 在 core_router、页面在各 feature,两边都要引用 +/// 同一批字符串。散着写字面量,改一个路径就会出现"跳转过去是 404"的活见鬼。 +abstract final class AppRoutes { + /// 启动页。会话恢复(读 token → 拉用户 → 拉门店)期间停在这里。 + static const String splash = '/splash'; + + /// 登录页。由 feature_auth 提供页面。 + static const String login = '/login'; + + /// 选店页。由 feature_auth 提供页面。 + static const String storePicker = '/store-picker'; + + /// 工作台。由 feature_home 提供页面。 + static const String home = '/home'; + + /// H5 容器。 + static const String webview = '/webview'; + + /// 拼一个 H5 路由。 + /// + /// --------------------------------------------------------------------------- + /// **只传 target,不传裸 URL**(04 §H5 页面的路由约定)。真实 URL 由 + /// core_webview 拿 `target` 去后端换票得到。 + /// + /// 如果路由里能直接塞 URL,任何能构造深链接的地方(推送、H5 内跳转、剪贴板) + /// 都能让 App 打开任意网页——这是一个明确的安全洞。`target` 是白名单枚举, + /// 能打开哪些页面由后端和 App 共同决定。 + /// --------------------------------------------------------------------------- + static String webviewFor(String target, {String? title}) { + final StringBuffer sb = StringBuffer('$webview?target=${Uri.encodeQueryComponent(target)}'); + if (title != null && title.isNotEmpty) { + sb.write('&title=${Uri.encodeQueryComponent(title)}'); + } + return sb.toString(); + } +} diff --git a/packages/core_router/pubspec.yaml b/packages/core_router/pubspec.yaml new file mode 100644 index 0000000..2b0a37a --- /dev/null +++ b/packages/core_router/pubspec.yaml @@ -0,0 +1,24 @@ +name: core_router +description: 路由聚合层。GoRouter 实例、登录态 redirect、菜单 code → 路由映射,并 re-export go_router 类型。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# core_router → core_auth 是 01 明确允许的三条 core 间依赖之一(redirect 需要登录态)。 +# 注意:本包**不**依赖任何 feature_*。feature 路由由 app/ 通过 appRoutesProvider 注入, +# 详见 lib/src/app_router.dart 顶部注释与 SCAFFOLD-NOTES.md §G。 +environment: + sdk: ^3.12.0 + +dependencies: + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + go_router: ^17.5.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_router/test/core_router_test.dart b/packages/core_router/test/core_router_test.dart new file mode 100644 index 0000000..b56651b --- /dev/null +++ b/packages/core_router/test/core_router_test.dart @@ -0,0 +1,102 @@ +// core_router 的高价值断言: +// 1. 未选店时**绝不能**放行到业务页——那边 currentStoreIdProvider 会直接 throw。 +// 2. 会话未就绪时停在启动页,不能提前放行。 +// 3. H5 路由里只有 target,永远不出现裸 URL(安全)。 + +import 'package:core_auth/core_auth.dart'; +import 'package:core_router/core_router.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const UserContext _user = UserContext( + userId: 'U1', + employeeId: 'E1', + phone: '13800000000', + roleCode: 'CLERK', + channel: 'APP', +); + +const StoreContext _store = StoreContext( + storeId: 7, + storeCode: 'S007', + storeName: '测试门店', + orgId: 1, +); + +void main() { + group('resolveRedirect', () { + test('会话未就绪时停在启动页,不提前放行到业务页', () { + expect(resolveRedirect(null, '/purchase/orders', Uri.parse('/purchase/orders')), '/splash'); + expect(resolveRedirect(null, '/splash', Uri.parse('/splash')), isNull); + expect(resolveRedirect(const SessionLoading(), '/home', Uri.parse('/home')), '/splash'); + }); + + test('未登录访问业务页 → 登录页并带上回跳目标', () { + final String? to = resolveRedirect( + const SessionUnauthenticated(), + '/purchase/orders', + Uri.parse('/purchase/orders?id=9'), + ); + expect(to, startsWith('/login?from=')); + expect(Uri.decodeComponent(Uri.parse(to!).queryParameters['from']!), '/purchase/orders?id=9'); + }); + + test('未登录停在登录页时不再跳转(否则死循环)', () { + expect( + resolveRedirect(const SessionUnauthenticated(), '/login', Uri.parse('/login')), + isNull, + ); + }); + + test('已登录但未选店时,任何业务页都被挡回选店页', () { + const AppSession awaiting = SessionAwaitingStore(user: _user, candidates: []); + // 这条是核心:放行过去 currentStoreIdProvider 会 throw(11)。 + expect(resolveRedirect(awaiting, '/home', Uri.parse('/home')), '/store-picker'); + expect(resolveRedirect(awaiting, '/store-picker', Uri.parse('/store-picker')), isNull); + }); + + group('已登录且已选店', () { + const AppSession active = SessionActive(user: _user, store: _store); + + test('停在启动页/选店页时进工作台', () { + expect(resolveRedirect(active, '/splash', Uri.parse('/splash')), '/home'); + expect(resolveRedirect(active, '/store-picker', Uri.parse('/store-picker')), '/home'); + }); + + test('登录页带 from 时回跳原目标,没有 from 时进工作台', () { + expect( + resolveRedirect( + active, + '/login', + Uri.parse('/login?from=${Uri.encodeComponent('/purchase/orders?id=9')}'), + ), + '/purchase/orders?id=9', + ); + expect(resolveRedirect(active, '/login', Uri.parse('/login')), '/home'); + }); + + test('业务页放行', () { + expect(resolveRedirect(active, '/purchase/orders', Uri.parse('/purchase/orders')), isNull); + }); + }); + }); + + group('菜单与 H5 路由', () { + test('未知菜单 code 返回 null,由调用方隐藏并上报', () { + expect(resolveMenuRoute('PURCHASE_ORDER'), '/purchase/orders'); + expect(resolveMenuRoute('SOMETHING_NEW_FROM_BACKEND'), isNull); + }); + + test('H5 路由只带 target,不出现裸 URL', () { + final String route = AppRoutes.webviewFor('QUOTE_ORDER', title: '报价开单'); + expect(route, startsWith('/webview?target=QUOTE_ORDER')); + expect(route, isNot(contains('http'))); + expect(Uri.parse(route).queryParameters['title'], '报价开单'); + }); + + test('菜单表里的 H5 项也是 target 形态', () { + for (final String path in menuRouteMap.values) { + expect(path, isNot(contains('http')), reason: '路由里塞裸 URL 是明确的安全洞'); + } + }); + }); +} diff --git a/packages/core_storage/analysis_options.yaml b/packages/core_storage/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_storage/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_storage/lib/core_storage.dart b/packages/core_storage/lib/core_storage.dart new file mode 100644 index 0000000..ab3cfe2 --- /dev/null +++ b/packages/core_storage/lib/core_storage.dart @@ -0,0 +1,11 @@ +/// 本地持久化。来源:conti-docs/06-local-storage.md。 +/// +/// 当前只有 KV 一档。**Drift 数据库暂缓**——骨架阶段没有任何业务表, +/// 见 pubspec.yaml 顶部的说明。 +/// +/// 边界约定:**token / refresh token 不在这里**——secure storage 归 +/// `core_auth` 独占(06 §决策表下的说明)。这里存的都是非敏感数据。 +library; + +export 'src/prefs.dart'; +export 'src/providers.dart'; diff --git a/packages/core_storage/lib/src/prefs.dart b/packages/core_storage/lib/src/prefs.dart new file mode 100644 index 0000000..3ef0903 --- /dev/null +++ b/packages/core_storage/lib/src/prefs.dart @@ -0,0 +1,68 @@ +/// 简单非敏感 KV 的统一封装。来源:06 §决策表第三档。 +library; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// 键名常量。 +/// +/// **命名约定:用户级的键一律以 [Prefs.userScopedPrefix] 开头。** +/// 登出时只清这些,App 级偏好(主题、是否看过引导页)保留——换人登录不该 +/// 把设备上的通用设置也重置掉。 +abstract final class PrefKeys { + /// 是否看过引导页。App 级,登出保留。 + static const String onboardingSeen = 'onboarding_seen'; + + /// 上次选中的门店 ID。用户级,登出必须清。 + static const String lastStoreId = '${Prefs.userScopedPrefix}last_store_id'; + + /// 是否已同意隐私政策。App 级——同意是设备行为,且埋点/崩溃 SDK 的 + /// 延迟初始化依赖它(见 13)。 + static const String privacyPolicyAccepted = 'privacy_policy_accepted'; +} + +/// `shared_preferences` 的唯一入口。 +/// +/// 这里存的都是**非敏感**数据。token / refresh token 走 `core_auth` 的 +/// secure storage,不允许出现在这里(06 §使用规则)。 +class Prefs { + /// [store] 默认用 `SharedPreferencesAsync`,测试可注入替身。 + Prefs([SharedPreferencesAsync? store]) : _store = store ?? SharedPreferencesAsync(); + + /// 用户级键的前缀。 + static const String userScopedPrefix = 'u_'; + + final SharedPreferencesAsync _store; + + /// 读一个字符串。 + Future getString(String key) => _store.getString(key); + + /// 写一个字符串。 + Future setString(String key, String value) => _store.setString(key, value); + + /// 读一个布尔值,缺省 [defaultValue]。 + Future getBool(String key, {bool defaultValue = false}) async => + await _store.getBool(key) ?? defaultValue; + + /// 写一个布尔值。 + Future setBool(String key, {required bool value}) => _store.setBool(key, value); + + /// 读一个整数。 + Future getInt(String key) => _store.getInt(key); + + /// 写一个整数。 + Future setInt(String key, int value) => _store.setInt(key, value); + + /// 删除一个键。 + Future remove(String key) => _store.remove(key); + + /// 登出时调用:只清 [userScopedPrefix] 前缀的键,保留 App 级偏好。 + /// + /// 用前缀而不是维护一张手写的键名清单——新增用户级配置项时只要遵守命名 + /// 约定就自动被清掉,手写清单一定会有人忘了加。 + Future clearUserScoped() async { + final Set keys = await _store.getKeys(); + for (final String key in keys.where((String k) => k.startsWith(userScopedPrefix))) { + await _store.remove(key); + } + } +} diff --git a/packages/core_storage/lib/src/providers.dart b/packages/core_storage/lib/src/providers.dart new file mode 100644 index 0000000..c9257ca --- /dev/null +++ b/packages/core_storage/lib/src/providers.dart @@ -0,0 +1,9 @@ +/// core_storage 的 Riverpod 接线。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'prefs.dart'; + +/// KV 存储的唯一入口。 +final Provider prefsProvider = Provider((Ref ref) => Prefs()); diff --git a/packages/core_storage/pubspec.yaml b/packages/core_storage/pubspec.yaml new file mode 100644 index 0000000..130e851 --- /dev/null +++ b/packages/core_storage/pubspec.yaml @@ -0,0 +1,27 @@ +name: core_storage +description: 本地持久化。当前只封装 SharedPreferences(KV),数据库层暂缓。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# 偏离 06 的说明:Drift 单库暂不落地。 +# 骨架阶段还没有任何需要本地缓存的业务表,先建库只会留下一个没人用、 +# 却要一直维护 schema 快照和迁移测试的空壳。06 §「所有业务缓存表必须带 +# storeId」「登出/切店清理策略」「migration 必须被验证」这三条规则在真的 +# 加第一张表时再一次性落地——那时才有东西可验证。 +# 连带收益:去掉 drift_dev 后 melos 可以留在 8.x(drift_dev 2.34.0 依赖 +# cli_util ^0.4.0,与 melos 8 的 ^0.5.0 互斥)。详见 SCAFFOLD-NOTES.md §A。 +environment: + sdk: ^3.12.0 + +dependencies: + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + shared_preferences: ^2.5.5 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_ui/analysis_options.yaml b/packages/core_ui/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_ui/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_ui/lib/core_ui.dart b/packages/core_ui/lib/core_ui.dart new file mode 100644 index 0000000..4de01de --- /dev/null +++ b/packages/core_ui/lib/core_ui.dart @@ -0,0 +1,9 @@ +/// 共享 UI 层。来源:conti-docs/12-error-and-api-contract.md §三、§四。 +/// +/// 这一层只放**与业务无关**的东西:主题、三态视图、错误文案映射。任何带 +/// 业务语义的 Widget(订单卡片、门店选择器)都属于对应的 `feature_*`。 +library; + +export 'src/error/error_presenter.dart'; +export 'src/error/error_view.dart'; +export 'src/theme/app_theme.dart'; diff --git a/packages/core_ui/lib/src/error/error_presenter.dart b/packages/core_ui/lib/src/error/error_presenter.dart new file mode 100644 index 0000000..7c005a7 --- /dev/null +++ b/packages/core_ui/lib/src/error/error_presenter.dart @@ -0,0 +1,97 @@ +/// 异常 → 用户可见文案的唯一映射点。来源:conti-docs/12-error-and-api-contract.md §三。 +library; + +import 'package:core_foundation/core_foundation.dart'; + +/// [ErrorPresenter.present] 的返回值。 +/// +/// 用 record 而不是类:这东西只在 build 方法里活几行,没有身份也没有行为。 +typedef ErrorDisplay = ({String title, String? detail, bool retryable, bool showTraceId}); + +/// 全 App 唯一的错误文案映射。 +/// +/// --------------------------------------------------------------------------- +/// **不要在 feature 里自己写 `if (e is XxxException)`。** 文案散在各处的结果是 +/// 同一个错误在订单页叫"网络开小差"、在首页叫"加载失败",用户反馈时对不上。 +/// +/// [AppException] 是 `sealed` 的,下面的 switch 是穷尽的——将来加一种异常类型, +/// 这里会编译报错,逼着人补文案,而不是悄悄落进"未知错误"。 +/// --------------------------------------------------------------------------- +abstract final class ErrorPresenter { + /// 把异常映射成一组展示参数。 + static ErrorDisplay present(AppException e) => switch (e) { + NetworkException(kind: NetworkErrorKind.noConnection) => ( + title: '网络未连接', + detail: '请检查网络后重试', + retryable: true, + showTraceId: false, + ), + NetworkException() => ( + title: '网络不太稳定', + detail: '请稍后重试', + retryable: true, + // 请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。 + showTraceId: false, + ), + ServerException() => ( + title: '系统繁忙', + detail: '请稍后重试', + retryable: true, + // 这正是 traceId 存在的意义:把一次投诉定位到一条服务端日志。 + showTraceId: true, + ), + // retryable 必须是 false:库存不足、订单已支付这类错误重试没有意义, + // 给一个重试按钮只会让用户反复点。 + BusinessException(:final String message) => ( + title: message, + detail: null, + retryable: false, + showTraceId: false, + ), + // 文档 12 的 switch 里漏了这一支,sealed 穷尽会直接编译不过。 + // 本地前置条件(如切店时有未完成的写操作)的 message 本身就是给用户看的。 + PreconditionException(:final String message) => ( + title: message, + detail: null, + retryable: false, + showTraceId: false, + ), + StorageException() => ( + title: '本地数据异常', + detail: '请重启 App', + retryable: false, + showTraceId: false, + ), + NativeException(code: NativeErrorCode.permissionDenied, :final String message) => ( + title: message, + detail: '可在系统设置中开启', + retryable: false, + showTraceId: false, + ), + NativeException(:final String message) => ( + title: message, + detail: null, + retryable: false, + showTraceId: false, + ), + // 不展示:登出流程本身会把用户送回登录页;取消是用户自己触发的。 + UnauthorizedException() || + RequestCancelledException() => (title: '', detail: null, retryable: false, showTraceId: false), + }; + + /// 这个错误是否应当**完全不出现在 UI 上**。 + /// + /// [RequestCancelledException]:用户返回上一页导致在途请求被取消, + /// 弹"请求已取消"是纯噪音。 + /// [UnauthorizedException]:登出跳转已经是最强的反馈了。 + static bool isSilent(Object error) => + error is UnauthorizedException || error is RequestCancelledException; + + /// 非 [AppException] 的兜底。 + /// + /// 正常情况下不该走到这里——网络层出口已经把一切归一化成 [AppException]。 + /// 走到这里说明是一个 bug(空指针、类型转换失败),文案上不能暴露技术细节。 + static ErrorDisplay presentUnknown(Object error) => error is AppException + ? present(error) + : (title: '出了点问题', detail: '请稍后重试', retryable: true, showTraceId: false); +} diff --git a/packages/core_ui/lib/src/error/error_view.dart b/packages/core_ui/lib/src/error/error_view.dart new file mode 100644 index 0000000..aa728a3 --- /dev/null +++ b/packages/core_ui/lib/src/error/error_view.dart @@ -0,0 +1,239 @@ +/// 三态视图与错误 Widget。来源:conti-docs/12-error-and-api-contract.md §三、§四。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'error_presenter.dart'; + +/// `AsyncValue` 的统一三态渲染。 +/// +/// --------------------------------------------------------------------------- +/// **每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理 +/// 不一致的根源**(12 §三)。所有页面级异步数据都走这里。 +/// +/// 内部统一处理: +/// - loading → 居中转圈 +/// - error → [ErrorPresenter.present] → 整页错误态 + 重试 +/// - [RequestCancelledException] / [UnauthorizedException] → **静默**,退回 loading 态 +/// - 空数据([isEmpty] 判定)→ 空态 +/// - 刷新失败但有旧数据 → 继续渲染旧数据(不把用户已经看到的内容换成错误页) +/// --------------------------------------------------------------------------- +class AsyncValueView extends StatelessWidget { + /// [data] 只在有数据时调用;[onRetry] 一般是 `() => ref.invalidate(xxxProvider)`。 + const AsyncValueView({ + required this.value, + required this.data, + this.onRetry, + this.isEmpty, + this.empty, + super.key, + }); + + /// 来自 `ref.watch(someProvider)`。 + final AsyncValue value; + + /// 有数据时的渲染。 + final Widget Function(T data) data; + + /// 重试回调。为 null 时错误态不显示重试按钮。 + final VoidCallback? onRetry; + + /// 判定"有数据但是空的"。默认不判定(即永远不显示空态)。 + final bool Function(T data)? isEmpty; + + /// 空态。不传时用一段默认文案。 + final Widget? empty; + + @override + Widget build(BuildContext context) { + // 注意顺序:先看有没有数据。刷新失败时 AsyncError 也可能带着上一次的 + // 数据(hasValue),这时候必须继续展示旧数据——把用户正在看的列表换成 + // 一整页错误,比什么都不做更糟。 + if (value.hasValue) { + final T current = value.value as T; + if (isEmpty?.call(current) ?? false) { + return empty ?? const _EmptyView(); + } + return data(current); + } + + if (value.hasError && !ErrorPresenter.isSilent(value.error!)) { + return ErrorView(error: value.error!, onRetry: onRetry); + } + + // 静默错误也走这里:用户看到的是"还在加载",而不是一个他不需要理解的错误。 + return const Center(child: CircularProgressIndicator()); + } +} + +/// 整页错误态。 +class ErrorView extends StatelessWidget { + /// [error] 通常是 `AsyncValue.error`,非 [AppException] 会走兜底文案。 + const ErrorView({required this.error, this.onRetry, super.key}); + + /// 原始错误对象。 + final Object error; + + /// 重试回调。 + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final ErrorDisplay display = ErrorPresenter.presentUnknown(error); + final String? traceId = error is AppException ? (error as AppException).traceId : null; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(display.title, style: Theme.of(context).textTheme.titleMedium), + if (display.detail != null) ...[ + const SizedBox(height: 8), + Text(display.detail!, style: Theme.of(context).textTheme.bodySmall), + ], + if (display.retryable && onRetry != null) ...[ + const SizedBox(height: 16), + FilledButton(onPressed: onRetry, child: const Text('重试')), + ], + // traceId 不印在主文案里——用户看到一串乱码只会更慌。 + // 折叠在「问题反馈」后面,客服话术是"把那串编号发给我"。 + if (display.showTraceId && traceId != null) ...[ + const SizedBox(height: 12), + _TraceIdSection(traceId: traceId), + ], + ], + ), + ), + ); + } +} + +/// 局部(tile 级)错误态。 +/// +/// 首页某个区块失败时用这个,**尺寸自适应,不撑破布局**——它会被塞进一个 +/// 高度有限的 tile 里,不能像 [ErrorView] 那样撑满。 +class TileErrorView extends StatelessWidget { + /// 构造。 + const TileErrorView({required this.error, this.onRetry, super.key}); + + /// 原始错误对象。 + final Object error; + + /// 重试回调。 + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final ErrorDisplay display = ErrorPresenter.presentUnknown(error); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + display.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (display.retryable && onRetry != null) + TextButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ); + } +} + +/// 「展示的是缓存数据」的顶部提示条。 +/// +/// 门店里网络不稳是常态,网络失败但本地有缓存时展示缓存 + 这条提示,比展示 +/// 一个错误页好得多。**但必须带时间戳**:展示旧数据却不告诉用户是旧的, +/// 比展示错误更危险——尤其是库存和价格(12 §四)。 +class StaleDataBanner extends StatelessWidget { + /// [updatedAt] 是缓存写入时间,必须真实。 + const StaleDataBanner({required this.updatedAt, this.onRefresh, super.key}); + + /// 缓存写入时间。 + final DateTime updatedAt; + + /// 刷新回调。 + final VoidCallback? onRefresh; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + '更新于 ${formatElapsed(updatedAt)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + if (onRefresh != null) TextButton(onPressed: onRefresh, child: const Text('刷新')), + ], + ), + ), + ); + } +} + +/// 把时间点格式化成"10 分钟前"这类相对文案。 +/// +/// [now] 只给测试用;生产代码不要传。 +String formatElapsed(DateTime updatedAt, {DateTime? now}) { + final Duration d = (now ?? DateTime.now()).difference(updatedAt); + if (d.inMinutes < 1) { + return '刚刚'; + } + if (d.inMinutes < 60) { + return '${d.inMinutes} 分钟前'; + } + if (d.inHours < 24) { + return '${d.inHours} 小时前'; + } + return '${d.inDays} 天前'; +} + +class _TraceIdSection extends StatefulWidget { + const _TraceIdSection({required this.traceId}); + + final String traceId; + + @override + State<_TraceIdSection> createState() => _TraceIdSectionState(); +} + +class _TraceIdSectionState extends State<_TraceIdSection> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + if (!_expanded) { + return TextButton( + onPressed: () => setState(() => _expanded = true), + child: const Text('问题反馈 ›'), + ); + } + return SelectableText(widget.traceId, style: Theme.of(context).textTheme.bodySmall); + } +} + +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) => + Center(child: Text('暂无数据', style: Theme.of(context).textTheme.bodySmall)); +} diff --git a/packages/core_ui/lib/src/theme/app_theme.dart b/packages/core_ui/lib/src/theme/app_theme.dart new file mode 100644 index 0000000..6f7708c --- /dev/null +++ b/packages/core_ui/lib/src/theme/app_theme.dart @@ -0,0 +1,28 @@ +/// 主题。 +/// +/// **这是一个占位实现。** conti-docs 的 `15-ui-design-system.md` 还没写,色板、 +/// 字号阶梯、间距规范都未定。这里只把结构搭出来(一个集中定义点 + 一个 +/// seed color),等设计规范落地后在这个文件里补,**不要在各 feature 里 +/// 自己 `ThemeData(...)`**——那正是这个文件存在的目的。 +library; + +import 'package:flutter/material.dart'; + +/// App 主题。 +abstract final class AppTheme { + /// TODO(design): 待 15-ui-design-system.md 确定品牌主色后替换。 + static const Color _seed = Color(0xFFFF6A13); + + /// 亮色主题。 + static ThemeData get light => _build(Brightness.light); + + /// 暗色主题。 + /// + /// 门店场景基本用不到,但 `MaterialApp` 需要一个,跟随系统即可。 + static ThemeData get dark => _build(Brightness.dark); + + static ThemeData _build(Brightness brightness) => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: _seed, brightness: brightness), + ); +} diff --git a/packages/core_ui/pubspec.yaml b/packages/core_ui/pubspec.yaml new file mode 100644 index 0000000..aaa88ac --- /dev/null +++ b/packages/core_ui/pubspec.yaml @@ -0,0 +1,19 @@ +name: core_ui +description: 共享 UI。主题、三态视图(AsyncValueView)、错误展示映射。 +publish_to: none +version: 0.1.0 +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_ui/test/core_ui_test.dart b/packages/core_ui/test/core_ui_test.dart new file mode 100644 index 0000000..541856a --- /dev/null +++ b/packages/core_ui/test/core_ui_test.dart @@ -0,0 +1,133 @@ +// core_ui 的高价值断言: +// 1. 取消/未授权必须静默——这两条一旦回归,用户每次返回上一页都会看到错误页。 +// 2. 刷新失败时旧数据不能被错误页顶掉。 +// 3. BusinessException 不给重试按钮。 + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('ErrorPresenter', () { + test('BusinessException 不可重试——重试一个"库存不足"没有意义', () { + final ErrorDisplay d = ErrorPresenter.present(const BusinessException(40001, '库存不足')); + expect(d.title, '库存不足'); + expect(d.retryable, isFalse); + expect(d.showTraceId, isFalse); + }); + + test('只有 ServerException 展示 traceId', () { + expect(ErrorPresenter.present(const ServerException('x')).showTraceId, isTrue); + // 请求没到后端,服务端日志里查不到这个 id。 + expect( + ErrorPresenter.present( + const NetworkException('x', kind: NetworkErrorKind.noConnection), + ).showTraceId, + isFalse, + ); + }); + + test('取消与未授权必须静默', () { + expect(ErrorPresenter.isSilent(const RequestCancelledException()), isTrue); + expect(ErrorPresenter.isSilent(const UnauthorizedException()), isTrue); + expect(ErrorPresenter.isSilent(const ServerException('x')), isFalse); + }); + + test('非 AppException 走兜底,不泄露技术细节', () { + final ErrorDisplay d = ErrorPresenter.presentUnknown(StateError('null check')); + expect(d.title, '出了点问题'); + }); + }); + + group('AsyncValueView', () { + testWidgets('error 态渲染文案与重试按钮', (WidgetTester tester) async { + await tester.pumpWidget( + _host( + AsyncValueView( + value: AsyncValue.error(const ServerException('x'), StackTrace.empty), + onRetry: () {}, + data: (int v) => Text('$v'), + ), + ), + ); + + expect(find.text('系统繁忙'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + }); + + testWidgets('取消错误不显示错误态', (WidgetTester tester) async { + await tester.pumpWidget( + _host( + AsyncValueView( + value: AsyncValue.error(const RequestCancelledException(), StackTrace.empty), + data: (int v) => Text('$v'), + ), + ), + ); + + expect(find.text('请求已取消'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('刷新失败但有旧数据时继续渲染旧数据,而不是换成整页错误', (WidgetTester tester) async { + // riverpod 在重建时会把上一次的值带进新的 AsyncError(hasValue 仍为 true)。 + // AsyncValueView 必须先看 hasValue——把用户正在看的列表换成一整页错误, + // 比什么都不做更糟。 + bool shouldFail = false; + final FutureProvider provider = FutureProvider((Ref ref) async { + if (shouldFail) { + throw const ServerException('x'); + } + return 7; + }); + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + // riverpod 3 默认 autoDispose,没有监听者的话读完就被回收了。 + container.listen>(provider, (_, _) {}); + + // runAsync:testWidgets 默认跑在 fake async 区里,真实的 Future 永远不会 + // 完成(会挂到 10 分钟超时)。碰真 provider 生命周期必须包这一层。 + final AsyncValue state = (await tester.runAsync(() async { + expect(await container.read(provider.future), 7); + shouldFail = true; + await expectLater(container.refresh(provider.future), throwsA(isA())); + return container.read(provider); + }))!; + + expect(state.hasError, isTrue); + expect(state.hasValue, isTrue, reason: '上一次的数据必须被保留'); + + await tester.pumpWidget( + _host(AsyncValueView(value: state, data: (int v) => Text('$v'))), + ); + expect(find.text('7'), findsOneWidget); + expect(find.text('系统繁忙'), findsNothing); + }); + + testWidgets('isEmpty 判定为真时走空态', (WidgetTester tester) async { + await tester.pumpWidget( + _host( + AsyncValueView>( + value: const AsyncValue>.data([]), + isEmpty: (List v) => v.isEmpty, + data: (List v) => Text('${v.length}'), + ), + ), + ); + + expect(find.text('暂无数据'), findsOneWidget); + }); + }); + + test('formatElapsed 给出人类可读的相对时间', () { + final DateTime now = DateTime(2026, 8, 17, 12); + expect(formatElapsed(now.subtract(const Duration(seconds: 30)), now: now), '刚刚'); + expect(formatElapsed(now.subtract(const Duration(minutes: 10)), now: now), '10 分钟前'); + expect(formatElapsed(now.subtract(const Duration(hours: 3)), now: now), '3 小时前'); + expect(formatElapsed(now.subtract(const Duration(days: 2)), now: now), '2 天前'); + }); +} diff --git a/packages/core_webview/analysis_options.yaml b/packages/core_webview/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/core_webview/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/core_webview/lib/core_webview.dart b/packages/core_webview/lib/core_webview.dart new file mode 100644 index 0000000..be94f77 --- /dev/null +++ b/packages/core_webview/lib/core_webview.dart @@ -0,0 +1,22 @@ +/// H5 容器。来源:conti-docs/10-webview-h5.md。 +/// +/// --------------------------------------------------------------------------- +/// **适用范围:Embedded H5 仅用于承载 F6 页面,不做通用外链容器**(PRD §7.1)。 +/// 任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。 +/// +/// 本包只负责**校验和承载**:换票(要发 HTTP)不在这里,`core_webview → +/// core_network` 不是 01 允许的依赖边——由调用方实现 +/// [H5LaunchRepository] 后 override 进来。 +/// +/// TODO(10): 12 项 bridge 能力的具体 handler 尚未实现(依赖 native_media / +/// native_device,本次脚手架范围外)。[BridgeDispatcher.handlers] 现在是空表, +/// 任何调用都会得到 `UNSUPPORTED_METHOD`——这是预期行为,不是 bug。 +/// --------------------------------------------------------------------------- +library; + +export 'src/bridge.dart'; +export 'src/bridge_shim.dart'; +export 'src/h5_launch.dart'; +export 'src/page_watchdog.dart'; +export 'src/url_guard.dart'; +export 'src/webview_session.dart'; diff --git a/packages/core_webview/lib/src/bridge.dart b/packages/core_webview/lib/src/bridge.dart new file mode 100644 index 0000000..9a31892 --- /dev/null +++ b/packages/core_webview/lib/src/bridge.dart @@ -0,0 +1,161 @@ +/// JSBridge 的消息分发。来源:conti-docs/10-webview-h5.md §JSBridge 协议。 +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:core_foundation/core_foundation.dart'; + +import 'url_guard.dart'; + +/// 回给 H5 的错误。 +/// +/// `code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码——H5 侧按 code +/// 分支处理,`message` 只用于展示。取值见 [AppException.bridgeCode] 和 +/// [BridgeErrorCode]。 +class BridgeError { + /// 构造。 + const BridgeError(this.code, this.message); + + /// 稳定错误码。**一旦发布不能改**,H5 侧按它分支。 + final String code; + + /// 展示文案。 + final String message; +} + +/// [BridgeError.code] 里由 bridge 自己产生(而非来自 [AppException])的取值。 +abstract final class BridgeErrorCode { + /// H5 调了一个当前 App 版本没有的能力。 + /// + /// 不静默忽略:H5 版本比 App 新时,明确告诉它"不支持"才能降级, + /// 否则 H5 侧的 Promise 永远 pending,页面卡死。 + static const String unsupportedMethod = 'UNSUPPORTED_METHOD'; + + /// handler 抛了非 [AppException] 的异常,属于 bug。 + static const String internalError = 'INTERNAL_ERROR'; +} + +/// 一项 bridge 能力的实现。 +typedef BridgeHandler = Future Function(Map params); + +/// bridge 的日志出口。core_webview 不能依赖 core_logging(不是允许的依赖边)。 +typedef BridgeLogSink = void Function(String message, {Object? error}); + +/// 把 `ContiBridge` 通道收到的原始字符串分发到各能力实现。 +/// +/// --------------------------------------------------------------------------- +/// 只开**一个** JavaScript Channel,所有能力走同一个通道分发。每个能力开一个 +/// channel 会让来源校验、日志、错误处理各写一遍。 +/// --------------------------------------------------------------------------- +class BridgeDispatcher { + /// [currentUrl] 通常是 `controller.currentUrl`;[evaluateJavaScript] 通常是 + /// `controller.runJavaScript`。注入而不是直接持有 `WebViewController`, + /// 是为了这段安全逻辑能被单测覆盖。 + BridgeDispatcher({ + required this.urlGuard, + required this.handlers, + required this.currentUrl, + required this.evaluateJavaScript, + required this.log, + }); + + /// 白名单。 + final UrlGuard urlGuard; + + /// method → 实现。 + final Map handlers; + + /// 当前主 frame 的 URL。 + final Future Function() currentUrl; + + /// 执行一段 JS(用于回包和推事件)。 + final Future Function(String js) evaluateJavaScript; + + /// 日志出口。 + final BridgeLogSink log; + + /// 处理一条来自 H5 的原始消息。 + Future handle(String raw) async { + // ------------------------------------------------------------------ + // 1. 来源校验。 + // + // JavaScript Channel 会注入到 WebView 的**所有 frame,包括 iframe**。 + // F6 页面里嵌的第三方 iframe 也能调 ContiBridge。 + // + // 注意 currentUrl() 返回的是**主 frame** 的 URL:这一条能挡住"整页被导航 + // 到恶意站点后调 bridge",挡不住"白名单页面内的恶意 iframe"。后者只能靠 + // 协议层面约定 F6 不嵌不受信 iframe + 导航拦截限制 iframe 域名。 + // ------------------------------------------------------------------ + final String? current = await currentUrl(); + if (!urlGuard.isAllowedUrl(current)) { + log('[bridge] 拒绝来自非白名单页面的调用: $current'); + // 静默丢弃,不回包——不给探测者任何反馈。 + return; + } + + // 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩。 + final Map req; + try { + final Object? decoded = jsonDecode(raw); + if (decoded is! Map) { + log('[bridge] 消息不是对象'); + return; + } + req = decoded; + } on FormatException catch (e) { + log('[bridge] 无法解析的消息', error: e); + return; + } + + // id 由 H5 侧生成并原样回传,App 不生成——H5 的 Promise 映射表由它自己管。 + final Object? id = req['id']; + final Object? method = req['method']; + if (id is! String || method is! String) { + log('[bridge] 缺少 id 或 method'); + return; + } + + final BridgeHandler? handler = handlers[method]; + if (handler == null) { + await _reply( + id, + error: const BridgeError(BridgeErrorCode.unsupportedMethod, '当前 App 版本不支持该能力'), + ); + return; + } + + final Object? rawParams = req['params']; + final Map params = rawParams is Map + ? rawParams + : const {}; + + try { + await _reply(id, data: await handler(params)); + } on AppException catch (e) { + // 供应商/原生错误不透传,只给稳定 code + 可展示文案。 + await _reply(id, error: BridgeError(e.bridgeCode, e.message)); + } on Object catch (e) { + log('[bridge] $method 未预期异常', error: e); + await _reply(id, error: const BridgeError(BridgeErrorCode.internalError, '操作失败,请重试')); + } + } + + /// 主动事件(App → H5,无 id)。如门店切换、上传进度。 + Future emit(String event, Map payload) { + final String json = jsonEncode({'event': event, 'payload': payload}); + return evaluateJavaScript('window.__contiBridgeEvent && window.__contiBridgeEvent($json);'); + } + + Future _reply(String id, {Object? data, BridgeError? error}) { + final Map resp = { + 'id': id, + 'ok': error == null, + if (error == null) 'data': data, + if (error != null) 'error': {'code': error.code, 'message': error.message}, + }; + return evaluateJavaScript( + 'window.__contiBridgeCallback && window.__contiBridgeCallback(${jsonEncode(resp)});', + ); + } +} diff --git a/packages/core_webview/lib/src/bridge_shim.dart b/packages/core_webview/lib/src/bridge_shim.dart new file mode 100644 index 0000000..fe7c77f --- /dev/null +++ b/packages/core_webview/lib/src/bridge_shim.dart @@ -0,0 +1,44 @@ +/// 注入给 H5 的 JS 胶水。来源:conti-docs/10-webview-h5.md §JS 侧胶水。 +library; + +/// `window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。 +/// 这段把它包成 Promise。 +/// +/// --------------------------------------------------------------------------- +/// **注入时机是 `onPageFinished`,不是 `onPageStarted`**——后者时 H5 的脚本 +/// 可能还没执行完,会重复注入或时序错乱。 +/// +/// `__contiBridgeReady` 做幂等保护:SPA 内部路由变化可能触发多次回调。 +/// +/// H5 侧要处理"bridge 还没就绪"的情况,约定等待 `window.__contiBridgeReady`。 +/// **这条要写进给 F6 的接入文档**(10 §与 F6 的接口对齐清单 第 1 条)。 +/// --------------------------------------------------------------------------- +const String kBridgeShim = r''' +(function () { + if (window.__contiBridgeReady) return; + const pending = new Map(); + window.__contiBridgeCallback = function (resp) { + const p = pending.get(resp.id); + if (!p) return; + pending.delete(resp.id); + resp.ok ? p.resolve(resp.data) : p.reject(resp.error); + }; + window.__contiBridgeEvent = function (evt) { + window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload })); + }; + const raw = window.ContiBridge; + window.ContiBridge = { + call: function (method, params) { + const id = String(Date.now()) + Math.random().toString(36).slice(2); + return new Promise(function (resolve, reject) { + pending.set(id, { resolve: resolve, reject: reject }); + raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} })); + }); + }, + }; + window.__contiBridgeReady = true; +})(); +'''; + +/// JavaScript Channel 名。H5 侧按这个名字调用,**改名等于破坏所有 F6 页面**。 +const String kBridgeChannelName = 'ContiBridge'; diff --git a/packages/core_webview/lib/src/h5_launch.dart b/packages/core_webview/lib/src/h5_launch.dart new file mode 100644 index 0000000..e816c96 --- /dev/null +++ b/packages/core_webview/lib/src/h5_launch.dart @@ -0,0 +1,49 @@ +/// H5 启动信息与换票端口。来源:conti-docs/10-webview-h5.md §H5 启动流程。 +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// `/api/v1/h5/launch` 的返回。 +@immutable +class H5LaunchInfo { + /// 构造。 + const H5LaunchInfo({required this.url, required this.title, required this.ttl}); + + /// 已由**后端**拼好票据和上下文参数的完整 URL。 + /// + /// --------------------------------------------------------------------------- + /// **启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。** + /// 客户端拼参数意味着 userId / storeId / roleCode 这些权限相关字段可以被本地 + /// 篡改;后端拼接时这些值都从服务端会话上下文取,客户端只能说"我要开 + /// QUOTE_ORDER"。 + /// + /// 客户端唯一负责传的是 traceId(请求头 `X-Trace-Id`),后端把它带进 H5 URL, + /// 这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。 + /// --------------------------------------------------------------------------- + final String url; + + /// 导航栏标题。 + final String title; + + /// 票据有效期,用于判断是否需要换票。 + final Duration ttl; +} + +/// 换票端口。 +/// +/// --------------------------------------------------------------------------- +/// 实现**不在本包**:换票要发 HTTP,而 `core_webview → core_network` 不是 01 +/// 允许的依赖边。由 `feature_*`(或 app/)实现后 override 进来。 +/// +/// 同 core_auth/src/session_ports.dart 的依赖反转套路。 +/// --------------------------------------------------------------------------- +abstract interface class H5LaunchRepository { + /// 用 [target](白名单枚举,不是 URL)换一份可加载的 [H5LaunchInfo]。 + Future launch(String target); +} + +/// 未 override 时直接报错,比默默打不开页面好。 +final Provider h5LaunchRepositoryProvider = Provider( + (Ref ref) => throw UnimplementedError('h5LaunchRepositoryProvider 必须在 bootstrap 里 override'), +); diff --git a/packages/core_webview/lib/src/page_watchdog.dart b/packages/core_webview/lib/src/page_watchdog.dart new file mode 100644 index 0000000..181cde7 --- /dev/null +++ b/packages/core_webview/lib/src/page_watchdog.dart @@ -0,0 +1,43 @@ +/// 白屏看门狗。来源:conti-docs/10-webview-h5.md §白屏、超时、网络失败兜底。 +library; + +import 'dart:async'; + +/// `onPageStarted` 后 15 秒还没 `onPageFinished` 就判超时。 +/// +/// --------------------------------------------------------------------------- +/// **WebView 在某些网络状况下既不成功也不报错**,`onWebResourceError` 不会触发, +/// 用户看到的是一片空白且永远等下去。只有超时能兜住这种情况——这是 H5 容器 +/// 体验最差的一类问题。 +/// +/// 超时后要展示错误态并**上报 `h5_failed` 埋点**(带 target、错误码、耗时、 +/// traceId):这一类失败后端完全看不到(换票请求是成功的,加载失败发生在 +/// WebView 内部),所以它必须由客户端报。这是 H5 链路健康度最重要的指标。 +/// --------------------------------------------------------------------------- +class PageWatchdog { + /// [onTimeout] 里做展示错误态 + 埋点上报。 + PageWatchdog({required this.onTimeout, this.timeout = const Duration(seconds: 15)}); + + /// 超时回调。 + final void Function() onTimeout; + + /// 超时时长。 + final Duration timeout; + + Timer? _timer; + + /// `onPageStarted` 时调。重复调用会重置计时。 + void start() { + _timer?.cancel(); + _timer = Timer(timeout, onTimeout); + } + + /// `onPageFinished` / 出错时调。 + void cancel() { + _timer?.cancel(); + _timer = null; + } + + /// 是否正在计时。 + bool get isRunning => _timer?.isActive ?? false; +} diff --git a/packages/core_webview/lib/src/url_guard.dart b/packages/core_webview/lib/src/url_guard.dart new file mode 100644 index 0000000..57bbd4c --- /dev/null +++ b/packages/core_webview/lib/src/url_guard.dart @@ -0,0 +1,54 @@ +/// 域名白名单。来源:conti-docs/10-webview-h5.md §域名白名单。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// H5 URL 的准入判定。 +/// +/// --------------------------------------------------------------------------- +/// 白名单在**三个位置**都要生效,缺一不可: +/// +/// 1. 首次加载前——校验后端返回的 URL(防后端配置错误)。 +/// 2. 导航拦截(`NavigationDelegate.onNavigationRequest`)——H5 内部跳到非白名单 +/// 域名一律 `prevent`,并记一条埋点。 +/// 3. JSBridge 每条消息进来时——校验当前页面的 host。 +/// +/// 少任何一处,前面的校验都会被绕过。 +/// --------------------------------------------------------------------------- +class UrlGuard { + /// [allowedHosts] 来自 `env/{flavor}.json`,各环境不同。 + const UrlGuard(this._allowedHosts); + + final Set _allowedHosts; + + /// 是否允许加载。 + bool isAllowed(Uri uri) { + // 只允许 HTTPS(PRD §7.6)。dev 也不放开——一旦放开,dev 上写的 + // http 地址会跟着代码活到 uat。 + if (uri.scheme != 'https') { + return false; + } + final String host = uri.host.toLowerCase(); + // ------------------------------------------------------------------ + // 用 endsWith('.$allowed') 而不是 contains: + // contains('example.com') 会让 f6.example.com.evil.com 通过校验。 + // 这是白名单实现里最经典的一个洞,别改成 contains。 + // ------------------------------------------------------------------ + return _allowedHosts.any((String allowed) => host == allowed || host.endsWith('.$allowed')); + } + + /// 字符串版,解析失败按不允许处理。 + bool isAllowedUrl(String? url) { + if (url == null) { + return false; + } + final Uri? uri = Uri.tryParse(url); + return uri != null && isAllowed(uri); + } +} + +/// 由 [AppEnv.h5AllowedHosts] 驱动。 +final Provider urlGuardProvider = Provider( + (Ref ref) => UrlGuard(ref.watch(appEnvProvider).h5AllowedHosts), +); diff --git a/packages/core_webview/lib/src/webview_session.dart b/packages/core_webview/lib/src/webview_session.dart new file mode 100644 index 0000000..1a5489f --- /dev/null +++ b/packages/core_webview/lib/src/webview_session.dart @@ -0,0 +1,98 @@ +/// H5 会话失效。来源:conti-docs/10-webview-h5.md §门店切换与登出时的会话失效。 +library; + +import 'package:core_auth/core_auth.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// 一个已打开的 H5 页面。 +/// +/// 抽象出来是为了 [WebViewSession] 的清理顺序能被单测覆盖——真的 +/// `WebViewController` 在单测里起不来。 +abstract interface class H5Surface { + /// 停掉当前页面(导航到 about:blank),防止在途请求继续。 + Future stop(); + + /// 清 LocalStorage 和 Cache。 + Future clearBrowsingData(); +} + +/// [H5Surface] 在真机上的实现。 +class WebViewSurface implements H5Surface { + /// 构造。 + const WebViewSurface(this._controller); + + final WebViewController _controller; + + @override + Future stop() => _controller.loadRequest(Uri.parse('about:blank')); + + @override + Future clearBrowsingData() async { + await _controller.clearLocalStorage(); + await _controller.clearCache(); + } +} + +/// 所有打开中的 H5 页面的登记处。 +/// +/// --------------------------------------------------------------------------- +/// PRD §7.5 的两条硬要求: +/// - **门店切换后,当前 H5 页面必须失效并提示用户重新进入**,但**不清 Cookie** +/// (用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。 +/// - **用户退出登录后,所有 H5 会话必须同步失效**,并**清 Cookie / LocalStorage +/// / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话—— +/// 同一台门店共用设备上这是真实会发生的。 +/// +/// 本类实现 [SessionScopedStore],由 `SessionNotifier` 的级联统一调用 +/// (11),不散在各处手动调。清理**必须 await 完成**再让新用户登录, +/// 不能 fire-and-forget。 +/// --------------------------------------------------------------------------- +class WebViewSession implements SessionScopedStore { + /// [clearCookies] 只给测试替换;生产用默认的 [WebViewCookieManager]。 + WebViewSession({Future Function()? clearCookies}) + : _clearCookies = clearCookies ?? _defaultClearCookies; + + static Future _defaultClearCookies() => WebViewCookieManager().clearCookies(); + + final Future Function() _clearCookies; + final List _open = []; + + /// 打开 H5 页时登记。 + void register(H5Surface surface) => _open.add(surface); + + /// 关闭 H5 页时注销。 + void unregister(H5Surface surface) => _open.remove(surface); + + /// 当前打开中的页面数。给测试和诊断用。 + int get openCount => _open.length; + + @override + String get debugName => 'WebViewSession'; + + @override + Future onStoreChanged() => invalidateAll(clearCookies: false); + + @override + Future onSessionEnded() => invalidateAll(clearCookies: true); + + /// 失效所有 H5 会话。 + Future invalidateAll({required bool clearCookies}) async { + // 先停掉页面,再清数据——反过来的话在途请求可能把刚清掉的东西又写回去。 + for (final H5Surface surface in _open) { + await surface.stop(); + } + if (clearCookies) { + await _clearCookies(); + for (final H5Surface surface in _open) { + await surface.clearBrowsingData(); + } + } + _open.clear(); + } +} + +/// 全 App 唯一的会话登记处。 +final Provider webViewSessionProvider = Provider( + (Ref ref) => WebViewSession(), +); diff --git a/packages/core_webview/pubspec.yaml b/packages/core_webview/pubspec.yaml new file mode 100644 index 0000000..0580b78 --- /dev/null +++ b/packages/core_webview/pubspec.yaml @@ -0,0 +1,24 @@ +name: core_webview +description: H5 容器。域名白名单、JSBridge、WebView 会话管理。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# core_webview → core_auth 是 01 允许的三条 core 间依赖之一(bridge 要读会话上下文)。 +# 「用 target 换真实 URL」的接口调用**不在本包**——那需要 core_network,不是允许的边。 +# 换票由调用方(feature)完成后把 URL 传进来,本包只负责校验和承载。 +environment: + sdk: ^3.12.0 + +dependencies: + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + webview_flutter: ^4.14.1 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/core_webview/test/core_webview_test.dart b/packages/core_webview/test/core_webview_test.dart new file mode 100644 index 0000000..04ce5f9 --- /dev/null +++ b/packages/core_webview/test/core_webview_test.dart @@ -0,0 +1,163 @@ +// core_webview 的高价值断言——这几条全是安全相关,回归了不会有人察觉: +// 1. 白名单不能被 f6.example.com.evil.com 绕过。 +// 2. 非白名单页面调 bridge 必须静默丢弃,连错误都不回(不给探测者反馈)。 +// 3. 畸形 JSON 不能让 App 崩。 +// 4. 未知 method 必须明确回 UNSUPPORTED_METHOD,不能静默(否则 H5 侧 Promise 永远 pending)。 +// 5. 登出清 Cookie,切店不清。 + +import 'dart:convert'; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_webview/core_webview.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSurface implements H5Surface { + bool stopped = false; + bool cleared = false; + + @override + Future stop() async => stopped = true; + + @override + Future clearBrowsingData() async => cleared = true; +} + +void main() { + group('UrlGuard', () { + const UrlGuard guard = UrlGuard({'example.com'}); + + test('放行白名单域名及其子域', () { + expect(guard.isAllowed(Uri.parse('https://example.com/a')), isTrue); + expect(guard.isAllowed(Uri.parse('https://f6.example.com/a')), isTrue); + expect(guard.isAllowed(Uri.parse('https://F6.EXAMPLE.COM/a')), isTrue); + }); + + test('挡住后缀伪装——这是白名单实现最经典的一个洞', () { + // 用 contains 实现的话这一条会通过。 + expect(guard.isAllowed(Uri.parse('https://f6.example.com.evil.com/a')), isFalse); + expect(guard.isAllowed(Uri.parse('https://notexample.com/a')), isFalse); + }); + + test('只允许 HTTPS,dev 也不放开', () { + expect(guard.isAllowed(Uri.parse('http://example.com/a')), isFalse); + expect(guard.isAllowedUrl('about:blank'), isFalse); + expect(guard.isAllowedUrl(null), isFalse); + }); + }); + + group('BridgeDispatcher', () { + late List evaluated; + late String currentUrl; + + BridgeDispatcher build(Map handlers) => BridgeDispatcher( + urlGuard: const UrlGuard({'example.com'}), + handlers: handlers, + currentUrl: () async => currentUrl, + evaluateJavaScript: (String js) async => evaluated.add(js), + log: (String message, {Object? error}) {}, + ); + + setUp(() { + evaluated = []; + currentUrl = 'https://f6.example.com/quote'; + }); + + test('非白名单页面的调用静默丢弃,不回包', () async { + currentUrl = 'https://evil.com/x'; + await build({ + 'scan': (Map _) async => 'never', + }).handle('{"id":"1","method":"scan"}'); + + expect(evaluated, isEmpty, reason: '回任何东西都是在给探测者反馈'); + }); + + test('畸形 JSON 不崩也不回包', () async { + await build(const {}).handle('{not json'); + expect(evaluated, isEmpty); + }); + + test('未知 method 明确回 UNSUPPORTED_METHOD,不静默', () async { + await build(const {}).handle('{"id":"1","method":"teleport"}'); + + expect(evaluated, hasLength(1)); + final Map resp = _decodeReply(evaluated.single); + expect(resp['ok'], isFalse); + expect((resp['error']! as Map)['code'], 'UNSUPPORTED_METHOD'); + }); + + test('成功调用原样回传 H5 生成的 id', () async { + await build({ + 'getStoreContext': (Map _) async => {'storeId': 7}, + }).handle('{"id":"c8f1","method":"getStoreContext"}'); + + final Map resp = _decodeReply(evaluated.single); + expect(resp['id'], 'c8f1'); + expect(resp['ok'], isTrue); + expect(resp['data'], {'storeId': 7}); + }); + + test('AppException 转成稳定的 bridgeCode,不透传原始错误', () async { + await build({ + 'scan': (Map _) async => + throw const NativeException(NativeErrorCode.permissionDenied, '未授予相机权限'), + }).handle('{"id":"1","method":"scan"}'); + + final Map error = + _decodeReply(evaluated.single)['error']! as Map; + expect(error['code'], 'PERMISSION_DENIED'); + expect(error['message'], '未授予相机权限'); + }); + + test('非 AppException 归一化成 INTERNAL_ERROR,不泄露技术细节', () async { + await build({ + 'scan': (Map _) async => throw StateError('null check on FooBar'), + }).handle('{"id":"1","method":"scan"}'); + + final Map error = + _decodeReply(evaluated.single)['error']! as Map; + expect(error['code'], 'INTERNAL_ERROR'); + expect(error['message'], isNot(contains('FooBar'))); + }); + }); + + group('WebViewSession', () { + test('切店:停页面但不清 Cookie——用户还是同一个人', () async { + bool cookiesCleared = false; + final WebViewSession session = WebViewSession( + clearCookies: () async => cookiesCleared = true, + ); + final _FakeSurface surface = _FakeSurface(); + session.register(surface); + + await session.onStoreChanged(); + + expect(surface.stopped, isTrue); + expect(cookiesCleared, isFalse); + expect(surface.cleared, isFalse); + expect(session.openCount, 0); + }); + + test('登出:必须清 Cookie——门店共用设备上会串号', () async { + bool cookiesCleared = false; + final WebViewSession session = WebViewSession( + clearCookies: () async => cookiesCleared = true, + ); + final _FakeSurface surface = _FakeSurface(); + session.register(surface); + + await session.onSessionEnded(); + + expect(surface.stopped, isTrue); + expect(cookiesCleared, isTrue); + expect(surface.cleared, isTrue); + expect(session.openCount, 0); + }); + }); +} + +/// 从 `window.__contiBridgeCallback({...});` 里把 JSON 抠出来。 +Map _decodeReply(String js) { + final int start = js.indexOf('({') + 1; + final int end = js.lastIndexOf('})') + 1; + return jsonDecode(js.substring(start, end)) as Map; +} diff --git a/packages/feature_auth/analysis_options.yaml b/packages/feature_auth/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/feature_auth/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/feature_auth/lib/feature_auth.dart b/packages/feature_auth/lib/feature_auth.dart new file mode 100644 index 0000000..89dd270 --- /dev/null +++ b/packages/feature_auth/lib/feature_auth.dart @@ -0,0 +1,11 @@ +/// 登录、登出、门店选择。 +/// +/// 对外只暴露三样东西:[buildAuthRoutes](给 app/ 拼路由表)、 +/// [authRepositoryProvider](给 app/ override `sessionRemoteProvider`)、 +/// 以及登录动作的 [loginControllerProvider]。页面本身不导出——别的包没有 +/// 直接构造它们的正当理由。 +library; + +export 'src/data/auth_repository.dart' show AuthRepository, LoginResult, authRepositoryProvider; +export 'src/presentation/login_controller.dart'; +export 'src/routes.dart'; diff --git a/packages/feature_auth/lib/src/data/auth_repository.dart b/packages/feature_auth/lib/src/data/auth_repository.dart new file mode 100644 index 0000000..edb4297 --- /dev/null +++ b/packages/feature_auth/lib/src/data/auth_repository.dart @@ -0,0 +1,125 @@ +/// 登录与会话相关的服务端调用。 +/// +/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层(登录是直白的 +/// 请求-响应,没有跨 repository 协调),所以接口直接声明在 `data/repository/`, +/// presentation 只依赖接口,不依赖 [AuthRepositoryImpl]。 +library; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_network/core_network.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 登录成功后拿到的东西。 +typedef LoginResult = ({TokenPair tokens, UserContext user}); + +/// --------------------------------------------------------------------------- +/// 它同时实现 core_auth 的 [SessionRemote]:core_auth 不能依赖 core_network +/// (01 的硬约束),所以那边只声明接口,实现落在这里,由 `app/bootstrap.dart` +/// 把 `sessionRemoteProvider` override 成本实现。 +/// --------------------------------------------------------------------------- +abstract interface class AuthRepository implements SessionRemote { + /// 账号密码登录。 + Future login({required String username, required String password}); +} + +/// [AuthRepository] 的实现。 +/// +/// TODO(backend): 下面所有路径和字段名都是按 05 / 11 的示例推的, +/// 待后端接口契约确认后校准。 +class AuthRepositoryImpl implements AuthRepository { + /// repository 一律注入 [ApiClient],不注入 Dio(05)。 + const AuthRepositoryImpl(this._api); + + final ApiClient _api; + + @override + Future login({required String username, required String password}) async { + final Map data = await _api.post>( + '/api/v1/auth/login', + data: {'username': username, 'password': password}, + ); + return ( + tokens: TokenPair( + accessToken: _requireString(data, 'accessToken'), + refreshToken: _requireString(data, 'refreshToken'), + ), + user: _parseUser(_requireMap(data, 'user')), + ); + } + + @override + Future fetchCurrentUser() async => + _parseUser(await _api.get>('/api/v1/auth/me')); + + @override + Future> fetchAccessibleStores() async { + final List raw = await _api.get>('/api/v1/stores/accessible'); + return raw.map((dynamic e) => _parseStore(e as Map)).toList(); + } + + @override + Future switchStore(int storeId) async => _parseStore( + await _api.post>( + '/api/v1/stores/switch', + data: {'storeId': storeId}, + ), + ); + + @override + Future revokeSession() => _api.post('/api/v1/auth/logout'); + + static UserContext _parseUser(Map json) => UserContext( + userId: _requireString(json, 'userId'), + employeeId: _requireString(json, 'employeeId'), + phone: _requireString(json, 'phone'), + roleCode: _requireString(json, 'roleCode'), + channel: _requireString(json, 'channel'), + permissions: { + ...?(json['permissions'] as List?)?.map((dynamic e) => e as String), + }, + ); + + static StoreContext _parseStore(Map json) => StoreContext( + storeId: (json['storeId'] as num).toInt(), + storeCode: _requireString(json, 'storeCode'), + storeName: _requireString(json, 'storeName'), + orgId: (json['orgId'] as num).toInt(), + parentStoreId: json['parentStoreId'] as String?, + menus: _parseMenus(json['menus'] as List?), + ); + + static List _parseMenus(List? raw) => raw == null + ? const [] + : raw.map((dynamic e) { + final Map m = e as Map; + return MenuItem( + code: _requireString(m, 'code'), + name: _requireString(m, 'name'), + children: _parseMenus(m['children'] as List?), + ); + }).toList(); + + // 缺字段直接当服务异常,不给默认值——一个 userId 为 '' 的会话会在后面 + // 十个地方以更难懂的方式炸掉。 + static String _requireString(Map json, String key) { + final Object? v = json[key]; + if (v is! String) { + throw ServerException('响应缺少字段 $key'); + } + return v; + } + + static Map _requireMap(Map json, String key) { + final Object? v = json[key]; + if (v is! Map) { + throw ServerException('响应缺少字段 $key'); + } + return v; + } +} + +/// presentation 通过它拿接口类型。 +final Provider authRepositoryProvider = Provider( + (Ref ref) => AuthRepositoryImpl(ref.watch(apiClientProvider)), +); diff --git a/packages/feature_auth/lib/src/presentation/login_controller.dart b/packages/feature_auth/lib/src/presentation/login_controller.dart new file mode 100644 index 0000000..e024c5f --- /dev/null +++ b/packages/feature_auth/lib/src/presentation/login_controller.dart @@ -0,0 +1,35 @@ +/// 登录页的状态。 +library; + +import 'dart:async'; + +import 'package:core_auth/core_auth.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/auth_repository.dart'; + +part 'login_controller.g.dart'; + +/// 登录动作的三态。 +/// +/// 用 `AsyncNotifier` 而不是自定义 state 类:登录只有"进行中/失败/成功" +/// 三种形态,成功后页面由路由 redirect 接管(会话变成 SessionActive, +/// goRouterProvider 会把用户送走),不需要在这里保留成功数据。 +@riverpod +class LoginController extends _$LoginController { + @override + FutureOr build() {} + + /// 提交登录。 + Future submit({required String username, required String password}) async { + state = const AsyncLoading(); + // AsyncValue.guard:异常留在 state 里由页面展示,不往外抛—— + // 抛出去会变成未捕获异常上报,而"密码错了"不是崩溃。 + state = await AsyncValue.guard(() async { + final LoginResult result = await ref + .read(authRepositoryProvider) + .login(username: username, password: password); + await ref.read(sessionProvider.notifier).onLoggedIn(tokens: result.tokens, user: result.user); + }); + } +} diff --git a/packages/feature_auth/lib/src/presentation/login_page.dart b/packages/feature_auth/lib/src/presentation/login_page.dart new file mode 100644 index 0000000..681719a --- /dev/null +++ b/packages/feature_auth/lib/src/presentation/login_page.dart @@ -0,0 +1,95 @@ +/// 登录页。 +library; + +import 'dart:async'; + +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'login_controller.dart'; + +/// 账号密码登录。 +/// +/// 这是一个**骨架**:字段、找回密码、验证码、记住账号等都待 PRD 细化。 +/// Key 是 integration test 的约定(09),改名会让端到端用例失效。 +class LoginPage extends ConsumerStatefulWidget { + /// 构造。 + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final TextEditingController _username = TextEditingController(); + final TextEditingController _password = TextEditingController(); + + @override + void dispose() { + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + void _submit() { + // 结果通过 state 回到 UI,这里不需要 await——但也不能裸调, + // discarded_futures 会拦。 + unawaited( + ref + .read(loginControllerProvider.notifier) + .submit(username: _username.text.trim(), password: _password.text), + ); + } + + @override + Widget build(BuildContext context) { + final AsyncValue state = ref.watch(loginControllerProvider); + final bool busy = state.isLoading; + + return Scaffold( + appBar: AppBar(title: const Text('登录')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + key: const Key('login_username'), + controller: _username, + enabled: !busy, + decoration: const InputDecoration(labelText: '账号'), + ), + const SizedBox(height: 12), + TextField( + key: const Key('login_password'), + controller: _password, + enabled: !busy, + obscureText: true, + decoration: const InputDecoration(labelText: '密码'), + ), + const SizedBox(height: 24), + if (state.hasError && !ErrorPresenter.isSilent(state.error!)) ...[ + Text( + ErrorPresenter.presentUnknown(state.error!).title, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 12), + ], + FilledButton( + key: const Key('login_submit'), + // busy 时置灰而不是靠节流:门店网络慢,用户会反复点。 + onPressed: busy ? null : _submit, + child: busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('登录'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/feature_auth/lib/src/presentation/store_picker_page.dart b/packages/feature_auth/lib/src/presentation/store_picker_page.dart new file mode 100644 index 0000000..2438f42 --- /dev/null +++ b/packages/feature_auth/lib/src/presentation/store_picker_page.dart @@ -0,0 +1,100 @@ +/// 选店页。来源:conti-docs/11-store-context-and-session.md。 +library; + +import 'dart:async'; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 登录后选择门店;也用于登录后切换门店。 +/// +/// --------------------------------------------------------------------------- +/// 用户停在这里时会话是 [SessionAwaitingStore]——**没有门店上下文**, +/// `currentStoreIdProvider` 会直接 throw。所以这个页面不能读任何门店维度的 +/// provider,路由 redirect 也不放行去别的页面。 +/// --------------------------------------------------------------------------- +class StorePickerPage extends ConsumerStatefulWidget { + /// 构造。 + const StorePickerPage({super.key}); + + @override + ConsumerState createState() => _StorePickerPageState(); +} + +class _StorePickerPageState extends ConsumerState { + Object? _error; + bool _switching = false; + + Future _pick(int storeId) async { + setState(() { + _switching = true; + _error = null; + }); + try { + // 切店的 8 步级联全在 SessionNotifier 里,页面只负责触发和展示失败。 + await ref.read(sessionProvider.notifier).switchStore(storeId); + } on Object catch (e) { + if (mounted) { + setState(() => _error = e); + } + } finally { + if (mounted) { + setState(() => _switching = false); + } + } + } + + @override + Widget build(BuildContext context) { + final AppSession? session = ref.watch(sessionProvider).value; + final List stores = switch (session) { + SessionAwaitingStore(:final List candidates) => candidates, + SessionActive(:final StoreContext store) => [store], + _ => const [], + }; + + return Scaffold( + appBar: AppBar(title: const Text('选择门店')), + body: Column( + children: [ + if (_error != null) TileErrorView(error: _error!), + if (_switching) const LinearProgressIndicator(), + Expanded( + child: stores.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('没有可访问的门店'), + const SizedBox(height: 12), + // 拉门店失败和"确实一家都没有"在 UI 上无法区分, + // 所以两种情况都给重试入口(11 §冷启动恢复)。 + FilledButton( + onPressed: () => + unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()), + child: const Text('重试'), + ), + ], + ), + ) + : ListView.builder( + itemCount: stores.length, + itemBuilder: (BuildContext context, int index) { + final StoreContext store = stores[index]; + return ListTile( + // 09 的端到端用例按 index 取,改 key 名会让用例失效。 + key: Key('store_item_$index'), + title: Text(store.storeName), + subtitle: Text(store.storeCode), + onTap: _switching ? null : () => unawaited(_pick(store.storeId)), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/feature_auth/lib/src/routes.dart b/packages/feature_auth/lib/src/routes.dart new file mode 100644 index 0000000..27985db --- /dev/null +++ b/packages/feature_auth/lib/src/routes.dart @@ -0,0 +1,28 @@ +/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。 +library; + +import 'package:core_router/core_router.dart'; +import 'package:flutter/widgets.dart'; + +import 'presentation/login_page.dart'; +import 'presentation/store_picker_page.dart'; + +/// 登录相关路由。 +/// +/// --------------------------------------------------------------------------- +/// feature 只**导出**自己的路由,不知道别人的存在,也不持有 GoRouter。 +/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——这样 core_router 不必依赖 +/// 任何 feature(01 的分层),feature 之间也不会互相 import。 +/// +/// `GoRoute` 类型来自 core_router 的 re-export,本包 pubspec 里没有 go_router。 +/// --------------------------------------------------------------------------- +List buildAuthRoutes() => [ + GoRoute( + path: AppRoutes.login, + builder: (BuildContext context, GoRouterState state) => const LoginPage(), + ), + GoRoute( + path: AppRoutes.storePicker, + builder: (BuildContext context, GoRouterState state) => const StorePickerPage(), + ), +]; diff --git a/packages/feature_auth/pubspec.yaml b/packages/feature_auth/pubspec.yaml new file mode 100644 index 0000000..3e5b252 --- /dev/null +++ b/packages/feature_auth/pubspec.yaml @@ -0,0 +1,30 @@ +name: feature_auth +description: 登录、登出、门店选择。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# feature_* 的 pubspec 是包边界的**执行现场**: +# - 这里不出现任何另一个 feature_*(feature 间禁止互相依赖,见 01) +# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04) +environment: + sdk: ^3.12.0 + +dependencies: + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + core_network: ^0.1.0 + core_router: ^0.1.0 + core_ui: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + riverpod_annotation: ^4.0.3 + +dev_dependencies: + build_runner: ^2.4.13 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.5 + riverpod_generator: ^4.0.4 diff --git a/packages/feature_auth/test/login_page_test.dart b/packages/feature_auth/test/login_page_test.dart new file mode 100644 index 0000000..8424300 --- /dev/null +++ b/packages/feature_auth/test/login_page_test.dart @@ -0,0 +1,89 @@ +// feature_auth 的高价值断言: +// 1. 登录失败不能变成未捕获异常("密码错了"不是崩溃),要留在 state 里展示。 +// 2. 提交中按钮必须置灰——门店网络慢,用户会反复点。 + +import 'dart:async'; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:feature_auth/feature_auth.dart'; +// LoginPage 不对外导出(别的包没有直接构造它的正当理由),测试走 src。 +import 'package:feature_auth/src/presentation/login_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FailingRepo implements AuthRepository { + /// 非 null 时 login 挂在这个 completer 上,用来观察"请求在途"这一帧。 + Completer? gate; + int calls = 0; + String? lastUsername; + + @override + Future login({required String username, required String password}) { + calls++; + lastUsername = username; + return gate?.future ?? Future.error(const BusinessException(40101, '账号或密码错误')); + } + + @override + Future fetchCurrentUser() => throw UnimplementedError(); + + @override + Future> fetchAccessibleStores() => throw UnimplementedError(); + + @override + Future switchStore(int storeId) => throw UnimplementedError(); + + @override + Future revokeSession() => throw UnimplementedError(); +} + +void main() { + testWidgets('登录失败展示业务文案,不抛出未捕获异常', (WidgetTester tester) async { + final _FailingRepo repo = _FailingRepo(); + + await tester.pumpWidget( + ProviderScope( + overrides: [authRepositoryProvider.overrideWithValue(repo)], + child: const MaterialApp(home: LoginPage()), + ), + ); + + // 前后空格是扫码枪/手动输入的常见污染,必须在提交前 trim。 + await tester.enterText(find.byKey(const Key('login_username')), ' clerk01 '); + await tester.enterText(find.byKey(const Key('login_password')), 'pwd'); + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + + expect(repo.lastUsername, 'clerk01'); + + await tester.pumpAndSettle(); + expect(find.text('账号或密码错误'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('提交中按钮置灰,重复点击不会重复发请求', (WidgetTester tester) async { + final _FailingRepo repo = _FailingRepo()..gate = Completer(); + + await tester.pumpWidget( + ProviderScope( + overrides: [authRepositoryProvider.overrideWithValue(repo)], + child: const MaterialApp(home: LoginPage()), + ), + ); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); // 请求还挂在 gate 上,这一帧就是"提交中" + + final FilledButton button = tester.widget(find.byKey(const Key('login_submit'))); + expect(button.onPressed, isNull, reason: '门店网络慢,用户会反复点'); + + await tester.tap(find.byKey(const Key('login_submit')), warnIfMissed: false); + await tester.pump(); + expect(repo.calls, 1); + + repo.gate!.completeError(const BusinessException(40101, '账号或密码错误')); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/feature_home/analysis_options.yaml b/packages/feature_home/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/feature_home/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/feature_home/lib/feature_home.dart b/packages/feature_home/lib/feature_home.dart new file mode 100644 index 0000000..29f87c8 --- /dev/null +++ b/packages/feature_home/lib/feature_home.dart @@ -0,0 +1,9 @@ +/// 工作台(首页)。 +/// +/// 对外只暴露 [buildHomeRoutes](给 app/ 拼路由表)和 [homeRepositoryProvider] +/// (给测试/后续替换实现用)。页面本身不导出。 +library; + +export 'src/data/home_models.dart'; +export 'src/data/home_repository.dart' show HomeRepository, homeRepositoryProvider; +export 'src/routes.dart'; diff --git a/packages/feature_home/lib/src/data/home_models.dart b/packages/feature_home/lib/src/data/home_models.dart new file mode 100644 index 0000000..de7e0eb --- /dev/null +++ b/packages/feature_home/lib/src/data/home_models.dart @@ -0,0 +1,36 @@ +/// 工作台的数据模型。 +/// +/// 字段按 PRD 的工作台描述反推,**待与后端接口对齐**——这里只保证结构和 +/// 降级逻辑成立,字段名后面照着真接口改即可。 +library; + +import 'package:flutter/foundation.dart'; + +/// 待办条目。 +@immutable +class TodoItem { + /// 构造。 + const TodoItem({required this.code, required this.title, required this.count}); + + /// 业务编码,和菜单 `code` 同一套字典——工作台的角标靠它对上菜单。 + final String code; + + /// 展示名。 + final String title; + + /// 待处理数量。 + final int count; +} + +/// 预警条目。 +@immutable +class AlertItem { + /// 构造。 + const AlertItem({required this.title, required this.detail}); + + /// 标题。 + final String title; + + /// 描述。 + final String detail; +} diff --git a/packages/feature_home/lib/src/data/home_repository.dart b/packages/feature_home/lib/src/data/home_repository.dart new file mode 100644 index 0000000..b480224 --- /dev/null +++ b/packages/feature_home/lib/src/data/home_repository.dart @@ -0,0 +1,73 @@ +/// 工作台的服务端调用。 +/// +/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层,接口直接声明在 +/// `data/repository/`,presentation 只依赖接口。 +library; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_network/core_network.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'home_models.dart'; + +/// 工作台数据。 +/// +/// --------------------------------------------------------------------------- +/// **两个方法,两个请求,不合并。** 合并成一个 `fetchHome()` 会把降级粒度也 +/// 一起合并掉:待办挂了就连预警一起看不见(12 §四)。接口层面就分开, +/// presentation 才有分开降级的可能。 +/// --------------------------------------------------------------------------- +abstract interface class HomeRepository { + /// 待办列表。 + Future> fetchTodos(); + + /// 预警列表。 + Future> fetchAlerts(); +} + +/// [HomeRepository] 的实现。 +/// +/// TODO(backend): 路径和字段名按 PRD 工作台描述推的,待接口契约确认后校准。 +class HomeRepositoryImpl implements HomeRepository { + /// repository 一律注入 [ApiClient],不注入 Dio(05)。 + const HomeRepositoryImpl(this._api); + + final ApiClient _api; + + @override + Future> fetchTodos() async { + final List raw = await _api.get>('/api/v1/home/todos'); + return raw.map((dynamic e) { + final Map m = e as Map; + return TodoItem( + code: _requireString(m, 'code'), + title: _requireString(m, 'title'), + count: (m['count'] as num?)?.toInt() ?? 0, + ); + }).toList(); + } + + @override + Future> fetchAlerts() async { + final List raw = await _api.get>('/api/v1/home/alerts'); + return raw.map((dynamic e) { + final Map m = e as Map; + return AlertItem(title: _requireString(m, 'title'), detail: _requireString(m, 'detail')); + }).toList(); + } + + // 缺字段直接当服务异常,不给默认值——理由同 feature_auth。 + // count 是例外:角标缺失降级为不显示,不值得整个待办区块挂掉。 + static String _requireString(Map json, String key) { + final Object? v = json[key]; + if (v is! String) { + throw ServerException('响应缺少字段 $key'); + } + return v; + } +} + +/// presentation 通过它拿接口类型。 +final Provider homeRepositoryProvider = Provider( + (Ref ref) => HomeRepositoryImpl(ref.watch(apiClientProvider)), +); diff --git a/packages/feature_home/lib/src/presentation/home_page.dart b/packages/feature_home/lib/src/presentation/home_page.dart new file mode 100644 index 0000000..321abdf --- /dev/null +++ b/packages/feature_home/lib/src/presentation/home_page.dart @@ -0,0 +1,235 @@ +/// 工作台(首页)。降级粒度来源:conti-docs/12-error-and-api-contract.md §四。 +library; + +import 'dart:async'; + +import 'package:core_auth/core_auth.dart'; +import 'package:core_router/core_router.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/home_models.dart'; +import 'home_providers.dart'; + +/// 工作台。 +/// +/// --------------------------------------------------------------------------- +/// 三档降级在这个文件里的对应关系(12 §四): +/// 1. 门店上下文、菜单挂了 → [HomePage] 整页 [ErrorView] + 重试 +/// 2. 待办、预警挂了 → 各自的 [_SectionAsync] 显示 [TileErrorView],其余照常 +/// 3. 菜单 tile 的角标挂了 → [_MenuSection] 里不显示角标,**不显示任何错误 UI** +/// +/// 每一档都由"谁 watch 谁"决定,不是由 try-catch 决定——所以这三个 section +/// 必须各 watch 各的 provider,不能在上层合并(见 `home_providers.dart`)。 +/// --------------------------------------------------------------------------- +class HomePage extends ConsumerWidget { + /// 构造。 + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue session = ref.watch(sessionProvider); + final AppSession? value = session.value; + final StoreContext? store = value is SessionActive ? value.store : null; + + return Scaffold( + appBar: AppBar( + title: Text(store?.storeName ?? '工作台'), + actions: [ + IconButton( + key: const Key('home_store_switch'), + icon: const Icon(Icons.store_outlined), + tooltip: '切换门店', + onPressed: () => context.push(AppRoutes.storePicker), + ), + ], + ), + body: store == null + // 第 1 档:没有门店上下文,首页整体没有意义。 + ? AsyncValueView( + value: session, + onRetry: () => unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()), + data: (AppSession _) => const Center(child: CircularProgressIndicator()), + ) + : ListView( + padding: const EdgeInsets.all(16), + children: const [_MenuSection(), _TodoSection(), _AlertSection()], + ), + ); + } +} + +class _MenuSection extends ConsumerWidget { + const _MenuSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final List entries = ref.watch(homeMenuEntriesProvider); + + // 第 3 档降级:角标是锦上添花,拿不到就不显示。 + // 这里**故意**只取 .value 而不处理 error——待办接口挂了,菜单入口照常 + // 能点,用户仍然能进去干活。给角标加一个错误 UI 只会挡住入口。 + final Map badges = { + for (final TodoItem todo in ref.watch(homeTodosProvider).value ?? const []) + todo.code: todo.count, + }; + + if (entries.isEmpty) { + return const _Section(title: '常用功能', child: Text('当前门店没有可用功能')); + } + + return _Section( + title: '常用功能', + child: GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (final MenuEntry entry in entries) _MenuTile(entry: entry, badge: badges[entry.code]), + ], + ), + ); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile({required this.entry, this.badge}); + + final MenuEntry entry; + final int? badge; + + @override + Widget build(BuildContext context) { + return InkWell( + key: Key('home_menu_${entry.code}'), + // 路由来自 menuRouteMap,绝不会是后端下发的 URL(04 的安全约定)。 + onTap: () => context.push(entry.route), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Badge( + isLabelVisible: badge != null && badge! > 0, + label: Text('$badge'), + child: const Icon(Icons.widgets_outlined, size: 32), + ), + const SizedBox(height: 8), + Text(entry.name, textAlign: TextAlign.center), + ], + ), + ); + } +} + +class _TodoSection extends ConsumerWidget { + const _TodoSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _Section( + title: '待办', + child: _SectionAsync>( + value: ref.watch(homeTodosProvider), + onRetry: () => ref.invalidate(homeTodosProvider), + empty: '暂无待办', + data: (List todos) => Column( + children: [ + for (final TodoItem todo in todos) + ListTile( + key: Key('home_todo_${todo.code}'), + title: Text(todo.title), + trailing: Text('${todo.count}'), + onTap: () { + final String? route = resolveMenuRoute(todo.code); + if (route != null) { + unawaited(context.push(route)); + } + }, + ), + ], + ), + ), + ); + } +} + +class _AlertSection extends ConsumerWidget { + const _AlertSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _Section( + title: '预警', + child: _SectionAsync>( + value: ref.watch(homeAlertsProvider), + onRetry: () => ref.invalidate(homeAlertsProvider), + empty: '暂无预警', + data: (List alerts) => Column( + children: [ + for (final AlertItem alert in alerts) + ListTile(title: Text(alert.title), subtitle: Text(alert.detail)), + ], + ), + ), + ); + } +} + +/// 区块级三态。 +/// +/// 和 core_ui 的 [AsyncValueView] 唯一的区别:错误态用 [TileErrorView] 而不是 +/// 整页 [ErrorView]——这就是第 2 档降级。判定顺序保持一致(先看有没有数据, +/// 刷新失败时继续渲染旧数据)。 +class _SectionAsync extends StatelessWidget { + const _SectionAsync({ + required this.value, + required this.data, + required this.onRetry, + required this.empty, + }); + + final AsyncValue value; + final Widget Function(T data) data; + final VoidCallback onRetry; + final String empty; + + @override + Widget build(BuildContext context) { + if (value.hasValue) { + final T current = value.value as T; + if (current is List && current.isEmpty) { + return Text(empty); + } + return data(current); + } + if (value.hasError && !ErrorPresenter.isSilent(value.error!)) { + return TileErrorView(error: value.error!, onRetry: onRetry); + } + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + child, + ], + ), + ); + } +} diff --git a/packages/feature_home/lib/src/presentation/home_providers.dart b/packages/feature_home/lib/src/presentation/home_providers.dart new file mode 100644 index 0000000..287715b --- /dev/null +++ b/packages/feature_home/lib/src/presentation/home_providers.dart @@ -0,0 +1,84 @@ +/// 工作台的 provider。来源:conti-docs/12-error-and-api-contract.md §四。 +library; + +import 'package:core_analytics/core_analytics.dart'; +import 'package:core_auth/core_auth.dart'; +import 'package:core_router/core_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/home_models.dart'; +import '../data/home_repository.dart'; + +part 'home_providers.g.dart'; + +/// 一个已经解析到本端路由的菜单入口。 +typedef MenuEntry = ({String code, String name, String route}); + +/// 待办。 +/// +/// --------------------------------------------------------------------------- +/// **为什么每个区块一个 provider,而不是一个 `Future.wait` 拉全部?** +/// +/// `Future.wait` 的语义是"全部成功才算成功"——四个请求里任意一个挂了,整个 +/// 工作台就是错误态。但门店现场最常见的情况恰恰是某一个下游服务抖动, +/// 这时把已经拿到的待办、菜单一起藏起来,用户就什么都干不了了。 +/// +/// 12 §四把降级粒度定死成三档: +/// 1. 门店上下文、菜单 → 整页错误态 + 重试(没有它首页无意义) +/// 2. 待办、预警、公告、促销位 → 该区块局部错误态,其余正常 +/// 3. tile 上的数字/角标 → 降级为不显示角标,不显示错误 UI +/// +/// 只有"一个区块一个 provider、各自 watch 各自的"这种写法能表达第 2 档。 +/// --------------------------------------------------------------------------- +/// +/// `ref.watch(currentStoreIdProvider)` 不是为了用返回值,而是为了**订阅门店**: +/// 切店后本 provider 自动失效重拉(11 §切店级联的第 4 步)。漏了这一句, +/// 切完店首页还显示上一家店的待办。 +@riverpod +Future> homeTodos(Ref ref) { + ref.watch(currentStoreIdProvider); + return ref.watch(homeRepositoryProvider).fetchTodos(); +} + +/// 预警。失败时只影响预警区块,见 [homeTodos] 的说明。 +@riverpod +Future> homeAlerts(Ref ref) { + ref.watch(currentStoreIdProvider); + return ref.watch(homeRepositoryProvider).fetchAlerts(); +} + +/// 当前门店的菜单,已按本端路由表过滤。 +/// +/// --------------------------------------------------------------------------- +/// 后端下发了本端没有的编码时:**隐藏该入口 + 上报**(04)。不能弹错、不能 +/// 留一个点了没反应的格子——灰度期后端先配菜单、App 后发版是常态。 +/// +/// 解析和上报放在 provider 里而不是 `build()` 里:`build()` 每帧都可能重跑, +/// 埋点会被刷爆;provider 只在门店(菜单随门店下发)变化时重算一次。 +/// --------------------------------------------------------------------------- +@riverpod +List homeMenuEntries(Ref ref) { + final AppSession? session = ref.watch(sessionProvider).value; + final List menus = session is SessionActive ? session.store.menus : const []; + + final List entries = []; + final List unsupported = []; + for (final MenuItem item in menus) { + final String? route = resolveMenuRoute(item.code); + if (route == null) { + unsupported.add(item.code); + continue; + } + entries.add((code: item.code, name: item.name, route: route)); + } + + if (unsupported.isNotEmpty) { + final Analytics analytics = ref.read(analyticsProvider); + for (final String code in unsupported) { + analytics.track(AnalyticsEvent.menuCodeUnsupported, { + AnalyticsParam.code: code, + }); + } + } + return entries; +} diff --git a/packages/feature_home/lib/src/routes.dart b/packages/feature_home/lib/src/routes.dart new file mode 100644 index 0000000..b11c8a4 --- /dev/null +++ b/packages/feature_home/lib/src/routes.dart @@ -0,0 +1,19 @@ +/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。 +library; + +import 'package:core_router/core_router.dart'; +import 'package:flutter/widgets.dart'; + +import 'presentation/home_page.dart'; + +/// 工作台路由。 +/// +/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——core_router 不依赖任何 +/// feature,feature 之间也不互相 import。`GoRoute` 来自 core_router 的 +/// re-export,本包 pubspec 里没有 go_router。 +List buildHomeRoutes() => [ + GoRoute( + path: AppRoutes.home, + builder: (BuildContext context, GoRouterState state) => const HomePage(), + ), +]; diff --git a/packages/feature_home/pubspec.yaml b/packages/feature_home/pubspec.yaml new file mode 100644 index 0000000..8a955f2 --- /dev/null +++ b/packages/feature_home/pubspec.yaml @@ -0,0 +1,32 @@ +name: feature_home +description: 工作台(首页)。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# feature_* 的 pubspec 是包边界的**执行现场**: +# - 这里不出现 feature_auth(feature 间禁止互相依赖,见 01)——工作台看起来 +# "需要" 登录信息,但它拿的是 core_auth 的会话状态,不是 feature_auth 的页面 +# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04) +environment: + sdk: ^3.12.0 + +dependencies: + core_analytics: ^0.1.0 + core_auth: ^0.1.0 + core_foundation: ^0.1.0 + core_network: ^0.1.0 + core_router: ^0.1.0 + core_ui: ^0.1.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + riverpod_annotation: ^4.0.3 + +dev_dependencies: + build_runner: ^2.4.13 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.5 + riverpod_generator: ^4.0.4 diff --git a/packages/feature_home/test/home_page_test.dart b/packages/feature_home/test/home_page_test.dart new file mode 100644 index 0000000..d3d46be --- /dev/null +++ b/packages/feature_home/test/home_page_test.dart @@ -0,0 +1,158 @@ +// feature_home 的高价值断言全部围绕**降级粒度**(12 §四)——工作台的复杂度 +// 不在于渲染,而在于"四个数据源里挂了一个的时候,屏幕上还剩下什么"。 +// 1. 待办挂了:菜单入口必须还在、角标消失、不能整页报错。 +// 2. 后端下发了本端没有的菜单编码:隐藏入口 + 上报,而不是留个死格子。 + +import 'package:core_analytics/core_analytics.dart'; +import 'package:core_auth/core_auth.dart'; +import 'package:core_foundation/core_foundation.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:feature_home/feature_home.dart'; +// 页面和内部 provider 不对外导出,测试走 src。 +import 'package:feature_home/src/presentation/home_page.dart'; +import 'package:feature_home/src/presentation/home_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const UserContext _user = UserContext( + userId: 'U1', + employeeId: 'E1', + phone: '13800000000', + roleCode: 'CLERK', + channel: 'RETAIL', + permissions: {}, +); + +StoreContext _store(List menus) => + StoreContext(storeId: 1, storeCode: 'S001', storeName: '朝阳店', orgId: 9, menus: menus); + +class _FakeSession extends SessionNotifier { + _FakeSession(this.session); + + final AppSession session; + + @override + Future build() async => session; +} + +class _FakeHomeRepository implements HomeRepository { + _FakeHomeRepository({this.todosError = false}); + + final bool todosError; + + @override + Future> fetchTodos() async { + if (todosError) { + throw const ServerException('boom'); + } + return const [TodoItem(code: 'PURCHASE_ORDER', title: '待收货', count: 3)]; + } + + @override + Future> fetchAlerts() async => const [ + AlertItem(title: '库存不足', detail: '3 个 SKU'), + ]; +} + +class _RecordingAnalytics implements Analytics { + final List<(String, Map)> events = <(String, Map)>[]; + + @override + void track(String event, [Map params = const {}]) => + events.add((event, params)); + + @override + void registerSuperProperties(Map props) {} + + @override + void identify(String userId) {} + + @override + void reset() {} +} + +Widget _host(AppSession session, HomeRepository repo, {Analytics? analytics}) { + return ProviderScope( + overrides: [ + sessionProvider.overrideWith(() => _FakeSession(session)), + homeRepositoryProvider.overrideWithValue(repo), + if (analytics != null) analyticsProvider.overrideWithValue(analytics), + ], + child: const MaterialApp(home: HomePage()), + ); +} + +void main() { + testWidgets('待办接口挂了,菜单入口和预警照常显示,只有待办区块降级', (WidgetTester tester) async { + final AppSession session = SessionActive( + user: _user, + store: _store(const [MenuItem(code: 'PURCHASE_ORDER', name: '采购下单')]), + ); + + await tester.pumpWidget(_host(session, _FakeHomeRepository(todosError: true))); + await tester.pumpAndSettle(); + + // 第 2 档:待办自己显示局部错误态。 + expect(find.byType(TileErrorView), findsOneWidget); + // 第 1 档没有触发:整页错误态不能出现。 + expect(find.byType(ErrorView), findsNothing); + + // 菜单入口还在——这是这条用例的重点,用户仍然能进去干活。 + expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget); + // 第 3 档:角标拿不到就不显示,不是显示 0,更不是显示错误。 + expect(find.text('3'), findsNothing); + + // 相邻区块不受牵连——这正是不能用 Future.wait 的原因。 + expect(find.text('库存不足'), findsOneWidget); + }); + + testWidgets('后端下发本端没有的菜单编码时隐藏入口并上报', (WidgetTester tester) async { + final _RecordingAnalytics analytics = _RecordingAnalytics(); + final AppSession session = SessionActive( + user: _user, + store: _store(const [ + MenuItem(code: 'PURCHASE_ORDER', name: '采购下单'), + MenuItem(code: 'BRAND_NEW_THING', name: '未来功能'), + ]), + ); + + await tester.pumpWidget(_host(session, _FakeHomeRepository(), analytics: analytics)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget); + // 点了没反应的格子比看不见更糟:灰度期后端先配菜单、App 后发版是常态。 + expect(find.text('未来功能'), findsNothing); + + expect(analytics.events, hasLength(1)); + expect(analytics.events.single.$1, AnalyticsEvent.menuCodeUnsupported); + expect(analytics.events.single.$2[AnalyticsParam.code], 'BRAND_NEW_THING'); + }); + + test('菜单解析只保留本端路由表里有的编码', () async { + final ProviderContainer container = ProviderContainer( + overrides: [ + sessionProvider.overrideWith( + () => _FakeSession( + SessionActive( + user: _user, + store: _store(const [ + MenuItem(code: 'QUOTE_ORDER', name: '报价单'), + MenuItem(code: 'NOPE', name: '不存在'), + ]), + ), + ), + ), + ], + ); + addTearDown(container.dispose); + + // sessionProvider 是异步的:不等它 resolve 就读,菜单是空的。 + await container.read(sessionProvider.future); + + final List entries = container.read(homeMenuEntriesProvider); + expect(entries.map((MenuEntry e) => e.code), ['QUOTE_ORDER']); + // 路由里只带 target 编码,绝不出现后端下发的 URL(04 的安全约定)。 + expect(entries.single.route, isNot(contains('http'))); + }); +} diff --git a/packages/native_scan/.gitignore b/packages/native_scan/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/packages/native_scan/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/native_scan/.metadata b/packages/native_scan/.metadata new file mode 100644 index 0000000..b8ce20c --- /dev/null +++ b/packages/native_scan/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6b182d2c7585eba26d4edce0f97630effd256c33" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: android + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: ios + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/native_scan/CHANGELOG.md b/packages/native_scan/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/packages/native_scan/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/packages/native_scan/LICENSE b/packages/native_scan/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/packages/native_scan/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/native_scan/README.md b/packages/native_scan/README.md new file mode 100644 index 0000000..1ff9f21 --- /dev/null +++ b/packages/native_scan/README.md @@ -0,0 +1,15 @@ +# native_scan + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/packages/native_scan/analysis_options.yaml b/packages/native_scan/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/native_scan/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/native_scan/android/.gitignore b/packages/native_scan/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/packages/native_scan/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/native_scan/android/build.gradle.kts b/packages/native_scan/android/build.gradle.kts new file mode 100644 index 0000000..b0baff4 --- /dev/null +++ b/packages/native_scan/android/build.gradle.kts @@ -0,0 +1,77 @@ +group = "com.conti.native_scan" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.3.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.conti.native_scan" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/native_scan/android/settings.gradle.kts b/packages/native_scan/android/settings.gradle.kts new file mode 100644 index 0000000..c8b529b --- /dev/null +++ b/packages/native_scan/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "native_scan" diff --git a/packages/native_scan/android/src/main/AndroidManifest.xml b/packages/native_scan/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..00ad8e3 --- /dev/null +++ b/packages/native_scan/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt b/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt new file mode 100644 index 0000000..1892f8d --- /dev/null +++ b/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt @@ -0,0 +1,28 @@ +package com.conti.native_scan + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +/** + * 扫码插件的 Android 入口。 + * + * TODO(native): 手写原生实现本次不在范围内,见 conti-docs/07-native-integration.md。 + * 补完时需要做的事: + * 1. 让某个类实现 pigeon 生成的 `ScanHostApi`(见 ScanApi.g.kt,不要手改生成物); + * 2. 在 onAttachedToEngine 里 `ScanHostApi.setUp(binding.binaryMessenger, impl)`; + * 3. 实现 ActivityAware 拿到 Activity(扫码要起页面、要申请相机权限); + * 4. 取消 / 权限拒绝 / 能力不可用必须回 FlutterError,code 用 + * CANCELLED / PERMISSION_DENIED / UNAVAILABLE —— 与 Dart 侧 + * NativeScanErrorCode 一一对应,**不允许返回占位假数据**。 + * + * 在此之前,Dart 侧调用会收到 MissingPluginException, + * 并被 NativeScan 转成 UNSUPPORTED_PLATFORM,属预期行为。 + */ +class NativeScanPlugin : FlutterPlugin { + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // TODO(native): ScanHostApi.setUp(binding.binaryMessenger, ScanApiImpl(...)) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // TODO(native): ScanHostApi.setUp(binding.binaryMessenger, null) + } +} diff --git a/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt b/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt new file mode 100644 index 0000000..731eb20 --- /dev/null +++ b/packages/native_scan/android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt @@ -0,0 +1,445 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.conti.native_scan + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object ScanApiPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun doubleEquals(a: Double, b: Double): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) + } + + fun floatEquals(a: Float, b: Float): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN()) + } + + fun doubleHash(d: Double): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (d == 0.0) 0.0 else d + val bits = java.lang.Double.doubleToLongBits(normalized) + return (bits xor (bits ushr 32)).toInt() + } + + fun floatHash(f: Float): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (f == 0.0f) 0.0f else f + return java.lang.Float.floatToIntBits(normalized) + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + if (a == null || b == null) { + return false + } + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!doubleEquals(a[i], b[i])) return false + } + return true + } + if (a is FloatArray && b is FloatArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!floatEquals(a[i], b[i])) return false + } + return true + } + if (a is Array<*> && b is Array<*>) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + val iterA = a.iterator() + val iterB = b.iterator() + while (iterA.hasNext() && iterB.hasNext()) { + if (!deepEquals(iterA.next(), iterB.next())) return false + } + return true + } + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + for (entry in a) { + val key = entry.key + var found = false + for (bEntry in b) { + if (deepEquals(key, bEntry.key)) { + if (deepEquals(entry.value, bEntry.value)) { + found = true + break + } else { + return false + } + } + } + if (!found) return false + } + return true + } + if (a is Double && b is Double) { + return doubleEquals(a, b) + } + if (a is Float && b is Float) { + return floatEquals(a, b) + } + return a == b + } + + fun deepHash(value: Any?): Int { + return when (value) { + null -> 0 + is ByteArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is DoubleArray -> { + var result = 1 + for (item in value) { + result = 31 * result + doubleHash(item) + } + result + } + is FloatArray -> { + var result = 1 + for (item in value) { + result = 31 * result + floatHash(item) + } + result + } + is Array<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is List<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is Map<*, *> -> { + var result = 0 + for (entry in value) { + result += ((deepHash(entry.key) * 31) xor deepHash(entry.value)) + } + result + } + is Double -> doubleHash(value) + is Float -> floatHash(value) + else -> value.hashCode() + } + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() + +/** + * 识别类型。 + * + * **即使首版只做条码,这个参数也必须先留出来**(07 §「待确认:VIN 码与车牌 + * 识别的技术路径」):车牌走的是专用 OCR、VIN 印刷字符走通用 OCR + 校验位 + * 过滤,技术路径还没定。参数先在 schema 里占好位,后面加识别类型就不用改 + * 接口签名——改签名意味着三端生成物和所有调用点一起动。 + */ +enum class ScanMode(val raw: Int) { + /** 二维码 / 条形码(商品、库位)。 */ + BARCODE(0), + /** VIN 码。可能是 Code 39 条码,也可能只有印刷字符。 */ + VIN(1), + /** 车牌。 */ + PLATE(2); + + companion object { + fun ofRaw(raw: Int): ScanMode? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * 扫码入参。 + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ScanOptions ( + /** 识别类型。 */ + val mode: ScanMode, + /** 超时毫秒数。null 表示不超时,由用户手动取消。 */ + val timeoutMs: Long? = null, + /** 是否默认打开闪光灯。 */ + val torchEnabled: Boolean? = null, + /** 扫码页标题。由调用方传,`native_scan` 不依赖任何 i18n 资源。 */ + val title: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ScanOptions { + val mode = pigeonVar_list[0] as ScanMode + val timeoutMs = pigeonVar_list[1] as Long? + val torchEnabled = pigeonVar_list[2] as Boolean? + val title = pigeonVar_list[3] as String? + return ScanOptions(mode, timeoutMs, torchEnabled, title) + } + } + fun toList(): List { + return listOf( + mode, + timeoutMs, + torchEnabled, + title, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as ScanOptions + return ScanApiPigeonUtils.deepEquals(this.mode, other.mode) && ScanApiPigeonUtils.deepEquals(this.timeoutMs, other.timeoutMs) && ScanApiPigeonUtils.deepEquals(this.torchEnabled, other.torchEnabled) && ScanApiPigeonUtils.deepEquals(this.title, other.title) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + ScanApiPigeonUtils.deepHash(this.mode) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.timeoutMs) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.torchEnabled) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.title) + return result + } + override fun toString(): String { + return "ScanOptions(mode=$mode, timeoutMs=$timeoutMs, torchEnabled=$torchEnabled, title=$title)" + } +} + +/** + * 扫码结果。 + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ScanResult ( + /** 实际生效的识别类型。 */ + val mode: ScanMode, + /** 识别到的文本。 */ + val value: String, + /** 从打开扫码页到出结果的耗时,供埋点用(见 13 的 `scan_succeeded`)。 */ + val durationMs: Long, + /** 原始码制(如 `CODE_39` / `QR_CODE`)。OCR 路径下为 null。 */ + val rawFormat: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ScanResult { + val mode = pigeonVar_list[0] as ScanMode + val value = pigeonVar_list[1] as String + val durationMs = pigeonVar_list[2] as Long + val rawFormat = pigeonVar_list[3] as String? + return ScanResult(mode, value, durationMs, rawFormat) + } + } + fun toList(): List { + return listOf( + mode, + value, + durationMs, + rawFormat, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as ScanResult + return ScanApiPigeonUtils.deepEquals(this.mode, other.mode) && ScanApiPigeonUtils.deepEquals(this.value, other.value) && ScanApiPigeonUtils.deepEquals(this.durationMs, other.durationMs) && ScanApiPigeonUtils.deepEquals(this.rawFormat, other.rawFormat) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + ScanApiPigeonUtils.deepHash(this.mode) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.value) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.durationMs) + result = 31 * result + ScanApiPigeonUtils.deepHash(this.rawFormat) + return result + } + override fun toString(): String { + return "ScanResult(mode=$mode, value=$value, durationMs=$durationMs, rawFormat=$rawFormat)" + } +} +private open class ScanApiPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + ScanMode.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + ScanOptions.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + ScanResult.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is ScanMode -> { + stream.write(129) + writeValue(stream, value.raw.toLong()) + } + is ScanOptions -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is ScanResult -> { + stream.write(131) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** + * Dart → 原生。 + * + * 用户取消、权限拒绝、平台未实现这三类都通过 `FlutterError` 抛出, + * 由 Dart 侧的公共 API 转成 `NativeScanException`——**原生异常类型 + * (`PlatformException`)不允许直接抛到业务代码里**(07 §使用规则)。 + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface ScanHostApi { + /** + * 打开扫码页并等待一次结果。 + * + * 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null—— + * 「取消」和「扫到了空字符串」必须能区分开。 + */ + fun startScan(options: ScanOptions, callback: (Result) -> Unit) + /** + * 当前平台是否支持指定识别类型。 + * + * 车牌识别的技术路径未定(见上),首版可能只有部分平台支持; + * 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。 + */ + fun isModeSupported(mode: ScanMode): Boolean + + companion object { + /** The codec used by ScanHostApi. */ + val codec: MessageCodec by lazy { + ScanApiPigeonCodec() + } + /** Sets up an instance of `ScanHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: ScanHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.native_scan.ScanHostApi.startScan$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsArg = args[0] as ScanOptions + api.startScan(optionsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(ScanApiPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(ScanApiPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.native_scan.ScanHostApi.isModeSupported$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val modeArg = args[0] as ScanMode + val wrapped: List = try { + listOf(api.isModeSupported(modeArg)) + } catch (exception: Throwable) { + ScanApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/native_scan/example/.gitignore b/packages/native_scan/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/packages/native_scan/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/native_scan/example/README.md b/packages/native_scan/example/README.md new file mode 100644 index 0000000..de2d4bb --- /dev/null +++ b/packages/native_scan/example/README.md @@ -0,0 +1,17 @@ +# native_scan_example + +Demonstrates how to use the native_scan plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/native_scan/example/analysis_options.yaml b/packages/native_scan/example/analysis_options.yaml new file mode 100644 index 0000000..e2badd7 --- /dev/null +++ b/packages/native_scan/example/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../analysis_options.yaml diff --git a/packages/native_scan/example/android/.gitignore b/packages/native_scan/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/packages/native_scan/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/packages/native_scan/example/android/app/build.gradle.kts b/packages/native_scan/example/android/app/build.gradle.kts new file mode 100644 index 0000000..889c60d --- /dev/null +++ b/packages/native_scan/example/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.conti.native_scan_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.conti.native_scan_example" + // 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 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/packages/native_scan/example/android/app/src/debug/AndroidManifest.xml b/packages/native_scan/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/native_scan/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/native_scan/example/android/app/src/main/AndroidManifest.xml b/packages/native_scan/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..931c665 --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/native_scan/example/android/app/src/main/kotlin/com/conti/native_scan_example/MainActivity.kt b/packages/native_scan/example/android/app/src/main/kotlin/com/conti/native_scan_example/MainActivity.kt new file mode 100644 index 0000000..cf5ff85 --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/kotlin/com/conti/native_scan_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.conti.native_scan_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/packages/native_scan/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/native_scan/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/native_scan/example/android/app/src/main/res/drawable/launch_background.xml b/packages/native_scan/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/native_scan/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/native_scan/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/native_scan/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/native_scan/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/native_scan/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/android/app/src/main/res/values-night/styles.xml b/packages/native_scan/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/native_scan/example/android/app/src/main/res/values/styles.xml b/packages/native_scan/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/packages/native_scan/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/native_scan/example/android/app/src/profile/AndroidManifest.xml b/packages/native_scan/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/native_scan/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/native_scan/example/android/build.gradle.kts b/packages/native_scan/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/packages/native_scan/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/native_scan/example/android/gradle.properties b/packages/native_scan/example/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/packages/native_scan/example/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/packages/native_scan/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/native_scan/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/packages/native_scan/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/packages/native_scan/example/android/settings.gradle.kts b/packages/native_scan/example/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/packages/native_scan/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/packages/native_scan/example/integration_test/plugin_integration_test.dart b/packages/native_scan/example/integration_test/plugin_integration_test.dart new file mode 100644 index 0000000..472602c --- /dev/null +++ b/packages/native_scan/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,19 @@ +// 原生侧接通后才能跑的集成测试。 +// +// 现在 Kotlin / Swift 实现还是空的(见 07),所以只验证「能力查询」这条 +// 不会崩、并且**如实返回 false**——静默返回 true 才是危险的假象。 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:native_scan/native_scan.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('isModeSupported 在原生未实现时返回 false 而不是抛错', (WidgetTester tester) async { + final NativeScan plugin = NativeScan(); + + // TODO(native): 原生实现落地后改为 expect(..., isTrue),并补 startScan 的用例。 + expect(await plugin.isModeSupported(ScanMode.barcode), isFalse); + }); +} diff --git a/packages/native_scan/example/ios/.gitignore b/packages/native_scan/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/native_scan/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/native_scan/example/ios/Flutter/AppFrameworkInfo.plist b/packages/native_scan/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/packages/native_scan/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/packages/native_scan/example/ios/Flutter/Debug.xcconfig b/packages/native_scan/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/packages/native_scan/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/packages/native_scan/example/ios/Flutter/Release.xcconfig b/packages/native_scan/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/packages/native_scan/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/packages/native_scan/example/ios/Runner.xcodeproj/project.pbxproj b/packages/native_scan/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..86d29ca --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,648 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* native_scan */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = native_scan; path = ../../ios/native_scan; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78DABEA22ED26510000E7860 /* native_scan */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.conti.nativeScanExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/native_scan/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/native_scan/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/native_scan/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/native_scan/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/native_scan/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/native_scan/example/ios/Runner/AppDelegate.swift b/packages/native_scan/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/packages/native_scan/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/native_scan/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/native_scan/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/native_scan/example/ios/Runner/Base.lproj/Main.storyboard b/packages/native_scan/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/native_scan/example/ios/Runner/Info.plist b/packages/native_scan/example/ios/Runner/Info.plist new file mode 100644 index 0000000..7fe0fbf --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Native Scan + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + native_scan_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/packages/native_scan/example/ios/Runner/Runner-Bridging-Header.h b/packages/native_scan/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/native_scan/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/native_scan/example/ios/Runner/SceneDelegate.swift b/packages/native_scan/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/packages/native_scan/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/packages/native_scan/example/ios/RunnerTests/RunnerTests.swift b/packages/native_scan/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..a57176f --- /dev/null +++ b/packages/native_scan/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,29 @@ +import Flutter +import UIKit +import XCTest + +// If your plugin has been explicitly set to "type: .dynamic" in the Package.swift, +// you will need to add your plugin as a dependency of RunnerTests within Xcode. + +@testable import native_scan + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = NativeScanPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/packages/native_scan/example/lib/main.dart b/packages/native_scan/example/lib/main.dart new file mode 100644 index 0000000..465dbe8 --- /dev/null +++ b/packages/native_scan/example/lib/main.dart @@ -0,0 +1,70 @@ +// native_scan 的最小演示壳。 +// +// 只用来手工验证原生实现接没接上:Kotlin / Swift 侧还没写(见 07), +// 所以现在点「扫码」必然走到 UNSUPPORTED_PLATFORM 分支——这是预期结果, +// 也正好演示了 07 §「OHOS 后续演进」要求的**不静默返回假数据**。 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:native_scan/native_scan.dart'; + +void main() { + runApp(const ExampleApp()); +} + +/// 演示用的根 Widget。 +class ExampleApp extends StatefulWidget { + /// 构造。 + const ExampleApp({super.key}); + + @override + State createState() => _ExampleAppState(); +} + +class _ExampleAppState extends State { + final NativeScan _scan = NativeScan(); + String _result = '未扫码'; + + Future _startScan() async { + try { + final ScanResult result = await _scan.startScan( + ScanOptions(mode: ScanMode.barcode, title: '扫描商品条码'), + ); + if (!mounted) return; + setState(() { + _result = '${result.value}(${result.durationMs}ms)'; + }); + } on NativeScanException catch (e) { + if (!mounted) return; + // 取消不是错误,静默回到原状态即可(07 §使用规则)。 + setState(() { + _result = e.isCancelled ? '已取消' : '${e.code}: ${e.message}'; + }); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('native_scan example')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_result), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + unawaited(_startScan()); + }, + child: const Text('扫码'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/native_scan/example/pubspec.yaml b/packages/native_scan/example/pubspec.yaml new file mode 100644 index 0000000..31c2f72 --- /dev/null +++ b/packages/native_scan/example/pubspec.yaml @@ -0,0 +1,22 @@ +name: native_scan_example +description: native_scan 的手动验证入口。补完原生实现后用它单独跑扫码,不用起整个 App。 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + flutter: + sdk: flutter + native_scan: ^0.1.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/native_scan/example/test/widget_test.dart b/packages/native_scan/example/test/widget_test.dart new file mode 100644 index 0000000..c89cabf --- /dev/null +++ b/packages/native_scan/example/test/widget_test.dart @@ -0,0 +1,17 @@ +// example 的 smoke test:只验证壳能起来。 +// +// 真正的扫码行为依赖原生实现,单测里跑不了;原生接通后请写 +// integration_test 而不是在这里 mock。 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_scan_example/main.dart'; + +void main() { + testWidgets('渲染出扫码入口', (WidgetTester tester) async { + await tester.pumpWidget(const ExampleApp()); + + expect(find.text('未扫码'), findsOneWidget); + expect(find.byWidgetPredicate((Widget w) => w is FilledButton), findsOneWidget); + }); +} diff --git a/packages/native_scan/ios/.gitignore b/packages/native_scan/ios/.gitignore new file mode 100644 index 0000000..034771f --- /dev/null +++ b/packages/native_scan/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/packages/native_scan/ios/native_scan.podspec b/packages/native_scan/ios/native_scan.podspec new file mode 100644 index 0000000..5e9da99 --- /dev/null +++ b/packages/native_scan/ios/native_scan.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint native_scan.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'native_scan' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'native_scan/Sources/native_scan/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'native_scan_privacy' => ['native_scan/Sources/native_scan/PrivacyInfo.xcprivacy']} +end diff --git a/packages/native_scan/ios/native_scan/Package.swift b/packages/native_scan/ios/native_scan/Package.swift new file mode 100644 index 0000000..6ccbbfa --- /dev/null +++ b/packages/native_scan/ios/native_scan/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "native_scan", + platforms: [ + .iOS("13.0") + ], + products: [ + .library(name: "native-scan", targets: ["native_scan"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "native_scan", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + resources: [ + // If your plugin requires a privacy manifest, for example if it uses any required + // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's + // privacy impact, and then uncomment these lines. For more information, see + // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + // .process("PrivacyInfo.xcprivacy"), + + // If you have other resources that need to be bundled with your plugin, refer to + // the following instructions to add them: + // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + ] + ) + ] +) diff --git a/packages/native_scan/ios/native_scan/Sources/native_scan/NativeScanPlugin.swift b/packages/native_scan/ios/native_scan/Sources/native_scan/NativeScanPlugin.swift new file mode 100644 index 0000000..b648fea --- /dev/null +++ b/packages/native_scan/ios/native_scan/Sources/native_scan/NativeScanPlugin.swift @@ -0,0 +1,21 @@ +import Flutter +import UIKit + +/// 扫码插件的 iOS 入口。 +/// +/// TODO(native): 手写原生实现本次不在范围内,见 conti-docs/07-native-integration.md。 +/// 补完时需要做的事: +/// 1. 让某个类实现 pigeon 生成的 `ScanHostApi`(见 ScanApi.g.swift,不要手改生成物); +/// 2. 在 register 里 `ScanHostApiSetup.setUp(binaryMessenger:api:)`; +/// 3. Info.plist 需要 NSCameraUsageDescription,否则相机权限弹窗会直接崩; +/// 4. 取消 / 权限拒绝 / 能力不可用必须回 FlutterError,code 用 +/// CANCELLED / PERMISSION_DENIED / UNAVAILABLE —— 与 Dart 侧 +/// NativeScanErrorCode 一一对应,**不允许返回占位假数据**。 +/// +/// 在此之前,Dart 侧调用会收到 MissingPluginException, +/// 并被 NativeScan 转成 UNSUPPORTED_PLATFORM,属预期行为。 +public class NativeScanPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // TODO(native): ScanHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: ScanApiImpl()) + } +} diff --git a/packages/native_scan/ios/native_scan/Sources/native_scan/PrivacyInfo.xcprivacy b/packages/native_scan/ios/native_scan/Sources/native_scan/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a34b7e2 --- /dev/null +++ b/packages/native_scan/ios/native_scan/Sources/native_scan/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/packages/native_scan/ios/native_scan/Sources/native_scan/ScanApi.g.swift b/packages/native_scan/ios/native_scan/Sources/native_scan/ScanApi.g.swift new file mode 100644 index 0000000..6c3fde3 --- /dev/null +++ b/packages/native_scan/ios/native_scan/Sources/native_scan/ScanApi.g.swift @@ -0,0 +1,432 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +enum ScanApiPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } + + if case Optional.some(Optional.none) = value { + return true + } + + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } + + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } + } + + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } + } + + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } + } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +/// 识别类型。 +/// +/// **即使首版只做条码,这个参数也必须先留出来**(07 §「待确认:VIN 码与车牌 +/// 识别的技术路径」):车牌走的是专用 OCR、VIN 印刷字符走通用 OCR + 校验位 +/// 过滤,技术路径还没定。参数先在 schema 里占好位,后面加识别类型就不用改 +/// 接口签名——改签名意味着三端生成物和所有调用点一起动。 +enum ScanMode: Int, CaseIterable { + /// 二维码 / 条形码(商品、库位)。 + case barcode = 0 + /// VIN 码。可能是 Code 39 条码,也可能只有印刷字符。 + case vin = 1 + /// 车牌。 + case plate = 2 +} + +/// 扫码入参。 +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ScanOptions: Hashable, CustomStringConvertible { + /// 识别类型。 + var mode: ScanMode + /// 超时毫秒数。null 表示不超时,由用户手动取消。 + var timeoutMs: Int64? = nil + /// 是否默认打开闪光灯。 + var torchEnabled: Bool? = nil + /// 扫码页标题。由调用方传,`native_scan` 不依赖任何 i18n 资源。 + var title: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ScanOptions? { + let mode = pigeonVar_list[0] as! ScanMode + let timeoutMs: Int64? = nilOrValue(pigeonVar_list[1]) + let torchEnabled: Bool? = nilOrValue(pigeonVar_list[2]) + let title: String? = nilOrValue(pigeonVar_list[3]) + + return ScanOptions( + mode: mode, + timeoutMs: timeoutMs, + torchEnabled: torchEnabled, + title: title + ) + } + func toList() -> [Any?] { + return [ + mode, + timeoutMs, + torchEnabled, + title, + ] + } + static func == (lhs: ScanOptions, rhs: ScanOptions) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return ScanApiPigeonInternal.deepEquals(lhs.mode, rhs.mode) && ScanApiPigeonInternal.deepEquals(lhs.timeoutMs, rhs.timeoutMs) && ScanApiPigeonInternal.deepEquals(lhs.torchEnabled, rhs.torchEnabled) && ScanApiPigeonInternal.deepEquals(lhs.title, rhs.title) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ScanOptions") + ScanApiPigeonInternal.deepHash(value: mode, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: timeoutMs, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: torchEnabled, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: title, hasher: &hasher) + } + + public var description: String { + return "ScanOptions(mode: \(String(describing: mode)), timeoutMs: \(String(describing: timeoutMs)), torchEnabled: \(String(describing: torchEnabled)), title: \(String(describing: title)))" + } +} + +/// 扫码结果。 +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ScanResult: Hashable, CustomStringConvertible { + /// 实际生效的识别类型。 + var mode: ScanMode + /// 识别到的文本。 + var value: String + /// 从打开扫码页到出结果的耗时,供埋点用(见 13 的 `scan_succeeded`)。 + var durationMs: Int64 + /// 原始码制(如 `CODE_39` / `QR_CODE`)。OCR 路径下为 null。 + var rawFormat: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ScanResult? { + let mode = pigeonVar_list[0] as! ScanMode + let value = pigeonVar_list[1] as! String + let durationMs = pigeonVar_list[2] as! Int64 + let rawFormat: String? = nilOrValue(pigeonVar_list[3]) + + return ScanResult( + mode: mode, + value: value, + durationMs: durationMs, + rawFormat: rawFormat + ) + } + func toList() -> [Any?] { + return [ + mode, + value, + durationMs, + rawFormat, + ] + } + static func == (lhs: ScanResult, rhs: ScanResult) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return ScanApiPigeonInternal.deepEquals(lhs.mode, rhs.mode) && ScanApiPigeonInternal.deepEquals(lhs.value, rhs.value) && ScanApiPigeonInternal.deepEquals(lhs.durationMs, rhs.durationMs) && ScanApiPigeonInternal.deepEquals(lhs.rawFormat, rhs.rawFormat) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ScanResult") + ScanApiPigeonInternal.deepHash(value: mode, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: value, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: durationMs, hasher: &hasher) + ScanApiPigeonInternal.deepHash(value: rawFormat, hasher: &hasher) + } + + public var description: String { + return "ScanResult(mode: \(String(describing: mode)), value: \(String(describing: value)), durationMs: \(String(describing: durationMs)), rawFormat: \(String(describing: rawFormat)))" + } +} + +private class ScanApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return ScanMode(rawValue: enumResultAsInt) + } + return nil + case 130: + return ScanOptions.fromList(self.readValue() as! [Any?]) + case 131: + return ScanResult.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class ScanApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ScanMode { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? ScanOptions { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? ScanResult { + super.writeByte(131) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class ScanApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return ScanApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return ScanApiPigeonCodecWriter(data: data) + } +} + +class ScanApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = ScanApiPigeonCodec(readerWriter: ScanApiPigeonCodecReaderWriter()) +} + + +/// Dart → 原生。 +/// +/// 用户取消、权限拒绝、平台未实现这三类都通过 `FlutterError` 抛出, +/// 由 Dart 侧的公共 API 转成 `NativeScanException`——**原生异常类型 +/// (`PlatformException`)不允许直接抛到业务代码里**(07 §使用规则)。 +/// +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol ScanHostApi { + /// 打开扫码页并等待一次结果。 + /// + /// 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null—— + /// 「取消」和「扫到了空字符串」必须能区分开。 + func startScan(options: ScanOptions, completion: @escaping (Result) -> Void) + /// 当前平台是否支持指定识别类型。 + /// + /// 车牌识别的技术路径未定(见上),首版可能只有部分平台支持; + /// 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。 + func isModeSupported(mode: ScanMode) throws -> Bool +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class ScanHostApiSetup { + static var codec: FlutterStandardMessageCodec { ScanApiPigeonCodec.shared } + /// Sets up an instance of `ScanHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ScanHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// 打开扫码页并等待一次结果。 + /// + /// 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null—— + /// 「取消」和「扫到了空字符串」必须能区分开。 + let startScanChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.native_scan.ScanHostApi.startScan\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startScanChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsArg = args[0] as! ScanOptions + api.startScan(options: optionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + startScanChannel.setMessageHandler(nil) + } + /// 当前平台是否支持指定识别类型。 + /// + /// 车牌识别的技术路径未定(见上),首版可能只有部分平台支持; + /// 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。 + let isModeSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.native_scan.ScanHostApi.isModeSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isModeSupportedChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let modeArg = args[0] as! ScanMode + do { + let result = try api.isModeSupported(mode: modeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isModeSupportedChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/native_scan/lib/native_scan.dart b/packages/native_scan/lib/native_scan.dart new file mode 100644 index 0000000..9212d2f --- /dev/null +++ b/packages/native_scan/lib/native_scan.dart @@ -0,0 +1,98 @@ +/// 扫码能力的对外 API。来源:conti-docs/07-native-integration.md。 +/// +/// **调用方只允许 import 这个文件**,不允许直接 import `src/generated/` 里的 +/// 生成代码(07 §使用规则)。调用方包括 `feature_scan` 和 `core_webview` 的 +/// JSBridge——后者正是 01 里「`core_*` 允许依赖 `native_*`」这条例外存在的 +/// 原因。 +/// +/// 本包按 01 的硬约束**不依赖仓库内任何其他包**(连 `core_foundation` 也不), +/// 所以这里抛的是包内自定义的 [NativeScanException];转成统一错误体系里的 +/// `NativeException` 由调用方完成。 +library; + +import 'package:flutter/services.dart'; + +import 'src/generated/scan_api.g.dart'; + +export 'src/generated/scan_api.g.dart' show ScanMode, ScanOptions, ScanResult; + +/// 扫码失败的错误码。 +/// +/// 取值与 12 的 `NativeException.code` 对齐,调用方可以直接透传。 +abstract final class NativeScanErrorCode { + /// 用户主动取消。 + /// + /// 这不是异常流程,调用方通常应当静默返回,**不弹错误提示、不上报**。 + static const String cancelled = 'CANCELLED'; + + /// 相机权限被拒绝。 + static const String permissionDenied = 'PERMISSION_DENIED'; + + /// 能力暂时不可用(相机被占用、初始化失败等)。 + static const String unavailable = 'UNAVAILABLE'; + + /// 当前平台没有实现。 + /// + /// 见 07 §「OHOS 后续演进」:**不允许静默返回空值或占位假数据**—— + /// 静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。 + static const String unsupportedPlatform = 'UNSUPPORTED_PLATFORM'; + + /// 超时。 + static const String timeout = 'TIMEOUT'; +} + +/// 扫码相关的异常。 +class NativeScanException implements Exception { + /// [code] 取自 [NativeScanErrorCode]。 + const NativeScanException(this.code, this.message); + + /// 稳定错误码。 + final String code; + + /// 面向开发者的描述。**不要直接展示给用户**——文案由调用方按 12 的 + /// `ErrorPresenter` 决定。 + final String message; + + /// 是否是用户主动取消。 + bool get isCancelled => code == NativeScanErrorCode.cancelled; + + @override + String toString() => 'NativeScanException($code): $message'; +} + +/// 扫码。 +class NativeScan { + /// [api] 仅供测试注入;生产走默认实例。 + NativeScan({ScanHostApi? api}) : _api = api ?? ScanHostApi(); + + final ScanHostApi _api; + + /// 打开扫码页并等待一次结果。 + /// + /// 用户取消时抛 [NativeScanException](`code == CANCELLED`)而不是返回 null—— + /// 「取消」和「扫到了空字符串」必须能区分开。 + Future startScan(ScanOptions options) async { + try { + return await _api.startScan(options); + } on PlatformException catch (e) { + // 原生异常类型不外泄(07 §使用规则)。 + throw NativeScanException(e.code, e.message ?? '扫码失败'); + } on MissingPluginException { + throw const NativeScanException(NativeScanErrorCode.unsupportedPlatform, '当前平台未实现扫码能力'); + } + } + + /// 当前平台是否支持指定识别类型。 + /// + /// 调用方应当先查这个再决定要不要显示入口,而不是等 [startScan] 抛错—— + /// 车牌识别的技术路径尚未确定(07 待确认项),首版可能只有部分平台支持。 + Future isModeSupported(ScanMode mode) async { + try { + return await _api.isModeSupported(mode); + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } + } +} diff --git a/packages/native_scan/lib/src/generated/scan_api.g.dart b/packages/native_scan/lib/src/generated/scan_api.g.dart new file mode 100644 index 0000000..af50f7e --- /dev/null +++ b/packages/native_scan/lib/src/generated/scan_api.g.dart @@ -0,0 +1,335 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && + a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + +/// 识别类型。 +/// +/// **即使首版只做条码,这个参数也必须先留出来**(07 §「待确认:VIN 码与车牌 +/// 识别的技术路径」):车牌走的是专用 OCR、VIN 印刷字符走通用 OCR + 校验位 +/// 过滤,技术路径还没定。参数先在 schema 里占好位,后面加识别类型就不用改 +/// 接口签名——改签名意味着三端生成物和所有调用点一起动。 +enum ScanMode { + /// 二维码 / 条形码(商品、库位)。 + barcode, + + /// VIN 码。可能是 Code 39 条码,也可能只有印刷字符。 + vin, + + /// 车牌。 + plate, +} + +/// 扫码入参。 +class ScanOptions { + ScanOptions({required this.mode, this.timeoutMs, this.torchEnabled, this.title}); + + /// 识别类型。 + ScanMode mode; + + /// 超时毫秒数。null 表示不超时,由用户手动取消。 + int? timeoutMs; + + /// 是否默认打开闪光灯。 + bool? torchEnabled; + + /// 扫码页标题。由调用方传,`native_scan` 不依赖任何 i18n 资源。 + String? title; + + List _toList() { + return [mode, timeoutMs, torchEnabled, title]; + } + + Object encode() { + return _toList(); + } + + static ScanOptions decode(Object result) { + result as List; + return ScanOptions( + mode: result[0]! as ScanMode, + timeoutMs: result[1] as int?, + torchEnabled: result[2] as bool?, + title: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ScanOptions || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(mode, other.mode) && + _deepEquals(timeoutMs, other.timeoutMs) && + _deepEquals(torchEnabled, other.torchEnabled) && + _deepEquals(title, other.title); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ScanOptions(mode: $mode, timeoutMs: $timeoutMs, torchEnabled: $torchEnabled, title: $title)'; + } +} + +/// 扫码结果。 +class ScanResult { + ScanResult({required this.mode, required this.value, required this.durationMs, this.rawFormat}); + + /// 实际生效的识别类型。 + ScanMode mode; + + /// 识别到的文本。 + String value; + + /// 从打开扫码页到出结果的耗时,供埋点用(见 13 的 `scan_succeeded`)。 + int durationMs; + + /// 原始码制(如 `CODE_39` / `QR_CODE`)。OCR 路径下为 null。 + String? rawFormat; + + List _toList() { + return [mode, value, durationMs, rawFormat]; + } + + Object encode() { + return _toList(); + } + + static ScanResult decode(Object result) { + result as List; + return ScanResult( + mode: result[0]! as ScanMode, + value: result[1]! as String, + durationMs: result[2]! as int, + rawFormat: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ScanResult || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(mode, other.mode) && + _deepEquals(value, other.value) && + _deepEquals(durationMs, other.durationMs) && + _deepEquals(rawFormat, other.rawFormat); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ScanResult(mode: $mode, value: $value, durationMs: $durationMs, rawFormat: $rawFormat)'; + } +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ScanMode) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is ScanOptions) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is ScanResult) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : ScanMode.values[value]; + case 130: + return ScanOptions.decode(readValue(buffer)!); + case 131: + return ScanResult.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +/// Dart → 原生。 +/// +/// 用户取消、权限拒绝、平台未实现这三类都通过 `FlutterError` 抛出, +/// 由 Dart 侧的公共 API 转成 `NativeScanException`——**原生异常类型 +/// (`PlatformException`)不允许直接抛到业务代码里**(07 §使用规则)。 +class ScanHostApi { + /// Constructor for [ScanHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + ScanHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// 打开扫码页并等待一次结果。 + /// + /// 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null—— + /// 「取消」和「扫到了空字符串」必须能区分开。 + Future startScan(ScanOptions options) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.native_scan.ScanHostApi.startScan$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([options]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as ScanResult; + } + + /// 当前平台是否支持指定识别类型。 + /// + /// 车牌识别的技术路径未定(见上),首版可能只有部分平台支持; + /// 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。 + Future isModeSupported(ScanMode mode) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.native_scan.ScanHostApi.isModeSupported$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([mode]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } +} diff --git a/packages/native_scan/pigeons/scan_api.dart b/packages/native_scan/pigeons/scan_api.dart new file mode 100644 index 0000000..dea608a --- /dev/null +++ b/packages/native_scan/pigeons/scan_api.dart @@ -0,0 +1,93 @@ +// 唯一手写的接口契约文件。来源:07 §「Pigeon 的工程化」。 +// +// 这不是可执行代码,只是给 pigeon 生成器读的 schema。改需求就改这里重新生成, +// **生成产物不手动修改**: +// dart run pigeon --input pigeons/scan_api.dart +// (或 melos run gen:pigeon) + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/generated/scan_api.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'), + swiftOut: 'ios/native_scan/Sources/native_scan/ScanApi.g.swift', + swiftOptions: SwiftOptions(), + dartPackageName: 'native_scan', + ), +) +library; + +import 'package:pigeon/pigeon.dart'; + +/// 识别类型。 +/// +/// **即使首版只做条码,这个参数也必须先留出来**(07 §「待确认:VIN 码与车牌 +/// 识别的技术路径」):车牌走的是专用 OCR、VIN 印刷字符走通用 OCR + 校验位 +/// 过滤,技术路径还没定。参数先在 schema 里占好位,后面加识别类型就不用改 +/// 接口签名——改签名意味着三端生成物和所有调用点一起动。 +enum ScanMode { + /// 二维码 / 条形码(商品、库位)。 + barcode, + + /// VIN 码。可能是 Code 39 条码,也可能只有印刷字符。 + vin, + + /// 车牌。 + plate, +} + +/// 扫码入参。 +class ScanOptions { + ScanOptions({required this.mode, this.timeoutMs, this.torchEnabled, this.title}); + + /// 识别类型。 + ScanMode mode; + + /// 超时毫秒数。null 表示不超时,由用户手动取消。 + int? timeoutMs; + + /// 是否默认打开闪光灯。 + bool? torchEnabled; + + /// 扫码页标题。由调用方传,`native_scan` 不依赖任何 i18n 资源。 + String? title; +} + +/// 扫码结果。 +class ScanResult { + ScanResult({required this.mode, required this.value, required this.durationMs, this.rawFormat}); + + /// 实际生效的识别类型。 + ScanMode mode; + + /// 识别到的文本。 + String value; + + /// 从打开扫码页到出结果的耗时,供埋点用(见 13 的 `scan_succeeded`)。 + int durationMs; + + /// 原始码制(如 `CODE_39` / `QR_CODE`)。OCR 路径下为 null。 + String? rawFormat; +} + +/// Dart → 原生。 +/// +/// 用户取消、权限拒绝、平台未实现这三类都通过 `FlutterError` 抛出, +/// 由 Dart 侧的公共 API 转成 `NativeScanException`——**原生异常类型 +/// (`PlatformException`)不允许直接抛到业务代码里**(07 §使用规则)。 +@HostApi() +abstract class ScanHostApi { + /// 打开扫码页并等待一次结果。 + /// + /// 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null—— + /// 「取消」和「扫到了空字符串」必须能区分开。 + @async + ScanResult startScan(ScanOptions options); + + /// 当前平台是否支持指定识别类型。 + /// + /// 车牌识别的技术路径未定(见上),首版可能只有部分平台支持; + /// 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。 + bool isModeSupported(ScanMode mode); +} diff --git a/packages/native_scan/pubspec.yaml b/packages/native_scan/pubspec.yaml new file mode 100644 index 0000000..6c655c5 --- /dev/null +++ b/packages/native_scan/pubspec.yaml @@ -0,0 +1,32 @@ +name: native_scan +description: 扫码能力(条码 / VIN / 车牌)。Pigeon 定义跨语言接口。 +publish_to: none +version: 0.1.0 +resolution: workspace + +# --------------------------------------------------------------------------- +# native_* 的硬约束(01):只依赖 Flutter SDK 和 Pigeon 产物, +# **不依赖仓库内任何包**——包括 core_foundation。 +# 因此本包抛的是包内自定义的 NativeScanException,由调用方转成 NativeException。 +# --------------------------------------------------------------------------- +environment: + sdk: ^3.12.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + pigeon: ^27.3.0 + +flutter: + plugin: + platforms: + android: + package: com.conti.native_scan + pluginClass: NativeScanPlugin + ios: + pluginClass: NativeScanPlugin diff --git a/packages/native_scan/test/native_scan_test.dart b/packages/native_scan/test/native_scan_test.dart new file mode 100644 index 0000000..bcbb11a --- /dev/null +++ b/packages/native_scan/test/native_scan_test.dart @@ -0,0 +1,60 @@ +// 唯一值得单测的东西:原生异常有没有被挡在包边界内(07 §使用规则)。 + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_scan/native_scan.dart'; +import 'package:native_scan/src/generated/scan_api.g.dart'; + +// pigeon 给 @HostApi 生成的是具体类(内部持有 BinaryMessenger), +// 所以这里只能 extends 覆写方法,不能 implements。 +class _ThrowingApi extends ScanHostApi { + _ThrowingApi(this.error); + + final Object error; + + @override + Future startScan(ScanOptions options) => Future.error(error); + + @override + Future isModeSupported(ScanMode mode) => Future.error(error); +} + +void main() { + final ScanOptions options = ScanOptions(mode: ScanMode.barcode); + + test('PlatformException 被转成 NativeScanException,原生类型不外泄', () async { + final NativeScan scan = NativeScan( + api: _ThrowingApi(PlatformException(code: 'CANCELLED', message: '用户取消')), + ); + + await expectLater( + scan.startScan(options), + throwsA( + isA() + .having((NativeScanException e) => e.code, 'code', NativeScanErrorCode.cancelled) + .having((NativeScanException e) => e.isCancelled, 'isCancelled', isTrue), + ), + ); + }); + + test('原生未实现时抛 UNSUPPORTED_PLATFORM,而不是静默返回', () async { + final NativeScan scan = NativeScan(api: _ThrowingApi(MissingPluginException())); + + await expectLater( + scan.startScan(options), + throwsA( + isA().having( + (NativeScanException e) => e.code, + 'code', + NativeScanErrorCode.unsupportedPlatform, + ), + ), + ); + }); + + test('isModeSupported 出错时降级为 false,调用方据此隐藏入口', () async { + final NativeScan scan = NativeScan(api: _ThrowingApi(MissingPluginException())); + + expect(await scan.isModeSupported(ScanMode.plate), isFalse); + }); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ad8f4a4 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1238 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + url: "https://pub.dev" + source: hosted + version: "4.1.5" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.dev" + source: hosted + version: "8.12.7" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_launcher: + dependency: transitive + description: + name: cli_launcher + sha256: "96883f87648524292e24e2cc6a369fbdf64883473fe3e3ddadd3d857bf16a484" + url: "https://pub.dev" + source: hosted + version: "0.3.3+2" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + conventional_commit: + dependency: transitive + description: + name: conventional_commit + sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 + url: "https://pub.dev" + source: hosted + version: "0.6.1+1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + url: "https://pub.dev" + source: hosted + version: "3.1.8" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: transitive + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: transitive + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_secure_storage: + dependency: transitive + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + go_router: + dependency: transitive + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + injector: + dependency: transitive + description: + name: injector + sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e + url: "https://pub.dev" + source: hosted + version: "3.0.0" + integration_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + melos: + dependency: "direct dev" + description: + name: melos + sha256: "7af6507486e19a0a79f1a21e722c0c4187680b914a41a09ddb3393cc6affecb1" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 + url: "https://pub.dev" + source: hosted + version: "5.6.4" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: c689b4d59176f8c7a2860aab765d205916aa5b06cfafff7613bfcc42fa996143 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + pigeon: + dependency: transitive + description: + name: pigeon + sha256: f90254ef7b22db026fd8d5fd44718fd60697d50968a144a8bc251e5e95d79e10 + url: "https://pub.dev" + source: hosted + version: "27.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + prompts: + dependency: transitive + description: + name: prompts + sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + properties: + dependency: transitive + description: + name: properties + sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68" + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.10" + riverpod_annotation: + dependency: transitive + description: + name: riverpod_annotation + sha256: "674dbb26e2db3d9253166faf4758c796af14146b8fbcf5e7102bc8a04cd359b8" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + riverpod_generator: + dependency: transitive + description: + name: riverpod_generator + sha256: "54d790c3fee1ae281c448801bfdbfaa9fd961a9d3998494e0fcd9ee32184d7eb" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + sentry: + dependency: transitive + description: + name: sentry + sha256: c2ecd8abe82e63cdcb6947f71320612ced56e10ba94db7529d85cc02be47cb3b + url: "https://pub.dev" + source: hosted + version: "9.27.0" + sentry_dart_plugin: + dependency: transitive + description: + name: sentry_dart_plugin + sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + sentry_flutter: + dependency: transitive + description: + name: sentry_flutter + sha256: ec89cc6ba939ca19155ea83900d9740a36544f50b3b6baf265518e3348fb0f50 + url: "https://pub.dev" + source: hosted + version: "9.27.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d + url: "https://pub.dev" + source: hosted + version: "4.14.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: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..82c8394 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,69 @@ +# Conti Retail App —— 根工程(Pub Workspace + Melos) +# +# 约定来源:conti-docs/01-project-structure.md +# 注意:Melos 8 不再使用独立的 melos.yaml,配置全部内联在本文件的 melos: 段。 +# 每个子包必须写 resolution: workspace,否则不会被纳入统一解析。 +name: conti_retail_app +publish_to: none + +environment: + sdk: ^3.12.0 + +# --------------------------------------------------------------------------- +# 工作区成员。新增包时必须同步加到这里,否则 melos bootstrap 看不到它。 +# 依赖方向规则(01 依赖约束,编译期强制): +# feature_* → core_* / native_* (feature 之间禁止互相依赖) +# core_* → native_* / core_foundation (core_* 之间仅允许下列三条例外) +# core_network → core_auth +# core_router → core_auth +# core_webview → core_auth +# native_* → 只依赖 Flutter SDK 和 Pigeon 产物,不依赖仓库内任何包 +# --------------------------------------------------------------------------- +workspace: + - app + - packages/core_foundation + - packages/core_logging + - packages/core_analytics + - packages/core_storage + - packages/core_auth + - packages/core_network + - packages/core_ui + - packages/core_router + - packages/core_webview + - packages/feature_auth + - packages/feature_home + - packages/native_scan + - packages/native_scan/example + +dev_dependencies: + melos: ^8.2.2 + +melos: + scripts: + analyze: + description: 静态分析。--fatal-infos 不可去掉,否则 info 级别的 lint 等于没配。 + run: melos exec --fail-fast -- flutter analyze --fatal-infos + + format: + description: 校验格式(page_width 由根 analysis_options.yaml 的 formatter 段控制)。 + run: melos exec -- dart format --set-exit-if-changed . + + test: + description: 跑所有带 test/ 目录的包。 + run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage + + coverage: + description: 合并各包 lcov 并检查 60% 门禁。 + run: dart pub global run coverde value -i coverage/lcov.info --min-coverage 60 + + gen: + description: 代码生成(riverpod / drift / json_serializable)。 + run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs + + gen:watch: + description: 开发期常驻的增量代码生成。 + run: melos exec --depends-on=build_runner -- dart run build_runner watch --delete-conflicting-outputs + + gen:pigeon: + description: 生成 native_* 的跨语言接口。产物必须提交到 git。 + run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/scan_api.dart