Commit d6f4e78019a0d39c87be728ef36d363332cd8d0a

Authored by 权海
1 parent 2a5eda60

feat(ui):增加微信登录

@@ -30,6 +30,7 @@ target 'Runner' do @@ -30,6 +30,7 @@ target 'Runner' do
30 use_frameworks! 30 use_frameworks!
31 pod 'OBS', :path => './Runner/Library' 31 pod 'OBS', :path => './Runner/Library'
32 pod 'GoogleSignIn' 32 pod 'GoogleSignIn'
  33 + pod 'WechatOpenSDK', '~> 2.0'
33 34
34 flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
35 end 36 end
@@ -97,6 +97,7 @@ PODS: @@ -97,6 +97,7 @@ PODS:
97 - webview_flutter_wkwebview (0.0.1): 97 - webview_flutter_wkwebview (0.0.1):
98 - Flutter 98 - Flutter
99 - FlutterMacOS 99 - FlutterMacOS
  100 + - WechatOpenSDK (2.0.7)
100 101
101 DEPENDENCIES: 102 DEPENDENCIES:
102 - Flutter (from `Flutter`) 103 - Flutter (from `Flutter`)
@@ -114,6 +115,7 @@ DEPENDENCIES: @@ -114,6 +115,7 @@ DEPENDENCIES:
114 - thinking_analytics (from `.symlinks/plugins/thinking_analytics/ios`) 115 - thinking_analytics (from `.symlinks/plugins/thinking_analytics/ios`)
115 - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) 116 - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
116 - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) 117 - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
  118 + - WechatOpenSDK (~> 2.0)
117 119
118 SPEC REPOS: 120 SPEC REPOS:
119 trunk: 121 trunk:
@@ -131,6 +133,7 @@ SPEC REPOS: @@ -131,6 +133,7 @@ SPEC REPOS:
131 - ThinkingDataCore 133 - ThinkingDataCore
132 - ThinkingSDK 134 - ThinkingSDK
133 - TOCropViewController 135 - TOCropViewController
  136 + - WechatOpenSDK
134 137
135 EXTERNAL SOURCES: 138 EXTERNAL SOURCES:
136 Flutter: 139 Flutter:
@@ -191,7 +194,8 @@ SPEC CHECKSUMS: @@ -191,7 +194,8 @@ SPEC CHECKSUMS:
191 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 194 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
192 video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 195 video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
193 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 196 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2
  197 + WechatOpenSDK: fbc76decf307d241a750c9e03b687e4d11aacf37
