fix:第一轮跑
This commit is contained in:
1 parent
e834ec7586
commit
33235f450b
56 files changed
+4357
-210
No files matched your search
@@ -1,20 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import '../data/one_key_login_api.dart';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(),
|
||||
(ref) => AuthController(ref.read(dioProvider)),
|
||||
);
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController() : super(const AsyncLoading()) {
|
||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
if (token == null || token.isEmpty) {
|
||||
@@ -54,6 +59,24 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test environment reuses Yanting's one-key verification endpoint until
|
||||
/// 金值 has its own account contract.
|
||||
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
|
||||
await saveJwt(reply.token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: reply.phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: reply.token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/router/app_router.dart';
|
||||
import 'auth_controller.dart';
|
||||
import 'one_key_login_service.dart';
|
||||
|
||||
/// Shared login entry: launch the carrier-owned authorization page first and
|
||||
/// use the Flutter SMS page only as a deliberate fallback.
|
||||
abstract final class LoginLauncher {
|
||||
static final _service = OneKeyLoginService();
|
||||
static bool _starting = false;
|
||||
|
||||
static Future<void> start(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? next,
|
||||
}) async {
|
||||
if (_starting) return;
|
||||
_starting = true;
|
||||
final dismissLoading = _showLoading(context);
|
||||
try {
|
||||
final observer = _OneKeyLoginLifecycleObserver(_service);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
late final OneKeyLoginOutcome outcome;
|
||||
try {
|
||||
outcome = await _service.start(
|
||||
context: context,
|
||||
verifyToken: ref.read(authProvider.notifier).verifyOneKeyLogin,
|
||||
onNativeRequestDispatched: dismissLoading,
|
||||
onAuthPageOpened: dismissLoading,
|
||||
);
|
||||
} catch (_) {
|
||||
outcome = OneKeyLoginOutcome.fallback;
|
||||
} finally {
|
||||
WidgetsBinding.instance.removeObserver(observer);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (outcome == OneKeyLoginOutcome.success) {
|
||||
context.go(next?.isNotEmpty == true ? next! : AppRoutes.profile);
|
||||
return;
|
||||
}
|
||||
if (outcome == OneKeyLoginOutcome.fallback ||
|
||||
outcome == OneKeyLoginOutcome.unsupported) {
|
||||
final target = next == null || next.isEmpty
|
||||
? AppRoutes.login
|
||||
: '${AppRoutes.login}?next=${Uri.encodeComponent(next)}';
|
||||
if (GoRouterState.of(context).matchedLocation != AppRoutes.login) {
|
||||
await context.push(target);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
dismissLoading();
|
||||
_starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static VoidCallback _showLoading(BuildContext context) {
|
||||
var visible = true;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const PopScope(
|
||||
canPop: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
return () {
|
||||
if (visible && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
visible = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _OneKeyLoginLifecycleObserver with WidgetsBindingObserver {
|
||||
_OneKeyLoginLifecycleObserver(this._service);
|
||||
final OneKeyLoginService _service;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) _service.onAppResumed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/tracking/umeng_service.dart';
|
||||
|
||||
enum OneKeyLoginOutcome { success, fallback, cancelled, unsupported, busy }
|
||||
|
||||
/// Runs only after an explicit user login action. Do not add an availability
|
||||
/// probe or `accelerateLoginPage` outside this class: Xiaomi rejects that as
|
||||
/// background carrier-auth prefetch.
|
||||
class OneKeyLoginService {
|
||||
Future<OneKeyLoginOutcome>? _operation;
|
||||
bool _authPageOpened = false;
|
||||
bool _terminal = false;
|
||||
Timer? _resumeCancellation;
|
||||
void Function(OneKeyLoginOutcome)? _completeCurrent;
|
||||
|
||||
Future<OneKeyLoginOutcome> start({
|
||||
required BuildContext context,
|
||||
required Future<void> Function(String token) verifyToken,
|
||||
VoidCallback? onNativeRequestDispatched,
|
||||
VoidCallback? onAuthPageOpened,
|
||||
}) {
|
||||
if (_operation != null) return Future.value(OneKeyLoginOutcome.busy);
|
||||
_terminal = false;
|
||||
_authPageOpened = false;
|
||||
final operation = _start(
|
||||
context: context,
|
||||
verifyToken: verifyToken,
|
||||
onNativeRequestDispatched: onNativeRequestDispatched,
|
||||
onAuthPageOpened: onAuthPageOpened,
|
||||
);
|
||||
_operation = operation;
|
||||
return operation.whenComplete(() {
|
||||
_operation = null;
|
||||
_completeCurrent = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<OneKeyLoginOutcome> _start({
|
||||
required BuildContext context,
|
||||
required Future<void> Function(String token) verifyToken,
|
||||
VoidCallback? onNativeRequestDispatched,
|
||||
VoidCallback? onAuthPageOpened,
|
||||
}) async {
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
||||
return OneKeyLoginOutcome.unsupported;
|
||||
}
|
||||
final uiConfig = _uiConfig(context);
|
||||
await UmengService.initOneKeyLoginIfAllowed();
|
||||
final completer = Completer<OneKeyLoginOutcome>();
|
||||
|
||||
void complete(OneKeyLoginOutcome outcome) {
|
||||
if (_terminal) return;
|
||||
_terminal = true;
|
||||
_resumeCancellation?.cancel();
|
||||
_resumeCancellation = null;
|
||||
_clearCallbacks();
|
||||
completer.complete(outcome);
|
||||
}
|
||||
|
||||
_completeCurrent = complete;
|
||||
|
||||
void handleUiResult(Object? result) {
|
||||
final code = _code(result);
|
||||
if (code == '700000') complete(OneKeyLoginOutcome.cancelled);
|
||||
if (code == '700001') complete(OneKeyLoginOutcome.fallback);
|
||||
}
|
||||
|
||||
void handleTokenResult(Object? result) {
|
||||
final code = _code(result);
|
||||
// iOS 700002 is only the native button click; token follows later.
|
||||
if (Platform.isIOS && code == '700002') return;
|
||||
// 600024 is Android environment availability, not a login result.
|
||||
if (code == '600024') return;
|
||||
if (code == '600001') {
|
||||
if (!_authPageOpened) {
|
||||
_authPageOpened = true;
|
||||
onAuthPageOpened?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (code == '700000') {
|
||||
complete(OneKeyLoginOutcome.cancelled);
|
||||
return;
|
||||
}
|
||||
if (code == '700001') {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
if (code != '600000' || result is! Map) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
final token = result['token']?.toString();
|
||||
if (token == null || token.isEmpty) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
unawaited(() async {
|
||||
try {
|
||||
await verifyToken(token);
|
||||
complete(OneKeyLoginOutcome.success);
|
||||
} catch (_) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
} finally {
|
||||
_closeNativePage();
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
UmengVerifySdk.getLoginTokenCallback(handleTokenResult);
|
||||
UmengVerifySdk.getWidgetEventCallback((_) {});
|
||||
} else {
|
||||
UmengVerifySdk.setTokenResultCallback_android(handleTokenResult);
|
||||
UmengVerifySdk.setUIClickCallback_android(handleUiResult);
|
||||
}
|
||||
try {
|
||||
final request = UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||||
onNativeRequestDispatched?.call();
|
||||
unawaited(
|
||||
request.catchError((Object _) {
|
||||
complete(OneKeyLoginOutcome.unsupported);
|
||||
}),
|
||||
);
|
||||
return await completer.future.timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => OneKeyLoginOutcome.fallback,
|
||||
);
|
||||
} finally {
|
||||
_clearCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
void onAppResumed() {
|
||||
if (!Platform.isAndroid ||
|
||||
!_authPageOpened ||
|
||||
_terminal ||
|
||||
_resumeCancellation != null) {
|
||||
return;
|
||||
}
|
||||
_resumeCancellation = Timer(const Duration(milliseconds: 500), () {
|
||||
_resumeCancellation = null;
|
||||
if (_terminal) return;
|
||||
_completeCurrent?.call(OneKeyLoginOutcome.cancelled);
|
||||
_closeNativePage();
|
||||
});
|
||||
}
|
||||
|
||||
UMCustomModel _uiConfig(BuildContext context) {
|
||||
const ink = Color(0xFF202124);
|
||||
const muted = Color(0xFF8A8F98);
|
||||
const background = Color(0xFFFFFFFF);
|
||||
final model = UMCustomModel()
|
||||
..logoIsHidden = true
|
||||
..numberColor = ink.toARGB32()
|
||||
..numberFont = 30
|
||||
..navTitle = ['', Colors.transparent.toARGB32(), 0]
|
||||
..navColor = background.toARGB32()
|
||||
..backgroundColor_ios = background.toARGB32()
|
||||
..sloganText = ['', background.toARGB32(), 0]
|
||||
..privacyPreText = '我已阅读并同意'
|
||||
..privacyOne = ['《隐私政策》', AppH5Urls.privacyUrl]
|
||||
..privacyTwo = ['《用户协议》', AppH5Urls.userProtocolUrl]
|
||||
..privacyColors = [muted.toARGB32(), ink.toARGB32()]
|
||||
..changeBtnTitle = ['其他手机号登录', ink.toARGB32(), 16]
|
||||
..loginBtnText = ['本机号码一键登录', Colors.white.toARGB32(), 16]
|
||||
..privacyFont = 12
|
||||
..checkBoxIsChecked = false;
|
||||
if (Platform.isAndroid) {
|
||||
model.numberFrame = [-1, 156, -1, -1];
|
||||
model.loginBtnFrame = [-1, 283, -1, -1, 52];
|
||||
model.changeBtnFrame = [368, -1, -1, -1];
|
||||
model.privacyFrame = [-1, 427, -1, -1];
|
||||
} else {
|
||||
final title = UMCustomWidget('title', UMCustomWidgetType.textView)
|
||||
..title = '金值'
|
||||
..titleFont = 26
|
||||
..titleColor = ink.toARGB32()
|
||||
..width = MediaQuery.sizeOf(context).width.toInt()
|
||||
..height = 54
|
||||
..top = 26
|
||||
..textAlignment = UMCustomWidgetTextAlignmentType.center
|
||||
..isClickEnable = false
|
||||
..rootViewId = UMRootViewId.body;
|
||||
model.customWidget = [title.toJsonMap()];
|
||||
model.privacyFont = 13;
|
||||
model.checkBoxWH = 20;
|
||||
model.checkBoxIsChecked = true;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
String _code(Object? result) => result is Map
|
||||
? (result['code'] ?? result['resultCode'] ?? '').toString()
|
||||
: '';
|
||||
|
||||
void _clearCallbacks() {
|
||||
UmengVerifySdk.getLoginTokenCallback(null);
|
||||
UmengVerifySdk.getWidgetEventCallback(null);
|
||||
UmengVerifySdk.setTokenResultCallback_android(null);
|
||||
UmengVerifySdk.setUIClickCallback_android(null);
|
||||
}
|
||||
|
||||
void _closeNativePage() {
|
||||
if (Platform.isIOS) {
|
||||
unawaited(UmengVerifySdk.cancelLoginVCAnimated(true));
|
||||
} else {
|
||||
UmengVerifySdk.quitLoginPage_android();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
|
||||
class OneKeyLoginApi {
|
||||
OneKeyLoginApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<OneKeyLoginReply> verify(String loginToken) async {
|
||||
final bytes = utf8.encode(loginToken);
|
||||
final request = Uint8List.fromList([
|
||||
0x0A,
|
||||
..._varint(bytes.length),
|
||||
...bytes,
|
||||
]);
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: request,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return _decode(Uint8List.fromList(response.data ?? const []));
|
||||
}
|
||||
|
||||
OneKeyLoginReply _decode(Uint8List data) {
|
||||
final fields = _fields(data);
|
||||
final token = utf8.decode(fields[1] ?? const []);
|
||||
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
|
||||
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
|
||||
return OneKeyLoginReply(
|
||||
token: token,
|
||||
phone: utf8.decode(user[2] ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
Map<int, List<int>> _fields(Uint8List data) {
|
||||
final result = <int, List<int>>{};
|
||||
var i = 0;
|
||||
while (i < data.length) {
|
||||
final tag = _readVarint(data, i);
|
||||
i = tag.$2;
|
||||
final wireType = tag.$1 & 7;
|
||||
if (wireType == 0) {
|
||||
i = _readVarint(data, i).$2;
|
||||
continue;
|
||||
}
|
||||
if (wireType != 2) {
|
||||
throw const FormatException('Unsupported protobuf field');
|
||||
}
|
||||
final length = _readVarint(data, i);
|
||||
i = length.$2;
|
||||
final end = i + length.$1;
|
||||
if (end > data.length) {
|
||||
throw const FormatException('Invalid protobuf payload');
|
||||
}
|
||||
result[tag.$1 >> 3] = data.sublist(i, end);
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
(int, int) _readVarint(Uint8List data, int index) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < data.length) {
|
||||
final byte = data[index++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return (value, index);
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf varint');
|
||||
}
|
||||
|
||||
List<int> _varint(int value) {
|
||||
final result = <int>[];
|
||||
while (value > 0x7f) {
|
||||
result.add((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
result.add(value);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class OneKeyLoginReply {
|
||||
const OneKeyLoginReply({required this.token, required this.phone});
|
||||
final String token;
|
||||
final String phone;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../application/login_launcher.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({super.key, this.next});
|
||||
@@ -31,6 +32,7 @@ class LoginPage extends HookConsumerWidget {
|
||||
final agreed = useState(false);
|
||||
final sending = useState(false);
|
||||
final verifying = useState(false);
|
||||
final oneKeyLoading = useState(false);
|
||||
final countdown = useState(0);
|
||||
final error = useState<String?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
@@ -163,6 +165,9 @@ class LoginPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> launchOneKey() =>
|
||||
LoginLauncher.start(context, ref, next: next);
|
||||
|
||||
void onSendTap() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
@@ -222,6 +227,8 @@ class LoginPage extends HookConsumerWidget {
|
||||
agreed: agreed.value,
|
||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||
onSend: onSendTap,
|
||||
oneKeyLoading: oneKeyLoading.value,
|
||||
onOneKey: launchOneKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -240,6 +247,8 @@ class _PhoneStep extends StatelessWidget {
|
||||
required this.onToggleAgreed,
|
||||
required this.error,
|
||||
required this.onSend,
|
||||
required this.oneKeyLoading,
|
||||
required this.onOneKey,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
@@ -251,6 +260,8 @@ class _PhoneStep extends StatelessWidget {
|
||||
final VoidCallback onToggleAgreed;
|
||||
final String? error;
|
||||
final VoidCallback onSend;
|
||||
final bool oneKeyLoading;
|
||||
final VoidCallback onOneKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -295,6 +306,13 @@ class _PhoneStep extends StatelessWidget {
|
||||
dim: !phoneReady,
|
||||
onTap: onSend,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PrimaryButton(
|
||||
label: '本机号码一键登录',
|
||||
enabled: !oneKeyLoading,
|
||||
loading: oneKeyLoading,
|
||||
onTap: onOneKey,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user