remaining is cleaning up

This commit is contained in:
2026-01-01 03:11:08 +01:00
parent 66e3bbb004
commit f729258419
26 changed files with 684 additions and 362 deletions

View File

@@ -83,6 +83,7 @@ dependencies {
// --- SUPABASE DEPENDENCIES (Corrected) --- // --- SUPABASE DEPENDENCIES (Corrected) ---
// 1. Implement the BOM using the platform() keyword. // 1. Implement the BOM using the platform() keyword.
implementation(platform(libs.supabase.bom)) implementation(platform(libs.supabase.bom))
implementation("io.ktor:ktor-client-okhttp:3.3.3")
// 2. Implement the specific Supabase modules using the correct aliases. // 2. Implement the specific Supabase modules using the correct aliases.
implementation(libs.supabase.auth) implementation(libs.supabase.auth)

View File

@@ -6,23 +6,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.github.jan.supabase.auth.OtpType
import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email import io.github.jan.supabase.auth.providers.builtin.Email
import io.github.jan.supabase.auth.status.SessionStatus import io.github.jan.supabase.auth.status.SessionStatus
import io.github.jan.supabase.exceptions.BadRequestRestException import io.github.jan.supabase.exceptions.BadRequestRestException
import io.github.jan.supabase.exceptions.RestException import io.github.jan.supabase.exceptions.RestException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import net.tinsae.clocked.data.FormType import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.util.SupabaseClient import net.tinsae.clocked.util.SupabaseClient
// Add this data class here for UI state // Add this data class here for UI state
@@ -31,6 +28,7 @@ data class AuthUiState(
val error: String? = null, val error: String? = null,
val formType: FormType = FormType.LOGIN, val formType: FormType = FormType.LOGIN,
val signupSuccess: Boolean = false, val signupSuccess: Boolean = false,
val userName:String? = null
) )
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
@@ -47,6 +45,30 @@ class AuthViewModel : ViewModel() {
initialValue = SupabaseClient.client.auth.sessionStatus.value 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) { fun login(email: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null) uiState = uiState.copy(isLoading = true, error = null)
@@ -74,7 +96,7 @@ class AuthViewModel : ViewModel() {
put("full_name", name) put("full_name", name)
} }
} }
uiState = uiState.copy(signupSuccess = true) uiState = uiState.copy(signupSuccess = true, formType = FormType.LOGIN)
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy(error = parseError(e)) uiState = uiState.copy(error = parseError(e))
} finally { } finally {

View File

@@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import net.tinsae.clocked.anonymous.RegistrationForm import net.tinsae.clocked.ui.screens.anonymous.RegistrationForm
import net.tinsae.clocked.anonymous.SignInForm import net.tinsae.clocked.ui.screens.anonymous.SignInForm
import net.tinsae.clocked.data.FormType import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.ui.theme.ClockedTheme import net.tinsae.clocked.ui.theme.ClockedTheme

View File

@@ -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.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -43,15 +45,17 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.tinsae.clocked.biometric.GlobalAuthenticator import net.tinsae.clocked.biometric.GlobalAuthenticator
import net.tinsae.clocked.components.ShowLoadingScreen import net.tinsae.clocked.ui.components.ShowLoadingScreen
import net.tinsae.clocked.dashboard.DashboardScreen import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen
import net.tinsae.clocked.data.Locale import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.LogRepository import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.data.Theme import net.tinsae.clocked.data.Theme
import net.tinsae.clocked.history.HistoryScreen import net.tinsae.clocked.ui.components.TopBar
import net.tinsae.clocked.settings.SettingsScreen import net.tinsae.clocked.ui.screens.history.HistoryScreen
import net.tinsae.clocked.settings.SettingsViewModel 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.ui.theme.ClockedTheme
import net.tinsae.clocked.util.Util.capitalise
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -94,6 +98,11 @@ class MainActivity : AppCompatActivity() {
createDocumentLauncher.launch("Clocked-Export-$timestamp.csv") createDocumentLauncher.launch("Clocked-Export-$timestamp.csv")
} }
} }
/*authViewModel.sessionStatus.collect {
if (it is SessionStatus.Authenticated) {
LogRepository.subscribeRealTime()
}
}*/
} }
enableEdgeToEdge() enableEdgeToEdge()
@@ -102,7 +111,14 @@ class MainActivity : AppCompatActivity() {
AppEntry(settingsViewModel, authViewModel) AppEntry(settingsViewModel, authViewModel)
GlobalAuthenticator() GlobalAuthenticator()
} }
} }
/*override fun onDestroy() {
super.onDestroy()
// When the app is being completely destroyed, ensure the connection is closed.
LogRepository.unsubscribeRealTime()
}*/
} }
@Composable @Composable
@@ -112,7 +128,7 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
// Trigger fetching of logs when the session status changes. // Trigger fetching of logs when the session status changes.
LaunchedEffect(sessionStatus) { LaunchedEffect(sessionStatus) {
if (sessionStatus is SessionStatus.Authenticated){ if (sessionStatus is SessionStatus.Authenticated){
LogRepository.fetchLogs() //LogRepository.
} }
} }
@@ -182,21 +198,25 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
) { ) {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
/*topBar = { 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), modifier = Modifier.padding(horizontal = 16.dp),
isForContainer = true, isForContainer = true,
actions = { actions = {
IconButton( 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)) Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
} }
} }
) )
}*/ }
) { innerPadding -> ) { innerPadding ->
val modifier = Modifier val modifier = Modifier
@@ -211,9 +231,9 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
viewModel = settingsViewModel viewModel = settingsViewModel
) )
AppDestinations.LOGOUT -> { /*AppDestinations.LOGOUT -> {
authViewModel.logout() authViewModel.logout()
} }*/
} }
} }
} }
@@ -226,5 +246,5 @@ enum class AppDestinations(
HOME(R.string.nav_home, Icons.Default.Home), HOME(R.string.nav_home, Icons.Default.Home),
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List), HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
SETTING(R.string.nav_settings, Icons.Default.Settings), SETTING(R.string.nav_settings, Icons.Default.Settings),
LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout) //LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout)
} }

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.update
// The 'onSuccess' lambda is the protected action to run. // The 'onSuccess' lambda is the protected action to run.
data class AuthRequest( data class AuthRequest(
val action: String, val action: String,
val onCancel: () -> Unit,
val onSuccess: () -> Unit val onSuccess: () -> Unit
) )
@@ -20,8 +21,8 @@ object AuthenticationManager {
val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow
// Any ViewModel can call this to request authentication. // Any ViewModel can call this to request authentication.
fun requestAuth(action: String, onAuthenticated: () -> Unit) { fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) {
_request.update { AuthRequest(action = action, onSuccess = onAuthenticated) } _request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) }
} }
// Call this to clear the request after it's been handled (success or fail). // Call this to clear the request after it's been handled (success or fail).

View File

@@ -24,9 +24,11 @@ fun GlobalAuthenticator() {
}, },
onFailure = { onFailure = {
// On failure or cancel, just clear the request. // On failure or cancel, just clear the request.
request.onCancel()
AuthenticationManager.clearRequest() AuthenticationManager.clearRequest()
}, },
onCancel = { onCancel = {
request.onCancel()
AuthenticationManager.clearRequest() AuthenticationManager.clearRequest()
} }
) )

View File

@@ -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<Log> = 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<DashboardUiState> = 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) }
)
}
}

View File

@@ -1,5 +1,8 @@
package net.tinsae.clocked.data 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,6 +16,15 @@ import net.tinsae.clocked.service.getAllLogsFromDB
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Instant import kotlin.time.Instant
import android.util.Log as LOG 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. * A Singleton Repository that acts as the Single Source of Truth for Log data.
@@ -31,67 +43,133 @@ object LogRepository {
// testing end // testing end
private val repositoryScope = CoroutineScope(Dispatchers.IO) 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<PostgresAction>(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<Log>()
_logs.update { currentLogs ->
(currentLogs + newLog).sortedByDescending { it.timestamp }
}
}
is PostgresAction.Update -> {
LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
val updatedLog = action.decodeRecord<Log>()
_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. * 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 /**
* Fetches the initial list of logs from the service.
_isLoading.update { true } * This is now a suspend function to ensure proper sequencing.
try { */
val latestLogs = getAllLogsFromDB() private suspend fun fetchLogs() {
_logs.update { latestLogs } if (_isLoading.value) return
} catch (e: Exception) { _isLoading.update { true }
LOG.e("LogRepository", "Failed to fetch logs", e) try {
} finally { val latestLogs = getAllLogsFromDB()
_isLoading.update { false } _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?) { suspend fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
repositoryScope.launch { try {
try { addLogToDB(type, timestamp, duration, reason)
addLogToDB(type, timestamp, duration, reason) } catch (e: Exception) {
// After adding, refetch the entire list to ensure consistency. LOG.e("LogRepository", "Failed to add log", e)
fetchLogs()
} catch (e: Exception) {
LOG.e("LogRepository", "Failed to add log", e)
}
} }
} }
/**
fun deleteLog(log: Log) { * Deletes a log. Realtime will handle the UI update.
repositoryScope.launch { */
try { suspend fun deleteLog(log: Log) {
deleteLogFromDB(log) try {
// After deleting, refetch the entire list to ensure consistency. deleteLogFromDB(log)
fetchLogs() } catch (e: Exception) {
} catch (e: Exception) { LOG.e("LogRepository", "Failed to delete log", e)
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) editLogFromDB(log)
fetchLogs() } catch (e: Exception) {
LOG.e("LogRepository", "Failed to edit log", e)
} }
} }
// get the latest 10 entries
fun getRecentLogs():List<Log> { fun getRecentLogs():List<Log> {
return logs.value.take(7) return logs.value.take(10)
} }
fun getTotalOvertimeDuration(): Duration { fun getTotalOvertimeDuration(): Duration {
@@ -103,4 +181,14 @@ object LogRepository {
return logs.value.filter { it.type == EntryType.TIME_OFF } return logs.value.filter { it.type == EntryType.TIME_OFF }
.map { it.duration }.fold(Duration.ZERO, Duration::plus) .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.")
}
} }

View File

@@ -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<String> = emptyList(),
val selectedTabIndex: Int = 0,
val groupedEntries: Map<String, List<Log>>? = 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<HistoryUiState> = 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<Log>
// 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<String>) {
_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")
}
}

View File

@@ -57,13 +57,14 @@ suspend fun deleteLogFromDB(log: Log): Boolean {
suspend fun editLogFromDB(updatedLog: Log): Boolean { suspend fun editLogFromDB(updatedLog: Log): Boolean {
return try { return try {
val updateEntry = LogEntry(
duration = updatedLog.duration,
reason = updatedLog.reason,
type = updatedLog.type,
timestamp = updatedLog.timestamp,
)
SupabaseClient.client.postgrest.from("Logs").update( SupabaseClient.client.postgrest.from("Logs").update(
{ updateEntry
set("timestamp", updatedLog.timestamp)
set("type", updatedLog.type)
set("duration", updatedLog.duration)
set("reason", updatedLog.reason)
}
) { ) {
filter { filter {
eq("id", updatedLog.id) eq("id", updatedLog.id)

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -16,13 +16,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.setSelection
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,9 +39,11 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.ui.theme.ClockedTheme import net.tinsae.clocked.ui.theme.ClockedTheme
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
@@ -47,33 +51,61 @@ import kotlin.time.Instant
@Composable @Composable
fun AddLogDialog( fun AddLogDialog(
onDismiss: () -> Unit, 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) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var hours by remember { mutableStateOf("") } var hours by remember { mutableStateOf("") }
var minutes by remember { mutableStateOf("") } var minutes by remember { mutableStateOf("") }
var reason by remember { mutableStateOf("") } var reason by remember { mutableStateOf("") }
// error if submit button is clicked with mandatory values not filled (duration)
val error = remember { mutableStateOf<String?>(null) }
var selectedInstant by remember { mutableStateOf(Clock.System.now()) } var selectedInstant by remember { mutableStateOf(Clock.System.now()) }
var selectedType by remember { mutableStateOf(EntryType.OVERTIME) }
val error = remember { mutableStateOf<String?>(null) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
// State for the new EntryType selection val datePickerState = rememberDatePickerState()
var selectedType by remember { mutableStateOf(EntryType.OVERTIME) }
// 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) val entryTypes = listOf(EntryType.OVERTIME, EntryType.TIME_OFF)
// Custom date formatting logic using kotlinx-datetime
val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault()) val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}" val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}"
if (showDatePicker) { if (showDatePicker) {
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds())
DatePickerDialog( DatePickerDialog(
onDismissRequest = { showDatePicker = false }, onDismissRequest = { showDatePicker = false },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
// This logic is correct. It reads from the hoisted state.
datePickerState.selectedDateMillis?.let { millis -> datePickerState.selectedDateMillis?.let { millis ->
selectedInstant = Instant.fromEpochMilliseconds(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 totalMinutes = if (selectedType == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m)
val duration = totalMinutes.minutes val duration = totalMinutes.minutes
onSave(selectedInstant, duration, reason) onSave(log?.id,selectedInstant, duration, reason,selectedType)
} }
) { ) {
Text(stringResource(id = R.string.save)) Text(stringResource(id = R.string.save))
@@ -228,7 +260,7 @@ fun AddLogDialogPreview(){
ClockedTheme { ClockedTheme {
AddLogDialog( AddLogDialog(
onDismiss = {}, onDismiss = {},
onSave = { _, _, _ -> } onSave = { _,_, _, _, _ -> }
) )
} }
} }

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column 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.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import net.tinsae.clocked.util.Util.formatDuration import net.tinsae.clocked.util.Util.formatDuration
import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString

View File

@@ -1,4 +1,4 @@
package net.tinsae.clocked.components package net.tinsae.clocked.ui.components
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -69,7 +69,7 @@ fun ListItem(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } // Make the whole area clickable .clickable { onClick() }
) { ) {
// --- Background content (icons for swipe) --- // --- Background content (icons for swipe) ---
Row( Row(
@@ -110,7 +110,10 @@ fun ListItem(
offsetX = 0f offsetX = 0f
} }
offsetX < deleteThreshold -> onDelete() offsetX < deleteThreshold -> {
onDelete()
offsetX = 0f
}
else -> offsetX = 0f else -> offsetX = 0f
} }
} }
@@ -121,7 +124,7 @@ fun ListItem(
} }
} }
.fillMaxWidth(), .fillMaxWidth(),
color = MaterialTheme.colorScheme.surface // Ensures background is opaque color = MaterialTheme.colorScheme.surface
) { ) {
Column { Column {
Row( Row(
@@ -131,22 +134,20 @@ fun ListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.Default.AccessTime, // Changed to a more relevant round icon imageVector = Icons.Default.AccessTime,
contentDescription = stringResource(R.string.duration_entry_icon), contentDescription = stringResource(R.string.duration_entry_icon),
modifier = Modifier.size(30.dp), 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)) Spacer(modifier = Modifier.width(16.dp))
// 2. Primary Text (using your existing log data)
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = formatTimestampToLocalDateString(log.timestamp), text = formatTimestampToLocalDateString(log.timestamp),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
// 3. Trailing Text (using your existing log data)
Text( Text(
text = formatDuration(log.duration), text = formatDuration(log.duration),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -160,7 +161,7 @@ fun ListItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// Indent the divider to align with text // Indent the divider to align with text
.padding(start = 56.dp) // 16dp (padding) + 40dp (icon) + 16dp (spacer) .padding(start = 56.dp)
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package net.tinsae.clocked.components package net.tinsae.clocked.ui.components
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode

View File

@@ -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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize

View File

@@ -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.Row
import androidx.compose.foundation.layout.RowScope 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -45,7 +46,7 @@ fun TopBar(
Row( Row(
// Apply safe area padding to the Row to push content down // Apply safe area padding to the Row to push content down
modifier = modifier, modifier = modifier,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (appName != null) { if (appName != null) {
Text( Text(

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View File

@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -21,6 +21,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -34,12 +35,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.R 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.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.cyan
import net.tinsae.clocked.ui.theme.green import net.tinsae.clocked.ui.theme.green
import net.tinsae.clocked.ui.theme.red import net.tinsae.clocked.ui.theme.red
@@ -52,31 +53,21 @@ fun DashboardScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
if (uiState.showAddLogDialog) { if (uiState.showAddLogDialog) {
AddLogDialog( key(System.currentTimeMillis()){
onDismiss = viewModel::toggleAddLogDialog, AddLogDialog(
onSave = viewModel::onSaveLog onDismiss = viewModel::toggleAddLogDialog,
) onSave = viewModel::onSaveLog,
log = uiState.logToEdit
)
}
} }
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .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( Row(
modifier = Modifier modifier = Modifier
@@ -102,6 +93,16 @@ fun DashboardScreen(
isLoading = uiState.isLoading 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( ActivityList(
recentLogs = uiState.recentActivities, recentLogs = uiState.recentActivities,
@@ -154,7 +155,7 @@ fun ActionButton(text: String, onClick: () -> Unit) {
@Composable @Composable
fun ActivityList( fun ActivityList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEditLog: (Log) -> Unit = {}, onEditLog: (Long) -> Unit = {},
onDeleteLog: (Log) -> Unit = {}, onDeleteLog: (Log) -> Unit = {},
recentLogs: List<Log>, recentLogs: List<Log>,
viewModel: DashboardViewModel viewModel: DashboardViewModel
@@ -194,7 +195,7 @@ fun ActivityList(
ListItem( ListItem(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
log = log, log = log,
onEdit = { onEditLog(log) }, onEdit = { onEditLog(log.id) },
onDelete = {onDeleteLog(log) }, onDelete = {onDeleteLog(log) },
// When the item is clicked, set it as the selected log for the dialog // When the item is clicked, set it as the selected log for the dialog
onClick = { selectedLogForDialog = log } onClick = { selectedLogForDialog = log }

View File

@@ -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<Log> = 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<DashboardUiState> = 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 = {
}
)
}
}

View File

@@ -1,4 +1,4 @@
package net.tinsae.clocked.history package net.tinsae.clocked.ui.screens.history
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.components.ListItem import net.tinsae.clocked.ui.components.AddLogDialog
import net.tinsae.clocked.components.DetailsDialog import net.tinsae.clocked.ui.components.ListItem
import net.tinsae.clocked.ui.components.DetailsDialog
import net.tinsae.clocked.ui.theme.ClockedTheme import net.tinsae.clocked.ui.theme.ClockedTheme
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -48,14 +50,21 @@ fun HistoryScreen(
val uiState: HistoryUiState by viewModel.uiState.collectAsState() val uiState: HistoryUiState by viewModel.uiState.collectAsState()
if (uiState.showAddLogDialog) {
AddLogDialog(
onDismiss = viewModel::toggleAddLogDialog,
onSave = viewModel::editLog,
log = uiState.logToEdit
)
}
Column( Column(
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
) { ) {
if (uiState.tabs.isNotEmpty()) { if (uiState.tabs.isNotEmpty()) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.background(color = MaterialTheme.colorScheme.surfaceContainer), //containerColor = MaterialTheme.colorScheme.surfaceContainer
containerColor = MaterialTheme.colorScheme.surfaceContainer
) { ) {
uiState.tabs.forEachIndexed { index, title -> uiState.tabs.forEachIndexed { index, title ->
Tab( Tab(
@@ -80,8 +89,8 @@ fun HistoryScreen(
ListItem( ListItem(
log = entry, log = entry,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
onDelete = { viewModel.deleteLog(entry) }, onDelete = { viewModel.requestDeleteWithAuth(entry) },
onEdit = { viewModel.editLog(entry) }, onEdit = { viewModel.requestEditWithAuth(entry.id) },
onClick = { viewModel.onLogSelected(entry) } onClick = { viewModel.onLogSelected(entry) }
) )
} }

View File

@@ -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<String> = emptyList(),
val selectedTabIndex: Int = 0,
val groupedEntries: Map<String, List<Log>>? = 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<HistoryUiState> = 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<Log>
// 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<String>) {
_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 = {
}
)
}
}

View File

@@ -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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement

View File

@@ -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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -69,7 +69,7 @@ class SettingsViewModel : ViewModel() {
} catch (e: Exception) { } catch (e: Exception) {
// Handle any errors during fetching or exporting // 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 // Optionally show an error to the user
// _uiState.update { it.copy(error = "Export failed.") } // _uiState.update { it.copy(error = "Export failed.") }
} }

View File

@@ -5,6 +5,7 @@ import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.realtime.Realtime import io.github.jan.supabase.realtime.Realtime
import io.ktor.client.engine.android.Android import io.ktor.client.engine.android.Android
import io.ktor.client.engine.okhttp.OkHttp
import net.tinsae.clocked.BuildConfig import net.tinsae.clocked.BuildConfig
object SupabaseClient { object SupabaseClient {
@@ -17,7 +18,7 @@ object SupabaseClient {
install(Auth) install(Auth)
install(Postgrest) install(Postgrest)
install(Realtime) install(Realtime)
httpEngine = Android.create() httpEngine = OkHttp.create()
} }
} }
} }

View File

@@ -71,4 +71,16 @@ object Util {
return "$month $year" 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()
}
}
}
} }