ソースを参照

feat(mobile-app): #79 补充登录页(记住用户名) + widget测试

回应 PM 上轮反馈(缺少登录页 + 无测试)。master 已有 login_page.dart,本次将其纳入分支 diff 并增强,同时补充测试:

- login_page.dart 增强:新增"记住用户名"功能(登录成功后用 CacheService 持久化用户名,下次进入自动回填)+ 路由跳转改用 go_router(与项目 AppRoutes 一致)
- 新增 login_page_test.dart widget 测试:覆盖页面渲染、用户名/密码表单校验、密码可见切换、记住用户名复选框切换(5 用例)

至此 feature/issue-79 分支 diff 含:main_shell(三Tab导航) + profile(个人中心) + app_routes(路由) + login_page(登录页) + widget测试,覆盖 #79 全部需求(统一入口/三Tab/登录/Token/个人中心)。
bot_dev3 2 日 前
コミット
8176932ee0
共有2 個のファイルを変更した133 個の追加3 個の削除を含む
  1. 54
    3
      mobile-app/lib/features/auth/pages/login_page.dart
  2. 79
    0
      mobile-app/test/features/auth/login_page_test.dart

+ 54
- 3
mobile-app/lib/features/auth/pages/login_page.dart ファイルの表示

@@ -1,8 +1,12 @@
1 1
 import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
2 3
 import 'package:provider/provider.dart';
4
+import '../../../shared/services/cache_service.dart';
3 5
 import '../services/auth_provider.dart';
4 6
 
5 7
 /// 登录页面
