Showing
20 changed files
with
483 additions
and
6 deletions
assets/images/common/ic_wechat.png
0 → 100644
1.17 KB
| @@ -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 | } |
ios/Runner/Pigeon/WeChatApi.swift
0 → 100644
| 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 | +} |
| @@ -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 | } |
ios/Runner/WeChatLoginBridge.swift
0 → 100644
| 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 |
lib/pigeon/wechat_api.g.dart
0 → 100644
| 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 | +} |
pigeon/wechat_api.dart
0 → 100644
| 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. |
-
mentioned in commit a2f703b2
-
Please register or login to post a comment