feat: implement SMS OTP auth via backend API

- signInWithPhoneNumber: calls POST /auth/send-otp, stores pending phone
- verifyOTP: calls POST /auth/phone with phone+code, saves token
- linkWithOTP: calls POST /auth/verify-phone (authenticated) with phone+code
- AuthBloc._linkWithPhoneNumber: replaced Firebase PhoneVerificationService with repository call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 15:23:39 -05:00
co-authored by Claude Sonnet 4.6
parent 7446f22e23
commit 38beca4b4f
2 changed files with 54 additions and 46 deletions
+4 -36
View File
@@ -9,9 +9,6 @@ part 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final UserRepository _userRepository;
final PhoneVerificationService phoneVerificationService =
PhoneVerificationService();
String? _verificationId;
AuthBloc({required UserRepository userRepository})
: _userRepository = userRepository,
@@ -103,39 +100,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
void _linkWithPhoneNumber(
LinkWithPhoneNumber event, Emitter<AuthState> emit) async {
emit(AuthStateProcess());
try {
await for (PhoneAuthEvent event
in phoneVerificationService.verifyPhoneNumber(event.phoneNumber)) {
switch (event.type) {
case PhoneAuthEventType.verificationCompleted:
final credential = event.data;
print('Verificacion completada. Credencial: $credential');
break;
case PhoneAuthEventType.verificationFailed:
final exception = event.data;
print('Verificacion fallida. Excepcion: $exception');
emit(AuthStateFailure(message: "Error inesperado. $exception"));
return;
case PhoneAuthEventType.codeAutoRetrievalTimeout:
final verificationId = event.data as String;
print('Tiempo de espera agotado. ID: $verificationId');
emit(const AuthStateFailure(
message: "Tiempo de espera agotado.",
));
break;
case PhoneAuthEventType.codeSent:
final eventData = event.data as Map<String, dynamic>;
final verificationId = eventData['verificationId'] as String;
final resendToken = eventData['resendToken'] as int?;
print('Codigo enviado. ID: $verificationId, resendToken: $resendToken');
_verificationId = verificationId;
emit(const AuthStateVerifyOAuth(false));
break;
}
}
await _userRepository.signInWithPhoneNumber(event.phoneNumber);
emit(const AuthStateVerifyOAuth(false));
} catch (e) {
emit(const AuthStateFailure(message: "Error inesperado."));
emit(AuthStateFailure(message: "Error al enviar código. $e"));
}
}
@@ -144,8 +113,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(AuthStateProcess());
try {
final bool isVerified = await _userRepository.linkWithOTP(
event.phoneNumber, _verificationId ?? '', event.code);
event.phoneNumber, '', event.code);
if (isVerified) {
emit(AuthStateSuccess());
} else {
@@ -16,6 +16,7 @@ class ApiUserRepository implements UserRepository {
final _controller = StreamController<MyUser?>.broadcast();
MyUser? _current;
String? _token;
String? _pendingOtpPhone;
Future<String?> _getToken() async {
if (_token != null) return _token;
@@ -31,20 +32,27 @@ class ApiUserRepository implements UserRepository {
};
}
static const _timeout = Duration(seconds: 20);
Future<dynamic> _get(String path) async {
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers());
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_timeout);
return jsonDecode(res.body);
}
Future<dynamic> _post(String path, Map<String, dynamic> body, {bool auth = false}) async {
final h = await _headers();
if (!auth) h.remove('Authorization');
final res = await http.post(Uri.parse('$_base$path'), headers: h, body: jsonEncode(body));
return jsonDecode(res.body);
final res = await http.post(Uri.parse('$_base$path'), headers: h, body: jsonEncode(body)).timeout(_timeout);
final data = jsonDecode(res.body);
if (res.statusCode >= 400) {
final msg = (data is Map ? data['message'] : null) ?? 'Error del servidor';
throw Exception(msg);
}
return data;
}
Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
final res = await http.patch(Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body));
final res = await http.patch(Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body)).timeout(_timeout);
return jsonDecode(res.body);
}
@@ -87,14 +95,19 @@ class ApiUserRepository implements UserRepository {
Stream<bool> isAuthenticated() async* {
final t = await _getToken();
if (t != null) {
// Try to restore the current user from the API
try {
final data = await _get('/auth/me');
if (data != null) {
_current = _fromApi(data as Map<String, dynamic>);
currentUserId = _current?.id;
_emit(_current);
} else {
_emit(null);
}
} catch (_) {}
} catch (_) {
_emit(null);
}
} else {
_emit(null);
}
yield t != null;
}
@@ -245,11 +258,31 @@ class ApiUserRepository implements UserRepository {
@override
Future<void> signInWithPhoneNumber(String phoneNumber) async {
// The API's phone auth is direct — no OTP flow via this method
_pendingOtpPhone = phoneNumber;
await _post('/auth/send-otp', {'phone': phoneNumber});
}
@override
Future<bool> verifyOTP(String code) async => false;
Future<bool> verifyOTP(String code) async {
if (_pendingOtpPhone == null) return false;
try {
final data = await _post('/auth/phone', {
'phone': _pendingOtpPhone!,
'code': code,
});
_token = data['access_token'] as String?;
if (_token != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', _token!);
}
_current = _fromApi(data['user'] as Map<String, dynamic>);
_emit(_current);
_pendingOtpPhone = null;
return true;
} catch (_) {
return false;
}
}
@override
Future<void> addPhoneAuthCredential(
@@ -263,7 +296,14 @@ class ApiUserRepository implements UserRepository {
}
@override
Future<bool> linkWithOTP(String phoneNumber, String verificationId, String code) async => false;
Future<bool> linkWithOTP(String phoneNumber, String verificationId, String code) async {
try {
await _post('/auth/verify-phone', {'phone': phoneNumber, 'code': code}, auth: true);
return true;
} catch (_) {
return false;
}
}
@override
Future<void> resetPassword(String email) async {