google_auth_bind_screen.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:qr_flutter/qr_flutter.dart';
  6. import '../../../core/l10n/app_localizations.dart';
  7. import '../../../core/theme/app_colors.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../providers/google_auth_provider.dart';
  10. /// 谷歌验证器绑定页面 — 单页三步表单
  11. ///
  12. /// 1. 显示秘钥 + QR 码(页面加载时自动获取)
  13. /// 2. 输入谷歌验证码(6位)
  14. /// 3. 发送并输入邮箱验证码(6位)
  15. /// → 提交按钮同时提交两个验证码 + 秘钥
  16. class GoogleAuthBindScreen extends ConsumerStatefulWidget {
  17. const GoogleAuthBindScreen({super.key});
  18. @override
  19. ConsumerState<GoogleAuthBindScreen> createState() =>
  20. _GoogleAuthBindScreenState();
  21. }
  22. class _GoogleAuthBindScreenState extends ConsumerState<GoogleAuthBindScreen> {
  23. final _googleCodeController = TextEditingController();
  24. final _emailCodeController = TextEditingController();
  25. @override
  26. void initState() {
  27. super.initState();
  28. Future.microtask(() {
  29. ref.invalidate(googleAuthProvider);
  30. ref.read(googleAuthProvider.notifier).fetchSecret();
  31. });
  32. }
  33. @override
  34. void dispose() {
  35. _googleCodeController.dispose();
  36. _emailCodeController.dispose();
  37. super.dispose();
  38. }
  39. bool get _canSubmit =>
  40. _googleCodeController.text.length == 6 &&
  41. _emailCodeController.text.length == 6;
  42. Future<void> _handleSubmit() async {
  43. final success = await ref.read(googleAuthProvider.notifier).bindGoogleAuth(
  44. googleCode: _googleCodeController.text,
  45. emailCode: _emailCodeController.text,
  46. );
  47. if (success && mounted) {
  48. showTopToast(context, message: AppLocalizations.of(context)!.bindSuccess, backgroundColor: AppColors.success);
  49. context.pop();
  50. }
  51. }
  52. @override
  53. Widget build(BuildContext context) {
  54. final cs = Theme.of(context).colorScheme;
  55. final isDark = Theme.of(context).brightness == Brightness.dark;
  56. final state = ref.watch(googleAuthProvider);
  57. // 监听错误
  58. ref.listen<GoogleAuthState>(googleAuthProvider, (prev, next) {
  59. if (next.errorMessage != null &&
  60. next.errorMessage != prev?.errorMessage) {
  61. final l10n = AppLocalizations.of(context)!;
  62. showTopToast(context, message: resolveProviderError(next.errorMessage!, l10n) ?? next.errorMessage!);
  63. ref.read(googleAuthProvider.notifier).clearError();
  64. }
  65. });
  66. return Scaffold(
  67. appBar: AppBar(
  68. elevation: 0,
  69. leading: IconButton(
  70. icon: const Icon(Icons.chevron_left, size: 28),
  71. onPressed: () => context.pop(),
  72. ),
  73. title: Text(
  74. AppLocalizations.of(context)!.authenticator,
  75. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  76. ),
  77. centerTitle: true,
  78. ),
  79. body: Column(
  80. children: [
  81. Expanded(
  82. child: SingleChildScrollView(
  83. padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
  84. child: Column(
  85. crossAxisAlignment: CrossAxisAlignment.start,
  86. children: [
  87. // ═══ 第一步:秘钥 + QR 码 ═══
  88. _SectionTitle(number: '1.'),
  89. const SizedBox(height: 8),
  90. Text(
  91. AppLocalizations.of(context)!.googleBindStep1Hint,
  92. style: TextStyle(
  93. color: cs.onSurface.withAlpha(153),
  94. fontSize: 13,
  95. height: 1.5,
  96. ),
  97. ),
  98. const SizedBox(height: 16),
  99. // 秘钥 + 复制
  100. if (state.isSecretLoading)
  101. const Center(
  102. child: Padding(
  103. padding: EdgeInsets.all(20),
  104. child: CircularProgressIndicator(strokeWidth: 2),
  105. ),
  106. )
  107. else ...[
  108. Row(
  109. children: [
  110. Expanded(
  111. child: Text(
  112. state.secret,
  113. style: TextStyle(
  114. color: cs.onSurface,
  115. fontSize: 16,
  116. fontWeight: FontWeight.w600,
  117. letterSpacing: 1,
  118. ),
  119. ),
  120. ),
  121. GestureDetector(
  122. onTap: () {
  123. if (state.secret.isEmpty) return;
  124. Clipboard.setData(
  125. ClipboardData(text: state.secret));
  126. showTopToast(
  127. context,
  128. message: AppLocalizations.of(context)!.keyCopied,
  129. backgroundColor: AppColors.success,
  130. duration: const Duration(seconds: 1),
  131. );
  132. },
  133. child: Icon(
  134. Icons.copy,
  135. size: 18,
  136. color: cs.onSurface.withAlpha(153),
  137. ),
  138. ),
  139. ],
  140. ),
  141. const SizedBox(height: 16),
  142. // QR 码
  143. if (state.otpauthUrl.isNotEmpty)
  144. Center(
  145. child: Container(
  146. padding: const EdgeInsets.all(8),
  147. decoration: BoxDecoration(
  148. color: Colors.white,
  149. borderRadius: BorderRadius.circular(8),
  150. ),
  151. child: QrImageView(
  152. data: state.otpauthUrl,
  153. version: QrVersions.auto,
  154. size: 160,
  155. backgroundColor: Colors.white,
  156. ),
  157. ),
  158. ),
  159. ],
  160. const SizedBox(height: 32),
  161. // ═══ 第二步:谷歌验证码 ═══
  162. _SectionTitle(number: '2.'),
  163. const SizedBox(height: 8),
  164. Text(
  165. AppLocalizations.of(context)!.googleBindStep2Hint,
  166. style: TextStyle(
  167. color: cs.onSurface.withAlpha(153),
  168. fontSize: 13,
  169. height: 1.5,
  170. ),
  171. ),
  172. const SizedBox(height: 12),
  173. Text(
  174. AppLocalizations.of(context)!.googleCode,
  175. style: TextStyle(
  176. color: cs.onSurface,
  177. fontSize: 14,
  178. fontWeight: FontWeight.w500,
  179. ),
  180. ),
  181. const SizedBox(height: 8),
  182. _CodeTextField(
  183. controller: _googleCodeController,
  184. hintText: AppLocalizations.of(context)!.googleCodeHint,
  185. onChanged: (_) => setState(() {}),
  186. ),
  187. const SizedBox(height: 32),
  188. // ═══ 第三步:邮箱验证码 ═══
  189. _SectionTitle(number: '3.'),
  190. const SizedBox(height: 8),
  191. Text(
  192. AppLocalizations.of(context)!.emailCodeInstruction,
  193. style: TextStyle(
  194. color: cs.onSurface.withAlpha(153),
  195. fontSize: 13,
  196. height: 1.5,
  197. ),
  198. ),
  199. const SizedBox(height: 12),
  200. // 脱敏邮箱
  201. Text(
  202. AppLocalizations.of(context)!.emailCodeSentTo(state.maskedEmail),
  203. style: TextStyle(
  204. color: cs.onSurface,
  205. fontSize: 14,
  206. fontWeight: FontWeight.w500,
  207. ),
  208. ),
  209. const SizedBox(height: 8),
  210. // 验证码输入 + 发送按钮
  211. Row(
  212. children: [
  213. Expanded(
  214. child: _CodeTextField(
  215. controller: _emailCodeController,
  216. hintText: AppLocalizations.of(context)!.enterCode,
  217. onChanged: (_) => setState(() {}),
  218. ),
  219. ),
  220. const SizedBox(width: 12),
  221. _SendCodeButton(
  222. cooldown: state.codeCooldown,
  223. isLoading: state.isSendingCode,
  224. onTap: () {
  225. ref
  226. .read(googleAuthProvider.notifier)
  227. .sendEmailCode();
  228. },
  229. ),
  230. ],
  231. ),
  232. const SizedBox(height: 12),
  233. Text(
  234. AppLocalizations.of(context)!.checkSpamMessage,
  235. style: TextStyle(
  236. color: cs.onSurface.withAlpha(100),
  237. fontSize: 12,
  238. ),
  239. ),
  240. ],
  241. ),
  242. ),
  243. ),
  244. // ── 提交按钮 ──────────────────────────────────────
  245. Padding(
  246. padding: const EdgeInsets.fromLTRB(20, 0, 20, 32),
  247. child: SizedBox(
  248. width: double.infinity,
  249. height: 50,
  250. child: ElevatedButton(
  251. onPressed:
  252. (_canSubmit && !state.isSubmitting) ? _handleSubmit : null,
  253. style: ElevatedButton.styleFrom(
  254. backgroundColor: AppColors.brand,
  255. disabledBackgroundColor: cs.outline.withAlpha(30),
  256. foregroundColor: Colors.black,
  257. shape: RoundedRectangleBorder(
  258. borderRadius: BorderRadius.circular(10),
  259. ),
  260. ),
  261. child: state.isSubmitting
  262. ? SizedBox(
  263. width: 20,
  264. height: 20,
  265. child: CircularProgressIndicator(
  266. strokeWidth: 2,
  267. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  268. ),
  269. )
  270. : Text(
  271. AppLocalizations.of(context)!.submit,
  272. style: TextStyle(
  273. color: _canSubmit
  274. ? cs.surface
  275. : cs.onSurface.withAlpha(153),
  276. fontSize: 15,
  277. fontWeight: FontWeight.w600,
  278. ),
  279. ),
  280. ),
  281. ),
  282. ),
  283. ],
  284. ),
  285. );
  286. }
  287. }
  288. // ── 步骤编号标题 ─────────────────────────────────────────────
  289. class _SectionTitle extends StatelessWidget {
  290. const _SectionTitle({required this.number});
  291. final String number;
  292. @override
  293. Widget build(BuildContext context) {
  294. final cs = Theme.of(context).colorScheme;
  295. return Text(
  296. number,
  297. style: TextStyle(
  298. color: cs.onSurface,
  299. fontSize: 20,
  300. fontWeight: FontWeight.w700,
  301. ),
  302. );
  303. }
  304. }
  305. // ── 验证码输入框 ─────────────────────────────────────────────
  306. class _CodeTextField extends StatelessWidget {
  307. const _CodeTextField({
  308. required this.controller,
  309. required this.hintText,
  310. required this.onChanged,
  311. });
  312. final TextEditingController controller;
  313. final String hintText;
  314. final ValueChanged<String> onChanged;
  315. @override
  316. Widget build(BuildContext context) {
  317. final cs = Theme.of(context).colorScheme;
  318. return TextField(
  319. controller: controller,
  320. keyboardType: TextInputType.number,
  321. maxLength: 6,
  322. inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  323. onChanged: onChanged,
  324. style: TextStyle(
  325. color: cs.onSurface,
  326. fontSize: 15,
  327. ),
  328. decoration: InputDecoration(
  329. counterText: '',
  330. hintText: hintText,
  331. hintStyle: TextStyle(
  332. color: cs.onSurface.withAlpha(80),
  333. fontSize: 14,
  334. ),
  335. contentPadding:
  336. const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  337. ),
  338. );
  339. }
  340. }
  341. // ── 发送验证码按钮 ──────────────────────────────────────────
  342. class _SendCodeButton extends StatelessWidget {
  343. const _SendCodeButton({
  344. required this.cooldown,
  345. required this.isLoading,
  346. required this.onTap,
  347. });
  348. final int cooldown;
  349. final bool isLoading;
  350. final VoidCallback onTap;
  351. @override
  352. Widget build(BuildContext context) {
  353. final cs = Theme.of(context).colorScheme;
  354. final disabled = cooldown > 0 || isLoading;
  355. return GestureDetector(
  356. onTap: disabled ? null : onTap,
  357. child: Container(
  358. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  359. decoration: BoxDecoration(
  360. borderRadius: BorderRadius.circular(10),
  361. border: Border.all(
  362. color: disabled ? cs.outline.withAlpha(60) : cs.onSurface,
  363. ),
  364. ),
  365. child: isLoading
  366. ? SizedBox(
  367. width: 16,
  368. height: 16,
  369. child: CircularProgressIndicator(
  370. strokeWidth: 2,
  371. color: cs.onSurface.withAlpha(153),
  372. ),
  373. )
  374. : Text(
  375. cooldown > 0 ? '${cooldown}s' : AppLocalizations.of(context)!.sendCode,
  376. style: TextStyle(
  377. color: disabled
  378. ? cs.onSurface.withAlpha(100)
  379. : cs.onSurface,
  380. fontSize: 14,
  381. fontWeight: FontWeight.w500,
  382. ),
  383. ),
  384. ),
  385. );
  386. }
  387. }