login_page.dart 18 KB

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