cleaning up
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
127
app/src/main/java/net/tinsae/clocked/ui/components/BottomBar.kt
Normal file
127
app/src/main/java/net/tinsae/clocked/ui/components/BottomBar.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
130
app/src/main/java/net/tinsae/clocked/ui/screens/about/About.kt
Normal file
130
app/src/main/java/net/tinsae/clocked/ui/screens/about/About.kt
Normal 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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
app/src/main/java/net/tinsae/clocked/util/ApacheExcel.kt
Normal file
86
app/src/main/java/net/tinsae/clocked/util/ApacheExcel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/net/tinsae/clocked/util/ExcelContent.kt
Normal file
13
app/src/main/java/net/tinsae/clocked/util/ExcelContent.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user