194 198
195 -PODFILE CHECKSUM: fe3137a8e1c8e115fcac0a685d21e651b2d2788b 199 +PODFILE CHECKSUM: 186a2af126028b1ccaa73baa82e32ae092583b1a
196 200
197 COCOAPODS: 1.16.2 201 COCOAPODS: 1.16.2
@@ -40,6 +40,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { @@ -40,6 +40,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
40 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 40 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
41 ) -> Bool { 41 ) -> Bool {
42 setup() 42 setup()
  43 + WeChatLoginBridge.shared.registerApp()
43 cacheLaunchURLIfNeeded(launchOptions) 44 cacheLaunchURLIfNeeded(launchOptions)
44 return true 45 return true
45 } 46 }
@@ -49,6 +50,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { @@ -49,6 +50,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
49 open url: URL, 50 open url: URL,
50 options: [UIApplication.OpenURLOptionsKey: Any] = [:] 51 options: [UIApplication.OpenURLOptionsKey: Any] = [:]
51 ) -> Bool { 52 ) -> Bool {
  53 + if WeChatLoginBridge.shared.handleOpenURL(url) {
  54 + return true
  55 + }
52 if GIDSignIn.sharedInstance.handle(url) { 56 if GIDSignIn.sharedInstance.handle(url) {
53 return true 57 return true
54 } 58 }
@@ -61,6 +65,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { @@ -61,6 +65,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
61 continue userActivity: NSUserActivity, 65 continue userActivity: NSUserActivity,
62 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void 66 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
63 ) -> Bool { 67 ) -> Bool {
  68 + if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
  69 + WeChatLoginBridge.shared.handleUniversalLink(userActivity) {
  70 + return true
  71 + }
64 guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, 72 guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
65 let urlString = userActivity.webpageURL?.absoluteString else { 73 let urlString = userActivity.webpageURL?.absoluteString else {
66 return false 74 return false
@@ -32,6 +32,16 @@ @@ -32,6 +32,16 @@
32 <string>doublefeel</string> 32 <string>doublefeel</string>
33 </array> 33 </array>
34 </dict> 34 </dict>
  35 + <dict>
  36 + <key>CFBundleTypeRole</key>
  37 + <string>Editor</string>
  38 + <key>CFBundleURLName</key>
  39 + <string>com.luiz.doublefeel.wechat</string>
  40 + <key>CFBundleURLSchemes</key>
  41 + <array>
  42 + <string>wx42b603acf3dd68f5</string>
  43 + </array>
  44 + </dict>
35 </array> 45 </array>
36 <key>NSAppTransportSecurity</key> 46 <key>NSAppTransportSecurity</key>
37 <dict> 47 <dict>
@@ -45,7 +55,12 @@ @@ -45,7 +55,12 @@
45 <string>ymail</string> 55 <string>ymail</string>
46 <string>readdle-spark</string> 56 <string>readdle-spark</string>
47 <string>mqqapi</string> 57 <string>mqqapi</string>
  58 + <string>weixin</string>
  59 + <string>weixinULAPI</string>
  60 + <string>weixinURLParamsAPI</string>
48 </array> 61 </array>
  62 + <key>WXAppID</key>
  63 + <string>wx42b603acf3dd68f5</string>
49 <key>NSHealthShareUsageDescription</key> 64 <key>NSHealthShareUsageDescription</key>
50 <string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string> 65 <string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string>
51 <key>GIDClientID</key> 66 <key>GIDClientID</key>
@@ -24,6 +24,16 @@ @@ -24,6 +24,16 @@
24 <string>doublefeel</string> 24 <string>doublefeel</string>
25 </array> 25 </array>
26 </dict> 26 </dict>
  27 + <dict>
  28 + <key>CFBundleTypeRole</key>
  29 + <string>Editor</string>
  30 + <key>CFBundleURLName</key>
  31 + <string>com.luiz.doublefeel.wechat</string>
  32 + <key>CFBundleURLSchemes</key>
  33 + <array>
  34 + <string>wx42b603acf3dd68f5</string>
  35 + </array>
  36 + </dict>
27 </array> 37 </array>
28 <key>NSAppTransportSecurity</key> 38 <key>NSAppTransportSecurity</key>
29 <dict> 39 <dict>
@@ -37,7 +47,12 @@ @@ -37,7 +47,12 @@
37 <string>ymail</string> 47 <string>ymail</string>
38 <string>readdle-spark</string> 48 <string>readdle-spark</string>
39 <string>mqqapi</string> 49 <string>mqqapi</string>
  50 + <string>weixin</string>
  51 + <string>weixinULAPI</string>
  52 + <string>weixinURLParamsAPI</string>
40 </array> 53 </array>
  54 + <key>WXAppID</key>
  55 + <string>wx42b603acf3dd68f5</string>
41 <key>NSHealthShareUsageDescription</key> 56 <key>NSHealthShareUsageDescription</key>
42 <string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string> 57 <string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string>
43 <key>NSHealthUpdateUsageDescription</key> 58 <key>NSHealthUpdateUsageDescription</key>
@@ -11,6 +11,7 @@ enum NativePigeonRegistrar { @@ -11,6 +11,7 @@ enum NativePigeonRegistrar {
11 HealthKitRawDataHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitRawDataHostApiImpl()) 11 HealthKitRawDataHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitRawDataHostApiImpl())
12 WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiImpl()) 12 WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiImpl())
13 AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub()) 13 AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub())
  14 + WeChatHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WeChatLoginBridge.shared)
