integration supabas

This commit is contained in:
2025-12-28 10:14:19 +01:00
parent 3a37eac107
commit 36d5fc5ce9
29 changed files with 1944 additions and 363 deletions

View File

@@ -1,7 +1,17 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
// Read properties from local.properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) }
}
android {
@@ -18,6 +28,10 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Expose Supabase keys to the app via BuildConfig
buildConfigField("String", "SUPABASE_URL", "\"${localProperties.getProperty("supabase.url")}\"")
buildConfigField("String", "SUPABASE_ANON_KEY", "\"${localProperties.getProperty("supabase.anon.key")}\"")
}
buildTypes {
@@ -41,6 +55,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true // Enable BuildConfig generation
}
}
@@ -58,6 +73,21 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.androidx.compose.material3.window.size.class1)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.appcompat)
// Supabase
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
// --- SUPABASE DEPENDENCIES (Corrected) ---
// 1. Implement the BOM using the platform() keyword.
implementation(platform(libs.supabase.bom))
// 2. Implement the specific Supabase modules using the correct aliases.
implementation(libs.supabase.auth)
implementation(libs.supabase.postgrest)
implementation(libs.ktor.client.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="true"

View File

@@ -0,0 +1,111 @@
package net.tinsae.clocked
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.jan.supabase.auth.OtpType
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email
import io.github.jan.supabase.auth.status.SessionStatus
import io.github.jan.supabase.exceptions.BadRequestRestException
import io.github.jan.supabase.exceptions.RestException
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
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 AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val formType: FormType = FormType.LOGIN,
val signupSuccess: Boolean = false,
)
class AuthViewModel : ViewModel() {
// --- State from LoginViewModel is now here ---
var uiState by mutableStateOf(AuthUiState())
private set
val sessionStatus: StateFlow<SessionStatus> = SupabaseClient.client.auth.sessionStatus
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
// Initialize with the client's current status, which will be .Initializing on cold start
initialValue = SupabaseClient.client.auth.sessionStatus.value
)
fun login(email: String, password: String) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
SupabaseClient.client.auth.signInWith(Email) {
this.email = email
this.password = password
}
// No need to emit a success event. The isAuthenticated flow will automatically update.
} catch (e: Exception) {
uiState = uiState.copy(error = parseError(e)) //e.message ?: "An unknown error occurred")
} finally {
uiState = uiState.copy(isLoading = false)
}
}
}
fun signUp(name: String, email: String, password: String) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
SupabaseClient.client.auth.signUpWith(Email) {
this.email = email
this.password = password
this.data = buildJsonObject {
put("full_name", name)
}
}
uiState = uiState.copy(signupSuccess = true)
} catch (e: Exception) {
uiState = uiState.copy(error = parseError(e))
} finally {
uiState = uiState.copy(isLoading = false)
}
}
}
fun setFormType(type: FormType) {
uiState = uiState.copy(formType = type, error = null)
}
fun logout() {
viewModelScope.launch {
SupabaseClient.client.auth.signOut()
}
}
// parse error message from exception
private fun parseError(e: Exception): String {
val defaultError = "An unknown error occurred. Please try again."
val message = e.message ?: return defaultError
Log.w("AuthenticatorViewModel: ", e.message ?: "SOMETHING HAPPENED!!!!!!!!!!!!!!!!!!!!!!!!!!!")
return when (e) {
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."
}
// For any other unexpected exception
else -> defaultError
}
}
}

View File

@@ -1,86 +1,43 @@
package net.tinsae.clocked
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.tinsae.clocked.anonymous.RegistrationForm
import net.tinsae.clocked.anonymous.SignInForm
import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.ui.theme.ClockedTheme
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit, modifier: Modifier = Modifier) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
fun LoginScreen(viewModel: AuthViewModel) {
val modifier = Modifier.fillMaxWidth()
val uiState = viewModel.uiState
if (uiState.formType == FormType.LOGIN){
SignInForm(
modifier = modifier,
uiState = uiState,
onLogin = viewModel::login,
onTogleForm = viewModel::setFormType
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
// 1. Use imePadding() to automatically handle keyboard insets.
// 2. Add verticalScroll to allow scrolling on small screens.
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.imePadding() // Automatically adds padding when the keyboard is open
.verticalScroll(rememberScrollState()) // Allows scrolling if content is too large
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Welcome to Clocked",
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true
}else{
RegistrationForm(
modifier = modifier,
uiState = uiState,
onRegister = viewModel::signUp,
onTogleForm = viewModel::setFormType
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onLoginSuccess,
modifier = Modifier.fillMaxWidth()
) {
Text("Log In")
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun LoginScreenPreview() {
fun LoginScreenPreview() {
ClockedTheme {
LoginScreen(onLoginSuccess = {})
LoginScreen(AuthViewModel())
}
}

View File

@@ -1,15 +1,26 @@
package net.tinsae.clocked
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
@@ -17,14 +28,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -32,139 +47,216 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.tinsae.clocked.dashboard.DashboardScreen
import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.Theme
import net.tinsae.clocked.history.HistoryScreen
import net.tinsae.clocked.settings.SettingsScreen
import net.tinsae.clocked.settings.SettingsViewModel
import net.tinsae.clocked.ui.theme.ClockedTheme
import java.text.SimpleDateFormat
import java.util.Date
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
import io.github.jan.supabase.auth.status.SessionStatus
import net.tinsae.clocked.data.LogRepository
class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
private val authViewModel: AuthViewModel by viewModels()
private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("text/csv")
) { uri: Uri? ->
uri?.let {
val content = settingsViewModel.uiState.value.pendingCsvContent
if (content != null) {
try {
contentResolver.openOutputStream(it)?.use { outputStream ->
outputStream.writer().use { writer ->
writer.write(content)
}
}
} catch (e: Exception) {
Log.e("MainActivity", "Error writing to file", e)
}
}
}
// Always reset the state
settingsViewModel.onExportHandled()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
settingsViewModel.uiState
.map { it.pendingCsvContent }
.distinctUntilChanged()
.collect { csvContent ->
if (csvContent != null) {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US).format(Date())
createDocumentLauncher.launch("Clocked-Export-$timestamp.csv")
}
}
}
enableEdgeToEdge()
setContent {
ClockedTheme {
// The root of the app now decides whether to show Login or Main content
AppEntry()
}
// The single ViewModel instances from the Activity are passed down.
AppEntry(settingsViewModel, authViewModel)
}
}
}
@Composable
fun AppEntry() {
// For now, we'll fake authentication. In a real app, this would come from a ViewModel or repository.
var isAuthenticated by rememberSaveable { mutableStateOf(true) } // Start as not authenticated
fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
val settingsState by settingsViewModel.uiState.collectAsState()
val sessionStatus by authViewModel.sessionStatus.collectAsState()
if (isAuthenticated) {
// If authenticated, show the main app UI
ClockedApp()
val useDarkTheme = when (settingsState.theme) {
Theme.LIGHT -> false
Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme()
}
val context = LocalContext.current
updateLocale(context, settingsState.locale)
ClockedTheme(darkTheme = useDarkTheme) {
// Use a 'when' statement to handle all possible states.
when (sessionStatus) {
is SessionStatus.Authenticated -> {
// If we know the user is authenticated, show the main app.
ClockedApp(settingsViewModel, authViewModel,sessionStatus)
}
is SessionStatus.NotAuthenticated -> {
// Only if we are certain the user is not logged in, show the login screen.
LoginScreen(authViewModel)
}
is SessionStatus.Initializing -> {
// While Supabase is loading the session, show a loading indicator.
// This prevents the flicker.
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {}
}
}
}
fun updateLocale(context: Context, locale: Locale) {
val localeTag = locale.tag.ifEmpty { null }
val localeList = if (localeTag != null) {
LocaleListCompat.forLanguageTags(localeTag)
} else {
// If not, show the Login screen
LoginScreen(onLoginSuccess = { })
LocaleListCompat.getEmptyLocaleList()
}
AppCompatDelegate.setApplicationLocales(localeList)
}
@PreviewScreenSizes
@Composable
fun ClockedApp() {
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel, sessionStatus: SessionStatus) {
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
Scaffold(
modifier = Modifier.fillMaxSize(), // Removed background for clarity
modifier = Modifier.fillMaxSize(),
topBar = {
Box(
Row(
modifier = Modifier
.fillMaxWidth()
// Apply padding ONLY for the top safe area (status bar)
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues())
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.CenterStart
.background(colorScheme.surface)
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()),
//contentAlignment = Alignment.CenterStart
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
style = typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
// 2. Use IconButton to make the icon clickable with a ripple effect
IconButton(onClick = { authViewModel.logout() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout, // Replace with your logout icon
contentDescription = "Logout"
)
}
}
},
) { innerPadding -> // This padding from the parent Scaffold accounts for the TopBar.
//val layoutDirection = LocalLayoutDirection.current
) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
val contentPadding = PaddingValues(
// Respect the horizontal padding for gestures, etc.
//start = innerPadding.calculateStartPadding(layoutDirection),
//end = innerPadding.calculateEndPadding(layoutDirection),
// Respect the top padding to stay below the top bar.
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection),
top = innerPadding.calculateTopPadding(),
// CRITICAL: Ignore the bottom padding from the parent Scaffold.
bottom = 0.dp
)
NavigationSuiteScaffold(
// Apply our custom-calculated padding.
modifier = Modifier.padding(contentPadding),
navigationSuiteItems = {
AppDestinations.entries.forEach {
AppDestinations.entries.forEach { destination ->
item(
icon = { Icon(it.icon, contentDescription = it.label) },
label = { Text(it.label) },
selected = it == currentDestination,
onClick = { currentDestination = it }
icon = { Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label))
},
label = { Text(stringResource(destination.label)) },
selected = destination == currentDestination,
onClick = { currentDestination = destination }
)
}
}
) {
// The router now shows the correct screen based on the destination.
when (currentDestination) {
AppDestinations.HOME -> DashboardScreen( modifier = Modifier.fillMaxSize())
AppDestinations.HISTORY -> HistoryScreen(modifier = Modifier.fillMaxSize())
AppDestinations.PROFILE -> Greeting(name = "Profile", modifier = Modifier.fillMaxSize())
}
}
}
}
enum class AppDestinations(
val label: String,
val icon: ImageVector,
) {
HOME("Home", Icons.Default.Home),
HISTORY("History", Icons.AutoMirrored.Filled.List),
PROFILE("Profile", Icons.Default.AccountBox),
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Scaffold(modifier = modifier) { innerPadding ->
Text(
text = "Hello $name!",
modifier = Modifier.padding(innerPadding)
AppDestinations.SETTING -> SettingsScreen(
modifier = Modifier.fillMaxSize(),
viewModel = settingsViewModel,
authViewModel = authViewModel
)
}
}
/*
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ClockedTheme {
Greeting("Android")
}
}
*/
// --- PREVIEW THE ACTUAL APP ENTRY POINT ---
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),
SETTING(R.string.nav_settings, Icons.Default.Settings),
}
@Preview(showBackground = true)
@Composable
fun AppEntryPreview() {
ClockedTheme {
// This preview will correctly show the LoginScreen because isAuthenticated starts as false.
AppEntry()
AppEntry(SettingsViewModel(), AuthViewModel())
}
}

View File

