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) ---
// 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)

View File

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

View File

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

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.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)
}

View File

@@ -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).

View File

@@ -24,9 +24,11 @@ fun GlobalAuthenticator() {
},
onFailure = {
// On failure or cancel, just clear the request.
request.onCancel()
AuthenticationManager.clearRequest()
},
onCancel = {
request.onCancel()
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
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,16 +43,86 @@ 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<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.
*/
fun fetchLogs() {
// TEST CODE HERE
repositoryScope.launch {
if (_isLoading.value) return@launch // Prevent concurrent fetches
/**
* 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()
@@ -51,47 +133,43 @@ object LogRepository {
_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 {
suspend fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
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)
}
}
}
fun deleteLog(log: Log) {
repositoryScope.launch {
/**
* Deletes a log. Realtime will handle the UI update.
*/
suspend fun deleteLog(log: Log) {
try {
deleteLogFromDB(log)
// After deleting, refetch the entire list to ensure consistency.
fetchLogs()
} 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<Log> {
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.")
}
}

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 {
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)

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.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<String?>(null) }
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) }
// 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 = { _,_, _, _, _ -> }
)
}
}

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.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

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.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)
)
}
}

View File

@@ -1,4 +1,4 @@
package net.tinsae.clocked.components
package net.tinsae.clocked.ui.components
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.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.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(

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.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.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.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) {
key(System.currentTimeMillis()){
AddLogDialog(
onDismiss = viewModel::toggleAddLogDialog,
onSave = viewModel::onSaveLog
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<Log>,
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 }

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 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) }
)
}

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.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.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.") }
}

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.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()
}
}
}

View File

@@ -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()
}
}
}
}