cleaning up

This commit is contained in:
2026-03-08 22:02:15 +01:00
parent 29f5716664
commit d6b97af239
29 changed files with 512 additions and 232 deletions

View File

@@ -22,7 +22,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "net.tinsae.clocked" applicationId = "net.tinsae.clocked"
minSdk = 24 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -74,7 +74,7 @@ dependencies {
implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.androidx.compose.material3.window.size.class1)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation("androidx.biometric:biometric:1.1.0") implementation(libs.androidx.biometric)
// Supabase // Supabase
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
@@ -83,7 +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") implementation(libs.ktor.client.okhttp)
// 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)
@@ -91,11 +91,11 @@ dependencies {
implementation(libs.ktor.client.android) implementation(libs.ktor.client.android)
implementation(libs.supabase.realtime) implementation(libs.supabase.realtime)
implementation(libs.poi.ooxml)
implementation(libs.fastexcel)
// Required for some Android environments to handle XML/StAX issues
implementation(libs.jackson.core)
//implementation(libs.supabase.gotrue.live) //implementation(libs.supabase.gotrue.live)
testImplementation(libs.junit) testImplementation(libs.junit)

View File

@@ -22,7 +22,7 @@ import kotlinx.serialization.json.put
import net.tinsae.clocked.data.FormType import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.util.SupabaseClient import net.tinsae.clocked.util.SupabaseClient
// Add this data class here for UI state // data class for UI state
data class AuthUiState( data class AuthUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
@@ -33,7 +33,7 @@ data class AuthUiState(
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
// --- State from LoginViewModel is now here --- // State from LoginViewModel
var uiState by mutableStateOf(AuthUiState()) var uiState by mutableStateOf(AuthUiState())
private set private set
@@ -41,7 +41,7 @@ class AuthViewModel : ViewModel() {
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), started = SharingStarted.WhileSubscribed(5000),
// Initialize with the client's current status, which will be .Initializing on cold start // Initialize with the client's current status. Initializing on cold start
initialValue = SupabaseClient.client.auth.sessionStatus.value initialValue = SupabaseClient.client.auth.sessionStatus.value
) )
@@ -52,17 +52,17 @@ class AuthViewModel : ViewModel() {
is SessionStatus.Authenticated -> { is SessionStatus.Authenticated -> {
val metadata = status.session.user?.userMetadata val metadata = status.session.user?.userMetadata
val fullName = (metadata?.get("full_name") ?: metadata?.get("user_name")) val fullName = (metadata?.get("full_name") ?: metadata?.get("user_name"))
?.jsonPrimitive // Get the JsonPrimitive? from the element ?.jsonPrimitive // JsonPrimitive from the element
?.contentOrNull // Get the String content, or null if it's not a string/is null ?.contentOrNull // String content, or null if it's not a string/is null
uiState = uiState.copy(userName = fullName) uiState = uiState.copy(userName = fullName)
} }
is SessionStatus.NotAuthenticated -> { is SessionStatus.NotAuthenticated -> {
// When the user logs out or the session expires, clear the name. // When the user logs out or the session expires, we clear the name.
uiState = uiState.copy(userName = null) uiState = uiState.copy(userName = null)
} }
else -> { else -> {
// Handles Initializing, LoadingFromStorage states. No UI change needed here. // Thinking about what to do here ...
} }
} }
} }
@@ -115,7 +115,7 @@ class AuthViewModel : ViewModel() {
} }
} }
// parse error message from exception // TODO this needs serious work
private fun parseError(e: Exception): String { private fun parseError(e: Exception): String {
val defaultError = "An unknown error occurred. Please try again." val defaultError = "An unknown error occurred. Please try again."
val message = e.message ?: return defaultError val message = e.message ?: return defaultError
@@ -124,8 +124,8 @@ class AuthViewModel : ViewModel() {
is BadRequestRestException -> { // server can be reached is BadRequestRestException -> { // server can be reached
message.lines().firstOrNull()?.trim() ?: defaultError message.lines().firstOrNull()?.trim() ?: defaultError
} }
is RestException -> { // server cant be reached is RestException -> { // server cant be reached or creds wrong
"A network error occurred. Please check your connection." "Either a connection error or invalid credentials. Please check your connection or credentials and try again."
} }
// For any other unexpected exception // For any other unexpected exception
else -> defaultError else -> defaultError

View File

@@ -14,16 +14,15 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Logout 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.Info
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.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
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
@@ -38,13 +37,15 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.github.jan.supabase.auth.status.SessionStatus import io.github.jan.supabase.auth.status.SessionStatus
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter
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.data.Locale import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.Theme import net.tinsae.clocked.data.Theme
import net.tinsae.clocked.ui.components.CustomBottomBar
import net.tinsae.clocked.ui.components.TopBar import net.tinsae.clocked.ui.components.TopBar
import net.tinsae.clocked.ui.screens.about.AboutScreen
import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen
import net.tinsae.clocked.ui.screens.history.HistoryScreen import net.tinsae.clocked.ui.screens.history.HistoryScreen
import net.tinsae.clocked.ui.screens.settings.SettingsScreen import net.tinsae.clocked.ui.screens.settings.SettingsScreen
@@ -60,16 +61,14 @@ class MainActivity : AppCompatActivity() {
private val authViewModel: AuthViewModel by viewModels() private val authViewModel: AuthViewModel by viewModels()
private val createDocumentLauncher = registerForActivityResult( private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("text/csv") ActivityResultContracts.CreateDocument("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
) { uri: Uri? -> ) { uri: Uri? ->
uri?.let { uri?.let {
val content = settingsViewModel.uiState.value.pendingCsvContent val content = settingsViewModel.uiState.value.pendingCsvContent?.bytes
if (content != null) { if (content != null) {
try { try {
contentResolver.openOutputStream(it)?.use { outputStream -> contentResolver.openOutputStream(it)?.use { outputStream ->
outputStream.writer().use { writer -> outputStream.write(content)
writer.write(content)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MainActivity", "Error writing to file", e) Log.e("MainActivity", "Error writing to file", e)
@@ -86,7 +85,9 @@ class MainActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
settingsViewModel.uiState settingsViewModel.uiState
.map { it.pendingCsvContent } .map { it.pendingCsvContent }
.distinctUntilChanged() // we export only if its not null
// we do reset it when we done
.filter{it != null}
.collect { .collect {
csvContent -> csvContent ->
if (csvContent != null) { if (csvContent != null) {
@@ -147,33 +148,18 @@ fun updateLocale(context: Context, locale: Locale) {
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) { fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) } var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
AppDestinations.entries.forEach { destination ->
item(
icon = {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
modifier = Modifier
//.padding(1.dp)
.size(24.dp)
)
},
alwaysShowLabel = false,
//label = { Text(stringResource(destination.label)) },
selected = destination == currentDestination,
onClick = { currentDestination = destination }
)
}
},
) {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
bottomBar = {
CustomBottomBar(
current = currentDestination,
destinations = AppDestinations.entries.toTypedArray(),
onItemSelected = { currentDestination = it }
)
},
topBar = { topBar = {
TopBar( TopBar(
appName = if(authViewModel.uiState.userName != null) appName = if (authViewModel.uiState.userName != null)
authViewModel.uiState.userName!!.capitalise() authViewModel.uiState.userName!!.capitalise()
else else
stringResource(R.string.app_name), stringResource(R.string.app_name),
@@ -184,36 +170,48 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
IconButton( IconButton(
onClick = { authViewModel.logout() }, modifier = Modifier.padding(6.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
.fillMaxWidth() .fillMaxWidth()
.padding(innerPadding) .padding(innerPadding)
when (currentDestination) { when (currentDestination) {
AppDestinations.HOME -> DashboardScreen(modifier = modifier) AppDestinations.HOME -> DashboardScreen(modifier = modifier)
AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) AppDestinations.HISTORY -> HistoryScreen(modifier = modifier)
AppDestinations.SETTING -> SettingsScreen( AppDestinations.SETTING -> SettingsScreen(
modifier = modifier, modifier = modifier,
viewModel = settingsViewModel viewModel = settingsViewModel
) )
AppDestinations.ABOUT -> {
AboutScreen(modifier = modifier,author = "Tinsae Ghilay", appName = "Clocked", email = "tinsaekahsay@gmail.com", version = "1.0")
} }
} }
} }
} }
enum class AppDestinations( enum class AppDestinations(
val label: Int, val label: Int,
val icon: ImageVector, val icon: ImageVector,
) { ) {
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),
//EDITORS(R.string.edit, Icons.Default.Edit),
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),
ABOUT(label = R.string.about, Icons.Filled.Info)
} }

View File

@@ -20,12 +20,12 @@ object AuthenticationManager {
private val _request = MutableStateFlow<AuthRequest?>(null) private val _request = MutableStateFlow<AuthRequest?>(null)
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. // Request authentication.
fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) { fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) {
_request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) } _request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) }
} }
// Call this to clear the request after it's been handled (success or fail). // clear the request after it's been handled (success or fail).
fun clearRequest() { fun clearRequest() {
_request.update { null } _request.update { null }
} }

View File

@@ -28,7 +28,7 @@ class BiometricAuthenticator(private val activity: FragmentActivity) {
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
// Distinguish between user cancellation and other errors // User cancellation and other errors
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) { if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
onCancel() onCancel()
} else { } else {

View File

@@ -8,18 +8,17 @@ import net.tinsae.clocked.R
@Composable @Composable
fun GlobalAuthenticator() { fun GlobalAuthenticator() {
// Collect the current authentication request from the global manager. // reference to authenticator
val authRequest by AuthenticationManager.request.collectAsState() val authRequest by AuthenticationManager.request.collectAsState()
// If there is a request, show the AuthenticationDialog. // There is a request? Show authentication dialog.
authRequest?.let { request -> authRequest?.let { request ->
AuthenticationDialog( AuthenticationDialog(
title = stringResource(R.string.app_name), title = stringResource(R.string.app_name),
action = request.action, action = request.action,
onSuccess = { onSuccess = {
// First, execute the protected action (e.g., viewModel.deleteLog)
request.onSuccess() request.onSuccess()
// Then, clear the request from the manager. // clear request after each callback.
AuthenticationManager.clearRequest() AuthenticationManager.clearRequest()
}, },
onFailure = { onFailure = {

View File

@@ -8,23 +8,25 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
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.seconds import kotlin.time.Duration.Companion.seconds
/**
* Serialiser for Kotlin Duration to Postgres Interval.
* had to be custom because its not serialisable by default.
*/
object IntervalSerialiser : KSerializer<Duration> { object IntervalSerialiser : KSerializer<Duration> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING)
// Serialization (sending data TO database) remains the same. // Serialization (sending data TO database).
override fun serialize(encoder: Encoder, value: Duration) { override fun serialize(encoder: Encoder, value: Duration) {
val totalSeconds = value.inWholeSeconds val totalSeconds = value.inWholeSeconds
encoder.encodeString("$totalSeconds seconds") encoder.encodeString("$totalSeconds seconds")
} }
// Deserialization (receiving data FROM database) is now custom. // Deserialization (receiving data FROM database)
override fun deserialize(decoder: Decoder): Duration { override fun deserialize(decoder: Decoder): Duration {
val timeString = decoder.decodeString() val timeString = decoder.decodeString()
// Handle negative intervals from Postgres, which might look like "-01:30:00" // Negative intervals from Postgres.
val isNegative = timeString.startsWith('-') val isNegative = timeString.startsWith('-')
val absTimeString = if (isNegative) timeString.substring(1) else timeString val absTimeString = if (isNegative) timeString.substring(1) else timeString

View File

@@ -5,15 +5,15 @@ import androidx.compose.runtime.Composable
enum class Locale(val tag: String) { enum class Locale(val tag: String) {
SYSTEM(""), SYSTEM(""),
ENGLISH("en"), ENGLISH("en"),
SPANISH("es"), SPANISH("de"),
FRENCH("fr"); FRENCH("ti");
val title:String val title:String
@Composable @Composable
get() = when (this) { get() = when (this) {
SYSTEM -> "System" SYSTEM -> "System"
ENGLISH -> "English" ENGLISH -> "English"
SPANISH -> "Español" SPANISH -> "German"
FRENCH -> "Français" FRENCH -> "Tigrinya"
} }
} }

View File

@@ -10,22 +10,27 @@ data class Log(
val id: Long, val id: Long,
val timestamp: Instant, val timestamp: Instant,
val type: EntryType, val type: EntryType,
// using Custom serialiser here to convert to Postgres Interval
@Serializable(with = IntervalSerialiser::class) @Serializable(with = IntervalSerialiser::class)
val duration: Duration, val duration: Duration,
val reason: String? = null, val reason: String? = null,
@SerialName("user_id") @SerialName("user_id")
val userId: String? = null // This will be populated by Supabase // this is null because it will be autogenerated by postgres (Supabase)
val userId: String? = null
) )
@Serializable @Serializable
data class LogEntry( data class LogEntry(
val timestamp: Instant, val timestamp: Instant,
val type: EntryType, val type: EntryType,
@Serializable(with = IntervalSerialiser::class) // Can reuse the serializer // Re-using Custom serialiser here as well
@Serializable(with = IntervalSerialiser::class)
val duration: Duration, val duration: Duration,
val reason: String? = null val reason: String? = null
) )
// Enums
@Serializable @Serializable
enum class EntryType { enum class EntryType {
OVERTIME, TIME_OFF OVERTIME, TIME_OFF

View File

@@ -1,6 +1,5 @@
package net.tinsae.clocked.data package net.tinsae.clocked.data
//import androidx.compose.ui.test.cancel
import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.status.SessionStatus import io.github.jan.supabase.auth.status.SessionStatus
import io.github.jan.supabase.realtime.PostgresAction import io.github.jan.supabase.realtime.PostgresAction
@@ -16,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.tinsae.clocked.service.addLogToDB import net.tinsae.clocked.service.addLogToDB
import net.tinsae.clocked.service.deleteLogFromDB import net.tinsae.clocked.service.deleteLogFromDB
import net.tinsae.clocked.service.editLogFromDB import net.tinsae.clocked.service.editLogFromDB
@@ -37,9 +38,10 @@ object LogRepository {
val logs = _logs.asStateFlow() val logs = _logs.asStateFlow()
// Testing code // Testing code
private val _isLoading = MutableStateFlow(false) // Start as true // this still needs work as loading should be unified.
// This solution is not a good one.
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow() val isLoading = _isLoading.asStateFlow()
// testing end
private val repositoryScope = CoroutineScope(Dispatchers.IO) private val repositoryScope = CoroutineScope(Dispatchers.IO)
private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel") private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel")
@@ -47,25 +49,22 @@ object LogRepository {
init { init {
repositoryScope.launch { repositoryScope.launch {
// Define the realtime listener flow once. This flow is cold and does not // Realtime listener flow.
// do anything until it's collected. // doesn't do anything until it's collected.
val changeFlow = realtimeChannel.postgresChangeFlow<PostgresAction>(schema = "public") { val changeFlow = realtimeChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "Logs" table = "Logs"
} }
// Launch a long-lived collector for the realtime changes. This coroutine // Long-lived collector for the realtime changes.
// will run for the lifetime of the repositoryScope. // Will run for the lifetime of the repositoryScope.
launch { launch {
changeFlow changeFlow
.catch { e -> .catch { e ->
// This handles terminal errors in the flow itself. // This handles terminal errors in the flow itself.
// The Supabase client should automatically try to recover from
// transient network issues like SocketException.
LOG.e("LogRepository", "Realtime flow collection error", e) LOG.e("LogRepository", "Realtime flow collection error", e)
} }
.collect { action -> .collect { action ->
// By placing the try-catch here, we ensure that a single
// malformed event doesn't stop us from processing subsequent events.
try { try {
handleRealtimeAction(action) handleRealtimeAction(action)
} catch (e: Exception) { } catch (e: Exception) {
@@ -76,17 +75,16 @@ object LogRepository {
// Collect session status to manage the subscription lifecycle and data fetching. // Collect session status to manage the subscription lifecycle and data fetching.
SupabaseClient.client.auth.sessionStatus.collect { status -> SupabaseClient.client.auth.sessionStatus.collect { status ->
// Cancel any existing data-fetching job to prevent race conditions on session changes. // To prevent race conditions on session changes,
sessionJob?.cancel() sessionJob?.cancel()
when (status) { when (status) {
is SessionStatus.Authenticated -> { is SessionStatus.Authenticated -> {
LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.") LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.")
// Create a new job for the tasks associated with this authenticated session. // New job for the tasks associated with this authenticated session.
sessionJob = launch { sessionJob = launch {
fetchLogs() fetchLogs()
// The postgresChangeFlow listener is already set up. // And subscribe to the realtime channel.
// subscribe() connects the websocket and starts receiving events.
realtimeChannel.subscribe() realtimeChannel.subscribe()
} }
} }
@@ -95,15 +93,14 @@ object LogRepository {
realtimeChannel.unsubscribe() realtimeChannel.unsubscribe()
clearLogs() clearLogs()
} }
else -> { /* Handle other states if necessary */ } else -> { /* Unhandled session status */ }
} }
} }
} }
} }
// Extracted the when block to a separate function for clarity // Handling CRUD actions.
// Extracted the when block to a separate function for clarity
private fun handleRealtimeAction(action: PostgresAction) { private fun handleRealtimeAction(action: PostgresAction) {
when (action) { when (action) {
is PostgresAction.Insert -> { is PostgresAction.Insert -> {
@@ -141,7 +138,7 @@ object LogRepository {
val latestLogs = getAllLogsFromDB() val latestLogs = getAllLogsFromDB()
_logs.update { latestLogs } _logs.update { latestLogs }
} catch (e: Exception) { } catch (e: Exception) {
// The CancellationException from the log will be caught here // CancellationException from the log will be caught here
if (e is java.util.concurrent.CancellationException) { if (e is java.util.concurrent.CancellationException) {
LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.") LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.")
} else { } else {
@@ -209,4 +206,8 @@ object LogRepository {
_isLoading.update { false } _isLoading.update { false }
LOG.d("LogRepository", "Local log data cleared.") LOG.d("LogRepository", "Local log data cleared.")
} }
fun getLogsByYear(year: Int): List<Log> {
return logs.value.filter { it.timestamp.toLocalDateTime(TimeZone.UTC).year == year }
}
} }

View File

@@ -1,18 +1,15 @@
package net.tinsae.clocked.service package net.tinsae.clocked.service
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.postgrest.query.Order import io.github.jan.supabase.postgrest.query.Order
import kotlinx.coroutines.delay
import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import android.util.Log as LOG
import net.tinsae.clocked.data.LogEntry import net.tinsae.clocked.data.LogEntry
import net.tinsae.clocked.util.SupabaseClient import net.tinsae.clocked.util.SupabaseClient
import kotlin.time.Instant
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Instant
import android.util.Log as LOG
// --- Public API for accessing and modifying logs --- //
suspend fun getAllLogsFromDB(): List<Log> { suspend fun getAllLogsFromDB(): List<Log> {
try { try {

View File

@@ -23,7 +23,6 @@ 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 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
@@ -31,7 +30,6 @@ 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
@@ -43,9 +41,9 @@ 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
import android.util.Log as LOG
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -66,9 +64,6 @@ fun AddLogDialog(
val datePickerState = rememberDatePickerState() 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) { LaunchedEffect(key1 = log) {
if (log != null) { if (log != null) {
// Editing an existing log: Populate all state variables here. // Editing an existing log: Populate all state variables here.
@@ -79,9 +74,7 @@ fun AddLogDialog(
reason = log.reason ?: "" reason = log.reason ?: ""
selectedInstant = log.timestamp selectedInstant = log.timestamp
selectedType = log.type selectedType = log.type
// **THE API FIX**: The correct function is `setSelection`, but it is internal. // set selected date to that of the selected Log if not null.
// The public API is to set the `selectedDateMillis` property directly.
// This will now correctly update the hoisted DatePickerState.
datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds() datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds()
LOG.d("AddLogDialog", "Populating fields from log: $log") LOG.d("AddLogDialog", "Populating fields from log: $log")
} else { } else {

View File

@@ -0,0 +1,127 @@
package net.tinsae.clocked.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.tinsae.clocked.AppDestinations
@Composable
fun CustomBottomBar(
destinations: Array<AppDestinations>,
current: AppDestinations,
onItemSelected: (AppDestinations) -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
tonalElevation = 8.dp,
shadowElevation = 8.dp,
color = colorScheme.surfaceContainer
) {
Row(
modifier = Modifier
.height(72.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
for(destination in destinations) {
BottomNavItem(
destination = destination,
selected = current == destination,
onClick = { onItemSelected(destination) },
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
fun BottomNavItem(
destination: AppDestinations,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val itemHeight = 46.dp
// movement needs to be small. try and error
val iconMove = itemHeight / 32
val duration = 50
val iconColor by animateColorAsState(
targetValue = if (selected) colorScheme.primary
else colorScheme.onSurfaceVariant,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
// Icon slides up/down linearly
val iconOffset by animateDpAsState(
targetValue = if (selected) -iconMove else 0.dp,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
Column(
modifier = modifier
.fillMaxHeight()
.height(itemHeight)
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint = iconColor,
modifier = Modifier.offset(y = iconOffset)
)
AnimatedVisibility(
visible = selected,
enter = fadeIn(animationSpec = tween(duration, easing = LinearEasing)),
exit = fadeOut(animationSpec = tween(duration, easing = LinearEasing))
) {
Text(
text = stringResource(destination.label),
fontSize = 12.sp,
color = iconColor
)
}
}
}

View File

@@ -57,7 +57,6 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) {
@Composable @Composable
private fun DetailRow(label: String, value: String) { private fun DetailRow(label: String, value: String) {
// buildAnnotatedString allows mixing different styles in one Text composable.
Text( Text(
buildAnnotatedString { buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -66,6 +65,6 @@ private fun DetailRow(label: String, value: String) {
append(" ") append(" ")
append(value) append(value)
}, },
style = MaterialTheme.typography.bodyLarge // Use a slightly larger font style style = MaterialTheme.typography.bodyLarge
) )
} }

View File

@@ -17,9 +17,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
// Import the new icon from the extended library
import androidx.compose.material.icons.filled.History
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -37,13 +34,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository.getRecentLogs
import net.tinsae.clocked.ui.theme.cyan import net.tinsae.clocked.ui.theme.cyan
import net.tinsae.clocked.ui.theme.red import net.tinsae.clocked.ui.theme.red
import net.tinsae.clocked.util.Util.formatDuration import net.tinsae.clocked.util.Util.formatDuration
@@ -71,7 +66,6 @@ fun ListItem(
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable { onClick() }
) { ) {
// --- Background content (icons for swipe) ---
Row( Row(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -96,8 +90,7 @@ fun ListItem(
} }
} }
// --- Foreground content (the new list item UI) --- Surface(
Surface( // Use Surface for background color and elevation control
modifier = Modifier modifier = Modifier
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) } .offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
.pointerInput(Unit) { .pointerInput(Unit) {
@@ -160,7 +153,7 @@ fun ListItem(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// Indent the divider to align with text // Indent divider to align with text
.padding(start = 56.dp) .padding(start = 56.dp)
) )
} }

View File

@@ -59,7 +59,7 @@ fun ShimmerBrush(showShimmer: Boolean = true, targetValue: Float = 1000f, color:
fun ShimmerListItemPreview() { fun ShimmerListItemPreview() {
val brush = ShimmerBrush() val brush = ShimmerBrush()
Row(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.padding(16.dp)) {
// Placeholder for a video thumbnail or user avatar // Placeholder
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.size(100.dp) .size(100.dp)

View File

@@ -41,7 +41,7 @@ fun TopBar(
.asPaddingValues() .asPaddingValues()
) )
else Modifier.fillMaxWidth(), else Modifier.fillMaxWidth(),
// No padding needed here on the Surface itself // No padding needed here
) { ) {
Row( Row(
// Apply safe area padding to the Row to push content down // Apply safe area padding to the Row to push content down
@@ -50,7 +50,6 @@ fun TopBar(
) { ) {
if (appName != null) { if (appName != null) {
Text( Text(
//modifier = Modifier.padding(start = 16.dp), // Vertical padding is handled by Row's alignment
text = appName, text = appName,
style = typography.titleLarge, style = typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold

View File

@@ -0,0 +1,130 @@
package net.tinsae.clocked.ui.screens.about
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.tinsae.clocked.ui.theme.cyan
@Composable
fun AboutScreen(modifier: Modifier, author: String, appName: String, email:String, version: String ) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// App Title
Text(
text = appName,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = cyan,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Version & Developer
Text(
text = "Version $version",
fontSize = 16.sp,
color = Color.Gray,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Text(
text = "Developed by $author",
fontSize = 14.sp,
color = Color.Gray,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// Info Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
//elevation = 4.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "About",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "$appName is a sleek and reliable time-tracking app designed to help you stay organized, manage your hours, and improve productivity.",
fontSize = 14.sp,
color = Color.DarkGray
)
}
}
// Support Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
//elevation = 4.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Support",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "For feedback or support, contact: $email",
fontSize = 14.sp,
color = Color.DarkGray
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Footer / Legal
Text(
text = "© 2026 $appName. All rights reserved.",
fontSize = 12.sp,
color = Color.Gray,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
textAlign = TextAlign.Center
)
}
}
@Preview
@Composable
fun ShowAboutScreen(){
AboutScreen(modifier = Modifier, author = "Tinsae Ghilay", appName = "Clocked", email = "tgk@tgk.com", version = "1.0")
}

View File

@@ -169,8 +169,8 @@ fun ActivityList(
recentLogs: List<Log>, recentLogs: List<Log>,
viewModel: DashboardViewModel viewModel: DashboardViewModel
) { ) {
//var mutableItems by remember { mutableStateOf(items) }
// State to hold the log that should be shown in the dialog. Null means no dialog. // State to hold the log that should be shown in the dialog.
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) } var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
@@ -206,7 +206,7 @@ fun ActivityList(
log = log, log = log,
onEdit = { onEditLog(log.id) }, onEdit = { onEditLog(log.id) },
onDelete = {onDeleteLog(log) }, onDelete = {onDeleteLog(log) },
// When the item is clicked, set it as the selected log for the dialog // item selected on click
onClick = { selectedLogForDialog = log } onClick = { selectedLogForDialog = log }
) )
} }
@@ -214,7 +214,7 @@ fun ActivityList(
} }
// When a log is selected, show the dialog. // show details dialog if item selected
selectedLogForDialog?.let { log -> selectedLogForDialog?.let { log ->
DetailsDialog( DetailsDialog(
log = log, log = log,

View File

@@ -33,7 +33,6 @@ data class DashboardUiState(
val balanceInDays: String = "0d", val balanceInDays: String = "0d",
val recentActivities: List<Log> = emptyList(), val recentActivities: List<Log> = emptyList(),
val showAddLogDialog: Boolean = false, val showAddLogDialog: Boolean = false,
//val dialogType: EntryType = EntryType.OVERTIME,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val logToEdit:Log? = null val logToEdit:Log? = null
) )
@@ -56,9 +55,8 @@ class DashboardViewModel : ViewModel() {
val netBalanceDuration = overtimeDuration + timeOffDuration val netBalanceDuration = overtimeDuration + timeOffDuration
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0 val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
// calculate the balance in days (assuming an 8-hour workday) // calculate the balance in days (assuming an 8-hour workday here)
// and format it to one decimal place for a clean look. val balanceInDaysValue = netBalanceInHours / 8.0 // TODO: make this a setting
val balanceInDaysValue = netBalanceInHours / 8.0
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d "
@@ -72,7 +70,6 @@ class DashboardViewModel : ViewModel() {
isLoading = isLoading, isLoading = isLoading,
// Pass through the dialog state from the internal state holder // Pass through the dialog state from the internal state holder
showAddLogDialog = internalState.showAddLogDialog, showAddLogDialog = internalState.showAddLogDialog,
//dialogType = internalState.dialogType,
logToEdit = internalState.logToEdit logToEdit = internalState.logToEdit
) )
}.stateIn( }.stateIn(
@@ -139,7 +136,7 @@ class DashboardViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
try { try {
LogRepository.editLog(log) LogRepository.editLog(log)
// Realtime will remove the item from the list automatically. // Realtime will handle the rest.
} catch (e: Exception) { } catch (e: Exception) {
LOG.e("DashboardViewModel", "Failed to edit log", e) LOG.e("DashboardViewModel", "Failed to edit log", e)
} }
@@ -150,7 +147,7 @@ class DashboardViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
try { try {
LogRepository.deleteLog(log) LogRepository.deleteLog(log)
// Realtime will remove the item from the list automatically. // Realtime will handle the rest here too.
} catch (e: Exception) { } catch (e: Exception) {
LOG.e("DashboardViewModel", "Failed to delete log", e) LOG.e("DashboardViewModel", "Failed to delete log", e)
} }
@@ -160,7 +157,7 @@ class DashboardViewModel : ViewModel() {
fun requestDeleteWithAuth(log: Log) { fun requestDeleteWithAuth(log: Log) {
AuthenticationManager.requestAuth( AuthenticationManager.requestAuth(
action = "delete this log", action = "delete this log",
onAuthenticated = { deleteLog(log) }, // Pass the function to run on success onAuthenticated = { deleteLog(log) },
onAuthCanceled = {} onAuthCanceled = {}
) )
} }

View File

@@ -13,11 +13,14 @@ 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.ui.components.ListItem import net.tinsae.clocked.ui.components.ListItem
@Composable @Composable
private fun EditorScreen(modifier: Modifier = Modifier){ fun EditorScreen(modifier: Modifier = Modifier){
val viewModel = EditorViewModel() val viewModel = EditorViewModel()
Surface(modifier = modifier.padding(16.dp).fillMaxSize()) { Surface(modifier = modifier.padding(16.dp).fillMaxSize()) {
@@ -39,6 +42,9 @@ private fun FilterLogs(modifier: Modifier = Modifier, viewModel: EditorViewModel
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "from") Text(modifier = Modifier.weight(1f).padding(16.dp), text = "from")
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "to") Text(modifier = Modifier.weight(1f).padding(16.dp), text = "to")
} }
Text(modifier = Modifier.fillMaxWidth().padding(16.dp), text = "Logs")
ShowLogs(LogRepository.getLogsByYear(java.time.LocalDate.now().year))
} }
} }

View File

@@ -14,6 +14,7 @@ import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable 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
@@ -64,7 +65,6 @@ fun HistoryScreen(
if (uiState.tabs.isNotEmpty()) { if (uiState.tabs.isNotEmpty()) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
//containerColor = MaterialTheme.colorScheme.surfaceContainer
) { ) {
uiState.tabs.forEachIndexed { index, title -> uiState.tabs.forEachIndexed { index, title ->
Tab( Tab(
@@ -110,8 +110,8 @@ fun HistoryScreen(
fun MonthHeader(text: String, modifier: Modifier = Modifier) { fun MonthHeader(text: String, modifier: Modifier = Modifier) {
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shadowElevation = 1.dp //shadowElevation = 1.dp
) { ) {
Text( Text(

View File

@@ -131,22 +131,21 @@ class HistoryViewModel : ViewModel() {
fun requestDeleteWithAuth(log: Log) { fun requestDeleteWithAuth(log: Log) {
AuthenticationManager.requestAuth( AuthenticationManager.requestAuth(
action = "delete this log", action = "delete this log",
onAuthenticated = { deleteLog(log) }, // Pass the function to run on success // passing callbacks
onAuthenticated = { deleteLog(log) },
onAuthCanceled = {} onAuthCanceled = {}
) )
} }
// Toggles dialog visibility
fun toggleAddLogDialog() { fun toggleAddLogDialog() {
_internalState.update { _internalState.update {
// If we are currently showing the dialog, the toggle will hide it.
// When hiding, we MUST also reset logToEdit.
if (it.showAddLogDialog) { if (it.showAddLogDialog) {
it.copy( it.copy(
showAddLogDialog = false, showAddLogDialog = false,
logToEdit = null logToEdit = null
) )
} else { } else {
// This handles showing the dialog for a NEW entry
it.copy(showAddLogDialog = true) it.copy(showAddLogDialog = true)
} }
} }

View File

@@ -11,7 +11,8 @@ import kotlinx.coroutines.launch
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.util.CsvExporter import net.tinsae.clocked.util.ApacheExcel
import net.tinsae.clocked.util.ExcelContent
data class SettingsUiState( data class SettingsUiState(
val defaultDuration: String = "8h", val defaultDuration: String = "8h",
@@ -22,8 +23,8 @@ data class SettingsUiState(
val appVersion: String = "1.0.0", val appVersion: String = "1.0.0",
val showThemeDialog: Boolean = false, val showThemeDialog: Boolean = false,
val showLocaleDialog: Boolean = false, val showLocaleDialog: Boolean = false,
// Hold the content of the CSV to be saved. Null means no save operation is pending. // Null means no save operation is pending.
val pendingCsvContent: String? = null val pendingCsvContent: ExcelContent? = null
) )
class SettingsViewModel : ViewModel() { class SettingsViewModel : ViewModel() {
@@ -57,21 +58,14 @@ class SettingsViewModel : ViewModel() {
fun onExportClicked() { fun onExportClicked() {
viewModelScope.launch { viewModelScope.launch {
try { try {
// 1. Fetch data from the service (suspend call) val logs = ApacheExcel.exportLogsToExcel(LogRepository.logs.value)
val logs = LogRepository.logs val csvContent = ExcelContent(logs)
// 2. Pass the fetched data to the exporter to generate the CSV string
val csvContent = CsvExporter.exportLogsToCsv(logs.value)
// 3. Update the UI state with the generated content.
// MainActivity is already observing this and will trigger the file save. // MainActivity is already observing this and will trigger the file save.
_uiState.update { it.copy(pendingCsvContent = csvContent) } _uiState.update { it.copy(pendingCsvContent = csvContent) }
} catch (e: Exception) { } catch (e: Exception) {
// Handle any errors during fetching or exporting
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

@@ -0,0 +1,86 @@
package net.tinsae.clocked.util
import android.util.Log as LOG
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.IndexedColors
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import java.io.ByteArrayOutputStream
object ApacheExcel {
fun exportLogsToExcel(logs: List<Log>): ByteArray {
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("Logs")
// styling for header cells
val headerStyle = workbook.createCellStyle().apply {
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
}
// Totals Section
val totalHeaderRow = sheet.createRow(0)
totalHeaderRow.createCell(0).setCellValue("Key")
totalHeaderRow.createCell(1).setCellValue("Value")
// Rows 1-3: Summary with Formulas
val otRow = sheet.createRow(1)
otRow.createCell(0).setCellValue("Total Overtime (hours)")
otRow.createCell(1).cellFormula = "SUM(D:D)"
val toRow = sheet.createRow(2)
toRow.createCell(0).setCellValue("Total Time Off (hours)")
toRow.createCell(1).cellFormula = "SUM(E:E)"
val netRow = sheet.createRow(3)
netRow.createCell(0).setCellValue("Net Balance (hours)")
netRow.createCell(1).cellFormula = "B2+B3"
// Data Table Section
// Header Row (Row 6 in Excel / Index 5)
val tableHeaderRow = sheet.createRow(5)
val headers = listOf("ID", "Date", "Reason", "Overtime (hours)", "Time Off (hours)")
headers.forEachIndexed { i, title ->
val cell = tableHeaderRow.createCell(i)
cell.setCellValue(title)
cell.cellStyle = headerStyle
}
// Data Rows
logs.forEachIndexed { index, log ->
val row = sheet.createRow(6 + index)
// ID
row.createCell(0).setCellValue((index + 1).toDouble())
// Date
val localDateTime = log.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
val dateStr = "${localDateTime.year}-${localDateTime.month.number.toString().padStart(2, '0')}-${localDateTime.day.toString().padStart(2, '0')}"
row.createCell(1).setCellValue(dateStr)
// Reason
row.createCell(2).setCellValue(log.reason ?: "")
// Calculation
val durationInHours = log.duration.inWholeMinutes / 60.0
// setting overtime and time off depending on the type value stored in DB
val overtime = if (log.type == EntryType.OVERTIME) durationInHours else 0.0
val timeOff = if (log.type == EntryType.TIME_OFF) durationInHours else 0.0
row.createCell(3).setCellValue(overtime)
row.createCell(4).setCellValue(timeOff)
}
// Convert to ByteArray to return
val bos = ByteArrayOutputStream()
workbook.write(bos)
workbook.close()
return bos.toByteArray()
}
}

View File

@@ -1,66 +0,0 @@
package net.tinsae.clocked.util
import kotlinx.datetime.LocalDateTime
import android.util.Log as LOG
import net.tinsae.clocked.data.EntryType
import kotlin.time.Duration
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import net.tinsae.clocked.data.Log
object CsvExporter {
fun exportLogsToCsv(logs: List<Log>): String {
val csvBuilder = StringBuilder()
// --- 1. Totals Section with Excel Formulas ---
val dataStartRow = 7 // CSV data will start on this row
val dataEndRow = dataStartRow + logs.size - 1
// Totals row
csvBuilder.append("Key,Value\n")
// Overtime is now in column D, Time Off is in column E
csvBuilder.append("Total Overtime (hours),=SUM(D${dataStartRow}:D${dataEndRow})\n")
csvBuilder.append("Total Time Off (hours),=SUM(E${dataStartRow}:E${dataEndRow})\n")
// Net balance is Overtime + Time Off (since time off is negative)
csvBuilder.append("Net Balance (hours),=B2+B3\n")
csvBuilder.append("\n") // Spacer row
// --- 2. Data Table Section ---
// Headers (Timestamp column removed)
csvBuilder.append("ID,Date,Reason,Overtime (hours),Time Off (hours)\n")
// Date formatter for Excel compatibility
//val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault())
// Rows
logs.forEachIndexed { index, log ->
val rowId = index + 1
// THE FIX: Format the kotlin.time.Instant correctly
val localDateTime = log.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
// Pad month and day with a leading zero if they are single-digit
val month = localDateTime.month.number.toString().padStart(2, '0')
val day = localDateTime.day.toString().padStart(2, '0')
val date = "${localDateTime.year}-${month}-${day}"
val reason = log.reason?.replace("\"", "\"\"") ?: ""
// Use the correct extension function for kotlin.time.Duration
val durationInHours = log.duration.inWholeMinutes / 60.0
val overtime = if (log.type == EntryType.OVERTIME) durationInHours else 0.0
val timeOff = if (log.type == EntryType.TIME_OFF) durationInHours else 0.0
csvBuilder.append(
"$rowId,$date,\"$reason\",$overtime,$timeOff\n"
)
}
val csvContent = csvBuilder.toString()
LOG.d("CsvExporter", "Generated CSV:\n$csvContent")
return csvContent
}
}

View File

@@ -0,0 +1,13 @@
package net.tinsae.clocked.util
class ExcelContent(val bytes: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ExcelContent) return false
return bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int {
return bytes.contentHashCode()
}
}

View File

@@ -14,10 +14,9 @@ object Util {
* @return A formatted date string. * @return A formatted date string.
*/ */
fun formatTimestampToLocalDateString(instant: Instant): String { fun formatTimestampToLocalDateString(instant: Instant): String {
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
// 2. Extract the components and build the string manually.
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() } val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
val day = localDateTime.day val day = localDateTime.day
val year = localDateTime.year val year = localDateTime.year
@@ -62,10 +61,9 @@ object Util {
* @return A formatted month and year string. * @return A formatted month and year string.
*/ */
fun formatTimestampToMonthYear(instant: Instant): String { fun formatTimestampToMonthYear(instant: Instant): String {
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
// 2. Extract the components and build the string.
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() } val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
val year = localDateTime.year val year = localDateTime.year

View File

@@ -1,5 +1,8 @@
[versions] [versions]
agp = "8.13.2" agp = "8.13.2"
biometric = "1.1.0"
fastexcel = "0.20.0"
jacksonCore = "2.21.1"
kotlin = "2.3.0" kotlin = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
@@ -7,6 +10,7 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
kotlinxSerializationJson = "1.9.0" kotlinxSerializationJson = "1.9.0"
ktorClientOkhttp = "3.4.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2" activityCompose = "1.12.2"
composeBom = "2025.12.01" composeBom = "2025.12.01"
@@ -15,14 +19,18 @@ appcompat = "1.7.1"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
material3WindowSizeClass = "1.4.0" material3WindowSizeClass = "1.4.0"
materialIconsExtended = "1.7.8" materialIconsExtended = "1.7.8"
poiOoxml = "5.5.1"
supabase = "3.2.6" supabase = "3.2.6"
ktor = "3.3.3" ktor = "3.3.3"
[libraries] [libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" } androidx-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
fastexcel = { module = "org.dhatim:fastexcel", version.ref = "fastexcel" }
jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jacksonCore" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -44,6 +52,8 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
# supabase # supabase
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientOkhttp" }
poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poiOoxml" }
supabase-bom = { group = "io.github.jan-tennert.supabase", name = "bom", version.ref = "supabase" } supabase-bom = { group = "io.github.jan-tennert.supabase", name = "bom", version.ref = "supabase" }
supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" } supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" }
supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" } supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" }