fix:第一轮跑

This commit is contained in:
jingyun committed 2026-08-24 19:07:31 +08:00
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),
],