forgot password and registration pages... cant fully test because fenaka sms is not working

This commit is contained in:
2025-07-24 18:25:11 +05:00
parent f13211fbd2
commit 2918cd79d0
13 changed files with 1418 additions and 6 deletions

View File

@@ -35,6 +35,26 @@
android:exported="false" android:exported="false"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.GridFlow.NoActionBar" /> android:theme="@style/Theme.GridFlow.NoActionBar" />
<activity
android:name=".ForgotPasswordActivity"
android:exported="false"
android:label="Reset Password"
android:theme="@style/Theme.GridFlow.NoActionBar" />
<activity
android:name=".RegisterActivity"
android:exported="false"
android:label="Create Account"
android:theme="@style/Theme.GridFlow.NoActionBar" />
<activity
android:name=".OtpVerificationActivity"
android:exported="false"
android:label="Verify OTP"
android:theme="@style/Theme.GridFlow.NoActionBar" />
<activity
android:name=".PasswordResetOtpActivity"
android:exported="false"
android:label="Reset Password OTP"
android:theme="@style/Theme.GridFlow.NoActionBar" />
</application> </application>
</manifest> </manifest>

View File

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

View File

@@ -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() { private fun handleSignIn() {
val mobileNumber = binding.etMobileNumber.text.toString().trim() val mobileNumber = binding.etMobileNumber.text.toString().trim()
val password = binding.etPassword.text.toString().trim() val password = binding.etPassword.text.toString().trim()
@@ -131,13 +148,13 @@ class LoginActivity : AppCompatActivity() {
is ApiResult.Error -> { is ApiResult.Error -> {
Log.d(TAG, "Login failed: ${result.message} (code: ${result.code})") Log.d(TAG, "Login failed: ${result.message} (code: ${result.code})")
setLoading(false) setLoading(false)
Toast.makeText(this@LoginActivity, result.message, Toast.LENGTH_LONG).show() showLoginError(result.message)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Exception in performLogin coroutine", e) Log.e(TAG, "Exception in performLogin coroutine", e)
setLoading(false) 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() { private fun handleRegister() {
Toast.makeText(this, "Register functionality coming soon", Toast.LENGTH_SHORT).show() Log.d(TAG, "Register button clicked")
// TODO: Navigate to registration screen val intent = Intent(this, RegisterActivity::class.java)
startActivity(intent)
} }
private fun handleForgotPassword() { private fun handleForgotPassword() {
Toast.makeText(this, "Forgot password functionality coming soon", Toast.LENGTH_SHORT).show() Log.d(TAG, "Forgot password button clicked")
// TODO: Navigate to forgot password screen val intent = Intent(this, ForgotPasswordActivity::class.java)
startActivity(intent)
} }
private fun handlePayWithoutAccount() { private fun handlePayWithoutAccount() {

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

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

View File

@@ -5,6 +5,16 @@ data class LoginRequest(
val password: String val password: String
) )
data class SignupRequest(
val email: String,
val mobile: String,
val password: String
)
data class ForgotPasswordRequest(
val mobile: String
)
data class LoginResponse( data class LoginResponse(
val id: Int, val id: Int,
val name: String, val name: String,
@@ -15,6 +25,15 @@ data class LoginResponse(
val deletedAt: String? val deletedAt: String?
) )
data class SignupResponse(
val email: String,
val mobile: String
)
data class ForgotPasswordResponse(
val mobile: String
)
data class ErrorResponse( data class ErrorResponse(
val errors: List<ValidationError>? = null, val errors: List<ValidationError>? = null,
val error: String? = null val error: String? = null

View File

@@ -9,8 +9,12 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import sh.sar.gridflow.data.ErrorResponse 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.LoginRequest
import sh.sar.gridflow.data.LoginResponse import sh.sar.gridflow.data.LoginResponse
import sh.sar.gridflow.data.SignupRequest
import sh.sar.gridflow.data.SignupResponse
import java.io.IOException import java.io.IOException
class FenakaApiService { class FenakaApiService {
@@ -92,6 +96,131 @@ class FenakaApiService {
} }
} }
suspend fun forgotPassword(mobile: String): ApiResult<ForgotPasswordResponse> = 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<SignupResponse> = 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<List<Any>> = withContext(Dispatchers.IO) { suspend fun getOutstandingBills(cookie: String): ApiResult<List<Any>> = withContext(Dispatchers.IO) {
val request = Request.Builder() val request = Request.Builder()
.url("$BASE_URL/saiph/subscriptions/summaries/outstanding") .url("$BASE_URL/saiph/subscriptions/summaries/outstanding")

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<!-- Logo and Title Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<ImageView
android:id="@+id/iv_app_logo"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="24dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="GridFlow Logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Reset password"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter your phone number"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Form Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="32dp">
<!-- Mobile Number Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="Mobile number"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_mobile_number"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:maxLength="7"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Continue Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_continue"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="Continue"
android:textSize="16sp"
android:textAllCaps="false"
app:cornerRadius="8dp"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Spacer for bottom -->
<View
android:layout_width="match_parent"
android:layout_height="32dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<!-- Logo and Title Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<ImageView
android:id="@+id/iv_app_logo"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="32dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="GridFlow Logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter OTP code"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="24dp"
android:gravity="center" />
<TextView
android:id="@+id/tv_otp_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="We have sent an OTP code to your\nnumber (+960) 7697583.\nDidn't receive the code?"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<!-- Countdown/Resend Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_resend"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="30"
android:textSize="14sp"
android:textAllCaps="false"
android:enabled="false"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- OTP Form Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- OTP Code Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="OTP Code"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_otp_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="6"
android:textSize="18sp"
android:gravity="center"
android:letterSpacing="0.5" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Continue Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_continue"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="Continue"
android:textSize="16sp"
android:textAllCaps="false"
app:cornerRadius="8dp"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Spacer for bottom -->
<View
android:layout_width="match_parent"
android:layout_height="32dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<!-- Logo and Title Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<ImageView
android:id="@+id/iv_app_logo"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="32dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="GridFlow Logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter OTP code"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="24dp"
android:gravity="center" />
<TextView
android:id="@+id/tv_otp_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="We have sent a password reset OTP to your\nnumber (+960) 7697583.\nDidn't receive the code?"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<!-- Countdown/Resend Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_resend"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="30"
android:textSize="14sp"
android:textAllCaps="false"
android:enabled="false"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- OTP Form Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- OTP Code Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="OTP Code"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_otp_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="6"
android:textSize="18sp"
android:gravity="center"
android:letterSpacing="0.5" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Continue Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_continue"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="Continue"
android:textSize="16sp"
android:textAllCaps="false"
app:cornerRadius="8dp"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Spacer for bottom -->
<View
android:layout_width="match_parent"
android:layout_height="32dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<!-- Logo and Title Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<ImageView
android:id="@+id/iv_app_logo"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="24dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="GridFlow Logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sign Up"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Registration Form -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="32dp">
<!-- Email Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="Email"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Mobile Number Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="Mobile number"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_mobile_number"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:maxLength="7"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="Password"
app:boxStrokeColor="@color/design_default_color_primary"
app:hintTextColor="@color/design_default_color_primary"
app:passwordToggleEnabled="true"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Sign Up Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_register"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="Sign up"
android:textSize="16sp"
android:textAllCaps="false"
app:cornerRadius="8dp"
android:layout_marginBottom="32dp" />
</LinearLayout>
<!-- Spacer for bottom -->
<View
android:layout_width="match_parent"
android:layout_height="32dp" />
</LinearLayout>
</ScrollView>