14 PlatformHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: PlatformHostApiImpl()) 15 PlatformHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: PlatformHostApiImpl())
15 } 16 }
16 } 17 }
  1 +// // Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Autogenerated from Pigeon (v25.5.0), do not edit directly.
  3 +// See also: https://pub.dev/packages/pigeon
  4 +
  5 +import Foundation
  6 +
  7 +#if os(iOS)
  8 + import Flutter
  9 +#elseif os(macOS)
  10 + import FlutterMacOS
  11 +#else
  12 + #error("Unsupported platform.")
  13 +#endif
  14 +
  15 +private func wrapResult(_ result: Any?) -> [Any?] {
  16 + return [result]
  17 +}
  18 +
  19 +private func wrapError(_ error: Any) -> [Any?] {
  20 + if let pigeonError = error as? PigeonError {
  21 + return [
  22 + pigeonError.code,
  23 + pigeonError.message,
  24 + pigeonError.details,
  25 + ]
  26 + }
  27 + if let flutterError = error as? FlutterError {
  28 + return [
  29 + flutterError.code,
  30 + flutterError.message,
  31 + flutterError.details,
  32 + ]
  33 + }
  34 + return [
  35 + "\(error)",
  36 + "\(type(of: error))",
  37 + "Stacktrace: \(Thread.callStackSymbols)",
  38 + ]
  39 +}
  40 +
  41 +private func isNullish(_ value: Any?) -> Bool {
  42 + return value is NSNull || value == nil
  43 +}
  44 +
  45 +private func nilOrValue<T>(_ value: Any?) -> T? {
  46 + if value is NSNull { return nil }
  47 + return value as! T?
  48 +}
  49 +
  50 +
  51 +private class WeChatApiPigeonCodecReader: FlutterStandardReader {
  52 +}
  53 +
  54 +private class WeChatApiPigeonCodecWriter: FlutterStandardWriter {
  55 +}
  56 +
  57 +private class WeChatApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
  58 + override func reader(with data: Data) -> FlutterStandardReader {
  59 + return WeChatApiPigeonCodecReader(data: data)
  60 + }
  61 +
  62 + override func writer(with data: NSMutableData) -> FlutterStandardWriter {
  63 + return WeChatApiPigeonCodecWriter(data: data)
  64 + }
  65 +}
  66 +
  67 +class WeChatApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
  68 + static let shared = WeChatApiPigeonCodec(readerWriter: WeChatApiPigeonCodecReaderWriter())
  69 +}
  70 +
  71 +
  72 +/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
  73 +protocol WeChatHostApi {
  74 + /// Opens WeChat and returns its one-time authorization code.
  75 + func requestAuthorizationCode(completion: @escaping (Result<String, Error>) -> Void)
  76 +}
  77 +
  78 +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
  79 +class WeChatHostApiSetup {
  80 + static var codec: FlutterStandardMessageCodec { WeChatApiPigeonCodec.shared }
  81 + /// Sets up an instance of `WeChatHostApi` to handle messages through the `binaryMessenger`.
  82 + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: WeChatHostApi?, messageChannelSuffix: String = "") {
  83 + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
  84 + /// Opens WeChat and returns its one-time authorization code.
  85 + let requestAuthorizationCodeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.WeChatHostApi.requestAuthorizationCode\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  86 + if let api = api {
  87 + requestAuthorizationCodeChannel.setMessageHandler { _, reply in
  88 + api.requestAuthorizationCode { result in
  89 + switch result {
  90 + case .success(let res):
  91 + reply(wrapResult(res))
  92 + case .failure(let error):
  93 + reply(wrapError(error))
  94 + }
  95 + }
  96 + }
  97 + } else {
  98 + requestAuthorizationCodeChannel.setMessageHandler(nil)
  99 + }
  100 + }
  101 +}
1 #import "GeneratedPluginRegistrant.h" 1 #import "GeneratedPluginRegistrant.h"
  2 +#import <WechatOpenSDK/WXApi.h>
