diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6177d4e..915263a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "net.tinsae.clocked" - minSdk = 24 + minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -74,7 +74,7 @@ dependencies { implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.appcompat) - implementation("androidx.biometric:biometric:1.1.0") + implementation(libs.androidx.biometric) // Supabase implementation(libs.kotlinx.serialization.json) @@ -83,7 +83,7 @@ dependencies { // --- SUPABASE DEPENDENCIES (Corrected) --- // 1. Implement the BOM using the platform() keyword. implementation(platform(libs.supabase.bom)) - implementation("io.ktor:ktor-client-okhttp:3.3.3") + implementation(libs.ktor.client.okhttp) // 2. Implement the specific Supabase modules using the correct aliases. implementation(libs.supabase.auth) @@ -91,11 +91,11 @@ dependencies { implementation(libs.ktor.client.android) 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) testImplementation(libs.junit) diff --git a/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt b/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt index 5ea5a1e..666b6b2 100644 --- a/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/AuthViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.json.put import net.tinsae.clocked.data.FormType import net.tinsae.clocked.util.SupabaseClient -// Add this data class here for UI state +// data class for UI state data class AuthUiState( val isLoading: Boolean = false, val error: String? = null, @@ -33,7 +33,7 @@ data class AuthUiState( class AuthViewModel : ViewModel() { - // --- State from LoginViewModel is now here --- + // State from LoginViewModel var uiState by mutableStateOf(AuthUiState()) private set @@ -41,7 +41,7 @@ class AuthViewModel : ViewModel() { .stateIn( scope = viewModelScope, 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 ) @@ -52,17 +52,17 @@ class AuthViewModel : ViewModel() { 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 + ?.jsonPrimitive // JsonPrimitive from the element + ?.contentOrNull // 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. + // When the user logs out or the session expires, we clear the name. uiState = uiState.copy(userName = null) } 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 { val defaultError = "An unknown error occurred. Please try again." val message = e.message ?: return defaultError @@ -124,8 +124,8 @@ class AuthViewModel : ViewModel() { is BadRequestRestException -> { // server can be reached message.lines().firstOrNull()?.trim() ?: defaultError } - is RestException -> { // server cant be reached - "A network error occurred. Please check your connection." + is RestException -> { // server cant be reached or creds wrong + "Either a connection error or invalid credentials. Please check your connection or credentials and try again." } // For any other unexpected exception else -> defaultError diff --git a/app/src/main/java/net/tinsae/clocked/MainActivity.kt b/app/src/main/java/net/tinsae/clocked/MainActivity.kt index 2f182bd..a58c45d 100644 --- a/app/src/main/java/net/tinsae/clocked/MainActivity.kt +++ b/app/src/main/java/net/tinsae/clocked/MainActivity.kt @@ -14,16 +14,15 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,13 +37,15 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope 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.launch import net.tinsae.clocked.biometric.GlobalAuthenticator import net.tinsae.clocked.data.Locale 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.screens.about.AboutScreen import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen import net.tinsae.clocked.ui.screens.history.HistoryScreen import net.tinsae.clocked.ui.screens.settings.SettingsScreen @@ -60,16 +61,14 @@ class MainActivity : AppCompatActivity() { private val authViewModel: AuthViewModel by viewModels() private val createDocumentLauncher = registerForActivityResult( - ActivityResultContracts.CreateDocument("text/csv") + ActivityResultContracts.CreateDocument("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") ) { uri: Uri? -> uri?.let { - val content = settingsViewModel.uiState.value.pendingCsvContent + val content = settingsViewModel.uiState.value.pendingCsvContent?.bytes if (content != null) { try { contentResolver.openOutputStream(it)?.use { outputStream -> - outputStream.writer().use { writer -> - writer.write(content) - } + outputStream.write(content) } } catch (e: Exception) { Log.e("MainActivity", "Error writing to file", e) @@ -86,7 +85,9 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { settingsViewModel.uiState .map { it.pendingCsvContent } - .distinctUntilChanged() + // we export only if its not null + // we do reset it when we done + .filter{it != null} .collect { csvContent -> if (csvContent != null) { @@ -147,33 +148,18 @@ fun updateLocale(context: Context, locale: Locale) { fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) { 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( modifier = Modifier.fillMaxSize(), + bottomBar = { + CustomBottomBar( + current = currentDestination, + destinations = AppDestinations.entries.toTypedArray(), + onItemSelected = { currentDestination = it } + ) + }, topBar = { TopBar( - appName = if(authViewModel.uiState.userName != null) + appName = if (authViewModel.uiState.userName != null) authViewModel.uiState.userName!!.capitalise() else stringResource(R.string.app_name), @@ -184,36 +170,48 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode IconButton( 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 -> - val modifier = Modifier - .fillMaxWidth() - .padding(innerPadding) + ) { innerPadding -> + val modifier = Modifier + .fillMaxWidth() + .padding(innerPadding) - when (currentDestination) { - AppDestinations.HOME -> DashboardScreen(modifier = modifier) - AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) - AppDestinations.SETTING -> SettingsScreen( - modifier = modifier, - viewModel = settingsViewModel - ) + when (currentDestination) { + AppDestinations.HOME -> DashboardScreen(modifier = modifier) + AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) + AppDestinations.SETTING -> SettingsScreen( + modifier = modifier, + viewModel = settingsViewModel + ) + + AppDestinations.ABOUT -> { + AboutScreen(modifier = modifier,author = "Tinsae Ghilay", appName = "Clocked", email = "tinsaekahsay@gmail.com", version = "1.0") } } - } + } } + + + + enum class AppDestinations( val label: Int, val icon: ImageVector, ) { HOME(R.string.nav_home, Icons.Default.Home), HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List), + //EDITORS(R.string.edit, Icons.Default.Edit), 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) } diff --git a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt index eb75762..4b4258d 100644 --- a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt +++ b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt @@ -20,12 +20,12 @@ object AuthenticationManager { private val _request = MutableStateFlow(null) 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) { _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() { _request.update { null } } diff --git a/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt b/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt index 4aa5d5d..bb3f1a3 100644 --- a/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt +++ b/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt @@ -28,7 +28,7 @@ class BiometricAuthenticator(private val activity: FragmentActivity) { object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { 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) { onCancel() } else { diff --git a/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt index 52878b2..a5a5d7c 100644 --- a/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt +++ b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt @@ -8,18 +8,17 @@ import net.tinsae.clocked.R @Composable fun GlobalAuthenticator() { - // Collect the current authentication request from the global manager. + // reference to authenticator val authRequest by AuthenticationManager.request.collectAsState() - // If there is a request, show the AuthenticationDialog. + // There is a request? Show authentication dialog. authRequest?.let { request -> AuthenticationDialog( title = stringResource(R.string.app_name), action = request.action, onSuccess = { - // First, execute the protected action (e.g., viewModel.deleteLog) request.onSuccess() - // Then, clear the request from the manager. + // clear request after each callback. AuthenticationManager.clearRequest() }, onFailure = { @@ -33,4 +32,4 @@ fun GlobalAuthenticator() { } ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt b/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt index 51ba61e..3ac3ca7 100644 --- a/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt +++ b/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt @@ -8,23 +8,25 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes 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 { 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) { val totalSeconds = value.inWholeSeconds encoder.encodeString("$totalSeconds seconds") } - // Deserialization (receiving data FROM database) is now custom. + // Deserialization (receiving data FROM database) override fun deserialize(decoder: Decoder): Duration { 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 absTimeString = if (isNegative) timeString.substring(1) else timeString diff --git a/app/src/main/java/net/tinsae/clocked/data/Locale.kt b/app/src/main/java/net/tinsae/clocked/data/Locale.kt index 80d14a9..5fe6d11 100644 --- a/app/src/main/java/net/tinsae/clocked/data/Locale.kt +++ b/app/src/main/java/net/tinsae/clocked/data/Locale.kt @@ -5,15 +5,15 @@ import androidx.compose.runtime.Composable enum class Locale(val tag: String) { SYSTEM(""), ENGLISH("en"), - SPANISH("es"), - FRENCH("fr"); + SPANISH("de"), + FRENCH("ti"); val title:String @Composable get() = when (this) { SYSTEM -> "System" ENGLISH -> "English" - SPANISH -> "Español" - FRENCH -> "Français" + SPANISH -> "German" + FRENCH -> "Tigrinya" } } diff --git a/app/src/main/java/net/tinsae/clocked/data/Log.kt b/app/src/main/java/net/tinsae/clocked/data/Log.kt index 519c8ae..c9e1a2f 100644 --- a/app/src/main/java/net/tinsae/clocked/data/Log.kt +++ b/app/src/main/java/net/tinsae/clocked/data/Log.kt @@ -10,22 +10,27 @@ data class Log( val id: Long, val timestamp: Instant, val type: EntryType, + // using Custom serialiser here to convert to Postgres Interval @Serializable(with = IntervalSerialiser::class) val duration: Duration, val reason: String? = null, @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 data class LogEntry( val timestamp: Instant, 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 reason: String? = null ) + +// Enums @Serializable enum class EntryType { OVERTIME, TIME_OFF diff --git a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt index 11fa274..23cf8a4 100644 --- a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt +++ b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt @@ -1,6 +1,5 @@ package net.tinsae.clocked.data -//import androidx.compose.ui.test.cancel import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.status.SessionStatus 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.update import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import net.tinsae.clocked.service.addLogToDB import net.tinsae.clocked.service.deleteLogFromDB import net.tinsae.clocked.service.editLogFromDB @@ -37,9 +38,10 @@ object LogRepository { val logs = _logs.asStateFlow() // 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() - // testing end private val repositoryScope = CoroutineScope(Dispatchers.IO) private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel") @@ -47,25 +49,22 @@ object LogRepository { init { repositoryScope.launch { - // Define the realtime listener flow once. This flow is cold and does not - // do anything until it's collected. + // Realtime listener flow. + // doesn't do anything until it's collected. val changeFlow = realtimeChannel.postgresChangeFlow(schema = "public") { table = "Logs" } - // Launch a long-lived collector for the realtime changes. This coroutine - // will run for the lifetime of the repositoryScope. + // Long-lived collector for the realtime changes. + // Will run for the lifetime of the repositoryScope. launch { changeFlow .catch { e -> // 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) } .collect { action -> - // By placing the try-catch here, we ensure that a single - // malformed event doesn't stop us from processing subsequent events. + try { handleRealtimeAction(action) } catch (e: Exception) { @@ -76,17 +75,16 @@ object LogRepository { // Collect session status to manage the subscription lifecycle and data fetching. 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() when (status) { is SessionStatus.Authenticated -> { 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 { fetchLogs() - // The postgresChangeFlow listener is already set up. - // subscribe() connects the websocket and starts receiving events. + // And subscribe to the realtime channel. realtimeChannel.subscribe() } } @@ -95,15 +93,14 @@ object LogRepository { realtimeChannel.unsubscribe() clearLogs() } - else -> { /* Handle other states if necessary */ } + else -> { /* Unhandled session status */ } } } } } - // Extracted the when block to a separate function for clarity - // Extracted the when block to a separate function for clarity + // Handling CRUD actions. private fun handleRealtimeAction(action: PostgresAction) { when (action) { is PostgresAction.Insert -> { @@ -141,7 +138,7 @@ object LogRepository { val latestLogs = getAllLogsFromDB() _logs.update { latestLogs } } 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) { LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.") } else { @@ -209,4 +206,8 @@ object LogRepository { _isLoading.update { false } LOG.d("LogRepository", "Local log data cleared.") } + + fun getLogsByYear(year: Int): List { + return logs.value.filter { it.timestamp.toLocalDateTime(TimeZone.UTC).year == year } + } } diff --git a/app/src/main/java/net/tinsae/clocked/service/LogService.kt b/app/src/main/java/net/tinsae/clocked/service/LogService.kt index 32b118e..c7a8c0a 100644 --- a/app/src/main/java/net/tinsae/clocked/service/LogService.kt +++ b/app/src/main/java/net/tinsae/clocked/service/LogService.kt @@ -1,18 +1,15 @@ 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.query.Order -import kotlinx.coroutines.delay import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.Log -import android.util.Log as LOG import net.tinsae.clocked.data.LogEntry import net.tinsae.clocked.util.SupabaseClient -import kotlin.time.Instant 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 { try { diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt b/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt index 6e0527e..8821b11 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/AddLogDialog.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import android.util.Log as LOG import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,7 +30,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.setSelection import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,9 +41,9 @@ import net.tinsae.clocked.data.Log import net.tinsae.clocked.ui.theme.ClockedTheme import kotlin.time.Clock import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant +import android.util.Log as LOG @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,9 +64,6 @@ fun AddLogDialog( val datePickerState = rememberDatePickerState() - // 2. REMOVE the unconditional `if (log != null)` block that was overwriting your state. - - // 3. Use ONE LaunchedEffect keyed to the `log` object to handle ALL state population. LaunchedEffect(key1 = log) { if (log != null) { // Editing an existing log: Populate all state variables here. @@ -79,9 +74,7 @@ fun AddLogDialog( 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. + // set selected date to that of the selected Log if not null. datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds() LOG.d("AddLogDialog", "Populating fields from log: $log") } else { diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/BottomBar.kt b/app/src/main/java/net/tinsae/clocked/ui/components/BottomBar.kt new file mode 100644 index 0000000..b9af028 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/ui/components/BottomBar.kt @@ -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, + 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 + ) + } + } +} + + + + + + diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt b/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt index e1735a4..de04c77 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/DetailsDialog.kt @@ -57,7 +57,6 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) { @Composable private fun DetailRow(label: String, value: String) { - // buildAnnotatedString allows mixing different styles in one Text composable. Text( buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { @@ -66,6 +65,6 @@ private fun DetailRow(label: String, value: String) { append(" ") append(value) }, - style = MaterialTheme.typography.bodyLarge // Use a slightly larger font style + style = MaterialTheme.typography.bodyLarge ) } diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt b/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt index 34e5dc8..8321d11 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/ListItem.kt @@ -17,9 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Delete 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.Icon 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.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import net.tinsae.clocked.R 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.red import net.tinsae.clocked.util.Util.formatDuration @@ -71,7 +66,6 @@ fun ListItem( .fillMaxWidth() .clickable { onClick() } ) { - // --- Background content (icons for swipe) --- Row( modifier = Modifier .matchParentSize() @@ -96,8 +90,7 @@ fun ListItem( } } - // --- Foreground content (the new list item UI) --- - Surface( // Use Surface for background color and elevation control + Surface( modifier = Modifier .offset { IntOffset(animatedOffsetX.roundToInt(), 0) } .pointerInput(Unit) { @@ -160,7 +153,7 @@ fun ListItem( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), modifier = Modifier .fillMaxWidth() - // Indent the divider to align with text + // Indent divider to align with text .padding(start = 56.dp) ) } diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt index 6bc3e6c..4baec5c 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt @@ -59,7 +59,7 @@ fun ShimmerBrush(showShimmer: Boolean = true, targetValue: Float = 1000f, color: fun ShimmerListItemPreview() { val brush = ShimmerBrush() Row(modifier = Modifier.padding(16.dp)) { - // Placeholder for a video thumbnail or user avatar + // Placeholder Spacer( modifier = Modifier .size(100.dp) diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt b/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt index 00a155a..4c1b18a 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/components/TopBar.kt @@ -41,7 +41,7 @@ fun TopBar( .asPaddingValues() ) else Modifier.fillMaxWidth(), - // No padding needed here on the Surface itself + // No padding needed here ) { Row( // Apply safe area padding to the Row to push content down @@ -50,7 +50,6 @@ fun TopBar( ) { if (appName != null) { Text( - //modifier = Modifier.padding(start = 16.dp), // Vertical padding is handled by Row's alignment text = appName, style = typography.titleLarge, fontWeight = FontWeight.Bold diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/about/About.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/about/About.kt new file mode 100644 index 0000000..2022144 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/about/About.kt @@ -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") +} diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt index 21aae03..dc92200 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt @@ -169,8 +169,8 @@ fun ActivityList( recentLogs: List, 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(null) } Column(modifier = modifier.fillMaxWidth()) { @@ -206,7 +206,7 @@ fun ActivityList( log = log, onEdit = { onEditLog(log.id) }, onDelete = {onDeleteLog(log) }, - // When the item is clicked, set it as the selected log for the dialog + // item selected on click 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 -> DetailsDialog( log = log, diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt index 75db570..900d701 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardViewModel.kt @@ -33,7 +33,6 @@ data class DashboardUiState( val balanceInDays: String = "0d", val recentActivities: List = emptyList(), val showAddLogDialog: Boolean = false, - //val dialogType: EntryType = EntryType.OVERTIME, val isLoading: Boolean = true, val logToEdit:Log? = null ) @@ -56,9 +55,8 @@ class DashboardViewModel : ViewModel() { 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 + // calculate the balance in days (assuming an 8-hour workday here) + val balanceInDaysValue = netBalanceInHours / 8.0 // TODO: make this a setting val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " @@ -72,7 +70,6 @@ class DashboardViewModel : ViewModel() { isLoading = isLoading, // Pass through the dialog state from the internal state holder showAddLogDialog = internalState.showAddLogDialog, - //dialogType = internalState.dialogType, logToEdit = internalState.logToEdit ) }.stateIn( @@ -139,7 +136,7 @@ class DashboardViewModel : ViewModel() { viewModelScope.launch { try { LogRepository.editLog(log) - // Realtime will remove the item from the list automatically. + // Realtime will handle the rest. } catch (e: Exception) { LOG.e("DashboardViewModel", "Failed to edit log", e) } @@ -150,7 +147,7 @@ class DashboardViewModel : ViewModel() { viewModelScope.launch { try { LogRepository.deleteLog(log) - // Realtime will remove the item from the list automatically. + // Realtime will handle the rest here too. } catch (e: Exception) { LOG.e("DashboardViewModel", "Failed to delete log", e) } @@ -160,7 +157,7 @@ class DashboardViewModel : ViewModel() { fun requestDeleteWithAuth(log: Log) { AuthenticationManager.requestAuth( action = "delete this log", - onAuthenticated = { deleteLog(log) }, // Pass the function to run on success + onAuthenticated = { deleteLog(log) }, onAuthCanceled = {} ) } diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/editor/EditorScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/editor/EditorScreen.kt index 7f1ef06..ef908ba 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/editor/EditorScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/editor/EditorScreen.kt @@ -13,11 +13,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview 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.LogRepository import net.tinsae.clocked.ui.components.ListItem @Composable -private fun EditorScreen(modifier: Modifier = Modifier){ +fun EditorScreen(modifier: Modifier = Modifier){ val viewModel = EditorViewModel() 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 = "to") } + + Text(modifier = Modifier.fillMaxWidth().padding(16.dp), text = "Logs") + ShowLogs(LogRepository.getLogsByYear(java.time.LocalDate.now().year)) } } diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt index 3916084..dea272a 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -64,7 +65,6 @@ fun HistoryScreen( if (uiState.tabs.isNotEmpty()) { PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, modifier = Modifier.fillMaxWidth(), - //containerColor = MaterialTheme.colorScheme.surfaceContainer ) { uiState.tabs.forEachIndexed { index, title -> Tab( @@ -110,8 +110,8 @@ fun HistoryScreen( fun MonthHeader(text: String, modifier: Modifier = Modifier) { Surface( modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - shadowElevation = 1.dp + color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + //shadowElevation = 1.dp ) { Text( diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt index b25b43a..575eaed 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/history/HistoryViewModel.kt @@ -131,22 +131,21 @@ class HistoryViewModel : ViewModel() { fun requestDeleteWithAuth(log: Log) { AuthenticationManager.requestAuth( action = "delete this log", - onAuthenticated = { deleteLog(log) }, // Pass the function to run on success + // passing callbacks + onAuthenticated = { deleteLog(log) }, onAuthCanceled = {} ) } + // Toggles dialog visibility 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) } } diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt index 2a2ad33..f394506 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/screens/settings/SettingsViewModel.kt @@ -11,7 +11,8 @@ import kotlinx.coroutines.launch import net.tinsae.clocked.data.Locale import net.tinsae.clocked.data.LogRepository 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( val defaultDuration: String = "8h", @@ -22,8 +23,8 @@ data class SettingsUiState( val appVersion: String = "1.0.0", val showThemeDialog: Boolean = false, val showLocaleDialog: Boolean = false, - // Hold the content of the CSV to be saved. Null means no save operation is pending. - val pendingCsvContent: String? = null + // Null means no save operation is pending. + val pendingCsvContent: ExcelContent? = null ) class SettingsViewModel : ViewModel() { @@ -57,21 +58,14 @@ class SettingsViewModel : ViewModel() { fun onExportClicked() { viewModelScope.launch { try { - // 1. Fetch data from the service (suspend call) - val logs = LogRepository.logs + val logs = ApacheExcel.exportLogsToExcel(LogRepository.logs.value) + 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. _uiState.update { it.copy(pendingCsvContent = csvContent) } } catch (e: Exception) { - // Handle any errors during fetching or exporting Log.e("SettingsViewModel", "Failed to export CSV", e) - // Optionally show an error to the user - // _uiState.update { it.copy(error = "Export failed.") } } } } diff --git a/app/src/main/java/net/tinsae/clocked/util/ApacheExcel.kt b/app/src/main/java/net/tinsae/clocked/util/ApacheExcel.kt new file mode 100644 index 0000000..20b6229 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/util/ApacheExcel.kt @@ -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): 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt b/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt deleted file mode 100644 index ba3ca0f..0000000 --- a/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt +++ /dev/null @@ -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): 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 - } -} diff --git a/app/src/main/java/net/tinsae/clocked/util/ExcelContent.kt b/app/src/main/java/net/tinsae/clocked/util/ExcelContent.kt new file mode 100644 index 0000000..498eda0 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/util/ExcelContent.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/util/Util.kt b/app/src/main/java/net/tinsae/clocked/util/Util.kt index d05e32b..81e5e82 100644 --- a/app/src/main/java/net/tinsae/clocked/util/Util.kt +++ b/app/src/main/java/net/tinsae/clocked/util/Util.kt @@ -14,10 +14,9 @@ object Util { * @return A formatted date 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()) - // 2. Extract the components and build the string manually. val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() } val day = localDateTime.day val year = localDateTime.year @@ -62,10 +61,9 @@ object Util { * @return A formatted month and year 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()) - // 2. Extract the components and build the string. val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() } val year = localDateTime.year diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c778f0..09a86b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,8 @@ [versions] agp = "8.13.2" +biometric = "1.1.0" +fastexcel = "0.20.0" +jacksonCore = "2.21.1" kotlin = "2.3.0" coreKtx = "1.17.0" junit = "4.13.2" @@ -7,6 +10,7 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" kotlinxDatetime = "0.7.1" kotlinxSerializationJson = "1.9.0" +ktorClientOkhttp = "3.4.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.2" composeBom = "2025.12.01" @@ -15,14 +19,18 @@ appcompat = "1.7.1" desugarJdkLibs = "2.1.5" material3WindowSizeClass = "1.4.0" materialIconsExtended = "1.7.8" +poiOoxml = "5.5.1" supabase = "3.2.6" ktor = "3.3.3" [libraries] 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-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" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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 kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 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-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" } supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" }