remaining is cleaning up
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = { _,_, _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 = {
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 = {
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.") }
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user