From 2c5b237890761865ec8cefb05d57215aef8a338d Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:53:43 -0500 Subject: [PATCH] feat: SMS OTP phone auth + mandatory phone verification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyUser: add isPhoneVerified field (from is_phone_verified in API) - ApiUserRepository: signInWithPhoneNumber calls POST /auth/send-otp, verifyOTP calls POST /auth/phone, linkWithOTP calls POST /auth/verify-phone Added updateFcmToken and changePassword methods - AuthBloc: remove Firebase PhoneVerificationService, use repository OTP flow - app_view.dart: gate authenticated users without verified phone to PhoneVerifyRequiredScreen before showing HomeScreen - welcome_screen.dart: full redesign with gradient header, logo, integrated OTP flow (phone → code in same screen), 60s resend timer - PhoneVerifyRequiredScreen: new screen for users who need to verify phone Co-Authored-By: Claude Sonnet 4.6 --- lib/app_view.dart | 11 +- .../authentication_bloc.dart | 4 +- .../phone_verify_required_screen.dart | 227 +++++++++ .../authentication/welcome_screen.dart | 457 +++++++++++------- .../lib/src/models/my_user.dart | 6 + .../src/repositories/api_user_repository.dart | 45 +- 6 files changed, 564 insertions(+), 186 deletions(-) create mode 100644 lib/screens/authentication/phone_verify_required_screen.dart diff --git a/lib/app_view.dart b/lib/app_view.dart index 29a2a79..f09469f 100644 --- a/lib/app_view.dart +++ b/lib/app_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:prosappco/blocs/notification_bloc/notification_bloc.dart'; import 'package:prosappco/screens/authentication/welcome_screen.dart'; +import 'package:prosappco/screens/authentication/phone_verify_required_screen.dart'; import 'package:prosappco/screens/home/home_screen.dart'; import 'package:prosappco/screens/splash/splash_screen.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -44,19 +45,23 @@ class MyAppView extends StatelessWidget { } }, builder: (context, state) { - return getScreen(state); + return _getScreen(state); }, ), ); } - getScreen(AuthenticationState state) { + Widget _getScreen(AuthenticationState state) { switch (state.status) { case AuthenticationStatus.authenticated: + final phoneVerified = state.user?.isPhoneVerified ?? false; + if (!phoneVerified) { + return PhoneVerifyRequiredScreen(phone: state.user?.phone); + } return HomeScreen(); case AuthenticationStatus.unauthenticated: - return WelcomeScreen(); + return const WelcomeScreen(); case AuthenticationStatus.unknown: return const SplashScreen(); diff --git a/lib/blocs/authentication_bloc/authentication_bloc.dart b/lib/blocs/authentication_bloc/authentication_bloc.dart index 8f9f082..a34c0a7 100644 --- a/lib/blocs/authentication_bloc/authentication_bloc.dart +++ b/lib/blocs/authentication_bloc/authentication_bloc.dart @@ -14,11 +14,13 @@ class AuthenticationBloc extends Bloc AuthenticationBloc({required UserRepository myUserRepository}) : userRepository = myUserRepository, - super(const AuthenticationState.unknown()) {_userSubscription = userRepository.streamUser().listen((authUser) { + super(const AuthenticationState.unknown()) { + _userSubscription = userRepository.streamUser().listen((authUser) { add(AuthenticationUserChanged(authUser)); }); on(_onAuthenticationUserChanged); on(_onAuthenticationLogoutRequested); + userRepository.isAuthenticated().listen((_) {}); } void _onAuthenticationUserChanged( diff --git a/lib/screens/authentication/phone_verify_required_screen.dart b/lib/screens/authentication/phone_verify_required_screen.dart new file mode 100644 index 0000000..b50e921 --- /dev/null +++ b/lib/screens/authentication/phone_verify_required_screen.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; +import 'package:injector/injector.dart'; +import 'package:intl_phone_field/intl_phone_field.dart'; +import 'package:prosappco/blocs/auth_bloc/auth_bloc.dart'; +import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart'; +import 'package:prosappco/components/general_primary_button.dart'; + +class PhoneVerifyRequiredScreen extends StatefulWidget { + final String? phone; + const PhoneVerifyRequiredScreen({super.key, this.phone}); + + @override + State createState() => _PhoneVerifyRequiredScreenState(); +} + +class _PhoneVerifyRequiredScreenState extends State { + late final AuthBloc authBloc; + String? _phone; + String _otpCode = ''; + bool _codeSent = false; + int _resendSeconds = 0; + Timer? _timer; + + @override + void initState() { + super.initState(); + authBloc = Injector.appInstance.get(); + _phone = widget.phone; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startResendTimer() { + setState(() => _resendSeconds = 60); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (_resendSeconds == 0) { + t.cancel(); + } else { + setState(() => _resendSeconds--); + } + }); + } + + void _sendCode() { + final phone = _phone; + if (phone == null || phone.isEmpty) return; + authBloc.add(AuthEventLoginOAuth(phone: phone)); + setState(() => _codeSent = true); + _startResendTimer(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => authBloc, + child: BlocConsumer( + listener: (context, state) { + if (state is AuthStateSuccess) { + context.read().add( + AuthenticationUserChanged(null), + ); + } else if (state is AuthStateVerifyOAuth && state.isWrongCode) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Código incorrecto, intenta de nuevo')), + ); + } + }, + builder: (context, state) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF42A4EF), Color(0xFF1565C0)], + ), + ), + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 40), + Image.asset('images/logo_prosapp.png', width: 160), + const SizedBox(height: 12), + const Text( + 'Verificación requerida', + style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + const Text( + 'Verifica tu número para continuar', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(height: 30), + Expanded( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: SingleChildScrollView( + child: _codeSent ? _buildOtpSection(state) : _buildPhoneSection(), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildPhoneSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Número de celular', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + const SizedBox(height: 4), + const Text('Ingresa tu número para recibir el código de verificación', + style: TextStyle(color: Colors.grey, fontSize: 13)), + const SizedBox(height: 20), + IntlPhoneField( + initialCountryCode: 'CO', + initialValue: _phone?.replaceAll('+57', '').replaceAll('+', ''), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (phone) => _phone = phone.completeNumber, + decoration: InputDecoration( + hintText: 'Número de celular', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _sendCode, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Enviar código', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 16), + Center( + child: TextButton( + onPressed: () => context.read().add(AuthenticationLogoutRequested()), + child: const Text('Cerrar sesión', style: TextStyle(color: Colors.grey)), + ), + ), + ], + ); + } + + Widget _buildOtpSection(AuthState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text('Ingresa el código', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18)), + const SizedBox(height: 8), + Text( + 'Enviamos un código de 6 dígitos a\n${_phone ?? ''}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 28), + OtpTextField( + numberOfFields: 6, + borderColor: const Color(0xFF42A4EF), + focusedBorderColor: const Color(0xFF1565C0), + showFieldAsBox: true, + fieldWidth: 44, + borderRadius: BorderRadius.circular(10), + onCodeChanged: (code) => _otpCode = code, + onSubmit: (code) => authBloc.add(AuthEventVerifyOAuth(code: code)), + ), + const SizedBox(height: 28), + if (state is AuthStateProcess) + const CircularProgressIndicator() + else + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_otpCode.length == 6) { + authBloc.add(AuthEventVerifyOAuth(code: _otpCode)); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Verificar', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 20), + _resendSeconds > 0 + ? Text('Reenviar código en $_resendSeconds s', style: const TextStyle(color: Colors.grey)) + : TextButton( + onPressed: _sendCode, + child: const Text('Reenviar código', style: TextStyle(color: Color(0xFF42A4EF))), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => setState(() { _codeSent = false; _otpCode = ''; }), + child: const Text('Cambiar número', style: TextStyle(color: Colors.grey)), + ), + ], + ); + } +} diff --git a/lib/screens/authentication/welcome_screen.dart b/lib/screens/authentication/welcome_screen.dart index b604def..1714ccf 100644 --- a/lib/screens/authentication/welcome_screen.dart +++ b/lib/screens/authentication/welcome_screen.dart @@ -1,186 +1,313 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/gestures.dart'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; +import 'package:injector/injector.dart'; import 'package:intl_phone_field/intl_phone_field.dart'; -import 'package:prosappco/components/general_input_decoration.dart'; -import 'package:prosappco/components/general_primary_button.dart'; -import 'package:prosappco/screens/authentication/otp_auth_screen.dart'; +import 'package:prosappco/blocs/auth_bloc/auth_bloc.dart'; import 'package:prosappco/screens/authentication/sign_screen.dart'; -class WelcomeScreen extends StatelessWidget { +class WelcomeScreen extends StatefulWidget { const WelcomeScreen({super.key}); + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + late final AuthBloc authBloc; + String? _phone; + String _otpCode = ''; + bool _codeSent = false; + int _resendSeconds = 0; + Timer? _timer; + + @override + void initState() { + super.initState(); + authBloc = Injector.appInstance.get(); + } + + @override + void 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--); + } + }); + } + + void _sendCode() { + final phone = _phone; + if (phone == null || phone.isEmpty) return; + authBloc.add(AuthEventLoginOAuth(phone: phone)); + setState(() { _codeSent = true; _otpCode = ''; }); + _startResendTimer(); + } + @override Widget build(BuildContext context) { - double width = MediaQuery.of(context).size.width; - String? _phoneNumber; - String? _errorMsg; - - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.tertiary, - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Center( - child: Image( - width: width * 0.7, - image: const AssetImage('images/logo_prosapp.png'), - ), - ), - ), - Expanded( - child: Container( + return BlocProvider( + create: (_) => authBloc, + child: BlocConsumer( + listener: (context, state) { + if (state is AuthStateFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message ?? 'Error al enviar código')), + ); + } else if (state is AuthStateVerifyOAuth && state.isWrongCode) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Código incorrecto, intenta de nuevo')), + ); + } + }, + builder: (context, state) { + return Scaffold( + body: Container( decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(60), - topRight: Radius.circular(60), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF42A4EF), Color(0xFF1565C0)], ), ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 15), - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: Text( - 'Iniciar sesión', - style: TextStyle( - // fontSize: 30, - fontSize: width * 0.08, - fontWeight: FontWeight.bold, - ), + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 36), + _buildHero(), + const SizedBox(height: 24), + Expanded( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + padding: const EdgeInsets.fromLTRB(28, 32, 28, 20), + child: SingleChildScrollView( + child: _codeSent + ? _buildOtpSection(state) + : _buildPhoneSection(state), ), ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: Text( - 'Numero de celular', - style: TextStyle( - fontSize: width * 0.045, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - ), - Form( - child: IntlPhoneField( - initialCountryCode: 'CO', - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly - ], - validator: (value) { - if (value == null) { - return 'Ingresa un numero'; - } else { - return null; - } - }, - onChanged: (phone) { - _phoneNumber = phone.completeNumber; - }, - decoration: GeneralInputDecoration.getCustomDecoration( - context: context, - hintText: 'Ingresa tu numero', - errorMsg: _errorMsg, - ), - ), - ), - Text('Un código será enviado a este numero de celular.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13.0,color: Theme.of(context).colorScheme.onBackground), - ), - GeneralPrimaryButton( - onPressed: () { - final phoneNumber = _phoneNumber; - - if (phoneNumber != null && phoneNumber.isNotEmpty) { - Navigator.push(context, - CupertinoPageRoute( - builder: (context) => OtpAuthScreen(phoneNumber: phoneNumber), - ), - ); - } - }, - label: 'Enviar código', - ), - // ElevatedButton( - // onPressed: () { - // final phoneNumber = _phoneNumber; - - // if (phoneNumber != null && phoneNumber.isNotEmpty) { - // Navigator.push( - // context, - // CupertinoPageRoute( - // builder: (context) => - // OtpAuthScreen(phoneNumber: phoneNumber), - // ), - // ); - // } else {} - // }, - // child: const Text('Enviar código'), - // ), - TextButton( - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const SignScreen(initialIndex: 0), - ), - ); - }, - child: Text( - 'Inicia sesión con tu correo electrónico', - style: TextStyle( - fontSize: width * 0.04, - ), - ), - ), - RichText( - text: TextSpan( - style: const TextStyle( - fontSize: 16.0, - color: Color(0xFF65676B), - fontFamily: 'Poppins', - ), - children: [ - const TextSpan(text: '¿No estás registrado? '), - TextSpan( - text: 'Regístrate', - style: TextStyle( - fontSize: width * 0.04, - color: Colors.blue, - fontWeight: FontWeight.w600, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - SignScreen(initialIndex: 1), - ), - ); - }, - ), - ], - ), - ), - ], - ), + ), + ], ), ), ), - ) - ], + ); + }, ), ); } + + Widget _buildHero() { + return Column( + children: [ + Image.asset('images/logo_prosapp.png', width: 180), + const SizedBox(height: 12), + const Text( + 'Conecta con los mejores profesionales', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w400, + letterSpacing: 0.3, + ), + ), + ], + ); + } + + Widget _buildPhoneSection(AuthState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Iniciar sesión', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)), + ), + const SizedBox(height: 6), + const Text( + 'Ingresa tu número para recibir un código de verificación', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 24), + IntlPhoneField( + initialCountryCode: 'CO', + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (phone) => _phone = phone.completeNumber, + decoration: InputDecoration( + hintText: 'Número de celular', + 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), width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2), + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'Te enviaremos un SMS con tu código. Máx 160 caracteres.', + style: TextStyle(color: Colors.grey, fontSize: 11), + ), + const SizedBox(height: 24), + if (state is AuthStateProcess) + const Center(child: CircularProgressIndicator()) + else + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _sendCode, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: const Text('Enviar código', style: TextStyle(fontSize: 16, 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.push( + context, + MaterialPageRoute(builder: (_) => const SignScreen(initialIndex: 0)), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + 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.push( + context, + MaterialPageRoute(builder: (_) => const SignScreen(initialIndex: 1)), + ), + child: RichText( + text: const TextSpan( + style: TextStyle(color: Colors.grey, fontSize: 14), + children: [ + TextSpan(text: '¿No tienes cuenta? '), + TextSpan( + text: 'Regístrate', + style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildOtpSection(AuthState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.sms_outlined, size: 48, color: Color(0xFF42A4EF)), + const SizedBox(height: 12), + const Text( + 'Código de verificación', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)), + ), + const SizedBox(height: 8), + Text( + 'Enviamos un código a\n${_phone ?? ''}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 32), + OtpTextField( + numberOfFields: 6, + borderColor: const Color(0xFFE0E7FF), + focusedBorderColor: const Color(0xFF42A4EF), + showFieldAsBox: true, + fieldWidth: 46, + borderRadius: BorderRadius.circular(10), + textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + onCodeChanged: (code) => _otpCode = code, + onSubmit: (code) => authBloc.add(AuthEventVerifyOAuth(code: code)), + ), + const SizedBox(height: 32), + if (state is AuthStateProcess) + const CircularProgressIndicator() + else + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_otpCode.length == 6) { + authBloc.add(AuthEventVerifyOAuth(code: _otpCode)); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: const Text('Verificar código', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 20), + _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; _otpCode = ''; }), + child: const Text('← Cambiar número', style: TextStyle(color: Colors.grey, fontSize: 13)), + ), + ], + ); + } } diff --git a/packages/user_repository/lib/src/models/my_user.dart b/packages/user_repository/lib/src/models/my_user.dart index e1aeba8..8088692 100644 --- a/packages/user_repository/lib/src/models/my_user.dart +++ b/packages/user_repository/lib/src/models/my_user.dart @@ -13,6 +13,7 @@ class MyUser extends Equatable { final String? gender; final ProState proState; final String? token; + final bool isPhoneVerified; const MyUser({ required this.id, @@ -26,6 +27,7 @@ class MyUser extends Equatable { this.gender, required this.proState, this.token, + this.isPhoneVerified = false, }); get drawerLabel => email != null && email!.isNotEmpty @@ -47,6 +49,7 @@ class MyUser extends Equatable { gender: '', proState: ProState.inactive, token: '', + isPhoneVerified: false, ); /// Modify MyUser parameters @@ -62,6 +65,7 @@ class MyUser extends Equatable { String? gender, ProState? proState, String? token, + bool? isPhoneVerified, }) { return MyUser( id: id ?? this.id, @@ -75,6 +79,7 @@ class MyUser extends Equatable { gender: gender ?? this.gender, proState: proState ?? this.proState, token: token ?? this.token, + isPhoneVerified: isPhoneVerified ?? this.isPhoneVerified, ); } @@ -129,5 +134,6 @@ class MyUser extends Equatable { gender, proState, token, + isPhoneVerified, ]; } diff --git a/packages/user_repository/lib/src/repositories/api_user_repository.dart b/packages/user_repository/lib/src/repositories/api_user_repository.dart index 021ee6b..5c3284b 100644 --- a/packages/user_repository/lib/src/repositories/api_user_repository.dart +++ b/packages/user_repository/lib/src/repositories/api_user_repository.dart @@ -69,6 +69,7 @@ class ApiUserRepository implements UserRepository { gender: json['gender']?.toString(), proState: _proStateFromInt((json['pro_state'] as num?)?.toInt() ?? 0), token: null, + isPhoneVerified: json['is_phone_verified'] as bool? ?? false, ); } @@ -117,9 +118,7 @@ class ApiUserRepository implements UserRepository { _controller.add(user); } - @override - Future signIn(String email, String password) async { - final data = await _post('/auth/login', {'email': email, 'password': password}); + Future _saveTokenAndEmit(Map data) async { _token = data['access_token'] as String?; if (_token != null) { final prefs = await SharedPreferences.getInstance(); @@ -129,6 +128,12 @@ class ApiUserRepository implements UserRepository { _emit(_current); } + @override + Future signIn(String email, String password) async { + final data = await _post('/auth/login', {'email': email, 'password': password}); + await _saveTokenAndEmit(data); + } + @override Future signUp(MyUser myUser, String password) async { final data = await _post('/auth/register', { @@ -136,13 +141,7 @@ class ApiUserRepository implements UserRepository { 'password': password, 'name': myUser.name ?? myUser.email ?? '', }); - _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); - _emit(_current); + await _saveTokenAndEmit(data); return _current!; } @@ -270,13 +269,7 @@ class ApiUserRepository implements UserRepository { '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); - _emit(_current); + await _saveTokenAndEmit(data); _pendingOtpPhone = null; return true; } catch (_) { @@ -309,4 +302,22 @@ class ApiUserRepository implements UserRepository { Future resetPassword(String email) async { // Not supported by current API — stub } + + Future updateFcmToken(String token) async { + try { + await _post('/users/me/fcm-token', {'token': token}, auth: true); + } catch (_) {} + } + + Future changePassword(String currentPassword, String newPassword) async { + try { + await _post('/auth/change-password', { + 'current_password': currentPassword, + 'new_password': newPassword, + }, auth: true); + return true; + } catch (_) { + return false; + } + } }