@@ -58,6 +58,9 @@ private struct RunnerLaunchView: View { @@ -58,6 +58,9 @@ private struct RunnerLaunchView: View {
58 .onAppear { 58 .onAppear {
59 appDelegate.setup() 59 appDelegate.setup()
60 }.onOpenURL { url in 60 }.onOpenURL { url in
  61 + if WeChatLoginBridge.shared.handleOpenURL(url) {
  62 + return
  63 + }
61 appDelegate.handleFlutterUrl(url.absoluteString) 64 appDelegate.handleFlutterUrl(url.absoluteString)
62 } 65 }
63 } 66 }
  1 +import Foundation
  2 +import UIKit
  3 +
  4 +/// Delivers the native WeChat authorization callback to Flutter's login flow.
  5 +final class WeChatLoginBridge: NSObject, WeChatHostApi, WXApiDelegate {
  6 + static let shared = WeChatLoginBridge()
  7 +
  8 + private let appId = "wx42b603acf3dd68f5"
  9 + private var pendingCompletion: ((Result<String, Error>) -> Void)?
  10 +
  11 + private override init() {}
  12 +
  13 + func registerApp() {
  14 + WXApi.registerApp(appId, universalLink: "")
  15 + }
  16 +
  17 + func requestAuthorizationCode(completion: @escaping (Result<String, Error>) -> Void) {
  18 + DispatchQueue.main.async {
  19 + guard self.pendingCompletion == nil else {
  20 + completion(.failure(PigeonError(
  21 + code: "wechat_authorization_in_progress",
  22 + message: "WeChat authorization is already in progress.",
  23 + details: nil
  24 + )))
  25 + return
  26 + }
  27 + guard WXApi.isWXAppInstalled() else {
  28 + completion(.failure(PigeonError(
  29 + code: "wechat_not_installed",
  30 + message: "WeChat is not installed.",
  31 + details: nil
  32 + )))
  33 + return
  34 + }
  35 +
  36 + let request = SendAuthReq()
  37 + request.scope = "snsapi_userinfo"
  38 + request.state = UUID().uuidString
  39 + self.pendingCompletion = completion
  40 + WXApi.send(request) { success in
  41 + if !success {
  42 + self.finish(.failure(PigeonError(
  43 + code: "wechat_request_failed",
  44 + message: "Unable to open WeChat.",
  45 + details: nil
  46 + )))
  47 + }
  48 + }
  49 + }
  50 + }
  51 +
  52 + func handleOpenURL(_ url: URL) -> Bool {
  53 + WXApi.handleOpen(url, delegate: self)
  54 + }
  55 +
  56 + func handleUniversalLink(_ userActivity: NSUserActivity) -> Bool {
  57 + WXApi.handleOpenUniversalLink(userActivity, delegate: self)
  58 + }
  59 +
  60 + func onReq(_ req: BaseReq) {}
  61 +
  62 + func onResp(_ resp: BaseResp) {
  63 + guard let authResponse = resp as? SendAuthResp else { return }
  64 + if authResponse.errCode == 0,
  65 + let code = authResponse.code,
  66 + !code.isEmpty {
  67 + finish(.success(code))
  68 + return
  69 + }
  70 + let message = authResponse.errStr.isEmpty
  71 + ? "WeChat authorization was cancelled or failed."
  72 + : authResponse.errStr
  73 + finish(.failure(PigeonError(
  74 + code: "wechat_authorization_failed",
  75 + message: message,
  76 + details: nil
  77 + )))
  78 + }
  79 +
  80 + private func finish(_ result: Result<String, Error>) {
  81 + let completion = pendingCompletion
  82 + pendingCompletion = nil
  83 + completion?(result)
  84 + }
  85 +}
@@ -44,6 +44,11 @@ abstract final class AppBootstrap { @@ -44,6 +44,11 @@ abstract final class AppBootstrap {
44 localStorage: local, 44 localStorage: local,
45 ).dependencies(); 45 ).dependencies();
46 46
  47 + // `UserPreferencesStorage.init` runs before GetX dependencies exist. Sync
  48 + // here so every cold start invokes native `updateLoginInfo` after the
  49 + // PlatformHostApi and HealthRawDataCoreService are ready.
  50 + await userPreferencesStorage.syncLoginInfoToNative();
  51 +
47 NativeHealthDataUpdateBridge.initialize(); 52 NativeHealthDataUpdateBridge.initialize();
48 53
49 LoadingService.init(); 54 LoadingService.init();
@@ -83,6 +88,8 @@ abstract final class AppBootstrap { @@ -83,6 +88,8 @@ abstract final class AppBootstrap {
83 localStorage: local, 88 localStorage: local,
84 ).dependencies(); 89 ).dependencies();
85 90
  91 + await userPreferencesStorage.syncLoginInfoToNative();
  92 +
