优化:添加登录失败提示
This commit is contained in:
parent
439247bfff
commit
8455c3946b
86
lib/app.dart
86
lib/app.dart
@ -42,9 +42,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// 冷启动时进程已在 resumed,didChangeAppLifecycleState 往往收不到「变为 resumed」,需首帧后再手动打一次
|
// Cold start is often already resumed, so lifecycle may not emit resumed—fire once after first frame.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// 去掉原生启动屏(黑底+Logo),进入 Flutter;之后由登录遮罩承接等待提示
|
// Remove native splash; Flutter login overlay takes over if startup is still running.
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
_reportFacebookActivateApp('first_frame');
|
_reportFacebookActivateApp('first_frame');
|
||||||
});
|
});
|
||||||
@ -56,16 +56,16 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `AutoLogAppEventsEnabled=false` 时手动上报 Facebook **安装 + 应用激活**(activateApp)
|
/// With `AutoLogAppEventsEnabled=false`, manually report Facebook install + activateApp.
|
||||||
void _reportFacebookActivateApp(String reason) {
|
void _reportFacebookActivateApp(String reason) {
|
||||||
_fbAppEvents.activateApp().then((_) {
|
_fbAppEvents.activateApp().then((_) {
|
||||||
if (FacebookConfig.debugLogs) {
|
if (FacebookConfig.debugLogs) {
|
||||||
_fbLog.d('activateApp(手动: $reason)');
|
_fbLog.d('activateApp (manual: $reason)');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从后台回到前台时触发;冷启动依赖 initState 里的 addPostFrameCallback
|
/// Foreground resume; cold start also uses addPostFrameCallback in initState.
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
@ -95,10 +95,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
bottom: false,
|
bottom: false,
|
||||||
child: child ?? const SizedBox.shrink(),
|
child: child ?? const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
FutureBuilder<void>(
|
ListenableBuilder(
|
||||||
future: AuthService.loginComplete,
|
listenable: AuthService.isLoginComplete,
|
||||||
builder: (context, snapshot) {
|
builder: (context, _) {
|
||||||
if (snapshot.connectionState == ConnectionState.done) {
|
if (AuthService.isLoginComplete.value) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return const _StartupLoginOverlay();
|
return const _StartupLoginOverlay();
|
||||||
@ -138,7 +138,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动阶段等待 [AuthService.loginComplete]:久等时提示网络/服务端问题,避免用户误以为「死机」。
|
/// Full-screen wait until [AuthService.loginComplete]; after a delay, show a gentle network hint.
|
||||||
class _StartupLoginOverlay extends StatefulWidget {
|
class _StartupLoginOverlay extends StatefulWidget {
|
||||||
const _StartupLoginOverlay();
|
const _StartupLoginOverlay();
|
||||||
|
|
||||||
@ -181,7 +181,7 @@ class _StartupLoginOverlayState extends State<_StartupLoginOverlay> {
|
|||||||
if (_showNetworkHint) ...[
|
if (_showNetworkHint) ...[
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Text(
|
Text(
|
||||||
'网络较慢或暂时无法连接',
|
'Still getting things ready',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.surface.withValues(alpha: 0.96),
|
color: AppColors.surface.withValues(alpha: 0.96),
|
||||||
@ -191,7 +191,7 @@ class _StartupLoginOverlayState extends State<_StartupLoginOverlay> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'请检查 Wi‑Fi 或移动数据,稍候片刻。若网络正常,可能是服务器繁忙,请过一会再试。',
|
'This is slower than usual—your connection may be weak. Check Wi‑Fi or mobile data; loading will continue automatically. You can also fully close the app and open it again.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.surface.withValues(alpha: 0.78),
|
color: AppColors.surface.withValues(alpha: 0.78),
|
||||||
@ -246,7 +246,7 @@ class _MainScaffoldState extends State<_MainScaffold> with RouteAware {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 挂在 `/` 的 [PageRoute] 上:任意子页面 pop 后底层重新露出时触发(比 [HomeScreen] 内订阅更可靠)
|
/// Subscribed on `/` [PageRoute]: fires when a pushed route is popped and home is visible again.
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
if (widget.currentTab == NavTab.home) {
|
if (widget.currentTab == NavTab.home) {
|
||||||
@ -257,12 +257,62 @@ class _MainScaffoldState extends State<_MainScaffold> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(
|
body: Column(
|
||||||
index: widget.currentTab.index,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
HomeScreen(isActive: widget.currentTab == NavTab.home),
|
ListenableBuilder(
|
||||||
GalleryScreen(isActive: widget.currentTab == NavTab.gallery),
|
listenable: AuthService.startupUiListenable,
|
||||||
ProfileScreen(isActive: widget.currentTab == NavTab.profile),
|
builder: (context, _) {
|
||||||
|
if (!AuthService.shouldShowStartupFailure) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final msg = AuthService.startupFailureMessage.value ??
|
||||||
|
'We couldn’t finish loading. Check your network and tap Retry.';
|
||||||
|
return Material(
|
||||||
|
color: const Color(0xFFFFF3E0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 12, 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.wifi_off_rounded,
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.85),
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
msg,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.brown.shade800,
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await AuthService.retryStartup();
|
||||||
|
},
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: IndexedStack(
|
||||||
|
index: widget.currentTab.index,
|
||||||
|
children: [
|
||||||
|
HomeScreen(isActive: widget.currentTab == NavTab.home),
|
||||||
|
GalleryScreen(isActive: widget.currentTab == NavTab.gallery),
|
||||||
|
ProfileScreen(isActive: widget.currentTab == NavTab.profile),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:adjust_sdk/adjust.dart';
|
import 'package:adjust_sdk/adjust.dart';
|
||||||
import 'package:adjust_sdk/adjust_event.dart';
|
import 'package:adjust_sdk/adjust_event.dart';
|
||||||
import 'package:facebook_app_events/facebook_app_events.dart';
|
import 'package:facebook_app_events/facebook_app_events.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../config/facebook_config.dart';
|
import '../config/facebook_config.dart';
|
||||||
|
import '../user/user_state.dart';
|
||||||
|
|
||||||
/// 事件埋点:Adjust + Facebook App Events 双通道上报
|
/// 事件埋点:Adjust + Facebook App Events 双通道上报
|
||||||
/// Adjust 识别码见 docs/adjuest.md
|
/// Adjust 识别码见 docs/adjuest.md
|
||||||
@ -76,6 +78,44 @@ abstract final class AdjustEvents {
|
|||||||
unawaited(fn());
|
unawaited(fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Facebook 自定义事件:冷启动 fast_login 失败(事件名按产品约定拼写为 LoginFaild)。
|
||||||
|
///
|
||||||
|
/// [apiMsg] 可为接口原文或异常信息,仅用于分析,不应对用户直接展示。
|
||||||
|
static void trackLoginFaild({
|
||||||
|
required String source,
|
||||||
|
int? apiCode,
|
||||||
|
String? apiMsg,
|
||||||
|
String? deviceId,
|
||||||
|
}) {
|
||||||
|
String truncate(String? s, int max) {
|
||||||
|
final t = (s ?? '').trim();
|
||||||
|
if (t.length <= max) return t;
|
||||||
|
return '${t.substring(0, max)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverCountry = UserState.navigate.value?.trim() ?? '';
|
||||||
|
final localeCountry =
|
||||||
|
(PlatformDispatcher.instance.locale.countryCode ?? '').trim();
|
||||||
|
|
||||||
|
final msg = truncate(apiMsg, 500);
|
||||||
|
|
||||||
|
_trackFb(
|
||||||
|
'LoginFaild source=$source api_code=${apiCode ?? ''}',
|
||||||
|
() => _fb.logEvent(
|
||||||
|
name: 'LoginFaild',
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'source': source,
|
||||||
|
'api_code': (apiCode ?? '').toString(),
|
||||||
|
'api_message': msg,
|
||||||
|
'device_id': deviceId ?? '',
|
||||||
|
'platform': defaultTargetPlatform.name,
|
||||||
|
'server_country': serverCountry,
|
||||||
|
'locale_country': localeCountry,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 购买档位(用户点击某档位购买时)
|
/// 购买档位(用户点击某档位购买时)
|
||||||
static void trackTier(String eventToken) {
|
static void trackTier(String eventToken) {
|
||||||
_track(eventToken);
|
_track(eventToken);
|
||||||
|
|||||||
@ -40,4 +40,7 @@ abstract final class ApiConfig {
|
|||||||
|
|
||||||
/// 单次 HTTP 请求超时(避免弱网/服务端无响应时永久卡住启动流程)
|
/// 单次 HTTP 请求超时(避免弱网/服务端无响应时永久卡住启动流程)
|
||||||
static const Duration httpRequestTimeout = Duration(seconds: 20);
|
static const Duration httpRequestTimeout = Duration(seconds: 20);
|
||||||
|
|
||||||
|
/// Wall-clock cap for cold-start login (device id, attribution, fast_login, etc.); then dismiss overlay and show in-app retry.
|
||||||
|
static const Duration startupWallTimeout = Duration(seconds: 72);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -256,7 +256,12 @@ class ProxyClient {
|
|||||||
final v2BodyEncoded = jsonEncode(v2Body);
|
final v2BodyEncoded = jsonEncode(v2Body);
|
||||||
|
|
||||||
final logStr =
|
final logStr =
|
||||||
'========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
|
'========== request (pre-encrypt) ===========\n'
|
||||||
|
'path: $path\n'
|
||||||
|
'method: $method\n'
|
||||||
|
'headers(V2 logical → encrypted into body): $headersEncoded\n'
|
||||||
|
'queryParams: $paramsEncoded\n'
|
||||||
|
'body(sanctum): ${jsonEncode(sanctum)}';
|
||||||
_log(logStr);
|
_log(logStr);
|
||||||
|
|
||||||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||||||
@ -283,12 +288,14 @@ class ProxyClient {
|
|||||||
|
|
||||||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||||||
_log('真实请求URL: $url');
|
_log('真实请求URL: $url');
|
||||||
|
const httpTransportHeaders = {'Content-Type': 'application/json'};
|
||||||
|
_log('HTTP transport headers: ${jsonEncode(httpTransportHeaders)}');
|
||||||
http.Response response;
|
http.Response response;
|
||||||
try {
|
try {
|
||||||
response = await http
|
response = await http
|
||||||
.post(
|
.post(
|
||||||
Uri.parse(url),
|
Uri.parse(url),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: httpTransportHeaders,
|
||||||
body: jsonEncode(proxyBody),
|
body: jsonEncode(proxyBody),
|
||||||
)
|
)
|
||||||
.timeout(ApiConfig.httpRequestTimeout);
|
.timeout(ApiConfig.httpRequestTimeout);
|
||||||
|
|||||||
@ -49,9 +49,42 @@ class AuthService {
|
|||||||
|
|
||||||
static Future<void>? _loginFuture;
|
static Future<void>? _loginFuture;
|
||||||
|
|
||||||
/// 登录是否已完成,用于 UI 控制(如登录中隐藏页面加载指示器)
|
/// Bumped each [init] so a superseded session’s `finally` does not complete the wrong [Completer].
|
||||||
|
static int _initGeneration = 0;
|
||||||
|
|
||||||
|
/// Context for Facebook custom event `LoginFaild` (sent once in [init] `finally` on failure).
|
||||||
|
static String? _loginFailDeviceId;
|
||||||
|
static int? _loginFailApiCode;
|
||||||
|
static String? _loginFailApiMsg;
|
||||||
|
static String _loginFailSource = '';
|
||||||
|
|
||||||
|
static void _resetLoginFailTelemetry() {
|
||||||
|
_loginFailDeviceId = null;
|
||||||
|
_loginFailApiCode = null;
|
||||||
|
_loginFailApiMsg = null;
|
||||||
|
_loginFailSource = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startup sequence finished (success or failure); drives overlay and list loading gates.
|
||||||
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// `fast_login` (and related) succeeded; when false but [isLoginComplete] is true, show the in-app retry strip.
|
||||||
|
static final ValueNotifier<bool> startupSucceeded = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// User-facing startup error copy; null when successful or not yet set.
|
||||||
|
static final ValueNotifier<String?> startupFailureMessage = ValueNotifier(null);
|
||||||
|
|
||||||
|
/// True when startup ended without success—show friendly UI instead of an endless spinner.
|
||||||
|
static bool get shouldShowStartupFailure =>
|
||||||
|
isLoginComplete.value && !startupSucceeded.value;
|
||||||
|
|
||||||
|
/// Merged listenable for overlay / home retry strip rebuilds.
|
||||||
|
static Listenable get startupUiListenable => Listenable.merge([
|
||||||
|
isLoginComplete,
|
||||||
|
startupSucceeded,
|
||||||
|
startupFailureMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||||
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||||
|
|
||||||
@ -191,124 +224,228 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// APP 启动时调用快速登录
|
static void _markStartupFailed(String message) {
|
||||||
/// 启动时网络可能未就绪,会延迟后重试
|
startupSucceeded.value = false;
|
||||||
|
startupFailureMessage.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Never show raw [ApiResponse.msg] to users; log it only.
|
||||||
|
static String _friendlyHintForFailedLogin(ApiResponse res) {
|
||||||
|
if (res.code == -1) {
|
||||||
|
return 'Your current network temporarily does not support access. Please switch to another network and try again.';
|
||||||
|
}
|
||||||
|
return 'We couldn’t sign you in just yet. Please try again in a moment, or tap Retry.';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _markStartupSucceeded() {
|
||||||
|
startupSucceeded.value = true;
|
||||||
|
startupFailureMessage.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry cold-start login from the home banner (only after a finished, failed attempt).
|
||||||
|
static Future<void> retryStartup() async {
|
||||||
|
if (!isLoginComplete.value) return;
|
||||||
|
if (startupSucceeded.value) return;
|
||||||
|
_loginFuture = null;
|
||||||
|
startupSucceeded.value = false;
|
||||||
|
startupFailureMessage.value = null;
|
||||||
|
isLoginComplete.value = false;
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cold-start fast login; retries on transient errors; whole flow is capped by [ApiConfig.startupWallTimeout].
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
if (_loginFuture != null) return _loginFuture!;
|
if (_loginFuture != null) return _loginFuture!;
|
||||||
|
_initGeneration++;
|
||||||
|
final gen = _initGeneration;
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
_loginFuture = completer.future;
|
_loginFuture = completer.future;
|
||||||
|
_resetLoginFailTelemetry();
|
||||||
|
|
||||||
_logMsg('init: 开始快速登录');
|
_logMsg('init: fast_login starting (gen=$gen)');
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
const retryDelay = Duration(seconds: 2);
|
const retryDelay = Duration(seconds: 2);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 极短让步:避免个别机型刚起进程时网络栈未就绪;过长会拖慢冷启动
|
await _initBody(
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
maxRetries: maxRetries,
|
||||||
|
retryDelay: retryDelay,
|
||||||
final deviceId = await _getDeviceId();
|
).timeout(ApiConfig.startupWallTimeout);
|
||||||
_logMsg('init: deviceId=$deviceId');
|
} on TimeoutException catch (e, st) {
|
||||||
|
_logMsg('init: wall-clock timeout $e');
|
||||||
final sign = _computeSign(deviceId);
|
_logMsg('init: stack $st');
|
||||||
_logMsg('init: sign=$sign');
|
_loginFailSource = 'startup_timeout';
|
||||||
|
_loginFailApiCode = -1;
|
||||||
final crest = await ReferrerService.getReferrer();
|
_loginFailApiMsg =
|
||||||
if (crest != null && crest.isNotEmpty) {
|
'Wall clock timeout after ${ApiConfig.startupWallTimeout.inSeconds}s';
|
||||||
_logMsg('init: crest(referrer)=$crest');
|
_markStartupFailed(
|
||||||
}
|
'This is taking longer than usual—your network may be slow. Check your connection, tap Retry, or try again later.',
|
||||||
|
);
|
||||||
await AuthTokenStore.restoreToApiClient();
|
|
||||||
if (ApiClient.instance.proxy.userToken != null &&
|
|
||||||
ApiClient.instance.proxy.userToken!.isNotEmpty) {
|
|
||||||
_logMsg('init: 已用本地 token 注入请求头,本次 fast_login 将携带 knight');
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse? res;
|
|
||||||
for (var i = 0; i < maxRetries; i++) {
|
|
||||||
if (i > 0) {
|
|
||||||
_logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
|
||||||
await Future<void>.delayed(retryDelay);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
res = await UserApi.fastLogin(
|
|
||||||
origin: deviceId,
|
|
||||||
resolution: sign,
|
|
||||||
digest: crest ?? '',
|
|
||||||
crest: crest,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
} catch (e) {
|
|
||||||
_logMsg('init: 第 ${i + 1} 次请求失败: $e');
|
|
||||||
if (i == maxRetries - 1) rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == null) return;
|
|
||||||
|
|
||||||
_logMsg('init: 登录结果 code=${res.code} msg=${res.msg}');
|
|
||||||
_logMsg('init: 登录响应 data=${res.data}');
|
|
||||||
|
|
||||||
if (res.isSuccess && res.data != null) {
|
|
||||||
final data = res.data as Map<String, dynamic>?;
|
|
||||||
final token = data?['reevaluate'] as String?;
|
|
||||||
if (token != null && token.isNotEmpty) {
|
|
||||||
ApiClient.instance.setUserToken(token);
|
|
||||||
await AuthTokenStore.write(token);
|
|
||||||
_logMsg('init: 已设置 userToken 并写入本地');
|
|
||||||
} else {
|
|
||||||
_logMsg('init: 响应中无 reevaluate (userToken),保留原本地 token(若有)');
|
|
||||||
}
|
|
||||||
// equip = 服务端 firstRegister(见 docs/user_login.md),为 true/1 时上报注册事件
|
|
||||||
final equipRaw = data?['equip'];
|
|
||||||
final equipFirstRegister = equipRaw == true ||
|
|
||||||
equipRaw == 1 ||
|
|
||||||
equipRaw == '1' ||
|
|
||||||
(equipRaw is String && equipRaw.toLowerCase() == 'true');
|
|
||||||
if (equipFirstRegister) {
|
|
||||||
AdjustEvents.trackRegister();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(
|
|
||||||
'adjust_register_date',
|
|
||||||
DateTime.now().toIso8601String().substring(0, 10),
|
|
||||||
);
|
|
||||||
_logMsg('init: equip=true 首次注册,已上报 register');
|
|
||||||
}
|
|
||||||
final credits = data?['reveal'] as int?;
|
|
||||||
if (credits != null) {
|
|
||||||
UserState.setCredits(credits);
|
|
||||||
_logMsg('init: 已同步积分 $credits');
|
|
||||||
}
|
|
||||||
final uid = data?['asset'] as String?;
|
|
||||||
if (uid != null && uid.isNotEmpty) {
|
|
||||||
UserState.setUserId(uid);
|
|
||||||
_logMsg('init: 已设置 userId');
|
|
||||||
}
|
|
||||||
final avatarUrl = data?['realm'] as String?;
|
|
||||||
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
|
||||||
UserState.setAvatar(avatarUrl);
|
|
||||||
}
|
|
||||||
final name = data?['terminal'] as String?;
|
|
||||||
if (name != null && name.isNotEmpty) {
|
|
||||||
UserState.setUserName(name);
|
|
||||||
}
|
|
||||||
final countryCode = data?['navigate'] as String?;
|
|
||||||
if (countryCode != null) {
|
|
||||||
UserState.setNavigate(countryCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 归因与 common_info:后台执行,不阻塞 loginComplete(避免服务端慢/挂起导致一直卡在启动遮罩)
|
|
||||||
unawaited(_runPostLoginReferrerWork(uid!, deviceId));
|
|
||||||
} else {
|
|
||||||
_logMsg('init: 登录失败');
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
_logMsg('init: 异常 $e');
|
_logMsg('init: exception $e');
|
||||||
_logMsg('init: 堆栈 $st');
|
_logMsg('init: stack $st');
|
||||||
} finally {
|
if (_loginFailSource.isEmpty) {
|
||||||
if (!completer.isCompleted) {
|
_loginFailSource = 'exception';
|
||||||
completer.complete();
|
_loginFailApiMsg = e.toString();
|
||||||
isLoginComplete.value = true;
|
|
||||||
}
|
}
|
||||||
|
if (!startupSucceeded.value &&
|
||||||
|
(startupFailureMessage.value == null ||
|
||||||
|
startupFailureMessage.value!.isEmpty)) {
|
||||||
|
_markStartupFailed(
|
||||||
|
'Something went wrong and we couldn’t reach the service. Check your network and tap Retry, or try again later.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (gen != _initGeneration) {
|
||||||
|
_logMsg('init: gen=$gen superseded; skipping completer');
|
||||||
|
} else {
|
||||||
|
if (!startupSucceeded.value &&
|
||||||
|
(startupFailureMessage.value == null ||
|
||||||
|
startupFailureMessage.value!.trim().isEmpty)) {
|
||||||
|
_markStartupFailed(
|
||||||
|
'We couldn’t finish loading. Check your network, tap Retry, or open the app again in a little while.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
isLoginComplete.value = true;
|
||||||
|
}
|
||||||
|
if (!startupSucceeded.value) {
|
||||||
|
if (_loginFailSource.isEmpty) {
|
||||||
|
_loginFailSource = 'unknown';
|
||||||
|
}
|
||||||
|
AdjustEvents.trackLoginFaild(
|
||||||
|
source: _loginFailSource,
|
||||||
|
apiCode: _loginFailApiCode,
|
||||||
|
apiMsg: _loginFailApiMsg,
|
||||||
|
deviceId: _loginFailDeviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_resetLoginFailTelemetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _initBody({
|
||||||
|
required int maxRetries,
|
||||||
|
required Duration retryDelay,
|
||||||
|
}) async {
|
||||||
|
// Short yield so the network stack can settle on some devices; keep small for cold start.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final deviceId = await _getDeviceId();
|
||||||
|
_loginFailDeviceId = deviceId;
|
||||||
|
_logMsg('init: deviceId=$deviceId');
|
||||||
|
|
||||||
|
final sign = _computeSign(deviceId);
|
||||||
|
_logMsg('init: sign=$sign');
|
||||||
|
|
||||||
|
final crest = await ReferrerService.getReferrer();
|
||||||
|
if (crest != null && crest.isNotEmpty) {
|
||||||
|
_logMsg('init: crest(referrer)=$crest');
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthTokenStore.restoreToApiClient();
|
||||||
|
if (ApiClient.instance.proxy.userToken != null &&
|
||||||
|
ApiClient.instance.proxy.userToken!.isNotEmpty) {
|
||||||
|
_logMsg('init: restored local token; fast_login will send knight if present');
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse? res;
|
||||||
|
for (var i = 0; i < maxRetries; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
_logMsg('init: retry ${i + 1}, waiting ${retryDelay.inSeconds}s...');
|
||||||
|
await Future<void>.delayed(retryDelay);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res = await UserApi.fastLogin(
|
||||||
|
origin: deviceId,
|
||||||
|
resolution: sign,
|
||||||
|
digest: crest ?? '',
|
||||||
|
crest: crest,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
_logMsg('init: attempt ${i + 1} failed: $e');
|
||||||
|
if (i == maxRetries - 1) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res == null) {
|
||||||
|
_loginFailSource = 'no_response';
|
||||||
|
_loginFailApiCode = null;
|
||||||
|
_loginFailApiMsg =
|
||||||
|
'No ApiResponse after fast_login attempts (or error before response)';
|
||||||
|
_markStartupFailed(
|
||||||
|
'We couldn’t reach the server. Check your network and tap Retry, or try again shortly.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logMsg('init: fast_login result code=${res.code} msg=${res.msg}');
|
||||||
|
_logMsg('init: fast_login data=${res.data}');
|
||||||
|
|
||||||
|
if (res.isSuccess && res.data != null) {
|
||||||
|
final data = res.data as Map<String, dynamic>?;
|
||||||
|
final token = data?['reevaluate'] as String?;
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
ApiClient.instance.setUserToken(token);
|
||||||
|
await AuthTokenStore.write(token);
|
||||||
|
_logMsg('init: userToken set and persisted');
|
||||||
|
} else {
|
||||||
|
_logMsg('init: no reevaluate in response; keeping prior local token if any');
|
||||||
|
}
|
||||||
|
// equip = 服务端 firstRegister(见 docs/user_login.md),为 true/1 时上报注册事件
|
||||||
|
final equipRaw = data?['equip'];
|
||||||
|
final equipFirstRegister = equipRaw == true ||
|
||||||
|
equipRaw == 1 ||
|
||||||
|
equipRaw == '1' ||
|
||||||
|
(equipRaw is String && equipRaw.toLowerCase() == 'true');
|
||||||
|
if (equipFirstRegister) {
|
||||||
|
AdjustEvents.trackRegister();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
'adjust_register_date',
|
||||||
|
DateTime.now().toIso8601String().substring(0, 10),
|
||||||
|
);
|
||||||
|
_logMsg('init: equip=true first register; register event sent');
|
||||||
|
}
|
||||||
|
final credits = data?['reveal'] as int?;
|
||||||
|
if (credits != null) {
|
||||||
|
UserState.setCredits(credits);
|
||||||
|
_logMsg('init: credits synced: $credits');
|
||||||
|
}
|
||||||
|
final uid = data?['asset'] as String?;
|
||||||
|
if (uid != null && uid.isNotEmpty) {
|
||||||
|
UserState.setUserId(uid);
|
||||||
|
_logMsg('init: userId set');
|
||||||
|
}
|
||||||
|
final avatarUrl = data?['realm'] as String?;
|
||||||
|
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
||||||
|
UserState.setAvatar(avatarUrl);
|
||||||
|
}
|
||||||
|
final name = data?['terminal'] as String?;
|
||||||
|
if (name != null && name.isNotEmpty) {
|
||||||
|
UserState.setUserName(name);
|
||||||
|
}
|
||||||
|
final countryCode = data?['navigate'] as String?;
|
||||||
|
if (countryCode != null) {
|
||||||
|
UserState.setNavigate(countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referrer + common_info in background; do not block loginComplete.
|
||||||
|
if (uid != null && uid.isNotEmpty) {
|
||||||
|
unawaited(_runPostLoginReferrerWork(uid, deviceId));
|
||||||
|
}
|
||||||
|
_markStartupSucceeded();
|
||||||
|
} else {
|
||||||
|
_logMsg(
|
||||||
|
'init: fast_login failed (user-facing copy sanitized) code=${res.code} msg=${res.msg}',
|
||||||
|
);
|
||||||
|
_loginFailSource = 'fast_login_api';
|
||||||
|
_loginFailApiCode = res.code;
|
||||||
|
_loginFailApiMsg = res.msg;
|
||||||
|
_markStartupFailed(_friendlyHintForFailedLogin(res));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -379,7 +379,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
if (refresh) _tasks = [];
|
if (refresh) _tasks = [];
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
_error = res.msg.isNotEmpty ? res.msg : 'Failed to load';
|
_error = 'Unable to load your gallery right now. Please check your connection and try again.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -388,7 +388,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
if (refresh) _tasks = [];
|
if (refresh) _tasks = [];
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
_error = e.toString();
|
_error = 'Something went wrong while loading your gallery. Please try again.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -284,7 +284,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Generation failed: ${e.toString().replaceAll('Exception: ', '')}'),
|
'We could not start generation right now. Please try again.'),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -63,7 +63,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_videoLoading = false;
|
_videoLoading = false;
|
||||||
_videoLoadError = e.toString();
|
_videoLoadError = 'Video preview is temporarily unavailable. Please try again later.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -519,7 +519,7 @@ class _ReportDialogState extends State<_ReportDialog> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _errorText = e.toString().replaceAll('Exception: ', ''));
|
setState(() => _errorText = 'Unable to submit your report right now. Please try again.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@ -240,6 +240,8 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|||||||
/// 登录未完成时不显示页面加载指示器,由登录遮罩负责
|
/// 登录未完成时不显示页面加载指示器,由登录遮罩负责
|
||||||
bool get _isListLoading {
|
bool get _isListLoading {
|
||||||
if (!AuthService.isLoginComplete.value) return false;
|
if (!AuthService.isLoginComplete.value) return false;
|
||||||
|
// After failed fast_login, extConfig never arrives so items stay null; without this, list loading spins forever.
|
||||||
|
if (AuthService.shouldShowStartupFailure) return false;
|
||||||
if (_showVideoMenu) {
|
if (_showVideoMenu) {
|
||||||
if (_categoriesLoading) return true;
|
if (_categoriesLoading) return true;
|
||||||
if (_selectedCategory?.id == kExtCategoryId) {
|
if (_selectedCategory?.id == kExtCategoryId) {
|
||||||
|
|||||||
@ -422,11 +422,11 @@ class _DeleteAccountDialogState extends State<_DeleteAccountDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() => _errorText = res.msg.isNotEmpty ? res.msg : 'Delete failed');
|
setState(() => _errorText = 'Unable to delete your account right now. Please try again.');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _errorText = e.toString().replaceAll('Exception: ', ''));
|
setState(() => _errorText = 'Something went wrong while deleting your account. Please try again.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_activities = [];
|
_activities = [];
|
||||||
_loadingTiers = false;
|
_loadingTiers = false;
|
||||||
_tierError = res.msg.isNotEmpty ? res.msg : 'Failed to load';
|
_tierError = 'Unable to load plans right now. Please check your connection and try again.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -127,7 +127,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_activities = [];
|
_activities = [];
|
||||||
_loadingTiers = false;
|
_loadingTiers = false;
|
||||||
_tierError = e.toString();
|
_tierError = 'Something went wrong while loading plans. Please try again.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,9 +185,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
if (!methodsRes.isSuccess || methodsRes.data == null) {
|
if (!methodsRes.isSuccess || methodsRes.data == null) {
|
||||||
_showSnackBar(
|
_showSnackBar(
|
||||||
context,
|
context,
|
||||||
methodsRes.msg.isNotEmpty
|
'Unable to load payment methods right now. Please try again shortly.',
|
||||||
? methodsRes.msg
|
|
||||||
: 'Failed to load payment methods',
|
|
||||||
isError: true,
|
isError: true,
|
||||||
);
|
);
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
@ -239,7 +237,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
|
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||||
}
|
}
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -271,7 +269,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
if (!createRes.isSuccess) {
|
if (!createRes.isSuccess) {
|
||||||
_showSnackBar(
|
_showSnackBar(
|
||||||
context,
|
context,
|
||||||
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
|
'Unable to create your order right now. Please try again.',
|
||||||
isError: true,
|
isError: true,
|
||||||
);
|
);
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
@ -342,7 +340,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
|
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||||
}
|
}
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -435,7 +433,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
if (!createRes.isSuccess) {
|
if (!createRes.isSuccess) {
|
||||||
_showSnackBar(
|
_showSnackBar(
|
||||||
context,
|
context,
|
||||||
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
|
'Unable to create your order right now. Please try again.',
|
||||||
isError: true,
|
isError: true,
|
||||||
);
|
);
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
@ -449,7 +447,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
serverOrderId: orderId, userId: userId);
|
serverOrderId: orderId, userId: userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
|
_showSnackBar(context, 'Payment could not be completed. Please try again.', isError: true);
|
||||||
}
|
}
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
} finally {
|
} finally {
|
||||||
@ -527,7 +525,7 @@ class _RechargeScreenState extends State<RechargeScreen>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnackBar(context, 'Google Pay error: ${e.toString()}',
|
_showSnackBar(context, 'Google Pay is temporarily unavailable. Please try again.',
|
||||||
isError: true);
|
isError: true);
|
||||||
}
|
}
|
||||||
AdjustEvents.trackPaymentFailed();
|
AdjustEvents.trackPaymentFailed();
|
||||||
|
|||||||
@ -18,7 +18,7 @@ void main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await ensureDeviceMemoryProfileInitialized();
|
await ensureDeviceMemoryProfileInitialized();
|
||||||
_initAdjust();
|
_initAdjust();
|
||||||
// 勿在此 await ReferrerService.init():会阻塞首帧(Adjust 最长可达十余秒)。[AuthService.init] 内已 await getReferrer()
|
// Do not await ReferrerService.init() here—it can block first frame (Adjust may take many seconds). AuthService.init awaits getReferrer().
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
statusBarColor: AppColors.surface,
|
statusBarColor: AppColors.surface,
|
||||||
@ -34,8 +34,11 @@ void main() async {
|
|||||||
GooglePlayPurchaseService.startPendingPurchaseListener();
|
GooglePlayPurchaseService.startPendingPurchaseListener();
|
||||||
}
|
}
|
||||||
// 登录完成后执行谷歌支付补单(未核销订单上报服务端并 completePurchase)
|
// 登录完成后执行谷歌支付补单(未核销订单上报服务端并 completePurchase)
|
||||||
AuthService.loginComplete
|
AuthService.loginComplete.then((_) {
|
||||||
.then((_) => GooglePlayPurchaseService.runOrderRecovery());
|
if (AuthService.startupSucceeded.value) {
|
||||||
|
GooglePlayPurchaseService.runOrderRecovery();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initAdjust() {
|
void _initAdjust() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user