login works

This commit is contained in:
2025-07-24 16:25:58 +05:00
parent 039fcd690f
commit cf4e2307b5
12 changed files with 623 additions and 30 deletions

View File

@@ -48,6 +48,10 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx) implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.gson)
implementation(libs.security.crypto)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,7 +12,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.GridFlow"> android:theme="@style/Theme.GridFlow"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".LoginActivity" android:name=".LoginActivity"
android:exported="true" android:exported="true"
@@ -22,6 +25,11 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".DebugLoginActivity"
android:exported="false"
android:label="@string/app_name"
android:theme="@style/Theme.GridFlow.NoActionBar" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="false" android:exported="false"

View File

@@ -0,0 +1,69 @@
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.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import sh.sar.gridflow.databinding.ActivityLoginBinding
import sh.sar.gridflow.network.ApiResult
import sh.sar.gridflow.network.FenakaApiService
class DebugLoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var apiService: FenakaApiService
companion object {
private const val TAG = "DebugLoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "DebugLoginActivity onCreate")
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
apiService = FenakaApiService()
// Simple test button
binding.btnSignIn.setOnClickListener {
testLogin()
}
}
private fun testLogin() {
Log.d(TAG, "Testing login with hardcoded values")
binding.btnSignIn.text = "Testing..."
binding.btnSignIn.isEnabled = false
lifecycleScope.launch {
try {
Log.d(TAG, "Making API call...")
val result = apiService.login("9999999", "testpassword123")
when (result) {
is ApiResult.Success -> {
Log.d(TAG, "Success: ${result.data}")
Toast.makeText(this@DebugLoginActivity, "Success!", Toast.LENGTH_LONG).show()
}
is ApiResult.Error -> {
Log.d(TAG, "Error: ${result.message}")
Toast.makeText(this@DebugLoginActivity, "Error: ${result.message}", Toast.LENGTH_LONG).show()
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception in test", e)
Toast.makeText(this@DebugLoginActivity, "Exception: ${e.message}", Toast.LENGTH_LONG).show()
}
binding.btnSignIn.text = "Sign In"
binding.btnSignIn.isEnabled = true
}
}
}

View File

@@ -2,20 +2,58 @@ package sh.sar.gridflow
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import sh.sar.gridflow.databinding.ActivityLoginBinding import sh.sar.gridflow.databinding.ActivityLoginBinding
import sh.sar.gridflow.network.ApiResult
import sh.sar.gridflow.network.FenakaApiService
import sh.sar.gridflow.utils.SecureStorage
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding private lateinit var binding: ActivityLoginBinding
private lateinit var secureStorage: SecureStorage
private lateinit var apiService: FenakaApiService
companion object {
private const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "LoginActivity onCreate")
binding = ActivityLoginBinding.inflate(layoutInflater) binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
try {
secureStorage = SecureStorage(this)
apiService = FenakaApiService()
// Check if already logged in
if (secureStorage.isLoggedIn()) {
Log.d(TAG, "User already logged in, navigating to main")
navigateToMain()
return
}
// Pre-fill saved credentials
secureStorage.getMobile()?.let { mobile ->
Log.d(TAG, "Pre-filling mobile number: $mobile")
binding.etMobileNumber.setText(mobile)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize SecureStorage, continuing without it", e)
apiService = FenakaApiService()
Toast.makeText(this, "Warning: Secure storage not available", Toast.LENGTH_SHORT).show()
}
setupClickListeners() setupClickListeners()
} }
@@ -41,17 +79,91 @@ class LoginActivity : AppCompatActivity() {
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()
Log.d(TAG, "handleSignIn called with mobile: $mobileNumber")
if (validateInput(mobileNumber, password)) { if (validateInput(mobileNumber, password)) {
// TODO: Implement actual authentication logic Log.d(TAG, "Input validation passed, calling performLogin")
// For now, just navigate to main activity performLogin(mobileNumber, password)
Toast.makeText(this, "Signing in...", Toast.LENGTH_SHORT).show() } else {
Log.d(TAG, "Input validation failed")
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
} }
} }
private fun performLogin(mobile: String, password: String) {
Log.d(TAG, "performLogin called with mobile: $mobile")
setLoading(true)
lifecycleScope.launch {
Log.d(TAG, "Starting coroutine for API call")
try {
when (val result = apiService.login(mobile, password)) {
is ApiResult.Success -> {
Log.d(TAG, "Login successful: ${result.data}")
// Save credentials and user info (if SecureStorage is available)
if (this@LoginActivity::secureStorage.isInitialized) {
try {
secureStorage.saveCredentials(mobile, password)
secureStorage.saveUserInfo(
result.data.name,
result.data.email,
result.data.id
)
// Extract and save cookie
result.cookie?.let { cookie ->
val sessionId = extractSessionId(cookie)
Log.d(TAG, "Extracted session ID: $sessionId")
secureStorage.saveCookie(sessionId)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to save credentials securely", e)
}
}
setLoading(false)
navigateToMain()
}
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()
}
}
} 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()
}
}
}
private fun extractSessionId(setCookieHeader: String): String {
// Extract the session ID from Set-Cookie header
// Format: connect.sid=s%3A-vUZGRtHZZygj5Xm1Xg9nKdcZqanQCWm.JVdk7%2Bv63292vx5TWsOiws4QiGwKCYKjh%2FUhLWGEYVs; HttpOnly; Path=/; Expires=...
return setCookieHeader.substringBefore(";").substringAfter("connect.sid=")
}
private fun setLoading(isLoading: Boolean) {
binding.btnSignIn.isEnabled = !isLoading
binding.btnSignIn.text = if (isLoading) "Signing in..." else "Sign In"
// Disable other buttons during loading
binding.btnRegister.isEnabled = !isLoading
binding.btnForgotPassword.isEnabled = !isLoading
binding.btnPayWithoutAccount.isEnabled = !isLoading
// Show/hide progress indicator
binding.etMobileNumber.isEnabled = !isLoading
binding.etPassword.isEnabled = !isLoading
}
private fun navigateToMain() {
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
private fun handleRegister() { private fun handleRegister() {
Toast.makeText(this, "Register functionality coming soon", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Register functionality coming soon", Toast.LENGTH_SHORT).show()
// TODO: Navigate to registration screen // TODO: Navigate to registration screen
@@ -78,6 +190,12 @@ class LoginActivity : AppCompatActivity() {
return false return false
} }
// Client-side password validation - must be at least 8 characters
if (password.length < 8) {
binding.etPassword.error = "Password must be at least 8 characters"
return false
}
// Basic Maldives mobile number validation (7xxxxxx format) // Basic Maldives mobile number validation (7xxxxxx format)
if (!mobileNumber.matches(Regex("^[79]\\d{6}$"))) { if (!mobileNumber.matches(Regex("^[79]\\d{6}$"))) {
binding.etMobileNumber.error = "Enter a valid Maldives mobile number" binding.etMobileNumber.error = "Enter a valid Maldives mobile number"

View File

@@ -0,0 +1,28 @@
package sh.sar.gridflow.data
data class LoginRequest(
val mobile: String,
val password: String
)
data class LoginResponse(
val id: Int,
val name: String,
val mobile: String,
val email: String,
val createdAt: String,
val updatedAt: String,
val deletedAt: String?
)
data class ErrorResponse(
val errors: List<ValidationError>? = null,
val error: String? = null
)
data class ValidationError(
val value: String,
val msg: String,
val param: String,
val location: String
)

View File

@@ -0,0 +1,130 @@
package sh.sar.gridflow.network
import android.util.Log
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.*
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.LoginRequest
import sh.sar.gridflow.data.LoginResponse
import java.io.IOException
class FenakaApiService {
private val gson = Gson()
private val client = OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor { message ->
Log.d(TAG, message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.build()
companion object {
private const val TAG = "FenakaApiService"
private const val BASE_URL = "https://api.fenaka.mv"
private const val BEARER_TOKEN = "T5kr13UksdBT5Mq3NqNrXQr8uapaje7ONveQMPJsS3" // Hardcoded as requested
private const val JSON_MEDIA_TYPE = "application/json; charset=utf-8"
}
suspend fun login(mobile: String, password: String): ApiResult<LoginResponse> = withContext(Dispatchers.IO) {
Log.d(TAG, "Attempting login for mobile: $mobile")
val loginRequest = LoginRequest(mobile, password)
val requestBody = gson.toJson(loginRequest).toRequestBody(JSON_MEDIA_TYPE.toMediaType())
Log.d(TAG, "Request body: ${gson.toJson(loginRequest)}")
val request = Request.Builder()
.url("$BASE_URL/auth/signin")
.post(requestBody)
.header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", JSON_MEDIA_TYPE)
.header("Host", "api.fenaka.mv")
.build()
Log.d(TAG, "Making request to: ${request.url}")
try {
val response = client.newCall(request).execute()
Log.d(TAG, "Response code: ${response.code}")
Log.d(TAG, "Response headers: ${response.headers}")
val responseBody = response.body?.string()
Log.d(TAG, "Response body: $responseBody")
when (response.code) {
200 -> {
val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
val setCookieHeader = response.header("Set-Cookie")
Log.d(TAG, "Login successful. Set-Cookie: $setCookieHeader")
ApiResult.Success(loginResponse, setCookieHeader)
}
401 -> {
val errorResponse = gson.fromJson(responseBody, ErrorResponse::class.java)
Log.d(TAG, "Login failed: 401 Unauthorized")
ApiResult.Error(errorResponse.error ?: "Incorrect credentials", 401)
}
422 -> {
val errorResponse = gson.fromJson(responseBody, ErrorResponse::class.java)
val errorMessage = errorResponse.errors?.firstOrNull()?.msg ?: "Validation error"
Log.d(TAG, "Login failed: 422 Validation error - $errorMessage")
ApiResult.Error(errorMessage, 422)
}
else -> {
Log.d(TAG, "Login failed: Unknown error ${response.code}")
ApiResult.Error("Unknown error occurred", response.code)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error during login", e)
ApiResult.Error("Network error: ${e.message}", -1)
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during login", e)
ApiResult.Error("Unexpected error: ${e.message}", -1)
}
}
suspend fun getOutstandingBills(cookie: String): ApiResult<List<Any>> = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$BASE_URL/saiph/subscriptions/summaries/outstanding")
.get()
.header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", "application/json")
.header("Cookie", cookie)
.header("Host", "api.fenaka.mv")
.header("User-Agent", "Dart/3.3 (dart:io)")
.build()
try {
val response = client.newCall(request).execute()
when (response.code) {
200 -> {
val responseBody = response.body?.string() ?: "[]"
// For now, we just need to know if it's empty or not
val bills = gson.fromJson(responseBody, Array<Any>::class.java).toList()
ApiResult.Success(bills, null)
}
else -> {
ApiResult.Error("Failed to fetch outstanding bills", response.code)
}
}
} catch (e: IOException) {
ApiResult.Error("Network error: ${e.message}", -1)
} catch (e: Exception) {
ApiResult.Error("Unexpected error: ${e.message}", -1)
}
}
}
sealed class ApiResult<T> {
data class Success<T>(val data: T, val cookie: String?) : ApiResult<T>()
data class Error<T>(val message: String, val code: Int) : ApiResult<T>()
}

View File

@@ -23,15 +23,33 @@ class HomeFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val homeViewModel = val homeViewModel =
ViewModelProvider(this).get(HomeViewModel::class.java) ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(requireActivity().application))
.get(HomeViewModel::class.java)
_binding = FragmentHomeBinding.inflate(inflater, container, false) _binding = FragmentHomeBinding.inflate(inflater, container, false)
val root: View = binding.root val root: View = binding.root
val textView: TextView = binding.textHome val welcomeTextView: TextView = binding.textHome
homeViewModel.text.observe(viewLifecycleOwner) { val billsStatusTextView: TextView = binding.textBillsStatus
textView.text = it val progressBar = binding.progressBills
homeViewModel.welcomeText.observe(viewLifecycleOwner) {
welcomeTextView.text = it
} }
homeViewModel.billsStatus.observe(viewLifecycleOwner) {
billsStatusTextView.text = it
}
homeViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
progressBar.visibility = View.VISIBLE
billsStatusTextView.text = "Checking bills status..."
} else {
progressBar.visibility = View.GONE
}
}
return root return root
} }

View File

@@ -1,13 +1,77 @@
package sh.sar.gridflow.ui.home package sh.sar.gridflow.ui.home
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import sh.sar.gridflow.network.ApiResult
import sh.sar.gridflow.network.FenakaApiService
import sh.sar.gridflow.utils.SecureStorage
class HomeViewModel : ViewModel() { class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _text = MutableLiveData<String>().apply { private val secureStorage = SecureStorage(application)
value = "This is home Fragment" private val apiService = FenakaApiService()
companion object {
private const val TAG = "HomeViewModel"
}
private val _welcomeText = MutableLiveData<String>()
val welcomeText: LiveData<String> = _welcomeText
private val _billsStatus = MutableLiveData<String>()
val billsStatus: LiveData<String> = _billsStatus
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
init {
Log.d(TAG, "HomeViewModel initialized")
loadUserData()
checkOutstandingBills()
}
private fun loadUserData() {
val userName = secureStorage.getUserName() ?: "User"
Log.d(TAG, "Loading user data: $userName")
_welcomeText.value = "Welcome\n$userName"
}
private fun checkOutstandingBills() {
Log.d(TAG, "Starting bills check")
val cookie = secureStorage.getCookie()
Log.d(TAG, "Retrieved cookie: ${cookie?.take(20)}...") // Only log first 20 chars for security
if (cookie == null) {
Log.d(TAG, "No cookie found, cannot check bills")
_billsStatus.value = "Unable to check bills status"
return
}
_isLoading.value = true
viewModelScope.launch {
Log.d(TAG, "Making API call to check outstanding bills")
when (val result = apiService.getOutstandingBills("connect.sid=$cookie")) {
is ApiResult.Success -> {
Log.d(TAG, "Bills check successful: ${result.data.size} bills found")
_isLoading.value = false
if (result.data.isEmpty()) {
_billsStatus.value = "You don't have any pending bills left"
} else {
_billsStatus.value = "You have ${result.data.size} pending bills"
}
}
is ApiResult.Error -> {
Log.d(TAG, "Bills check failed: ${result.message}")
_isLoading.value = false
_billsStatus.value = "Unable to check bills status"
}
}
}
} }
val text: LiveData<String> = _text
} }

View File

@@ -0,0 +1,84 @@
package sh.sar.gridflow.utils
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureStorage(context: Context) {
private val sharedPreferences: SharedPreferences
companion object {
private const val TAG = "SecureStorage"
private const val PREFS_NAME = "gridflow_secure_prefs"
private const val KEY_MOBILE = "mobile"
private const val KEY_PASSWORD = "password"
private const val KEY_COOKIE = "cookie"
private const val KEY_USER_NAME = "user_name"
private const val KEY_USER_EMAIL = "user_email"
private const val KEY_USER_ID = "user_id"
}
init {
Log.d(TAG, "Initializing SecureStorage")
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
sharedPreferences = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
Log.d(TAG, "SecureStorage initialized successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize SecureStorage", e)
throw e
}
}
fun saveCredentials(mobile: String, password: String) {
Log.d(TAG, "Saving credentials for mobile: $mobile")
sharedPreferences.edit()
.putString(KEY_MOBILE, mobile)
.putString(KEY_PASSWORD, password)
.apply()
}
fun saveCookie(cookie: String) {
Log.d(TAG, "Saving cookie: $cookie")
sharedPreferences.edit()
.putString(KEY_COOKIE, cookie)
.apply()
}
fun saveUserInfo(name: String, email: String, userId: Int) {
Log.d(TAG, "Saving user info: name=$name, email=$email, userId=$userId")
sharedPreferences.edit()
.putString(KEY_USER_NAME, name)
.putString(KEY_USER_EMAIL, email)
.putInt(KEY_USER_ID, userId)
.apply()
}
fun getMobile(): String? = sharedPreferences.getString(KEY_MOBILE, null)
fun getPassword(): String? = sharedPreferences.getString(KEY_PASSWORD, null)
fun getCookie(): String? = sharedPreferences.getString(KEY_COOKIE, null)
fun getUserName(): String? = sharedPreferences.getString(KEY_USER_NAME, null)
fun getUserEmail(): String? = sharedPreferences.getString(KEY_USER_EMAIL, null)
fun getUserId(): Int = sharedPreferences.getInt(KEY_USER_ID, -1)
fun isLoggedIn(): Boolean {
return getCookie() != null && getUserName() != null
}
fun clearCredentials() {
Log.d(TAG, "Clearing all credentials")
sharedPreferences.edit().clear().apply()
}
}

View File

@@ -1,22 +1,79 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".ui.home.HomeFragment"> tools:context=".ui.home.HomeFragment">
<TextView <LinearLayout
android:id="@+id/text_home"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:orientation="vertical"
android:layout_marginTop="8dp" android:padding="20dp">
android:layout_marginEnd="8dp"
android:textAlignment="center" <!-- Welcome Section -->
android:textSize="20sp" <TextView
app:layout_constraintBottom_toBottomOf="parent" android:id="@+id/text_home"
app:layout_constraintEnd_toEndOf="parent" android:layout_width="match_parent"
app:layout_constraintStart_toStartOf="parent" android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" /> android:layout_marginBottom="24dp"
</androidx.constraintlayout.widget.ConstraintLayout> android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/black"
android:lineSpacingExtra="4dp"
tools:text="Welcome\nShihaam Abdul Rahman" />
<!-- Bills Status Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_bills_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="#E8F5E8">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/text_bills_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textColor="#2E7D32"
android:textStyle="bold"
tools:text="You don't have any pending bills left" />
<ProgressBar
android:id="@+id/progress_bills"
android:layout_width="24dp"
android:layout_height="24dp"
android:visibility="gone"
android:indeterminateTint="#2E7D32" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Placeholder for future features -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="More features coming soon..."
android:textSize="14sp"
android:textColor="@color/black"
android:alpha="0.6"
android:gravity="center"
android:layout_marginTop="32dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">api.fenaka.mv</domain>
</domain-config>
</network-security-config>

View File

@@ -12,6 +12,9 @@ lifecycleLivedataKtx = "2.6.1"
lifecycleViewmodelKtx = "2.6.1" lifecycleViewmodelKtx = "2.6.1"
navigationFragmentKtx = "2.6.0" navigationFragmentKtx = "2.6.0"
navigationUiKtx = "2.6.0" navigationUiKtx = "2.6.0"
okhttp = "4.12.0"
gson = "2.10.1"
security = "1.1.0-alpha06"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -25,6 +28,10 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" } androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }