Commit d6f4e78019a0d39c87be728ef36d363332cd8d0a

Authored by 权海
1 parent 2a5eda60

feat(ui):增加微信登录

... ... @@ -30,6 +30,7 @@ target 'Runner' do
use_frameworks!
pod 'OBS', :path => './Runner/Library'
pod 'GoogleSignIn'
pod 'WechatOpenSDK', '~> 2.0'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
... ...
... ... @@ -97,6 +97,7 @@ PODS:
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
- WechatOpenSDK (2.0.7)
DEPENDENCIES:
- Flutter (from `Flutter`)
... ... @@ -114,6 +115,7 @@ DEPENDENCIES:
- thinking_analytics (from `.symlinks/plugins/thinking_analytics/ios`)
- video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
- WechatOpenSDK (~> 2.0)
SPEC REPOS:
trunk:
... ... @@ -131,6 +133,7 @@ SPEC REPOS:
- ThinkingDataCore
- ThinkingSDK
- TOCropViewController
- WechatOpenSDK
EXTERNAL SOURCES:
Flutter:
... ... @@ -191,7 +194,8 @@ SPEC CHECKSUMS:
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2
WechatOpenSDK: fbc76decf307d241a750c9e03b687e4d11aacf37
PODFILE CHECKSUM: fe3137a8e1c8e115fcac0a685d21e651b2d2788b
PODFILE CHECKSUM: 186a2af126028b1ccaa73baa82e32ae092583b1a
COCOAPODS: 1.16.2
... ...
... ... @@ -40,6 +40,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
setup()
WeChatLoginBridge.shared.registerApp()
cacheLaunchURLIfNeeded(launchOptions)
return true
}
... ... @@ -49,6 +50,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if WeChatLoginBridge.shared.handleOpenURL(url) {
return true
}
if GIDSignIn.sharedInstance.handle(url) {
return true
}
... ... @@ -61,6 +65,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
WeChatLoginBridge.shared.handleUniversalLink(userActivity) {
return true
}
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let urlString = userActivity.webpageURL?.absoluteString else {
return false
... ...
... ... @@ -32,6 +32,16 @@
<string>doublefeel</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.luiz.doublefeel.wechat</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wx42b603acf3dd68f5</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
... ... @@ -45,7 +55,12 @@
<string>ymail</string>
<string>readdle-spark</string>
<string>mqqapi</string>
<string>weixin</string>
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
</array>
<key>WXAppID</key>
<string>wx42b603acf3dd68f5</string>
<key>NSHealthShareUsageDescription</key>
<string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string>
<key>GIDClientID</key>
... ...
... ... @@ -24,6 +24,16 @@
<string>doublefeel</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.luiz.doublefeel.wechat</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wx42b603acf3dd68f5</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
... ... @@ -37,7 +47,12 @@
<string>ymail</string>
<string>readdle-spark</string>
<string>mqqapi</string>
<string>weixin</string>
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
</array>
<key>WXAppID</key>
<string>wx42b603acf3dd68f5</string>
<key>NSHealthShareUsageDescription</key>
<string>需要读取 Apple Health 中的心率、HRV、睡眠、步数、活动能量等数据,用于展示健康状态并同步到 Apple Watch。</string>
<key>NSHealthUpdateUsageDescription</key>
... ...
... ... @@ -11,6 +11,7 @@ enum NativePigeonRegistrar {
HealthKitRawDataHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitRawDataHostApiImpl())
WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiImpl())
AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub())
WeChatHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WeChatLoginBridge.shared)
PlatformHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: PlatformHostApiImpl())
}
}
... ...
// // Copyright 2013 The Flutter Authors. All rights reserved.
// Autogenerated from Pigeon (v25.5.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
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)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
private class WeChatApiPigeonCodecReader: FlutterStandardReader {
}
private class WeChatApiPigeonCodecWriter: FlutterStandardWriter {
}
private class WeChatApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return WeChatApiPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return WeChatApiPigeonCodecWriter(data: data)
}
}
class WeChatApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = WeChatApiPigeonCodec(readerWriter: WeChatApiPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol WeChatHostApi {
/// Opens WeChat and returns its one-time authorization code.
func requestAuthorizationCode(completion: @escaping (Result<String, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class WeChatHostApiSetup {
static var codec: FlutterStandardMessageCodec { WeChatApiPigeonCodec.shared }
/// Sets up an instance of `WeChatHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: WeChatHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
/// Opens WeChat and returns its one-time authorization code.
let requestAuthorizationCodeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.WeChatHostApi.requestAuthorizationCode\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestAuthorizationCodeChannel.setMessageHandler { _, reply in
api.requestAuthorizationCode { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestAuthorizationCodeChannel.setMessageHandler(nil)
}
}
}
... ...
#import "GeneratedPluginRegistrant.h"
#import <WechatOpenSDK/WXApi.h>
... ...
... ... @@ -58,6 +58,9 @@ private struct RunnerLaunchView: View {
.onAppear {
appDelegate.setup()
}.onOpenURL { url in
if WeChatLoginBridge.shared.handleOpenURL(url) {
return
}
appDelegate.handleFlutterUrl(url.absoluteString)
}
}
... ...
import Foundation
import UIKit
/// Delivers the native WeChat authorization callback to Flutter's login flow.
final class WeChatLoginBridge: NSObject, WeChatHostApi, WXApiDelegate {
static let shared = WeChatLoginBridge()
private let appId = "wx42b603acf3dd68f5"
private var pendingCompletion: ((Result<String, Error>) -> Void)?
private override init() {}
func registerApp() {
WXApi.registerApp(appId, universalLink: "")
}
func requestAuthorizationCode(completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.main.async {
guard self.pendingCompletion == nil else {
completion(.failure(PigeonError(
code: "wechat_authorization_in_progress",
message: "WeChat authorization is already in progress.",
details: nil
)))
return
}
guard WXApi.isWXAppInstalled() else {
completion(.failure(PigeonError(
code: "wechat_not_installed",
message: "WeChat is not installed.",
details: nil
)))
return
}
let request = SendAuthReq()
request.scope = "snsapi_userinfo"
request.state = UUID().uuidString
self.pendingCompletion = completion
WXApi.send(request) { success in
if !success {
self.finish(.failure(PigeonError(
code: "wechat_request_failed",
message: "Unable to open WeChat.",
details: nil
)))
}
}
}
}
func handleOpenURL(_ url: URL) -> Bool {
WXApi.handleOpen(url, delegate: self)
}
func handleUniversalLink(_ userActivity: NSUserActivity) -> Bool {
WXApi.handleOpenUniversalLink(userActivity, delegate: self)
}
func onReq(_ req: BaseReq) {}
func onResp(_ resp: BaseResp) {
guard let authResponse = resp as? SendAuthResp else { return }
if authResponse.errCode == 0,
let code = authResponse.code,
!code.isEmpty {
finish(.success(code))
return
}
let message = authResponse.errStr.isEmpty
? "WeChat authorization was cancelled or failed."
: authResponse.errStr
finish(.failure(PigeonError(
code: "wechat_authorization_failed",
message: message,
details: nil
)))
}
private func finish(_ result: Result<String, Error>) {
let completion = pendingCompletion
pendingCompletion = nil
completion?(result)
}
}
... ...
... ... @@ -44,6 +44,11 @@ abstract final class AppBootstrap {
localStorage: local,
).dependencies();
// `UserPreferencesStorage.init` runs before GetX dependencies exist. Sync
// here so every cold start invokes native `updateLoginInfo` after the
// PlatformHostApi and HealthRawDataCoreService are ready.
await userPreferencesStorage.syncLoginInfoToNative();
NativeHealthDataUpdateBridge.initialize();
LoadingService.init();
... ... @@ -83,6 +88,8 @@ abstract final class AppBootstrap {
localStorage: local,
).dependencies();
await userPreferencesStorage.syncLoginInfoToNative();
NativeHealthDataUpdateBridge.initialize();
// 2. 后台无 UI,不需要初始化全局 Loading 弹窗
LoadingService.init();
... ...
... ... @@ -13,6 +13,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/l10n/gen/app_localizations.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:doublefeel_flutter/pigeon/wechat_api.g.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
... ... @@ -190,6 +191,26 @@ class LoginController extends GetxController {
}
}
void onWechatLoginPressed() async {
if (!termsChecked.value &&
environmentConfig.region.value == AppRegion.china) {
AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
return;
}
try {
final code = await LoadingService.instance.run(
() => WeChatHostApi().requestAuthorizationCode(),
type: LoadingType.circular,
);
if (code.isEmpty) return;
await _loginWithWechat(code);
} on PlatformException catch (e) {
AppToast.show(e.message?.isNotEmpty == true ? e.message! : '微信登录失败');
} catch (e) {
AppToast.show(e.toString());
}
}
void onDebugPressed() {
Get.toNamed(AppRoutes.debugEnvironment);
}
... ... @@ -465,6 +486,22 @@ class LoginController extends GetxController {
}
}
Future<void> _loginWithWechat(String code) async {
isLoggingIn.value = true;
try {
await LoadingService.instance.run(() async {
final loginRes = await _userApi.loginWithWechat(code);
if (loginRes is AppSuccess<LoginResponse>) {
await _handleLoginSuccess(loginRes.data, wechatCode: code);
await Get.find<LocalStorage>().setLastLoginMethod('wechat');
await environmentConfig.updateHideChooseRegion(true);
}
});
} finally {
isLoggingIn.value = false;
}
}
Future<void> _loginWithGoogle(GoogleSignInModel googleModel) async {
isLoggingIn.value = true;
try {
... ... @@ -486,10 +523,12 @@ class LoginController extends GetxController {
Future<void> _handleLoginSuccess(LoginResponse loginData,
{AppleSignInModel? appleModel,
GoogleSignInModel? googleModel,
String? emailPassword}) async {
String? emailPassword,
String? wechatCode}) async {
bool isApple = appleModel != null;
bool isGoogle = googleModel != null;
bool isEmail = emailPassword != null;
bool isWechat = wechatCode != null;
final isRegister = loginData.isNewUser == true;
String accessToken = loginData.accessToken;
... ... @@ -513,6 +552,11 @@ class LoginController extends GetxController {
'email': emailInput.value.trim(),
'password': emailPassword,
};
} else if (isWechat) {
registerData = {
'login_type': 'wechat',
'wechat_code': wechatCode,
};
} else {
registerData = {
'login_type': 'verification_code',
... ...
... ... @@ -233,6 +233,44 @@ class LoginView extends GetView<LoginController> {
),
),
),
if (region == AppRegion.china) ...[
const SizedBox(height: 12),
_LoginMethodButton(
width: buttonWidth,
label: '通过微信登录',
onPressed: controller.onWechatLoginPressed,
isLastUsed:
Get.find<LocalStorage>().lastLoginMethod ==
'wechat',
lastUsedLabel: l10n.loginLastUsed,
icon: Image.asset(
'assets/images/common/ic_wechat.png',
width: 24,
height: 24,
),
buttonStyle: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: context.colors.textPrimary,
disabledBackgroundColor: context
.colors.textPrimary
.withValues(alpha: 0.5),
disabledForegroundColor:
context.colors.textPrimary,
elevation: 0,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
),
... ...
... ... @@ -125,6 +125,19 @@ class UserApi {
);
}
Future<AppResult<LoginResponse>> loginWithWechat(String code) {
return safeCall(
call: () async {
final response = await _dioClient.dio.post(
ApiPaths.userLogin,
data: {'wechat_code': code, 'login_type': 'wechat'},
options: _dioClient.noTokenOptions(),
);
return LoginResponse.fromJson(response.data as Map<String, dynamic>);
},
);
}
Future<AppResult<LoginResponse>> loginWithGoogle(
GoogleSignInModel googleModel) {
return safeCall(
... ...
... ... @@ -233,6 +233,8 @@ class AppleHealthRawDataCoreService {
if (running != null) {
return running;
}
// Flush rows from prior calculations without delaying a new calculation.
unawaited(_flushNativeUpload(reason: 'before_calculation'));
final task = _runWithLog(
action: 'startCoreCaculate',
body: () => _readCalculateAndStore(
... ... @@ -243,7 +245,11 @@ class AppleHealthRawDataCoreService {
);
_coreCalculation = task;
try {
return await task;
final result = await task;
// `_readCalculateAndStore` has committed all four result tables by now.
// Trigger a second pass without delaying downstream UI or notifications.
unawaited(_flushNativeUpload(reason: 'after_calculation'));
return result;
} finally {
if (identical(_coreCalculation, task)) {
_coreCalculation = null;
... ... @@ -251,6 +257,29 @@ class AppleHealthRawDataCoreService {
}
}
Future<void> _flushNativeUpload({required String reason}) async {
try {
final succeeded = await performHealthDataUpload();
AppLogger.d('Native health upload $reason succeeded=$succeeded');
await _saveLocalNotificationDebugEvent(<String, Object?>{
'event': 'flutterNativeHealthUploadFinished',
'identifier': 'flutter.native_health_upload.finished',
'reason': reason,
'success': succeeded,
});
} catch (error, stackTrace) {
// Upload failures must leave the calculation path alive. SQLite rows stay
// pending and will be retried by the next pre/post-calculation flush.
_logError('native health upload $reason failed', error, stackTrace);
await _saveLocalNotificationDebugEvent(<String, Object?>{
'event': 'flutterNativeHealthUploadFailed',
'identifier': 'flutter.native_health_upload.failed',
'reason': reason,
'error': error.toString(),
});
}
}
Future<HealthRawStressCalculationResult> syncAndStore({
int? startTime,
int? endTime,
... ...
... ... @@ -57,7 +57,13 @@ class UserPreferencesStorage {
);
preferences.value = UserPreferences.empty;
}
_syncLoginInfoToNative(userInfo: preferences.value);
}
/// Call after the startup dependency graph has registered the environment and
/// health services. This deliberately runs on every cold start so iOS has a
/// valid session before any HealthKit observer callback is processed.
Future<void> syncLoginInfoToNative() async {
await _syncLoginInfoToNative(userInfo: preferences.value);
}
Future<void> _syncLoginInfoToNative(
... ... @@ -79,8 +85,9 @@ class UserPreferencesStorage {
.updateLoginInfo(jsonEncode(userInfo), env.serverBaseUrl);
await _syncHealthRawDatabaseToNative();
}
} on Exception {
return;
AppLogger.d('Native login info synced for cold start');
} catch (error, stackTrace) {
AppLogger.e('Native login info sync failed', error, stackTrace);
}
}
... ...
// // Copyright 2013 The Flutter Authors. All rights reserved.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// 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
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class WeChatHostApi {
/// Constructor for [WeChatHostApi]. 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.
WeChatHostApi(
{BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix =
messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
/// Opens WeChat and returns its one-time authorization code.
Future<String> requestAuthorizationCode() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.doublefeel_flutter.WeChatHostApi.requestAuthorizationCode$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as String?)!;
}
}
}
... ...
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/pigeon/wechat_api.g.dart',
dartPackageName: 'doublefeel_flutter',
dartOptions: DartOptions(),
swiftOut: 'ios/Runner/Pigeon/WeChatApi.swift',
swiftOptions: SwiftOptions(),
arkTSOut: 'ohos/entry/src/main/ets/pigeon/WeChatApi.ets',
copyrightHeader: 'pigeon/copyright.txt',
),
)
@HostApi()
abstract class WeChatHostApi {
/// Opens WeChat and returns its one-time authorization code.
@async
String requestAuthorizationCode();
}
... ...
... ... @@ -7,6 +7,7 @@ dart run pigeon --input pigeon/wear_engine_api.dart
dart run pigeon --input pigeon/alipay_api.dart
dart run pigeon --input pigeon/platform_api.dart
dart run pigeon --input pigeon/health_kit_raw_data_api.dart
dart run pigeon --input pigeon/wechat_api.dart
# Pigeon emits PigeonError in every swiftOut file; Runner needs a single definition.
... ...