login_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:path/path.dart' as path;
  5. import 'package:sino_med_cloud/l10n/app_localizations.dart';
  6. import 'package:sino_med_cloud/core/constants/app_constants.dart';
  7. import '../../../core/constants/api_constants.dart';
  8. import '../../../core/network/dio_client.dart';
  9. import '../../../core/storage/local_storage.dart';
  10. import '../../../core/utils/logger.dart';
  11. import '../../../core/utils/crypto_utils.dart';
  12. import 'login_provider.dart';
  13. import 'package:dio/dio.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. // 密码登录表单
  29. final _phoneController = TextEditingController();
  30. final _passwordController = TextEditingController();
  31. // 验证码登录表单
  32. final _phoneSmsController = TextEditingController();
  33. final _smsCodeController = TextEditingController();
  34. @override
  35. void initState() {
  36. super.initState();
  37. _tabController = TabController(length: 2, vsync: this);
  38. }
  39. void _setupListeners() {
  40. // 监听 Tab 切换,同步到 Provider
  41. _tabController.addListener(() {
  42. if (!_tabController.indexIsChanging) {
  43. ref.read(loginTabIndexProvider.notifier).state = _tabController.index;
  44. }
  45. });
  46. // 同步初始 tab 索引
  47. ref.read(loginTabIndexProvider.notifier).state = _tabController.index;
  48. // 监听输入框变化,同步到 Provider
  49. _phoneController.addListener(() {
  50. ref.read(passwordLoginPhoneProvider.notifier).state = _phoneController.text;
  51. });
  52. _passwordController.addListener(() {
  53. ref.read(passwordLoginPasswordProvider.notifier).state = _passwordController.text;
  54. });
  55. _phoneSmsController.addListener(() {
  56. ref.read(smsLoginPhoneProvider.notifier).state = _phoneSmsController.text;
  57. });
  58. _smsCodeController.addListener(() {
  59. ref.read(smsLoginCodeProvider.notifier).state = _smsCodeController.text;
  60. });
  61. }
  62. @override
  63. void dispose() {
  64. _tabController.dispose();
  65. _phoneController.dispose();
  66. _passwordController.dispose();
  67. _phoneSmsController.dispose();
  68. _smsCodeController.dispose();
  69. super.dispose();
  70. }
  71. // 发送验证码
  72. void _sendSmsCode() {
  73. final l10n = AppLocalizations.of(context)!;
  74. final phone = ref.read(smsLoginPhoneProvider);
  75. if (phone.isEmpty) {
  76. ScaffoldMessenger.of(context).showSnackBar(
  77. SnackBar(content: Text(l10n.phoneNumberRequiredForSms)),
  78. );
  79. return;
  80. }
  81. // TODO: 调用发送验证码接口
  82. // 开始倒计时
  83. ref.read(smsCountdownProvider.notifier).state = AppConstants.smsCodeCountdown;
  84. // 倒计时
  85. Future.doWhile(() async {
  86. await Future.delayed(const Duration(seconds: 1));
  87. if (mounted) {
  88. final currentCountdown = ref.read(smsCountdownProvider);
  89. if (currentCountdown > 0) {
  90. ref.read(smsCountdownProvider.notifier).state = currentCountdown - 1;
  91. }
  92. }
  93. return ref.read(smsCountdownProvider) > 0;
  94. });
  95. }
  96. // 密码登录
  97. void _handlePasswordLogin() async {
  98. try {
  99. // 当前手机号及密码的格式已经验证过
  100. if (_passwordFormKey.currentState!.validate()) {
  101. final phoneNumber = ref.watch(passwordLoginPhoneProvider);
  102. final password = ref.watch(passwordLoginPasswordProvider);
  103. // 对密码进行 MD5 加密
  104. final encryptedPassword = CryptoUtils.md5(password);
  105. final parame = {
  106. "mobile": phoneNumber,
  107. "login_system": _loginSystem,
  108. "password": encryptedPassword,
  109. "login_type": _loginType
  110. };
  111. AppLogger.d('登录请求参数parame: $parame');
  112. Response response = await DioClient.post<Map<String, dynamic>>(
  113. path.join(ApiConstants.baseUrl, ApiConstants.login),
  114. data: parame,
  115. );
  116. if (response.statusCode == 200) {
  117. final data = response.data;
  118. if (data['code'] == 20000) {
  119. AppLogger.d('登录成功: $data');
  120. final jsonData = data['data'];
  121. final accessToken = jsonData['access_token'];
  122. final userInfo = jsonData['user_info'];
  123. LocalStorage.saveToken(accessToken);
  124. LocalStorage.saveUserInfo(userInfo);
  125. if (mounted) {
  126. context.replace('/mainTab');
  127. }
  128. } else {
  129. AppLogger.d('登录失败: $data');
  130. }
  131. } else {
  132. AppLogger.d('密码登录请求错误: ${response.statusCode}, ${response.statusMessage}');
  133. }
  134. }
  135. } catch (e) {
  136. AppLogger.e('密码登录错误:handlePasswordLogin', e);
  137. rethrow;
  138. }
  139. }
  140. // 验证码登录
  141. void _handleSmsLogin() {
  142. final l10n = AppLocalizations.of(context)!;
  143. if (_smsFormKey.currentState!.validate()) {
  144. // TODO: 调用登录接口
  145. // ScaffoldMessenger.of(context).showSnackBar(
  146. // SnackBar(content: Text(l10n.loginNotImplemented)),
  147. // );
  148. context.push('/mainTab');
  149. }
  150. }
  151. @override
  152. Widget build(BuildContext context) {
  153. // 设置监听器(只设置一次)
  154. if (!_listenersSetup) {
  155. WidgetsBinding.instance.addPostFrameCallback((_) {
  156. _setupListeners();
  157. _listenersSetup = true;
  158. });
  159. }
  160. final l10n = AppLocalizations.of(context)!;
  161. return Scaffold(
  162. body: GestureDetector(
  163. onTap: () {
  164. // 点击空白区域时收起键盘并移除焦点
  165. FocusScope.of(context).unfocus();
  166. },
  167. behavior: HitTestBehavior.opaque,
  168. child: SafeArea(
  169. child: SingleChildScrollView(
  170. padding: const EdgeInsets.all(24),
  171. child: Column(
  172. crossAxisAlignment: CrossAxisAlignment.stretch,
  173. children: [
  174. const SizedBox(height: 40),
  175. // Logo 或标题
  176. Text(
  177. l10n.appName,
  178. style: const TextStyle(
  179. fontSize: 32,
  180. fontWeight: FontWeight.bold,
  181. color: Color(0xFF1F2937),
  182. ),
  183. textAlign: TextAlign.center,
  184. ),
  185. const SizedBox(height: 8),
  186. Text(
  187. l10n.appSubtitle,
  188. style: const TextStyle(
  189. fontSize: 16,
  190. color: Color(0xFF6B7280),
  191. ),
  192. textAlign: TextAlign.center,
  193. ),
  194. const SizedBox(height: 48),
  195. // Tab 切换
  196. Container(
  197. decoration: BoxDecoration(
  198. color: Colors.white,
  199. borderRadius: BorderRadius.circular(12),
  200. ),
  201. child: TabBar(
  202. controller: _tabController,
  203. indicator: BoxDecoration(
  204. borderRadius: BorderRadius.circular(12),
  205. color: const Color(0xFF00BFA5),
  206. ),
  207. indicatorSize: TabBarIndicatorSize.tab,
  208. dividerColor: Colors.transparent,
  209. labelColor: Colors.white,
  210. unselectedLabelColor: const Color(0xFF6B7280),
  211. labelStyle: const TextStyle(
  212. fontSize: 16,
  213. fontWeight: FontWeight.w600,
  214. ),
  215. unselectedLabelStyle: const TextStyle(
  216. fontSize: 16,
  217. fontWeight: FontWeight.w500,
  218. ),
  219. tabs: [
  220. Tab(text: l10n.passwordLogin),
  221. Tab(text: l10n.smsLogin),
  222. ],
  223. ),
  224. ),
  225. const SizedBox(height: 24),
  226. // Tab 内容
  227. SizedBox(
  228. height: 400,
  229. child: TabBarView(
  230. controller: _tabController,
  231. children: [
  232. _buildPasswordLoginForm(),
  233. _buildSmsLoginForm(),
  234. ],
  235. ),
  236. ),
  237. ],
  238. ),
  239. ),
  240. ),
  241. ),
  242. );
  243. }
  244. // 密码登录表单
  245. Widget _buildPasswordLoginForm() {
  246. final l10n = AppLocalizations.of(context)!;
  247. return Form(
  248. key: _passwordFormKey,
  249. child: Column(
  250. crossAxisAlignment: CrossAxisAlignment.stretch,
  251. children: [
  252. const SizedBox(height: 4),
  253. // 手机号输入
  254. TextFormField(
  255. controller: _phoneController,
  256. keyboardType: TextInputType.phone,
  257. decoration: InputDecoration(
  258. labelText: l10n.phoneNumber,
  259. hintText: l10n.phoneNumberHint,
  260. prefixIcon: const Icon(Icons.phone_outlined),
  261. ),
  262. validator: (value) {
  263. if (value == null || value.isEmpty) {
  264. return l10n.phoneNumberRequired;
  265. }
  266. if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
  267. return l10n.phoneNumberInvalid;
  268. }
  269. return null;
  270. },
  271. ),
  272. const SizedBox(height: 16),
  273. // 密码输入
  274. Consumer(
  275. builder: (context, ref, child) {
  276. final obscurePassword = ref.watch(passwordObscureProvider);
  277. return TextFormField(
  278. controller: _passwordController,
  279. obscureText: obscurePassword,
  280. decoration: InputDecoration(
  281. labelText: l10n.password,
  282. hintText: l10n.passwordHint,
  283. prefixIcon: const Icon(Icons.lock_outline),
  284. suffixIcon: IconButton(
  285. icon: Icon(
  286. obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined,
  287. ),
  288. onPressed: () {
  289. ref.read(passwordObscureProvider.notifier).state = !obscurePassword;
  290. },
  291. ),
  292. ),
  293. validator: (value) {
  294. if (value == null || value.isEmpty) {
  295. return l10n.passwordRequired;
  296. }
  297. if (value.length < AppConstants.passwordMinLength) {
  298. return l10n.passwordMinLength;
  299. }
  300. return null;
  301. },
  302. );
  303. },
  304. ),
  305. const SizedBox(height: 8),
  306. // 忘记密码
  307. Align(
  308. alignment: Alignment.centerRight,
  309. child: TextButton(
  310. onPressed: () {
  311. // TODO: 跳转到忘记密码页面
  312. ScaffoldMessenger.of(context).showSnackBar(
  313. SnackBar(content: Text(l10n.forgotPasswordNotImplemented)),
  314. );
  315. },
  316. child: Text(l10n.forgotPassword),
  317. ),
  318. ),
  319. const SizedBox(height: 24),
  320. // 登录按钮
  321. ElevatedButton(
  322. onPressed: _handlePasswordLogin,
  323. style: ElevatedButton.styleFrom(
  324. padding: const EdgeInsets.symmetric(vertical: 16),
  325. ),
  326. child: Text(l10n.login),
  327. ),
  328. ],
  329. ),
  330. );
  331. }
  332. // 验证码登录表单
  333. Widget _buildSmsLoginForm() {
  334. final l10n = AppLocalizations.of(context)!;
  335. return Form(
  336. key: _smsFormKey,
  337. child: Column(
  338. crossAxisAlignment: CrossAxisAlignment.stretch,
  339. children: [
  340. const SizedBox(height: 4),
  341. // 手机号输入
  342. TextFormField(
  343. controller: _phoneSmsController,
  344. keyboardType: TextInputType.phone,
  345. decoration: InputDecoration(
  346. labelText: l10n.phoneNumber,
  347. hintText: l10n.phoneNumberHint,
  348. prefixIcon: const Icon(Icons.phone_outlined),
  349. ),
  350. validator: (value) {
  351. if (value == null || value.isEmpty) {
  352. return l10n.phoneNumberRequired;
  353. }
  354. if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
  355. return l10n.phoneNumberInvalid;
  356. }
  357. return null;
  358. },
  359. ),
  360. const SizedBox(height: 16),
  361. // 验证码输入
  362. Row(
  363. children: [
  364. Expanded(
  365. child: TextFormField(
  366. controller: _smsCodeController,
  367. keyboardType: TextInputType.number,
  368. decoration: InputDecoration(
  369. labelText: l10n.smsCode,
  370. hintText: l10n.smsCodeHint,
  371. prefixIcon: const Icon(Icons.sms_outlined),
  372. ),
  373. validator: (value) {
  374. if (value == null || value.isEmpty) {
  375. return l10n.smsCodeRequired;
  376. }
  377. if (value.length != AppConstants.smsCodeLength) {
  378. return l10n.smsCodeLength;
  379. }
  380. return null;
  381. },
  382. ),
  383. ),
  384. const SizedBox(width: 12),
  385. Consumer(
  386. builder: (context, ref, child) {
  387. final countdown = ref.watch(smsCountdownProvider);
  388. return SizedBox(
  389. width: 100,
  390. child: ElevatedButton(
  391. onPressed: countdown > 0 ? null : _sendSmsCode,
  392. style: ElevatedButton.styleFrom(
  393. padding: const EdgeInsets.symmetric(vertical: 16),
  394. backgroundColor: countdown > 0
  395. ? const Color(0xFFE5E7EB)
  396. : const Color(0xFF00BFA5),
  397. ),
  398. child: Text(
  399. countdown > 0
  400. ? l10n.smsCodeCountdown(countdown)
  401. : l10n.getSmsCode,
  402. style: TextStyle(
  403. color: countdown > 0
  404. ? const Color(0xFF6B7280)
  405. : Colors.white,
  406. ),
  407. ),
  408. ),
  409. );
  410. },
  411. ),
  412. ],
  413. ),
  414. const SizedBox(height: 24),
  415. // 登录按钮
  416. ElevatedButton(
  417. onPressed: _handleSmsLogin,
  418. style: ElevatedButton.styleFrom(
  419. padding: const EdgeInsets.symmetric(vertical: 16),
  420. ),
  421. child: Text(l10n.login),
  422. ),
  423. ],
  424. ),
  425. );
  426. }
  427. }