From f7292584190e34c1be2d74e55227b28a5ccc538e Mon Sep 17 00:00:00 2001 From: Tinsae Date: Thu, 1 Jan 2026 03:11:08 +0100 Subject: [PATCH] remaining is cleaning up --- app/build.gradle.kts | 1 + .../java/net/tinsae/clocked/AuthViewModel.kt | 34 +++- .../java/net/tinsae/clocked/LoginScreen.kt | 4 +- .../java/net/tinsae/clocked/MainActivity.kt | 46 +++-- .../biometric/AuthenticationManager.kt | 5 +- .../clocked/biometric/GlobalAuthenticator.kt | 2 + .../clocked/dashboard/DashboardViewModel.kt | 125 ------------ .../net/tinsae/clocked/data/LogRepository.kt | 168 ++++++++++++---- .../clocked/history/HistoryViewModel.kt | 99 ---------- .../net/tinsae/clocked/service/LogService.kt | 13 +- .../{ => ui}/components/AddLogDialog.kt | 58 ++++-- .../{ => ui}/components/DetailsDialog.kt | 3 +- .../clocked/{ => ui}/components/ListItem.kt | 19 +- .../{ => ui}/components/LoadingAnimation.kt | 2 +- .../{ => ui}/components/LoadingScreen.kt | 2 +- .../clocked/{ => ui}/components/TopBar.kt | 5 +- .../screens}/anonymous/RegistrationForm.kt | 2 +- .../{ => ui/screens}/anonymous/SignInForm.kt | 2 +- .../screens}/dashboard/DashboardScreen.kt | 53 ++--- .../screens/dashboard/DashboardViewModel.kt | 184 ++++++++++++++++++ .../{ => ui/screens}/history/HistoryScreen.kt | 25 ++- .../ui/screens/history/HistoryViewModel.kt | 171 ++++++++++++++++ .../screens}/settings/SettingsScreen.kt | 2 +- .../screens}/settings/SettingsViewModel.kt | 6 +- .../net/tinsae/clocked/util/SupabaseClient.kt | 3 +- .../main/java/net/tinsae/clocked/util/Util.kt | 12 ++ 26 files changed, 684 insertions(+), 362 deletions(-) delete mode 100644 app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt delete mode 100644 app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/AddLogDialog.kt (80%) rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/DetailsDialog.kt (97%) rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/ListItem.kt (91%) rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/LoadingAnimation.kt (97%) rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/LoadingScreen.kt (91%) rename app/src/main/java/net/tinsae/clocked/{ => ui}/components/TopBar.kt (94%) rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/anonymous/RegistrationForm.kt (99%) rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/anonymous/SignInForm.kt (99%) rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/dashboard/DashboardScreen.kt (84%) create mode 100644 app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/history/HistoryScreen.kt (83%) create mode 100644 app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/settings/SettingsScreen.kt (99%) rename app/src/main/java/net/tinsae/clocked/{ => ui/screens}/settings/SettingsViewModel.kt (94%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c81488a..6177d4e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { // --- SUPABASE DEPENDENCIES (Corrected) --- // 1. Implement the BOM using the platform() keyword. implementation(platform(libs.supabase.bom)) + implementation("io.ktor:ktor-client-okhttp:3.3.3") // 2. Implement the specific Supabase modules using the correct aliases. implementation(libs.supabase.auth) diff --git a/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt b/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt index 80df16e..5ea5a1e 100644 --- a/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt @@ -6,23 +6,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.github.jan.supabase.auth.OtpType import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email import io.github.jan.supabase.auth.status.SessionStatus import io.github.jan.supabase.exceptions.BadRequestRestException import io.github.jan.supabase.exceptions.RestException -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import net.tinsae.clocked.data.FormType -import net.tinsae.clocked.data.LogRepository import net.tinsae.clocked.util.SupabaseClient // Add this data class here for UI state @@ -31,6 +28,7 @@ data class AuthUiState( val error: String? = null, val formType: FormType = FormType.LOGIN, val signupSuccess: Boolean = false, + val userName:String? = null ) class AuthViewModel : ViewModel() { @@ -47,6 +45,30 @@ class AuthViewModel : ViewModel() { initialValue = SupabaseClient.client.auth.sessionStatus.value ) + init { + viewModelScope.launch { + sessionStatus.collect { status -> + when (status) { + is SessionStatus.Authenticated -> { + val metadata = status.session.user?.userMetadata + val fullName = (metadata?.get("full_name") ?: metadata?.get("user_name")) + ?.jsonPrimitive // Get the JsonPrimitive? from the element + ?.contentOrNull // Get the String content, or null if it's not a string/is null + + uiState = uiState.copy(userName = fullName) + } + is SessionStatus.NotAuthenticated -> { + // When the user logs out or the session expires, clear the name. + uiState = uiState.copy(userName = null) + } + else -> { + // Handles Initializing, LoadingFromStorage states. No UI change needed here. + } + } + } + } + } + fun login(email: String, password: String) { viewModelScope.launch { uiState = uiState.copy(isLoading = true, error = null) @@ -74,7 +96,7 @@ class AuthViewModel : ViewModel() { put("full_name", name) } } - uiState = uiState.copy(signupSuccess = true) + uiState = uiState.copy(signupSuccess = true, formType = FormType.LOGIN) } catch (e: Exception) { uiState = uiState.copy(error = parseError(e)) } finally { diff --git a/app/src/main/java/net/tinsae/clocked/LoginScreen.kt b/app/src/main/java/net/tinsae/clocked/LoginScreen.kt index 85a2bfe..677a184 100644 --- a/app/src/main/java/net/tinsae/clocked/LoginScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/LoginScreen.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import net.tinsae.clocked.anonymous.RegistrationForm -import net.tinsae.clocked.anonymous.SignInForm +import net.tinsae.clocked.ui.screens.anonymous.RegistrationForm +import net.tinsae.clocked.ui.screens.anonymous.SignInForm import net.tinsae.clocked.data.FormType import net.tinsae.clocked.ui.theme.ClockedTheme diff --git a/app/src/main/java/net/tinsae/clocked/MainActivity.kt b/app/src/main/java/net/tinsae/clocked/MainActivity.kt index f6f79e5..ba58214 100644 --- a/app/src/main/java/net/tinsae/clocked/MainActivity.kt +++ b/app/src/main/java/net/tinsae/clocked/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold @@ -35,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope @@ -43,15 +45,17 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import net.tinsae.clocked.biometric.GlobalAuthenticator -import net.tinsae.clocked.components.ShowLoadingScreen -import net.tinsae.clocked.dashboard.DashboardScreen +import net.tinsae.clocked.ui.components.ShowLoadingScreen +import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen import net.tinsae.clocked.data.Locale import net.tinsae.clocked.data.LogRepository import net.tinsae.clocked.data.Theme -import net.tinsae.clocked.history.HistoryScreen -import net.tinsae.clocked.settings.SettingsScreen -import net.tinsae.clocked.settings.SettingsViewModel +import net.tinsae.clocked.ui.components.TopBar +import net.tinsae.clocked.ui.screens.history.HistoryScreen +import net.tinsae.clocked.ui.screens.settings.SettingsScreen +import net.tinsae.clocked.ui.screens.settings.SettingsViewModel import net.tinsae.clocked.ui.theme.ClockedTheme +import net.tinsae.clocked.util.Util.capitalise import java.text.SimpleDateFormat import java.util.Date @@ -94,6 +98,11 @@ class MainActivity : AppCompatActivity() { createDocumentLauncher.launch("Clocked-Export-$timestamp.csv") } } + /*authViewModel.sessionStatus.collect { + if (it is SessionStatus.Authenticated) { + LogRepository.subscribeRealTime() + } + }*/ } enableEdgeToEdge() @@ -102,7 +111,14 @@ class MainActivity : AppCompatActivity() { AppEntry(settingsViewModel, authViewModel) GlobalAuthenticator() } + + } + /*override fun onDestroy() { + super.onDestroy() + // When the app is being completely destroyed, ensure the connection is closed. + LogRepository.unsubscribeRealTime() + }*/ } @Composable @@ -112,7 +128,7 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) // Trigger fetching of logs when the session status changes. LaunchedEffect(sessionStatus) { if (sessionStatus is SessionStatus.Authenticated){ - LogRepository.fetchLogs() + //LogRepository. } } @@ -182,21 +198,25 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode ) { Scaffold( modifier = Modifier.fillMaxSize(), - /*topBar = { + topBar = { TopBar( - appName = stringResource(id = R.string.app_name, currentDestination.label), + appName = if(authViewModel.uiState.userName != null) + authViewModel.uiState.userName!!.capitalise() + else + stringResource(R.string.app_name), + modifier = Modifier.padding(horizontal = 16.dp), isForContainer = true, actions = { IconButton( - onClick = { authViewModel.logout() }, modifier = Modifier.padding(10.dp), + onClick = { authViewModel.logout() }, modifier = Modifier.padding(6.dp), ) { Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout)) } } ) - }*/ + } ) { innerPadding -> val modifier = Modifier @@ -211,9 +231,9 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode viewModel = settingsViewModel ) - AppDestinations.LOGOUT -> { + /*AppDestinations.LOGOUT -> { authViewModel.logout() - } + }*/ } } } @@ -226,5 +246,5 @@ enum class AppDestinations( HOME(R.string.nav_home, Icons.Default.Home), HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List), SETTING(R.string.nav_settings, Icons.Default.Settings), - LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout) + //LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout) } diff --git a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt index ea23a25..eb75762 100644 --- a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt +++ b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.update // The 'onSuccess' lambda is the protected action to run. data class AuthRequest( val action: String, + val onCancel: () -> Unit, val onSuccess: () -> Unit ) @@ -20,8 +21,8 @@ object AuthenticationManager { val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow // Any ViewModel can call this to request authentication. - fun requestAuth(action: String, onAuthenticated: () -> Unit) { - _request.update { AuthRequest(action = action, onSuccess = onAuthenticated) } + fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) { + _request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) } } // Call this to clear the request after it's been handled (success or fail). diff --git a/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt index 1708dc4..52878b2 100644 --- a/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt +++ b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt @@ -24,9 +24,11 @@ fun GlobalAuthenticator() { }, onFailure = { // On failure or cancel, just clear the request. + request.onCancel() AuthenticationManager.clearRequest() }, onCancel = { + request.onCancel() AuthenticationManager.clearRequest() } ) diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt deleted file mode 100644 index ca6e51d..0000000 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt +++ /dev/null @@ -1,125 +0,0 @@ -package net.tinsae.clocked.dashboard - - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import net.tinsae.clocked.biometric.AuthenticationManager -import net.tinsae.clocked.data.EntryType -import net.tinsae.clocked.data.Log -import net.tinsae.clocked.data.LogRepository -import net.tinsae.clocked.data.LogRepository.getRecentLogs -import net.tinsae.clocked.data.LogRepository.getTotalOvertimeDuration -import net.tinsae.clocked.data.LogRepository.getTotalTimeOffDuration -import net.tinsae.clocked.util.Util.formatDuration -import kotlin.time.Duration -import kotlin.time.Instant -import android.util.Log as LOG - - - -data class DashboardUiState( - val overtime: String = "0m", - val timeOff: String = "0m", - val netBalance: String = "0m", - val balanceInDays: String = "0d", - val recentActivities: List = emptyList(), - val showAddLogDialog: Boolean = false, - val dialogType: EntryType = EntryType.OVERTIME, - val isLoading: Boolean = true, -) - -class DashboardViewModel : ViewModel() { - - private val _internalState = MutableStateFlow(DashboardUiState()) - - val uiState: StateFlow = combine( - LogRepository.isLoading, - _internalState - ) { isLoading, internalState -> - val overtimeDuration = getTotalOvertimeDuration() - val timeOffDuration = getTotalTimeOffDuration() - - - val netBalanceDuration = overtimeDuration + timeOffDuration - val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0 - - // calculate the balance in days (assuming an 8-hour workday) - // and format it to one decimal place for a clean look. - val balanceInDaysValue = netBalanceInHours / 8.0 - val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " - - - - DashboardUiState( - overtime = formatDuration(overtimeDuration), - timeOff = formatDuration(timeOffDuration), - netBalance = formatDuration(overtimeDuration + timeOffDuration), - recentActivities = getRecentLogs(), - balanceInDays = balanceInDaysString, - isLoading = isLoading, - // Pass through the dialog state from the internal state holder - showAddLogDialog = internalState.showAddLogDialog, - dialogType = internalState.dialogType - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = DashboardUiState(isLoading = true) - ) - - fun toggleAddLogDialog(){ - _internalState.update { it.copy(showAddLogDialog = !it.showAddLogDialog) } - } - - fun onSaveLog(timestamp: Instant, duration: Duration, reason: String?) { - viewModelScope.launch { - try { - LogRepository.addLog( - // Use the dialogType from the internal state's current value - type = _internalState.value.dialogType, - timestamp = timestamp, - duration = duration, - reason = reason - ) - // On success, hide the dialog. The repository will trigger the data refresh. - toggleAddLogDialog() - - } catch (e: Exception) { - // Handle errors - LOG.e("DashboardViewModel", "Failed to save log", e) - } - } - } - - - - - fun editLog(log: Log) { - LogRepository.editLog(log) - } - - fun deleteLog(log: Log) { - LogRepository.deleteLog(log) - } - - fun requestDeleteWithAuth(log: Log) { - AuthenticationManager.requestAuth( - action = "delete this log", - onAuthenticated = { deleteLog(log) } // Pass the function to run on success - ) - } - - fun requestEditWithAuth(log: Log) { - AuthenticationManager.requestAuth( - action = "edit this log", - onAuthenticated = { editLog(log) } - ) - } -} diff --git a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt index bbfd7b4..1564464 100644 --- a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt +++ b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt @@ -1,5 +1,8 @@ package net.tinsae.clocked.data +import io.github.jan.supabase.auth.auth +import io.github.jan.supabase.auth.status.SessionStatus +import io.github.jan.supabase.realtime.Realtime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +16,15 @@ import net.tinsae.clocked.service.getAllLogsFromDB import kotlin.time.Duration import kotlin.time.Instant import android.util.Log as LOG +import io.github.jan.supabase.realtime.PostgresAction +import io.github.jan.supabase.realtime.RealtimeChannel +import io.github.jan.supabase.realtime.channel +import io.github.jan.supabase.realtime.decodeRecord +import io.github.jan.supabase.realtime.postgresChangeFlow +import io.github.jan.supabase.realtime.realtime +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch +import net.tinsae.clocked.util.SupabaseClient /** * A Singleton Repository that acts as the Single Source of Truth for Log data. @@ -31,67 +43,133 @@ object LogRepository { // testing end private val repositoryScope = CoroutineScope(Dispatchers.IO) + private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel") + private var realtimeJob: Job? = null + + init { + repositoryScope.launch { + SupabaseClient.client.auth.sessionStatus.collect { status -> + when (status) { + is SessionStatus.Authenticated -> { + LOG.d("LogRepository", "Auth: Authenticated. Fetching data and subscribing.") + // Cancel any previous realtime listener to avoid duplicates + realtimeJob?.cancel() + // Fetch initial data + fetchLogs() + // Start listening for new changes and subscribe + listenForLogChanges() + realtimeChannel.subscribe() + } + is SessionStatus.NotAuthenticated -> { + LOG.d("LogRepository", "Auth: Not Authenticated. Clearing data.") + // User is signed out, clear local data and unsubscribe + clearLogs() + realtimeJob?.cancel() + realtimeChannel.unsubscribe() + } + else -> { /* Handle other states if necessary */ } + } + } + } + } + + private fun listenForLogChanges() { + realtimeJob = repositoryScope.launch { + val changeFlow = realtimeChannel.postgresChangeFlow(schema = "public") { + table = "Logs" // The name of your table in Supabase + } + + changeFlow + .catch { e -> LOG.e("LogRepository", "Realtime error", e) } + .collect { action -> + when (action) { + is PostgresAction.Insert -> { + LOG.d("LogRepository", "Realtime INSERT: ${action.record}") + val newLog = action.decodeRecord() + _logs.update { currentLogs -> + (currentLogs + newLog).sortedByDescending { it.timestamp } + } + } + is PostgresAction.Update -> { + LOG.d("LogRepository", "Realtime UPDATE: ${action.record}") + val updatedLog = action.decodeRecord() + _logs.update { currentLogs -> + currentLogs.map { if (it.id == updatedLog.id) updatedLog else it } + .sortedByDescending { it.timestamp } + } + } + is PostgresAction.Delete -> { + LOG.d("LogRepository", "Realtime DELETE: ${action.oldRecord}") + val deletedId = action.oldRecord["id"]?.toString()?.toLongOrNull() + if (deletedId != null) { + _logs.update { currentLogs -> + currentLogs.filterNot { it.id == deletedId } + } + } + } + else -> { /* Unhandled action */ } + } + } + } + } /** * Fetches the latest logs from the service and updates the flow. */ - fun fetchLogs() { - // TEST CODE HERE - repositoryScope.launch { - if (_isLoading.value) return@launch // Prevent concurrent fetches - - _isLoading.update { true } - try { - val latestLogs = getAllLogsFromDB() - _logs.update { latestLogs } - } catch (e: Exception) { - LOG.e("LogRepository", "Failed to fetch logs", e) - } finally { - _isLoading.update { false } - } + /** + * Fetches the initial list of logs from the service. + * This is now a suspend function to ensure proper sequencing. + */ + private suspend fun fetchLogs() { + if (_isLoading.value) return + _isLoading.update { true } + try { + val latestLogs = getAllLogsFromDB() + _logs.update { latestLogs } + } catch (e: Exception) { + LOG.e("LogRepository", "Failed to fetch logs", e) + } finally { + _isLoading.update { false } } } /** - * Adds a new log via the service and then triggers a refetch to update the flow. + * Adds a new log. Realtime will handle the UI update. */ - fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) { - repositoryScope.launch { - try { - addLogToDB(type, timestamp, duration, reason) - // After adding, refetch the entire list to ensure consistency. - fetchLogs() - } catch (e: Exception) { - LOG.e("LogRepository", "Failed to add log", e) - } + suspend fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) { + try { + addLogToDB(type, timestamp, duration, reason) + } catch (e: Exception) { + LOG.e("LogRepository", "Failed to add log", e) } } - - fun deleteLog(log: Log) { - repositoryScope.launch { - try { - deleteLogFromDB(log) - // After deleting, refetch the entire list to ensure consistency. - fetchLogs() - } catch (e: Exception) { - LOG.e("LogRepository", "Failed to delete log", e) - } - + /** + * Deletes a log. Realtime will handle the UI update. + */ + suspend fun deleteLog(log: Log) { + try { + deleteLogFromDB(log) + } catch (e: Exception) { + LOG.e("LogRepository", "Failed to delete log", e) } } - fun editLog(log: Log) { - repositoryScope.launch { + /** + * Edits a log. Realtime will handle the UI update. + */ + suspend fun editLog(log: Log) { + try { editLogFromDB(log) - fetchLogs() + } catch (e: Exception) { + LOG.e("LogRepository", "Failed to edit log", e) } } - + // get the latest 10 entries fun getRecentLogs():List { - return logs.value.take(7) + return logs.value.take(10) } fun getTotalOvertimeDuration(): Duration { @@ -103,4 +181,14 @@ object LogRepository { return logs.value.filter { it.type == EntryType.TIME_OFF } .map { it.duration }.fold(Duration.ZERO, Duration::plus) } + + fun getLogById(id: Long): Log? { + return logs.value.find { it.id == id } + } + + private fun clearLogs() { + _logs.update { emptyList() } + _isLoading.update { false } + LOG.d("LogRepository", "Local log data cleared.") + } } diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt b/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt deleted file mode 100644 index 8fb030d..0000000 --- a/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package net.tinsae.clocked.history - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import net.tinsae.clocked.data.EntryType -import net.tinsae.clocked.data.Log -import net.tinsae.clocked.data.LogRepository -import net.tinsae.clocked.util.Util.formatTimestampToMonthYear -import android.util.Log as LOG - - -data class HistoryUiState( - val tabs: List = emptyList(), - val selectedTabIndex: Int = 0, - val groupedEntries: Map>? = emptyMap(), - val selectedLogForDialog: Log? = null, - val isLoading: Boolean = true, // isLoading is true when the log list is empty - val showBiometricDialog: Boolean = false -) - -class HistoryViewModel : ViewModel() { - // This state now only holds things that are NOT derived from the main log list, - // like which tab is selected or which dialog is shown. - private val _internalState = MutableStateFlow(HistoryUiState()) - - // --- THE FIX IS HERE --- - val uiState: StateFlow = combine( - LogRepository.logs, // Source 1: The list of all logs from the repository - _internalState // Source 2: The internal state (e.g., selected tab) - ) { logs, internalState -> - val allLogs = logs // The 'logs' parameter is already the List - - // Perform filtering based on the selected tab index from the internal state - val filteredEntries = when (internalState.selectedTabIndex) { - 1 -> allLogs.filter { it.type == EntryType.OVERTIME } - 2 -> allLogs.filter { it.type == EntryType.TIME_OFF } - else -> allLogs - } - - // Perform grouping on the filtered list - val grouped = filteredEntries.groupBy { log -> - formatTimestampToMonthYear(log.timestamp) - } - - // Construct the final UI state - HistoryUiState( - tabs = internalState.tabs, - selectedTabIndex = internalState.selectedTabIndex, - selectedLogForDialog = internalState.selectedLogForDialog, - groupedEntries = grouped, - isLoading = allLogs.isEmpty() // Show loading indicator if the log list is empty - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = HistoryUiState(isLoading = true) // Start with a default empty state - ) - - fun setTabTitles(titles: List) { - _internalState.update { it.copy(tabs = titles) } - } - - fun onTabSelected(index: Int) { - // When a tab is selected, we only need to update the internal state. - // The 'combine' block will automatically re-run and do the filtering. - _internalState.update { it.copy(selectedTabIndex = index) } - } - - fun onLogSelected(log: Log) { - _internalState.update { it.copy(selectedLogForDialog = log) } - } - - fun onDismissDialog() { - _internalState.update { it.copy(selectedLogForDialog = null) } - } - - fun deleteLog(log: Log) { - // Deleting and editing are "write" operations, they should talk to the repository. - LogRepository.deleteLog(log) - } - - fun editLog(log: Log) { - LogRepository.editLog(log) - } - - // The old filterAndGroupEntries() function is no longer needed, - // as its logic is now inside the 'combine' block. - // private fun filterAndGroupEntries() { ... } - fun onToggleBiometricDialog() { - _internalState.update { it.copy(showBiometricDialog = !it.showBiometricDialog) } - LOG.d("HistoryViewModel", "Biometric dialog toggled") - } -} diff --git a/app/src/main/java/net/tinsae/clocked/service/LogService.kt b/app/src/main/java/net/tinsae/clocked/service/LogService.kt index 4800611..32b118e 100644 --- a/app/src/main/java/net/tinsae/clocked/service/LogService.kt +++ b/app/src/main/java/net/tinsae/clocked/service/LogService.kt @@ -57,13 +57,14 @@ suspend fun deleteLogFromDB(log: Log): Boolean { suspend fun editLogFromDB(updatedLog: Log): Boolean { return try { + val updateEntry = LogEntry( + duration = updatedLog.duration, + reason = updatedLog.reason, + type = updatedLog.type, + timestamp = updatedLog.timestamp, + ) SupabaseClient.client.postgrest.from("Logs").update( - { - set("timestamp", updatedLog.timestamp) - set("type", updatedLog.type) - set("duration", updatedLog.duration) - set("reason", updatedLog.reason) - } + updateEntry ) { filter { eq("id", updatedLog.id) diff --git a/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt b/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt similarity index 80% rename from app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt index 2f77a58..6e0527e 100644 --- a/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,13 +16,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Snackbar import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import android.util.Log as LOG import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.setSelection import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -37,9 +39,11 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import net.tinsae.clocked.R import net.tinsae.clocked.data.EntryType +import net.tinsae.clocked.data.Log import net.tinsae.clocked.ui.theme.ClockedTheme import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant @@ -47,33 +51,61 @@ import kotlin.time.Instant @Composable fun AddLogDialog( onDismiss: () -> Unit, - onSave: (timestamp: Instant, duration: Duration, reason: String?) -> Unit + onSave: (id:Long?, timestamp: Instant, duration: Duration, reason: String?,type: EntryType) -> Unit, + log: Log? = null ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var hours by remember { mutableStateOf("") } var minutes by remember { mutableStateOf("") } var reason by remember { mutableStateOf("") } - // error if submit button is clicked with mandatory values not filled (duration) - val error = remember { mutableStateOf(null) } - var selectedInstant by remember { mutableStateOf(Clock.System.now()) } + var selectedType by remember { mutableStateOf(EntryType.OVERTIME) } + + val error = remember { mutableStateOf(null) } var showDatePicker by remember { mutableStateOf(false) } - // State for the new EntryType selection - var selectedType by remember { mutableStateOf(EntryType.OVERTIME) } + val datePickerState = rememberDatePickerState() + + // 2. REMOVE the unconditional `if (log != null)` block that was overwriting your state. + + // 3. Use ONE LaunchedEffect keyed to the `log` object to handle ALL state population. + LaunchedEffect(key1 = log) { + if (log != null) { + // Editing an existing log: Populate all state variables here. + val totalMinutes = log.duration.inWholeMinutes + selectedType = if (totalMinutes >= 0) EntryType.OVERTIME else EntryType.TIME_OFF + hours = kotlin.math.abs(totalMinutes / 60).toString() + minutes = kotlin.math.abs(totalMinutes % 60).toString() + reason = log.reason ?: "" + selectedInstant = log.timestamp + selectedType = log.type + // **THE API FIX**: The correct function is `setSelection`, but it is internal. + // The public API is to set the `selectedDateMillis` property directly. + // This will now correctly update the hoisted DatePickerState. + datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds() + LOG.d("AddLogDialog", "Populating fields from log: $log") + } else { + // Adding a new log: Reset fields to their defaults. + hours = "" + minutes = "" + reason = "" + selectedInstant = Clock.System.now() + selectedType = EntryType.OVERTIME + datePickerState.selectedDateMillis = Clock.System.now().toEpochMilliseconds() + } + } + val entryTypes = listOf(EntryType.OVERTIME, EntryType.TIME_OFF) - // Custom date formatting logic using kotlinx-datetime val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault()) val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}" if (showDatePicker) { - val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds()) - DatePickerDialog( onDismissRequest = { showDatePicker = false }, confirmButton = { TextButton(onClick = { + // This logic is correct. It reads from the hoisted state. datePickerState.selectedDateMillis?.let { millis -> selectedInstant = Instant.fromEpochMilliseconds(millis) } @@ -213,7 +245,7 @@ fun AddLogDialog( val totalMinutes = if (selectedType == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m) val duration = totalMinutes.minutes - onSave(selectedInstant, duration, reason) + onSave(log?.id,selectedInstant, duration, reason,selectedType) } ) { Text(stringResource(id = R.string.save)) @@ -228,7 +260,7 @@ fun AddLogDialogPreview(){ ClockedTheme { AddLogDialog( onDismiss = {}, - onSave = { _, _, _ -> } + onSave = { _,_, _, _, _ -> } ) } } diff --git a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt b/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt similarity index 97% rename from app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt index 01d46e9..e1735a4 100644 --- a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,7 +21,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import net.tinsae.clocked.R -import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.Log import net.tinsae.clocked.util.Util.formatDuration import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString diff --git a/app/src/main/java/net/tinsae/clocked/components/ListItem.kt b/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt similarity index 91% rename from app/src/main/java/net/tinsae/clocked/components/ListItem.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt index f8c42bf..34e5dc8 100644 --- a/app/src/main/java/net/tinsae/clocked/components/ListItem.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -69,7 +69,7 @@ fun ListItem( Box( modifier = modifier .fillMaxWidth() - .clickable { onClick() } // Make the whole area clickable + .clickable { onClick() } ) { // --- Background content (icons for swipe) --- Row( @@ -110,7 +110,10 @@ fun ListItem( offsetX = 0f } - offsetX < deleteThreshold -> onDelete() + offsetX < deleteThreshold -> { + onDelete() + offsetX = 0f + } else -> offsetX = 0f } } @@ -121,7 +124,7 @@ fun ListItem( } } .fillMaxWidth(), - color = MaterialTheme.colorScheme.surface // Ensures background is opaque + color = MaterialTheme.colorScheme.surface ) { Column { Row( @@ -131,22 +134,20 @@ fun ListItem( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Default.AccessTime, // Changed to a more relevant round icon + imageVector = Icons.Default.AccessTime, contentDescription = stringResource(R.string.duration_entry_icon), modifier = Modifier.size(30.dp), - tint = MaterialTheme.colorScheme.secondary // Changed to secondary for better contrast + tint = MaterialTheme.colorScheme.secondary ) Spacer(modifier = Modifier.width(16.dp)) - // 2. Primary Text (using your existing log data) Text( modifier = Modifier.weight(1f), text = formatTimestampToLocalDateString(log.timestamp), style = MaterialTheme.typography.bodyLarge, ) - // 3. Trailing Text (using your existing log data) Text( text = formatDuration(log.duration), style = MaterialTheme.typography.bodyMedium, @@ -160,7 +161,7 @@ fun ListItem( modifier = Modifier .fillMaxWidth() // Indent the divider to align with text - .padding(start = 56.dp) // 16dp (padding) + 40dp (icon) + 16dp (spacer) + .padding(start = 56.dp) ) } } diff --git a/app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt similarity index 97% rename from app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt index 5bedea1..17fc1cd 100644 --- a/app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.animation.core.RepeatMode diff --git a/app/src/main/java/net/tinsae/clocked/components/LoadingScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingScreen.kt similarity index 91% rename from app/src/main/java/net/tinsae/clocked/components/LoadingScreen.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/LoadingScreen.kt index b47b4ef..12d3463 100644 --- a/app/src/main/java/net/tinsae/clocked/components/LoadingScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingScreen.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize diff --git a/app/src/main/java/net/tinsae/clocked/components/TopBar.kt b/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt similarity index 94% rename from app/src/main/java/net/tinsae/clocked/components/TopBar.kt rename to app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt index e56e983..00a155a 100644 --- a/app/src/main/java/net/tinsae/clocked/components/TopBar.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.components +package net.tinsae.clocked.ui.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -45,7 +46,7 @@ fun TopBar( Row( // Apply safe area padding to the Row to push content down modifier = modifier, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically ) { if (appName != null) { Text( diff --git a/app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/RegistrationForm.kt similarity index 99% rename from app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/RegistrationForm.kt index e1083e9..9458ebe 100644 --- a/app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/RegistrationForm.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.anonymous +package net.tinsae.clocked.ui.screens.anonymous import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/SignInForm.kt similarity index 99% rename from app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/SignInForm.kt index 321cbfb..5a7bf6f 100644 --- a/app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/anonymous/SignInForm.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.anonymous +package net.tinsae.clocked.ui.screens.anonymous import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt similarity index 84% rename from app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt index 772a1a3..7ba2a07 100644 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.dashboard +package net.tinsae.clocked.ui.screens.dashboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -34,12 +35,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import net.tinsae.clocked.R -import net.tinsae.clocked.components.AddLogDialog -import net.tinsae.clocked.components.DetailsDialog -import net.tinsae.clocked.components.ListItem -import net.tinsae.clocked.components.LoadingAnimation -import net.tinsae.clocked.components.TopBar import net.tinsae.clocked.data.Log +import net.tinsae.clocked.ui.components.AddLogDialog +import net.tinsae.clocked.ui.components.DetailsDialog +import net.tinsae.clocked.ui.components.ListItem +import net.tinsae.clocked.ui.components.LoadingAnimation +import net.tinsae.clocked.ui.components.TopBar import net.tinsae.clocked.ui.theme.cyan import net.tinsae.clocked.ui.theme.green import net.tinsae.clocked.ui.theme.red @@ -52,31 +53,21 @@ fun DashboardScreen( ) { val uiState by viewModel.uiState.collectAsState() + if (uiState.showAddLogDialog) { - AddLogDialog( - onDismiss = viewModel::toggleAddLogDialog, - onSave = viewModel::onSaveLog - ) + key(System.currentTimeMillis()){ + AddLogDialog( + onDismiss = viewModel::toggleAddLogDialog, + onSave = viewModel::onSaveLog, + log = uiState.logToEdit + ) + } } Column( modifier = modifier .fillMaxSize() ) { - //BalanceBanner(uiState.netBalance+" ( "+uiState.balanceInDays+")", cyan, uiState.isLoading) - TopBar( - modifier = Modifier.padding(16.dp), - appName = stringResource(id = R.string.net_balance), - actions = { - Text( - text = uiState.netBalance+" ( "+uiState.balanceInDays+")", - style = MaterialTheme.typography.titleLarge, - color = cyan, - fontWeight = FontWeight.Bold - ) - } - ) - Row( modifier = Modifier @@ -102,6 +93,16 @@ fun DashboardScreen( isLoading = uiState.isLoading ) } + SummaryCard( + title = stringResource(id = R.string.net_balance), + value = uiState.netBalance+" ( "+uiState.balanceInDays+")", + //style = MaterialTheme.typography.titleLarge, + color = cyan, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + titleStyle = MaterialTheme.typography.titleMedium, + isLoading = uiState.isLoading + ) + Spacer(modifier = Modifier.height(16.dp)) ActivityList( recentLogs = uiState.recentActivities, @@ -154,7 +155,7 @@ fun ActionButton(text: String, onClick: () -> Unit) { @Composable fun ActivityList( modifier: Modifier = Modifier, - onEditLog: (Log) -> Unit = {}, + onEditLog: (Long) -> Unit = {}, onDeleteLog: (Log) -> Unit = {}, recentLogs: List, viewModel: DashboardViewModel @@ -194,7 +195,7 @@ fun ActivityList( ListItem( modifier = Modifier.padding(horizontal = 16.dp), log = log, - onEdit = { onEditLog(log) }, + onEdit = { onEditLog(log.id) }, onDelete = {onDeleteLog(log) }, // When the item is clicked, set it as the selected log for the dialog onClick = { selectedLogForDialog = log } diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..75db570 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt @@ -0,0 +1,184 @@ +package net.tinsae.clocked.ui.screens.dashboard + + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.tinsae.clocked.biometric.AuthenticationManager +import net.tinsae.clocked.data.EntryType +import net.tinsae.clocked.data.Log +import net.tinsae.clocked.data.LogRepository +import net.tinsae.clocked.data.LogRepository.getLogById +import net.tinsae.clocked.data.LogRepository.getRecentLogs +import net.tinsae.clocked.data.LogRepository.getTotalOvertimeDuration +import net.tinsae.clocked.data.LogRepository.getTotalTimeOffDuration +import net.tinsae.clocked.util.Util.formatDuration +import kotlin.time.Duration +import kotlin.time.Instant +import android.util.Log as LOG + + + +data class DashboardUiState( + val overtime: String = "0m", + val timeOff: String = "0m", + val netBalance: String = "0m", + val balanceInDays: String = "0d", + val recentActivities: List = emptyList(), + val showAddLogDialog: Boolean = false, + //val dialogType: EntryType = EntryType.OVERTIME, + val isLoading: Boolean = true, + val logToEdit:Log? = null +) + +class DashboardViewModel : ViewModel() { + + private val _internalState = MutableStateFlow(DashboardUiState()) + private val _isSaving = MutableStateFlow(false) + val isSaving = _isSaving.asStateFlow() + + val uiState: StateFlow = combine( + LogRepository.logs, + LogRepository.isLoading, + _internalState + ) {logs, isLoading, internalState -> + val overtimeDuration = getTotalOvertimeDuration() + val timeOffDuration = getTotalTimeOffDuration() + + + val netBalanceDuration = overtimeDuration + timeOffDuration + val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0 + + // calculate the balance in days (assuming an 8-hour workday) + // and format it to one decimal place for a clean look. + val balanceInDaysValue = netBalanceInHours / 8.0 + val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " + + + + DashboardUiState( + overtime = formatDuration(overtimeDuration), + timeOff = formatDuration(timeOffDuration), + netBalance = formatDuration(overtimeDuration + timeOffDuration), + recentActivities = logs.take(6), + balanceInDays = balanceInDaysString, + isLoading = isLoading, + // Pass through the dialog state from the internal state holder + showAddLogDialog = internalState.showAddLogDialog, + //dialogType = internalState.dialogType, + logToEdit = internalState.logToEdit + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = DashboardUiState(isLoading = true) + ) + + fun toggleAddLogDialog() { + _internalState.update { + // If we are currently showing the dialog, the toggle will hide it. + // When hiding, we MUST also reset logToEdit. + if (it.showAddLogDialog) { + it.copy( + showAddLogDialog = false, + logToEdit = null + ) + } else { + // This handles showing the dialog for a NEW entry + it.copy(showAddLogDialog = true) + } + } + } + + fun onSaveLog(id:Long?,timestamp: Instant, duration: Duration, reason: String?, type: EntryType) { + viewModelScope.launch { + _isSaving.value = true + try { + if (id == null) { + LogRepository.addLog( + type = type, + timestamp = timestamp, + duration = duration, + reason = reason + ) + } else { + val original = getLogById(id) + if(original != null) { + val updated = original.copy( + type = type, + timestamp = timestamp, + duration = duration, + reason = reason + ) + LogRepository.editLog(updated) + }else { + LOG.e("DashboardViewModel", "Tried to edit a log that doesn't exist: ID $id") + } + } + // After saving, hide the dialog and reset the state. + _internalState.update { + it.copy(showAddLogDialog = false, logToEdit = null) + } + LOG.d("DashboardViewModel", "log set to: ${uiState.value.logToEdit}") + } catch (e: Exception) { + LOG.e("DashboardViewModel", "Error saving log", e) + } finally { + _isSaving.value = false + } + } + } + + fun editLog(log: Log) { + viewModelScope.launch { + try { + LogRepository.editLog(log) + // Realtime will remove the item from the list automatically. + } catch (e: Exception) { + LOG.e("DashboardViewModel", "Failed to edit log", e) + } + } + } + + fun deleteLog(log: Log) { + viewModelScope.launch { + try { + LogRepository.deleteLog(log) + // Realtime will remove the item from the list automatically. + } catch (e: Exception) { + LOG.e("DashboardViewModel", "Failed to delete log", e) + } + } + } + + fun requestDeleteWithAuth(log: Log) { + AuthenticationManager.requestAuth( + action = "delete this log", + onAuthenticated = { deleteLog(log) }, // Pass the function to run on success + onAuthCanceled = {} + ) + } + + fun requestEditWithAuth(id:Long) { + AuthenticationManager.requestAuth( + action = "edit this log", + onAuthenticated = { + val freshLog = getLogById(id) + _internalState.update { currentState -> + currentState.copy( + logToEdit = freshLog, + showAddLogDialog = true + ) + } + }, + onAuthCanceled = { + } + ) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt similarity index 83% rename from app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt index 313108e..3916084 100644 --- a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.history +package net.tinsae.clocked.ui.screens.history import android.annotation.SuppressLint import androidx.compose.foundation.ExperimentalFoundationApi @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource @@ -26,8 +27,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import net.tinsae.clocked.R -import net.tinsae.clocked.components.ListItem -import net.tinsae.clocked.components.DetailsDialog +import net.tinsae.clocked.ui.components.AddLogDialog +import net.tinsae.clocked.ui.components.ListItem +import net.tinsae.clocked.ui.components.DetailsDialog import net.tinsae.clocked.ui.theme.ClockedTheme @OptIn(ExperimentalFoundationApi::class) @@ -48,14 +50,21 @@ fun HistoryScreen( val uiState: HistoryUiState by viewModel.uiState.collectAsState() + if (uiState.showAddLogDialog) { + AddLogDialog( + onDismiss = viewModel::toggleAddLogDialog, + onSave = viewModel::editLog, + log = uiState.logToEdit + ) + } + Column( modifier = modifier.fillMaxSize() ) { if (uiState.tabs.isNotEmpty()) { PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, - modifier = Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surfaceContainer), - containerColor = MaterialTheme.colorScheme.surfaceContainer + modifier = Modifier.fillMaxWidth(), + //containerColor = MaterialTheme.colorScheme.surfaceContainer ) { uiState.tabs.forEachIndexed { index, title -> Tab( @@ -80,8 +89,8 @@ fun HistoryScreen( ListItem( log = entry, modifier = Modifier.padding(horizontal = 16.dp), - onDelete = { viewModel.deleteLog(entry) }, - onEdit = { viewModel.editLog(entry) }, + onDelete = { viewModel.requestDeleteWithAuth(entry) }, + onEdit = { viewModel.requestEditWithAuth(entry.id) }, onClick = { viewModel.onLogSelected(entry) } ) } diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt new file mode 100644 index 0000000..b25b43a --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt @@ -0,0 +1,171 @@ +package net.tinsae.clocked.ui.screens.history + +import android.util.Log as LOG +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.tinsae.clocked.biometric.AuthenticationManager +import net.tinsae.clocked.data.EntryType +import net.tinsae.clocked.data.Log +import net.tinsae.clocked.data.LogRepository +import net.tinsae.clocked.data.LogRepository.getLogById +import net.tinsae.clocked.util.Util.formatTimestampToMonthYear +import kotlin.time.Duration +import kotlin.time.Instant + + +data class HistoryUiState( + val tabs: List = emptyList(), + val selectedTabIndex: Int = 0, + val groupedEntries: Map>? = emptyMap(), + val selectedLogForDialog: Log? = null, + val isLoading: Boolean = true, // isLoading is true when the log list is empty + val showBiometricDialog: Boolean = false, + val logToEdit:Log? = null, + val showAddLogDialog: Boolean = false, +) + +class HistoryViewModel : ViewModel() { + // This state now only holds things that are NOT derived from the main log list, + // like which tab is selected or which dialog is shown. + private val _internalState = MutableStateFlow(HistoryUiState()) + + // --- THE FIX IS HERE --- + val uiState: StateFlow = combine( + LogRepository.logs, // Source 1: The list of all logs from the repository + _internalState // Source 2: The internal state (e.g., selected tab) + ) { logs, internalState -> + val allLogs = logs // The 'logs' parameter is already the List + + // Perform filtering based on the selected tab index from the internal state + val filteredEntries = when (internalState.selectedTabIndex) { + 1 -> allLogs.filter { it.type == EntryType.OVERTIME } + 2 -> allLogs.filter { it.type == EntryType.TIME_OFF } + else -> allLogs + } + + // Perform grouping on the filtered list + val grouped = filteredEntries.groupBy { log -> + formatTimestampToMonthYear(log.timestamp) + } + + // Construct the final UI state + HistoryUiState( + tabs = internalState.tabs, + selectedTabIndex = internalState.selectedTabIndex, + selectedLogForDialog = internalState.selectedLogForDialog, + groupedEntries = grouped, + logToEdit = internalState.logToEdit, + showAddLogDialog = internalState.showAddLogDialog, + showBiometricDialog = false, + isLoading = allLogs.isEmpty() // Show loading indicator if the log list is empty + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = HistoryUiState(isLoading = true) // Start with a default empty state + ) + + fun setTabTitles(titles: List) { + _internalState.update { it.copy(tabs = titles) } + } + + fun onTabSelected(index: Int) { + // When a tab is selected, we only need to update the internal state. + // The 'combine' block will automatically re-run and do the filtering. + _internalState.update { it.copy(selectedTabIndex = index) } + } + + fun onLogSelected(log: Log) { + _internalState.update { it.copy(selectedLogForDialog = log) } + } + + fun onDismissDialog() { + _internalState.update { it.copy(selectedLogForDialog = null) } + } + + fun deleteLog(log: Log) { + viewModelScope.launch { + try { + LogRepository.deleteLog(log) + // Realtime will remove the item from the list automatically. + } catch (e: Exception) { + LOG.e("HistoryViewModel", "Failed to delete log", e) + } + } + } + + fun editLog(id:Long?,timestamp: Instant, duration: Duration, reason: String?, type: EntryType) { + if(id == null) return + LOG.d("HistoryViewModel", "Editing log with ID: $id") + viewModelScope.launch { + try { + val original = getLogById(id) + if(original != null) { + val updated = original.copy( + type = type, + timestamp = timestamp, + duration = duration, + reason = reason + ) + LogRepository.editLog(updated) + // After saving, hide the dialog and reset the state. + _internalState.update { + it.copy(showAddLogDialog = false, logToEdit = null) + } + } else { + LOG.e("HistoryViewModel", "No log found to edit") + } + } catch (e: Exception) { + LOG.e("HistoryViewModel", "Failed to edit log", e) + } + } + } + + fun requestDeleteWithAuth(log: Log) { + AuthenticationManager.requestAuth( + action = "delete this log", + onAuthenticated = { deleteLog(log) }, // Pass the function to run on success + onAuthCanceled = {} + ) + } + + fun toggleAddLogDialog() { + _internalState.update { + // If we are currently showing the dialog, the toggle will hide it. + // When hiding, we MUST also reset logToEdit. + if (it.showAddLogDialog) { + it.copy( + showAddLogDialog = false, + logToEdit = null + ) + } else { + // This handles showing the dialog for a NEW entry + it.copy(showAddLogDialog = true) + } + } + } + + fun requestEditWithAuth(id:Long) { + AuthenticationManager.requestAuth( + action = "edit this log", + onAuthenticated = { + val freshLog = getLogById(id) + _internalState.update { currentState -> + currentState.copy( + logToEdit = freshLog, + showAddLogDialog = true + ) + } + }, + onAuthCanceled = { + } + ) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsScreen.kt similarity index 99% rename from app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsScreen.kt index 5a26bda..0d1ecc3 100644 --- a/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package net.tinsae.clocked.settings +package net.tinsae.clocked.ui.screens.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt similarity index 94% rename from app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt rename to app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt index 69451d1..2a2ad33 100644 --- a/app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt @@ -1,6 +1,6 @@ -package net.tinsae.clocked.settings +package net.tinsae.clocked.ui.screens.settings -import androidx.activity.result.launch +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow @@ -69,7 +69,7 @@ class SettingsViewModel : ViewModel() { } catch (e: Exception) { // Handle any errors during fetching or exporting - android.util.Log.e("SettingsViewModel", "Failed to export CSV", e) + Log.e("SettingsViewModel", "Failed to export CSV", e) // Optionally show an error to the user // _uiState.update { it.copy(error = "Export failed.") } } diff --git a/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt b/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt index 8c201f0..9c15dd9 100644 --- a/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt +++ b/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt @@ -5,6 +5,7 @@ import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.realtime.Realtime import io.ktor.client.engine.android.Android +import io.ktor.client.engine.okhttp.OkHttp import net.tinsae.clocked.BuildConfig object SupabaseClient { @@ -17,7 +18,7 @@ object SupabaseClient { install(Auth) install(Postgrest) install(Realtime) - httpEngine = Android.create() + httpEngine = OkHttp.create() } } } diff --git a/app/src/main/java/net/tinsae/clocked/util/Util.kt b/app/src/main/java/net/tinsae/clocked/util/Util.kt index 2ba5653..d05e32b 100644 --- a/app/src/main/java/net/tinsae/clocked/util/Util.kt +++ b/app/src/main/java/net/tinsae/clocked/util/Util.kt @@ -71,4 +71,16 @@ object Util { return "$month $year" } + + fun String.capitalise(): String { + return this.split(' ') + .joinToString(" ") + { word -> + word.replaceFirstChar { + if (it.isLowerCase()) + it.titlecase(java.util.Locale.getDefault()) + else it.toString() + } + } + } } \ No newline at end of file