From 36d5fc5ce930d4a2da2e90f026e81903be228277 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 28 Dec 2025 10:14:19 +0100 Subject: [PATCH] integration supabas --- app/build.gradle.kts | 30 +++ app/src/main/AndroidManifest.xml | 3 + .../java/net/tinsae/clocked/AuthViewModel.kt | 111 ++++++++ .../java/net/tinsae/clocked/LoginScreen.kt | 95 ++----- .../java/net/tinsae/clocked/MainActivity.kt | 232 +++++++++++------ .../clocked/anonymous/RegistrationForm.kt | 150 +++++++++++ .../tinsae/clocked/anonymous/SignInForm.kt | 166 ++++++++++++ .../tinsae/clocked/components/ActivityList.kt | 47 ++-- .../tinsae/clocked/components/AddLogDialog.kt | 189 ++++++++++++++ .../clocked/components/DetailsDialog.kt | 22 +- .../clocked/dashboard/DashboardScreen.kt | 54 +++- .../clocked/dashboard/DashboardViewModel.kt | 106 ++++++-- .../tinsae/clocked/data/IntervalSerialiser.kt | 41 +++ .../java/net/tinsae/clocked/data/Locale.kt | 19 ++ .../main/java/net/tinsae/clocked/data/Log.kt | 33 ++- .../net/tinsae/clocked/data/LogRepository.kt | 83 ++++++ .../net/tinsae/clocked/data/SupabaseClient.kt | 20 ++ .../java/net/tinsae/clocked/data/Theme.kt | 19 ++ .../tinsae/clocked/history/HistoryScreen.kt | 104 +++----- .../clocked/history/HistoryViewModel.kt | 93 +++++++ .../net/tinsae/clocked/service/LogService.kt | 144 +++++------ .../tinsae/clocked/settings/SettingsScreen.kt | 237 ++++++++++++++++++ .../clocked/settings/SettingsViewModel.kt | 83 ++++++ .../java/net/tinsae/clocked/ui/theme/Type.kt | 2 + .../net/tinsae/clocked/util/CsvExporter.kt | 66 +++++ .../net/tinsae/clocked/util/SupabaseClient.kt | 19 ++ .../main/java/net/tinsae/clocked/util/Util.kt | 52 +++- app/src/main/res/values/strings.xml | 68 +++++ gradle/libs.versions.toml | 19 +- 29 files changed, 1944 insertions(+), 363 deletions(-) create mode 100644 app/src/main/java/net/tinsae/clocked/AuthViewModel.kt create mode 100644 app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt create mode 100644 app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt create mode 100644 app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt create mode 100644 app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt create mode 100644 app/src/main/java/net/tinsae/clocked/data/Locale.kt create mode 100644 app/src/main/java/net/tinsae/clocked/data/LogRepository.kt create mode 100644 app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt create mode 100644 app/src/main/java/net/tinsae/clocked/data/Theme.kt create mode 100644 app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt create mode 100644 app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt create mode 100644 app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt create mode 100644 app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1706494..790fe5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4d94418..0350e8e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + = 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 + } + } + +} diff --git a/app/src/main/java/net/tinsae/clocked/LoginScreen.kt b/app/src/main/java/net/tinsae/clocked/LoginScreen.kt index bc17478..85a2bfe 100644 --- a/app/src/main/java/net/tinsae/clocked/LoginScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/LoginScreen.kt @@ -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 - 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 - ) + if (uiState.formType == FormType.LOGIN){ + SignInForm( + modifier = modifier, + uiState = uiState, + onLogin = viewModel::login, + onTogleForm = viewModel::setFormType - 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 - ) - - 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") - } - } + ) + }else{ + RegistrationForm( + modifier = modifier, + uiState = uiState, + onRegister = viewModel::signUp, + onTogleForm = viewModel::setFormType + ) } + } @Preview(showBackground = true) @Composable -private fun LoginScreenPreview() { +fun LoginScreenPreview() { ClockedTheme { - LoginScreen(onLoginSuccess = {}) + LoginScreen(AuthViewModel()) } } + diff --git a/app/src/main/java/net/tinsae/clocked/MainActivity.kt b/app/src/main/java/net/tinsae/clocked/MainActivity.kt index 7096d77..e71f3b2 100644 --- a/app/src/main/java/net/tinsae/clocked/MainActivity.kt +++ b/app/src/main/java/net/tinsae/clocked/MainActivity.kt @@ -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() - } else { - // If not, show the Login screen - LoginScreen(onLoginSuccess = { }) + 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 -> {} + } } } -@PreviewScreenSizes +fun updateLocale(context: Context, locale: Locale) { + val localeTag = locale.tag.ifEmpty { null } + val localeList = if (localeTag != null) { + LocaleListCompat.forLanguageTags(localeTag) + } else { + LocaleListCompat.getEmptyLocaleList() + } + AppCompatDelegate.setApplicationLocales(localeList) +} + @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.HOME -> DashboardScreen( modifier = Modifier.fillMaxSize()) AppDestinations.HISTORY -> HistoryScreen(modifier = Modifier.fillMaxSize()) - AppDestinations.PROFILE -> Greeting(name = "Profile", modifier = Modifier.fillMaxSize()) + AppDestinations.SETTING -> SettingsScreen( + modifier = Modifier.fillMaxSize(), + viewModel = settingsViewModel, + authViewModel = authViewModel + ) } } } } - - enum class AppDestinations( - val label: String, + val label: Int, val icon: ImageVector, ) { - HOME("Home", Icons.Default.Home), - HISTORY("History", Icons.AutoMirrored.Filled.List), - PROFILE("Profile", Icons.Default.AccountBox), + 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), } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Scaffold(modifier = modifier) { innerPadding -> - Text( - text = "Hello $name!", - modifier = Modifier.padding(innerPadding) - ) - } -} -/* -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - ClockedTheme { - Greeting("Android") - } -} -*/ - -// --- PREVIEW THE ACTUAL APP ENTRY POINT --- @Preview(showBackground = true) @Composable fun AppEntryPreview() { ClockedTheme { - // This preview will correctly show the LoginScreen because isAuthenticated starts as false. - AppEntry() + AppEntry(SettingsViewModel(), AuthViewModel()) } } - diff --git a/app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt b/app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt new file mode 100644 index 0000000..e1083e9 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/anonymous/RegistrationForm.kt @@ -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 = { _ ->} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt b/app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt new file mode 100644 index 0000000..321cbfb --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt @@ -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 = { _ ->} + ) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/components/ActivityList.kt b/app/src/main/java/net/tinsae/clocked/components/ActivityList.kt index 330ac1c..1faca95 100644 --- a/app/src/main/java/net/tinsae/clocked/components/ActivityList.kt +++ b/app/src/main/java/net/tinsae/clocked/components/ActivityList.kt @@ -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, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onEditLog: (Log) -> Unit = {}, + onDeleteLog: (Log) -> Unit = {}, + recentLogs: List ) { - 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(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 = {_->{}}) } diff --git a/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt b/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt new file mode 100644 index 0000000..fe56b50 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt @@ -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 = { _, _, _ -> } + ) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt b/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt index 487d472..c3f1023 100644 --- a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt @@ -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. diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt index a4a302b..60e85a3 100644 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt @@ -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()) } } diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt index 41529cd..4624e52 100644 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt @@ -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 = emptyList() + val balanceInDays: String = "0d", + val recentActivities: List = 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 = _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 = combine( + LogRepository.logs, // Source 1: The List + _internalState // Source 2: The internal UI state + ) { logs, internalState -> + // THE FIX: The 'logs' parameter is already the List. + // 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) } } diff --git a/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt b/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt new file mode 100644 index 0000000..51ba61e --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/data/IntervalSerialiser.kt @@ -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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/data/Locale.kt b/app/src/main/java/net/tinsae/clocked/data/Locale.kt new file mode 100644 index 0000000..80d14a9 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/data/Locale.kt @@ -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" + } +} diff --git a/app/src/main/java/net/tinsae/clocked/data/Log.kt b/app/src/main/java/net/tinsae/clocked/data/Log.kt index 4c6e4a7..519c8ae 100644 --- a/app/src/main/java/net/tinsae/clocked/data/Log.kt +++ b/app/src/main/java/net/tinsae/clocked/data/Log.kt @@ -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 -} \ No newline at end of file +} +enum class FormType { + LOGIN, SIGNUP +} diff --git a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt new file mode 100644 index 0000000..ccc41b0 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt @@ -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>(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() + } + } +} diff --git a/app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt b/app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt new file mode 100644 index 0000000..073bc73 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt @@ -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) + } + } + +} diff --git a/app/src/main/java/net/tinsae/clocked/data/Theme.kt b/app/src/main/java/net/tinsae/clocked/data/Theme.kt new file mode 100644 index 0000000..b3efd3f --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/data/Theme.kt @@ -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" + } +} diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt b/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt index 58c40eb..75e3109 100644 --- a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt @@ -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(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 - } + LaunchedEffect(tabTitles) { + viewModel.setTabTitles(tabTitles) } - // 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) - } - } + val uiState: HistoryUiState by viewModel.uiState.collectAsState() - -Column( - modifier = modifier - .fillMaxSize() - .fillMaxSize() + Column( + modifier = modifier.fillMaxSize() ) { - PrimaryTabRow (selectedTabIndex = selectedTabIndex) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTabIndex == index, - onClick = { selectedTabIndex = index }, - text = { Text(title) } - ) + if (uiState.tabs.isNotEmpty()) { + PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex) { + uiState.tabs.forEachIndexed { index, title -> + Tab( + 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()) } } diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt b/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt new file mode 100644 index 0000000..938e6a6 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt @@ -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 = emptyList(), + val selectedTabIndex: Int = 0, + val groupedEntries: Map> = 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 = 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 + + // 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) { + _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() { ... } +} diff --git a/app/src/main/java/net/tinsae/clocked/service/LogService.kt b/app/src/main/java/net/tinsae/clocked/service/LogService.kt index 1cde3ef..6bb87f5 100644 --- a/app/src/main/java/net/tinsae/clocked/service/LogService.kt +++ b/app/src/main/java/net/tinsae/clocked/service/LogService.kt @@ -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") - -// --- Programmatically generate a list of 30 random logs --- -private fun generateRandomLogs(): List { - val logs = mutableListOf() - for (i in 1..30) { - val randomDaysAgo = Random.nextLong(0, 365) - val timestamp = Instant.now().minus(randomDaysAgo, ChronoUnit.DAYS) - val isOvertime = Random.nextBoolean() - - 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, - timestamp = timestamp - ) - } - logs.add(log) +suspend fun getAllLogsFromDB(): List { + try { + val logs = SupabaseClient.client.postgrest.from("Logs").select { + order("timestamp", Order.DESCENDING) + }.decodeList() + LOG.d("LogService", "Fetched ${logs.size} logs") + return logs + } catch (e: Exception) { + LOG.e("LogService", "Error fetching logs", e) + return emptyList() } - return logs } - -val logs: List = generateRandomLogs().sortedByDescending { it.timestamp } - -fun getAllLogs(): List { - return logs +suspend fun getRecentLogsFromDB(): List { + return getAllLogsFromDB().take(5) } -fun getOvertimeLogs(): List { - return logs.filter { it.type == EntryType.OVERTIME } +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) + } } -fun getTimeOffLogs(): List { - return logs.filter { it.type == EntryType.TIME_OFF } +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 getRecentLogs(): List { - // 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) +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 getTotalOvertimeDuration(): Duration { - return logs - .filter { it.type == EntryType.OVERTIME } - .map { it.duration } - .fold(Duration.ZERO, Duration::plus) +// --- Calculation functions that operate on a given list of logs --- + +fun getTotalOvertimeDuration(logs: List): Duration { + return logs.filter { it.type == EntryType.OVERTIME }.fold(Duration.ZERO) { acc, log -> acc + log.duration } } -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): Duration { + return logs.filter { it.type == EntryType.TIME_OFF }.fold(Duration.ZERO) { acc, log -> acc + log.duration } } diff --git a/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt b/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt new file mode 100644 index 0000000..52b76d6 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt @@ -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()) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt b/app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt new file mode 100644 index 0000000..69451d1 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/settings/SettingsViewModel.kt @@ -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 = _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) } + } +} diff --git a/app/src/main/java/net/tinsae/clocked/ui/theme/Type.kt b/app/src/main/java/net/tinsae/clocked/ui/theme/Type.kt index 9fb587a..321f3b6 100644 --- a/app/src/main/java/net/tinsae/clocked/ui/theme/Type.kt +++ b/app/src/main/java/net/tinsae/clocked/ui/theme/Type.kt @@ -31,4 +31,6 @@ val Typography = Typography( letterSpacing = 0.5.sp ) */ + + ) \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt b/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt new file mode 100644 index 0000000..ba3ca0f --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt @@ -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): String { + val csvBuilder = StringBuilder() + + // --- 1. Totals Section with Excel Formulas --- + val dataStartRow = 7 // CSV data will start on this row + val dataEndRow = dataStartRow + logs.size - 1 + + // Totals row + csvBuilder.append("Key,Value\n") + // Overtime is now in column D, Time Off is in column E + csvBuilder.append("Total Overtime (hours),=SUM(D${dataStartRow}:D${dataEndRow})\n") + csvBuilder.append("Total Time Off (hours),=SUM(E${dataStartRow}:E${dataEndRow})\n") + // Net balance is Overtime + Time Off (since time off is negative) + csvBuilder.append("Net Balance (hours),=B2+B3\n") + csvBuilder.append("\n") // Spacer row + + // --- 2. Data Table Section --- + + // Headers (Timestamp column removed) + csvBuilder.append("ID,Date,Reason,Overtime (hours),Time Off (hours)\n") + + // Date formatter for Excel compatibility + //val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()) + + // Rows + logs.forEachIndexed { index, log -> + val rowId = index + 1 + + // THE FIX: Format the kotlin.time.Instant correctly + val localDateTime = log.timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + // Pad month and day with a leading zero if they are single-digit + val month = localDateTime.month.number.toString().padStart(2, '0') + val day = localDateTime.day.toString().padStart(2, '0') + val date = "${localDateTime.year}-${month}-${day}" + + val reason = log.reason?.replace("\"", "\"\"") ?: "" + // Use the correct extension function for kotlin.time.Duration + val durationInHours = log.duration.inWholeMinutes / 60.0 + + val overtime = if (log.type == EntryType.OVERTIME) durationInHours else 0.0 + val timeOff = if (log.type == EntryType.TIME_OFF) durationInHours else 0.0 + + csvBuilder.append( + "$rowId,$date,\"$reason\",$overtime,$timeOff\n" + ) + } + + val csvContent = csvBuilder.toString() + LOG.d("CsvExporter", "Generated CSV:\n$csvContent") + return csvContent + } +} diff --git a/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt b/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt new file mode 100644 index 0000000..df6d9e0 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt @@ -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) + } + } +} diff --git a/app/src/main/java/net/tinsae/clocked/util/Util.kt b/app/src/main/java/net/tinsae/clocked/util/Util.kt index 7218909..2ba5653 100644 --- a/app/src/main/java/net/tinsae/clocked/util/Util.kt +++ b/app/src/main/java/net/tinsae/clocked/util/Util.kt @@ -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" } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59d677f..0ca3105 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,71 @@ Clocked + + + Home + History + Settings + + + All + Overtime + Time Off + Date + Duration + Reason + Cancel + Save + OK + Edit + Delete + Close + Theme + Language + + + Add Overtime + Add Time Off + Hours + Minutes + Reason (Optional) + + + Net Balance + + Overtime + − Time Off + Recent Activity + + + Data + Export to CSV + Navigate + Preferences + Default duration + Time rounding + Week starts on + Appearance + App version + About + + + Log Details + No reason provided + Type + + + Invalid email or password. + A user with this email already exists. + A network error occurred. Please check your connection. + An unknown error occurred. Please try again. + + + Welcome to Clocked + Email + Password + Log In + Sign Up + Don\'t have an account? Sign up + Already have an account? Log in + Sign-up successful! Please check your email for a confirmation link. + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78cdac8..384407a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }