cleaning up
This commit is contained in:
@@ -22,7 +22,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "net.tinsae.clocked"
|
applicationId = "net.tinsae.clocked"
|
||||||
minSdk = 24
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
@@ -74,7 +74,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3.window.size.class1)
|
implementation(libs.androidx.compose.material3.window.size.class1)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
// Supabase
|
// Supabase
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
@@ -83,7 +83,7 @@ dependencies {
|
|||||||
// --- SUPABASE DEPENDENCIES (Corrected) ---
|
// --- SUPABASE DEPENDENCIES (Corrected) ---
|
||||||
// 1. Implement the BOM using the platform() keyword.
|
// 1. Implement the BOM using the platform() keyword.
|
||||||
implementation(platform(libs.supabase.bom))
|
implementation(platform(libs.supabase.bom))
|
||||||
implementation("io.ktor:ktor-client-okhttp:3.3.3")
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|
||||||
// 2. Implement the specific Supabase modules using the correct aliases.
|
// 2. Implement the specific Supabase modules using the correct aliases.
|
||||||
implementation(libs.supabase.auth)
|
implementation(libs.supabase.auth)
|
||||||
@@ -91,11 +91,11 @@ dependencies {
|
|||||||
implementation(libs.ktor.client.android)
|
implementation(libs.ktor.client.android)
|
||||||
implementation(libs.supabase.realtime)
|
implementation(libs.supabase.realtime)
|
||||||
|
|
||||||
|
implementation(libs.poi.ooxml)
|
||||||
|
implementation(libs.fastexcel)
|
||||||
|
|
||||||
|
// Required for some Android environments to handle XML/StAX issues
|
||||||
|
implementation(libs.jackson.core)
|
||||||
|
|
||||||
|
|
||||||
//implementation(libs.supabase.gotrue.live)
|
//implementation(libs.supabase.gotrue.live)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import kotlinx.serialization.json.put
|
|||||||
import net.tinsae.clocked.data.FormType
|
import net.tinsae.clocked.data.FormType
|
||||||
import net.tinsae.clocked.util.SupabaseClient
|
import net.tinsae.clocked.util.SupabaseClient
|
||||||
|
|
||||||
// Add this data class here for UI state
|
// data class for UI state
|
||||||
data class AuthUiState(
|
data class AuthUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -33,7 +33,7 @@ data class AuthUiState(
|
|||||||
|
|
||||||
class AuthViewModel : ViewModel() {
|
class AuthViewModel : ViewModel() {
|
||||||
|
|
||||||
// --- State from LoginViewModel is now here ---
|
// State from LoginViewModel
|
||||||
var uiState by mutableStateOf(AuthUiState())
|
var uiState by mutableStateOf(AuthUiState())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class AuthViewModel : ViewModel() {
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
// Initialize with the client's current status, which will be .Initializing on cold start
|
// Initialize with the client's current status. Initializing on cold start
|
||||||
initialValue = SupabaseClient.client.auth.sessionStatus.value
|
initialValue = SupabaseClient.client.auth.sessionStatus.value
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,17 +52,17 @@ class AuthViewModel : ViewModel() {
|
|||||||
is SessionStatus.Authenticated -> {
|
is SessionStatus.Authenticated -> {
|
||||||
val metadata = status.session.user?.userMetadata
|
val metadata = status.session.user?.userMetadata
|
||||||
val fullName = (metadata?.get("full_name") ?: metadata?.get("user_name"))
|
val fullName = (metadata?.get("full_name") ?: metadata?.get("user_name"))
|
||||||
?.jsonPrimitive // Get the JsonPrimitive? from the element
|
?.jsonPrimitive // JsonPrimitive from the element
|
||||||
?.contentOrNull // Get the String content, or null if it's not a string/is null
|
?.contentOrNull // String content, or null if it's not a string/is null
|
||||||
|
|
||||||
uiState = uiState.copy(userName = fullName)
|
uiState = uiState.copy(userName = fullName)
|
||||||
}
|
}
|
||||||
is SessionStatus.NotAuthenticated -> {
|
is SessionStatus.NotAuthenticated -> {
|
||||||
// When the user logs out or the session expires, clear the name.
|
// When the user logs out or the session expires, we clear the name.
|
||||||
uiState = uiState.copy(userName = null)
|
uiState = uiState.copy(userName = null)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Handles Initializing, LoadingFromStorage states. No UI change needed here.
|
// Thinking about what to do here ...
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ class AuthViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse error message from exception
|
// TODO this needs serious work
|
||||||
private fun parseError(e: Exception): String {
|
private fun parseError(e: Exception): String {
|
||||||
val defaultError = "An unknown error occurred. Please try again."
|
val defaultError = "An unknown error occurred. Please try again."
|
||||||
val message = e.message ?: return defaultError
|
val message = e.message ?: return defaultError
|
||||||
@@ -124,8 +124,8 @@ class AuthViewModel : ViewModel() {
|
|||||||
is BadRequestRestException -> { // server can be reached
|
is BadRequestRestException -> { // server can be reached
|
||||||
message.lines().firstOrNull()?.trim() ?: defaultError
|
message.lines().firstOrNull()?.trim() ?: defaultError
|
||||||
}
|
}
|
||||||
is RestException -> { // server cant be reached
|
is RestException -> { // server cant be reached or creds wrong
|
||||||
"A network error occurred. Please check your connection."
|
"Either a connection error or invalid credentials. Please check your connection or credentials and try again."
|
||||||
}
|
}
|
||||||
// For any other unexpected exception
|
// For any other unexpected exception
|
||||||
else -> defaultError
|
else -> defaultError
|
||||||
|
|||||||
@@ -14,16 +14,15 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -38,13 +37,15 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import io.github.jan.supabase.auth.status.SessionStatus
|
import io.github.jan.supabase.auth.status.SessionStatus
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.tinsae.clocked.biometric.GlobalAuthenticator
|
import net.tinsae.clocked.biometric.GlobalAuthenticator
|
||||||
import net.tinsae.clocked.data.Locale
|
import net.tinsae.clocked.data.Locale
|
||||||
import net.tinsae.clocked.data.Theme
|
import net.tinsae.clocked.data.Theme
|
||||||
|
import net.tinsae.clocked.ui.components.CustomBottomBar
|
||||||
import net.tinsae.clocked.ui.components.TopBar
|
import net.tinsae.clocked.ui.components.TopBar
|
||||||
|
import net.tinsae.clocked.ui.screens.about.AboutScreen
|
||||||
import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen
|
import net.tinsae.clocked.ui.screens.dashboard.DashboardScreen
|
||||||
import net.tinsae.clocked.ui.screens.history.HistoryScreen
|
import net.tinsae.clocked.ui.screens.history.HistoryScreen
|
||||||
import net.tinsae.clocked.ui.screens.settings.SettingsScreen
|
import net.tinsae.clocked.ui.screens.settings.SettingsScreen
|
||||||
@@ -60,16 +61,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val authViewModel: AuthViewModel by viewModels()
|
private val authViewModel: AuthViewModel by viewModels()
|
||||||
|
|
||||||
private val createDocumentLauncher = registerForActivityResult(
|
private val createDocumentLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("text/csv")
|
ActivityResultContracts.CreateDocument("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
val content = settingsViewModel.uiState.value.pendingCsvContent
|
val content = settingsViewModel.uiState.value.pendingCsvContent?.bytes
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
try {
|
try {
|
||||||
contentResolver.openOutputStream(it)?.use { outputStream ->
|
contentResolver.openOutputStream(it)?.use { outputStream ->
|
||||||
outputStream.writer().use { writer ->
|
outputStream.write(content)
|
||||||
writer.write(content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("MainActivity", "Error writing to file", e)
|
Log.e("MainActivity", "Error writing to file", e)
|
||||||
@@ -86,7 +85,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
settingsViewModel.uiState
|
settingsViewModel.uiState
|
||||||
.map { it.pendingCsvContent }
|
.map { it.pendingCsvContent }
|
||||||
.distinctUntilChanged()
|
// we export only if its not null
|
||||||
|
// we do reset it when we done
|
||||||
|
.filter{it != null}
|
||||||
.collect {
|
.collect {
|
||||||
csvContent ->
|
csvContent ->
|
||||||
if (csvContent != null) {
|
if (csvContent != null) {
|
||||||
@@ -147,33 +148,18 @@ fun updateLocale(context: Context, locale: Locale) {
|
|||||||
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
|
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
|
||||||
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
|
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
navigationSuiteItems = {
|
|
||||||
AppDestinations.entries.forEach { destination ->
|
|
||||||
item(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = destination.icon,
|
|
||||||
contentDescription = stringResource(destination.label),
|
|
||||||
modifier = Modifier
|
|
||||||
//.padding(1.dp)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
alwaysShowLabel = false,
|
|
||||||
//label = { Text(stringResource(destination.label)) },
|
|
||||||
selected = destination == currentDestination,
|
|
||||||
onClick = { currentDestination = destination }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
bottomBar = {
|
||||||
|
CustomBottomBar(
|
||||||
|
current = currentDestination,
|
||||||
|
destinations = AppDestinations.entries.toTypedArray(),
|
||||||
|
onItemSelected = { currentDestination = it }
|
||||||
|
)
|
||||||
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
TopBar(
|
TopBar(
|
||||||
appName = if(authViewModel.uiState.userName != null)
|
appName = if (authViewModel.uiState.userName != null)
|
||||||
authViewModel.uiState.userName!!.capitalise()
|
authViewModel.uiState.userName!!.capitalise()
|
||||||
else
|
else
|
||||||
stringResource(R.string.app_name),
|
stringResource(R.string.app_name),
|
||||||
@@ -184,36 +170,48 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = { authViewModel.logout() }, modifier = Modifier.padding(6.dp),
|
onClick = { authViewModel.logout() }, modifier = Modifier.padding(6.dp),
|
||||||
|
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = stringResource(R.string.logout)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
|
||||||
when (currentDestination) {
|
when (currentDestination) {
|
||||||
AppDestinations.HOME -> DashboardScreen(modifier = modifier)
|
AppDestinations.HOME -> DashboardScreen(modifier = modifier)
|
||||||
AppDestinations.HISTORY -> HistoryScreen(modifier = modifier)
|
AppDestinations.HISTORY -> HistoryScreen(modifier = modifier)
|
||||||
AppDestinations.SETTING -> SettingsScreen(
|
AppDestinations.SETTING -> SettingsScreen(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
viewModel = settingsViewModel
|
viewModel = settingsViewModel
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AppDestinations.ABOUT -> {
|
||||||
|
AboutScreen(modifier = modifier,author = "Tinsae Ghilay", appName = "Clocked", email = "tinsaekahsay@gmail.com", version = "1.0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum class AppDestinations(
|
enum class AppDestinations(
|
||||||
val label: Int,
|
val label: Int,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
) {
|
) {
|
||||||
HOME(R.string.nav_home, Icons.Default.Home),
|
HOME(R.string.nav_home, Icons.Default.Home),
|
||||||
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
|
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
|
||||||
|
//EDITORS(R.string.edit, Icons.Default.Edit),
|
||||||
SETTING(R.string.nav_settings, Icons.Default.Settings),
|
SETTING(R.string.nav_settings, Icons.Default.Settings),
|
||||||
//LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout)
|
//LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout),
|
||||||
|
ABOUT(label = R.string.about, Icons.Filled.Info)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ object AuthenticationManager {
|
|||||||
private val _request = MutableStateFlow<AuthRequest?>(null)
|
private val _request = MutableStateFlow<AuthRequest?>(null)
|
||||||
val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow
|
val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow
|
||||||
|
|
||||||
// Any ViewModel can call this to request authentication.
|
// Request authentication.
|
||||||
fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) {
|
fun requestAuth(action: String, onAuthenticated: () -> Unit, onAuthCanceled: () -> Unit) {
|
||||||
_request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) }
|
_request.update { AuthRequest(action = action, onSuccess = onAuthenticated, onCancel = onAuthCanceled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call this to clear the request after it's been handled (success or fail).
|
// clear the request after it's been handled (success or fail).
|
||||||
fun clearRequest() {
|
fun clearRequest() {
|
||||||
_request.update { null }
|
_request.update { null }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class BiometricAuthenticator(private val activity: FragmentActivity) {
|
|||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
super.onAuthenticationError(errorCode, errString)
|
super.onAuthenticationError(errorCode, errString)
|
||||||
// Distinguish between user cancellation and other errors
|
// User cancellation and other errors
|
||||||
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||||
onCancel()
|
onCancel()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ import net.tinsae.clocked.R
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GlobalAuthenticator() {
|
fun GlobalAuthenticator() {
|
||||||
// Collect the current authentication request from the global manager.
|
// reference to authenticator
|
||||||
val authRequest by AuthenticationManager.request.collectAsState()
|
val authRequest by AuthenticationManager.request.collectAsState()
|
||||||
|
|
||||||
// If there is a request, show the AuthenticationDialog.
|
// There is a request? Show authentication dialog.
|
||||||
authRequest?.let { request ->
|
authRequest?.let { request ->
|
||||||
AuthenticationDialog(
|
AuthenticationDialog(
|
||||||
title = stringResource(R.string.app_name),
|
title = stringResource(R.string.app_name),
|
||||||
action = request.action,
|
action = request.action,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
// First, execute the protected action (e.g., viewModel.deleteLog)
|
|
||||||
request.onSuccess()
|
request.onSuccess()
|
||||||
// Then, clear the request from the manager.
|
// clear request after each callback.
|
||||||
AuthenticationManager.clearRequest()
|
AuthenticationManager.clearRequest()
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
|
|||||||
@@ -8,23 +8,25 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
|||||||
import kotlinx.serialization.encoding.Decoder
|
import kotlinx.serialization.encoding.Decoder
|
||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialiser for Kotlin Duration to Postgres Interval.
|
||||||
|
* had to be custom because its not serialisable by default.
|
||||||
|
*/
|
||||||
object IntervalSerialiser : KSerializer<Duration> {
|
object IntervalSerialiser : KSerializer<Duration> {
|
||||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING)
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING)
|
||||||
|
|
||||||
// Serialization (sending data TO database) remains the same.
|
// Serialization (sending data TO database).
|
||||||
override fun serialize(encoder: Encoder, value: Duration) {
|
override fun serialize(encoder: Encoder, value: Duration) {
|
||||||
val totalSeconds = value.inWholeSeconds
|
val totalSeconds = value.inWholeSeconds
|
||||||
encoder.encodeString("$totalSeconds seconds")
|
encoder.encodeString("$totalSeconds seconds")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deserialization (receiving data FROM database) is now custom.
|
// Deserialization (receiving data FROM database)
|
||||||
override fun deserialize(decoder: Decoder): Duration {
|
override fun deserialize(decoder: Decoder): Duration {
|
||||||
val timeString = decoder.decodeString()
|
val timeString = decoder.decodeString()
|
||||||
// Handle negative intervals from Postgres, which might look like "-01:30:00"
|
// Negative intervals from Postgres.
|
||||||
val isNegative = timeString.startsWith('-')
|
val isNegative = timeString.startsWith('-')
|
||||||
val absTimeString = if (isNegative) timeString.substring(1) else timeString
|
val absTimeString = if (isNegative) timeString.substring(1) else timeString
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import androidx.compose.runtime.Composable
|
|||||||
enum class Locale(val tag: String) {
|
enum class Locale(val tag: String) {
|
||||||
SYSTEM(""),
|
SYSTEM(""),
|
||||||
ENGLISH("en"),
|
ENGLISH("en"),
|
||||||
SPANISH("es"),
|
SPANISH("de"),
|
||||||
FRENCH("fr");
|
FRENCH("ti");
|
||||||
|
|
||||||
val title:String
|
val title:String
|
||||||
@Composable
|
@Composable
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
SYSTEM -> "System"
|
SYSTEM -> "System"
|
||||||
ENGLISH -> "English"
|
ENGLISH -> "English"
|
||||||
SPANISH -> "Español"
|
SPANISH -> "German"
|
||||||
FRENCH -> "Français"
|
FRENCH -> "Tigrinya"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,27 @@ data class Log(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val timestamp: Instant,
|
val timestamp: Instant,
|
||||||
val type: EntryType,
|
val type: EntryType,
|
||||||
|
// using Custom serialiser here to convert to Postgres Interval
|
||||||
@Serializable(with = IntervalSerialiser::class)
|
@Serializable(with = IntervalSerialiser::class)
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val reason: String? = null,
|
val reason: String? = null,
|
||||||
@SerialName("user_id")
|
@SerialName("user_id")
|
||||||
val userId: String? = null // This will be populated by Supabase
|
// this is null because it will be autogenerated by postgres (Supabase)
|
||||||
|
val userId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LogEntry(
|
data class LogEntry(
|
||||||
val timestamp: Instant,
|
val timestamp: Instant,
|
||||||
val type: EntryType,
|
val type: EntryType,
|
||||||
@Serializable(with = IntervalSerialiser::class) // Can reuse the serializer
|
// Re-using Custom serialiser here as well
|
||||||
|
@Serializable(with = IntervalSerialiser::class)
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val reason: String? = null
|
val reason: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// Enums
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class EntryType {
|
enum class EntryType {
|
||||||
OVERTIME, TIME_OFF
|
OVERTIME, TIME_OFF
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package net.tinsae.clocked.data
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
//import androidx.compose.ui.test.cancel
|
|
||||||
import io.github.jan.supabase.auth.auth
|
import io.github.jan.supabase.auth.auth
|
||||||
import io.github.jan.supabase.auth.status.SessionStatus
|
import io.github.jan.supabase.auth.status.SessionStatus
|
||||||
import io.github.jan.supabase.realtime.PostgresAction
|
import io.github.jan.supabase.realtime.PostgresAction
|
||||||
@@ -16,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import net.tinsae.clocked.service.addLogToDB
|
import net.tinsae.clocked.service.addLogToDB
|
||||||
import net.tinsae.clocked.service.deleteLogFromDB
|
import net.tinsae.clocked.service.deleteLogFromDB
|
||||||
import net.tinsae.clocked.service.editLogFromDB
|
import net.tinsae.clocked.service.editLogFromDB
|
||||||
@@ -37,9 +38,10 @@ object LogRepository {
|
|||||||
val logs = _logs.asStateFlow()
|
val logs = _logs.asStateFlow()
|
||||||
|
|
||||||
// Testing code
|
// Testing code
|
||||||
private val _isLoading = MutableStateFlow(false) // Start as true
|
// this still needs work as loading should be unified.
|
||||||
|
// This solution is not a good one.
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
val isLoading = _isLoading.asStateFlow()
|
val isLoading = _isLoading.asStateFlow()
|
||||||
// testing end
|
|
||||||
|
|
||||||
private val repositoryScope = CoroutineScope(Dispatchers.IO)
|
private val repositoryScope = CoroutineScope(Dispatchers.IO)
|
||||||
private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel")
|
private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel")
|
||||||
@@ -47,25 +49,22 @@ object LogRepository {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
repositoryScope.launch {
|
repositoryScope.launch {
|
||||||
// Define the realtime listener flow once. This flow is cold and does not
|
// Realtime listener flow.
|
||||||
// do anything until it's collected.
|
// doesn't do anything until it's collected.
|
||||||
val changeFlow = realtimeChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
|
val changeFlow = realtimeChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
|
||||||
table = "Logs"
|
table = "Logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch a long-lived collector for the realtime changes. This coroutine
|
// Long-lived collector for the realtime changes.
|
||||||
// will run for the lifetime of the repositoryScope.
|
// Will run for the lifetime of the repositoryScope.
|
||||||
launch {
|
launch {
|
||||||
changeFlow
|
changeFlow
|
||||||
.catch { e ->
|
.catch { e ->
|
||||||
// This handles terminal errors in the flow itself.
|
// This handles terminal errors in the flow itself.
|
||||||
// The Supabase client should automatically try to recover from
|
|
||||||
// transient network issues like SocketException.
|
|
||||||
LOG.e("LogRepository", "Realtime flow collection error", e)
|
LOG.e("LogRepository", "Realtime flow collection error", e)
|
||||||
}
|
}
|
||||||
.collect { action ->
|
.collect { action ->
|
||||||
// By placing the try-catch here, we ensure that a single
|
|
||||||
// malformed event doesn't stop us from processing subsequent events.
|
|
||||||
try {
|
try {
|
||||||
handleRealtimeAction(action)
|
handleRealtimeAction(action)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -76,17 +75,16 @@ object LogRepository {
|
|||||||
|
|
||||||
// Collect session status to manage the subscription lifecycle and data fetching.
|
// Collect session status to manage the subscription lifecycle and data fetching.
|
||||||
SupabaseClient.client.auth.sessionStatus.collect { status ->
|
SupabaseClient.client.auth.sessionStatus.collect { status ->
|
||||||
// Cancel any existing data-fetching job to prevent race conditions on session changes.
|
// To prevent race conditions on session changes,
|
||||||
sessionJob?.cancel()
|
sessionJob?.cancel()
|
||||||
|
|
||||||
when (status) {
|
when (status) {
|
||||||
is SessionStatus.Authenticated -> {
|
is SessionStatus.Authenticated -> {
|
||||||
LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.")
|
LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.")
|
||||||
// Create a new job for the tasks associated with this authenticated session.
|
// New job for the tasks associated with this authenticated session.
|
||||||
sessionJob = launch {
|
sessionJob = launch {
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
// The postgresChangeFlow listener is already set up.
|
// And subscribe to the realtime channel.
|
||||||
// subscribe() connects the websocket and starts receiving events.
|
|
||||||
realtimeChannel.subscribe()
|
realtimeChannel.subscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,15 +93,14 @@ object LogRepository {
|
|||||||
realtimeChannel.unsubscribe()
|
realtimeChannel.unsubscribe()
|
||||||
clearLogs()
|
clearLogs()
|
||||||
}
|
}
|
||||||
else -> { /* Handle other states if necessary */ }
|
else -> { /* Unhandled session status */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Extracted the when block to a separate function for clarity
|
// Handling CRUD actions.
|
||||||
// Extracted the when block to a separate function for clarity
|
|
||||||
private fun handleRealtimeAction(action: PostgresAction) {
|
private fun handleRealtimeAction(action: PostgresAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is PostgresAction.Insert -> {
|
is PostgresAction.Insert -> {
|
||||||
@@ -141,7 +138,7 @@ object LogRepository {
|
|||||||
val latestLogs = getAllLogsFromDB()
|
val latestLogs = getAllLogsFromDB()
|
||||||
_logs.update { latestLogs }
|
_logs.update { latestLogs }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The CancellationException from the log will be caught here
|
// CancellationException from the log will be caught here
|
||||||
if (e is java.util.concurrent.CancellationException) {
|
if (e is java.util.concurrent.CancellationException) {
|
||||||
LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.")
|
LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.")
|
||||||
} else {
|
} else {
|
||||||
@@ -209,4 +206,8 @@ object LogRepository {
|
|||||||
_isLoading.update { false }
|
_isLoading.update { false }
|
||||||
LOG.d("LogRepository", "Local log data cleared.")
|
LOG.d("LogRepository", "Local log data cleared.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLogsByYear(year: Int): List<Log> {
|
||||||
|
return logs.value.filter { it.timestamp.toLocalDateTime(TimeZone.UTC).year == year }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package net.tinsae.clocked.service
|
package net.tinsae.clocked.service
|
||||||
|
|
||||||
import io.github.jan.supabase.auth.auth
|
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
import io.github.jan.supabase.postgrest.postgrest
|
||||||
import io.github.jan.supabase.postgrest.query.Order
|
import io.github.jan.supabase.postgrest.query.Order
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import net.tinsae.clocked.data.EntryType
|
import net.tinsae.clocked.data.EntryType
|
||||||
import net.tinsae.clocked.data.Log
|
import net.tinsae.clocked.data.Log
|
||||||
import android.util.Log as LOG
|
|
||||||
import net.tinsae.clocked.data.LogEntry
|
import net.tinsae.clocked.data.LogEntry
|
||||||
import net.tinsae.clocked.util.SupabaseClient
|
import net.tinsae.clocked.util.SupabaseClient
|
||||||
import kotlin.time.Instant
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import android.util.Log as LOG
|
||||||
|
|
||||||
// --- Public API for accessing and modifying logs --- //
|
|
||||||
|
|
||||||
suspend fun getAllLogsFromDB(): List<Log> {
|
suspend fun getAllLogsFromDB(): List<Log> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import androidx.compose.material3.rememberDatePickerState
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import android.util.Log as LOG
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -31,7 +30,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.setSelection
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -43,9 +41,9 @@ import net.tinsae.clocked.data.Log
|
|||||||
import net.tinsae.clocked.ui.theme.ClockedTheme
|
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import android.util.Log as LOG
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -66,9 +64,6 @@ fun AddLogDialog(
|
|||||||
|
|
||||||
val datePickerState = rememberDatePickerState()
|
val datePickerState = rememberDatePickerState()
|
||||||
|
|
||||||
// 2. REMOVE the unconditional `if (log != null)` block that was overwriting your state.
|
|
||||||
|
|
||||||
// 3. Use ONE LaunchedEffect keyed to the `log` object to handle ALL state population.
|
|
||||||
LaunchedEffect(key1 = log) {
|
LaunchedEffect(key1 = log) {
|
||||||
if (log != null) {
|
if (log != null) {
|
||||||
// Editing an existing log: Populate all state variables here.
|
// Editing an existing log: Populate all state variables here.
|
||||||
@@ -79,9 +74,7 @@ fun AddLogDialog(
|
|||||||
reason = log.reason ?: ""
|
reason = log.reason ?: ""
|
||||||
selectedInstant = log.timestamp
|
selectedInstant = log.timestamp
|
||||||
selectedType = log.type
|
selectedType = log.type
|
||||||
// **THE API FIX**: The correct function is `setSelection`, but it is internal.
|
// set selected date to that of the selected Log if not null.
|
||||||
// The public API is to set the `selectedDateMillis` property directly.
|
|
||||||
// This will now correctly update the hoisted DatePickerState.
|
|
||||||
datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds()
|
datePickerState.selectedDateMillis = log.timestamp.toEpochMilliseconds()
|
||||||
LOG.d("AddLogDialog", "Populating fields from log: $log")
|
LOG.d("AddLogDialog", "Populating fields from log: $log")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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
|
@Composable
|
||||||
private fun DetailRow(label: String, value: String) {
|
private fun DetailRow(label: String, value: String) {
|
||||||
// buildAnnotatedString allows mixing different styles in one Text composable.
|
|
||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
@@ -66,6 +65,6 @@ private fun DetailRow(label: String, value: String) {
|
|||||||
append(" ")
|
append(" ")
|
||||||
append(value)
|
append(value)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge // Use a slightly larger font style
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.AccessTime
|
import androidx.compose.material.icons.filled.AccessTime
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
// Import the new icon from the extended library
|
|
||||||
import androidx.compose.material.icons.filled.History
|
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -37,13 +34,11 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.tinsae.clocked.R
|
import net.tinsae.clocked.R
|
||||||
import net.tinsae.clocked.data.Log
|
import net.tinsae.clocked.data.Log
|
||||||
import net.tinsae.clocked.data.LogRepository.getRecentLogs
|
|
||||||
import net.tinsae.clocked.ui.theme.cyan
|
import net.tinsae.clocked.ui.theme.cyan
|
||||||
import net.tinsae.clocked.ui.theme.red
|
import net.tinsae.clocked.ui.theme.red
|
||||||
import net.tinsae.clocked.util.Util.formatDuration
|
import net.tinsae.clocked.util.Util.formatDuration
|
||||||
@@ -71,7 +66,6 @@ fun ListItem(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
) {
|
) {
|
||||||
// --- Background content (icons for swipe) ---
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
@@ -96,8 +90,7 @@ fun ListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Foreground content (the new list item UI) ---
|
Surface(
|
||||||
Surface( // Use Surface for background color and elevation control
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
|
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
@@ -160,7 +153,7 @@ fun ListItem(
|
|||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// Indent the divider to align with text
|
// Indent divider to align with text
|
||||||
.padding(start = 56.dp)
|
.padding(start = 56.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ fun ShimmerBrush(showShimmer: Boolean = true, targetValue: Float = 1000f, color:
|
|||||||
fun ShimmerListItemPreview() {
|
fun ShimmerListItemPreview() {
|
||||||
val brush = ShimmerBrush()
|
val brush = ShimmerBrush()
|
||||||
Row(modifier = Modifier.padding(16.dp)) {
|
Row(modifier = Modifier.padding(16.dp)) {
|
||||||
// Placeholder for a video thumbnail or user avatar
|
// Placeholder
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(100.dp)
|
.size(100.dp)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fun TopBar(
|
|||||||
.asPaddingValues()
|
.asPaddingValues()
|
||||||
)
|
)
|
||||||
else Modifier.fillMaxWidth(),
|
else Modifier.fillMaxWidth(),
|
||||||
// No padding needed here on the Surface itself
|
// No padding needed here
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
// Apply safe area padding to the Row to push content down
|
// Apply safe area padding to the Row to push content down
|
||||||
@@ -50,7 +50,6 @@ fun TopBar(
|
|||||||
) {
|
) {
|
||||||
if (appName != null) {
|
if (appName != null) {
|
||||||
Text(
|
Text(
|
||||||
//modifier = Modifier.padding(start = 16.dp), // Vertical padding is handled by Row's alignment
|
|
||||||
text = appName,
|
text = appName,
|
||||||
style = typography.titleLarge,
|
style = typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
|
|||||||
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>,
|
recentLogs: List<Log>,
|
||||||
viewModel: DashboardViewModel
|
viewModel: DashboardViewModel
|
||||||
) {
|
) {
|
||||||
//var mutableItems by remember { mutableStateOf(items) }
|
|
||||||
// State to hold the log that should be shown in the dialog. Null means no dialog.
|
// State to hold the log that should be shown in the dialog.
|
||||||
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
@@ -206,7 +206,7 @@ fun ActivityList(
|
|||||||
log = log,
|
log = log,
|
||||||
onEdit = { onEditLog(log.id) },
|
onEdit = { onEditLog(log.id) },
|
||||||
onDelete = {onDeleteLog(log) },
|
onDelete = {onDeleteLog(log) },
|
||||||
// When the item is clicked, set it as the selected log for the dialog
|
// item selected on click
|
||||||
onClick = { selectedLogForDialog = log }
|
onClick = { selectedLogForDialog = log }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ fun ActivityList(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a log is selected, show the dialog.
|
// show details dialog if item selected
|
||||||
selectedLogForDialog?.let { log ->
|
selectedLogForDialog?.let { log ->
|
||||||
DetailsDialog(
|
DetailsDialog(
|
||||||
log = log,
|
log = log,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ data class DashboardUiState(
|
|||||||
val balanceInDays: String = "0d",
|
val balanceInDays: String = "0d",
|
||||||
val recentActivities: List<Log> = emptyList(),
|
val recentActivities: List<Log> = emptyList(),
|
||||||
val showAddLogDialog: Boolean = false,
|
val showAddLogDialog: Boolean = false,
|
||||||
//val dialogType: EntryType = EntryType.OVERTIME,
|
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val logToEdit:Log? = null
|
val logToEdit:Log? = null
|
||||||
)
|
)
|
||||||
@@ -56,9 +55,8 @@ class DashboardViewModel : ViewModel() {
|
|||||||
val netBalanceDuration = overtimeDuration + timeOffDuration
|
val netBalanceDuration = overtimeDuration + timeOffDuration
|
||||||
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
|
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
|
||||||
|
|
||||||
// calculate the balance in days (assuming an 8-hour workday)
|
// calculate the balance in days (assuming an 8-hour workday here)
|
||||||
// and format it to one decimal place for a clean look.
|
val balanceInDaysValue = netBalanceInHours / 8.0 // TODO: make this a setting
|
||||||
val balanceInDaysValue = netBalanceInHours / 8.0
|
|
||||||
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d "
|
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d "
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +70,6 @@ class DashboardViewModel : ViewModel() {
|
|||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
// Pass through the dialog state from the internal state holder
|
// Pass through the dialog state from the internal state holder
|
||||||
showAddLogDialog = internalState.showAddLogDialog,
|
showAddLogDialog = internalState.showAddLogDialog,
|
||||||
//dialogType = internalState.dialogType,
|
|
||||||
logToEdit = internalState.logToEdit
|
logToEdit = internalState.logToEdit
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@@ -139,7 +136,7 @@ class DashboardViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
LogRepository.editLog(log)
|
LogRepository.editLog(log)
|
||||||
// Realtime will remove the item from the list automatically.
|
// Realtime will handle the rest.
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LOG.e("DashboardViewModel", "Failed to edit log", e)
|
LOG.e("DashboardViewModel", "Failed to edit log", e)
|
||||||
}
|
}
|
||||||
@@ -150,7 +147,7 @@ class DashboardViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
LogRepository.deleteLog(log)
|
LogRepository.deleteLog(log)
|
||||||
// Realtime will remove the item from the list automatically.
|
// Realtime will handle the rest here too.
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LOG.e("DashboardViewModel", "Failed to delete log", e)
|
LOG.e("DashboardViewModel", "Failed to delete log", e)
|
||||||
}
|
}
|
||||||
@@ -160,7 +157,7 @@ class DashboardViewModel : ViewModel() {
|
|||||||
fun requestDeleteWithAuth(log: Log) {
|
fun requestDeleteWithAuth(log: Log) {
|
||||||
AuthenticationManager.requestAuth(
|
AuthenticationManager.requestAuth(
|
||||||
action = "delete this log",
|
action = "delete this log",
|
||||||
onAuthenticated = { deleteLog(log) }, // Pass the function to run on success
|
onAuthenticated = { deleteLog(log) },
|
||||||
onAuthCanceled = {}
|
onAuthCanceled = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
import net.tinsae.clocked.data.Log
|
import net.tinsae.clocked.data.Log
|
||||||
|
import net.tinsae.clocked.data.LogRepository
|
||||||
import net.tinsae.clocked.ui.components.ListItem
|
import net.tinsae.clocked.ui.components.ListItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorScreen(modifier: Modifier = Modifier){
|
fun EditorScreen(modifier: Modifier = Modifier){
|
||||||
|
|
||||||
val viewModel = EditorViewModel()
|
val viewModel = EditorViewModel()
|
||||||
Surface(modifier = modifier.padding(16.dp).fillMaxSize()) {
|
Surface(modifier = modifier.padding(16.dp).fillMaxSize()) {
|
||||||
@@ -39,6 +42,9 @@ private fun FilterLogs(modifier: Modifier = Modifier, viewModel: EditorViewModel
|
|||||||
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "from")
|
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "from")
|
||||||
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "to")
|
Text(modifier = Modifier.weight(1f).padding(16.dp), text = "to")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(modifier = Modifier.fillMaxWidth().padding(16.dp), text = "Logs")
|
||||||
|
ShowLogs(LogRepository.getLogsByYear(java.time.LocalDate.now().year))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.material3.PrimaryTabRow
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -64,7 +65,6 @@ fun HistoryScreen(
|
|||||||
if (uiState.tabs.isNotEmpty()) {
|
if (uiState.tabs.isNotEmpty()) {
|
||||||
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
|
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
//containerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
) {
|
) {
|
||||||
uiState.tabs.forEachIndexed { index, title ->
|
uiState.tabs.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
@@ -110,8 +110,8 @@ fun HistoryScreen(
|
|||||||
fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||||
shadowElevation = 1.dp
|
//shadowElevation = 1.dp
|
||||||
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -131,22 +131,21 @@ class HistoryViewModel : ViewModel() {
|
|||||||
fun requestDeleteWithAuth(log: Log) {
|
fun requestDeleteWithAuth(log: Log) {
|
||||||
AuthenticationManager.requestAuth(
|
AuthenticationManager.requestAuth(
|
||||||
action = "delete this log",
|
action = "delete this log",
|
||||||
onAuthenticated = { deleteLog(log) }, // Pass the function to run on success
|
// passing callbacks
|
||||||
|
onAuthenticated = { deleteLog(log) },
|
||||||
onAuthCanceled = {}
|
onAuthCanceled = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggles dialog visibility
|
||||||
fun toggleAddLogDialog() {
|
fun toggleAddLogDialog() {
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
// If we are currently showing the dialog, the toggle will hide it.
|
|
||||||
// When hiding, we MUST also reset logToEdit.
|
|
||||||
if (it.showAddLogDialog) {
|
if (it.showAddLogDialog) {
|
||||||
it.copy(
|
it.copy(
|
||||||
showAddLogDialog = false,
|
showAddLogDialog = false,
|
||||||
logToEdit = null
|
logToEdit = null
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// This handles showing the dialog for a NEW entry
|
|
||||||
it.copy(showAddLogDialog = true)
|
it.copy(showAddLogDialog = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import kotlinx.coroutines.launch
|
|||||||
import net.tinsae.clocked.data.Locale
|
import net.tinsae.clocked.data.Locale
|
||||||
import net.tinsae.clocked.data.LogRepository
|
import net.tinsae.clocked.data.LogRepository
|
||||||
import net.tinsae.clocked.data.Theme
|
import net.tinsae.clocked.data.Theme
|
||||||
import net.tinsae.clocked.util.CsvExporter
|
import net.tinsae.clocked.util.ApacheExcel
|
||||||
|
import net.tinsae.clocked.util.ExcelContent
|
||||||
|
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val defaultDuration: String = "8h",
|
val defaultDuration: String = "8h",
|
||||||
@@ -22,8 +23,8 @@ data class SettingsUiState(
|
|||||||
val appVersion: String = "1.0.0",
|
val appVersion: String = "1.0.0",
|
||||||
val showThemeDialog: Boolean = false,
|
val showThemeDialog: Boolean = false,
|
||||||
val showLocaleDialog: Boolean = false,
|
val showLocaleDialog: Boolean = false,
|
||||||
// Hold the content of the CSV to be saved. Null means no save operation is pending.
|
// Null means no save operation is pending.
|
||||||
val pendingCsvContent: String? = null
|
val pendingCsvContent: ExcelContent? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel : ViewModel() {
|
class SettingsViewModel : ViewModel() {
|
||||||
@@ -57,21 +58,14 @@ class SettingsViewModel : ViewModel() {
|
|||||||
fun onExportClicked() {
|
fun onExportClicked() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// 1. Fetch data from the service (suspend call)
|
val logs = ApacheExcel.exportLogsToExcel(LogRepository.logs.value)
|
||||||
val logs = LogRepository.logs
|
val csvContent = ExcelContent(logs)
|
||||||
|
|
||||||
// 2. Pass the fetched data to the exporter to generate the CSV string
|
|
||||||
val csvContent = CsvExporter.exportLogsToCsv(logs.value)
|
|
||||||
|
|
||||||
// 3. Update the UI state with the generated content.
|
|
||||||
// MainActivity is already observing this and will trigger the file save.
|
// MainActivity is already observing this and will trigger the file save.
|
||||||
_uiState.update { it.copy(pendingCsvContent = csvContent) }
|
_uiState.update { it.copy(pendingCsvContent = csvContent) }
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Handle any errors during fetching or exporting
|
|
||||||
Log.e("SettingsViewModel", "Failed to export CSV", e)
|
Log.e("SettingsViewModel", "Failed to export CSV", e)
|
||||||
// Optionally show an error to the user
|
|
||||||
// _uiState.update { it.copy(error = "Export failed.") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
* @return A formatted date string.
|
||||||
*/
|
*/
|
||||||
fun formatTimestampToLocalDateString(instant: Instant): String {
|
fun formatTimestampToLocalDateString(instant: Instant): String {
|
||||||
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
|
||||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
|
||||||
// 2. Extract the components and build the string manually.
|
|
||||||
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
||||||
val day = localDateTime.day
|
val day = localDateTime.day
|
||||||
val year = localDateTime.year
|
val year = localDateTime.year
|
||||||
@@ -62,10 +61,9 @@ object Util {
|
|||||||
* @return A formatted month and year string.
|
* @return A formatted month and year string.
|
||||||
*/
|
*/
|
||||||
fun formatTimestampToMonthYear(instant: Instant): String {
|
fun formatTimestampToMonthYear(instant: Instant): String {
|
||||||
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
|
||||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
|
||||||
// 2. Extract the components and build the string.
|
|
||||||
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
||||||
val year = localDateTime.year
|
val year = localDateTime.year
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.2"
|
agp = "8.13.2"
|
||||||
|
biometric = "1.1.0"
|
||||||
|
fastexcel = "0.20.0"
|
||||||
|
jacksonCore = "2.21.1"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
@@ -7,6 +10,7 @@ junitVersion = "1.3.0"
|
|||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
kotlinxDatetime = "0.7.1"
|
kotlinxDatetime = "0.7.1"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
|
ktorClientOkhttp = "3.4.1"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.2"
|
activityCompose = "1.12.2"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2025.12.01"
|
||||||
@@ -15,14 +19,18 @@ appcompat = "1.7.1"
|
|||||||
desugarJdkLibs = "2.1.5"
|
desugarJdkLibs = "2.1.5"
|
||||||
material3WindowSizeClass = "1.4.0"
|
material3WindowSizeClass = "1.4.0"
|
||||||
materialIconsExtended = "1.7.8"
|
materialIconsExtended = "1.7.8"
|
||||||
|
poiOoxml = "5.5.1"
|
||||||
supabase = "3.2.6"
|
supabase = "3.2.6"
|
||||||
ktor = "3.3.3"
|
ktor = "3.3.3"
|
||||||
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
|
||||||
androidx-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" }
|
androidx-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" }
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
fastexcel = { module = "org.dhatim:fastexcel", version.ref = "fastexcel" }
|
||||||
|
jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jacksonCore" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
@@ -44,6 +52,8 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
|
|||||||
# supabase
|
# supabase
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientOkhttp" }
|
||||||
|
poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poiOoxml" }
|
||||||
supabase-bom = { group = "io.github.jan-tennert.supabase", name = "bom", version.ref = "supabase" }
|
supabase-bom = { group = "io.github.jan-tennert.supabase", name = "bom", version.ref = "supabase" }
|
||||||
supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" }
|
supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" }
|
||||||
supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" }
|
supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" }
|
||||||
|
|||||||
Reference in New Issue
Block a user