flutter-tab-auth-debug

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Flutter Tab Auth Debug

Flutter标签页授权调试

Core problem

核心问题

Flutter + Riverpod + GoRouter bottom tab app có thể bị stuck loading hoặc redirect sai sau reload. Nguyên nhân thường là auth status transition race + redirect logic hardcode gây mất tab context.
Flutter + Riverpod + GoRouter 底部标签页应用可能会在重载后出现加载卡住或重定向错误的问题。常见原因是授权状态转换竞争 + 硬编码重定向逻辑导致标签页上下文丢失。

1. Khi nào áp dụng

1. 适用场景

  • Bottom tab stuck at loading spinner, especially Account tab
  • SplashScreen redirect về wrong tab sau reload
  • Reload page ở
    /account
    thì quay về
    /attendance
  • session == null
    hiển thị loading vô hạn dù đã login
  • App treo ở SplashScreen vì
    checkAuth()
    không resolve
  • 底部标签页卡在加载 spinner,尤其是“账户”标签页
  • 启动页重载后重定向到错误的标签页
  • /account
    页面重载后跳转到
    /attendance
  • 已登录但
    session == null
    仍显示无限加载
  • checkAuth()
    未执行完成,应用卡在启动页

2. Quy trình debug

2. 调试流程

Bước 1: Reproduce → mở DevTools console/log, xem
Splash auth status changed
log có in ra không, authStatus qua các state nào:
unknown → authenticated → ?
Bước 2: Nếu authStatus mãi là
unknown
checkAuth()
treo. Thêm timeout cho
verifySession()
(10s).
Bước 3: Nếu authStatus sang
authenticated
nhưng tab vẫn sai → SplashScreen hardcode redirect sang
/attendance
. Phải giữ route hiện tại.
Bước 4: Nếu reload ở tab Profile mà stuck loading → check
app.dart
redirect logic:
authStatus == unknown
có trả
null
không. Nếu trả
null
, GoRouter load route trực tiếp trước khi auth resolve → AccountScreen thấy
session == null
→ stuck.
步骤1:复现问题 → 打开DevTools控制台/日志,查看是否输出
Splash auth status changed
日志,以及authStatus的状态变化:
unknown → authenticated → ?
步骤2:如果authStatus一直是
unknown
checkAuth()
卡住。为
verifySession()
添加超时(10秒)。
步骤3:如果authStatus变为
authenticated
但标签页仍错误 → 启动页硬编码重定向到
/attendance
。需要保留当前路由。
步骤4:如果在“个人资料”标签页重载后卡住加载 → 检查
app.dart
的重定向逻辑:当
authStatus == unknown
时是否返回
null
。如果返回
null
,GoRouter会在授权验证完成前直接加载路由 → AccountScreen检测到
session == null
→ 卡住。

3. Pattern đúng

3. 正确实现模式

Pattern A: checkAuth() có timeout

模式A:为checkAuth()添加超时

dart
Future<void> checkAuth() async {
  // ... read token, baseUrl ...

  try {
    final result = await client
        .verifySession(token)
        .timeout(const Duration(seconds: 10));

    if (result != null) {
      final sessionData = result['session'] as Map<String, dynamic>?;
      if (sessionData != null) {
        final session = Session.fromJson(sessionData);
        ref.read(sessionProvider.notifier).set(session);
        state = AuthStatus.authenticated;
        return;
      }
    }
  } on TimeoutException {
    // server treo → coi unauthenticated
  } catch (_) {}

  state = AuthStatus.unauthenticated;
}
dart
Future<void> checkAuth() async {
  // ... 读取token、baseUrl ...

  try {
    final result = await client
        .verifySession(token)
        .timeout(const Duration(seconds: 10));

    if (result != null) {
      final sessionData = result['session'] as Map<String, dynamic>?;
      if (sessionData != null) {
        final session = Session.fromJson(sessionData);
        ref.read(sessionProvider.notifier).set(session);
        state = AuthStatus.authenticated;
        return;
      }
    }
  } on TimeoutException {
    // 服务器无响应 → 视为未授权
  } catch (_) {}

  state = AuthStatus.unauthenticated;
}

Pattern B: SplashScreen giữ route hiện tại

模式B:启动页保留当前路由

dart
ref.listen<AuthStatus>(authStatusProvider, (previous, next) {
  if (!mounted) return;
  if (next == AuthStatus.authenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        final location = GoRouter.of(context).state.matchedLocation;
        if (location == '/' || location == '/login') {
          context.go('/attendance');
        }
        // Otherwise stay on current route
      }
    });
  } else if (next == AuthStatus.unauthenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) context.go('/login');
    });
  }
});
dart
ref.listen<AuthStatus>(authStatusProvider, (previous, next) {
  if (!mounted) return;
  if (next == AuthStatus.authenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        final location = GoRouter.of(context).state.matchedLocation;
        if (location == '/' || location == '/login') {
          context.go('/attendance');
        }
        // 否则保留当前路由
      }
    });
  } else if (next == AuthStatus.unauthenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) context.go('/login');
    });
  }
});

Pattern C: app.dart redirect về / khi unknown

