login_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:sino_med_cloud/l10n/app_localizations.dart';
  6. import 'package:sino_med_cloud/core/constants/app_constants.dart';
  7. import 'package:sino_med_cloud/core/utils/toast_utils.dart'; // 添加 ToastUtils 引用
  8. import '../../../core/utils/logger.dart';
  9. import '../../../core/utils/crypto_utils.dart';
  10. import '../../MinePage/data/change_password_provider.dart';
  11. import '../../MinePage/presentation/change_password_page.dart';
  12. import '../domain/login_service.dart';
  13. import '../data/login_provider.dart';
  14. class LoginPage extends ConsumerStatefulWidget {
  15. const LoginPage({super.key});
  16. @override
  17. ConsumerState<LoginPage> createState() => _LoginPageState();
  18. }
  19. class _LoginPageState extends ConsumerState<LoginPage>
  20. with SingleTickerProviderStateMixin {
  21. late TabController _tabController;
  22. final _passwordFormKey = GlobalKey<FormState>();
  23. final _smsFormKey = GlobalKey<FormState>();
  24. bool _listenersSetup = false;
  25. //用于test
  26. final _loginSystem = "YUN_HIS_PC_WEB";
  27. final _loginType = "MOBILE_PASSWORD";
  28. final _loginSmsType = "MOBILE_SMS_CODE";
  29. // 密码登录表单
  30. final _phoneController = TextEditingController();
  31. final _passwordController = TextEditingController();
  32. // 验证码登录表单
  33. final _phoneSmsController = TextEditingController();
  34. final _smsCodeController = TextEditingController();
  35. Timer? _countdownTimer;
  36. // 防止循环更新的标志
  37. bool _isSyncingPhone = false;
  38. @override
  39. void initState() {
  40. super.initState();
  41. _tabController = TabController(length: 2, vsync: this);
  42. }
  43. void _setupListeners() {
  44. // 监听 Tab 切换,同步到 Provider
  45. _tabController.addListener(() {
  46. if (!_tabController.indexIsChanging) {
  47. ref.read(loginTabIndexProvider.notifier).state = _tabController.index;
  48. }
  49. });
  50. // 同步初始 tab 索引
  51. ref.read(loginTabIndexProvider.notifier).state = _tabController.index;
  52. // 监听输入框变化,同步到 Provider 和另一个输入框
  53. _phoneController.addListener(() {
  54. final text = _phoneController.text;
  55. ref.read(passwordLoginPhoneProvider.notifier).state = text;
  56. // 同步到验证码登录的手机号输入框
  57. if (!_isSyncingPhone && _phoneSmsController.text != text) {
  58. _isSyncingPhone = true;
  59. _phoneSmsController.text = text;
  60. ref.read(smsLoginPhoneProvider.notifier).state = text;
  61. _isSyncingPhone = false;
  62. }
  63. });
  64. _passwordController.addListener(() {
  65. ref.read(passwordLoginPasswordProvider.notifier).state = _passwordController.text;
  66. });
  67. _phoneSmsController.addListener(() {
  68. final text = _phoneSmsController.text;
  69. ref.read(smsLoginPhoneProvider.notifier).state = text;
  70. // 同步到密码登录的手机号输入框
  71. if (!_isSyncingPhone && _phoneController.text != text) {
  72. _isSyncingPhone = true;
  73. _phoneController.text = text;
  74. ref.read(passwordLoginPhoneProvider.notifier).state = text;
  75. _isSyncingPhone = false;
  76. }
  77. });
  78. _smsCodeController.addListener(() {
  79. ref.read(smsLoginCodeProvider.notifier).state = _smsCodeController.text;
  80. });
  81. }
  82. @override
  83. void dispose() {
  84. _tabController.dispose();
  85. _phoneController.dispose();
  86. _passwordController.dispose();
  87. _phoneSmsController.dispose();
  88. _smsCodeController.dispose();
  89. _clearCountdownTimer();
  90. super.dispose();
  91. }
  92. /// 清理倒计时定时器
  93. void _clearCountdownTimer() {
  94. _countdownTimer?.cancel();
  95. _countdownTimer = null;
  96. }
  97. // 发送验证码
  98. void _sendSmsCode() async {
  99. final l10n = AppLocalizations.of(context)!;
  100. final phone = ref.read(smsLoginPhoneProvider);
  101. if (phone.isEmpty) {
  102. ToastUtils.show(l10n.phoneNumberRequired);
  103. return;
  104. }
  105. try {
  106. // 清理之前的定时器
  107. _clearCountdownTimer();
  108. // 调用发送验证码服务
  109. final smsCode = await LoginService.sendSmsCode(mobile: phone);
  110. // 保存验证码到 Provider
  111. ref.read(smsCodeFromServerProvider.notifier).state = smsCode;
  112. // 标记已获取过验证码
  113. ref.read(smsHasReceivedProvider.notifier).state = true;
  114. // 开始倒计时
  115. ref.read(smsCountdownProvider.notifier).state = AppConstants.smsCodeCountdown;
  116. // 使用 Timer 进行倒计时
  117. _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
  118. if (!mounted) {
  119. timer.cancel();
  120. return;
  121. }
  122. final currentCountdown = ref.read(smsCountdownProvider);
  123. if (currentCountdown > 1) {
  124. ref.read(smsCountdownProvider.notifier).state = currentCountdown - 1;
  125. } else {
  126. // 倒计时结束(currentCountdown == 1 或 0)
  127. // 先清空验证码,再设置倒计时为0,确保 Consumer 能正确监听到变化
  128. ref.read(smsCodeFromServerProvider.notifier).state = '';
  129. ref.read(smsCountdownProvider.notifier).state = 0;
  130. timer.cancel();
  131. _countdownTimer = null;
  132. AppLogger.d('倒计时结束,验证码已清空,hasReceived: ${ref.read(smsHasReceivedProvider)}');
  133. }
  134. });
  135. } catch (e) {
  136. AppLogger.e('发送验证码错误', e);
  137. if (mounted) {
  138. ToastUtils.showError(e.toString());
  139. }
  140. }
  141. }
  142. // 密码登录
  143. void _handlePasswordLogin() async {
  144. try {
  145. // 当前手机号及密码的格式已经验证过
  146. if (_passwordFormKey.currentState!.validate()) {
  147. // 在异步方法中应使用 ref.read
  148. final phoneNumber = ref.read(passwordLoginPhoneProvider);
  149. final password = ref.read(passwordLoginPasswordProvider);
  150. // 对密码进行 MD5 加密
  151. final encryptedPassword = CryptoUtils.md5(password);
  152. AppLogger.d('开始密码登录请求: $phoneNumber');
  153. // 调用登录服务
  154. await LoginService.passwordLogin(
  155. mobile: phoneNumber,
  156. password: encryptedPassword,
  157. loginSystem: _loginSystem,
  158. loginType: _loginType,
  159. );
  160. AppLogger.d('密码登录成功,准备跳转主页');
  161. // 登录成功,跳转到主页
  162. if (mounted) {
  163. context.go('/mainTab');
  164. }
  165. }
  166. } catch (e, stack) {
  167. AppLogger.e('密码登录错误', e, stack);
  168. if (mounted) {
  169. ToastUtils.showError(e.toString().replaceAll('Exception: ', ''));
  170. }
  171. }
  172. }
  173. // 验证码登录
  174. void _handleSmsLogin() async {
  175. try {
  176. final l10n = AppLocalizations.of(context)!;
  177. if (_smsFormKey.currentState!.validate()) {
  178. // 在异步方法中应使用 ref.read
  179. final smsCode = ref.read(smsLoginCodeProvider);
  180. final serverSmsCode = ref.read(smsCodeFromServerProvider);
  181. // 检查验证码是否已过期(为空或倒计时已结束)
  182. if (serverSmsCode.isEmpty) {
  183. ToastUtils.show(l10n.smsCodeHasExpired);
  184. return;
  185. }
  186. // 验证码输入错误
  187. if (smsCode != serverSmsCode) {
  188. ToastUtils.showError(l10n.smsCodeError);
  189. return;
  190. }
  191. final phoneNumber = ref.read(smsLoginPhoneProvider);
  192. AppLogger.d('开始验证码登录请求: $phoneNumber');
  193. // 调用登录服务
  194. await LoginService.smsLogin(
  195. mobile: phoneNumber,
  196. smsCode: smsCode,
  197. loginSystem: _loginSystem,
  198. loginType: _loginSmsType,
  199. );
  200. AppLogger.d('验证码登录成功,清理状态并跳转主页');
  201. // 登录成功,清理倒计时和定时器
  202. _clearCountdownTimer();
  203. ref.read(smsCountdownProvider.notifier).state = 0;
  204. ref.read(smsHasReceivedProvider.notifier).state = false;
  205. ref.read(smsCodeFromServerProvider.notifier).state = '';
  206. // 跳转到主页
  207. if (mounted) {
  208. context.go('/mainTab');
  209. }
  210. }
  211. } catch (e, stack) {
  212. AppLogger.e('验证码登录错误', e, stack);
  213. if (mounted) {
  214. ToastUtils.showError(e.toString().replaceAll('Exception: ', ''));
  215. }
  216. }
  217. }
  218. @override
  219. Widget build(BuildContext context) {
  220. // 设置监听器(只设置一次)
  221. if (!_listenersSetup) {
  222. WidgetsBinding.instance.addPostFrameCallback((_) {
  223. _setupListeners();
  224. _listenersSetup = true;
  225. });
  226. }
  227. final l10n = AppLocalizations.of(context)!;
  228. return Scaffold(
  229. body: GestureDetector(
  230. onTap: () {
  231. // 点击空白区域时收起键盘并移除焦点
  232. FocusScope.of(context).unfocus();
  233. },
  234. behavior: HitTestBehavior.opaque,
  235. child: SafeArea(
  236. child: SingleChildScrollView(
  237. padding: const EdgeInsets.all(24),
  238. child: Column(
  239. crossAxisAlignment: CrossAxisAlignment.stretch,
  240. children: [
  241. const SizedBox(height: 40),
  242. // Logo 或标题
  243. Text(
  244. l10n.appName,
  245. style: const TextStyle(
  246. fontSize: 32,
  247. fontWeight: FontWeight.bold,
  248. color: Color(0xFF1F2937),
  249. ),
  250. textAlign: TextAlign.center,
  251. ),
  252. const SizedBox(height: 8),
  253. Text(
  254. l10n.appSubtitle,
  255. style: const TextStyle(
  256. fontSize: 16,
  257. color: Color(0xFF6B7280),
  258. ),
  259. textAlign: TextAlign.center,
  260. ),
  261. const SizedBox(height: 48),
  262. // Tab 切换
  263. Container(
  264. decoration: BoxDecoration(
  265. color: Colors.white,
  266. borderRadius: BorderRadius.circular(12),
  267. ),
  268. child: TabBar(
  269. controller: _tabController,
  270. indicator: BoxDecoration(
  271. borderRadius: BorderRadius.circular(12),
  272. color: const Color(0xFF00BFA5),
  273. ),
  274. indicatorSize: TabBarIndicatorSize.tab,
  275. dividerColor: Colors.transparent,
  276. labelColor: Colors.white,
  277. unselectedLabelColor: const Color(0xFF6B7280),
  278. labelStyle: const TextStyle(
  279. fontSize: 16,
  280. fontWeight: FontWeight.w600,
  281. ),
  282. unselectedLabelStyle: const TextStyle(
  283. fontSize: 16,
  284. fontWeight: FontWeight.w500,
  285. ),
  286. tabs: [
  287. Tab(text: l10n.passwordLogin),
  288. Tab(text: l10n.smsLogin),
  289. ],
  290. ),
  291. ),
  292. const SizedBox(height: 24),
  293. // Tab 内容
  294. SizedBox(
  295. height: 400,
  296. child: TabBarView(
  297. controller: _tabController,
  298. children: [
  299. _buildPasswordLoginForm(),
  300. _buildSmsLoginForm(),
  301. ],
  302. ),
  303. ),
  304. ],
  305. ),
  306. ),
  307. ),
  308. ),
  309. );
  310. }
  311. // 密码登录表单
  312. Widget _buildPasswordLoginForm() {
  313. final l10n = AppLocalizations.of(context)!;
  314. return Form(
  315. key: _passwordFormKey,
  316. child: Column(
  317. crossAxisAlignment: CrossAxisAlignment.stretch,
  318. children: [
  319. const SizedBox(height: 4),
  320. // 手机号输入
  321. TextFormField(
  322. controller: _phoneController,
  323. keyboardType: TextInputType.phone,
  324. decoration: InputDecoration(
  325. labelText: l10n.phoneNumber,
  326. hintText: l10n.phoneNumberRequired,
  327. prefixIcon: const Icon(Icons.phone_outlined),
  328. ),
  329. validator: (value) {
  330. if (value == null || value.isEmpty) {
  331. return l10n.phoneNumberRequired;
  332. }
  333. if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
  334. return l10n.phoneNumberInvalid;
  335. }
  336. return null;
  337. },
  338. ),
  339. const SizedBox(height: 16),
  340. // 密码输入
  341. Consumer(
  342. builder: (context, ref, child) {
  343. final obscurePassword = ref.watch(passwordObscureProvider);
  344. return TextFormField(
  345. controller: _passwordController,
  346. obscureText: obscurePassword,
  347. decoration: InputDecoration(
  348. labelText: l10n.password,
  349. hintText: l10n.passwordRequired,
  350. prefixIcon: const Icon(Icons.lock_outline),
  351. suffixIcon: IconButton(
  352. icon: Icon(
  353. obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  354. ),
  355. onPressed: () {
  356. ref.read(passwordObscureProvider.notifier).state = !obscurePassword;
  357. },
  358. ),
  359. ),
  360. validator: (value) {
  361. if (value == null || value.isEmpty) {
  362. return l10n.passwordRequired;
  363. }
  364. if (value.length < AppConstants.passwordMinLength) {
  365. return l10n.passwordMinLength;
  366. }
  367. return null;
  368. },
  369. );
  370. },
  371. ),
  372. const SizedBox(height: 8),
  373. // 忘记密码
  374. Align(
  375. alignment: Alignment.centerRight,
  376. child: TextButton(
  377. onPressed: () {
  378. // 跳转到忘记密码页面
  379. if (mounted) {
  380. // 打开对话框前重置所有状态,确保每次打开都是干净的
  381. ref.read(changePwdPhoneProvider.notifier).state = '';
  382. ref.read(changePwdServerSmsCodeProvider.notifier).state = '';
  383. ref.read(changePwdPasswordProvider.notifier).state = '';
  384. ref.read(changePwdPasswordVisibleProvider.notifier).state = false;
  385. ref.read(changePwdConfirmPasswordProvider.notifier).state = '';
  386. ref.read(changePwdConfirmPasswordVisibleProvider.notifier).state = false;
  387. ref.read(changePwdSmsHasSentProvider.notifier).state = false;
  388. ref.read(changePwdSmsCountdownProvider.notifier).state = 0;
  389. ref.read(changePwdShowPasswordErrorProvider.notifier).state = false;
  390. showDialog(
  391. context: context,
  392. builder: (context) => const ChangePasswordPage(),
  393. );
  394. }
  395. },
  396. child: Text(l10n.forgotPassword),
  397. ),
  398. ),
  399. const SizedBox(height: 24),
  400. // 登录按钮
  401. ElevatedButton(
  402. onPressed: _handlePasswordLogin,
  403. style: ElevatedButton.styleFrom(
  404. padding: const EdgeInsets.symmetric(vertical: 16),
  405. ),
  406. child: Text(l10n.login),
  407. ),
  408. ],
  409. ),
  410. );
  411. }
  412. // 验证码登录表单
  413. Widget _buildSmsLoginForm() {
  414. final l10n = AppLocalizations.of(context)!;
  415. return Form(
  416. key: _smsFormKey,
  417. child: Column(
  418. crossAxisAlignment: CrossAxisAlignment.stretch,
  419. children: [
  420. const SizedBox(height: 4),
  421. // 手机号输入
  422. TextFormField(
  423. controller: _phoneSmsController,
  424. keyboardType: TextInputType.phone,
  425. decoration: InputDecoration(
  426. labelText: l10n.phoneNumber,
  427. hintText: l10n.phoneNumberRequired,
  428. prefixIcon: const Icon(Icons.phone_outlined),
  429. ),
  430. validator: (value) {
  431. if (value == null || value.isEmpty) {
  432. return l10n.phoneNumberRequired;
  433. }
  434. if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
  435. return l10n.phoneNumberInvalid;
  436. }
  437. return null;
  438. },
  439. ),
  440. const SizedBox(height: 16),
  441. // 验证码输入
  442. Row(
  443. children: [
  444. Expanded(
  445. child: TextFormField(
  446. controller: _smsCodeController,
  447. keyboardType: TextInputType.number,
  448. decoration: InputDecoration(
  449. labelText: l10n.smsCode,
  450. hintText: l10n.smsCodeRequired,
  451. prefixIcon: const Icon(Icons.sms_outlined),
  452. ),
  453. validator: (value) {
  454. // 验证码为空
  455. if (value == null || value.isEmpty) {
  456. return l10n.smsCodeRequired;
  457. }
  458. // 验证码格式不正确(长度或格式)
  459. if (value.length != AppConstants.smsCodeLength || !RegExp(r'^\d+$').hasMatch(value)) {
  460. return l10n.smsCodeInvalid;
  461. }
  462. return null;
  463. },
  464. ),
  465. ),
  466. const SizedBox(width: 12),
  467. Consumer(
  468. builder: (context, ref, child) {
  469. final countdown = ref.watch(smsCountdownProvider);
  470. final hasReceived = ref.watch(smsHasReceivedProvider);
  471. final serverSmsCode = ref.watch(smsCodeFromServerProvider);
  472. // 验证码已过期:曾经获取过验证码,但现在验证码为空且倒计时为0
  473. final isExpired = hasReceived && serverSmsCode.isEmpty && countdown == 0;
  474. // 调试日志
  475. if (countdown == 0 && hasReceived) {
  476. AppLogger.d('按钮状态 - countdown: $countdown, hasReceived: $hasReceived, serverSmsCode: $serverSmsCode, isExpired: $isExpired');
  477. }
  478. return SizedBox(
  479. width: 100,
  480. child: ElevatedButton(
  481. onPressed: countdown > 0 ? null : _sendSmsCode,
  482. style: ElevatedButton.styleFrom(
  483. padding: const EdgeInsets.symmetric(vertical: 16),
  484. backgroundColor: countdown > 0
  485. ? const Color(0xFFE5E7EB)
  486. : const Color(0xFF00BFA5),
  487. ),
  488. child: Text(
  489. countdown > 0
  490. ? l10n.smsCodeCountdown(countdown)
  491. : (isExpired ? l10n.resendSmsCode : l10n.getSmsCode),
  492. style: TextStyle(
  493. color: countdown > 0
  494. ? const Color(0xFF6B7280)
  495. : Colors.white,
  496. fontSize: isExpired ? 12 : 14,
  497. ),
  498. ),
  499. ),
  500. );
  501. },
  502. ),
  503. ],
  504. ),
  505. const SizedBox(height: 24),
  506. // 登录按钮
  507. ElevatedButton(
  508. onPressed: _handleSmsLogin,
  509. style: ElevatedButton.styleFrom(
  510. padding: const EdgeInsets.symmetric(vertical: 16),
  511. ),
  512. child: Text(l10n.login),
  513. ),
  514. ],
  515. ),
  516. );
  517. }
  518. }