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

View File

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

View File

@@ -20,12 +20,12 @@ object AuthenticationManager {
private val _request = MutableStateFlow<AuthRequest?>(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 }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<PostgresAction>(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<Log> {
return logs.value.filter { it.timestamp.toLocalDateTime(TimeZone.UTC).year == year }
}
}

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -33,7 +33,6 @@ data class DashboardUiState(
val balanceInDays: String = "0d",
val recentActivities: List<Log> = emptyList(),
val showAddLogDialog: Boolean = false,
//val dialogType: EntryType = EntryType.OVERTIME,
val isLoading: Boolean = true,
val logToEdit:Log? = null
)
@@ -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 = {}
)
}

View File

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

View File

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

View File

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

View File

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

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