login_page.dart 18 KB

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