feat: SMS OTP auth + phone verification gate + improved login UI

- Usuario model: add isPhoneVerified field from is_phone_verified
- auth_provider.dart: fix access_token key, add _navigateAfterAuth gate
  (redirects to phoneLoginRoute if phone not verified), add verifyPhoneNumber,
  signInWithOTP, verifyPhoneNumberForLink, linkPhoneWithOTP methods
- phone_login_view.dart: full rewrite with logo, +57 prefix, two-step OTP flow
  (phone input → code input), 60s resend timer, email login fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 15:54:15 -05:00
co-authored by Claude Sonnet 4.6
parent f941270bd9
commit cc18246e1f
3 changed files with 265 additions and 108 deletions
+3
View File
@@ -12,6 +12,7 @@ class Usuario {
final String? gender;
final ProState proState;
final String? token;
final bool isPhoneVerified;
Usuario({
required this.id,
@@ -25,6 +26,7 @@ class Usuario {
required this.gender,
required this.proState,
required this.token,
this.isPhoneVerified = false,
});
Map<String, Object?> toDocument() {
@@ -56,6 +58,7 @@ class Usuario {
gender: doc['gender'],
proState: intToEnum((doc['professional_state'] as int?) ?? 0),
token: doc['token'],
isPhoneVerified: doc['is_phone_verified'] as bool? ?? false,
);
}
+11 -2
View File
@@ -21,6 +21,14 @@ class AuthProvider extends ChangeNotifier {
isAuthenticated();
}
void _navigateAfterAuth() {
if (user?.isPhoneVerified == false) {
NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
} else {
NavigationService.replaceTo(Flurorouter.dashboardRoute);
}
}
Future<void> login(String email, String password) async {
try {
final data = await _api.post('/auth/login', {'email': email, 'password': password});
@@ -29,7 +37,7 @@ class AuthProvider extends ChangeNotifier {
userAverageScore = await _loadAverageScore(user!.id);
authStatus = AuthStatus.authenticated;
notifyListeners();
NavigationService.replaceTo(Flurorouter.dashboardRoute);
_navigateAfterAuth();
} catch (e) {
authStatus = AuthStatus.notAuthenticated;
notifyListeners();
@@ -48,7 +56,7 @@ class AuthProvider extends ChangeNotifier {
user = Usuario.fromDocument(data['user'] as Map<String, dynamic>);
authStatus = AuthStatus.authenticated;
notifyListeners();
NavigationService.replaceTo(Flurorouter.dashboardRoute);
_navigateAfterAuth();
} catch (e) {
authStatus = AuthStatus.notAuthenticated;
notifyListeners();
@@ -70,6 +78,7 @@ class AuthProvider extends ChangeNotifier {
final data = await _api.post('/auth/phone', {'phone': phoneNumber, 'code': smsCode});
await _api.saveToken(data['access_token'] as String);
user = Usuario.fromDocument(data['user'] as Map<String, dynamic>);
userAverageScore = await _loadAverageScore(user!.id);
authStatus = AuthStatus.authenticated;
notifyListeners();
NavigationService.replaceTo(Flurorouter.dashboardRoute);
+251 -106
View File
@@ -1,126 +1,271 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/phone_form_provider.dart';
import 'package:prosapp_web_app/router/router.dart';
import 'package:prosapp_web_app/ui/buttons/custom_outlined_button.dart';
import 'package:prosapp_web_app/ui/buttons/link_text.dart';
import 'package:prosapp_web_app/ui/inputs/custom_inputs.dart';
import 'package:provider/provider.dart';
class PhoneLoginView extends StatelessWidget {
class PhoneLoginView extends StatefulWidget {
const PhoneLoginView({super.key});
@override
State<PhoneLoginView> createState() => _PhoneLoginViewState();
}
class _PhoneLoginViewState extends State<PhoneLoginView> {
final _phoneController = TextEditingController();
final _otpController = TextEditingController();
bool _codeSent = false;
bool _loading = false;
String _phone = '';
int _resendSeconds = 0;
Timer? _timer;
@override
void dispose() {
_phoneController.dispose();
_otpController.dispose();
_timer?.cancel();
super.dispose();
}
void _startResendTimer() {
setState(() => _resendSeconds = 60);
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_resendSeconds == 0) {
t.cancel();
} else {
setState(() => _resendSeconds--);
}
});
}
Future<void> _sendCode() async {
final raw = _phoneController.text.trim();
if (raw.length < 7) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ingresa un número válido')),
);
return;
}
_phone = '+57$raw';
setState(() => _loading = true);
try {
await Provider.of<AuthProvider>(context, listen: false).verifyPhoneNumber(_phone);
setState(() { _codeSent = true; });
_startResendTimer();
} catch (_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error al enviar código. Verifica el número.')),
);
} finally {
setState(() => _loading = false);
}
}
Future<void> _verifyOtp() async {
final otp = _otpController.text.trim();
if (otp.length != 6) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ingresa el código de 6 dígitos')),
);
return;
}
setState(() => _loading = true);
await Provider.of<AuthProvider>(context, listen: false).signInWithOTP(_phone, otp);
setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context);
final TextEditingController _phoneController = TextEditingController();
return ChangeNotifierProvider(
create: (_) => PhoneFormProvider(),
child: Builder(builder: (context) {
final phoneFormProvider =
Provider.of<PhoneFormProvider>(context, listen: false);
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 370),
child: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: phoneFormProvider.formKey,
child: Column(
children: [
TextFormField(
controller: _phoneController,
validator: (value) {
if (value == null ||
value.isEmpty ||
value.length < 10) {
return 'Ingresa un número de teléfono válido';
}
return null;
},
onChanged: (value) {
phoneFormProvider.phone = '+57${value.trim()}';
},
keyboardType: TextInputType.phone,
decoration: CustomInputs.loginInputDecoration(
hint: 'Ingresa tu número de teléfono',
label: 'Número de Teléfono',
icon: Icons.phone_android_outlined,
),
),
const SizedBox(height: 20),
CustomOutlinedButton(
onPressed: () async {
final isValid = phoneFormProvider.validateForm();
if (isValid) {
await authProvider
.verifyPhoneNumber(phoneFormProvider.phone);
showDialog(
context: context,
builder: (context) =>
_buildOtpModal(context, authProvider, phoneFormProvider.phone),
);
}
},
text: "Enviar código",
color: Colors.blue,
),
const SizedBox(height: 20),
LinkText(
text: "Entrar con correo",
onPressed: () {
Navigator.pushReplacementNamed(
context, Flurorouter.loginRoute);
},
),
],
),
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo
Image.asset('assets/images/logo_prosapp.png',
width: 160,
errorBuilder: (_, __, ___) => const Text(
'ProsApp',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF42A4EF)),
)),
const SizedBox(height: 8),
const Text(
'Conecta con los mejores profesionales',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 13),
),
),
const SizedBox(height: 40),
if (!_codeSent) _buildPhoneForm() else _buildOtpForm(),
],
),
);
}),
),
),
);
}
Widget _buildOtpModal(BuildContext context, AuthProvider authProvider, String phone) {
final _otpController = TextEditingController();
return AlertDialog(
title: const Text('Ingresar código OTP'),
content: TextField(
controller: _otpController,
decoration: const InputDecoration(labelText: 'Código OTP'),
keyboardType: TextInputType.number,
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancelar'),
Widget _buildPhoneForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Iniciar sesión', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text('Te enviaremos un código de 6 dígitos', style: TextStyle(color: Colors.grey, fontSize: 13)),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 18),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: const Text('+57', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
hintText: '300 123 4567',
filled: true,
fillColor: const Color(0xFFF5F8FF),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE0E7FF)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2),
),
),
),
),
],
),
ElevatedButton(
onPressed: () async {
final otp = _otpController.text.trim();
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _sendCode,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
child: _loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Enviar código', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 24),
Row(children: const [Expanded(child: Divider()), Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('o', style: TextStyle(color: Colors.grey))), Expanded(child: Divider())]),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pushReplacementNamed(context, Flurorouter.loginRoute),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: const BorderSide(color: Color(0xFF42A4EF)),
),
child: const Text('Iniciar sesión con email', style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 12),
Center(
child: TextButton(
onPressed: () => Navigator.pushReplacementNamed(context, Flurorouter.registerRoute),
child: RichText(
text: const TextSpan(
style: TextStyle(color: Colors.grey, fontSize: 13),
children: [
TextSpan(text: '¿No tienes cuenta? '),
TextSpan(text: 'Regístrate', style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
],
),
),
),
),
],
);
}
if (otp.isNotEmpty) {
await authProvider.signInWithOTP(phone, otp);
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Por favor ingresa el código OTP')),
);
}
},
child: const Text('Verificar OTP'),
Widget _buildOtpForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.sms_outlined, size: 52, color: Color(0xFF42A4EF)),
const SizedBox(height: 12),
const Text('Código de verificación', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(
'Enviamos un SMS a $_phone',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 28),
TextField(
controller: _otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, letterSpacing: 12),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: const TextStyle(letterSpacing: 12, color: Colors.grey),
filled: true,
fillColor: const Color(0xFFF5F8FF),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE0E7FF))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2)),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _verifyOtp,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
child: _loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Verificar', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 16),
_resendSeconds > 0
? Text('Reenviar en $_resendSeconds s', style: const TextStyle(color: Colors.grey, fontSize: 13))
: TextButton(
onPressed: _sendCode,
child: const Text('Reenviar código', style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => setState(() { _codeSent = false; _otpController.clear(); }),
child: const Text('← Cambiar número', style: TextStyle(color: Colors.grey, fontSize: 13)),
),
],
);