login_page.dart 14 KB

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