feat(auth): transition from email to phone-based OTP authentication

- Replace Email+Password login with Phone+OTP flow
- Remove RegisterCubit and registration screens (email verification)
- Remove ResetPasswordCubit and reset password screens
- Add phone normalization and international dial code support
- Update LoginCubit with sendCode/resend cooldown logic
- Add new widgets: phone prefix selector, confirm sheet
- Update all auth API endpoints: /otp/send, /phone-session
- Update form inputs: Email -> Phone with E.164 validation
- Update tests for new auth flow
This commit is contained in:
qzl
2026-03-19 18:42:05 +08:00
parent 636b37ee5a
commit 0661016827
29 changed files with 615 additions and 2030 deletions
@@ -1,4 +1,5 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:formz/formz.dart';
import 'package:mocktail/mocktail.dart';
@@ -26,17 +27,68 @@ void main() {
});
blocTest<LoginCubit, LoginState>(
'emailChanged updates email',
'phoneChanged updates phone',
build: () => cubit,
act: (c) => c.emailChanged('test@example.com'),
act: (c) => c.phoneChanged('+8613812345678'),
expect: () => [isA<LoginState>()],
);
blocTest<LoginCubit, LoginState>(
'passwordChanged updates password',
'codeChanged updates code',
build: () => cubit,
act: (c) => c.passwordChanged('password123'),
act: (c) => c.codeChanged('123456'),
expect: () => [isA<LoginState>()],
);
test('sendCode success starts 60s cooldown', () {
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
fakeAsync((async) {
cubit.phoneChanged('13812345678');
cubit.sendCode();
async.flushMicrotasks();
expect(cubit.state.resendCooldownSeconds, 60);
async.elapse(const Duration(seconds: 1));
expect(cubit.state.resendCooldownSeconds, 59);
async.elapse(const Duration(seconds: 59));
expect(cubit.state.resendCooldownSeconds, 0);
});
});
test('sendCode is blocked during cooldown', () async {
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
cubit.phoneChanged('13812345678');
final first = await cubit.sendCode();
final second = await cubit.sendCode();
expect(first, isTrue);
expect(second, isFalse);
verify(() => mockRepository.sendOtp(any())).called(1);
});
test('phone change resets cooldown and code state', () {
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
fakeAsync((async) {
cubit.phoneChanged('13812345678');
cubit.codeChanged('123456');
cubit.sendCode();
async.flushMicrotasks();
expect(cubit.state.resendCooldownSeconds, 60);
expect(cubit.state.codeSent, isTrue);
cubit.phoneChanged('14155552671');
expect(cubit.state.resendCooldownSeconds, 0);
expect(cubit.state.codeSent, isFalse);
expect(cubit.state.code.value, '');
});
});
});
}