diff --git a/.gitignore b/.gitignore
index e2ce4be..7cf7dda 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Android/Java/Gradle
.gradle/
+.gradle-user/
.idea/
.DS_Store
/build/
@@ -141,3 +142,4 @@ code-visualizer/
# Markdown files (except README.md)
*.md
!README.md
+.run/
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index bc80ff1..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 01ae5d0..210314c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,8 +21,8 @@ android {
applicationId "com.ash.simpledataentry"
minSdk 24
targetSdk 35
- versionCode 1
- versionName "1.0"
+ versionCode 2
+ versionName "1.0.1-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -66,6 +66,7 @@ android {
}
buildFeatures {
compose true
+ buildConfig true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.1'
@@ -89,6 +90,7 @@ dependencies {
implementation 'androidx.compose.material3:material3'
implementation 'androidx.navigation:navigation-compose:2.7.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.7.3'
+ implementation "com.google.android.material:material:1.13.0"
// Unit testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.7.0'
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4892c90..50fd92d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -12,8 +12,8 @@ android {
applicationId = "com.ash.simpledataentry"
minSdk = 24
targetSdk = 35
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2
+ versionName = "1.0.1-beta"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -36,6 +36,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
}
@@ -56,4 +57,4 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/ash/simpledataentry/data/AccountManager.kt b/app/src/main/java/com/ash/simpledataentry/data/AccountManager.kt
index d2d4269..b980d68 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/AccountManager.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/AccountManager.kt
@@ -149,6 +149,11 @@ class AccountManager @Inject constructor() {
Log.d(TAG, "Active account set to: $accountId")
}
+ fun clearActiveAccountId(context: Context) {
+ getPrefs(context).edit().remove(KEY_ACTIVE_ACCOUNT_ID).commit()
+ Log.d(TAG, "Active account cleared")
+ }
+
/**
* Get active account info
*/
diff --git a/app/src/main/java/com/ash/simpledataentry/data/SessionManager.kt b/app/src/main/java/com/ash/simpledataentry/data/SessionManager.kt
index 9d4c2be..45bbb44 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/SessionManager.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/SessionManager.kt
@@ -32,6 +32,7 @@ import java.security.MessageDigest
import java.nio.charset.StandardCharsets
import org.hisp.dhis.android.core.maintenance.D2Error
import org.hisp.dhis.android.core.maintenance.D2ErrorCode
+import java.util.concurrent.TimeUnit
/**
* Result of downloading a single metadata type
@@ -89,6 +90,13 @@ class SessionManager @Inject constructor(
}
}
+ private fun isOptionalUseCasesMissing(error: Throwable?): Boolean {
+ val message = error?.message ?: return false
+ return message.contains("stockUseCases", ignoreCase = true) ||
+ message.contains("USE_CASES", ignoreCase = true) ||
+ message.contains("E1005", ignoreCase = true)
+ }
+
/**
* Initialize D2 SDK (shared across all accounts).
* Note: D2 SDK uses single database, account isolation handled by Room.
@@ -361,8 +369,6 @@ class SessionManager @Inject constructor(
))
// Set this as the active account
- accountManager.setActiveAccountId(context, accountInfo.accountId)
-
// SECURITY ENHANCEMENT: Store password hash for secure offline validation
val passwordHash = hashPassword(dhis2Config.password, dhis2Config.username + dhis2Config.serverUrl)
val prefs = context.getSharedPreferences("session_prefs", Context.MODE_PRIVATE)
@@ -373,10 +379,6 @@ class SessionManager @Inject constructor(
putLong("hash_created", System.currentTimeMillis())
}
- // Emit account change event for ViewModels (using accountId, not username@serverUrl)
- _currentAccountId.value = accountInfo.accountId
- Log.d("SessionManager", "Account changed, notifying observers: ${accountInfo.accountId}")
-
// Step 3: Download Metadata (30-80%) - RESILIENT, UI LOCKED
// Use resilient metadata download with granular progress feedback
val metadataResult = downloadMetadataResilient(onProgress = onProgress)
@@ -411,6 +413,13 @@ class SessionManager @Inject constructor(
}
}
+ // Now that metadata is available, mark account as active
+ accountManager.setActiveAccountId(context, accountInfo.accountId)
+
+ // Emit account change event for ViewModels (using accountId, not username@serverUrl)
+ _currentAccountId.value = accountInfo.accountId
+ Log.d("SessionManager", "Account changed, notifying observers: ${accountInfo.accountId}")
+
onProgress(NavigationProgress(
phase = LoadingPhase.DOWNLOADING_METADATA,
overallPercentage = 80,
@@ -491,7 +500,23 @@ class SessionManager @Inject constructor(
Log.e("SessionManager", "Enhanced login failed: errorCode=$errorCode, description=${d2Error?.errorDescription()}", e)
onProgress(NavigationProgress.error(userMessage))
- throw e
+ secureLogout(context)
+ accountManager.clearActiveAccountId(context)
+ _currentAccountId.value = null
+
+ val mappedException = when (errorCode) {
+ D2ErrorCode.SERVER_CONNECTION_ERROR,
+ D2ErrorCode.UNKNOWN_HOST,
+ D2ErrorCode.SOCKET_TIMEOUT,
+ D2ErrorCode.SSL_ERROR,
+ D2ErrorCode.URL_NOT_FOUND,
+ D2ErrorCode.SERVER_URL_MALFORMED -> java.io.IOException(userMessage)
+ D2ErrorCode.BAD_CREDENTIALS,
+ D2ErrorCode.USER_ACCOUNT_DISABLED,
+ D2ErrorCode.USER_ACCOUNT_LOCKED -> SecurityException(userMessage)
+ else -> Exception(userMessage)
+ }
+ throw mappedException
}
}
@@ -842,6 +867,7 @@ class SessionManager @Inject constructor(
var lastError: String? = null
val maxRetries = 3
+ val progressTimeoutSeconds = 60L
for (attempt in 1..maxRetries) {
Log.d("SessionManager", "Metadata download attempt $attempt of $maxRetries")
@@ -858,25 +884,37 @@ class SessionManager @Inject constructor(
try {
// Following official DHIS2 Capture app pattern:
// Use non-blocking download() with onErrorComplete() to swallow errors
- io.reactivex.Completable.fromObservable(
- d2Instance.metadataModule().download()
- .doOnNext { progress ->
- val percent = progress.percentage() ?: 0.0
- Log.d("SessionManager", "Metadata progress: ${percent.toInt()}%")
+ val downloadObservable = d2Instance.metadataModule().download()
+ .doOnNext { progress ->
+ val percent = progress.percentage() ?: 0.0
+ Log.d("SessionManager", "Metadata progress: ${percent.toInt()}%")
+ onProgress(NavigationProgress(
+ phase = LoadingPhase.DOWNLOADING_METADATA,
+ overallPercentage = 30 + (percent * 0.5).toInt(),
+ phaseTitle = "Downloading Metadata",
+ phaseDetail = "Progress: ${percent.toInt()}%"
+ ))
+ }
+ .timeout(progressTimeoutSeconds, TimeUnit.SECONDS)
+
+ io.reactivex.Completable.fromObservable(downloadObservable)
+ .doOnError { error ->
+ if (isOptionalUseCasesMissing(error)) {
+ Log.w("SessionManager", "Optional server config missing: USE_CASES/stockUseCases (continuing)")
onProgress(NavigationProgress(
phase = LoadingPhase.DOWNLOADING_METADATA,
- overallPercentage = 30 + (percent * 0.5).toInt(),
+ overallPercentage = 35,
phaseTitle = "Downloading Metadata",
- phaseDetail = "Progress: ${percent.toInt()}%"
+ phaseDetail = "Optional server config missing (USE_CASES/stockUseCases). Ask admin to add it."
))
+ downloadError = null
+ } else {
+ Log.w("SessionManager", "Metadata download error: ${error.message}")
+ downloadError = error.message
}
- )
- .doOnError { error ->
- Log.w("SessionManager", "Metadata download error: ${error.message}")
- downloadError = error.message
- }
- .onErrorComplete() // Swallow errors like official app
- .blockingAwait()
+ }
+ .onErrorComplete { error -> isOptionalUseCasesMissing(error) }
+ .blockingAwait()
Log.d("SessionManager", "Metadata download stream completed for attempt $attempt")
} catch (e: Exception) {
diff --git a/app/src/main/java/com/ash/simpledataentry/data/cache/MetadataCacheService.kt b/app/src/main/java/com/ash/simpledataentry/data/cache/MetadataCacheService.kt
index 42afbcd..496a1f4 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/cache/MetadataCacheService.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/cache/MetadataCacheService.kt
@@ -156,6 +156,47 @@ class MetadataCacheService @Inject constructor(
baseData.copy(sdkDataValues = sdkDataValues)
}
+
+ /**
+ * Force-refresh data values for a specific dataset instance and cache them in Room.
+ * Returns the number of values fetched from the server.
+ */
+ suspend fun refreshDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Int = withContext(Dispatchers.IO) {
+ val defaultCombo = d2.categoryModule().categoryOptionCombos()
+ .byDisplayName().eq("default")
+ .one()
+ .blockingGet()
+ ?.uid()
+ .orEmpty()
+
+ val resolvedAttr = if (attributeOptionCombo.isBlank()) defaultCombo else attributeOptionCombo
+
+ val rawSdkDataValues = d2.dataValueModule().dataValues()
+ .byDataSetUid(datasetId)
+ .byPeriod().eq(period)
+ .byOrganisationUnitUid().eq(orgUnit)
+ .byAttributeOptionComboUid().eq(resolvedAttr)
+ .blockingGet()
+
+ if (rawSdkDataValues.isEmpty() && resolvedAttr != defaultCombo && defaultCombo.isNotBlank()) {
+ val fallbackValues = d2.dataValueModule().dataValues()
+ .byDataSetUid(datasetId)
+ .byPeriod().eq(period)
+ .byOrganisationUnitUid().eq(orgUnit)
+ .byAttributeOptionComboUid().eq(defaultCombo)
+ .blockingGet()
+ storeDataValuesInRoom(datasetId, period, orgUnit, defaultCombo, fallbackValues)
+ fallbackValues.size
+ } else {
+ storeDataValuesInRoom(datasetId, period, orgUnit, resolvedAttr, rawSdkDataValues)
+ rawSdkDataValues.size
+ }
+ }
/**
* Get sections for a dataset with caching
@@ -166,6 +207,10 @@ class MetadataCacheService @Inject constructor(
.withDataElements()
.byDataSetUid().eq(datasetId)
.blockingGet()
+ .sortedWith(
+ compareBy { it.sortOrder() ?: Int.MAX_VALUE }
+ .thenBy { it.displayName() ?: "" }
+ )
if (sections.isEmpty()) {
val dataSet = d2.dataSetModule().dataSets()
diff --git a/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/AuthRepositoryImpl.kt b/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/AuthRepositoryImpl.kt
index 3357a6f..427b32c 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/AuthRepositoryImpl.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/AuthRepositoryImpl.kt
@@ -44,41 +44,37 @@ class AuthRepositoryImpl @Inject constructor(
context: Context,
onProgress: (NavigationProgress) -> Unit
): Boolean {
- return try {
- // Blocking metadata download with UI lock (completes before returning)
- sessionManager.loginWithProgress(context, Dhis2Config(serverUrl, username, password), backgroundSyncManager, onProgress)
-
- // CRITICAL: Clear metadata caches after successful login (handles user-switch scenarios)
- metadataCacheService.clearAllCaches()
-
- // CRITICAL: Start async background data sync AFTER metadata completes
- // UI is unlocked, user can navigate immediately
- Log.d("AuthRepositoryImpl", "Metadata sync complete - starting background data sync")
- backgroundScope.launch {
- var syncSuccess = false
- var syncMessage: String? = null
-
- sessionManager.startBackgroundDataSync(context) { success, message ->
- syncSuccess = success
- syncMessage = message
- }
+ // Blocking metadata download with UI lock (completes before returning)
+ sessionManager.loginWithProgress(context, Dhis2Config(serverUrl, username, password), backgroundSyncManager, onProgress)
+
+ // CRITICAL: Clear metadata caches after successful login (handles user-switch scenarios)
+ metadataCacheService.clearAllCaches()
+
+ // CRITICAL: Start async background data sync AFTER metadata completes
+ // UI is unlocked, user can navigate immediately
+ Log.d("AuthRepositoryImpl", "Metadata sync complete - starting background data sync")
+ backgroundScope.launch {
+ var syncSuccess = false
+ var syncMessage: String? = null
+
+ sessionManager.startBackgroundDataSync(context) { success, message ->
+ syncSuccess = success
+ syncMessage = message
+ }
- // Show non-intrusive toast notification when background sync completes
- withContext(Dispatchers.Main) {
- val toastMessage = if (syncSuccess) {
- "✓ Data sync complete"
- } else {
- "⚠ Data sync incomplete: ${syncMessage ?: "Unknown error"}"
- }
- Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
- Log.d("AuthRepositoryImpl", "Background sync completed: $toastMessage")
+ // Show non-intrusive toast notification when background sync completes
+ withContext(Dispatchers.Main) {
+ val toastMessage = if (syncSuccess) {
+ "✓ Data sync complete"
+ } else {
+ "⚠ Data sync incomplete: ${syncMessage ?: "Unknown error"}"
}
+ Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
+ Log.d("AuthRepositoryImpl", "Background sync completed: $toastMessage")
}
-
- true
- } catch (e: Exception) {
- false
}
+
+ return true
}
/**
@@ -128,4 +124,4 @@ class SystemRepositoryImpl @Inject constructor(
throw e
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/DataEntryRepositoryImpl.kt b/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/DataEntryRepositoryImpl.kt
index 86dcbc7..fa60faa 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/DataEntryRepositoryImpl.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/repositoryImpl/DataEntryRepositoryImpl.kt
@@ -71,6 +71,8 @@ class DataEntryRepositoryImpl @Inject constructor(
ValueType.PERCENTAGE -> DataEntryType.PERCENTAGE
ValueType.DATE -> DataEntryType.DATE
ValueType.BOOLEAN -> DataEntryType.YES_NO
+ ValueType.TRUE_ONLY -> DataEntryType.YES_ONLY
+ ValueType.PHONE_NUMBER -> DataEntryType.PHONE_NUMBER
ValueType.COORDINATE -> DataEntryType.COORDINATES
//ValueType.OPTION_SET -> DataEntryType.MULTIPLE_CHOICE
else -> DataEntryType.TEXT
@@ -122,6 +124,8 @@ class DataEntryRepositoryImpl @Inject constructor(
"PERCENTAGE" -> DataEntryType.PERCENTAGE
"DATE" -> DataEntryType.DATE
"BOOLEAN" -> DataEntryType.YES_NO
+ "TRUE_ONLY" -> DataEntryType.YES_ONLY
+ "PHONE_NUMBER" -> DataEntryType.PHONE_NUMBER
"COORDINATE" -> DataEntryType.COORDINATES
else -> DataEntryType.TEXT
}
@@ -146,9 +150,16 @@ class DataEntryRepositoryImpl @Inject constructor(
severity = ValidationState.ERROR
))
}
+ "PHONE_NUMBER" -> {
+ rules.add(ValidationRule(
+ rule = "phone",
+ message = "Please enter a valid phone number",
+ severity = ValidationState.ERROR
+ ))
+ }
"COORDINATE" -> {
rules.add(ValidationRule(
- rule = "coordinates",
+ rule = "coordinates",
message = "Please enter valid coordinates",
severity = ValidationState.ERROR
))
@@ -416,8 +427,9 @@ class DataEntryRepositoryImpl @Inject constructor(
.blockingGet() ?: return DataValueValidationResult(false, ValidationState.ERROR, "Unknown data element type")
// Check if value is required
- if (value.isBlank() && dataElementObj.optionSet() == null &&
- dataElementObj.valueType() != org.hisp.dhis.android.core.common.ValueType.BOOLEAN) {
+ if (value.isBlank() && dataElementObj.optionSet() == null &&
+ dataElementObj.valueType() != org.hisp.dhis.android.core.common.ValueType.BOOLEAN &&
+ dataElementObj.valueType() != org.hisp.dhis.android.core.common.ValueType.TRUE_ONLY) {
return DataValueValidationResult(false, ValidationState.ERROR, "This field is required")
}
@@ -443,6 +455,31 @@ class DataEntryRepositoryImpl @Inject constructor(
DataValueValidationResult(true, ValidationState.VALID, null)
}
}
+ org.hisp.dhis.android.core.common.ValueType.TRUE_ONLY -> {
+ if (value.isNotBlank() && value != "true") {
+ DataValueValidationResult(false, ValidationState.ERROR, "Value must be true or empty")
+ } else {
+ DataValueValidationResult(true, ValidationState.VALID, null)
+ }
+ }
+ org.hisp.dhis.android.core.common.ValueType.DATE -> {
+ try {
+ val sdf = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US)
+ sdf.isLenient = false
+ sdf.parse(value)
+ DataValueValidationResult(true, ValidationState.VALID, null)
+ } catch (e: Exception) {
+ DataValueValidationResult(false, ValidationState.ERROR, "Use date format YYYY-MM-DD")
+ }
+ }
+ org.hisp.dhis.android.core.common.ValueType.PHONE_NUMBER -> {
+ val regex = Regex("^\\+?[0-9]{6,15}$")
+ if (!regex.matches(value)) {
+ DataValueValidationResult(false, ValidationState.ERROR, "Please enter a valid phone number")
+ } else {
+ DataValueValidationResult(true, ValidationState.VALID, null)
+ }
+ }
else -> DataValueValidationResult(true, ValidationState.VALID, null)
}
} catch (e: Exception) {
@@ -527,6 +564,23 @@ class DataEntryRepositoryImpl @Inject constructor(
}
}
+ override suspend fun getDatasetIdsAttachedToOrgUnits(
+ orgUnitIds: Set,
+ datasetIds: List
+ ): Set {
+ if (orgUnitIds.isEmpty() || datasetIds.isEmpty()) return emptySet()
+ return withContext(Dispatchers.IO) {
+ datasetIds.filter { datasetId ->
+ val attachedOrgUnits = d2.organisationUnitModule().organisationUnits()
+ .byDataSetUids(listOf(datasetId))
+ .blockingGet()
+ .map { it.uid() }
+ .toSet()
+ attachedOrgUnits.any { it in orgUnitIds }
+ }.toSet()
+ }
+ }
+
override suspend fun expandOrgUnitSelection(targetId: String, orgUnitId: String): Set {
return withContext(Dispatchers.IO) {
val targetType = resolveTargetType(targetId)
@@ -641,6 +695,26 @@ class DataEntryRepositoryImpl @Inject constructor(
}
}
+ override suspend fun refreshDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Int {
+ return metadataCacheService.refreshDataValues(datasetId, period, orgUnit, attributeOptionCombo)
+ }
+
+ override suspend fun hasCachedDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Boolean {
+ return withContext(Dispatchers.IO) {
+ dataValueDao.getValuesForInstance(datasetId, period, orgUnit, attributeOptionCombo).isNotEmpty()
+ }
+ }
+
override suspend fun getCategoryComboStructure(categoryComboUid: String): List>>> {
return metadataCacheService.getCategoryComboStructure(categoryComboUid)
}
diff --git a/app/src/main/java/com/ash/simpledataentry/data/sync/BackgroundDataPrefetcher.kt b/app/src/main/java/com/ash/simpledataentry/data/sync/BackgroundDataPrefetcher.kt
index b144f66..750bb09 100644
--- a/app/src/main/java/com/ash/simpledataentry/data/sync/BackgroundDataPrefetcher.kt
+++ b/app/src/main/java/com/ash/simpledataentry/data/sync/BackgroundDataPrefetcher.kt
@@ -25,7 +25,7 @@ class BackgroundDataPrefetcher @Inject constructor(
/**
* Start background prefetching after successful login
*/
- fun startPrefetching() {
+ fun startPrefetching(topDatasetCount: Int = 3) {
prefetchJob?.cancel()
prefetchJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
try {
@@ -33,13 +33,13 @@ class BackgroundDataPrefetcher @Inject constructor(
// 1. Pre-warm metadata caches for all available datasets
datasetDao.getAll().collect { datasets ->
- val datasetIds = datasets.map { it.id }
+ val datasetIds = datasets.map { it.id }.take(topDatasetCount)
Log.d("BackgroundDataPrefetcher", "Pre-warming caches for ${datasetIds.size} datasets")
metadataCacheService.preWarmCaches(datasetIds)
// 2. Pre-fetch recent data values for commonly used datasets (optional)
- prefetchRecentDataValues(datasetIds.take(5)) // Limit to top 5 datasets to avoid excessive API calls
+ prefetchRecentDataValues(datasetIds)
Log.d("BackgroundDataPrefetcher", "Background prefetching completed successfully")
}
@@ -48,6 +48,22 @@ class BackgroundDataPrefetcher @Inject constructor(
}
}
}
+
+ /**
+ * Preload metadata (and a small slice of data values) for a selected dataset.
+ * Used to speed up form preparation after user selection.
+ */
+ fun prefetchForDataset(datasetId: String) {
+ CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
+ try {
+ Log.d("BackgroundDataPrefetcher", "Prefetching data for dataset $datasetId")
+ metadataCacheService.preWarmCaches(listOf(datasetId))
+ prefetchRecentDataValues(listOf(datasetId))
+ } catch (e: Exception) {
+ Log.w("BackgroundDataPrefetcher", "Dataset prefetch failed for $datasetId", e)
+ }
+ }
+ }
/**
* Stop background prefetching
diff --git a/app/src/main/java/com/ash/simpledataentry/domain/model/DataEntry.kt b/app/src/main/java/com/ash/simpledataentry/domain/model/DataEntry.kt
index c68a5a4..440aac2 100644
--- a/app/src/main/java/com/ash/simpledataentry/domain/model/DataEntry.kt
+++ b/app/src/main/java/com/ash/simpledataentry/domain/model/DataEntry.kt
@@ -24,6 +24,7 @@ enum class DataEntryType {
NUMBER,
DATE,
YES_NO,
+ YES_ONLY,
MULTIPLE_CHOICE,
COORDINATES,
PERCENTAGE,
@@ -31,7 +32,8 @@ enum class DataEntryType {
POSITIVE_INTEGER,
NEGATIVE_INTEGER,
POSITIVE_NUMBER,
- NEGATIVE_NUMBER
+ NEGATIVE_NUMBER,
+ PHONE_NUMBER
}
sealed class DataEntryValidation {
@@ -66,6 +68,12 @@ fun DataEntryType.getDefaultValidation(): Array {
DataEntryValidation.Pattern("^\\d*\\.?\\d*$", "Please enter a valid percentage"),
DataEntryValidation.MaxValue(100.0, "Percentage cannot exceed 100%")
)
+ DataEntryType.DATE -> arrayOf(
+ DataEntryValidation.Pattern("^\\d{4}-\\d{2}-\\d{2}$", "Use date format YYYY-MM-DD")
+ )
+ DataEntryType.PHONE_NUMBER -> arrayOf(
+ DataEntryValidation.Pattern("^\\+?[0-9]{6,15}$", "Please enter a valid phone number")
+ )
else -> emptyArray()
}
}
diff --git a/app/src/main/java/com/ash/simpledataentry/domain/repository/DataEntryRepository.kt b/app/src/main/java/com/ash/simpledataentry/domain/repository/DataEntryRepository.kt
index 74c0269..75f60fc 100644
--- a/app/src/main/java/com/ash/simpledataentry/domain/repository/DataEntryRepository.kt
+++ b/app/src/main/java/com/ash/simpledataentry/domain/repository/DataEntryRepository.kt
@@ -36,9 +36,22 @@ interface DataEntryRepository {
suspend fun getUserOrgUnits(datasetId: String): List
suspend fun getScopedOrgUnits(): List
suspend fun getOrgUnitsAttachedToDataSets(datasetIds: List): Set
+ suspend fun getDatasetIdsAttachedToOrgUnits(orgUnitIds: Set, datasetIds: List): Set
suspend fun expandOrgUnitSelection(targetId: String, orgUnitId: String): Set
suspend fun getDefaultAttributeOptionCombo(): String
suspend fun getAttributeOptionCombos(datasetId: String): List>
+ suspend fun refreshDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Int
+ suspend fun hasCachedDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Boolean
suspend fun getCategoryComboStructure(categoryComboUid: String): List>>>
suspend fun getCategoryOptionCombos(categoryComboUid: String): List>>
diff --git a/app/src/main/java/com/ash/simpledataentry/navigation/AppNavigation.kt b/app/src/main/java/com/ash/simpledataentry/navigation/AppNavigation.kt
index da9fa2e..6bde25a 100644
--- a/app/src/main/java/com/ash/simpledataentry/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/ash/simpledataentry/navigation/AppNavigation.kt
@@ -24,6 +24,7 @@ import com.ash.simpledataentry.presentation.datasetInstances.DatasetInstancesScr
import com.ash.simpledataentry.presentation.datasets.DatasetsScreen
import com.ash.simpledataentry.presentation.issues.ReportIssuesScreen
import com.ash.simpledataentry.presentation.login.LoginScreen
+import com.ash.simpledataentry.presentation.settings.EditAccountScreen
import com.ash.simpledataentry.presentation.settings.SettingsScreen
import com.ash.simpledataentry.presentation.tracker.TrackerEnrollmentScreen
import com.ash.simpledataentry.presentation.tracker.EventCaptureScreen
@@ -35,6 +36,8 @@ sealed class Screen(val route: String) {
data object DatasetsScreen : Screen("datasets")
data class DatasetInstanceScreen(val datasetId: String, val datasetName: String) : Screen("instances")
data object SettingsScreen : Screen("settings")
+ data object AddAccountScreen : Screen("add_account")
+ data object EditAccountScreen : Screen("edit_account")
data object AboutScreen : Screen("about")
data object ReportIssuesScreen : Screen("report_issues")
// data object CreateNewEntryScreen : Screen("createnewinstance")
@@ -58,6 +61,17 @@ fun AppNavigation(
composable(LoginScreen.route) {
LoginScreen(navController = navController)
}
+ composable(
+ route = "${Screen.AddAccountScreen.route}?skipAutoLogin={skipAutoLogin}",
+ arguments = listOf(
+ navArgument("skipAutoLogin") {
+ type = NavType.BoolType
+ defaultValue = true
+ }
+ )
+ ) {
+ LoginScreen(navController = navController, isAddAccount = true)
+ }
composable(DatasetsScreen.route) {
DatasetsScreen(navController = navController)
}
@@ -169,6 +183,9 @@ fun AppNavigation(
composable(Screen.SettingsScreen.route) {
SettingsScreen(navController = navController)
}
+ composable(Screen.EditAccountScreen.route) {
+ EditAccountScreen(navController = navController)
+ }
composable(Screen.AboutScreen.route) {
AboutScreen(navController = navController)
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/MainActivity.kt b/app/src/main/java/com/ash/simpledataentry/presentation/MainActivity.kt
index 19150de..0f4b5d5 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/MainActivity.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/MainActivity.kt
@@ -6,16 +6,23 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.ash.simpledataentry.data.SessionManager
@@ -25,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme
+import com.ash.simpledataentry.ui.theme.SimpleDataEntryTheme
import javax.inject.Inject
@@ -41,7 +48,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
-
+ WindowCompat.setDecorFitsSystemWindows(window, true)
// Initialize D2 on app start
lifecycleScope.launch {
try {
@@ -54,7 +61,21 @@ class MainActivity : ComponentActivity() {
setContent {
val isRestoring by isRestoringSession.collectAsState()
- DHIS2Theme {
+ SimpleDataEntryTheme {
+ val isLightTheme = !isSystemInDarkTheme()
+ val barColor = if (isLightTheme) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ val useDarkIcons = isLightTheme && barColor.luminance() > 0.5f
+ SideEffect {
+ window.statusBarColor = barColor.toArgb()
+ window.navigationBarColor = barColor.toArgb()
+ val insetsController = WindowInsetsControllerCompat(window, window.decorView)
+ insetsController.isAppearanceLightStatusBars = useDarkIcons
+ insetsController.isAppearanceLightNavigationBars = useDarkIcons
+ }
val navController = rememberNavController()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/core/BaseScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/core/BaseScreen.kt
index 31af32a..d743a2e 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/core/BaseScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/core/BaseScreen.kt
@@ -20,8 +20,13 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.navigation.NavController
import com.ash.simpledataentry.data.sync.SyncStatusController
import org.hisp.dhis.mobile.ui.designsystem.component.Title
@@ -29,6 +34,8 @@ import org.hisp.dhis.mobile.ui.designsystem.component.TopBar
import org.hisp.dhis.mobile.ui.designsystem.component.TopBarType
import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor
import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor
+import androidx.core.view.WindowInsetsControllerCompat
+import android.app.Activity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -36,6 +43,7 @@ fun BaseScreen(
title: String,
subtitle: String? = null,
navController: NavController,
+ usePrimaryTopBar: Boolean = true,
navigationIcon: @Composable (() -> Unit)? = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
@@ -60,8 +68,29 @@ fun BaseScreen(
val effectiveShowProgress = showProgress || syncShowProgress
val effectiveProgress = progress ?: syncProgressValue
- val titleContentColor = MaterialTheme.colorScheme.onPrimary
- val subtitleColor = titleContentColor.copy(alpha = 0.75f)
+ val titleTextColor = if (usePrimaryTopBar) TextColor.OnPrimary else TextColor.OnSurface
+ val subtitleColor = if (usePrimaryTopBar) {
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ }
+ val view = LocalView.current
+ val isLightTheme = !isSystemInDarkTheme()
+ val statusBarColor = if (isLightTheme) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ SideEffect {
+ val window = (view.context as? Activity)?.window ?: return@SideEffect
+ val colorInt = statusBarColor.toArgb()
+ window.statusBarColor = colorInt
+ window.navigationBarColor = colorInt
+ val insetsController = WindowInsetsControllerCompat(window, view)
+ val useDarkIcons = isLightTheme && statusBarColor.luminance() > 0.5f
+ insetsController.isAppearanceLightStatusBars = useDarkIcons
+ insetsController.isAppearanceLightNavigationBars = useDarkIcons
+ }
Scaffold(
topBar = {
@@ -69,7 +98,7 @@ fun BaseScreen(
TopBar(
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Title(text = title, textColor = TextColor.OnPrimary)
+ Title(text = title, textColor = titleTextColor)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
@@ -86,11 +115,11 @@ fun BaseScreen(
actions()
},
colors = TopAppBarColors(
- containerColor = SurfaceColor.Primary,
- titleContentColor = TextColor.OnSurface,
+ containerColor = if (usePrimaryTopBar) SurfaceColor.Primary else SurfaceColor.Surface,
+ titleContentColor = if (usePrimaryTopBar) TextColor.OnSurface else TextColor.OnSurface,
navigationIconContentColor = TextColor.OnSurface,
actionIconContentColor = TextColor.OnSurface,
- scrolledContainerColor = SurfaceColor.Container,
+ scrolledContainerColor = if (usePrimaryTopBar) SurfaceColor.Container else SurfaceColor.Surface,
),
)
// PHASE 4: Progress indicator beneath top bar
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/core/StepLoadingScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/core/StepLoadingScreen.kt
index 5aabf78..c2da4c4 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/core/StepLoadingScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/core/StepLoadingScreen.kt
@@ -28,19 +28,26 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ash.simpledataentry.ui.theme.DHIS2Blue
import com.ash.simpledataentry.ui.theme.DHIS2BlueDark
import com.ash.simpledataentry.ui.theme.DatasetAccent
+import androidx.core.view.WindowInsetsControllerCompat
+import android.app.Activity
enum class StepLoadingType {
LOGIN,
@@ -57,8 +64,21 @@ fun StepLoadingScreen(
currentStep: Int,
progressPercent: Int,
currentLabel: String? = null,
+ actionLabel: String? = null,
+ onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
+ val view = LocalView.current
+ val statusBarColor = DHIS2Blue
+ SideEffect {
+ val window = (view.context as? Activity)?.window ?: return@SideEffect
+ val colorInt = statusBarColor.toArgb()
+ window.statusBarColor = colorInt
+ window.navigationBarColor = colorInt
+ val insetsController = WindowInsetsControllerCompat(window, view)
+ insetsController.isAppearanceLightStatusBars = statusBarColor.luminance() > 0.5f
+ insetsController.isAppearanceLightNavigationBars = statusBarColor.luminance() > 0.5f
+ }
val steps = when (type) {
StepLoadingType.LOGIN -> listOf(
StepLoadingStep("Initializing"),
@@ -230,6 +250,16 @@ fun StepLoadingScreen(
Spacer(modifier = Modifier.height(20.dp))
+ if (actionLabel != null && onAction != null) {
+ OutlinedButton(
+ onClick = onAction,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = actionLabel)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
Text(
text = "Please do not close the app",
style = MaterialTheme.typography.bodySmall,
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/CreateNewEntryScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/CreateNewEntryScreen.kt
index 016ab3b..06373f0 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/CreateNewEntryScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/CreateNewEntryScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -47,7 +48,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -57,9 +57,6 @@ import com.ash.simpledataentry.domain.model.OrganisationUnit
import com.ash.simpledataentry.domain.model.Period
import com.ash.simpledataentry.presentation.core.BaseScreen
import com.ash.simpledataentry.presentation.core.OrgUnitTreePickerDialog
-import com.ash.simpledataentry.ui.theme.DHIS2Blue
-import com.ash.simpledataentry.ui.theme.DHIS2BlueDark
-import com.ash.simpledataentry.ui.theme.DHIS2BlueLight
import java.net.URLDecoder
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -88,6 +85,7 @@ fun CreateNewEntryScreen(
var selectedAttributeOptionCombo by remember { mutableStateOf("") }
var expandedAttributeOptionCombo by remember { mutableStateOf(false) }
var showAllPeriods by remember { mutableStateOf(false) }
+ var isFetchingExistingData by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
val decodedDatasetName = remember(datasetName) { URLDecoder.decode(datasetName, "UTF-8") }
val snackbarHostState = remember { SnackbarHostState() }
@@ -114,16 +112,13 @@ fun CreateNewEntryScreen(
BaseScreen(
title = "Create New Entry",
subtitle = decodedDatasetName,
- navController = navController
+ navController = navController,
+ usePrimaryTopBar = false
) {
- val gradientBrush = Brush.verticalGradient(
- colors = listOf(DHIS2Blue, DHIS2BlueDark)
- )
-
Box(
modifier = Modifier
.fillMaxSize()
- .background(gradientBrush)
+ .background(MaterialTheme.colorScheme.background)
) {
if (isLoading) {
Box(
@@ -164,7 +159,7 @@ fun CreateNewEntryScreen(
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
- colors = CardDefaults.cardColors(containerColor = Color.White),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
@@ -180,14 +175,14 @@ fun CreateNewEntryScreen(
Box(
modifier = Modifier
.size(56.dp)
- .background(DHIS2BlueLight, CircleShape),
+ .background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_menu_edit),
contentDescription = "New Entry",
modifier = Modifier.size(28.dp),
- tint = DHIS2Blue
+ tint = MaterialTheme.colorScheme.primary
)
}
Column {
@@ -205,13 +200,13 @@ fun CreateNewEntryScreen(
}
Surface(
- color = DHIS2BlueLight.copy(alpha = 0.35f),
+ color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Offline mode supported. Entries will sync when connected.",
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(12.dp)
)
}
@@ -237,7 +232,6 @@ fun CreateNewEntryScreen(
value = selectedOrgUnit?.name ?: "Select Organization Unit",
onValueChange = {},
readOnly = true,
- enabled = false,
label = { Text("Organization Unit") },
trailingIcon = {
Icon(
@@ -245,7 +239,20 @@ fun CreateNewEntryScreen(
contentDescription = null
)
},
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
+ disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedBorderColor = MaterialTheme.colorScheme.outline,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ disabledBorderColor = MaterialTheme.colorScheme.outline,
+ focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
)
}
@@ -261,7 +268,20 @@ fun CreateNewEntryScreen(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedPeriod) },
modifier = Modifier
.fillMaxWidth()
- .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true)
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
+ disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedBorderColor = MaterialTheme.colorScheme.outline,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ disabledBorderColor = MaterialTheme.colorScheme.outline,
+ focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
)
ExposedDropdownMenu(
expanded = expandedPeriod,
@@ -294,48 +314,51 @@ fun CreateNewEntryScreen(
}
}
- // Attribute Option Combo Dropdown
- ExposedDropdownMenuBox(
- expanded = expandedAttributeOptionCombo && !isDefaultOnlyAttrCombo,
- onExpandedChange = {
- if (!isDefaultOnlyAttrCombo) {
+ if (!isDefaultOnlyAttrCombo) {
+ ExposedDropdownMenuBox(
+ expanded = expandedAttributeOptionCombo,
+ onExpandedChange = {
expandedAttributeOptionCombo = !expandedAttributeOptionCombo
}
- }
- ) {
- OutlinedTextField(
- value = selectedAttrComboName.ifBlank {
- if (isDefaultOnlyAttrCombo) {
- "Default"
- } else {
- "Select Attribute Option Combo"
- }
- },
- onValueChange = {},
- readOnly = true,
- enabled = !isDefaultOnlyAttrCombo,
- label = { Text("Attribute Option Combo") },
- trailingIcon = {
- if (!isDefaultOnlyAttrCombo) {
- ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedAttributeOptionCombo)
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = !isDefaultOnlyAttrCombo)
- )
- ExposedDropdownMenu(
- expanded = expandedAttributeOptionCombo && !isDefaultOnlyAttrCombo,
- onDismissRequest = { expandedAttributeOptionCombo = false }
) {
- attributeOptionCombos.forEach { (uid, displayName) ->
- DropdownMenuItem(
- text = { Text(displayName) },
- onClick = {
- selectedAttributeOptionCombo = uid
- expandedAttributeOptionCombo = false
- }
+ OutlinedTextField(
+ value = selectedAttrComboName.ifBlank { "Select Attribute Option Combo" },
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Attribute Option Combo") },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedAttributeOptionCombo)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
+ disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedBorderColor = MaterialTheme.colorScheme.outline,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ disabledBorderColor = MaterialTheme.colorScheme.outline,
+ focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
)
+ )
+ ExposedDropdownMenu(
+ expanded = expandedAttributeOptionCombo,
+ onDismissRequest = { expandedAttributeOptionCombo = false }
+ ) {
+ attributeOptionCombos.forEach { (uid, displayName) ->
+ DropdownMenuItem(
+ text = { Text(displayName) },
+ onClick = {
+ selectedAttributeOptionCombo = uid
+ expandedAttributeOptionCombo = false
+ }
+ )
+ }
}
}
}
@@ -354,29 +377,71 @@ fun CreateNewEntryScreen(
Box(
modifier = Modifier
.fillMaxWidth()
- .height(48.dp)
.clickable(enabled = !canContinue) {
coroutineScope.launch { tooltipState.show() }
}
) {
- Button(
- onClick = {
- val encodedDatasetName = java.net.URLEncoder.encode(datasetName, "UTF-8")
- val encodedPeriod = java.net.URLEncoder.encode(selectedPeriod, "UTF-8")
- val encodedOrgUnit = java.net.URLEncoder.encode(selectedOrgUnit!!.id, "UTF-8")
- val encodedAttributeOptionCombo = java.net.URLEncoder.encode(resolvedAttributeOptionCombo, "UTF-8")
- navController.navigate(
- "EditEntry/$datasetId/$encodedPeriod/$encodedOrgUnit/$encodedAttributeOptionCombo/$encodedDatasetName"
- ) {
- popUpTo("CreateDataEntry/$datasetId/$datasetName") { inclusive = true }
- }
- },
- enabled = canContinue,
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp)
- ) {
- Text("Continue")
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = {
+ val encodedDatasetName = java.net.URLEncoder.encode(datasetName, "UTF-8")
+ val encodedPeriod = java.net.URLEncoder.encode(selectedPeriod, "UTF-8")
+ val encodedOrgUnit = java.net.URLEncoder.encode(selectedOrgUnit!!.id, "UTF-8")
+ val encodedAttributeOptionCombo = java.net.URLEncoder.encode(resolvedAttributeOptionCombo, "UTF-8")
+ navController.navigate(
+ "EditEntry/$datasetId/$encodedPeriod/$encodedOrgUnit/$encodedAttributeOptionCombo/$encodedDatasetName"
+ ) {
+ popUpTo("CreateDataEntry/$datasetId/$datasetName") { inclusive = true }
+ }
+ },
+ enabled = canContinue,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ ) {
+ Text("Continue")
+ }
+
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ isFetchingExistingData = true
+ val result = viewModel.fetchExistingDataForInstance(
+ datasetId = datasetId,
+ period = selectedPeriod,
+ orgUnit = selectedOrgUnit!!.id,
+ attributeOptionCombo = resolvedAttributeOptionCombo
+ )
+ isFetchingExistingData = false
+ result.fold(
+ onSuccess = { count ->
+ if (count > 0) {
+ val encodedDatasetName = java.net.URLEncoder.encode(datasetName, "UTF-8")
+ val encodedPeriod = java.net.URLEncoder.encode(selectedPeriod, "UTF-8")
+ val encodedOrgUnit = java.net.URLEncoder.encode(selectedOrgUnit!!.id, "UTF-8")
+ val encodedAttributeOptionCombo = java.net.URLEncoder.encode(resolvedAttributeOptionCombo, "UTF-8")
+ navController.navigate(
+ "EditEntry/$datasetId/$encodedPeriod/$encodedOrgUnit/$encodedAttributeOptionCombo/$encodedDatasetName"
+ ) {
+ popUpTo("CreateDataEntry/$datasetId/$datasetName") { inclusive = true }
+ }
+ } else {
+ snackbarHostState.showSnackbar("No existing data found for this period and org unit.")
+ }
+ },
+ onFailure = { error ->
+ snackbarHostState.showSnackbar(error.message ?: "Failed to fetch existing data.")
+ }
+ )
+ }
+ },
+ enabled = canContinue && !isFetchingExistingData,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ ) {
+ Text(if (isFetchingExistingData) "Loading..." else "Load existing data")
+ }
}
}
}
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/DataEntryViewModel.kt b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/DataEntryViewModel.kt
index fe0d560..d884671 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/DataEntryViewModel.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/DataEntryViewModel.kt
@@ -102,7 +102,8 @@ data class DataEntryState(
// PERFORMANCE OPTIMIZATION: Pre-computed data element ordering per section
// This avoids expensive re-computation on every render
// Key: sectionName, Value: Map
- val dataElementOrdering: Map> = emptyMap()
+ val dataElementOrdering: Map> = emptyMap(),
+ val lastSyncTime: Long? = null
)
@HiltViewModel
@@ -125,6 +126,11 @@ class DataEntryViewModel @Inject constructor(
private var lastSuccessfulState: DataEntryState? = null
val syncController: SyncStatusController = syncStatusController
private val draftDao get() = databaseProvider.getCurrentDatabase().dataValueDraftDao()
+ private val optionSetCache = mutableMapOf>()
+ private val renderTypeCache = mutableMapOf>()
+ private val refreshInFlight = mutableSetOf()
+ private val lastRefreshByInstance = mutableMapOf()
+ private val refreshThrottleMs = 10 * 60 * 1000L
private fun emitSuccessState() {
val current = _state.value
@@ -172,6 +178,13 @@ class DataEntryViewModel @Inject constructor(
}
}
}
+ viewModelScope.launch {
+ syncController.appSyncState.collect { syncState ->
+ updateState { currentState ->
+ currentState.copy(lastSyncTime = syncState.lastSync)
+ }
+ }
+ }
}
// --- BEGIN: Per-field TextFieldValue state ---
@@ -201,7 +214,8 @@ class DataEntryViewModel @Inject constructor(
period: String,
orgUnitId: String,
attributeOptionCombo: String,
- isEditMode: Boolean
+ isEditMode: Boolean,
+ skipBackgroundRefresh: Boolean = false
) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -282,6 +296,7 @@ class DataEntryViewModel @Inject constructor(
}
val dataValuesFlow = repository.getDataValues(datasetId, period, orgUnitId, attributeOptionCombo)
+ var refreshTriggered = false
dataValuesFlow.collect { values ->
// Step 3: Process Categories (50-70%)
updateState {
@@ -381,14 +396,16 @@ class DataEntryViewModel @Inject constructor(
)
}
- val optionSets = repository.getAllOptionSetsForDataset(datasetId)
+ val optionSets = optionSetCache[datasetId] ?: repository.getAllOptionSetsForDataset(datasetId).also {
+ optionSetCache[datasetId] = it
+ }
// Compute render types on background thread to avoid UI freezes
- val renderTypes = withContext(Dispatchers.Default) {
+ val renderTypes = renderTypeCache[datasetId] ?: withContext(Dispatchers.Default) {
optionSets.mapValues { (_, optionSet) ->
optionSet.computeRenderType()
}
- }
+ }.also { renderTypeCache[datasetId] = it }
// Fetch validation rules for intelligent grouping
val validationRules = repository.getValidationRulesForDataset(datasetId)
@@ -552,6 +569,15 @@ class DataEntryViewModel @Inject constructor(
// Load draft count after data is loaded
loadDraftCount()
emitSuccessState()
+ if (!skipBackgroundRefresh && !refreshTriggered) {
+ refreshTriggered = true
+ maybeRefreshDataValues(
+ datasetId = datasetId,
+ period = period,
+ orgUnit = orgUnitId,
+ attributeOptionCombo = attributeOptionCombo
+ )
+ }
} catch (e: Exception) {
Log.e("DataEntryViewModel", "Failed to load data values", e)
@@ -646,10 +672,14 @@ class DataEntryViewModel @Inject constructor(
.mapValues { (_, sectionValues) ->
sectionValues.groupBy { it.dataElement }
}
+ val updatedValuesByCombo = updatedValues.groupBy { it.categoryOptionCombo }
+ val updatedValuesByElement = updatedValues.groupBy { it.dataElement }
currentState.copy(
dataValues = updatedValues,
dataElementGroupedSections = updatedGroupedSections,
+ valuesByCombo = updatedValuesByCombo,
+ valuesByElement = updatedValuesByElement,
currentDataValue = if (currentState.currentDataValue?.dataElement == dataElementUid && currentState.currentDataValue?.categoryOptionCombo == categoryOptionComboUid) updatedValueObject else currentState.currentDataValue
)
}
@@ -855,6 +885,42 @@ class DataEntryViewModel @Inject constructor(
}
+ private fun maybeRefreshDataValues(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ) {
+ val instanceKey = "$datasetId|$period|$orgUnit|$attributeOptionCombo"
+ val now = System.currentTimeMillis()
+ val last = lastRefreshByInstance[instanceKey] ?: 0L
+ if (now - last < refreshThrottleMs) return
+ if (refreshInFlight.contains(instanceKey)) return
+ val networkState = networkStateManager.networkState.value
+ if (!networkState.isConnected || !networkState.hasInternet) return
+
+ refreshInFlight.add(instanceKey)
+ lastRefreshByInstance[instanceKey] = now
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ repository.refreshDataValues(datasetId, period, orgUnit, attributeOptionCombo)
+ loadDataValues(
+ datasetId = datasetId,
+ datasetName = _state.value.datasetName,
+ period = period,
+ orgUnitId = orgUnit,
+ attributeOptionCombo = attributeOptionCombo,
+ isEditMode = _state.value.isEditMode,
+ skipBackgroundRefresh = true
+ )
+ } catch (e: Exception) {
+ Log.w("DataEntryViewModel", "Background refresh failed: ${e.message}")
+ } finally {
+ refreshInFlight.remove(instanceKey)
+ }
+ }
+ }
+
private fun loadDraftCount() {
viewModelScope.launch {
try {
@@ -874,6 +940,27 @@ class DataEntryViewModel @Inject constructor(
}
}
+ suspend fun fetchExistingDataForInstance(
+ datasetId: String,
+ period: String,
+ orgUnit: String,
+ attributeOptionCombo: String
+ ): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val networkState = networkStateManager.networkState.value
+ if (!networkState.isConnected || !networkState.hasInternet) {
+ return@withContext Result.failure(Exception("No internet connection"))
+ }
+ val count = repository.refreshDataValues(datasetId, period, orgUnit, attributeOptionCombo)
+ Result.success(count)
+ } catch (e: Exception) {
+ Log.e("DataEntryViewModel", "Failed to refresh existing data", e)
+ Result.failure(e)
+ }
+ }
+ }
+
fun syncDataEntry(uploadFirst: Boolean = false) {
val stateSnapshot = _state.value
if (stateSnapshot.datasetId.isEmpty()) {
@@ -1186,6 +1273,22 @@ class DataEntryViewModel @Inject constructor(
updateState { it.copy(isLoading = true, error = null) }
try {
+ val validationSummary = stateSnapshot.validationSummary
+ if (validationSummary?.hasErrors == true) {
+ val firstIssue = extractFirstValidationIssue(validationSummary)
+ val message = firstIssue?.description ?: "Validation failed. Please fix errors before submitting."
+ applyValidationIssues(validationSummary)
+ firstIssue?.affectedDataElements?.firstOrNull()?.let { navigateToDataElement(it) }
+ updateState {
+ it.copy(
+ isLoading = false,
+ validationMessage = message
+ )
+ }
+ onResult(false, message)
+ return@launch
+ }
+
val result = useCases.completeDatasetInstance(
stateSnapshot.datasetId,
stateSnapshot.period,
@@ -1194,7 +1297,6 @@ class DataEntryViewModel @Inject constructor(
)
if (result.isSuccess) {
- val validationSummary = stateSnapshot.validationSummary
val successMessage = if (validationSummary?.warningCount ?: 0 > 0) {
"Dataset marked as complete successfully. Note: ${validationSummary?.warningCount} validation warning(s) were found."
} else {
@@ -1228,6 +1330,40 @@ class DataEntryViewModel @Inject constructor(
}
}
+ private fun extractFirstValidationIssue(validationSummary: ValidationSummary): ValidationIssue? {
+ return when (val result = validationSummary.validationResult) {
+ is ValidationResult.Error -> result.errors.firstOrNull()
+ is ValidationResult.Mixed -> result.errors.firstOrNull()
+ else -> null
+ }
+ }
+
+ private fun applyValidationIssues(validationSummary: ValidationSummary) {
+ val errors = when (val result = validationSummary.validationResult) {
+ is ValidationResult.Error -> result.errors
+ is ValidationResult.Mixed -> result.errors
+ else -> emptyList()
+ }
+
+ if (errors.isEmpty()) return
+
+ val fieldErrors = errors.flatMap { issue ->
+ issue.affectedDataElements.map { it to issue.description }
+ }.toMap()
+
+ updateState { it.copy(fieldErrors = fieldErrors) }
+ }
+
+ private fun navigateToDataElement(dataElementId: String) {
+ val sections = _state.value.dataElementGroupedSections.entries.toList()
+ val targetIndex = sections.indexOfFirst { (_, elementGroups) ->
+ elementGroups.containsKey(dataElementId)
+ }
+ if (targetIndex >= 0) {
+ updateState { it.copy(currentSectionIndex = targetIndex) }
+ }
+ }
+
fun markDatasetIncomplete(onResult: (Boolean, String?) -> Unit) {
val stateSnapshot = _state.value
viewModelScope.launch {
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/EditEntryScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/EditEntryScreen.kt
index ebe29f0..be5590b 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/EditEntryScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/EditEntryScreen.kt
@@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.*
@@ -18,8 +21,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
+import android.text.format.DateUtils
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -28,6 +34,11 @@ import androidx.navigation.NavController
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.ash.simpledataentry.presentation.datasetInstances.SyncConfirmationDialog
+import com.ash.simpledataentry.presentation.core.DatePickerDialog
+import com.ash.simpledataentry.presentation.core.DatePickerUtils
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.Date
import com.ash.simpledataentry.presentation.datasetInstances.SyncOptions
import org.hisp.dhis.mobile.ui.designsystem.component.Button
import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle
@@ -62,6 +73,57 @@ import com.ash.simpledataentry.presentation.core.UiState
data class Quadruple(val first: A, val second: B, val third: C, val fourth: D)
+private val LocalShowFieldLabel = compositionLocalOf { true }
+
+private data class ParsedGridField(
+ val rowTitle: String,
+ val rowKey: String,
+ val columnTitle: String,
+ val dataValue: DataValue
+)
+
+private fun parseGridLabel(name: String): Pair? {
+ // Only split on primary separators between row and column.
+ // Avoid splitting on "/" which is commonly inside column titles like "Meeting/Workshop".
+ val delimiterRegex = Regex("\\s*[-–—:|]\\s*")
+ val matches = delimiterRegex.findAll(name).toList()
+ if (matches.isNotEmpty()) {
+ val last = matches.last()
+ val index = last.range.first
+ val delimiterLength = last.value.length
+ if (index > 0 && index < name.length - delimiterLength) {
+ val row = name.substring(0, index)
+ .replace(Regex("\\s+"), " ")
+ .trim()
+ .trimEnd('-', ':', '|', '/', '\\')
+ .trim()
+ val col = name.substring(index + delimiterLength)
+ .replace(Regex("\\s+"), " ")
+ .trim()
+ if (row.isNotBlank() && col.isNotBlank()) {
+ return row to col
+ }
+ }
+ }
+ return null
+}
+
+private fun normalizeRowKey(value: String): String {
+ return value
+ .lowercase()
+ .replace(Regex("\\s+"), " ")
+ .replace(Regex("\\s*\\(.*?\\)\\s*"), " ")
+ .trim()
+}
+
+private fun formatRelativeTime(timestamp: Long): String {
+ return DateUtils.getRelativeTimeSpanString(
+ timestamp,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS
+ ).toString()
+}
+
@Composable
fun SectionContent(
@@ -108,7 +170,8 @@ fun SectionContent(
DataValueField(
dataValue = firstValue,
onValueChange = { value -> onValueChange(value, firstValue) },
- viewModel = viewModel
+ viewModel = viewModel,
+ showLabel = false
)
} else {
// Has category combo - render nested category accordions
@@ -121,7 +184,8 @@ fun SectionContent(
viewModel = viewModel,
parentPath = listOf(elementKey),
expandedAccordions = expandedAccordions,
- onToggle = onToggle
+ onToggle = onToggle,
+ showElementHeader = false
)
}
}
@@ -133,17 +197,25 @@ fun DataElementRow(
dataElementName: String,
fields: List,
onValueChange: (String, DataValue) -> Unit,
- viewModel: DataEntryViewModel
+ viewModel: DataEntryViewModel,
+ showHeader: Boolean = true
) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)) {
- Text(text = dataElementName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
+ if (showHeader) {
+ Text(
+ text = dataElementName,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ }
fields.filterNotNull().forEach { dataValue ->
DataValueField(
dataValue = dataValue,
onValueChange = { value -> onValueChange(value, dataValue) },
- viewModel = viewModel
+ viewModel = viewModel,
+ showLabel = false
)
}
}
@@ -155,8 +227,12 @@ fun DataValueField(
dataValue: DataValue,
onValueChange: (String) -> Unit,
viewModel: DataEntryViewModel,
- enabled: Boolean = true
+ enabled: Boolean = true,
+ showLabel: Boolean = true,
+ labelOverride: String? = null,
+ compact: Boolean = false
) {
+ val effectiveShowLabel = showLabel && LocalShowFieldLabel.current
val key = remember(dataValue.dataElement, dataValue.categoryOptionCombo) {
"${dataValue.dataElement}|${dataValue.categoryOptionCombo}"
}
@@ -184,10 +260,20 @@ fun DataValueField(
!isDisabledByRule &&
!isDisabledByMetadata
+ val labelText = if (effectiveShowLabel) {
+ val baseLabel = labelOverride?.ifBlank { null } ?: dataValue.dataElementName
+ baseLabel + if (isMandatoryByRule) " *" else ""
+ } else {
+ ""
+ }
+
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp)
+ .padding(
+ horizontal = if (compact) 0.dp else 16.dp,
+ vertical = if (compact) 2.dp else 4.dp
+ )
.let { base ->
if (!effectiveEnabled) {
base.background(
@@ -199,12 +285,17 @@ fun DataValueField(
) {
when {
optionSet != null && renderType != null -> {
+ val selectedOptionCode = if (!dataValue.value.isNullOrBlank()) {
+ dataValue.value
+ } else {
+ calculatedValue
+ }
when (renderType) {
RenderType.DROPDOWN -> {
com.ash.simpledataentry.presentation.dataEntry.components.OptionSetDropdown(
optionSet = optionSet,
- selectedCode = calculatedValue ?: dataValue.value,
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ selectedCode = selectedOptionCode,
+ title = labelText,
isRequired = isMandatoryByRule,
enabled = effectiveEnabled,
onOptionSelected = { selectedCode ->
@@ -218,8 +309,8 @@ fun DataValueField(
RenderType.RADIO_BUTTONS -> {
com.ash.simpledataentry.presentation.dataEntry.components.OptionSetRadioGroup(
optionSet = optionSet,
- selectedCode = calculatedValue ?: dataValue.value,
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ selectedCode = selectedOptionCode,
+ title = labelText,
isRequired = isMandatoryByRule,
enabled = effectiveEnabled,
onOptionSelected = { selectedCode ->
@@ -232,8 +323,8 @@ fun DataValueField(
}
RenderType.YES_NO_BUTTONS -> {
com.ash.simpledataentry.presentation.dataEntry.components.YesNoCheckbox(
- selectedValue = calculatedValue ?: dataValue.value,
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ selectedValue = selectedOptionCode,
+ title = labelText,
isRequired = isMandatoryByRule,
enabled = effectiveEnabled,
onValueChanged = { newValue ->
@@ -247,8 +338,8 @@ fun DataValueField(
else -> {
com.ash.simpledataentry.presentation.dataEntry.components.OptionSetDropdown(
optionSet = optionSet,
- selectedCode = calculatedValue ?: dataValue.value,
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ selectedCode = selectedOptionCode,
+ title = labelText,
isRequired = isMandatoryByRule,
enabled = effectiveEnabled,
onOptionSelected = { selectedCode ->
@@ -262,6 +353,15 @@ fun DataValueField(
}
}
dataValue.dataEntryType == DataEntryType.YES_NO -> {
+ val yesNoLabel = if (labelText.isNotBlank()) labelText else dataValue.dataElementName
+ if (yesNoLabel.isNotBlank()) {
+ Text(
+ text = yesNoLabel,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -284,12 +384,112 @@ fun DataValueField(
)
}
}
+ dataValue.dataEntryType == DataEntryType.DATE -> {
+ var showDatePicker by remember { mutableStateOf(false) }
+ val isoFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false } }
+ val displayFormat = remember { SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).apply { isLenient = false } }
+ val fallbackFormat = remember { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US).apply { isLenient = false } }
+ val fallbackFormatNoMillis = remember { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { isLenient = false } }
+ val rawDateText = fieldState.text
+ val parsedDate = remember(rawDateText) {
+ listOf(isoFormat, displayFormat, fallbackFormat, fallbackFormatNoMillis)
+ .firstNotNullOfOrNull { formatter ->
+ runCatching { formatter.parse(rawDateText) }.getOrNull()
+ }
+ }
+ val displayValue = if (rawDateText.isBlank()) {
+ ""
+ } else {
+ parsedDate?.let { DatePickerUtils.formatDateForDisplay(it) } ?: rawDateText
+ }
+
+ OutlinedTextField(
+ value = displayValue,
+ onValueChange = {},
+ readOnly = true,
+ enabled = effectiveEnabled,
+ label = { if (labelText.isNotBlank()) Text(labelText) },
+ isError = dataValue.validationState == ValidationState.ERROR || error != null,
+ trailingIcon = {
+ IconButton(
+ onClick = { showDatePicker = true },
+ enabled = effectiveEnabled
+ ) {
+ Icon(
+ imageVector = Icons.Default.DateRange,
+ contentDescription = "Pick date"
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = effectiveEnabled) { showDatePicker = true }
+ )
+
+ if (showDatePicker) {
+ DatePickerDialog(
+ onDateSelected = { date ->
+ val isoValue = isoFormat.format(date)
+ viewModel.onFieldValueChange(TextFieldValue(isoValue), dataValue)
+ showDatePicker = false
+ },
+ onDismissRequest = { showDatePicker = false },
+ initialDate = parsedDate ?: Date(),
+ title = if (labelText.isNotBlank()) labelText else "Select date"
+ )
+ }
+ }
+ dataValue.dataEntryType == DataEntryType.YES_ONLY -> {
+ val yesOnlyLabel = if (labelText.isNotBlank()) labelText else dataValue.dataElementName
+ val isChecked = (calculatedValue ?: dataValue.value)?.lowercase() in listOf("true", "1", "yes")
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = isChecked,
+ onCheckedChange = { checked ->
+ if (effectiveEnabled) {
+ val newValue = if (checked) "true" else ""
+ onValueChange(newValue)
+ }
+ },
+ enabled = effectiveEnabled
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = yesOnlyLabel,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ dataValue.dataEntryType == DataEntryType.PHONE_NUMBER -> {
+ OutlinedTextField(
+ value = fieldState,
+ onValueChange = { newValue ->
+ if (effectiveEnabled) {
+ val cleaned = newValue.text.filter { it.isDigit() || it == '+' }
+ viewModel.onFieldValueChange(
+ newValue.copy(text = cleaned),
+ dataValue
+ )
+ }
+ },
+ label = { if (labelText.isNotBlank()) Text(labelText) },
+ isError = dataValue.validationState == ValidationState.ERROR || error != null,
+ enabled = effectiveEnabled,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
dataValue.dataEntryType == DataEntryType.NUMBER ||
dataValue.dataEntryType == DataEntryType.INTEGER ||
dataValue.dataEntryType == DataEntryType.POSITIVE_INTEGER ||
dataValue.dataEntryType == DataEntryType.NEGATIVE_INTEGER -> {
InputNumber(
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ title = labelText,
state = when {
!effectiveEnabled -> InputShellState.DISABLED
dataValue.validationState == ValidationState.ERROR || error != null -> InputShellState.ERROR
@@ -305,7 +505,7 @@ fun DataValueField(
}
dataValue.dataEntryType == DataEntryType.PERCENTAGE -> {
InputText(
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ title = labelText,
state = when {
!effectiveEnabled -> InputShellState.DISABLED
dataValue.validationState == ValidationState.ERROR || error != null -> InputShellState.ERROR
@@ -321,7 +521,7 @@ fun DataValueField(
}
else -> {
InputText(
- title = dataValue.dataElementName + if (isMandatoryByRule) " *" else "",
+ title = labelText,
state = when {
!effectiveEnabled -> InputShellState.DISABLED
dataValue.validationState == ValidationState.ERROR || error != null -> InputShellState.ERROR
@@ -367,7 +567,11 @@ fun DataElementAccordion(
.fillMaxWidth()
.padding(vertical = 6.dp),
colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surface
+ containerColor = if (hasData) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
@@ -435,7 +639,71 @@ fun DataElementAccordion(
.padding(start = 12.dp, end = 12.dp, bottom = 16.dp)
.bringIntoViewRequester(bringIntoViewRequester)
) {
- content()
+ CompositionLocalProvider(LocalShowFieldLabel provides false) {
+ content()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun GridRowCard(
+ rowTitle: String,
+ columns: List,
+ onValueChange: (String, DataValue) -> Unit,
+ viewModel: DataEntryViewModel,
+ enabled: Boolean
+) {
+ val maxItems = when {
+ columns.size >= 3 -> 3
+ else -> 2
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = rowTitle,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ CompositionLocalProvider(LocalShowFieldLabel provides true) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ maxItemsInEachRow = maxItems
+ ) {
+ columns.forEach { field ->
+ Column(
+ modifier = Modifier
+ .widthIn(min = 120.dp)
+ ) {
+ DataValueField(
+ dataValue = field.dataValue,
+ onValueChange = { value -> onValueChange(value, field.dataValue) },
+ viewModel = viewModel,
+ enabled = enabled,
+ showLabel = true,
+ labelOverride = field.columnTitle,
+ compact = true
+ )
+ }
}
}
}
@@ -543,6 +811,7 @@ fun CategoryAccordionRecursive(
parentPath: List = emptyList(),
expandedAccordions: Map, String?>,
onToggle: (List, String) -> Unit,
+ showElementHeader: Boolean = true
) {
if (categories.size == 1 &&
categories.first().second.size == 1 &&
@@ -553,7 +822,8 @@ fun CategoryAccordionRecursive(
dataElementName = dataValue.dataElementName,
fields = listOf(dataValue),
onValueChange = onValueChange,
- viewModel = viewModel
+ viewModel = viewModel,
+ showHeader = showElementHeader
)
}
return
@@ -564,7 +834,8 @@ fun CategoryAccordionRecursive(
dataElementName = dataValue.dataElementName,
fields = listOf(dataValue),
onValueChange = onValueChange,
- viewModel = viewModel
+ viewModel = viewModel,
+ showHeader = showElementHeader
)
}
return
@@ -614,7 +885,8 @@ fun CategoryAccordionRecursive(
DataValueField(
dataValue = dataValue,
onValueChange = { value -> onValueChange(value, dataValue) },
- viewModel = viewModel
+ viewModel = viewModel,
+ showLabel = false
)
}
}
@@ -644,7 +916,8 @@ fun CategoryAccordionRecursive(
DataValueField(
dataValue = dataValue,
onValueChange = { value -> onValueChange(value, dataValue) },
- viewModel = viewModel
+ viewModel = viewModel,
+ showLabel = false
)
}
}
@@ -680,7 +953,8 @@ fun CategoryAccordionRecursive(
viewModel = viewModel,
parentPath = newPath,
expandedAccordions = expandedAccordions,
- onToggle = onToggle
+ onToggle = onToggle,
+ showElementHeader = showElementHeader
)
}
}
@@ -1160,6 +1434,16 @@ fun EditEntryScreen(
},
hasSubsections = subsectionTitles.isNotEmpty()
)
+ state.lastSyncTime?.let { lastSync ->
+ Text(
+ text = "Last sync: ${formatRelativeTime(lastSync)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 8.dp)
+ )
+ }
val showFormContent = !state.isLoading && isUIReady
when {
@@ -1266,114 +1550,170 @@ fun EditEntryScreen(
}
// Section Content - Use key to prevent unnecessary recomposition
- AnimatedVisibility(
- visible = sectionIsExpanded,
- modifier = Modifier.fillMaxWidth()
- ) {
- // Memoize the section content to prevent recomposition on every animation frame
- val sectionContent = remember(sectionName, elementGroups, state.radioButtonGroups) {
- elementGroups
- }
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ if (sectionIsExpanded) {
+ AnimatedVisibility(
+ visible = true,
+ modifier = Modifier.fillMaxWidth()
) {
- if (allElementsHaveDefaultCategories) {
- // All elements have default categories - render as simple vertical list
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- // Collect all data elements for this section
- val allDataElements = sectionContent.values.flatten()
+ // Memoize the section content to prevent recomposition on every animation frame
+ val sectionContent = remember(sectionName, elementGroups, state.radioButtonGroups) {
+ elementGroups
+ }
- // Track which fields are part of grouped radio buttons
- val fieldsInGroups = state.radioButtonGroups.values.flatten().toSet()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (allElementsHaveDefaultCategories) {
+ // All elements have default categories - render as simple vertical list
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Collect all data elements for this section
+ val allDataElements = sectionContent.values.flatten()
- // Render grouped radio buttons first
- state.radioButtonGroups.forEach { (groupTitle, dataElementIds) ->
- // Only render groups where at least one field is in this section
- val groupFields = allDataElements.filter { it.dataElement in dataElementIds }
- if (groupFields.isNotEmpty()) {
- // Get optionSet if available, otherwise provide empty one
- // Note: GroupedRadioButtons doesn't actually use optionSet, it's just for API compatibility
- val optionSet = groupFields.firstOrNull()?.let { state.optionSets[it.dataElement] }
- ?: com.ash.simpledataentry.domain.model.OptionSet(id = "", name = "", options = emptyList())
+ // Track which fields are part of grouped radio buttons
+ val fieldsInGroups = state.radioButtonGroups.values.flatten().toSet()
- // Find which field (if any) has value "YES" or "true"
- val selectedFieldId = groupFields.firstOrNull {
- it.value?.lowercase() in listOf("yes", "true", "1")
- }?.dataElement
+ // Render grouped radio buttons first
+ state.radioButtonGroups.forEach { (groupTitle, dataElementIds) ->
+ // Only render groups where at least one field is in this section
+ val groupFields = allDataElements.filter { it.dataElement in dataElementIds }
+ if (groupFields.isNotEmpty()) {
+ // Get optionSet if available, otherwise provide empty one
+ // Note: GroupedRadioButtons doesn't actually use optionSet, it's just for API compatibility
+ val optionSet = groupFields.firstOrNull()?.let { state.optionSets[it.dataElement] }
+ ?: com.ash.simpledataentry.domain.model.OptionSet(id = "", name = "", options = emptyList())
- com.ash.simpledataentry.presentation.dataEntry.components.GroupedRadioButtons(
- groupTitle = groupTitle,
- fields = groupFields,
- selectedFieldId = selectedFieldId,
- optionSet = optionSet,
- enabled = true,
- onFieldSelected = { selectedDataElementId ->
- // Set selected field to YES, others to NO
- groupFields.forEach { field ->
- val newValue = if (field.dataElement == selectedDataElementId) "true" else "false"
- onValueChange(newValue, field)
- }
- },
- modifier = Modifier.fillMaxWidth()
- )
+ // Find which field (if any) has value "YES" or "true"
+ val selectedFieldId = groupFields.firstOrNull {
+ it.value?.lowercase() in listOf("yes", "true", "1")
+ }?.dataElement
+
+ com.ash.simpledataentry.presentation.dataEntry.components.GroupedRadioButtons(
+ groupTitle = groupTitle,
+ fields = groupFields,
+ selectedFieldId = selectedFieldId,
+ optionSet = optionSet,
+ enabled = true,
+ onFieldSelected = { selectedDataElementId ->
+ // Set selected field to YES, others to NO
+ groupFields.forEach { field ->
+ val newValue = if (field.dataElement == selectedDataElementId) "true" else "false"
+ onValueChange(newValue, field)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
- }
- // Then render individual fields (excluding grouped ones)
- // Use remember to prevent recomputation on every recomposition
- val individualFields = remember(sectionContent, fieldsInGroups, dataElementOrder) {
- sectionContent.entries
- .sortedBy { dataElementOrder[it.key] ?: Int.MAX_VALUE }
- .flatMap { (_, dataValues) ->
- dataValues.filter { it.dataElement !in fieldsInGroups }
+ // Then render individual fields (excluding grouped ones)
+ // Use remember to prevent recomputation on every recomposition
+ val individualFields = remember(sectionContent, fieldsInGroups, dataElementOrder) {
+ sectionContent.entries
+ .sortedBy { dataElementOrder[it.key] ?: Int.MAX_VALUE }
+ .flatMap { (_, dataValues) ->
+ dataValues.filter { it.dataElement !in fieldsInGroups }
+ }
+ }
+
+ val parsedFields = remember(individualFields) {
+ individualFields.mapNotNull { dataValue ->
+ val parsed = parseGridLabel(dataValue.dataElementName) ?: return@mapNotNull null
+ val rowKey = normalizeRowKey(parsed.first)
+ ParsedGridField(
+ rowTitle = parsed.first,
+ rowKey = rowKey,
+ columnTitle = parsed.second,
+ dataValue = dataValue
+ )
}
- }
+ }
+ val gridRowCounts = remember(parsedFields) {
+ parsedFields.groupingBy { it.rowKey }.eachCount()
+ }
+ val gridRowTitles = remember(gridRowCounts) {
+ gridRowCounts.filter { it.value >= 2 }.keys
+ }
+ val parsedByKey = remember(parsedFields) {
+ parsedFields.associateBy {
+ "${it.dataValue.dataElement}|${it.dataValue.categoryOptionCombo}"
+ }
+ }
+ val gridColumnsByRow = remember(individualFields, parsedByKey, gridRowTitles) {
+ val map = LinkedHashMap>()
+ individualFields.forEach { dataValue ->
+ val key = "${dataValue.dataElement}|${dataValue.categoryOptionCombo}"
+ val parsed = parsedByKey[key]
+ if (parsed != null && parsed.rowKey in gridRowTitles) {
+ map.getOrPut(parsed.rowKey) { mutableListOf() }.add(parsed)
+ }
+ }
+ map
+ }
- individualFields.forEach { dataValue ->
- key("field_${dataValue.dataElement}_${dataValue.categoryOptionCombo}") {
- DataValueField(
- dataValue = dataValue,
- onValueChange = { value -> onValueChange(value, dataValue) },
- viewModel = viewModel
- )
+ val renderedRows = mutableSetOf()
+ individualFields.forEach { dataValue ->
+ val key = "${dataValue.dataElement}|${dataValue.categoryOptionCombo}"
+ val parsed = parsedByKey[key]
+ if (parsed != null && parsed.rowKey in gridRowTitles) {
+ if (renderedRows.add(parsed.rowKey)) {
+ val columns = gridColumnsByRow[parsed.rowKey].orEmpty()
+ val rowTitle = columns.firstOrNull()?.rowTitle ?: parsed.rowTitle
+ key("grid_${parsed.rowKey}") {
+ GridRowCard(
+ rowTitle = rowTitle,
+ columns = columns,
+ onValueChange = onValueChange,
+ viewModel = viewModel,
+ enabled = state.isEntryEditable && !state.isCompleted
+ )
+ }
+ }
+ } else {
+ key("field_${dataValue.dataElement}_${dataValue.categoryOptionCombo}") {
+ DataValueField(
+ dataValue = dataValue,
+ onValueChange = { value -> onValueChange(value, dataValue) },
+ viewModel = viewModel
+ )
+ }
+ }
}
}
- }
- } else {
- val sectionValues = sectionContent.values.flatten()
- SectionContent(
- sectionName = sectionName,
- values = sectionValues,
- valuesByCombo = state.valuesByCombo,
- valuesByElement = state.valuesByElement,
- dataElementsForSection = state.dataElementsBySection[sectionName].orEmpty(),
- categoryComboStructures = state.categoryComboStructures,
- optionUidsToComboUidByCombo = state.optionUidsToComboUid,
- onValueChange = onValueChange,
- viewModel = viewModel,
- expandedAccordions = expandedAccordions.value,
- onToggle = onAccordionToggle,
- onElementSelected = { dataElementId ->
- val targetTitle = subsectionGroups
- .firstOrNull { group ->
- group.members.any { it.dataElement == dataElementId }
+ } else {
+ val sectionValues = sectionContent.values.flatten()
+ SectionContent(
+ sectionName = sectionName,
+ values = sectionValues,
+ valuesByCombo = state.valuesByCombo,
+ valuesByElement = state.valuesByElement,
+ dataElementsForSection = state.dataElementsBySection[sectionName].orEmpty(),
+ categoryComboStructures = state.categoryComboStructures,
+ optionUidsToComboUidByCombo = state.optionUidsToComboUid,
+ onValueChange = onValueChange,
+ viewModel = viewModel,
+ expandedAccordions = expandedAccordions.value,
+ onToggle = onAccordionToggle,
+ onElementSelected = { dataElementId ->
+ val targetTitle = subsectionGroups
+ .firstOrNull { group ->
+ group.members.any { it.dataElement == dataElementId }
+ }
+ ?.groupTitle
+ val targetIndex = subsectionTitles.indexOf(targetTitle)
+ if (targetIndex >= 0) {
+ subsectionIndex = targetIndex
}
- ?.groupTitle
- val targetIndex = subsectionTitles.indexOf(targetTitle)
- if (targetIndex >= 0) {
- subsectionIndex = targetIndex
}
- }
- )
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/components/OptionSetComponents.kt b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/components/OptionSetComponents.kt
index e8623ed..f20df40 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/components/OptionSetComponents.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/dataEntry/components/OptionSetComponents.kt
@@ -32,6 +32,8 @@ fun OptionSetDropdown(
var expanded by remember { mutableStateOf(false) }
+ val labelText = if (isRequired) "$title *" else title
+
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (enabled) expanded = !expanded },
@@ -41,7 +43,11 @@ fun OptionSetDropdown(
value = selectedOption?.let { it.displayName ?: it.name } ?: "",
onValueChange = {},
readOnly = true,
- label = { Text(if (isRequired) "$title *" else title) },
+ label = if (labelText.isNotBlank()) {
+ { Text(labelText) }
+ } else {
+ null
+ },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
enabled = enabled,
modifier = Modifier
@@ -83,11 +89,14 @@ fun OptionSetRadioGroup(
val sortedOptions = optionSet.options.sortedBy { it.sortOrder }
Column(modifier = modifier) {
- Text(
- text = if (isRequired) "$title *" else title,
- style = MaterialTheme.typography.bodyMedium
- )
- Spacer(modifier = Modifier.height(8.dp))
+ val labelText = if (isRequired) "$title *" else title
+ if (labelText.isNotBlank()) {
+ Text(
+ text = labelText,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
sortedOptions.forEach { option ->
Row(
@@ -135,6 +144,7 @@ fun YesNoCheckbox(
localChecked = isChecked
}
+ val labelText = if (isRequired) "$title *" else title
Row(
modifier = modifier
.fillMaxWidth()
@@ -157,10 +167,12 @@ fun YesNoCheckbox(
enabled = enabled
)
Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = if (isRequired) "$title *" else title,
- style = MaterialTheme.typography.bodyMedium
- )
+ if (labelText.isNotBlank()) {
+ Text(
+ text = labelText,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
}
}
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesScreen.kt
index 5fa6d36..7c29c69 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesScreen.kt
@@ -669,6 +669,7 @@ fun DatasetInstancesScreen(
title = datasetName,
subtitle = subtitle,
navController = navController,
+ usePrimaryTopBar = false,
syncStatusController = viewModel.syncController,
actions = {
IconButton(
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesViewModel.kt b/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesViewModel.kt
index 7e3c114..2ff2b75 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesViewModel.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/datasetInstances/DatasetInstancesViewModel.kt
@@ -34,6 +34,7 @@ import com.ash.simpledataentry.presentation.core.UiState
import com.ash.simpledataentry.util.toUiError
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -572,7 +573,12 @@ class DatasetInstancesViewModel @Inject constructor(
} else {
"Dataset instances synced successfully"
}
- loadData() // Reload all data after sync
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ clearDraftsForInstances(currentData().instancesWithDrafts)
+ }
+ loadData() // Reload all data after sync
+ }
},
onFailure = { throwable ->
Log.e("DatasetInstancesVM", "Dataset sync failed", throwable)
@@ -621,8 +627,18 @@ class DatasetInstancesViewModel @Inject constructor(
result.fold(
onSuccess = {
syncQueueManager.clearErrorState()
- loadData()
- onResult(true, "Entry synced successfully.")
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ draftDao.deleteDraftsForInstance(
+ datasetId = instance.programId,
+ period = instance.period.id,
+ orgUnit = instance.organisationUnit.id,
+ attributeOptionCombo = instance.attributeOptionCombo
+ )
+ }
+ loadData()
+ onResult(true, "Entry synced successfully.")
+ }
},
onFailure = { error ->
syncQueueManager.clearErrorState()
@@ -642,6 +658,23 @@ class DatasetInstancesViewModel @Inject constructor(
loadData()
}
+ private suspend fun clearDraftsForInstances(instanceKeys: Set) {
+ if (instanceKeys.isEmpty()) {
+ return
+ }
+ instanceKeys.forEach { key ->
+ val parts = key.split("|")
+ if (parts.size >= 4) {
+ draftDao.deleteDraftsForInstance(
+ datasetId = parts[0],
+ period = parts[1],
+ orgUnit = parts[2],
+ attributeOptionCombo = parts[3]
+ )
+ }
+ }
+ }
+
fun dismissSyncOverlay() {
// Clear error state in SyncQueueManager to prevent persistent dialogs
syncQueueManager.clearErrorState()
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsScreen.kt
index 10e4a5a..a8d6179 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsScreen.kt
@@ -16,53 +16,45 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.People
-import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.automirrored.filled.Logout
-import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
-import androidx.compose.material.icons.filled.DataUsage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalDrawerSheet
-import androidx.compose.material3.ModalNavigationDrawer
-import androidx.compose.material3.NavigationDrawerItem
-import androidx.compose.material3.NavigationDrawerItemDefaults
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.ScrollableTabRow
-import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRowDefaults
-import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.rememberDrawerState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@@ -71,7 +63,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -80,22 +72,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.ash.simpledataentry.data.SessionManager
import com.ash.simpledataentry.domain.model.FilterState
import com.ash.simpledataentry.domain.model.DatasetPeriodType
import com.ash.simpledataentry.domain.model.OrganisationUnit
import com.ash.simpledataentry.domain.model.ProgramItem
import com.ash.simpledataentry.domain.model.ProgramType as DomainProgramType
import com.ash.simpledataentry.navigation.Screen
-import com.ash.simpledataentry.presentation.core.BaseScreen
import com.ash.simpledataentry.presentation.core.OrgUnitTreeMultiPickerDialog
-import kotlinx.coroutines.launch
-import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -103,8 +87,6 @@ import com.ash.simpledataentry.presentation.datasets.components.DatasetIcon
import com.ash.simpledataentry.presentation.datasets.components.ProgramType
import com.ash.simpledataentry.presentation.core.AdaptiveLoadingOverlay
import com.ash.simpledataentry.presentation.core.UiState
-import com.ash.simpledataentry.presentation.core.LoadingOperation
-import com.ash.simpledataentry.ui.theme.DHIS2BlueDeep
import com.ash.simpledataentry.ui.theme.DatasetAccent
import com.ash.simpledataentry.ui.theme.DatasetAccentLight
import com.ash.simpledataentry.ui.theme.EventAccent
@@ -113,6 +95,151 @@ import com.ash.simpledataentry.ui.theme.TrackerAccent
import com.ash.simpledataentry.ui.theme.TrackerAccentLight
import android.text.format.DateUtils
+@Composable
+private fun HomeCategoryCard(
+ title: String,
+ subtitle: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ isSelected: Boolean,
+ accentColor: Color,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val containerColor = MaterialTheme.colorScheme.surface
+ val borderColor = if (isSelected) accentColor else MaterialTheme.colorScheme.outlineVariant
+
+ Card(
+ modifier = modifier
+ .height(150.dp)
+ .clickable { onClick() },
+ shape = RoundedCornerShape(18.dp),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ border = BorderStroke(1.dp, borderColor)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .background(
+ color = accentColor.copy(alpha = 0.12f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = accentColor,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .height(4.dp)
+ .fillMaxWidth(0.4f)
+ .background(
+ color = accentColor,
+ shape = RoundedCornerShape(999.dp)
+ )
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun HomeRecentItem(
+ program: ProgramItem,
+ onClick: () -> Unit
+) {
+ val (accentColor, accentLightColor) = when (program.programType) {
+ DomainProgramType.DATASET -> DatasetAccent to DatasetAccentLight
+ DomainProgramType.EVENT -> EventAccent to EventAccentLight
+ DomainProgramType.TRACKER -> TrackerAccent to TrackerAccentLight
+ else -> DatasetAccent to DatasetAccentLight
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ onClick = onClick
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .background(color = accentLightColor, shape = CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ DatasetIcon(
+ style = when (program) {
+ is ProgramItem.DatasetProgram -> program.style
+ else -> null
+ },
+ size = 18.dp,
+ programType = when (program.programType) {
+ DomainProgramType.DATASET -> ProgramType.DATASET
+ DomainProgramType.TRACKER -> ProgramType.TRACKER_PROGRAM
+ DomainProgramType.EVENT -> ProgramType.EVENT_PROGRAM
+ else -> ProgramType.DATASET
+ },
+ tint = accentColor
+ )
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = program.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1
+ )
+ Text(
+ text = "${program.instanceCount} entries",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatasetsFilterSection(
@@ -167,7 +294,7 @@ fun DatasetsFilterSection(
)
)
},
- label = { Text("Search datasets", color = Color.White) },
+ label = { Text("Search programs", color = Color.White) },
placeholder = { Text("Enter dataset name...", color = Color.White.copy(alpha = 0.7f)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
@@ -368,22 +495,12 @@ fun DatasetsScreen(
navController: NavController,
viewModel: DatasetsViewModel = hiltViewModel()
) {
- val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
- val scope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
- var showDeleteConfirmation by remember { mutableStateOf(false) }
- var showFilterSection by remember { mutableStateOf(false) }
- var filterOrgUnits by remember { mutableStateOf>(emptyList()) }
- var attachedOrgUnitIds by remember { mutableStateOf>(emptySet()) }
+ var selectedTab by rememberSaveable { mutableStateOf(HomeTab.Home) }
+ var searchQuery by remember { mutableStateOf("") }
val activeAccountLabel by viewModel.activeAccountLabel.collectAsState()
val activeAccountSubtitle by viewModel.activeAccountSubtitle.collectAsState()
- val subtitle = when ((uiState as? UiState.Success)?.data?.currentProgramType ?: DomainProgramType.ALL) {
- DomainProgramType.ALL -> "All programs"
- DomainProgramType.DATASET -> "Datasets"
- DomainProgramType.TRACKER -> "Tracker programs"
- DomainProgramType.EVENT -> "Event programs"
- }
val syncState by viewModel.syncController.appSyncState.collectAsState()
val backgroundSyncRunning by viewModel.backgroundSyncRunning.collectAsState()
val isRefreshingAfterSync by viewModel.isRefreshingAfterSync.collectAsState()
@@ -395,606 +512,695 @@ fun DatasetsScreen(
else -> "Up to date"
}
- // Do not auto-sync when navigating back; sync is login/ manual only.
- LaunchedEffect(Unit) {
- filterOrgUnits = runCatching { viewModel.getScopedOrgUnits() }.getOrDefault(emptyList())
- }
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ selected = selectedTab == HomeTab.Activities,
+ onClick = { selectedTab = HomeTab.Activities },
+ icon = { Icon(Icons.Default.AccessTime, contentDescription = null) },
+ label = { Text("Activities") }
+ )
+ NavigationBarItem(
+ selected = selectedTab == HomeTab.Home,
+ onClick = { selectedTab = HomeTab.Home },
+ icon = { Icon(Icons.Default.Home, contentDescription = null) },
+ label = { Text("DHIS2 Home") }
+ )
+ NavigationBarItem(
+ selected = selectedTab == HomeTab.Account,
+ onClick = { selectedTab = HomeTab.Account },
+ icon = { Icon(Icons.Default.AccountCircle, contentDescription = null) },
+ label = { Text("My Account") }
+ )
+ }
+ }
+ ) { innerPadding ->
+ AdaptiveLoadingOverlay(
+ uiState = uiState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ val data = when (val state = uiState) {
+ is UiState.Success -> state.data
+ is UiState.Error -> state.previousData ?: DatasetsData()
+ is UiState.Loading -> DatasetsData()
+ }
- ModalNavigationDrawer(
- drawerState = drawerState,
- drawerContent = {
- ModalDrawerSheet {
- Spacer(modifier = Modifier.height(16.dp))
- val headerTitle = activeAccountLabel ?: "Menu"
- val headerSubtitle = activeAccountSubtitle.orEmpty()
- Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
- Text(
- text = headerTitle,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- if (headerSubtitle.isNotBlank()) {
- Text(
- text = headerSubtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ LaunchedEffect(data.syncMessage) {
+ data.syncMessage?.let { message ->
+ snackbarHostState.showSnackbar(message)
+ viewModel.clearSyncMessage()
+ }
+ }
+
+ LaunchedEffect(data.currentFilter.searchQuery) {
+ if (searchQuery != data.currentFilter.searchQuery) {
+ searchQuery = data.currentFilter.searchQuery
+ }
+ }
+
+ val applySearch: (String) -> Unit = { query ->
+ val trimmed = query.trim()
+ viewModel.applyFilter(data.currentFilter.copy(searchQuery = trimmed))
+ if (trimmed.isBlank()) {
+ viewModel.filterByProgramType(DomainProgramType.ALL)
+ } else {
+ val matchingTypes = data.programs.filter { program ->
+ program.name.contains(trimmed, ignoreCase = true) ||
+ (program.description?.contains(trimmed, ignoreCase = true) == true)
+ }.map { it.programType }.distinct()
+ if (matchingTypes.size == 1) {
+ viewModel.filterByProgramType(matchingTypes.first())
}
}
+ }
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ val recentPrograms = remember(data.programs) {
+ data.programs
+ .filter { it.instanceCount > 0 }
+ .sortedByDescending { it.instanceCount }
+ .take(8)
+ }
- Text(
- text = "Menu",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
- )
+ when (selectedTab) {
+ HomeTab.Home -> {
+ HomeContent(
+ navController = navController,
+ data = data,
+ recentPrograms = recentPrograms,
+ searchQuery = searchQuery,
+ onSearchChange = {
+ searchQuery = it
+ applySearch(it)
+ },
+ onProgramTypeSelected = { viewModel.filterByProgramType(it) },
+ onProgramSelected = { viewModel.prefetchProgramIfNeeded(it) },
+ onSyncClick = {
+ if (uiState !is UiState.Loading) {
+ viewModel.downloadOnlySync()
+ }
+ },
+ syncInProgress = syncState.isRunning,
+ activeAccountLabel = activeAccountLabel,
+ lastSyncLabel = lastSyncLabel,
+ syncStatusLabel = syncStatusLabel,
+ syncState = syncState
+ )
+ }
- NavigationDrawerItem(
- icon = { Icon(Icons.Default.Settings, contentDescription = null) },
- label = { Text("Settings") },
- selected = false,
- onClick = {
- scope.launch {
- drawerState.close()
- navController.navigate("settings")
- }
- }
- )
+ HomeTab.Activities -> {
+ ActivitiesContent(
+ recentPrograms = recentPrograms,
+ navController = navController,
+ activeAccountLabel = activeAccountLabel
+ )
+ }
- NavigationDrawerItem(
- icon = { Icon(Icons.Default.Info, contentDescription = null) },
- label = { Text("About") },
- selected = false,
- onClick = {
- scope.launch {
- drawerState.close()
- navController.navigate(Screen.AboutScreen.route)
+ HomeTab.Account -> {
+ AccountContent(
+ activeAccountLabel = activeAccountLabel,
+ activeAccountSubtitle = activeAccountSubtitle,
+ onEditAccount = { navController.navigate(Screen.EditAccountScreen.route) },
+ onSettings = { navController.navigate(Screen.SettingsScreen.route) },
+ onAbout = { navController.navigate(Screen.AboutScreen.route) },
+ onReportIssues = { navController.navigate(Screen.ReportIssuesScreen.route) },
+ onLogout = {
+ viewModel.logout()
+ navController.navigate("login") { popUpTo(0) }
}
- }
- )
+ )
+ }
+ }
- NavigationDrawerItem(
- icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
- label = { Text("Report Issues") },
- selected = false,
- onClick = {
- scope.launch {
- drawerState.close()
- navController.navigate(Screen.ReportIssuesScreen.route)
- }
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ snackbar = { data ->
+ Snackbar(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = Color.White
+ ) {
+ Text(
+ data.visuals.message,
+ style = MaterialTheme.typography.bodyLarge
+ )
}
- )
+ }
+ )
+ }
+ }
- Spacer(modifier = Modifier.weight(1f))
+}
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+private enum class HomeTab {
+ Activities,
+ Home,
+ Account
+}
- Text(
- text = "Account",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
- )
+@Composable
+private fun HomeContent(
+ navController: NavController,
+ data: DatasetsData,
+ recentPrograms: List,
+ searchQuery: String,
+ onSearchChange: (String) -> Unit,
+ onProgramTypeSelected: (DomainProgramType) -> Unit,
+ onProgramSelected: (ProgramItem) -> Unit,
+ onSyncClick: () -> Unit,
+ syncInProgress: Boolean,
+ activeAccountLabel: String?,
+ lastSyncLabel: String,
+ syncStatusLabel: String,
+ syncState: com.ash.simpledataentry.data.sync.AppSyncState
+) {
+ val welcomeName = activeAccountLabel ?: "User"
+ val programs = data.filteredPrograms
+ val showProgramList = searchQuery.isNotBlank() || data.currentProgramType != DomainProgramType.ALL
- NavigationDrawerItem(
- icon = { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) },
- label = { Text("Logout") },
- selected = false,
- onClick = {
- scope.launch {
- viewModel.logout()
- navController.navigate("login") {
- popUpTo(0)
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 140.dp)
+ ) {
+ item {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "DHIS2 Home",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ IconButton(onClick = onSyncClick) {
+ if (syncInProgress) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Sync,
+ contentDescription = "Sync data",
+ tint = MaterialTheme.colorScheme.primary
+ )
}
}
}
- )
-
- NavigationDrawerItem(
- icon = { Icon(Icons.Default.Delete, contentDescription = null) },
- label = { Text("Delete Account") },
- selected = false,
- onClick = {
- scope.launch {
- drawerState.close()
- showDeleteConfirmation = true
- }
- },
- colors = NavigationDrawerItemDefaults.colors(
- unselectedIconColor = MaterialTheme.colorScheme.error,
- unselectedTextColor = MaterialTheme.colorScheme.error
- )
- )
- Spacer(modifier = Modifier.height(16.dp))
- }
- },
- gesturesEnabled = true
- ) {
- BaseScreen(
- title = "Home",
- subtitle = subtitle,
- navController = navController,
- actions = {
- // Background loading indicator during sync
- if (syncState.isRunning) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.primary
+ Text(
+ text = "Welcome, $welcomeName",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
+ }
- // Sync button
- IconButton(
- onClick = {
- if (uiState !is UiState.Loading) {
- viewModel.downloadOnlySync()
- }
- }
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- Icon(
- imageVector = Icons.Default.Sync,
- contentDescription = "Download latest data",
- tint = TextColor.OnSurface,
- modifier = Modifier.size(24.dp)
+ HomeCategoryCard(
+ title = "Datasets",
+ subtitle = "Data Collection",
+ icon = Icons.Default.Storage,
+ isSelected = data.currentProgramType == DomainProgramType.DATASET,
+ accentColor = DatasetAccent,
+ onClick = { onProgramTypeSelected(DomainProgramType.DATASET) },
+ modifier = Modifier.weight(1f)
)
- }
-
- IconButton(onClick = { showFilterSection = !showFilterSection }) {
- Icon(
- imageVector = Icons.Default.FilterList,
- contentDescription = "Filter & Sort",
- tint = if (showFilterSection) Color.White else TextColor.OnSurface,
- modifier = Modifier.size(24.dp)
+ HomeCategoryCard(
+ title = "Tracker",
+ subtitle = "Follow Up",
+ icon = Icons.Default.People,
+ isSelected = data.currentProgramType == DomainProgramType.TRACKER,
+ accentColor = TrackerAccent,
+ onClick = { onProgramTypeSelected(DomainProgramType.TRACKER) },
+ modifier = Modifier.weight(1f)
)
- }
- },
- navigationIcon = {
- IconButton(
- onClick = {
- scope.launch {
- drawerState.open()
- }
- }
- ) {
- Icon(
- imageVector = Icons.Default.Menu,
- contentDescription = "Menu",
- tint = TextColor.OnSurface,
- modifier = Modifier.size(24.dp)
+ HomeCategoryCard(
+ title = "Events",
+ subtitle = "Event Entry",
+ icon = Icons.Default.Event,
+ isSelected = data.currentProgramType == DomainProgramType.EVENT,
+ accentColor = EventAccent,
+ onClick = { onProgramTypeSelected(DomainProgramType.EVENT) },
+ modifier = Modifier.weight(1f)
)
}
}
- ) {
- AdaptiveLoadingOverlay(
- uiState = uiState,
- modifier = Modifier.fillMaxSize()
- ) {
- // Extract data safely from UiState
- val data = when (val state = uiState) {
- is UiState.Success -> state.data
- is UiState.Error -> state.previousData ?: DatasetsData()
- is UiState.Loading -> DatasetsData()
- }
- val datasetIds = remember(data.programs) {
- data.programs
- .filterIsInstance()
- .map { it.id }
- .distinct()
- }
- LaunchedEffect(datasetIds) {
- attachedOrgUnitIds = runCatching {
- viewModel.getAttachedOrgUnitIdsForDatasets(datasetIds)
- }.getOrDefault(emptySet())
- }
-
- Column {
- // Pull-down filter section
- AnimatedVisibility(
- visible = showFilterSection,
- enter = slideInVertically(
- initialOffsetY = { -it },
- animationSpec = androidx.compose.animation.core.tween(200)
- ),
- exit = slideOutVertically(
- targetOffsetY = { -it },
- animationSpec = androidx.compose.animation.core.tween(150)
- )
- ) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = DHIS2BlueDeep
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
- shape = RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp, bottomStart = 8.dp, bottomEnd = 8.dp)
- ) {
- DatasetsFilterSection(
- currentFilter = data.currentFilter,
- orgUnits = filterOrgUnits,
- attachedOrgUnitIds = attachedOrgUnitIds,
- onApplyFilter = { newFilter ->
- viewModel.applyFilter(newFilter)
+ if (showProgramList) {
+ if (programs.isEmpty()) {
+ item {
+ val showMessage = data.currentProgramType != DomainProgramType.ALL ||
+ data.currentFilter.searchQuery.isNotBlank()
+ if (showMessage) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "No programs found",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Try re-sync to retrieve metadata.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ TextButton(onClick = onSyncClick) {
+ Text("Sync metadata")
+ }
}
- )
+ }
}
}
-
- // Program type filter tabs
- ScrollableTabRow(
- selectedTabIndex = when (data.currentProgramType) {
- DomainProgramType.ALL -> 0
- DomainProgramType.DATASET -> 1
- DomainProgramType.TRACKER -> 2
- DomainProgramType.EVENT -> 3
- },
- modifier = Modifier.fillMaxWidth(),
- containerColor = MaterialTheme.colorScheme.surface,
- contentColor = MaterialTheme.colorScheme.onSurface,
- indicator = { tabPositions ->
- TabRowDefaults.SecondaryIndicator(
- modifier = Modifier.tabIndicatorOffset(tabPositions[when (data.currentProgramType) {
- DomainProgramType.ALL -> 0
- DomainProgramType.DATASET -> 1
- DomainProgramType.TRACKER -> 2
- DomainProgramType.EVENT -> 3
- }]),
- color = MaterialTheme.colorScheme.primary
- )
+ } else {
+ items(items = programs, key = { it.id }) { program ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 2.dp,
+ pressedElevation = 4.dp
+ ),
+ onClick = {
+ onProgramSelected(program)
+ val route = when (program.programType) {
+ DomainProgramType.TRACKER -> "TrackerEnrollments/${program.id}/${program.name}"
+ DomainProgramType.EVENT -> "EventInstances/${program.id}/${program.name}"
+ else -> "DatasetInstances/${program.id}/${program.name}"
}
+ navController.navigate(route)
+ }
) {
- // All Programs Tab
- Tab(
- selected = data.currentProgramType == DomainProgramType.ALL,
- onClick = { viewModel.filterByProgramType(DomainProgramType.ALL) },
- text = { Text(text = "All", style = MaterialTheme.typography.labelMedium) },
- icon = {
- Icon(
- imageVector = Icons.Default.Apps,
- contentDescription = null
- )
- }
- )
-
- // Datasets Tab
- Tab(
- selected = data.currentProgramType == DomainProgramType.DATASET,
- onClick = { viewModel.filterByProgramType(DomainProgramType.DATASET) },
- text = { Text(text = "Datasets", style = MaterialTheme.typography.labelMedium) },
- icon = {
- Icon(
- imageVector = Icons.Default.Storage,
- contentDescription = null
- )
- }
- )
-
- // Tracker Programs Tab
- Tab(
- selected = data.currentProgramType == DomainProgramType.TRACKER,
- onClick = { viewModel.filterByProgramType(DomainProgramType.TRACKER) },
- text = { Text(text = "Tracker", style = MaterialTheme.typography.labelMedium) },
- icon = {
- Icon(
- imageVector = Icons.Default.People,
- contentDescription = null
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ val (accentColor, accentLightColor) = when (program.programType) {
+ DomainProgramType.DATASET -> DatasetAccent to DatasetAccentLight
+ DomainProgramType.EVENT -> EventAccent to EventAccentLight
+ DomainProgramType.TRACKER -> TrackerAccent to TrackerAccentLight
+ else -> DatasetAccent to DatasetAccentLight
}
- )
- // Event Programs Tab
- Tab(
- selected = data.currentProgramType == DomainProgramType.EVENT,
- onClick = { viewModel.filterByProgramType(DomainProgramType.EVENT) },
- text = { Text(text = "Events", style = MaterialTheme.typography.labelMedium) },
- icon = {
- Icon(
- imageVector = Icons.Default.Event,
- contentDescription = null
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .background(
+ color = accentLightColor,
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ DatasetIcon(
+ style = when (program) {
+ is ProgramItem.DatasetProgram -> program.style
+ else -> null
+ },
+ size = 22.dp,
+ programType = when (program.programType) {
+ DomainProgramType.DATASET -> ProgramType.DATASET
+ DomainProgramType.TRACKER -> ProgramType.TRACKER_PROGRAM
+ DomainProgramType.EVENT -> ProgramType.EVENT_PROGRAM
+ else -> ProgramType.DATASET
+ },
+ tint = accentColor
)
}
- )
- }
-
- // Show sync success message
- LaunchedEffect(data.syncMessage) {
- data.syncMessage?.let { message ->
- snackbarHostState.showSnackbar(message)
- // Don't clear immediately - let snackbar show first
- kotlinx.coroutines.delay(2000)
- viewModel.clearSyncMessage()
- }
- }
- // Main content - programs list
- val programs = data.filteredPrograms
- if (programs.isEmpty()) {
- val hasFilters = data.currentFilter != FilterState() || data.currentProgramType != DomainProgramType.ALL
- val headline = if (hasFilters) "No programs match your filters" else "No programs available"
- val guidance = if (hasFilters) {
- "Clear filters or change program type."
- } else {
- "Try syncing or confirm your account access."
- }
-
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(24.dp),
- contentAlignment = Alignment.Center
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = headline,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = guidance,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- if (hasFilters) {
- OutlinedButton(
- onClick = {
- viewModel.applyFilter(FilterState())
- viewModel.filterByProgramType(DomainProgramType.ALL)
- }
- ) {
- Text("Clear filters")
- }
- }
- Button(
- onClick = { viewModel.downloadOnlySync() }
- ) {
- Text("Sync now")
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val (countSingular, countPlural) = when (program.programType) {
+ DomainProgramType.DATASET -> "entry" to "entries"
+ DomainProgramType.TRACKER -> "enrollment" to "enrollments"
+ DomainProgramType.EVENT -> "event" to "events"
+ else -> "item" to "items"
}
- }
- }
- }
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- items(items = programs, key = { it.id }) { program ->
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surface
- ),
- elevation = CardDefaults.cardElevation(
- defaultElevation = 2.dp,
- pressedElevation = 4.dp
- ),
- onClick = {
- // Route to appropriate screen based on program type
- val route = when (program.programType) {
- DomainProgramType.TRACKER -> "TrackerEnrollments/${program.id}/${program.name}"
- DomainProgramType.EVENT -> "EventInstances/${program.id}/${program.name}"
- else -> "DatasetInstances/${program.id}/${program.name}"
- }
- navController.navigate(route)
+ val countLabel = if (program.instanceCount == 1) countSingular else countPlural
+ val countText = if (program.instanceCount > 0) {
+ "${program.instanceCount} $countLabel"
+ } else {
+ "No $countPlural yet"
}
- ) {
+
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- val (accentColor, accentLightColor) = when (program.programType) {
- DomainProgramType.DATASET -> DatasetAccent to DatasetAccentLight
- DomainProgramType.EVENT -> EventAccent to EventAccentLight
- DomainProgramType.TRACKER -> TrackerAccent to TrackerAccentLight
- else -> DatasetAccent to DatasetAccentLight
- }
+ Text(
+ text = program.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ modifier = Modifier.weight(1f, fill = false)
+ )
- Box(
- modifier = Modifier
- .size(44.dp)
- .background(
- color = accentLightColor,
- shape = CircleShape
- ),
- contentAlignment = Alignment.Center
+ Surface(
+ color = accentLightColor,
+ shape = RoundedCornerShape(12.dp)
) {
- DatasetIcon(
- style = when (program) {
- is ProgramItem.DatasetProgram -> program.style
- else -> null
- },
- size = 22.dp,
- programType = when (program.programType) {
- DomainProgramType.DATASET -> ProgramType.DATASET
- DomainProgramType.TRACKER -> ProgramType.TRACKER_PROGRAM
- DomainProgramType.EVENT -> ProgramType.EVENT_PROGRAM
- else -> ProgramType.DATASET
- },
- tint = accentColor
+ Text(
+ text = program.instanceCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = accentColor,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
- // Content column
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- val (countSingular, countPlural) = when (program.programType) {
- DomainProgramType.DATASET -> "entry" to "entries"
- DomainProgramType.TRACKER -> "enrollment" to "enrollments"
- DomainProgramType.EVENT -> "event" to "events"
- else -> "item" to "items"
- }
- val countLabel = if (program.instanceCount == 1) countSingular else countPlural
- val countText = if (program.instanceCount > 0) {
- "${program.instanceCount} $countLabel"
- } else {
- "No $countPlural yet"
- }
-
- // Title with program type badge
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = program.name,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 2,
- modifier = Modifier.weight(1f, fill = false)
- )
-
- Surface(
- color = accentLightColor,
- shape = RoundedCornerShape(12.dp)
- ) {
- Text(
- text = program.instanceCount.toString(),
- style = MaterialTheme.typography.labelSmall,
- color = accentColor,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
- )
- }
-
- Icon(
- imageVector = Icons.Default.ChevronRight,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- // Description (if available)
- program.description?.let { description ->
- if (description.isNotBlank()) {
- Text(
- text = description,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 2
- )
- }
- }
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ program.description?.let { description ->
+ if (description.isNotBlank()) {
Text(
- text = countText,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2
)
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = "Last sync: $lastSyncLabel",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- SyncStatusChip(
- label = syncStatusLabel,
- isError = !syncState.error.isNullOrBlank()
- )
- }
}
}
+
+ Text(
+ text = countText,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Last sync: $lastSyncLabel",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ SyncStatusChip(
+ label = syncStatusLabel,
+ isError = !syncState.error.isNullOrBlank()
+ )
+ }
}
}
}
}
}
}
- }
+ }
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(horizontal = 16.dp, vertical = 16.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ TextField(
+ value = searchQuery,
+ onValueChange = onSearchChange,
+ placeholder = { Text("Search programs...") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null
+ )
+ },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ shape = RoundedCornerShape(18.dp)
+ )
+
+ IconButton(
+ onClick = { navController.navigate(Screen.SettingsScreen.route) }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+
+ }
+}
+
+@Composable
+private fun ActivitiesContent(
+ recentPrograms: List,
+ navController: NavController,
+ activeAccountLabel: String?
+) {
+ val welcomeName = activeAccountLabel ?: "User"
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+ item {
+ Text(
+ text = "Activities",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Welcome, $welcomeName",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
- Box(modifier = Modifier.fillMaxSize()) {
- SnackbarHost(
- hostState = snackbarHostState,
+ if (recentPrograms.isEmpty()) {
+ item {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
modifier = Modifier
- .align(Alignment.BottomCenter)
+ .fillMaxWidth()
.padding(16.dp),
- snackbar = { data ->
- Snackbar(
- containerColor = MaterialTheme.colorScheme.surface,
- contentColor = Color.White
- ) {
- Text(
- data.visuals.message,
- style = MaterialTheme.typography.bodyLarge
- )
- }
- }
- )
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ text = "No recent activity yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Your latest activity will appear here.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
+ } else {
+ items(items = recentPrograms, key = { it.id }) { program ->
+ HomeRecentItem(
+ program = program,
+ onClick = {
+ val route = when (program.programType) {
+ DomainProgramType.TRACKER -> "TrackerEnrollments/${program.id}/${program.name}"
+ DomainProgramType.EVENT -> "EventInstances/${program.id}/${program.name}"
+ else -> "DatasetInstances/${program.id}/${program.name}"
+ }
+ navController.navigate(route)
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+}
- // Delete Account Confirmation Dialog
- if (showDeleteConfirmation) {
- val context = androidx.compose.ui.platform.LocalContext.current
+@Composable
+private fun AccountContent(
+ activeAccountLabel: String?,
+ activeAccountSubtitle: String?,
+ onEditAccount: () -> Unit,
+ onSettings: () -> Unit,
+ onAbout: () -> Unit,
+ onReportIssues: () -> Unit,
+ onLogout: () -> Unit
+) {
+ val accountName = activeAccountLabel ?: "My Account"
+ val accountSubtitle = activeAccountSubtitle.orEmpty()
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+ item {
+ Text(
+ text = "My Account",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
- AlertDialog(
- onDismissRequest = { showDeleteConfirmation = false },
- title = {
+ item {
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(40.dp)
+ )
+ Column(modifier = Modifier.weight(1f)) {
Text(
- "Delete Account",
- color = MaterialTheme.colorScheme.error
+ text = accountName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
)
- },
- text = {
- Column {
- Text("Are you sure you want to delete your account?")
- Spacer(modifier = Modifier.height(8.dp))
+ if (accountSubtitle.isNotBlank()) {
Text(
- "This will permanently delete:",
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Bold
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text("• All saved login credentials")
- Text("• All downloaded data")
- Text("• All unsaved draft entries")
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- "This action cannot be undone.",
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall
- )
- }
- },
- confirmButton = {
- Button(
- onClick = {
- viewModel.deleteAccount(context)
- showDeleteConfirmation = false
- // Navigate to login after deletion
- navController.navigate("login") {
- popUpTo(0)
- }
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
+ text = accountSubtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
- ) {
- Text("Delete Account")
- }
- },
- dismissButton = {
- TextButton(
- onClick = { showDeleteConfirmation = false }
- ) {
- Text("Cancel")
}
}
- )
+ }
}
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ item {
+ AccountRow(
+ icon = Icons.Default.Edit,
+ title = "Edit Account",
+ onClick = onEditAccount
+ )
+ AccountRow(
+ icon = Icons.Default.Settings,
+ title = "Settings",
+ onClick = onSettings
+ )
+ AccountRow(
+ icon = Icons.Default.Info,
+ title = "About",
+ onClick = onAbout
+ )
+ AccountRow(
+ icon = Icons.Default.BugReport,
+ title = "Report Issues",
+ onClick = onReportIssues
+ )
}
+
+ item {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = "Version ${com.ash.simpledataentry.BuildConfig.VERSION_NAME}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ onClick = onLogout,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Logout")
+ }
+ }
+ }
+}
+
+@Composable
+private fun AccountRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ title: String,
+ titleColor: Color = MaterialTheme.colorScheme.onSurface,
+ onClick: () -> Unit
+) {
+ Card(
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp)
+ .clickable { onClick() }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = titleColor
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = titleColor
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsViewModel.kt b/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsViewModel.kt
index 4c7864d..768190d 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsViewModel.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/datasets/DatasetsViewModel.kt
@@ -128,7 +128,7 @@ class DatasetsViewModel @Inject constructor(
// Initial load (may use fallback database if session not yet restored)
loadPrograms()
// Start background prefetching after programs are loaded
- backgroundDataPrefetcher.startPrefetching()
+ backgroundDataPrefetcher.startPrefetching(topDatasetCount = 3)
// REMOVED: Background sync progress observer
// Background sync after login should NOT block the UI with an overlay
@@ -158,6 +158,12 @@ class DatasetsViewModel @Inject constructor(
}
}
+ fun prefetchProgramIfNeeded(program: ProgramItem) {
+ if (program.programType == com.ash.simpledataentry.domain.model.ProgramType.DATASET) {
+ backgroundDataPrefetcher.prefetchForDataset(program.id)
+ }
+ }
+
fun loadPrograms() {
viewModelScope.launch {
_uiState.emitLoading(LoadingOperation.Initial)
@@ -176,11 +182,38 @@ class DatasetsViewModel @Inject constructor(
val currentFilter = currentData.currentFilter
val currentProgramType = currentData.currentProgramType
+ val scopedOrgUnitIds = runCatching {
+ dataEntryRepository.getScopedOrgUnits().map { it.id }.toSet()
+ }.getOrDefault(emptySet())
+
+ val datasetIds = programs
+ .filterIsInstance()
+ .map { it.id }
+
+ val allowedDatasetIds = if (scopedOrgUnitIds.isEmpty() || datasetIds.isEmpty()) {
+ datasetIds.toSet()
+ } else {
+ runCatching {
+ dataEntryRepository.getDatasetIdsAttachedToOrgUnits(scopedOrgUnitIds, datasetIds)
+ }.getOrDefault(emptySet())
+ }
+
+ val scopedPrograms = if (allowedDatasetIds.isEmpty()) {
+ programs
+ } else {
+ programs.filter { program ->
+ when (program) {
+ is ProgramItem.DatasetProgram -> program.id in allowedDatasetIds
+ else -> true
+ }
+ }
+ }
+
// Filter programs if needed
- val filteredPrograms = filterPrograms(programs, currentFilter, currentProgramType)
+ val filteredPrograms = filterPrograms(scopedPrograms, currentFilter, currentProgramType)
val newData = DatasetsData(
- programs = programs,
+ programs = scopedPrograms,
filteredPrograms = filteredPrograms,
currentFilter = currentFilter,
currentProgramType = currentProgramType,
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginScreen.kt
index f04bdf3..5caf4be 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginScreen.kt
@@ -1,8 +1,11 @@
package com.ash.simpledataentry.presentation.login
+import android.app.Activity
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,6 +25,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -34,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Clear
@@ -51,6 +56,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -59,16 +65,18 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
-import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -79,6 +87,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.width
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.compose.ui.graphics.luminance
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ash.simpledataentry.R
@@ -89,7 +99,6 @@ import com.ash.simpledataentry.presentation.core.NavigationProgress
import com.ash.simpledataentry.presentation.core.StepLoadingScreen
import com.ash.simpledataentry.presentation.core.StepLoadingType
import com.ash.simpledataentry.presentation.core.UiState
-import com.ash.simpledataentry.presentation.login.ProfileSelectorCard
import com.ash.simpledataentry.ui.theme.DHIS2Blue
import com.ash.simpledataentry.ui.theme.DHIS2BlueDark
import com.ash.simpledataentry.ui.theme.DHIS2BlueLight
@@ -109,11 +118,32 @@ private data class StepLoadingInfo(
@Composable
fun LoginScreen(
navController: NavController,
- viewModel: LoginViewModel = hiltViewModel()
+ viewModel: LoginViewModel = hiltViewModel(),
+ isAddAccount: Boolean = false
) {
val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
+ val view = LocalView.current
+ val isLightTheme = !isSystemInDarkTheme()
+ val isDarkTheme = !isLightTheme
+ val statusBarColor = if (isLightTheme) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ val useDarkIcons = isLightTheme && statusBarColor.luminance() > 0.5f
+
+ SideEffect {
+ val window = (view.context as? Activity)?.window ?: return@SideEffect
+ window.statusBarColor = statusBarColor.toArgb()
+ window.navigationBarColor = statusBarColor.toArgb()
+ WindowInsetsControllerCompat(window, window.decorView).apply {
+ isAppearanceLightStatusBars = useDarkIcons
+ isAppearanceLightNavigationBars = useDarkIcons
+ }
+ }
// Extract data from UiState
val loginData = when (val state = uiState) {
@@ -191,10 +221,30 @@ fun LoginScreen(
}
}
- LaunchedEffect(loginData.isLoggedIn, loginData.saveAccountOffered) {
+ var lastProgressPercent by remember { mutableStateOf(0) }
+ var lastProgressTimestamp by remember { mutableStateOf(System.currentTimeMillis()) }
+ LaunchedEffect(stepLoadingInfo?.percent) {
+ val percent = stepLoadingInfo?.percent ?: return@LaunchedEffect
+ if (percent != lastProgressPercent) {
+ lastProgressPercent = percent
+ lastProgressTimestamp = System.currentTimeMillis()
+ }
+ }
+ val isStalled = stepLoadingInfo != null && (System.currentTimeMillis() - lastProgressTimestamp) > 45000
+
+ LaunchedEffect(loginData.isLoggedIn, loginData.saveAccountOffered, isAddAccount) {
if (loginData.isLoggedIn && !loginData.saveAccountOffered) {
- navController.navigate("datasets") {
- popUpTo("login") { inclusive = true }
+ if (isAddAccount) {
+ navController.navigate(com.ash.simpledataentry.navigation.Screen.EditAccountScreen.route) {
+ popUpTo(com.ash.simpledataentry.navigation.Screen.AddAccountScreen.route) {
+ inclusive = true
+ }
+ launchSingleTop = true
+ }
+ } else {
+ navController.navigate("datasets") {
+ popUpTo("login") { inclusive = true }
+ }
}
}
}
@@ -215,7 +265,16 @@ fun LoginScreen(
type = StepLoadingType.LOGIN,
currentStep = currentStepInfo?.stepIndex ?: 0,
progressPercent = currentStepInfo?.percent ?: 0,
- currentLabel = currentStepInfo?.label ?: "Initializing...",
+ currentLabel = when {
+ isStalled -> "Taking longer than usual. Check your connection or server."
+ else -> currentStepInfo?.label ?: "Initializing..."
+ },
+ actionLabel = if (isStalled) "Back to Login" else null,
+ onAction = if (isStalled) {
+ { viewModel.abortLogin(context, "Login cancelled. Please try again.") }
+ } else {
+ null
+ },
modifier = Modifier.fillMaxSize()
)
} else {
@@ -229,7 +288,6 @@ fun LoginScreen(
var password by rememberSaveable { mutableStateOf("") }
var showUrlDropdown by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
- val context = LocalContext.current
val versionLabel = runCatching {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
"Version ${info.versionName}"
@@ -238,17 +296,25 @@ fun LoginScreen(
var selectedProfileId by rememberSaveable {
mutableStateOf(loginData.savedAccounts.firstOrNull { it.isActive }?.id)
}
+ var isAddingNew by rememberSaveable { mutableStateOf(loginData.savedAccounts.isEmpty()) }
LaunchedEffect(loginData.savedAccounts) {
- if (loginData.savedAccounts.isNotEmpty()) {
+ if (loginData.savedAccounts.isEmpty()) {
+ isAddingNew = true
+ selectedProfileId = null
+ } else {
if (selectedProfileId == null || loginData.savedAccounts.none { it.id == selectedProfileId }) {
selectedProfileId = loginData.savedAccounts.firstOrNull { it.isActive }?.id
?: loginData.savedAccounts.firstOrNull()?.id
}
+ isAddingNew = false
}
}
val selectedProfile = loginData.savedAccounts.firstOrNull { it.id == selectedProfileId }
+ val hasSavedProfiles = loginData.savedAccounts.isNotEmpty()
+ val showFullForm = !hasSavedProfiles || isAddingNew
+ val showPasswordOnly = hasSavedProfiles && !isAddingNew && selectedProfile != null
val scrollState = rememberScrollState()
val usernameBringIntoViewRequester = remember { BringIntoViewRequester() }
val passwordBringIntoViewRequester = remember { BringIntoViewRequester() }
@@ -290,6 +356,12 @@ fun LoginScreen(
val gradientBrush = Brush.verticalGradient(
colors = listOf(DHIS2Blue, DHIS2BlueDark)
)
+ val mistOverlay = Brush.verticalGradient(
+ colors = listOf(
+ Color.White.copy(alpha = 0.08f),
+ Color.Transparent
+ )
+ )
fun resetForm() {
serverUrl = "https://"
@@ -305,47 +377,264 @@ fun LoginScreen(
.fillMaxSize()
.background(gradientBrush)
) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ .background(mistOverlay)
+ )
Column(
modifier = Modifier
.fillMaxSize()
- .imePadding()
.verticalScroll(scrollState)
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
- Icon(
- painter = painterResource(id = R.drawable.dhis2_official_logo),
- contentDescription = "DHIS2 Logo",
- modifier = Modifier
- .height(220.dp)
- .padding(bottom = 12.dp),
- tint = Color.Unspecified
- )
+ Surface(
+ shape = RoundedCornerShape(28.dp),
+ color = Color.White.copy(alpha = 0.1f),
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Surface(
+ shape = RoundedCornerShape(22.dp),
+ color = Color.White
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.login_logo),
+ contentDescription = "App Logo",
+ modifier = Modifier
+ .height(72.dp)
+ .padding(10.dp),
+ tint = Color.Unspecified
+ )
+ }
+ Text(
+ text = "Simple Data Entry",
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.White
+ )
+ Text(
+ text = "Fast, reliable DHIS2 data capture",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.85f)
+ )
+ }
+ }
- ProfileSelectorCard(
- profiles = loginData.savedAccounts,
- selectedProfile = selectedProfile,
- onSelectProfile = { account ->
- val (url, user, _) = viewModel.selectAccount(account)
- serverUrl = url
- username = user
- selectedProfileId = account.id
- focusRequestCounter += 1
- },
- onAddNew = {
- resetForm()
- },
- modifier = Modifier
- .fillMaxWidth()
- .bringIntoViewRequester(profileBringIntoViewRequester)
- )
+ if (hasSavedProfiles) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(profileBringIntoViewRequester),
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = BorderStroke(2.dp, DHIS2Blue),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Saved Connections",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Save your server details for quick access.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ loginData.savedAccounts.forEach { profile ->
+ val isSelected = profile.id == selectedProfileId && !isAddingNew
+ val selectedContainerColor = if (isDarkTheme) {
+ DHIS2BlueDark
+ } else {
+ DHIS2BlueLight
+ }
+ val selectedContentColor = if (isDarkTheme) {
+ Color.White
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ }
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ val (url, user, _) = viewModel.selectAccount(profile)
+ serverUrl = url
+ username = user
+ password = ""
+ selectedProfileId = profile.id
+ isAddingNew = false
+ focusRequestCounter += 1
+ },
+ shape = RoundedCornerShape(16.dp),
+ color = if (isSelected) selectedContainerColor else MaterialTheme.colorScheme.surface,
+ contentColor = if (isSelected) selectedContentColor else MaterialTheme.colorScheme.onSurface,
+ border = BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color = if (isSelected) DHIS2Blue else MaterialTheme.colorScheme.outline
+ ),
+ tonalElevation = if (isSelected) 4.dp else 0.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(14.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .background(
+ color = DHIS2Blue.copy(alpha = 0.2f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Cloud,
+ contentDescription = null,
+ tint = DHIS2Blue,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = profile.displayName,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = profile.username,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ if (isSelected) {
+ Text(
+ text = "Selected",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Text(
+ text = profile.serverUrl,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ Text(
+ text = "Last used ${formatRelativeTime(profile.lastUsed)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ resetForm()
+ isAddingNew = true
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add connection",
+ tint = DHIS2Blue
+ )
+ Text(
+ text = "Add Connection",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ } else {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(profileBringIntoViewRequester),
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = BorderStroke(2.dp, DHIS2Blue),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "No Saved Connections",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "No saved connections. Add one to sign in faster next time.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ resetForm()
+ isAddingNew = true
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add connection",
+ tint = DHIS2Blue
+ )
+ Text(
+ text = "Add Connection",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
- containerColor = Color.White
+ containerColor = if (isDarkTheme) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ Color.White
+ }
),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
@@ -355,210 +644,247 @@ fun LoginScreen(
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Text(
- text = "Welcome back",
- style = MaterialTheme.typography.titleMedium
- )
+ if (showPasswordOnly && selectedProfile != null) {
+ Text(
+ text = "Signing in as",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = selectedProfile.username,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = selectedProfile.serverUrl,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ Text(
+ text = "Sign in",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "Use your DHIS2 server credentials to continue.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
var serverUrlFieldSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
- Box {
- OutlinedTextField(
- value = serverUrl,
- onValueChange = {
- serverUrl = it
- viewModel.clearUrlSuggestions()
- },
- label = { Text("Server URL") },
- modifier = Modifier
- .fillMaxWidth()
- .onGloballyPositioned { coordinates ->
- serverUrlFieldSize = coordinates.size
- }
- .onFocusChanged { focusState ->
- if (!focusState.isFocused) {
- viewModel.clearUrlSuggestions()
+ if (showFullForm) {
+ Box {
+ OutlinedTextField(
+ value = serverUrl,
+ onValueChange = {
+ serverUrl = it
+ viewModel.clearUrlSuggestions()
+ },
+ label = { Text("Server URL (e.g., play.dhis2.org)") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ serverUrlFieldSize = coordinates.size
}
+ .onFocusChanged { focusState ->
+ if (!focusState.isFocused) {
+ viewModel.clearUrlSuggestions()
+ }
+ },
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Link,
+ contentDescription = "Server URL",
+ tint = MaterialTheme.colorScheme.primary
+ )
},
- enabled = !isLoading,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.Link,
- contentDescription = "Server URL",
- tint = MaterialTheme.colorScheme.primary
- )
- },
- trailingIcon = {
- if (loginData.cachedUrls.isNotEmpty()) {
- IconButton(
- onClick = {
- showUrlDropdown = !showUrlDropdown
- if (showUrlDropdown) {
- viewModel.clearUrlSuggestions()
+ trailingIcon = {
+ if (loginData.cachedUrls.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ showUrlDropdown = !showUrlDropdown
+ if (showUrlDropdown) {
+ viewModel.clearUrlSuggestions()
+ }
}
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Show cached URLs"
+ )
}
- ) {
- Icon(
- imageVector = Icons.Default.ArrowDropDown,
- contentDescription = "Show cached URLs"
- )
}
}
- }
- )
-
- DropdownMenu(
- expanded = showUrlDropdown,
- onDismissRequest = { showUrlDropdown = false },
- modifier = Modifier.width(
- with(density) { serverUrlFieldSize.width.toDp() }
)
- ) {
- loginData.cachedUrls.take(5).forEach { cachedUrl ->
- val isSelected = cachedUrl.url == serverUrl
- DropdownMenuItem(
- text = {
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- shape = RoundedCornerShape(16.dp),
- color = if (isSelected) {
- DHIS2BlueLight
- } else {
- MaterialTheme.colorScheme.surface
- },
- border = BorderStroke(
- width = if (isSelected) 2.dp else 1.dp,
- color = if (isSelected) DHIS2Blue else MaterialTheme.colorScheme.outline
- ),
- tonalElevation = if (isSelected) 4.dp else 0.dp
- ) {
- Row(
+
+ DropdownMenu(
+ expanded = showUrlDropdown,
+ onDismissRequest = { showUrlDropdown = false },
+ modifier = Modifier.width(
+ with(density) { serverUrlFieldSize.width.toDp() }
+ )
+ ) {
+ loginData.cachedUrls.take(5).forEach { cachedUrl ->
+ val isSelected = cachedUrl.url == serverUrl
+ DropdownMenuItem(
+ text = {
+ Surface(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ .padding(vertical = 4.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = if (isSelected) {
+ DHIS2BlueLight
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ border = BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color = if (isSelected) DHIS2Blue else MaterialTheme.colorScheme.outline
+ ),
+ tonalElevation = if (isSelected) 4.dp else 0.dp
) {
- Box(
+ Row(
modifier = Modifier
- .size(36.dp)
- .background(
- color = DHIS2Blue.copy(alpha = 0.2f),
- shape = CircleShape
- ),
- contentAlignment = Alignment.Center
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- Icon(
- imageVector = Icons.Default.Link,
- contentDescription = null,
- tint = DHIS2Blue,
- modifier = Modifier.size(18.dp)
- )
- }
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .background(
+ color = DHIS2Blue.copy(alpha = 0.2f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Link,
+ contentDescription = null,
+ tint = DHIS2Blue,
+ modifier = Modifier.size(18.dp)
+ )
+ }
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(2.dp)
- ) {
- Text(
- text = cachedUrl.url,
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1
- )
- Text(
- text = "Tap to use this server",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = cachedUrl.url,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1
+ )
+ Text(
+ text = "Tap to use this server",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
- IconButton(
- onClick = {
- viewModel.removeUrl(cachedUrl.url)
- showUrlDropdown = false
+ IconButton(
+ onClick = {
+ viewModel.removeUrl(cachedUrl.url)
+ showUrlDropdown = false
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Remove URL",
+ tint = MaterialTheme.colorScheme.error
+ )
}
- ) {
- Icon(
- imageVector = Icons.Default.Clear,
- contentDescription = "Remove URL",
- tint = MaterialTheme.colorScheme.error
- )
}
}
- }
- },
- onClick = {
- serverUrl = cachedUrl.url
- showUrlDropdown = false
- viewModel.clearUrlSuggestions()
- },
- leadingIcon = null,
- trailingIcon = null,
- contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
- )
+ },
+ onClick = {
+ serverUrl = cachedUrl.url
+ showUrlDropdown = false
+ viewModel.clearUrlSuggestions()
+ },
+ leadingIcon = null,
+ trailingIcon = null,
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
+ )
+ }
}
}
- }
- OutlinedTextField(
- value = username,
- onValueChange = { username = it },
- label = { Text("Username") },
- modifier = Modifier
- .fillMaxWidth()
- .bringIntoViewRequester(usernameBringIntoViewRequester)
- .onFocusChanged { focusState ->
- usernameFocused = focusState.isFocused
- },
- enabled = !isLoading,
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.Person,
- contentDescription = "Username",
- tint = MaterialTheme.colorScheme.primary
- )
- }
- )
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text("Username") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(usernameBringIntoViewRequester)
+ .onFocusChanged { focusState ->
+ usernameFocused = focusState.isFocused
+ },
+ enabled = !isLoading,
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Username",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ )
+ } else if (!showPasswordOnly) {
+ Text(
+ text = "Select a saved connection to continue.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
- OutlinedTextField(
- value = password,
- onValueChange = { password = it },
- label = { Text("Password") },
- visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(passwordFocusRequester)
- .bringIntoViewRequester(passwordBringIntoViewRequester)
- .onFocusChanged { focusState ->
- passwordFocused = focusState.isFocused
- },
- enabled = !isLoading,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.Lock,
- contentDescription = "Password",
- tint = MaterialTheme.colorScheme.primary
- )
- },
- trailingIcon = {
- IconButton(
- onClick = { passwordVisible = !passwordVisible }
- ) {
+ if (showFullForm || showPasswordOnly) {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(passwordFocusRequester)
+ .bringIntoViewRequester(passwordBringIntoViewRequester)
+ .onFocusChanged { focusState ->
+ passwordFocused = focusState.isFocused
+ },
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ leadingIcon = {
Icon(
- imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
- contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Password",
+ tint = MaterialTheme.colorScheme.primary
)
+ },
+ trailingIcon = {
+ IconButton(
+ onClick = { passwordVisible = !passwordVisible }
+ ) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
}
- }
- )
+ )
+ }
Surface(
- color = DHIS2BlueLight.copy(alpha = 0.3f),
+ color = if (isDarkTheme) {
+ DHIS2BlueDark.copy(alpha = 0.7f)
+ } else {
+ DHIS2BlueLight.copy(alpha = 0.3f)
+ },
shape = RoundedCornerShape(12.dp)
) {
Row(
@@ -592,7 +918,14 @@ fun LoginScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
- onClick = { resetForm() },
+ onClick = {
+ resetForm()
+ if (hasSavedProfiles) {
+ isAddingNew = false
+ selectedProfileId = loginData.savedAccounts.firstOrNull { it.isActive }?.id
+ ?: loginData.savedAccounts.firstOrNull()?.id
+ }
+ },
modifier = Modifier
.weight(1f)
.height(48.dp),
@@ -606,8 +939,8 @@ fun LoginScreen(
viewModel.loginWithProgress(serverUrl, username, password, context)
},
enabled = !isLoading &&
- serverUrl.isNotBlank() &&
- username.isNotBlank() &&
+ (if (showFullForm) serverUrl.isNotBlank() else true) &&
+ (if (showFullForm) username.isNotBlank() else true) &&
password.isNotBlank(),
modifier = Modifier
.weight(1f)
@@ -619,7 +952,7 @@ fun LoginScreen(
)
) {
Text(
- text = "Login & Sync",
+ text = "Login",
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
@@ -701,9 +1034,8 @@ fun LoginScreen(
Button(
onClick = {
if (displayName.isNotBlank()) {
- val (serverUrl, username, password) = loginData.pendingCredentials!!
android.util.Log.d("LoginDebug", "Saving account: $displayName")
- viewModel.saveAccount(displayName, serverUrl, username, password)
+ viewModel.savePendingAccount(displayName)
}
},
enabled = displayName.isNotBlank()
@@ -725,6 +1057,20 @@ fun LoginScreen(
}
}
+private fun formatRelativeTime(timestamp: Long): String {
+ if (timestamp <= 0L) return "never"
+ val diffMillis = System.currentTimeMillis() - timestamp
+ val minutes = diffMillis / (60 * 1000)
+ val hours = diffMillis / (60 * 60 * 1000)
+ val days = diffMillis / (24 * 60 * 60 * 1000)
+ return when {
+ minutes < 1 -> "just now"
+ minutes < 60 -> "$minutes min ago"
+ hours < 24 -> "$hours hr ago"
+ else -> "$days d ago"
+ }
+}
+
@Composable
fun Dhis2PulsingLoader() {
// Three pulsing dots animation inspired by DHIS2 Android Capture App
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginViewModel.kt b/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginViewModel.kt
index e39d27c..69e51e7 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginViewModel.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/login/LoginViewModel.kt
@@ -1,6 +1,7 @@
package com.ash.simpledataentry.presentation.login
import android.content.Context
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ash.simpledataentry.domain.useCase.LoginUseCase
@@ -9,6 +10,7 @@ import com.ash.simpledataentry.data.repositoryImpl.LoginUrlCacheRepository
import com.ash.simpledataentry.data.repositoryImpl.SavedAccountRepository
import com.ash.simpledataentry.data.repositoryImpl.AuthRepositoryImpl
import com.ash.simpledataentry.presentation.core.NavigationProgress
+import com.ash.simpledataentry.presentation.core.UiError
import com.ash.simpledataentry.presentation.core.UiState
import com.ash.simpledataentry.presentation.core.LoadingOperation
import com.ash.simpledataentry.presentation.core.BackgroundOperation
@@ -72,6 +74,7 @@ data class LoginData(
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
private val loginUseCase: LoginUseCase,
private val urlCacheRepository: LoginUrlCacheRepository,
private val savedAccountRepository: SavedAccountRepository,
@@ -105,7 +108,10 @@ class LoginViewModel @Inject constructor(
init {
loadCachedUrls()
loadSavedAccounts()
- checkExistingSession()
+ val skipAutoLogin = savedStateHandle.get("skipAutoLogin") ?: false
+ if (!skipAutoLogin) {
+ checkExistingSession()
+ }
}
/**
@@ -280,6 +286,23 @@ class LoginViewModel @Inject constructor(
}
}
+ fun abortLogin(context: Context, message: String) {
+ viewModelScope.launch {
+ try {
+ sessionManager.secureLogout(context)
+ } catch (e: Exception) {
+ android.util.Log.w("LoginViewModel", "Abort login logout failed: ${e.message}")
+ }
+
+ val currentData = getCurrentData().copy(
+ isLoggedIn = false,
+ showSplash = false
+ )
+ val uiError = Exception(message).toUiError()
+ _uiState.value = UiState.Error(uiError, currentData)
+ }
+ }
+
fun hideSplash() {
val currentData = getCurrentData()
val newData = currentData.copy(showSplash = false)
@@ -394,6 +417,22 @@ class LoginViewModel @Inject constructor(
}
}
+ fun savePendingAccount(displayName: String) {
+ val pending = getCurrentData().pendingCredentials
+ if (pending == null) {
+ val currentData = getCurrentData().copy(
+ saveAccountOffered = false,
+ pendingCredentials = null
+ )
+ _uiState.value = UiState.Error(
+ UiError.Local("No pending credentials to save"),
+ currentData
+ )
+ return
+ }
+ saveAccount(displayName, pending.first, pending.second, pending.third)
+ }
+
/**
* Logs in using a saved account with encrypted credentials.
* Supports both offline login (using cached session) and online login with progress tracking.
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/settings/EditAccountScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/settings/EditAccountScreen.kt
new file mode 100644
index 0000000..c6dfd1f
--- /dev/null
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/settings/EditAccountScreen.kt
@@ -0,0 +1,443 @@
+package com.ash.simpledataentry.presentation.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import com.ash.simpledataentry.domain.model.SavedAccount
+import com.ash.simpledataentry.navigation.Screen
+import com.ash.simpledataentry.presentation.core.BaseScreen
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun EditAccountScreen(
+ navController: NavController,
+ viewModel: SettingsViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var lastData by remember { mutableStateOf(SettingsData()) }
+ val state = when (val current = uiState) {
+ is com.ash.simpledataentry.presentation.core.UiState.Success -> {
+ lastData = current.data
+ current.data
+ }
+ is com.ash.simpledataentry.presentation.core.UiState.Error -> current.previousData ?: lastData
+ is com.ash.simpledataentry.presentation.core.UiState.Loading -> lastData
+ }
+ val dateFormatter = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) }
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showEditAccountDialog by remember { mutableStateOf(null) }
+ var showDeleteAccountConfirmation by remember { mutableStateOf(null) }
+ var showDeleteAllConfirmation by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ viewModel.loadAccounts()
+ }
+
+ BaseScreen(
+ title = "Edit Account",
+ subtitle = "Profile and saved accounts",
+ navController = navController
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ val activeAccount = state.accounts.firstOrNull { it.isActive }
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = activeAccount?.displayName ?: "No active account",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ activeAccount?.let {
+ Text(
+ text = it.username,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = it.serverUrl,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ IconButton(
+ onClick = { activeAccount?.let { showEditAccountDialog = it } }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = "Edit profile name"
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Button(
+ onClick = { navController.navigate(Screen.AddAccountScreen.route) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text("Add Account")
+ }
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Saved Accounts",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ TextButton(
+ onClick = { showDeleteAllConfirmation = true },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text("Delete All")
+ }
+ }
+ }
+
+ if (state.accounts.isEmpty()) {
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "No Saved Accounts",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "Add an account to get started.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ } else {
+ items(items = state.accounts, key = { it.id }) { account ->
+ EditAccountListItem(
+ account = account,
+ dateFormatter = dateFormatter,
+ onEditClick = { showEditAccountDialog = account },
+ onDeleteClick = { showDeleteAccountConfirmation = account }
+ )
+ }
+ }
+ }
+
+ SnackbarHost(hostState = snackbarHostState)
+ }
+
+ showEditAccountDialog?.let { account ->
+ EditAccountNameDialog(
+ account = account,
+ onDismiss = { showEditAccountDialog = null },
+ onSave = { newDisplayName ->
+ viewModel.updateAccountDisplayName(account.id, newDisplayName)
+ showEditAccountDialog = null
+ }
+ )
+ }
+
+ showDeleteAccountConfirmation?.let { account ->
+ AlertDialog(
+ onDismissRequest = { showDeleteAccountConfirmation = null },
+ title = { Text("Delete Account") },
+ text = {
+ Text("Are you sure you want to delete the account \"${account.displayName}\"?")
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ viewModel.deleteAccount(account.id)
+ showDeleteAccountConfirmation = null
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ )
+ ) {
+ Text("Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteAccountConfirmation = null }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+
+ if (showDeleteAllConfirmation) {
+ AlertDialog(
+ onDismissRequest = { showDeleteAllConfirmation = false },
+ title = { Text("Delete All Accounts") },
+ text = {
+ Text("Are you sure you want to delete all saved accounts? This action cannot be undone.")
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ viewModel.deleteAllAccounts()
+ showDeleteAllConfirmation = false
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ )
+ ) {
+ Text("Delete All")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteAllConfirmation = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun EditAccountListItem(
+ account: SavedAccount,
+ dateFormatter: SimpleDateFormat,
+ onEditClick: () -> Unit,
+ onDeleteClick: () -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (account.isActive) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (account.isActive) 6.dp else 2.dp,
+ pressedElevation = if (account.isActive) 10.dp else 4.dp
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = account.displayName.take(2).uppercase(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+
+ Spacer(modifier = Modifier.size(12.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = account.displayName,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = account.username,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = account.serverUrl,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = "Last used: ${dateFormatter.format(Date(account.lastUsed))}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ IconButton(onClick = onEditClick) {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = "Edit account",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ IconButton(onClick = onDeleteClick) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete account",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EditAccountNameDialog(
+ account: SavedAccount,
+ onDismiss: () -> Unit,
+ onSave: (String) -> Unit
+) {
+ var displayName by remember { mutableStateOf(account.displayName) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Edit Account") },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Edit the display name for this account.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ androidx.compose.material3.OutlinedTextField(
+ value = displayName,
+ onValueChange = { displayName = it },
+ label = { Text("Display Name") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onSave(displayName.trim()) },
+ enabled = displayName.trim().isNotBlank()
+ ) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsScreen.kt
index 08aba69..958bb62 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsScreen.kt
@@ -5,17 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material.icons.filled.Notifications
-import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.*
import androidx.compose.material3.MenuAnchorType
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -26,14 +19,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
-import com.ash.simpledataentry.domain.model.SavedAccount
import com.ash.simpledataentry.presentation.core.BaseScreen
-import java.text.SimpleDateFormat
-import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -51,11 +40,7 @@ fun SettingsScreen(
is com.ash.simpledataentry.presentation.core.UiState.Error -> current.previousData ?: lastData
is com.ash.simpledataentry.presentation.core.UiState.Loading -> lastData
}
- val dateFormatter = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) }
val haptic = LocalHapticFeedback.current
- var showDeleteAllConfirmation by remember { mutableStateOf(false) }
- var showDeleteAccountConfirmation by remember { mutableStateOf(null) }
- var showEditAccountDialog by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
viewModel.loadAccounts()
@@ -149,6 +134,7 @@ fun SettingsScreen(
isChecking = state.updateCheckInProgress,
updateAvailable = state.updateAvailable,
latestVersion = state.latestVersion,
+ currentVersion = state.currentVersion,
onCheckForUpdates = viewModel::checkForUpdates,
enabled = true // ENABLED: Update checking is now fully implemented
)
@@ -156,287 +142,9 @@ fun SettingsScreen(
}
}
- // ACCOUNT MANAGEMENT SECTION
- item {
- SettingsSection(
- title = "Account Management",
- description = "Manage saved accounts and security settings.",
- icon = Icons.Default.Person
- ) {
- // Content is in the next items
- }
- }
-
- // Account Statistics
- item {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceEvenly
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = "${state.accounts.size}",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
- Text(
- text = "Total Accounts",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- HorizontalDivider(
- modifier = Modifier
- .height(48.dp)
- .width(1.dp)
- )
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- val activeCount = state.accounts.count { it.isActive }
- Text(
- text = "$activeCount",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.secondary
- )
- Text(
- text = "Active Account",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- }
-
- // Security Section
- item {
- Card(
- modifier = Modifier.fillMaxWidth()
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Security,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- text = "Security",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- }
-
- val encryptionStatus = if (state.isEncryptionAvailable) "Enabled" else "Not Available"
- val encryptionColor = if (state.isEncryptionAvailable)
- MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "Android Keystore Encryption",
- style = MaterialTheme.typography.bodyMedium
- )
- Surface(
- shape = RoundedCornerShape(12.dp),
- color = encryptionColor.copy(alpha = 0.1f)
- ) {
- Text(
- text = encryptionStatus,
- style = MaterialTheme.typography.labelSmall,
- color = encryptionColor,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
- )
- }
- }
-
- Text(
- text = "Your account passwords are encrypted using Android Keystore for maximum security.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
-
- // Saved Accounts Section Header
- if (state.accounts.isNotEmpty()) {
- item {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "Saved Accounts",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- TextButton(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- showDeleteAllConfirmation = true
- },
- colors = ButtonDefaults.textButtonColors(
- contentColor = MaterialTheme.colorScheme.error
- )
- ) {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text("Delete All")
- }
- }
- }
- }
-
- // Saved Accounts List
- if (state.accounts.isEmpty()) {
- item {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant
- )
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Person,
- contentDescription = null,
- modifier = Modifier.size(48.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- text = "No Saved Accounts",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = "Your saved accounts will appear here for easy management.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- } else {
- items(items = state.accounts, key = { it.id }) { account ->
- AccountManagementItem(
- account = account,
- dateFormatter = dateFormatter,
- onEditClick = { showEditAccountDialog = account },
- onDeleteClick = { showDeleteAccountConfirmation = account }
- )
- }
- }
}
}
- // Delete All Accounts Confirmation Dialog
- if (showDeleteAllConfirmation) {
- AlertDialog(
- onDismissRequest = { showDeleteAllConfirmation = false },
- title = { Text("Delete All Accounts") },
- text = {
- Text("Are you sure you want to delete all saved accounts? This action cannot be undone and you will need to re-enter your login credentials.")
- },
- confirmButton = {
- Button(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- viewModel.deleteAllAccounts()
- showDeleteAllConfirmation = false
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError
- )
- ) {
- Text("Delete All")
- }
- },
- dismissButton = {
- TextButton(onClick = { showDeleteAllConfirmation = false }) {
- Text("Cancel")
- }
- }
- )
- }
-
- // Delete Single Account Confirmation Dialog
- showDeleteAccountConfirmation?.let { account ->
- AlertDialog(
- onDismissRequest = { showDeleteAccountConfirmation = null },
- title = { Text("Delete Account") },
- text = {
- Text("Are you sure you want to delete the account \"${account.displayName}\"? This action cannot be undone.")
- },
- confirmButton = {
- Button(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- viewModel.deleteAccount(account.id)
- showDeleteAccountConfirmation = null
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError
- )
- ) {
- Text("Delete")
- }
- },
- dismissButton = {
- TextButton(onClick = { showDeleteAccountConfirmation = null }) {
- Text("Cancel")
- }
- }
- )
- }
-
- // Edit Account Dialog
- showEditAccountDialog?.let { account ->
- EditAccountDialog(
- account = account,
- onDismiss = { showEditAccountDialog = null },
- onSave = { newDisplayName ->
- viewModel.updateAccountDisplayName(account.id, newDisplayName)
- showEditAccountDialog = null
- }
- )
- }
-
// Error Snackbar
(uiState as? com.ash.simpledataentry.presentation.core.UiState.Error)?.error?.let { error ->
LaunchedEffect(error) {
@@ -445,207 +153,6 @@ fun SettingsScreen(
}
}
-@Composable
-private fun AccountManagementItem(
- account: SavedAccount,
- dateFormatter: SimpleDateFormat,
- onEditClick: () -> Unit,
- onDeleteClick: () -> Unit
-) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = if (account.isActive) {
- MaterialTheme.colorScheme.secondaryContainer
- } else {
- MaterialTheme.colorScheme.surface
- }
- ),
- elevation = CardDefaults.cardElevation(
- defaultElevation = if (account.isActive) 8.dp else 4.dp,
- pressedElevation = if (account.isActive) 12.dp else 6.dp
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Account Avatar
- Box(
- modifier = Modifier
- .size(48.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = account.displayName.take(2).uppercase(),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimary
- )
- }
-
- Spacer(modifier = Modifier.width(16.dp))
-
- // Account Details
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = account.displayName,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- if (account.isActive) {
- Surface(
- shape = RoundedCornerShape(12.dp),
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(horizontal = 4.dp)
- ) {
- Text(
- text = "Active",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
- )
- }
- }
- }
-
- Text(
- text = account.username,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = account.serverUrl,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = "Last used: ${dateFormatter.format(Date(account.lastUsed))}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- // Action Buttons
- Row {
- IconButton(
- onClick = onEditClick,
- modifier = Modifier.size(40.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Edit,
- contentDescription = "Edit Account",
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp)
- )
- }
-
- IconButton(
- onClick = onDeleteClick,
- modifier = Modifier.size(40.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = "Delete Account",
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(20.dp)
- )
- }
- }
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun EditAccountDialog(
- account: SavedAccount,
- onDismiss: () -> Unit,
- onSave: (String) -> Unit
-) {
- var displayName by remember { mutableStateOf(account.displayName) }
-
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Edit Account") },
- text = {
- Column(
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(
- text = "Edit the display name for this account.",
- style = MaterialTheme.typography.bodyMedium
- )
-
- OutlinedTextField(
- value = displayName,
- onValueChange = { displayName = it },
- label = { Text("Display Name") },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true
- )
-
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant
- )
- ) {
- Column(
- modifier = Modifier.padding(12.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- Text(
- text = "Account Details:",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = "Username: ${account.username}",
- style = MaterialTheme.typography.bodySmall
- )
- Text(
- text = "Server: ${account.serverUrl}",
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- }
- },
- confirmButton = {
- TextButton(
- onClick = { onSave(displayName.trim()) },
- enabled = displayName.trim().isNotBlank()
- ) {
- Text("Save")
- }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) {
- Text("Cancel")
- }
- }
- )
-}
-
// === NEW UI COMPONENTS FOR SETTINGS FEATURES ===
@Composable
@@ -855,12 +362,13 @@ private fun UpdateSection(
isChecking: Boolean,
updateAvailable: Boolean,
latestVersion: String?,
+ currentVersion: String,
onCheckForUpdates: () -> Unit,
enabled: Boolean = true
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
- text = "Current version: 1.0.0",
+ text = "Current version: $currentVersion",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
diff --git a/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsViewModel.kt
index d43b487..4d7b140 100644
--- a/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/ash/simpledataentry/presentation/settings/SettingsViewModel.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import com.ash.simpledataentry.BuildConfig
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
@@ -46,7 +47,8 @@ data class SettingsData(
val isDeleting: Boolean = false,
val updateCheckInProgress: Boolean = false,
val updateAvailable: Boolean = false,
- val latestVersion: String? = null
+ val latestVersion: String? = null,
+ val currentVersion: String = BuildConfig.VERSION_NAME
)
@@ -346,8 +348,8 @@ class SettingsViewModel @Inject constructor(
BackgroundOperation.Syncing
)
- val currentVersion = "1.0" // Current app version from build.gradle
- val latestVersion = fetchLatestVersionFromGitHub()
+ val currentVersion = normalizeVersion(getCurrentData().currentVersion)
+ val latestVersion = fetchLatestVersionFromGitHub()?.let(::normalizeVersion)
val updateAvailable = if (latestVersion != null) {
compareVersions(currentVersion, latestVersion) < 0
@@ -374,7 +376,7 @@ class SettingsViewModel @Inject constructor(
private suspend fun fetchLatestVersionFromGitHub(): String? = withContext(Dispatchers.IO) {
try {
// Note: Replace with actual GitHub repository URL
- val githubApiUrl = "https://api.github.com/repos/username/simpleDataEntry/releases/latest"
+ val githubApiUrl = "https://api.github.com/repos/HISP-Uganda/simpleDataEntry/releases/latest"
val url = URL(githubApiUrl)
val connection = url.openConnection() as HttpURLConnection
@@ -427,4 +429,8 @@ class SettingsViewModel @Inject constructor(
return 0
}
+
+ private fun normalizeVersion(version: String): String {
+ return Regex("\\d+(?:\\.\\d+)*").find(version)?.value ?: version
+ }
}
diff --git a/app/src/main/java/com/ash/simpledataentry/ui/theme/Color.kt b/app/src/main/java/com/ash/simpledataentry/ui/theme/Color.kt
index 15ff62f..f8c98e9 100644
--- a/app/src/main/java/com/ash/simpledataentry/ui/theme/Color.kt
+++ b/app/src/main/java/com/ash/simpledataentry/ui/theme/Color.kt
@@ -2,34 +2,38 @@ package com.ash.simpledataentry.ui.theme
import androidx.compose.ui.graphics.Color
-// Primary brand and neutral tones inspired by the design-system bundle.
+// Clean clinical palette: cool neutrals with a strong primary.
val BrandGreen = Color(0xFF16A34A)
-val BrandGreenDark = Color(0xFF15803D)
val BrandGreenLight = Color(0xFFD1FAE5)
-// DHIS2 blue remains available for secondary accents and legacy usage.
-val DHIS2Blue = Color(0xFF0073E7)
-val DHIS2BlueLight = Color(0xFF4A90E2)
-val DHIS2BlueDark = Color(0xFF004BA0)
-val DHIS2BlueDeep = Color(0xFF1976D2)
+val ClinicalBlue = Color(0xFF0F6CBD)
+val ClinicalBlueLight = Color(0xFF5BA3E6)
+val ClinicalBlueDark = Color(0xFF0B4F8A)
+val ClinicalBlueContainer = Color(0xFFD7E9FF)
-// Neutral surfaces (matching design-system theme.css intent).
-val NeutralBackground = Color(0xFFFFFFFF)
+// Legacy DHIS2 color aliases (keep existing usages compiling)
+val DHIS2Blue = ClinicalBlue
+val DHIS2BlueLight = ClinicalBlueLight
+val DHIS2BlueDark = ClinicalBlueDark
+val DHIS2BlueDeep = ClinicalBlueDark
+
+// Cool neutral surfaces.
+val NeutralBackground = Color(0xFFF5F7FA)
val NeutralSurface = Color(0xFFFFFFFF)
-val NeutralSurfaceVariant = Color(0xFFECECF0)
-val NeutralOutline = Color(0x1A000000) // 10% black
-val NeutralMuted = Color(0xFF717182)
-val NeutralOnSurface = Color(0xFF0F0F12)
+val NeutralSurfaceVariant = Color(0xFFE9EEF3)
+val NeutralOutline = Color(0xFFCBD5E1)
+val NeutralMuted = Color(0xFF64748B)
+val NeutralOnSurface = Color(0xFF1B2430)
// Light theme colors
-val Primary40 = DHIS2Blue
-val PrimaryContainer40 = Color(0xFFDBEAFE) // Light blue
-val Secondary40 = BrandGreen // Green available as secondary accent
+val Primary40 = ClinicalBlue
+val PrimaryContainer40 = ClinicalBlueContainer
+val Secondary40 = BrandGreen
// Dark theme colors
-val Primary80 = DHIS2BlueLight
-val PrimaryContainer80 = Color(0xFF1E3A5F) // Dark blue
-val Secondary80 = BrandGreenLight // Green as secondary accent
+val Primary80 = ClinicalBlueLight
+val PrimaryContainer80 = Color(0xFF19304A)
+val Secondary80 = BrandGreenLight
// Legacy colors for compatibility
val Purple80 = Primary80
@@ -45,8 +49,8 @@ val Pink40 = Color(0xFF7D5260)
// ============================================
// Dataset accent (blue)
-val DatasetAccent = Color(0xFF2563EB) // blue-600
-val DatasetAccentLight = Color(0xFFDBEAFE) // blue-100
+val DatasetAccent = ClinicalBlue
+val DatasetAccentLight = ClinicalBlueContainer
// Event accent (orange)
val EventAccent = Color(0xFFEA580C) // orange-600
diff --git a/app/src/main/java/com/ash/simpledataentry/ui/theme/Theme.kt b/app/src/main/java/com/ash/simpledataentry/ui/theme/Theme.kt
index 7317c3f..d2542b7 100644
--- a/app/src/main/java/com/ash/simpledataentry/ui/theme/Theme.kt
+++ b/app/src/main/java/com/ash/simpledataentry/ui/theme/Theme.kt
@@ -23,8 +23,8 @@ private val DarkColorScheme = darkColorScheme(
surface = Color(0xFF101214),
onSurface = Color(0xFFE6E8EC),
surfaceVariant = Color(0xFF1E2226),
- onSurfaceVariant = Color(0xFFB8BCC2),
- outline = Color(0xFF343A40)
+ onSurfaceVariant = Color(0xFFD3D7DD),
+ outline = Color(0xFF3F4650)
)
private val LightColorScheme = lightColorScheme(
diff --git a/app/src/main/res/drawable/login_logo.png b/app/src/main/res/drawable/login_logo.png
new file mode 100644
index 0000000..0df2859
Binary files /dev/null and b/app/src/main/res/drawable/login_logo.png differ
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..be43858
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..be43858
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6f4c762
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6f4c762
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6f4c762
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6f4c762
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6f4c762
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..f588421
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..d0d372f 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,5 @@
#FF018786
#FF000000
#FFFFFFFF
-
\ No newline at end of file
+ #0F6CBD
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..235e2fa
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #F5F7FA
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 00f0b98..aa382f9 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,10 @@
-
-
\ No newline at end of file
+
+
diff --git a/build.gradle b/build.gradle
index c7e5c29..34eab2e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,14 +4,14 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath("com.android.tools.build:gradle:8.9.1")
+ classpath('com.android.tools.build:gradle:8.13.2')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21")
classpath "com.google.dagger:hilt-android-gradle-plugin:2.56.2"
}
}
plugins {
- id 'com.android.application' version '8.9.1' apply false
+ id 'com.android.application' version '8.13.2' apply false
id 'com.android.library' version '8.2.0' apply false
id 'org.jetbrains.kotlin.android' version '2.1.21' apply false
id("com.google.devtools.ksp") version "2.1.21-2.0.1" apply false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f983fc8..90b6c13 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "8.9.1"
+agp = "8.13.2"
kotlin = "2.1.21"
coreKtx = "1.16.0"
junit = "4.13.2"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index fadb03d..6b69558 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Apr 28 14:34:41 EAT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists