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