@@ -0,0 +1,150 @@
package net.tinsae.clocked.anonymous
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.tinsae.clocked.AuthUiState
import net.tinsae.clocked.R
import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.ui.theme.ClockedTheme
@Composable
fun RegistrationForm(
modifier: Modifier = Modifier,
uiState: AuthUiState,
onRegister: (String, String, String) -> Unit,
onTogleForm: (FormType) -> Unit
) {
var name by rememberSaveable { mutableStateOf("") }
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.login_welcome),
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Please register with email and password"
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Full name") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
isError = uiState.error != null
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
isError = uiState.error != null
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
isError = uiState.error != null
)
uiState.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { onRegister(name,email, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary)
} else {
Text(stringResource(R.string.signup_button))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text("Have an account?")
TextButton(onClick = { onTogleForm(FormType.LOGIN) }) {
Text("Sign in", color = Color.Blue)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun RegisterPreview(){
ClockedTheme {
RegistrationForm(
uiState = AuthUiState(),
onRegister = { _, _, _ -> },
onTogleForm = { _ ->}
)
}
}

View File

@@ -0,0 +1,166 @@
package net.tinsae.clocked.anonymous
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.tinsae.clocked.AuthUiState
import net.tinsae.clocked.R
import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.ui.theme.ClockedTheme
import net.tinsae.clocked.ui.theme.red
@Composable
fun SignInForm(
modifier: Modifier = Modifier,
uiState: AuthUiState,
onLogin: (String, String) -> Unit,
onTogleForm: (FormType) -> Unit
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.login_welcome),
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
isError = uiState.error != null
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
isError = uiState.error != null
)
uiState.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = red)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { onLogin(email, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary)
} else {
Text(stringResource(R.string.login_button))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text("No account yet?")
TextButton(onClick = { onTogleForm(FormType.SIGNUP) }) {
Text(stringResource(R.string.signup_button), color = Color.Blue)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun LoginScreenNormalPreview() {
ClockedTheme {
SignInForm(
uiState = AuthUiState(),
onLogin = { _, _ -> },
onTogleForm = { _ ->}
)
}
}
@Preview(showBackground = true)
@Composable
private fun LoginScreenPreview() {
ClockedTheme {
SignInForm (
uiState = AuthUiState(error = "This is a sample error message"),
onLogin = { _, _ -> },
onTogleForm = { _ ->}
)
}
}
@Preview(showBackground = true)
@Composable
private fun LoginScreenLoadingPreview() {
ClockedTheme {
SignInForm(
uiState = AuthUiState(isLoading = true),
onLogin = { _, _ -> },
onTogleForm = { _ ->}
)
}
}

View File

@@ -4,7 +4,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,15 +17,14 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -36,18 +34,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration // Use stable LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.launch
import net.tinsae.clocked.R
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.service.deleteLog
import net.tinsae.clocked.service.editLog
import net.tinsae.clocked.service.getRecentLogs
import net.tinsae.clocked.ui.theme.cyan
import net.tinsae.clocked.ui.theme.red
import net.tinsae.clocked.util.Util.formatDuration
@@ -56,30 +51,28 @@ import kotlin.math.roundToInt
@Composable
fun ActivityList(
items: List<Log>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onEditLog: (Log) -> Unit = {},
onDeleteLog: (Log) -> Unit = {},
recentLogs: List<Log>
) {
var mutableItems by remember { mutableStateOf(items) }
//var mutableItems by remember { mutableStateOf(items) }
// State to hold the log that should be shown in the dialog. Null means no dialog.
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Recent Activity",
text = stringResource(R.string.recent_activity),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(mutableItems, key = { it.id }) { log ->
items(recentLogs, key = { it.id }) { log ->
ActivityItem(
log = log,
onEdit = { editLog(log) },
onDelete = {
if (deleteLog(log)) {
mutableItems = mutableItems.filter { it.id != log.id }
}
},
onEdit = { onEditLog(log) },
onDelete = {onDeleteLog(log) },
// When the item is clicked, set it as the selected log for the dialog
onClick = { selectedLogForDialog = log }
)
@@ -105,13 +98,13 @@ fun ActivityItem(
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
var offsetX by remember { mutableStateOf(0f) }
var offsetX by remember { mutableFloatStateOf(0f) }
val animatedOffsetX by animateFloatAsState(targetValue = offsetX, label = "offset")
// Use stable LocalConfiguration
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
val screenWidthPx = LocalWindowInfo.current.containerSize.width
// Define thresholds for swiping
val editThreshold = screenWidthPx * 0.4f
val deleteThreshold = -screenWidthPx * 0.4f
@@ -132,7 +125,7 @@ fun ActivityItem(
if (animatedOffsetX > 0) { // Swiping right
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
contentDescription = stringResource(R.string.edit),
tint = Color.White
)
}
@@ -140,7 +133,7 @@ fun ActivityItem(
if (animatedOffsetX < 0) { // Swiping left
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = stringResource(R.string.delete),
tint = Color.White
)
}
@@ -201,5 +194,5 @@ fun ActivityItem(
@Composable
@Preview(showBackground = true)
fun ActivityItemPreview() {
ActivityList(items = getRecentLogs())
ActivityList(recentLogs = emptyList(), onEditLog = {_->{}}, onDeleteLog = {_->{}})
}

View File

@@ -0,0 +1,189 @@
package net.tinsae.clocked.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.tinsae.clocked.R
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.ui.theme.ClockedTheme
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLogDialog(
type: EntryType,
onDismiss: () -> Unit,
onSave: (timestamp: Instant, duration: Duration, reason: String?) -> Unit
) {
val sheetState = rememberModalBottomSheetState()
var hours by remember { mutableStateOf("") }
var minutes by remember { mutableStateOf("") }
var reason by remember { mutableStateOf("") }
var selectedInstant by remember { mutableStateOf(Clock.System.now()) }
var showDatePicker by remember { mutableStateOf(false) }
// Custom date formatting logic using kotlinx-datetime
val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}"
if (showDatePicker) {
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds())
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
selectedInstant = Instant.fromEpochMilliseconds(millis)
}
showDatePicker = false
}) {
Text(stringResource(id = R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(id = R.string.cancel))
}
}
) {
DatePicker(state = datePickerState)
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = if (type == EntryType.OVERTIME) stringResource(id = R.string.add_overtime_title) else stringResource(id = R.string.add_time_off_title),
style = MaterialTheme.typography.titleLarge
)
// Date Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.date), style = MaterialTheme.typography.bodyLarge)
TextButton(
onClick = { showDatePicker = true },
) {
Text(formattedDate,
style = MaterialTheme.typography.bodyLarge)
}
}
// Duration Row
Text(
text = stringResource(id = R.string.duration),
style = MaterialTheme.typography.bodyLarge,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = hours,
onValueChange = { hours = it.filter(Char::isDigit) },
label = { Text(stringResource(id = R.string.hours)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.5f)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = minutes,
onValueChange = {
val filtered = it.filter(Char::isDigit)
if (filtered.isEmpty() || filtered.toInt() < 60) {
minutes = filtered
}
},
label = { Text(stringResource(id = R.string.minutes)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.5f)
)
}
// Reason Field
OutlinedTextField(
value = reason,
onValueChange = { reason = it },
label = { Text(stringResource(id = R.string.reason)) },
modifier = Modifier.fillMaxWidth()
)
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(id = R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val h = hours.toLongOrNull() ?: 0L
val m = minutes.toLongOrNull() ?: 0L
val totalMinutes = if (type == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m)
val duration = totalMinutes.minutes
onSave(selectedInstant, duration, reason.takeIf(String::isNotBlank))
}
) {
Text(stringResource(id = R.string.save))
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun AddLogDialogPreview(){
ClockedTheme {
AddLogDialog(
type = EntryType.OVERTIME,
onDismiss = {},
onSave = { _, _, _ -> }
)
}
}

View File

@@ -14,11 +14,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import net.tinsae.clocked.R
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.util.Util.formatDuration
import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString
@@ -35,23 +38,30 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) {
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "Log Details", style = MaterialTheme.typography.titleLarge)
Text(text = stringResource(R.string.details_title), style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
DetailRow("Date:", formatTimestampToLocalDateString(log.timestamp))
DetailRow("Type:", log.type.toString())
DetailRow("Duration:", formatDuration(log.duration))
DetailRow("Reason:", log.reason ?: "No reason provided")
DetailRow(stringResource(R.string.date), formatTimestampToLocalDateString(log.timestamp))
DetailRow(stringResource(R.string.type), log.type.toString())
DetailRow(stringResource(R.string.duration), formatDuration(log.duration))
DetailRow(stringResource(R.string.reason), log.reason ?: stringResource(R.string.details_no_reason))
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("Close")
Text(stringResource(R.string.close))
}
}
}
}
@Composable
private fun getType(type: EntryType):String{
return if(type==EntryType.OVERTIME) stringResource(R.string.overtime) else stringResource(R.string.time_off)
}
@Composable
private fun DetailRow(label: String, value: String) {
// buildAnnotatedString allows mixing different styles in one Text composable.

View File

@@ -15,17 +15,24 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.github.jan.supabase.auth.status.SessionStatus
import net.tinsae.clocked.R
import net.tinsae.clocked.components.ActivityList
import net.tinsae.clocked.components.AddLogDialog
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.ui.theme.ClockedTheme
import net.tinsae.clocked.ui.theme.cyan
import net.tinsae.clocked.ui.theme.green
@@ -38,6 +45,18 @@ fun DashboardScreen(
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
LogRepository.fetchLogs()
}
if (uiState.showAddLogDialog) {
AddLogDialog(
type = uiState.dialogType,
onDismiss = viewModel::onDismissDialog,
onSave = viewModel::onSaveLog
)
}
Column(
modifier = modifier
.fillMaxSize()
@@ -47,14 +66,14 @@ fun DashboardScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
SummaryCard(title = "Overtime", value = uiState.overtime, color = green, modifier = Modifier.weight(1f))
SummaryCard(title = stringResource(id = R.string.overtime), value = uiState.overtime, color = green, modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(16.dp))
SummaryCard(title = "Time Off", value = uiState.timeOff, color = red, modifier = Modifier.weight(1f))
SummaryCard(title = stringResource(id = R.string.time_off), value = uiState.timeOff, color = red, modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(16.dp))
NetBalanceCard(value = uiState.netBalance)
NetBalanceCard(value = uiState.netBalance+" / "+uiState.balanceInDays, modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(16.dp))
@@ -62,16 +81,26 @@ fun DashboardScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
ActionButton(text = "+ Overtime", modifier = Modifier.weight(1f))
ActionButton(
text = stringResource(id = R.string.add_overtime),
onClick = { viewModel.onAddLogClicked(EntryType.OVERTIME) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
ActionButton(text = " Time Off", modifier = Modifier.weight(1f))
ActionButton(
text = stringResource(id = R.string.add_time_off),
onClick = { viewModel.onAddLogClicked(EntryType.TIME_OFF) },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
ActivityList(
items = uiState.recentActivities,
modifier = Modifier.weight(1f)
recentLogs = uiState.recentActivities,
modifier = Modifier.weight(1f),
onEditLog = viewModel::editLog,
onDeleteLog = viewModel::deleteLog
)
}
}
@@ -106,7 +135,7 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Net Balance", style = MaterialTheme. typography.titleLarge)
Text(text = stringResource(id = R.string.net_balance), style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text(text = value, style = MaterialTheme.typography.headlineLarge, color = cyan, fontWeight = FontWeight.Bold)
}
@@ -114,10 +143,11 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
}
@Composable
fun ActionButton(text: String, modifier: Modifier = Modifier) {
fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = { /*TODO*/ },
modifier = modifier.fillMaxWidth() ) {
onClick = onClick,
modifier = modifier.fillMaxWidth()
) {
Text(text = text, fontSize = 16.sp, modifier = Modifier.padding(6.dp))
}
}
@@ -126,6 +156,6 @@ fun ActionButton(text: String, modifier: Modifier = Modifier) {
@Composable
fun DashboardScreenPreview() {
ClockedTheme {
DashboardScreen(viewModel = DashboardViewModel())
//DashboardScreen(viewModel = DashboardViewModel())
}
}

View File

@@ -1,43 +1,113 @@
package net.tinsae.clocked.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.service.getRecentLogs
import net.tinsae.clocked.service.getTotalOvertimeDuration
import net.tinsae.clocked.service.getTotalTimeOffDuration
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.util.Util.formatDuration
import kotlin.time.Duration
import kotlin.time.Instant
data class DashboardUiState(
val overtime: String = "0m",
val timeOff: String = "0m",
val netBalance: String = "0m",
val recentActivities: List<Log> = emptyList()
val balanceInDays: String = "0d",
val recentActivities: List<Log> = emptyList(),
val showAddLogDialog: Boolean = false,
val dialogType: EntryType = EntryType.OVERTIME // Default, will be updated
)
class DashboardViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState
// This is the state holder for UI-driven events (e.g., showing a dialog)
private val _internalState = MutableStateFlow(DashboardUiState())
init {
loadDashboardData()
}
val uiState: StateFlow<DashboardUiState> = combine(
LogRepository.logs, // Source 1: The List<Log>
_internalState // Source 2: The internal UI state
) { logs, internalState ->
// THE FIX: The 'logs' parameter is already the List<Log>.
// You do not need to call .first() on it.
val allLogs = logs
private fun loadDashboardData() {
val recentLogs = getRecentLogs()
val overtimeDuration = allLogs.filter{ it.type == EntryType.OVERTIME }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
val overtimeDuration = getTotalOvertimeDuration()
val timeOffDuration = getTotalTimeOffDuration()
val timeOffDuration = allLogs.filter { it.type == EntryType.TIME_OFF }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
// timeOffDuration is negative, so we add it to get the difference
val netBalanceDuration = overtimeDuration.plus(timeOffDuration)
// Construct the final state using data from both sources.
val netBalanceDuration = overtimeDuration + timeOffDuration
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
_uiState.value = DashboardUiState(
// 2. Then, calculate the balance in days (assuming an 8-hour workday)
// We format it to one decimal place for a clean look.
val balanceInDaysValue = netBalanceInHours / 8.0
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " days"
DashboardUiState(
overtime = formatDuration(overtimeDuration),
timeOff = formatDuration(timeOffDuration),
netBalance = formatDuration(netBalanceDuration),
recentActivities = recentLogs
netBalance = formatDuration(overtimeDuration + timeOffDuration),
recentActivities = allLogs.take(7),
balanceInDays = balanceInDaysString,
// Pass through the dialog state from the internal state holder
showAddLogDialog = internalState.showAddLogDialog,
dialogType = internalState.dialogType
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
)
fun onAddLogClicked(type: EntryType) {
// This correctly updates the internal state, which triggers the 'combine' to re-run.
_internalState.update { it.copy(showAddLogDialog = true, dialogType = type) }
}
fun onDismissDialog() {
_internalState.update { it.copy(showAddLogDialog = false) }
}
fun onSaveLog(timestamp: Instant, duration: Duration, reason: String?) {
viewModelScope.launch {
try {
LogRepository.addLog(
// Use the dialogType from the internal state's current value
type = _internalState.value.dialogType,
timestamp = timestamp,
duration = duration,
reason = reason
)
// On success, hide the dialog. The repository will trigger the data refresh.
onDismissDialog()
} catch (e: Exception) {
// Handle errors
android.util.Log.e("DashboardViewModel", "Failed to save log", e)
}
}
}
fun editLog(log: Log) {
LogRepository.editLog(log)
}
fun deleteLog(log: Log) {
LogRepository.deleteLog(log)
}
}

View File

@@ -0,0 +1,41 @@
package net.tinsae.clocked.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
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
object IntervalSerialiser : KSerializer<Duration> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING)
// Serialization (sending data TO database) remains the same.
override fun serialize(encoder: Encoder, value: Duration) {
val totalSeconds = value.inWholeSeconds
encoder.encodeString("$totalSeconds seconds")
}
// Deserialization (receiving data FROM database) is now custom.
override fun deserialize(decoder: Decoder): Duration {
val timeString = decoder.decodeString()
// Handle negative intervals from Postgres, which might look like "-01:30:00"
val isNegative = timeString.startsWith('-')
val absTimeString = if (isNegative) timeString.substring(1) else timeString
val parts = absTimeString.split(':').map { it.toDoubleOrNull() ?: 0.0 }
if (parts.size == 3) {
val (h, m, s) = parts
val totalSeconds = h * 3600 + m * 60 + s
return if (isNegative) (-totalSeconds).seconds else totalSeconds.seconds
}
return Duration.ZERO
}
}

View File

@@ -0,0 +1,19 @@
package net.tinsae.clocked.data
import androidx.compose.runtime.Composable
enum class Locale(val tag: String) {
SYSTEM(""),
ENGLISH("en"),
SPANISH("es"),
FRENCH("fr");
val title:String
@Composable
get() = when (this) {
SYSTEM -> "System"
ENGLISH -> "English"
SPANISH -> "Español"
FRENCH -> "Français"
}
}

View File

@@ -1,16 +1,35 @@
package net.tinsae.clocked.data
import java.time.Duration
import java.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.time.Duration
@Serializable
data class Log(
val id: Int,
val duration: Duration,
val reason: String?,
val id: Long,
val timestamp: Instant,
val type: EntryType,
val timestamp: Instant
@Serializable(with = IntervalSerialiser::class)
val duration: Duration,
val reason: String? = null,
@SerialName("user_id")
val userId: String? = null // This will be populated by Supabase
)
@Serializable
data class LogEntry(
val timestamp: Instant,
val type: EntryType,
@Serializable(with = IntervalSerialiser::class) // Can reuse the serializer
val duration: Duration,
val reason: String? = null
)
@Serializable
enum class EntryType {
OVERTIME, TIME_OFF
}
enum class FormType {
LOGIN, SIGNUP
}

View File

@@ -0,0 +1,83 @@
package net.tinsae.clocked.data
import android.util.Log as LOG
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.tinsae.clocked.service.*
import kotlin.time.Duration
import kotlin.time.Instant
/**
* A Singleton Repository that acts as the Single Source of Truth for Log data.
* It fetches data from the service and caches it, providing a Flow for ViewModels to observe.
*/
object LogRepository {
// A private mutable flow to hold the list of logs.
private val _logs = MutableStateFlow<List<Log>>(emptyList())
// A public, read-only StateFlow that ViewModels can collect.
val logs = _logs.asStateFlow()
private val repositoryScope = CoroutineScope(Dispatchers.IO)
init {
// Fetch initial data when the repository is first created.
fetchLogs()
}
/**
* Fetches the latest logs from the service and updates the flow.
*/
fun fetchLogs() {
repositoryScope.launch {
try {
val latestLogs = getAllLogsFromDB()
_logs.update { latestLogs }
LOG.d("LogRepository", "Successfully fetched ${latestLogs.size} logs.")
} catch (e: Exception) {
LOG.e("LogRepository", "Failed to fetch logs", e)
// Optionally, you could expose an error state here.
}
}
}
/**
* Adds a new log via the service and then triggers a refetch to update the flow.
*/
fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
repositoryScope.launch {
try {
addLogToDB(type, timestamp, duration, reason)
// After adding, refetch the entire list to ensure consistency.
fetchLogs()
} catch (e: Exception) {
LOG.e("LogRepository", "Failed to add log", e)
}
}
}
fun deleteLog(log: Log) {
repositoryScope.launch {
try {
deleteLogFromDB(log)
// After deleting, refetch the entire list to ensure consistency.
fetchLogs()
} catch (e: Exception) {
LOG.e("LogRepository", "Failed to delete log", e)
}
}
}
fun editLog(log: Log) {
repositoryScope.launch {
editLogFromDB(log)
fetchLogs()
}
}
}

View File

@@ -0,0 +1,20 @@
package net.tinsae.clocked.data
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import net.tinsae.clocked.BuildConfig
object SupabaseClient {
val client by lazy {
createSupabaseClient(
supabaseUrl = BuildConfig.SUPABASE_URL,
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
) {
install(Auth)
install(Postgrest)
}
}
}

View File

@@ -0,0 +1,19 @@
package net.tinsae.clocked.data
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import net.tinsae.clocked.R
enum class Theme {
SYSTEM,
LIGHT,
DARK;
val title: String
@Composable
get() = when (this) {
SYSTEM -> "System"
LIGHT -> "Light"
DARK -> "Dark"
}
}

View File

@@ -1,5 +1,6 @@
package net.tinsae.clocked.history
import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
@@ -14,102 +15,78 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.R
import net.tinsae.clocked.components.ActivityItem
import net.tinsae.clocked.components.DetailsDialog
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.service.deleteLog
import net.tinsae.clocked.service.editLog
import net.tinsae.clocked.service.getAllLogs
import net.tinsae.clocked.service.getOvertimeLogs
import net.tinsae.clocked.service.getTimeOffLogs
import net.tinsae.clocked.ui.theme.ClockedTheme
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryScreen(modifier: Modifier = Modifier) {
var allEntries by remember { mutableStateOf(getAllLogs()) }
val overTimes by remember { mutableStateOf( getOvertimeLogs())}
val timeOffs by remember { mutableStateOf( getTimeOffLogs())}
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
fun HistoryScreen(
modifier: Modifier = Modifier,
viewModel: HistoryViewModel = viewModel()
) {
val tabTitles = listOf(
stringResource(id = R.string.all),
stringResource(id = R.string.overtime),
stringResource(id = R.string.time_off)
)
val tabs = listOf("All", "Overtime", "Time Off")
var selectedTabIndex by remember { mutableIntStateOf(0) }
// Filter the list based on the selected tab
// I want this done in service. so I have functions for them
val filteredEntries = remember(selectedTabIndex) {
when (tabs[selectedTabIndex]) {
"Overtime" -> overTimes
"Time Off" -> timeOffs
else -> allEntries
}
}
// Group the filtered entries by month and year
val groupedEntries = remember(filteredEntries) {
// SIMPLIFIED: No need to destructure a Pair
filteredEntries.groupBy { log ->
formatTimestampToMonthYear(log.timestamp)
}
LaunchedEffect(tabTitles) {
viewModel.setTabTitles(tabTitles)
}
val uiState: HistoryUiState by viewModel.uiState.collectAsState()
Column(
modifier = modifier
.fillMaxSize()
.fillMaxSize()
modifier = modifier.fillMaxSize()
) {
PrimaryTabRow (selectedTabIndex = selectedTabIndex) {
tabs.forEachIndexed { index, title ->
if (uiState.tabs.isNotEmpty()) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex) {
uiState.tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
selected = uiState.selectedTabIndex == index,
onClick = { viewModel.onTabSelected(index) },
text = { Text(title) }
)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
//.padding(horizontal = 16.dp)
modifier = Modifier.fillMaxWidth()
) {
// Iterate through the grouped map
groupedEntries.forEach { (monthYear, entries) ->
// Add a sticky header for the month
uiState.groupedEntries.forEach { (monthYear, entries) ->
stickyHeader {
MonthHeader(text = monthYear)
}
// Add the items for that month
items(entries, key = { it.id }) { entry ->
ActivityItem(log = entry,
ActivityItem(
log = entry,
modifier = Modifier.padding(horizontal = 16.dp),
onDelete = { deleteLog(entry) },
onEdit = { editLog(entry) },
onClick = { selectedLogForDialog = entry }
onDelete = { viewModel.deleteLog(entry) },
onEdit = { viewModel.editLog(entry) },
onClick = { viewModel.onLogSelected(entry) }
)
}
}
}
}
// When a log is selected, show the dialog.
selectedLogForDialog?.let { log ->
uiState.selectedLogForDialog?.let { log ->
DetailsDialog(
log = log,
onDismiss = { selectedLogForDialog = null }
onDismiss = { viewModel.onDismissDialog() }
)
}
}
@@ -119,7 +96,6 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
Surface(
modifier = modifier
.fillMaxWidth()
// Use the theme's background color to overlay content correctly
.background(MaterialTheme.colorScheme.background)
) {
Text(
@@ -131,11 +107,11 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun HistoryScreenPreview() {
ClockedTheme {
HistoryScreen()
HistoryScreen(modifier = Modifier.fillMaxSize())
}
}

View File

@@ -0,0 +1,93 @@
package net.tinsae.clocked.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
data class HistoryUiState(
val tabs: List<String> = emptyList(),
val selectedTabIndex: Int = 0,
val groupedEntries: Map<String, List<Log>> = emptyMap(),
val selectedLogForDialog: Log? = null,
val isLoading: Boolean = true // isLoading is true when the log list is empty
)
class HistoryViewModel : ViewModel() {
// This state now only holds things that are NOT derived from the main log list,
// like which tab is selected or which dialog is shown.
private val _internalState = MutableStateFlow(HistoryUiState())
// --- THE FIX IS HERE ---
val uiState: StateFlow<HistoryUiState> = combine(
LogRepository.logs, // Source 1: The list of all logs from the repository
_internalState // Source 2: The internal state (e.g., selected tab)
) { logs, internalState ->
val allLogs = logs // The 'logs' parameter is already the List<Log>
// Perform filtering based on the selected tab index from the internal state
val filteredEntries = when (internalState.selectedTabIndex) {
1 -> allLogs.filter { it.type == EntryType.OVERTIME }
2 -> allLogs.filter { it.type == EntryType.TIME_OFF }
else -> allLogs
}
// Perform grouping on the filtered list
val grouped = filteredEntries.groupBy { log ->
formatTimestampToMonthYear(log.timestamp)
}
// Construct the final UI state
HistoryUiState(
tabs = internalState.tabs,
selectedTabIndex = internalState.selectedTabIndex,
selectedLogForDialog = internalState.selectedLogForDialog,
groupedEntries = grouped,
isLoading = allLogs.isEmpty() // Show loading indicator if the log list is empty
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = HistoryUiState() // Start with a default empty state
)
fun setTabTitles(titles: List<String>) {
_internalState.update { it.copy(tabs = titles) }
}
fun onTabSelected(index: Int) {
// When a tab is selected, we only need to update the internal state.
// The 'combine' block will automatically re-run and do the filtering.
_internalState.update { it.copy(selectedTabIndex = index) }
}
fun onLogSelected(log: Log) {
_internalState.update { it.copy(selectedLogForDialog = log) }
}
fun onDismissDialog() {
_internalState.update { it.copy(selectedLogForDialog = null) }
}
fun deleteLog(log: Log) {
// Deleting and editing are "write" operations, they should talk to the repository.
LogRepository.deleteLog(log)
}
fun editLog(log: Log) {
LogRepository.editLog(log)
}
// The old filterAndGroupEntries() function is no longer needed,
// as its logic is now inside the 'combine' block.
// private fun filterAndGroupEntries() { ... }
}

View File

@@ -1,96 +1,90 @@
package net.tinsae.clocked.service
import androidx.compose.runtime.retain.retain
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import android.util.Log as LOG
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.random.Random
import net.tinsae.clocked.data.LogEntry
import net.tinsae.clocked.data.SupabaseClient
import kotlin.time.Instant
import kotlin.time.Duration
// --- Data for programmatic generation ---
private val overtimeReasons = listOf("Project deadline", "Production hotfix", "Client demo preparation", "Server maintenance", "Completed quarterly reports", "Feature implementation")
private val timeOffReasons = listOf("Left early", "Doctor's appointment", "Personal errand", "Family matter", "Vacation day - Completed quarterly reports")
// --- Public API for accessing and modifying logs --- //
// UPDATED: Values are now in ISO 8601 format, matching Supabase's INTERVAL output
private val overtimeValues = listOf("PT1H", "PT2H30M", "PT45M", "PT3H15M", "PT1H45M")
private val timeOffValues = listOf("-PT8H", "-PT4H", "-PT1H30M", "-PT2H")
suspend fun getAllLogsFromDB(): List<Log> {
try {
val logs = SupabaseClient.client.postgrest.from("Logs").select {
order("timestamp", Order.DESCENDING)
}.decodeList<Log>()
LOG.d("LogService", "Fetched ${logs.size} logs")
return logs
} catch (e: Exception) {
LOG.e("LogService", "Error fetching logs", e)
return emptyList()
}
}
// --- Programmatically generate a list of 30 random logs ---
private fun generateRandomLogs(): List<Log> {
val logs = mutableListOf<Log>()
for (i in 1..30) {
val randomDaysAgo = Random.nextLong(0, 365)
val timestamp = Instant.now().minus(randomDaysAgo, ChronoUnit.DAYS)
val isOvertime = Random.nextBoolean()
suspend fun getRecentLogsFromDB(): List<Log> {
return getAllLogsFromDB().take(5)
}
val log = if (isOvertime) {
Log(
id = i,
// Use Duration.parse() to convert the string to a Duration object
duration = Duration.parse(overtimeValues.random()),
reason = if (Random.nextBoolean()) overtimeReasons.random() else null,
type = EntryType.OVERTIME,
timestamp = timestamp
)
} else {
Log(
id = i,
// Use Duration.parse() here as well
duration = Duration.parse(timeOffValues.random()),
reason = if (Random.nextBoolean()) timeOffReasons.random() else null,
type = EntryType.TIME_OFF,
suspend fun addLogToDB(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
val newLog = LogEntry(
duration = duration,
reason = reason,
type = type,
timestamp = timestamp
)
try {
SupabaseClient.client.postgrest["Logs"].insert(newLog)
} catch (e: Exception) {
LOG.e("LogService", "Error adding log", e)
}
logs.add(log)
}
return logs
}
val logs: List<Log> = generateRandomLogs().sortedByDescending { it.timestamp }
fun getAllLogs(): List<Log> {
return logs
suspend fun deleteLogFromDB(log: Log): Boolean {
return try {
SupabaseClient.client.postgrest.from("Logs").delete {
filter {
eq("id", log.id)
}
}
true
} catch (e: Exception) {
LOG.e("LogService", "Error deleting log", e)
false
}
}
fun getOvertimeLogs(): List<Log> {
return logs.filter { it.type == EntryType.OVERTIME }
suspend fun editLogFromDB(updatedLog: Log): Boolean {
return try {
SupabaseClient.client.postgrest.from("Logs").update(
{
set("timestamp", updatedLog.timestamp)
set("type", updatedLog.type)
set("duration", updatedLog.duration)
set("reason", updatedLog.reason)
}
) {
filter {
eq("id", updatedLog.id)
}
}
true
} catch (e: Exception) {
LOG.e("LogService", "Error editing log", e)
false
}
}
fun getTimeOffLogs(): List<Log> {
return logs.filter { it.type == EntryType.TIME_OFF }
// --- Calculation functions that operate on a given list of logs ---
fun getTotalOvertimeDuration(logs: List<Log>): Duration {
return logs.filter { it.type == EntryType.OVERTIME }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
}
fun getRecentLogs(): List<Log> {
// This function can now be simplified as the main list is already sorted
// We'll just take the most recent 7 items.
return logs.take(7)
}
fun getTotalOvertimeDuration(): Duration {
return logs
.filter { it.type == EntryType.OVERTIME }
.map { it.duration }
.fold(Duration.ZERO, Duration::plus)
}
fun getTotalTimeOffDuration(): Duration {
return logs
.filter { it.type == EntryType.TIME_OFF }
.map { it.duration }
.fold(Duration.ZERO, Duration::plus)
}
fun deleteLog(log: Log): Boolean {
logs.filter { it.id != log.id }
LOG.d("TAG","Deleted log with ID: ${log.id}")
return logs.contains(log)
}
fun editLog(log: Log): Boolean{
LOG.d("TAG","Edited log with ID: ${log.id}")
return logs.contains(log)
fun getTotalTimeOffDuration(logs: List<Log>): Duration {
return logs.filter { it.type == EntryType.TIME_OFF }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
}

View File

@@ -0,0 +1,237 @@
package net.tinsae.clocked.settings
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.AuthViewModel
import net.tinsae.clocked.R
import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.Theme
import net.tinsae.clocked.ui.theme.ClockedTheme
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(),
authViewModel: AuthViewModel
) {
val uiState by viewModel.uiState.collectAsState()
if (uiState.showThemeDialog) {
ThemeSelectionDialog(
currentTheme = uiState.theme,
onThemeSelected = viewModel::onThemeSelected,
onDismiss = viewModel::onDismissThemeDialog
)
}
if (uiState.showLocaleDialog) {
LocaleSelectionDialog(
currentLocale = uiState.locale,
onLocaleSelected = viewModel::onLocaleSelected,
onDismiss = viewModel::onDismissLocaleDialog
)
}
Column(
modifier = modifier
.padding(16.dp)
) {
SettingsSection(title = stringResource(R.string.settings_section_data)) {
SettingsItem(
title = stringResource(R.string.settings_item_export_csv),
onClick = viewModel::onExportClicked,
trailingContent = {
Icon(
Icons.Filled.ChevronRight,
contentDescription = stringResource(R.string.settings_item_navigate)
)
}
)
}
Spacer(modifier = Modifier.height(24.dp))
SettingsSection(title = stringResource(R.string.settings_section_preferences)) {
SettingsItem(title = stringResource(R.string.settings_item_default_duration), value = uiState.defaultDuration, onClick = { /* TODO: Handle duration change */ })
SettingsItem(title = stringResource(R.string.settings_item_time_rounding), value = uiState.timeRounding, onClick = { /* TODO: Handle rounding change */ })
SettingsItem(title = stringResource(R.string.settings_item_week_starts_on), value = uiState.weekStartsOn, onClick = { /* TODO: Handle week start change */ })
}
Spacer(modifier = Modifier.height(24.dp))
SettingsSection(title = stringResource(R.string.settings_section_appearance)) {
SettingsItem(title = stringResource(R.string.theme), value = uiState.theme.title, onClick = viewModel::onThemeClicked)
SettingsItem(title = stringResource(R.string.language), value = uiState.locale.title, onClick = viewModel::onLocaleClicked)
}
Spacer(modifier = Modifier.height(24.dp))
SettingsSection(title = stringResource(R.string.about)) {
SettingsItem(title = stringResource(R.string.settings_item_app_version), value = uiState.appVersion)
}
}
}
@Composable
fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column {
Text(text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
content()
}
}
@Composable
fun SettingsItem(
title: String,
modifier: Modifier = Modifier,
value: String? = null,
onClick: (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null
) {
val itemModifier = if (onClick != null) {
modifier.clickable(onClick = onClick)
} else {
modifier
}
Row(
modifier = itemModifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
if (value != null) {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (trailingContent != null) {
trailingContent()
}
}
}
@Composable
fun ThemeSelectionDialog(
currentTheme: Theme,
onThemeSelected: (Theme) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.theme)) },
text = {
Column(Modifier.selectableGroup()) {
Theme.entries.forEach { theme ->
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = (theme == currentTheme),
onClick = { onThemeSelected(theme) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (theme == currentTheme),
onClick = null // null recommended for accessibility with screenreaders
)
Text(
text = theme.title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
fun LocaleSelectionDialog(
currentLocale: Locale,
onLocaleSelected: (Locale) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.language)) },
text = {
Column(Modifier.selectableGroup()) {
Locale.entries.forEach { locale ->
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = (locale == currentLocale),
onClick = { onLocaleSelected(locale) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (locale == currentLocale),
onClick = null // null recommended for accessibility with screenreaders
)
Text(
text = locale.title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Preview(showBackground = true)
@Composable
fun SettingsScreenPreview() {
ClockedTheme {
SettingsScreen(viewModel = SettingsViewModel(), authViewModel = AuthViewModel())
}
}

View File

@@ -0,0 +1,83 @@
package net.tinsae.clocked.settings
import androidx.activity.result.launch
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
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
data class SettingsUiState(
val defaultDuration: String = "8h",
val timeRounding: String = "15 min",
val weekStartsOn: String = "Monday",
val theme: Theme = Theme.SYSTEM,
val locale: Locale = Locale.SYSTEM,
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
)
class SettingsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
fun onThemeClicked() {
_uiState.update { it.copy(showThemeDialog = true) }
}
fun onThemeSelected(theme: Theme) {
_uiState.update { it.copy(theme = theme, showThemeDialog = false) }
}
fun onDismissThemeDialog() {
_uiState.update { it.copy(showThemeDialog = false) }
}
fun onLocaleClicked() {
_uiState.update { it.copy(showLocaleDialog = true) }
}
fun onLocaleSelected(locale: Locale) {
_uiState.update { it.copy(locale = locale, showLocaleDialog = false) }
}
fun onDismissLocaleDialog() {
_uiState.update { it.copy(showLocaleDialog = false) }
}
fun onExportClicked() {
viewModelScope.launch {
try {
// 1. Fetch data from the service (suspend call)
val logs = LogRepository.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
android.util.Log.e("SettingsViewModel", "Failed to export CSV", e)
// Optionally show an error to the user
// _uiState.update { it.copy(error = "Export failed.") }
}
}
}
fun onExportHandled() {
// Clear the pending content after it has been saved or the user has cancelled.
_uiState.update { it.copy(pendingCsvContent = null) }
}
}

View File

@@ -31,4 +31,6 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
*/
)

View File

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

View File

@@ -0,0 +1,19 @@
package net.tinsae.clocked.util
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import net.tinsae.clocked.BuildConfig
object SupabaseClient {
val client by lazy {
createSupabaseClient(
supabaseUrl = BuildConfig.SUPABASE_URL,
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
) {
install(Auth)
install(Postgrest)
}
}
}

View File

@@ -1,28 +1,43 @@
package net.tinsae.clocked.util
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.Duration
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration
import kotlin.time.Instant
object Util {
/**
* Formats an Instant to a human-readable date string.
* @param instant The Instant to format.
* @return A formatted date string.
*/
fun formatTimestampToLocalDateString(instant: Instant): String {
val formatter = DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM) // Creates a locale-aware date format
.withZone(ZoneId.systemDefault()) // Converts the UTC Instant to the user's local time zone
return formatter.format(instant)
// 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
return "$month $day, $year"
}
/**
* Formats a Duration to a human-readable duration string.
* @param duration The Duration to format.
* @return A formatted duration string.
*/
fun formatDuration(duration: Duration): String {
if (duration.isZero) {
if (duration.inWholeMinutes == 0L) {
return "0m"
}
val totalMinutes = duration.toMinutes()
val totalMinutes = duration.inWholeMinutes
val sign = if (totalMinutes < 0) "" else "+"
val absMinutes = kotlin.math.abs(totalMinutes)
val hours = absMinutes / 60
@@ -41,8 +56,19 @@ object Util {
}
/**
* Formats an Instant to a human-readable month and year string.
* @param instant The Instant to format.
* @return A formatted month and year string.
*/
fun formatTimestampToMonthYear(instant: Instant): String {
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy").withZone(ZoneId.systemDefault())
return formatter.format(instant)
// 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
return "$month $year"
}
}

View File

@@ -1,3 +1,71 @@
<resources>
<string name="app_name">Clocked</string>
<!-- Navigation -->
<string name="nav_home">Home</string>
<string name="nav_history">History</string>
<string name="nav_settings">Settings</string>
<!-- Common Terms -->
<string name="all">All</string>
<string name="overtime">Overtime</string>
<string name="time_off">Time Off</string>
<string name="date">Date</string>
<string name="duration">Duration</string>
<string name="reason">Reason</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="ok">OK</string>
<string name="edit">Edit</string>
<string name="delete">Delete</string>
<string name="close">Close</string>
<string name="theme">Theme</string>
<string name="language">Language</string>
<!-- Add/Edit Log Dialog -->
<string name="add_overtime_title">Add Overtime</string>
<string name="add_time_off_title">Add Time Off</string>
<string name="hours">Hours</string>
<string name="minutes">Minutes</string>
<string name="reason_optional">Reason (Optional)</string>
<!-- Dashboard Screen -->
<string name="net_balance">Net Balance</string>
<string name="add_overtime">+ Overtime</string>
<string name="add_time_off"> Time Off</string>
<string name="recent_activity">Recent Activity</string>
<!-- Settings Screen -->
<string name="settings_section_data">Data</string>
<string name="settings_item_export_csv">Export to CSV</string>
<string name="settings_item_navigate">Navigate</string>
<string name="settings_section_preferences">Preferences</string>
<string name="settings_item_default_duration">Default duration</string>
<string name="settings_item_time_rounding">Time rounding</string>
<string name="settings_item_week_starts_on">Week starts on</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_item_app_version">App version</string>
<string name="about">About</string>
<!-- Details Dialog -->
<string name="details_title">Log Details</string>
<string name="details_no_reason">No reason provided</string>
<string name="type">Type</string>
<!-- Error messages -->
<string name="error_invalid_credentials">Invalid email or password.</string>
<string name="error_user_already_exists">A user with this email already exists.</string>
<string name="error_network_issue">A network error occurred. Please check your connection.</string>
<string name="error_unknown">An unknown error occurred. Please try again.</string>
<!-- Login Screen -->
<string name="login_welcome">Welcome to Clocked</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_button">Log In</string>
<string name="signup_button">Sign Up</string>
<string name="login_prompt_signup">Don\'t have an account? Sign up</string>
<string name="signup_prompt_login">Already have an account? Log in</string>
<string name="signup_success_message">Sign-up successful! Please check your email for a confirmation link.</string>
</resources>

View File

@@ -5,17 +5,22 @@ coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.7.1"
kotlinxSerializationJson = "1.9.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2"
composeBom = "2025.12.01"
lifecycle = "2.8.0"
lifecycle = "2.10.0"
appcompat = "1.7.1"
desugarJdkLibs = "2.1.5"
material3WindowSizeClass = "1.4.0"
materialIconsExtended = "1.7.8"
supabase = "3.2.6"
ktor = "3.3.3"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
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" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -36,8 +41,18 @@ androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compo
android-desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
# 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" }
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" }
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }