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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+