模式C:app.dart在状态为unknown时重定向到/

dart
redirect: (context, state) {
  final authStatus = ref.read(authStatusProvider);

  if (authStatus == AuthStatus.unknown) {
    final path = state.matchedLocation;
    if (path == '/' || path == '/login') return null;
    return '/';
  }

  // ... authenticated / unauthenticated branches
}
dart
redirect: (context, state) {
  final authStatus = ref.read(authStatusProvider);

  if (authStatus == AuthStatus.unknown) {
    final path = state.matchedLocation;
    if (path == '/' || path == '/login') return null;
    return '/';
  }

  // ... 已授权/未授权分支逻辑
}

Pattern D: AccountScreen handle session == null

模式D:AccountScreen处理session == null的情况

dart
if (session == null) {
  final authStatus = ref.watch(authStatusProvider);

  if (authStatus == AuthStatus.unauthenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) context.go('/login');
    });
    return Scaffold(body: Center(child: Text('Phiên hết hạn')));
  }

  return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
dart
if (session == null) {
  final authStatus = ref.watch(authStatusProvider);

  if (authStatus == AuthStatus.unauthenticated) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) context.go('/login');
    });
    return Scaffold(body: Center(child: Text('会话已过期')));
  }

  return const Scaffold(body: Center(child: CircularProgressIndicator()));
}

4. Luồng xử lý sau fix

4. 修复后的处理流程

Reload page at /account
  ├─ app.dart: authStatus == unknown → redirect → /
  ├─ SplashScreen renders
  ├─ checkAuth() gọi verifySession (có timeout 10s)
  ├─ AuthStatus → authenticated
  ├─ SplashScreen: location == '/' → go('/attendance')
  └─ User manually navigate sang /account → OK

Reload page at /account (fix state A/B/C applied)
  ├─ app.dart: authStatus == unknown → redirect → /
  ├─ SplashScreen renders
  ├─ checkAuth() gọi verifySession (có timeout 10s)
  ├─ AuthStatus → authenticated
  ├─ SplashScreen: location == '/' → go('/attendance')
  └─ User manually navigate sang /account → OK
在/account页面重载
  ├─ app.dart: authStatus == unknown → 重定向 → /
  ├─ 启动页渲染
  ├─ checkAuth()调用verifySession(带10秒超时)
  ├─ AuthStatus → authenticated
  ├─ 启动页: location == '/' → 跳转到/attendance
  └─ 用户手动导航到/account → 正常

在/account页面重载(已应用A/B/C修复)
  ├─ app.dart: authStatus == unknown → 重定向 → /
  ├─ 启动页渲染
  ├─ checkAuth()调用verifySession(带10秒超时)
  ├─ AuthStatus → authenticated
  ├─ 启动页: location == '/' → 跳转到/attendance
  └─ 用户手动导航到/account → 正常

5. Kiểm tra

5. 验证检查

  • Login → switch tab nhanh 5-10 lần → không stuck
  • Stop server sau login → switch tab → không loading vô hạn
  • Reload ở từng tab (Chấm công / Yêu cầu / Tài khoản) → giữ nguyên tab
  • Token hết hạn → redirect về /login
  • 登录后快速切换标签页5-10次 → 无卡顿
  • 登录后停止服务器 → 切换标签页 → 无无限加载
  • 在每个标签页(考勤/申请/账户)重载 → 保留当前标签页
  • 令牌过期 → 重定向到/login

6. Lỗi thường gặp

6. 常见错误

LỗiNguyên nhânFix
GoRouter.of(context).location
undefined
go_router ^17 không có getter
location
Dùng
GoRouter.of(context).state.matchedLocation
SplashScreen chuyển về
/attendance
dù đang ở
/account
Hardcode
context.go('/attendance')
Kiểm tra
matchedLocation
trước
AccountScreen loading vô hạn sau reload
app.dart
redirect
null
khi unknown
Redirect về
/
thay vì
null
checkAuth()
treo vô hạn
Không có timeout trên verifySession
.timeout(Duration(seconds: 10))
错误原因修复方案
GoRouter.of(context).location
未定义
go_router ^17版本没有
location
getter
使用
GoRouter.of(context).state.matchedLocation
启动页即使在/account仍跳转到/attendance硬编码
context.go('/attendance')
先检查
matchedLocation
重载后AccountScreen无限加载
app.dart
在unknown状态下重定向返回
null
改为重定向到/而非
null
checkAuth()
无限卡住
verifySession未设置超时添加
.timeout(Duration(seconds: 10))

7. Tài liệu tham khảo

7. 参考文档

  • .edits/fix1.md
    — fix gốc cho bottom tab stuck loading
  • lib/app.dart
    — GoRouter config
  • lib/screens/splash_screen.dart
    — auth listener + redirect
  • lib/providers/providers.dart
    — checkAuth + routerRefreshProvider
  • .edits/fix1.md
    — 底部标签页加载卡住的原始修复方案
  • lib/app.dart
    — GoRouter配置
  • lib/screens/splash_screen.dart
    — 授权监听器 + 重定向逻辑
  • lib/providers/providers.dart
    — checkAuth + routerRefreshProvider