login_page.dart 18 KB

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