86 NativeHealthDataUpdateBridge.initialize(); 93 NativeHealthDataUpdateBridge.initialize();
87 // 2. 后台无 UI,不需要初始化全局 Loading 弹窗 94 // 2. 后台无 UI,不需要初始化全局 Loading 弹窗
88 LoadingService.init(); 95 LoadingService.init();
@@ -13,6 +13,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; @@ -13,6 +13,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
13 import 'package:doublefeel_flutter/l10n/gen/app_localizations.dart'; 13 import 'package:doublefeel_flutter/l10n/gen/app_localizations.dart';
14 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 14 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
15 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; 15 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
  16 +import 'package:doublefeel_flutter/pigeon/wechat_api.g.dart';
16 import 'package:flutter/services.dart'; 17 import 'package:flutter/services.dart';
17 import 'package:flutter/widgets.dart'; 18 import 'package:flutter/widgets.dart';
18 import 'package:get/get.dart'; 19 import 'package:get/get.dart';
@@ -190,6 +191,26 @@ class LoginController extends GetxController { @@ -190,6 +191,26 @@ class LoginController extends GetxController {
190 } 191 }
191 } 192 }
192 193
  194 + void onWechatLoginPressed() async {
  195 + if (!termsChecked.value &&
  196 + environmentConfig.region.value == AppRegion.china) {
  197 + AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
  198 + return;
  199 + }
  200 + try {
  201 + final code = await LoadingService.instance.run(
  202 + () => WeChatHostApi().requestAuthorizationCode(),
  203 + type: LoadingType.circular,
  204 + );
  205 + if (code.isEmpty) return;
  206 + await _loginWithWechat(code);
  207 + } on PlatformException catch (e) {
  208 + AppToast.show(e.message?.isNotEmpty == true ? e.message! : '微信登录失败');
  209 + } catch (e) {
  210 + AppToast.show(e.toString());
  211 + }
  212 + }
  213 +
193 void onDebugPressed() { 214 void onDebugPressed() {
194 Get.toNamed(AppRoutes.debugEnvironment); 215 Get.toNamed(AppRoutes.debugEnvironment);
195 } 216 }
@@ -465,6 +486,22 @@ class LoginController extends GetxController { @@ -465,6 +486,22 @@ class LoginController extends GetxController {
465 } 486 }
466 } 487 }
467 488
  489 + Future<void> _loginWithWechat(String code) async {
  490 + isLoggingIn.value = true;
  491 + try {
  492 + await LoadingService.instance.run(() async {
  493 + final loginRes = await _userApi.loginWithWechat(code);
  494 + if (loginRes is AppSuccess<LoginResponse>) {
  495 + await _handleLoginSuccess(loginRes.data, wechatCode: code);
  496 + await Get.find<LocalStorage>().setLastLoginMethod('wechat');
  497 + await environmentConfig.updateHideChooseRegion(true);
  498 + }
  499 + });
  500 + } finally {
  501 + isLoggingIn.value = false;
  502 + }
  503 + }
  504 +
