diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78bfe18..8ce50c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,26 @@ android:exported="false" android:label="@string/app_name" android:theme="@style/Theme.GridFlow.NoActionBar" /> + + + + \ No newline at end of file diff --git a/app/src/main/java/sh/sar/gridflow/ForgotPasswordActivity.kt b/app/src/main/java/sh/sar/gridflow/ForgotPasswordActivity.kt new file mode 100644 index 0000000..1a06c38 --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/ForgotPasswordActivity.kt @@ -0,0 +1,141 @@ +package sh.sar.gridflow + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import sh.sar.gridflow.databinding.ActivityForgotPasswordBinding +import sh.sar.gridflow.network.ApiResult +import sh.sar.gridflow.network.FenakaApiService + +class ForgotPasswordActivity : AppCompatActivity() { + + private lateinit var binding: ActivityForgotPasswordBinding + private lateinit var apiService: FenakaApiService + + companion object { + private const val TAG = "ForgotPasswordActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Force system theme (follows device dark mode setting) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + Log.d(TAG, "ForgotPasswordActivity onCreate") + + binding = ActivityForgotPasswordBinding.inflate(layoutInflater) + setContentView(binding.root) + + apiService = FenakaApiService() + + setupClickListeners() + } + + private fun setupClickListeners() { + binding.btnContinue.setOnClickListener { + handleContinue() + } + } + + private fun showMobileNotFoundDialog(mobile: String) { + val dialog = androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Mobile Number Not Registered") + .setMessage("The mobile number $mobile is not registered with any account. Would you like to create a new account instead?") + .setPositiveButton("Register") { _, _ -> + // Navigate to registration screen + val intent = Intent(this, RegisterActivity::class.java) + startActivity(intent) + finish() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .setCancelable(true) + .create() + + dialog.show() + } + + private fun handleContinue() { + val mobileNumber = binding.etMobileNumber.text.toString().trim() + + Log.d(TAG, "handleContinue called with mobile: $mobileNumber") + + if (validateInput(mobileNumber)) { + Log.d(TAG, "Input validation passed, sending reset request") + performPasswordReset(mobileNumber) + } else { + Log.d(TAG, "Input validation failed") + } + } + + private fun performPasswordReset(mobile: String) { + setLoading(true) + + lifecycleScope.launch { + try { + when (val result = apiService.forgotPassword(mobile)) { + is ApiResult.Success -> { + Log.d(TAG, "Forgot password successful: ${result.data}") + setLoading(false) + + // Navigate to OTP verification for password reset + val intent = Intent(this@ForgotPasswordActivity, PasswordResetOtpActivity::class.java) + intent.putExtra(PasswordResetOtpActivity.EXTRA_MOBILE_NUMBER, mobile) + startActivity(intent) + finish() + } + is ApiResult.Error -> { + Log.d(TAG, "Forgot password failed: ${result.message} (code: ${result.code})") + setLoading(false) + + if (result.message == "MOBILE_NOT_FOUND") { + showMobileNotFoundDialog(mobile) + } else { + Toast.makeText( + this@ForgotPasswordActivity, + result.message, + Toast.LENGTH_LONG + ).show() + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in password reset", e) + setLoading(false) + Toast.makeText( + this@ForgotPasswordActivity, + "Failed to send reset instructions: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun validateInput(mobileNumber: String): Boolean { + if (mobileNumber.isEmpty()) { + binding.etMobileNumber.error = "Mobile number is required" + return false + } + + // Basic Maldives mobile number validation (7xxxxxx format) + if (!mobileNumber.matches(Regex("^[79]\\d{6}$"))) { + binding.etMobileNumber.error = "Enter a valid Maldives mobile number" + return false + } + + return true + } + + private fun setLoading(isLoading: Boolean) { + binding.btnContinue.isEnabled = !isLoading + binding.btnContinue.text = if (isLoading) "Sending..." else "Continue" + binding.etMobileNumber.isEnabled = !isLoading + } +} diff --git a/app/src/main/java/sh/sar/gridflow/LoginActivity.kt b/app/src/main/java/sh/sar/gridflow/LoginActivity.kt index 8122358..8e74b09 100644 --- a/app/src/main/java/sh/sar/gridflow/LoginActivity.kt +++ b/app/src/main/java/sh/sar/gridflow/LoginActivity.kt @@ -79,6 +79,23 @@ class LoginActivity : AppCompatActivity() { } } + private fun showLoginError(message: String) { + val dialog = androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Login Failed") + .setMessage(message) + .setPositiveButton("Retry") { _, _ -> + // Retry the login with the same credentials + handleSignIn() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .setCancelable(true) + .create() + + dialog.show() + } + private fun handleSignIn() { val mobileNumber = binding.etMobileNumber.text.toString().trim() val password = binding.etPassword.text.toString().trim() @@ -131,13 +148,13 @@ class LoginActivity : AppCompatActivity() { is ApiResult.Error -> { Log.d(TAG, "Login failed: ${result.message} (code: ${result.code})") setLoading(false) - Toast.makeText(this@LoginActivity, result.message, Toast.LENGTH_LONG).show() + showLoginError(result.message) } } } catch (e: Exception) { Log.e(TAG, "Exception in performLogin coroutine", e) setLoading(false) - Toast.makeText(this@LoginActivity, "Login failed: ${e.message}", Toast.LENGTH_LONG).show() + showLoginError("Login failed: ${e.message}") } } } @@ -169,13 +186,15 @@ class LoginActivity : AppCompatActivity() { } private fun handleRegister() { - Toast.makeText(this, "Register functionality coming soon", Toast.LENGTH_SHORT).show() - // TODO: Navigate to registration screen + Log.d(TAG, "Register button clicked") + val intent = Intent(this, RegisterActivity::class.java) + startActivity(intent) } private fun handleForgotPassword() { - Toast.makeText(this, "Forgot password functionality coming soon", Toast.LENGTH_SHORT).show() - // TODO: Navigate to forgot password screen + Log.d(TAG, "Forgot password button clicked") + val intent = Intent(this, ForgotPasswordActivity::class.java) + startActivity(intent) } private fun handlePayWithoutAccount() { diff --git a/app/src/main/java/sh/sar/gridflow/OtpVerificationActivity.kt b/app/src/main/java/sh/sar/gridflow/OtpVerificationActivity.kt new file mode 100644 index 0000000..0cce018 --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/OtpVerificationActivity.kt @@ -0,0 +1,205 @@ +package sh.sar.gridflow + +import android.content.Intent +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import sh.sar.gridflow.databinding.ActivityOtpVerificationBinding +import sh.sar.gridflow.network.ApiResult +import sh.sar.gridflow.network.FenakaApiService + +class OtpVerificationActivity : AppCompatActivity() { + + private lateinit var binding: ActivityOtpVerificationBinding + private lateinit var apiService: FenakaApiService + private var countDownTimer: CountDownTimer? = null + private var mobileNumber: String = "" + private var email: String = "" + private var password: String = "" + + companion object { + private const val TAG = "OtpVerificationActivity" + const val EXTRA_MOBILE_NUMBER = "mobile_number" + const val EXTRA_EMAIL = "email" + const val EXTRA_PASSWORD = "password" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Force system theme (follows device dark mode setting) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + Log.d(TAG, "OtpVerificationActivity onCreate") + + binding = ActivityOtpVerificationBinding.inflate(layoutInflater) + setContentView(binding.root) + + apiService = FenakaApiService() + + // Get data from intent + mobileNumber = intent.getStringExtra(EXTRA_MOBILE_NUMBER) ?: "" + email = intent.getStringExtra(EXTRA_EMAIL) ?: "" + password = intent.getStringExtra(EXTRA_PASSWORD) ?: "" + + setupUI() + setupClickListeners() + startCountdown() + } + + private fun setupUI() { + // Format mobile number for display: (+960) 7697583 + val formattedNumber = "(+960) $mobileNumber" + binding.tvOtpMessage.text = "We have sent an OTP code to your\nnumber $formattedNumber.\nDidn't receive the code?" + } + + private fun setupClickListeners() { + binding.btnContinue.setOnClickListener { + handleContinue() + } + + binding.btnResend.setOnClickListener { + handleResend() + } + } + + private fun handleContinue() { + val otpCode = binding.etOtpCode.text.toString().trim() + + Log.d(TAG, "handleContinue called with OTP: $otpCode") + + if (validateOtp(otpCode)) { + Log.d(TAG, "OTP validation passed, verifying with server") + performOtpVerification(otpCode) + } else { + Log.d(TAG, "OTP validation failed") + } + } + + private fun handleResend() { + Log.d(TAG, "Resend OTP requested - resubmitting registration") + + setLoading(true) + + lifecycleScope.launch { + try { + when (val result = apiService.signup(email, mobileNumber, password)) { + is ApiResult.Success -> { + Log.d(TAG, "Registration resubmitted successfully") + setLoading(false) + Toast.makeText(this@OtpVerificationActivity, "New OTP sent to $mobileNumber", Toast.LENGTH_SHORT).show() + + // Restart countdown + startCountdown() + } + is ApiResult.Error -> { + Log.d(TAG, "Registration resubmission failed: ${result.message}") + setLoading(false) + Toast.makeText( + this@OtpVerificationActivity, + "Failed to resend OTP: ${result.message}", + Toast.LENGTH_LONG + ).show() + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in resend", e) + setLoading(false) + Toast.makeText( + this@OtpVerificationActivity, + "Failed to resend OTP: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun performOtpVerification(otpCode: String) { + setLoading(true) + + // TODO: Implement actual OTP verification API call + // For now, just simulate success + lifecycleScope.launch { + try { + // Simulate API call + kotlinx.coroutines.delay(1500) + + setLoading(false) + Toast.makeText( + this@OtpVerificationActivity, + "Account verified successfully!", + Toast.LENGTH_LONG + ).show() + + // Navigate back to login + val intent = Intent(this@OtpVerificationActivity, LoginActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + finish() + + } catch (e: Exception) { + Log.e(TAG, "Exception in OTP verification", e) + setLoading(false) + Toast.makeText( + this@OtpVerificationActivity, + "Failed to verify OTP: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun validateOtp(otpCode: String): Boolean { + if (otpCode.isEmpty()) { + binding.etOtpCode.error = "OTP code is required" + return false + } + + if (otpCode.length != 6) { + binding.etOtpCode.error = "OTP code must be 6 digits" + return false + } + + if (!otpCode.matches(Regex("^\\d{6}$"))) { + binding.etOtpCode.error = "OTP code must contain only numbers" + return false + } + + return true + } + + private fun startCountdown() { + binding.btnResend.isEnabled = false + binding.btnResend.text = "30" + + countDownTimer?.cancel() + countDownTimer = object : CountDownTimer(30000, 1000) { + override fun onTick(millisUntilFinished: Long) { + val seconds = millisUntilFinished / 1000 + binding.btnResend.text = seconds.toString() + } + + override fun onFinish() { + binding.btnResend.isEnabled = true + binding.btnResend.text = "Resend" + } + }.start() + } + + private fun setLoading(isLoading: Boolean) { + binding.btnContinue.isEnabled = !isLoading + binding.btnContinue.text = if (isLoading) "Verifying..." else "Continue" + binding.etOtpCode.isEnabled = !isLoading + binding.btnResend.isEnabled = !isLoading && binding.btnResend.text == "Resend" + } + + override fun onDestroy() { + super.onDestroy() + countDownTimer?.cancel() + } +} diff --git a/app/src/main/java/sh/sar/gridflow/PasswordResetOtpActivity.kt b/app/src/main/java/sh/sar/gridflow/PasswordResetOtpActivity.kt new file mode 100644 index 0000000..f63e037 --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/PasswordResetOtpActivity.kt @@ -0,0 +1,200 @@ +package sh.sar.gridflow + +import android.content.Intent +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import sh.sar.gridflow.databinding.ActivityPasswordResetOtpBinding +import sh.sar.gridflow.network.ApiResult +import sh.sar.gridflow.network.FenakaApiService + +class PasswordResetOtpActivity : AppCompatActivity() { + + private lateinit var binding: ActivityPasswordResetOtpBinding + private lateinit var apiService: FenakaApiService + private var countDownTimer: CountDownTimer? = null + private var mobileNumber: String = "" + + companion object { + private const val TAG = "PasswordResetOtpActivity" + const val EXTRA_MOBILE_NUMBER = "mobile_number" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Force system theme (follows device dark mode setting) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + Log.d(TAG, "PasswordResetOtpActivity onCreate") + + binding = ActivityPasswordResetOtpBinding.inflate(layoutInflater) + setContentView(binding.root) + + apiService = FenakaApiService() + + // Get mobile number from intent + mobileNumber = intent.getStringExtra(EXTRA_MOBILE_NUMBER) ?: "" + + setupUI() + setupClickListeners() + startCountdown() + } + + private fun setupUI() { + // Format mobile number for display: (+960) 7697583 + val formattedNumber = "(+960) $mobileNumber" + binding.tvOtpMessage.text = "We have sent a password reset OTP to your\nnumber $formattedNumber.\nDidn't receive the code?" + } + + private fun setupClickListeners() { + binding.btnContinue.setOnClickListener { + handleContinue() + } + + binding.btnResend.setOnClickListener { + handleResend() + } + } + + private fun handleContinue() { + val otpCode = binding.etOtpCode.text.toString().trim() + + Log.d(TAG, "handleContinue called with OTP: $otpCode") + + if (validateOtp(otpCode)) { + Log.d(TAG, "OTP validation passed, verifying with server") + performOtpVerification(otpCode) + } else { + Log.d(TAG, "OTP validation failed") + } + } + + private fun handleResend() { + Log.d(TAG, "Resend OTP requested - resubmitting forgot password request") + + setLoading(true) + + lifecycleScope.launch { + try { + when (val result = apiService.forgotPassword(mobileNumber)) { + is ApiResult.Success -> { + Log.d(TAG, "Forgot password resubmitted successfully") + setLoading(false) + Toast.makeText(this@PasswordResetOtpActivity, "New OTP sent to $mobileNumber", Toast.LENGTH_SHORT).show() + + // Restart countdown + startCountdown() + } + is ApiResult.Error -> { + Log.d(TAG, "Forgot password resubmission failed: ${result.message}") + setLoading(false) + Toast.makeText( + this@PasswordResetOtpActivity, + "Failed to resend OTP: ${result.message}", + Toast.LENGTH_LONG + ).show() + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in resend", e) + setLoading(false) + Toast.makeText( + this@PasswordResetOtpActivity, + "Failed to resend OTP: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun performOtpVerification(otpCode: String) { + setLoading(true) + + // TODO: Implement actual OTP verification API call for password reset + // For now, just simulate success + lifecycleScope.launch { + try { + // Simulate API call + kotlinx.coroutines.delay(1500) + + setLoading(false) + Toast.makeText( + this@PasswordResetOtpActivity, + "OTP verified! You can now set a new password.", + Toast.LENGTH_LONG + ).show() + + // Navigate to set new password screen (TODO: implement this screen) + // For now, go back to login + val intent = Intent(this@PasswordResetOtpActivity, LoginActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + finish() + + } catch (e: Exception) { + Log.e(TAG, "Exception in OTP verification", e) + setLoading(false) + Toast.makeText( + this@PasswordResetOtpActivity, + "Failed to verify OTP: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun validateOtp(otpCode: String): Boolean { + if (otpCode.isEmpty()) { + binding.etOtpCode.error = "OTP code is required" + return false + } + + if (otpCode.length != 6) { + binding.etOtpCode.error = "OTP code must be 6 digits" + return false + } + + if (!otpCode.matches(Regex("^\\d{6}$"))) { + binding.etOtpCode.error = "OTP code must contain only numbers" + return false + } + + return true + } + + private fun startCountdown() { + binding.btnResend.isEnabled = false + binding.btnResend.text = "30" + + countDownTimer?.cancel() + countDownTimer = object : CountDownTimer(30000, 1000) { + override fun onTick(millisUntilFinished: Long) { + val seconds = millisUntilFinished / 1000 + binding.btnResend.text = seconds.toString() + } + + override fun onFinish() { + binding.btnResend.isEnabled = true + binding.btnResend.text = "Resend" + } + }.start() + } + + private fun setLoading(isLoading: Boolean) { + binding.btnContinue.isEnabled = !isLoading + binding.btnContinue.text = if (isLoading) "Verifying..." else "Continue" + binding.etOtpCode.isEnabled = !isLoading + binding.btnResend.isEnabled = !isLoading && binding.btnResend.text == "Resend" + } + + override fun onDestroy() { + super.onDestroy() + countDownTimer?.cancel() + } +} diff --git a/app/src/main/java/sh/sar/gridflow/RegisterActivity.kt b/app/src/main/java/sh/sar/gridflow/RegisterActivity.kt new file mode 100644 index 0000000..92c1e85 --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/RegisterActivity.kt @@ -0,0 +1,211 @@ +package sh.sar.gridflow + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import sh.sar.gridflow.databinding.ActivityRegisterBinding +import sh.sar.gridflow.network.ApiResult +import sh.sar.gridflow.network.FenakaApiService + +class RegisterActivity : AppCompatActivity() { + + private lateinit var binding: ActivityRegisterBinding + private lateinit var apiService: FenakaApiService + private var hasEmailError: Boolean = false + + companion object { + private const val TAG = "RegisterActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Force system theme (follows device dark mode setting) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + Log.d(TAG, "RegisterActivity onCreate") + + binding = ActivityRegisterBinding.inflate(layoutInflater) + setContentView(binding.root) + + apiService = FenakaApiService() + + setupClickListeners() + } + + private fun setupClickListeners() { + binding.btnRegister.setOnClickListener { + handleRegister() + } + } + + private fun showMobileExistsDialog(mobile: String) { + val dialog = androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Mobile Number Already Registered") + .setMessage("The mobile number $mobile is already registered with an account. Would you like to reset your password instead?") + .setPositiveButton("Reset Password") { _, _ -> + // Navigate to forgot password screen + val intent = Intent(this, ForgotPasswordActivity::class.java) + startActivity(intent) + finish() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + // Check if we should also show email error + checkForEmailError() + } + .setCancelable(true) + .setOnCancelListener { + // If dialog is cancelled (back button), also check for email error + checkForEmailError() + } + .create() + + dialog.show() + } + + private fun checkForEmailError() { + // If we detected an email error along with mobile error, show it now + if (hasEmailError) { + binding.etEmail.error = "Email address is also already registered" + binding.etEmail.requestFocus() + hasEmailError = false // Reset the flag + } + } + + private fun handleValidationErrors(errorMessage: String) { + // For other validation errors, show a toast for now + // In the future, you could parse more specific field errors here + Toast.makeText( + this, + "Validation error: $errorMessage", + Toast.LENGTH_LONG + ).show() + } + + private fun handleRegister() { + val email = binding.etEmail.text.toString().trim() + val mobileNumber = binding.etMobileNumber.text.toString().trim() + val password = binding.etPassword.text.toString().trim() + + Log.d(TAG, "handleRegister called for: $email, $mobileNumber") + + if (validateInput(email, mobileNumber, password)) { + Log.d(TAG, "Input validation passed, creating account") + performRegistration(email, mobileNumber, password) + } else { + Log.d(TAG, "Input validation failed") + } + } + + private fun performRegistration(email: String, mobile: String, password: String) { + setLoading(true) + + lifecycleScope.launch { + try { + when (val result = apiService.signup(email, mobile, password)) { + is ApiResult.Success -> { + Log.d(TAG, "Signup successful: ${result.data}") + setLoading(false) + + // Navigate to OTP verification + val intent = Intent(this@RegisterActivity, OtpVerificationActivity::class.java) + intent.putExtra(OtpVerificationActivity.EXTRA_MOBILE_NUMBER, mobile) + intent.putExtra(OtpVerificationActivity.EXTRA_EMAIL, email) + intent.putExtra(OtpVerificationActivity.EXTRA_PASSWORD, password) + startActivity(intent) + finish() + } + is ApiResult.Error -> { + Log.d(TAG, "Signup failed: ${result.message} (code: ${result.code})") + setLoading(false) + + if (result.message == "MOBILE_ALREADY_EXISTS") { + showMobileExistsDialog(mobile) + } else if (result.message == "MOBILE_AND_EMAIL_EXIST") { + hasEmailError = true + showMobileExistsDialog(mobile) + } else if (result.message == "EMAIL_ALREADY_EXISTS") { + // Show error directly on email field + binding.etEmail.error = "Email address is already registered" + binding.etEmail.requestFocus() + } else { + // Check if it's a validation error we can parse + if (result.code == 422) { + handleValidationErrors(result.message) + } else { + Toast.makeText( + this@RegisterActivity, + result.message, + Toast.LENGTH_LONG + ).show() + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in registration", e) + setLoading(false) + Toast.makeText( + this@RegisterActivity, + "Failed to create account: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + + private fun validateInput( + email: String, + mobileNumber: String, + password: String + ): Boolean { + + if (email.isEmpty()) { + binding.etEmail.error = "Email is required" + return false + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + binding.etEmail.error = "Enter a valid email address" + return false + } + + if (mobileNumber.isEmpty()) { + binding.etMobileNumber.error = "Mobile number is required" + return false + } + + // Basic Maldives mobile number validation (7xxxxxx format) + if (!mobileNumber.matches(Regex("^[79]\\d{6}$"))) { + binding.etMobileNumber.error = "Enter a valid Maldives mobile number" + return false + } + + if (password.isEmpty()) { + binding.etPassword.error = "Password is required" + return false + } + + if (password.length < 8) { + binding.etPassword.error = "Password must be at least 8 characters" + return false + } + + return true + } + + private fun setLoading(isLoading: Boolean) { + binding.btnRegister.isEnabled = !isLoading + binding.btnRegister.text = if (isLoading) "Creating Account..." else "Sign up" + + binding.etEmail.isEnabled = !isLoading + binding.etMobileNumber.isEnabled = !isLoading + binding.etPassword.isEnabled = !isLoading + } +} diff --git a/app/src/main/java/sh/sar/gridflow/data/Models.kt b/app/src/main/java/sh/sar/gridflow/data/Models.kt index d2b3839..b0b3560 100644 --- a/app/src/main/java/sh/sar/gridflow/data/Models.kt +++ b/app/src/main/java/sh/sar/gridflow/data/Models.kt @@ -5,6 +5,16 @@ data class LoginRequest( val password: String ) +data class SignupRequest( + val email: String, + val mobile: String, + val password: String +) + +data class ForgotPasswordRequest( + val mobile: String +) + data class LoginResponse( val id: Int, val name: String, @@ -15,6 +25,15 @@ data class LoginResponse( val deletedAt: String? ) +data class SignupResponse( + val email: String, + val mobile: String +) + +data class ForgotPasswordResponse( + val mobile: String +) + data class ErrorResponse( val errors: List? = null, val error: String? = null diff --git a/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt b/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt index 8131157..ae0eae7 100644 --- a/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt +++ b/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt @@ -9,8 +9,12 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.logging.HttpLoggingInterceptor import sh.sar.gridflow.data.ErrorResponse +import sh.sar.gridflow.data.ForgotPasswordRequest +import sh.sar.gridflow.data.ForgotPasswordResponse import sh.sar.gridflow.data.LoginRequest import sh.sar.gridflow.data.LoginResponse +import sh.sar.gridflow.data.SignupRequest +import sh.sar.gridflow.data.SignupResponse import java.io.IOException class FenakaApiService { @@ -92,6 +96,131 @@ class FenakaApiService { } } + suspend fun forgotPassword(mobile: String): ApiResult = withContext(Dispatchers.IO) { + Log.d(TAG, "Attempting forgot password for mobile: $mobile") + + val forgotPasswordRequest = ForgotPasswordRequest(mobile) + val requestBody = gson.toJson(forgotPasswordRequest).toRequestBody(JSON_MEDIA_TYPE.toMediaType()) + + Log.d(TAG, "Forgot password request body: ${gson.toJson(forgotPasswordRequest)}") + + val request = Request.Builder() + .url("$BASE_URL/auth/password-reset/request") + .post(requestBody) + .header("Authorization", "Bearer $BEARER_TOKEN") + .header("Content-Type", JSON_MEDIA_TYPE) + .header("Host", "api.fenaka.mv") + .header("User-Agent", "Dart/3.5 (dart:io)") + .build() + + Log.d(TAG, "Making forgot password request to: ${request.url}") + + try { + val response = client.newCall(request).execute() + Log.d(TAG, "Forgot password response code: ${response.code}") + Log.d(TAG, "Forgot password response headers: ${response.headers}") + + val responseBody = response.body?.string() + Log.d(TAG, "Forgot password response body: $responseBody") + + when (response.code) { + 200 -> { + val forgotPasswordResponse = gson.fromJson(responseBody, ForgotPasswordResponse::class.java) + Log.d(TAG, "Forgot password successful for: ${forgotPasswordResponse.mobile}") + ApiResult.Success(forgotPasswordResponse, null) + } + 404 -> { + Log.d(TAG, "Forgot password failed: 404 Mobile number not found") + ApiResult.Error("MOBILE_NOT_FOUND", 404) + } + else -> { + Log.d(TAG, "Forgot password failed: Unknown error ${response.code}") + ApiResult.Error("Unknown error occurred", response.code) + } + } + } catch (e: IOException) { + Log.e(TAG, "Network error during forgot password", e) + ApiResult.Error("Network error: ${e.message}", -1) + } catch (e: Exception) { + Log.e(TAG, "Unexpected error during forgot password", e) + ApiResult.Error("Unexpected error: ${e.message}", -1) + } + } + + suspend fun signup(email: String, mobile: String, password: String): ApiResult = withContext(Dispatchers.IO) { + Log.d(TAG, "Attempting signup for mobile: $mobile, email: $email") + + val signupRequest = SignupRequest(email, mobile, password) + val requestBody = gson.toJson(signupRequest).toRequestBody(JSON_MEDIA_TYPE.toMediaType()) + + Log.d(TAG, "Signup request body: ${gson.toJson(signupRequest)}") + + val request = Request.Builder() + .url("$BASE_URL/auth/signup") + .post(requestBody) + .header("Authorization", "Bearer $BEARER_TOKEN") + .header("Content-Type", JSON_MEDIA_TYPE) + .header("Host", "api.fenaka.mv") + .header("User-Agent", "Dart/3.5 (dart:io)") + .build() + + Log.d(TAG, "Making signup request to: ${request.url}") + + try { + val response = client.newCall(request).execute() + Log.d(TAG, "Signup response code: ${response.code}") + Log.d(TAG, "Signup response headers: ${response.headers}") + + val responseBody = response.body?.string() + Log.d(TAG, "Signup response body: $responseBody") + + when (response.code) { + 200 -> { + val signupResponse = gson.fromJson(responseBody, SignupResponse::class.java) + Log.d(TAG, "Signup successful for: ${signupResponse.email}, ${signupResponse.mobile}") + ApiResult.Success(signupResponse, null) + } + 422 -> { + val errorResponse = gson.fromJson(responseBody, ErrorResponse::class.java) + Log.d(TAG, "Signup failed: 422 Validation errors - ${errorResponse.errors}") + + // Check for multiple errors and prioritize mobile over email + val mobileError = errorResponse.errors?.find { it.param == "mobile" && it.msg?.contains("already exists", ignoreCase = true) == true } + val emailError = errorResponse.errors?.find { it.param == "email" && it.msg?.contains("already exists", ignoreCase = true) == true } + + when { + mobileError != null -> { + // Mobile already exists takes priority (used for login) + // Pass additional info about email error if it exists + val message = if (emailError != null) "MOBILE_AND_EMAIL_EXIST" else "MOBILE_ALREADY_EXISTS" + ApiResult.Error(message, 422) + } + emailError != null -> { + // Email already exists (only if mobile is not duplicate) + ApiResult.Error("EMAIL_ALREADY_EXISTS", 422) + } + else -> { + // Other validation errors + val firstError = errorResponse.errors?.firstOrNull() + val errorMessage = firstError?.msg ?: "Validation error" + ApiResult.Error(errorMessage, 422) + } + } + } + else -> { + Log.d(TAG, "Signup failed: Unknown error ${response.code}") + ApiResult.Error("Unknown error occurred", response.code) + } + } + } catch (e: IOException) { + Log.e(TAG, "Network error during signup", e) + ApiResult.Error("Network error: ${e.message}", -1) + } catch (e: Exception) { + Log.e(TAG, "Unexpected error during signup", e) + ApiResult.Error("Unexpected error: ${e.message}", -1) + } + } + suspend fun getOutstandingBills(cookie: String): ApiResult> = withContext(Dispatchers.IO) { val request = Request.Builder() .url("$BASE_URL/saiph/subscriptions/summaries/outstanding") diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..07756e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_forgot_password.xml b/app/src/main/res/layout/activity_forgot_password.xml new file mode 100644 index 0000000..ab98305 --- /dev/null +++ b/app/src/main/res/layout/activity_forgot_password.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_otp_verification.xml b/app/src/main/res/layout/activity_otp_verification.xml new file mode 100644 index 0000000..4aa713b --- /dev/null +++ b/app/src/main/res/layout/activity_otp_verification.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_password_reset_otp.xml b/app/src/main/res/layout/activity_password_reset_otp.xml new file mode 100644 index 0000000..dbffec2 --- /dev/null +++ b/app/src/main/res/layout/activity_password_reset_otp.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_register.xml b/app/src/main/res/layout/activity_register.xml new file mode 100644 index 0000000..b9a855b --- /dev/null +++ b/app/src/main/res/layout/activity_register.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +