feat(client/auth): 注册页加邀请码(选填)输入,提交时传 invite_code
This commit is contained in:
@@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1);
|
|||||||
enum _AuthMode { login, register }
|
enum _AuthMode { login, register }
|
||||||
|
|
||||||
class AuthScreen extends ConsumerStatefulWidget {
|
class AuthScreen extends ConsumerStatefulWidget {
|
||||||
const AuthScreen({super.key, required this.onDone, required this.t});
|
const AuthScreen({super.key, required this.onDone, required this.t, this.api});
|
||||||
final VoidCallback onDone;
|
final VoidCallback onDone;
|
||||||
final AppText t;
|
final AppText t;
|
||||||
|
|
||||||
|
/// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部
|
||||||
|
/// 用 kApiBaseUrl 现建一个,行为不变。
|
||||||
|
final AuthApi? api;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
final _email = TextEditingController();
|
final _email = TextEditingController();
|
||||||
final _code = TextEditingController();
|
final _code = TextEditingController();
|
||||||
final _pw = TextEditingController();
|
final _pw = TextEditingController();
|
||||||
|
final _invite = TextEditingController();
|
||||||
|
|
||||||
final _emailFocus = FocusNode();
|
final _emailFocus = FocusNode();
|
||||||
final _pwFocus = FocusNode();
|
final _pwFocus = FocusNode();
|
||||||
@@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
duration: const Duration(milliseconds: 620),
|
duration: const Duration(milliseconds: 620),
|
||||||
);
|
);
|
||||||
|
|
||||||
late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl);
|
late final AuthApi _api = widget.api ?? AuthApi(baseUrl: kApiBaseUrl);
|
||||||
|
|
||||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||||
|
|
||||||
@@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
_email.dispose();
|
_email.dispose();
|
||||||
_code.dispose();
|
_code.dispose();
|
||||||
_pw.dispose();
|
_pw.dispose();
|
||||||
|
_invite.dispose();
|
||||||
_emailFocus.dispose();
|
_emailFocus.dispose();
|
||||||
_pwFocus.dispose();
|
_pwFocus.dispose();
|
||||||
_codeFocus.dispose();
|
_codeFocus.dispose();
|
||||||
@@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
code: _code.text.trim(),
|
code: _code.text.trim(),
|
||||||
password: _pw.text,
|
password: _pw.text,
|
||||||
device: device,
|
device: device,
|
||||||
|
inviteCode: _invite.text.trim(),
|
||||||
);
|
);
|
||||||
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
||||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||||
@@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)),
|
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_field(c, icon: PangolinIcons.gift, label: t.inviteCodeFieldLabel, focused: false,
|
||||||
|
child: TextField(key: const Key('invite-code-field'), controller: _invite,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: _bare.copyWith(hintText: ''))),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
|
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
|
||||||
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
|
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// auth_invite_field_test.dart — Task 13: 注册页「邀请码(选填)」输入框
|
||||||
|
//
|
||||||
|
// 驱动路径:切到注册 tab(step0)→ 填邮箱 → 点发送验证码(mock 网络 204)→
|
||||||
|
// 填 6 位验证码 → 点下一步(进 step1 设密码步)→ 断言 step1 渲染出
|
||||||
|
// key 为 invite-code-field 的 TextField,label 为 t.inviteCodeFieldLabel。
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||||
|
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||||
|
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||||
|
import 'package:pangolin_vpn/services/token_store.dart';
|
||||||
|
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||||
|
import 'package:pangolin_vpn/widgets/auth_screen.dart';
|
||||||
|
|
||||||
|
import '../helpers/harness.dart';
|
||||||
|
|
||||||
|
class _NullTokenStore implements TokenStore {
|
||||||
|
const _NullTokenStore();
|
||||||
|
@override
|
||||||
|
Future<void> saveTokens({required String access, required String refresh}) async {}
|
||||||
|
@override
|
||||||
|
Future<String?> loadAccessToken() async => null;
|
||||||
|
@override
|
||||||
|
Future<String?> loadRefreshToken() async => null;
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {}
|
||||||
|
@override
|
||||||
|
Future<void> markOnboarded() async {}
|
||||||
|
@override
|
||||||
|
Future<bool> isOnboarded() async => true;
|
||||||
|
@override
|
||||||
|
Future<void> saveLastEmail(String email) async {}
|
||||||
|
@override
|
||||||
|
Future<String?> loadLastEmail() async => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(disableGoogleFontsFetching);
|
||||||
|
const t = StringsZh();
|
||||||
|
|
||||||
|
Future<void> _driveToRegisterStep1(WidgetTester tester) async {
|
||||||
|
final mockClient = MockClient((req) async {
|
||||||
|
if (req.method == 'POST' && req.url.path == '/v1/auth/code') {
|
||||||
|
return http.Response('', 204);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'message_zh': 'unexpected'}), 404);
|
||||||
|
});
|
||||||
|
final api = AuthApi(baseUrl: 'http://test.invalid', client: mockClient);
|
||||||
|
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
|
||||||
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: PangolinTheme.light,
|
||||||
|
home: AuthScreen(t: t, onDone: () {}, api: api),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 切到注册 tab。
|
||||||
|
await tester.tap(find.text(t.tabRegister));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// step0:填邮箱 → 点发送验证码(mock 204)。
|
||||||
|
await tester.enterText(find.byType(TextField).first, 'invitee@example.com');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text(t.sendCode));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 填 6 位验证码 → 下一步,进 step1。
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), '123456');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text(t.doNext));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('注册 step1(设密码步)渲染邀请码(选填)输入框', (tester) async {
|
||||||
|
await _driveToRegisterStep1(tester);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('invite-code-field')), findsOneWidget);
|
||||||
|
expect(find.text(t.inviteCodeFieldLabel), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('登录路径不含邀请码输入框', (tester) async {
|
||||||
|
final api = AuthApi(baseUrl: 'http://test.invalid', client: MockClient((req) async => http.Response('', 404)));
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
|
||||||
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: PangolinTheme.light,
|
||||||
|
home: AuthScreen(t: t, onDone: () {}, api: api),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('invite-code-field')), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user