468 Future<void> _loginWithGoogle(GoogleSignInModel googleModel) async { 505 Future<void> _loginWithGoogle(GoogleSignInModel googleModel) async {
469 isLoggingIn.value = true; 506 isLoggingIn.value = true;
470 try { 507 try {
@@ -486,10 +523,12 @@ class LoginController extends GetxController { @@ -486,10 +523,12 @@ class LoginController extends GetxController {
486 Future<void> _handleLoginSuccess(LoginResponse loginData, 523 Future<void> _handleLoginSuccess(LoginResponse loginData,
487 {AppleSignInModel? appleModel, 524 {AppleSignInModel? appleModel,
488 GoogleSignInModel? googleModel, 525 GoogleSignInModel? googleModel,
489 - String? emailPassword}) async { 526 + String? emailPassword,
  527 + String? wechatCode}) async {
490 bool isApple = appleModel != null; 528 bool isApple = appleModel != null;
491 bool isGoogle = googleModel != null; 529 bool isGoogle = googleModel != null;
492 bool isEmail = emailPassword != null; 530 bool isEmail = emailPassword != null;
  531 + bool isWechat = wechatCode != null;
493 final isRegister = loginData.isNewUser == true; 532 final isRegister = loginData.isNewUser == true;
494 String accessToken = loginData.accessToken; 533 String accessToken = loginData.accessToken;
495 534
@@ -513,6 +552,11 @@ class LoginController extends GetxController { @@ -513,6 +552,11 @@ class LoginController extends GetxController {
513 'email': emailInput.value.trim(), 552 'email': emailInput.value.trim(),
514 'password': emailPassword, 553 'password': emailPassword,
515 }; 554 };
  555 + } else if (isWechat) {
  556 + registerData = {
  557 + 'login_type': 'wechat',
  558 + 'wechat_code': wechatCode,
  559 + };
516 } else { 560 } else {
517 registerData = { 561 registerData = {
518 'login_type': 'verification_code', 562 'login_type': 'verification_code',
@@ -233,6 +233,44 @@ class LoginView extends GetView<LoginController> { @@ -233,6 +233,44 @@ class LoginView extends GetView<LoginController> {
233 ), 233 ),
234 ), 234 ),
235 ), 235 ),
  236 + if (region == AppRegion.china) ...[
  237 + const SizedBox(height: 12),
  238 + _LoginMethodButton(
  239 + width: buttonWidth,
  240 + label: '通过微信登录',
  241 + onPressed: controller.onWechatLoginPressed,
  242 + isLastUsed:
  243 + Get.find<LocalStorage>().lastLoginMethod ==
  244 + 'wechat',
  245 + lastUsedLabel: l10n.loginLastUsed,
  246 + icon: Image.asset(
  247 + 'assets/images/common/ic_wechat.png',
  248 + width: 24,
  249 + height: 24,
  250 + ),
  251 + buttonStyle: ElevatedButton.styleFrom(
  252 + backgroundColor: Colors.white,
  253 + foregroundColor: context.colors.textPrimary,
  254 + disabledBackgroundColor: context
  255 + .colors.textPrimary
  256 + .withValues(alpha: 0.5),
  257 + disabledForegroundColor:
  258 + context.colors.textPrimary,
  259 + elevation: 0,
  260 + shadowColor: Colors.transparent,
  261 + padding: const EdgeInsets.symmetric(
  262 + horizontal: 12,
  263 + ),
  264 + shape: RoundedRectangleBorder(
  265 + borderRadius: BorderRadius.circular(24),
  266 + ),
  267 + textStyle: const TextStyle(
  268 + fontSize: 16,
  269 + fontWeight: FontWeight.w700,
  270 + ),
  271 + ),
  272 + ),
  273 + ],
236 ], 274 ],
237 ), 275 ),
238 ), 276 ),
@@ -125,6 +125,19 @@ class UserApi { @@ -125,6 +125,19 @@ class UserApi {
125 ); 125 );
126 } 126 }
127 127
  128 + Future<AppResult<LoginResponse>> loginWithWechat(String code) {
  129 + return safeCall(
  130 + call: () async {
  131 + final response = await _dioClient.dio.post(
  132 + ApiPaths.userLogin,
  133 + data: {'wechat_code': code, 'login_type': 'wechat'},
  134 + options: _dioClient.noTokenOptions(),
  135 + );
  136 + return LoginResponse.fromJson(response.data as Map<String, dynamic>);
  137 + },
  138 + );
  139 + }
  140 +
128 Future<AppResult<LoginResponse>> loginWithGoogle( 141 Future<AppResult<LoginResponse>> loginWithGoogle(
129 GoogleSignInModel googleModel) { 142 GoogleSignInModel googleModel) {
130 return safeCall( 143 return safeCall(
@@ -233,6 +233,8 @@ class AppleHealthRawDataCoreService { @@ -233,6 +233,8 @@ class AppleHealthRawDataCoreService {
233 if (running != null) { 233 if (running != null) {
234 return running; 234 return running;
235 } 235 }
  236 + // Flush rows from prior calculations without delaying a new calculation.
  237 + unawaited(_flushNativeUpload(reason: 'before_calculation'));
236 final task = _runWithLog( 238 final task = _runWithLog(
237 action: 'startCoreCaculate', 239 action: 'startCoreCaculate',
238 body: () => _readCalculateAndStore( 240 body: () => _readCalculateAndStore(
@@ -243,7 +245,11 @@ class AppleHealthRawDataCoreService { @@ -243,7 +245,11 @@ class AppleHealthRawDataCoreService {
243 ); 245 );
244 _coreCalculation = task; 246 _coreCalculation = task;
245 try { 247 try {
246 - return await task; 248 + final result = await task;
  249 + // `_readCalculateAndStore` has committed all four result tables by now.
  250 + // Trigger a second pass without delaying downstream UI or notifications.
  251 + unawaited(_flushNativeUpload(reason: 'after_calculation'));
  252 + return result;
247 } finally { 253 } finally {
248 if (identical(_coreCalculation, task)) { 254 if (identical(_coreCalculation, task)) {
249 _coreCalculation = null; 255 _coreCalculation = null;
@@ -251,6 +257,29 @@ class AppleHealthRawDataCoreService { @@ -251,6 +257,29 @@ class AppleHealthRawDataCoreService {
251 } 257 }
252 } 258 }
253 259
  260 + Future<void> _flushNativeUpload({required String reason}) async {
  261 + try {
  262 + final succeeded = await performHealthDataUpload();
  263 + AppLogger.d('Native health upload $reason succeeded=$succeeded');
  264 + await _saveLocalNotificationDebugEvent(<String, Object?>{
  265 + 'event': 'flutterNativeHealthUploadFinished',
  266 + 'identifier': 'flutter.native_health_upload.finished',
  267 + 'reason': reason,
  268 + 'success': succeeded,
  269 + });
  270 + } catch (error, stackTrace) {
  271 + // Upload failures must leave the calculation path alive. SQLite rows stay
  272 + // pending and will be retried by the next pre/post-calculation flush.
  273 + _logError('native health upload $reason failed', error, stackTrace);
  274 + await _saveLocalNotificationDebugEvent(<String, Object?>{
  275 + 'event': 'flutterNativeHealthUploadFailed',
  276 + 'identifier': 'flutter.native_health_upload.failed',
  277 + 'reason': reason,
  278 + 'error': error.toString(),
  279 + });
  280 + }
  281 + }
  282 +
254 Future<HealthRawStressCalculationResult> syncAndStore({ 283 Future<HealthRawStressCalculationResult> syncAndStore({
255 int? startTime, 284 int? startTime,
256 int? endTime, 285 int? endTime,
@@ -57,7 +57,13 @@ class UserPreferencesStorage { @@ -57,7 +57,13 @@ class UserPreferencesStorage {
57 ); 57 );
58 preferences.value = UserPreferences.empty; 58 preferences.value = UserPreferences.empty;
59 } 59 }
60 - _syncLoginInfoToNative(userInfo: preferences.value); 60 + }
  61 +
  62 + /// Call after the startup dependency graph has registered the environment and
  63 + /// health services. This deliberately runs on every cold start so iOS has a
  64 + /// valid session before any HealthKit observer callback is processed.
  65 + Future<void> syncLoginInfoToNative() async {
  66 + await _syncLoginInfoToNative(userInfo: preferences.value);
61 } 67 }
62 68
63 Future<void> _syncLoginInfoToNative( 69 Future<void> _syncLoginInfoToNative(
@@ -79,8 +85,9 @@ class UserPreferencesStorage { @@ -79,8 +85,9 @@ class UserPreferencesStorage {
79 .updateLoginInfo(jsonEncode(userInfo), env.serverBaseUrl); 85 .updateLoginInfo(jsonEncode(userInfo), env.serverBaseUrl);
80 await _syncHealthRawDatabaseToNative(); 86 await _syncHealthRawDatabaseToNative();
81 } 87 }
82 - } on Exception {  
83 - return; 88 + AppLogger.d('Native login info synced for cold start');
  89 + } catch (error, stackTrace) {
  90 + AppLogger.e('Native login info sync failed', error, stackTrace);
84 } 91 }
85 } 92 }
86 93
  1 +// // Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Autogenerated from Pigeon (v25.5.0), do not edit directly.
  3 +// See also: https://pub.dev/packages/pigeon
  4 +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
  5 +
  6 +import 'dart:async';
  7 +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
  8 +
  9 +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
  10 +import 'package:flutter/services.dart';
  11 +
  12 +PlatformException _createConnectionError(String channelName) {
  13 + return PlatformException(
  14 + code: 'channel-error',
  15 + message: 'Unable to establish connection on channel: "$channelName".',
  16 + );
  17 +}
  18 +
  19 +class _PigeonCodec extends StandardMessageCodec {
  20 + const _PigeonCodec();
  21 + @override
  22 + void writeValue(WriteBuffer buffer, Object? value) {
  23 + if (value is int) {
  24 + buffer.putUint8(4);
  25 + buffer.putInt64(value);
  26 + } else {
  27 + super.writeValue(buffer, value);
  28 + }
  29 + }
  30 +
  31 + @override
  32 + Object? readValueOfType(int type, ReadBuffer buffer) {
  33 + switch (type) {
  34 + default:
  35 + return super.readValueOfType(type, buffer);
  36 + }
  37 + }
  38 +}
  39 +
  40 +class WeChatHostApi {
  41 + /// Constructor for [WeChatHostApi]. The [binaryMessenger] named argument is
  42 + /// available for dependency injection. If it is left null, the default
  43 + /// BinaryMessenger will be used which routes to the host platform.
  44 + WeChatHostApi(
  45 + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
  46 + : pigeonVar_binaryMessenger = binaryMessenger,
  47 + pigeonVar_messageChannelSuffix =
  48 + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
  49 + final BinaryMessenger? pigeonVar_binaryMessenger;
  50 +
  51 + static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
  52 +
  53 + final String pigeonVar_messageChannelSuffix;
  54 +
  55 + /// Opens WeChat and returns its one-time authorization code.
  56 + Future<String> requestAuthorizationCode() async {
  57 + final String pigeonVar_channelName =
  58 + 'dev.flutter.pigeon.doublefeel_flutter.WeChatHostApi.requestAuthorizationCode$pigeonVar_messageChannelSuffix';
  59 + final BasicMessageChannel<Object?> pigeonVar_channel =
  60 + BasicMessageChannel<Object?>(
  61 + pigeonVar_channelName,
  62 + pigeonChannelCodec,
  63 + binaryMessenger: pigeonVar_binaryMessenger,
  64 + );
  65 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
  66 + final List<Object?>? pigeonVar_replyList =
  67 + await pigeonVar_sendFuture as List<Object?>?;
  68 + if (pigeonVar_replyList == null) {
  69 + throw _createConnectionError(pigeonVar_channelName);
  70 + } else if (pigeonVar_replyList.length > 1) {
  71 + throw PlatformException(
  72 + code: pigeonVar_replyList[0]! as String,
  73 + message: pigeonVar_replyList[1] as String?,
  74 + details: pigeonVar_replyList[2],
  75 + );
  76 + } else if (pigeonVar_replyList[0] == null) {
  77 + throw PlatformException(
  78 + code: 'null-error',
  79 + message: 'Host platform returned null value for non-null return value.',
  80 + );
  81 + } else {
  82 + return (pigeonVar_replyList[0] as String?)!;
  83 + }
  84 + }
  85 +}
  1 +import 'package:pigeon/pigeon.dart';
  2 +
  3 +@ConfigurePigeon(
  4 + PigeonOptions(
  5 + dartOut: 'lib/pigeon/wechat_api.g.dart',
  6 + dartPackageName: 'doublefeel_flutter',
  7 + dartOptions: DartOptions(),
  8 + swiftOut: 'ios/Runner/Pigeon/WeChatApi.swift',
  9 + swiftOptions: SwiftOptions(),
  10 + arkTSOut: 'ohos/entry/src/main/ets/pigeon/WeChatApi.ets',
  11 + copyrightHeader: 'pigeon/copyright.txt',
  12 + ),
  13 +)
  14 +@HostApi()
  15 +abstract class WeChatHostApi {
  16 + /// Opens WeChat and returns its one-time authorization code.
  17 + @async
  18 + String requestAuthorizationCode();
  19 +}
@@ -7,6 +7,7 @@ dart run pigeon --input pigeon/wear_engine_api.dart @@ -7,6 +7,7 @@ dart run pigeon --input pigeon/wear_engine_api.dart
7 dart run pigeon --input pigeon/alipay_api.dart 7 dart run pigeon --input pigeon/alipay_api.dart
8 dart run pigeon --input pigeon/platform_api.dart 8 dart run pigeon --input pigeon/platform_api.dart
9 dart run pigeon --input pigeon/health_kit_raw_data_api.dart 9 dart run pigeon --input pigeon/health_kit_raw_data_api.dart
  10 +dart run pigeon --input pigeon/wechat_api.dart
10 11
11 12
12 # Pigeon emits PigeonError in every swiftOut file; Runner needs a single definition. 13 # Pigeon emits PigeonError in every swiftOut file; Runner needs a single definition.