diff --git a/lib/screens/activation_screen.dart b/lib/screens/activation_screen.dart index 0f86df5..cf06293 100644 --- a/lib/screens/activation_screen.dart +++ b/lib/screens/activation_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:async'; import '../services/odoo_service.dart'; import '../services/config.dart'; import '../theme/app_theme.dart'; @@ -13,12 +14,29 @@ class ActivationScreen extends StatefulWidget { class _ActivationScreenState extends State { final _phoneController = TextEditingController(); + final _emailController = TextEditingController(); + final _otpController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); DateTime? _selectedDate; bool _agreedToTerms = false; bool _isLoading = false; + bool _sendingOtp = false; + bool _otpSent = false; + int _countdown = 0; + Timer? _timer; + + @override + void dispose() { + _phoneController.dispose(); + _emailController.dispose(); + _otpController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _timer?.cancel(); + super.dispose(); + } void _selectBirthDate() async { final DateTime? picked = await showDatePicker( @@ -51,8 +69,83 @@ class _ActivationScreenState extends State { } } + void _sendVerificationCode() async { + final phone = _phoneController.text.trim(); + final email = _emailController.text.trim(); + + if (phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter your phone number first.')), + ); + return; + } + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid email address.')), + ); + return; + } + + setState(() => _sendingOtp = true); + + try { + final service = OdooService(); + service.connect(AppConfig.odooUrl); + + final response = await service.sendOtp( + email: email, + phone: phone, + type: 'activation', + ); + + if (response != null && response['status'] == 'success') { + setState(() { + _otpSent = true; + _countdown = 60; + }); + _startTimer(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'] ?? 'Verification code sent!')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response?['message'] ?? 'Failed to send verification code.')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _sendingOtp = false); + } + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown == 0) { + timer.cancel(); + } else { + setState(() { + _countdown--; + }); + } + }); + } + void _activate() async { final phone = _phoneController.text.trim(); + final email = _emailController.text.trim(); + final otp = _otpController.text.trim(); final password = _passwordController.text; final confirmPassword = _confirmPasswordController.text; @@ -68,6 +161,18 @@ class _ActivationScreenState extends State { ); return; } + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid email address.')), + ); + return; + } + if (otp.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter the verification code (OTP).')), + ); + return; + } if (password.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please enter your password.')), @@ -96,7 +201,13 @@ class _ActivationScreenState extends State { // Ensure connected (will use same URL as login) service.connect(AppConfig.odooUrl); - final response = await service.activateAccount(phone, formattedDate, password); + final response = await service.activateAccount( + phone: phone, + email: email, + birthDate: formattedDate, + password: password, + otp: otp, + ); if (response != null && response['status'] == 'success') { if (mounted) { @@ -185,6 +296,58 @@ class _ActivationScreenState extends State { ), ), const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email Address', + hintText: 'yourname@example.com', + ), + ), + ), + const SizedBox(width: 12), + _sendingOtp + ? const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ) + : Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: ElevatedButton( + onPressed: _countdown > 0 ? null : _sendVerificationCode, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + minimumSize: Size.zero, + ), + child: Text( + _countdown > 0 ? '${_countdown}s' : (_otpSent ? 'Resend' : 'Send OTP'), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + if (_otpSent) ...[ + const SizedBox(height: 20), + TextField( + controller: _otpController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Verification Code (OTP)', + hintText: 'Enter 6-digit code', + prefixIcon: Icon(Icons.security), + ), + ), + ], + const SizedBox(height: 20), TextField( controller: _passwordController, diff --git a/lib/screens/forgot_password_screen.dart b/lib/screens/forgot_password_screen.dart new file mode 100644 index 0000000..15c2138 --- /dev/null +++ b/lib/screens/forgot_password_screen.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; +import '../services/odoo_service.dart'; +import '../services/config.dart'; +import '../theme/app_theme.dart'; + +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _phoneOrEmailController = TextEditingController(); + final _otpController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isLoading = false; + bool _sendingOtp = false; + bool _otpSent = false; + int _countdown = 0; + Timer? _timer; + String? _targetEmail; // The email returned by Odoo where OTP was sent + + @override + void dispose() { + _phoneOrEmailController.dispose(); + _otpController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _timer?.cancel(); + super.dispose(); + } + + void _sendResetCode() async { + final phoneOrEmail = _phoneOrEmailController.text.trim(); + + if (phoneOrEmail.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter your phone number or email.')), + ); + return; + } + + setState(() => _sendingOtp = true); + + try { + final service = OdooService(); + service.connect(AppConfig.odooUrl); + + final response = await service.sendOtp( + phoneOrEmail: phoneOrEmail, + type: 'reset_password', + ); + + if (response != null && response['status'] == 'success') { + setState(() { + _otpSent = true; + _countdown = 60; + _targetEmail = response['email']; + }); + _startTimer(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'] ?? 'Verification code sent!')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response?['message'] ?? 'Failed to send reset code.')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _sendingOtp = false); + } + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown == 0) { + timer.cancel(); + } else { + setState(() { + _countdown--; + }); + } + }); + } + + void _resetPassword() async { + final phoneOrEmail = _phoneOrEmailController.text.trim(); + final otp = _otpController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + + if (phoneOrEmail.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter your phone number or email.')), + ); + return; + } + if (otp.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter the verification code (OTP).')), + ); + return; + } + if (password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a new password.')), + ); + return; + } + if (password != confirmPassword) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Passwords do not match.')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final service = OdooService(); + service.connect(AppConfig.odooUrl); + + final response = await service.resetPassword( + phoneOrEmail: phoneOrEmail, + otp: otp, + password: password, + ); + + if (response != null && response['status'] == 'success') { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'] ?? 'Password reset successfully!')), + ); + Navigator.of(context).pop(); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response?['message'] ?? 'Reset failed.')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Reset Password'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Forgot Password?', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppTheme.onSurface, + fontSize: 28, + ), + ), + const SizedBox(height: 8), + Text( + 'Enter your phone number or email address. We will send a verification code to your registered email to reset your password.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 36), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _phoneOrEmailController, + keyboardType: TextInputType.text, + decoration: const InputDecoration( + labelText: 'Phone Number or Email', + hintText: 'e.g. 08123456789 or name@domain.com', + ), + enabled: !_otpSent, + ), + ), + if (!_otpSent) ...[ + const SizedBox(width: 12), + _sendingOtp + ? const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ) + : Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: ElevatedButton( + onPressed: _sendResetCode, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + minimumSize: Size.zero, + ), + child: const Text( + 'Send OTP', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ], + ), + if (_otpSent) ...[ + const SizedBox(height: 20), + if (_targetEmail != null) ...[ + Text( + 'OTP has been sent to your registered email address.', + style: TextStyle( + color: AppTheme.secondary.withOpacity(0.8), + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 12), + ], + TextField( + controller: _otpController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Verification Code (OTP)', + hintText: 'Enter 6-digit code', + prefixIcon: Icon(Icons.security), + ), + ), + const SizedBox(height: 20), + TextField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'New Password', + ), + obscureText: true, + ), + const SizedBox(height: 20), + TextField( + controller: _confirmPasswordController, + decoration: const InputDecoration( + labelText: 'Confirm New Password', + ), + obscureText: true, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _sendingOtp ? null : _sendResetCode, + child: Text( + _countdown > 0 ? 'Resend code in ${_countdown}s' : 'Resend Verification Code', + style: const TextStyle(color: AppTheme.secondary), + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 56, + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + onPressed: _resetPassword, + child: const Text( + 'Reset Password', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 131d739..26dfacc 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -8,6 +8,7 @@ import 'loyalty_dashboard.dart'; import 'branches_screen.dart'; import 'activation_screen.dart'; import 'signup_screen.dart'; +import 'forgot_password_screen.dart'; import '../theme/app_theme.dart'; class LoginScreen extends StatefulWidget { @@ -113,7 +114,29 @@ class _LoginScreenState extends State { decoration: const InputDecoration(labelText: 'Password'), obscureText: true, ), - const SizedBox(height: 48), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen(), + ), + ), + style: TextButton.styleFrom( + foregroundColor: AppTheme.onSurfaceVariant, + padding: EdgeInsets.zero, + ), + child: const Text( + 'Forgot Password?', + style: TextStyle( + fontSize: 13, + ), + ), + ), + ), + const SizedBox(height: 24), SizedBox( height: 56, child: _isLoading diff --git a/lib/screens/signup_screen.dart b/lib/screens/signup_screen.dart index 82d6c3d..bfffa21 100644 --- a/lib/screens/signup_screen.dart +++ b/lib/screens/signup_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:async'; import '../services/odoo_service.dart'; import '../services/config.dart'; import '../theme/app_theme.dart'; @@ -14,6 +15,8 @@ class SignupScreen extends StatefulWidget { class _SignupScreenState extends State { final _nameController = TextEditingController(); final _phoneController = TextEditingController(); + final _emailController = TextEditingController(); + final _otpController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); @@ -21,9 +24,25 @@ class _SignupScreenState extends State { String? _selectedGender; bool _agreedToTerms = false; bool _isLoading = false; + bool _sendingOtp = false; + bool _otpSent = false; + int _countdown = 0; + Timer? _timer; final List _genderOptions = ['Male', 'Female']; + @override + void dispose() { + _nameController.dispose(); + _phoneController.dispose(); + _emailController.dispose(); + _otpController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _timer?.cancel(); + super.dispose(); + } + void _selectBirthDate() async { final DateTime? picked = await showDatePicker( context: context, @@ -55,9 +74,84 @@ class _SignupScreenState extends State { } } + void _sendVerificationCode() async { + final phone = _phoneController.text.trim(); + final email = _emailController.text.trim(); + + if (phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter your phone number first.')), + ); + return; + } + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid email address.')), + ); + return; + } + + setState(() => _sendingOtp = true); + + try { + final service = OdooService(); + service.connect(AppConfig.odooUrl); + + final response = await service.sendOtp( + email: email, + phone: phone, + type: 'signup', + ); + + if (response != null && response['status'] == 'success') { + setState(() { + _otpSent = true; + _countdown = 60; + }); + _startTimer(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'] ?? 'Verification code sent!')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response?['message'] ?? 'Failed to send verification code.')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _sendingOtp = false); + } + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown == 0) { + timer.cancel(); + } else { + setState(() { + _countdown--; + }); + } + }); + } + void _signUp() async { final name = _nameController.text.trim(); final phone = _phoneController.text.trim(); + final email = _emailController.text.trim(); + final otp = _otpController.text.trim(); final password = _passwordController.text; final confirmPassword = _confirmPasswordController.text; @@ -73,6 +167,18 @@ class _SignupScreenState extends State { ); return; } + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid email address.')), + ); + return; + } + if (otp.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter the verification code (OTP).')), + ); + return; + } if (_selectedDate == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please select your birth date.')), @@ -113,11 +219,13 @@ class _SignupScreenState extends State { service.connect(AppConfig.odooUrl); final response = await service.signUpMember( - name, - phone, - formattedDate, - _selectedGender!, - password, + name: name, + phone: phone, + email: email, + birthDate: formattedDate, + gender: _selectedGender!, + password: password, + otp: otp, ); if (response != null && response['status'] == 'success') { @@ -190,6 +298,58 @@ class _SignupScreenState extends State { ), ), const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email Address', + hintText: 'yourname@example.com', + ), + ), + ), + const SizedBox(width: 12), + _sendingOtp + ? const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ) + : Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: ElevatedButton( + onPressed: _countdown > 0 ? null : _sendVerificationCode, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + minimumSize: Size.zero, + ), + child: Text( + _countdown > 0 ? '${_countdown}s' : (_otpSent ? 'Resend' : 'Send OTP'), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + if (_otpSent) ...[ + const SizedBox(height: 20), + TextField( + controller: _otpController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Verification Code (OTP)', + hintText: 'Enter 6-digit code', + prefixIcon: Icon(Icons.security), + ), + ), + ], + const SizedBox(height: 20), InkWell( onTap: _selectBirthDate, diff --git a/lib/services/config.dart b/lib/services/config.dart index 133ca6e..e4715c4 100644 --- a/lib/services/config.dart +++ b/lib/services/config.dart @@ -1,4 +1,5 @@ class AppConfig { - static const String odooUrl = 'http://localhost:8069'; // Default local dev url - static const String odooDb = 'mapangroup_o19_11'; // Default local dev db + static const String odooUrl = + 'https://odoodev.mapan.co.id'; // Default local dev url + static const String odooDb = 'mapangroup_o19_260605'; // Default local dev db } diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index d1a801b..43c0c38 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -30,13 +30,34 @@ class OdooService { }) as List; } - Future signUpMember( - String name, - String phone, - String birthDate, - String gender, - String password, - ) async { + Future sendOtp({ + String? email, + String? phone, + String? phoneOrEmail, + required String type, + }) async { + if (client == null) throw Exception("Connect to Odoo first"); + return await client!.callRPC( + '/api/loyalty/send_otp', + 'call', + { + if (email != null) 'email': email, + if (phone != null) 'phone': phone, + if (phoneOrEmail != null) 'phone_or_email': phoneOrEmail, + 'type': type, + }, + ); + } + + Future signUpMember({ + required String name, + required String phone, + required String email, + required String birthDate, + required String gender, + required String password, + required String otp, + }) async { if (client == null) throw Exception("Connect to Odoo first"); return await client!.callRPC( '/api/loyalty/signup_member', @@ -44,26 +65,49 @@ class OdooService { { 'name': name, 'phone': phone, + 'email': email, 'birth_date': birthDate, 'gender': gender, 'password': password, + 'otp': otp, }, ); } - Future activateAccount( - String phone, - String birthDate, - String password, - ) async { + Future activateAccount({ + required String phone, + required String email, + required String birthDate, + required String password, + required String otp, + }) async { if (client == null) throw Exception("Connect to Odoo first"); return await client!.callRPC( '/api/loyalty/activate_account', 'call', { 'phone': phone, + 'email': email, 'birth_date': birthDate, 'password': password, + 'otp': otp, + }, + ); + } + + Future resetPassword({ + required String phoneOrEmail, + required String otp, + required String password, + }) async { + if (client == null) throw Exception("Connect to Odoo first"); + return await client!.callRPC( + '/api/loyalty/reset_password', + 'call', + { + 'phone_or_email': phoneOrEmail, + 'otp': otp, + 'password': password, }, ); }