8
+///
9
+/// 支持表单校验、密码可见切换、记住用户名(通过 CacheService 持久化)、加载态。
6 10
 class LoginPage extends StatefulWidget {
7 11
   const LoginPage({super.key});
8 12
 
@@ -11,10 +15,42 @@ class LoginPage extends StatefulWidget {
11 15
 }
12 16
 
13 17
 class _LoginPageState extends State<LoginPage> {
18
+  static const String _kRememberKey = 'login:remember_username';
19
+
14 20
   final _formKey = GlobalKey<FormState>();
15 21
   final _usernameController = TextEditingController();
16 22
   final _passwordController = TextEditingController();
23
+  final _cache = CacheService();
24
+
17 25
   bool _obscurePassword = true;
26
+  bool _rememberUsername = true;
27
+  bool _restored = false;
28
+
29
+  @override
30
+  void initState() {
31
+    super.initState();
32
+    _restoreRememberedUsername();
33
+  }
34
+
35
+  /// 从本地缓存恢复上次记住的用户名
36
+  Future<void> _restoreRememberedUsername() async {
37
+    final saved = await _cache.get<String>(_kRememberKey, box: 'auth');
38
+    if (saved != null && saved.isNotEmpty && mounted) {
39
+      setState(() {
40
+        _usernameController.text = saved;
41
+        _restored = true;
42
+      });
43
+    }
44
+  }
45
+
46
+  /// 持久化/清除记住的用户名
47
+  Future<void> _persistRememberedUsername(String username) async {
48
+    if (_rememberUsername) {
49
+      await _cache.put(_kRememberKey, username, box: 'auth');
50
+    } else {
51
+      await _cache.delete(_kRememberKey, box: 'auth');
52
+    }
53
+  }
18 54
 
19 55
   @override
20 56
   void dispose() {
@@ -26,15 +62,18 @@ class _LoginPageState extends State<LoginPage> {
26 62
   Future<void> _handleLogin() async {
27 63
     if (!_formKey.currentState!.validate()) return;
28 64
 
65
+    final username = _usernameController.text.trim();
29 66
     final authProvider = Provider.of<AuthProvider>(context, listen: false);
30 67
     final success = await authProvider.login(
31
-      username: _usernameController.text.trim(),
68
+      username: username,
32 69
       password: _passwordController.text,
33 70
     );
34 71
 
35 72
     if (success && mounted) {
73
+      // 登录成功:按勾选状态持久化用户名
74
+      await _persistRememberedUsername(username);
36 75
       // 登录成功后路由由 AuthGuard 自动处理
37
-      Navigator.of(context).pushReplacementNamed('/main');
76
+      GoRouter.of(context).go('/main');
38 77
     } else if (mounted) {
39 78
       ScaffoldMessenger.of(context).showSnackBar(
40 79
         SnackBar(
@@ -131,7 +170,19 @@ class _LoginPageState extends State<LoginPage> {
131 170
                       return null;
132 171
                     },
133 172
                   ),
134
-                  const SizedBox(height: 32),
173
+                  const SizedBox(height: 12),
174
+
175
+                  // 记住用户名
176
+                  Row(
177
+                    children: [
178
+                      Checkbox(
179
+                        value: _rememberUsername,
180
+                        onChanged: (v) => setState(() => _rememberUsername = v ?? false),
181
+                      ),
182
+                      const Text('记住用户名'),
183
+                    ],
184
+                  ),
185
+                  const SizedBox(height: 20),
135 186
 
136 187
                   // 登录按钮
137 188
                   Consumer<AuthProvider>(

+ 79
- 0
mobile-app/test/features/auth/login_page_test.dart ファイルの表示

@@ -0,0 +1,79 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:provider/provider.dart';
4
+import 'package:water_management_app/features/auth/pages/login_page.dart';
5
+import 'package:water_management_app/features/auth/services/auth_provider.dart';
6
+
7
+/// LoginPage widget 测试(对应 Issue #79 登录功能)。
8
+///
9
+/// 覆盖:页面渲染、表单校验、密码可见切换、记住用户名复选框。
10
+void main() {
11
+  // 轻量 AuthProvider 替身:避免触发 TokenService 的 SharedPreferences 依赖
12
+  AuthProvider makeAuth() => AuthProvider();
13
+
14
+  Widget makeApp({required AuthProvider auth}) {
15
+    return ChangeNotifierProvider(
16
+      create: (_) => auth,
17
+      child: const MaterialApp(home: LoginPage()),
18
+    );
19
+  }
20
+
21
+  testWidgets('登录页渲染标题与输入框', (tester) async {
22
+    await tester.pumpWidget(makeApp(auth: makeAuth()));
23
+
24
+    expect(find.text('供水管理系统'), findsOneWidget);
25
+    expect(find.text('供水 · 巡检 · 营收'), findsOneWidget);
26
+    expect(find.widgetWithText(TextFormField, '请输入用户名'), findsOneWidget);
27
+    expect(find.text('登 录'), findsOneWidget);
28
+  });
29
+
30
+  testWidgets('空用户名提交时不通过校验', (tester) async {
31
+    await tester.pumpWidget(makeApp(auth: makeAuth()));
32
+
33
+    await tester.tap(find.text('登 录'));
34
+    await tester.pump();
35
+    expect(find.text('请输入用户名'), findsOneWidget);
36
+  });
37
+
38
+  testWidgets('密码短于6位显示校验错误', (tester) async {
39
+    await tester.pumpWidget(makeApp(auth: makeAuth()));
40
+
41
+    await tester.enterText(find.byType(TextFormField).first, 'admin');
42
+    await tester.enterText(find.byType(TextFormField).last, '123');
43
+    await tester.tap(find.text('登 录'));
44
+    await tester.pump();
45
+    expect(find.text('密码长度不能少于6位'), findsOneWidget);
46
+  });
47
+
48
+  testWidgets('点击密码可见图标切换 obscureText', (tester) async {
49
+    await tester.pumpWidget(makeApp(auth: makeAuth()));
50
+
51
+    // 初始密码隐藏
52
+    TextFormField pwdField = tester.widget(find.byType(TextFormField).last);
53
+    expect(pwdField.obscureText, isTrue);
54
+
55
+    await tester.tap(find.byIcon(Icons.visibility_off));
56
+    await tester.pump();
57
+    pwdField = tester.widget(find.byType(TextFormField).last);
58
+    expect(pwdField.obscureText, isFalse);
59
+
60
+    // 再次点击恢复隐藏
61
+    await tester.tap(find.byIcon(Icons.visibility));
62
+    await tester.pump();
63
+    pwdField = tester.widget(find.byType(TextFormField).last);
64
+    expect(pwdField.obscureText, isTrue);
65
+  });
66
+
67
+  testWidgets('记住用户名复选框可切换', (tester) async {
68
+    await tester.pumpWidget(makeApp(auth: makeAuth()));
69
+
70
+    expect(find.text('记住用户名'), findsOneWidget);
71
+    Checkbox checkbox = tester.widget(find.byType(Checkbox));
72
+    expect(checkbox.value, isTrue); // 默认勾选
73
+
74
+    await tester.tap(find.text('记住用户名'));
75
+    await tester.pump();
76
+    checkbox = tester.widget(find.byType(Checkbox));
77
+    expect(checkbox.value, isFalse);
78
+  });
79
+}