integration supabas
This commit is contained in:
@@ -1,7 +1,17 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
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 {
|
android {
|
||||||
@@ -18,6 +28,10 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
buildTypes {
|
||||||
@@ -41,6 +55,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
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.adaptive.navigation.suite)
|
||||||
implementation(libs.androidx.compose.material3.window.size.class1)
|
implementation(libs.androidx.compose.material3.window.size.class1)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
|
// 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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
111
app/src/main/java/net/tinsae/clocked/AuthViewModel.kt
Normal file
111
app/src/main/java/net/tinsae/clocked/AuthViewModel.kt
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package net.tinsae.clocked
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.github.jan.supabase.auth.OtpType
|
||||||
|
import io.github.jan.supabase.auth.auth
|
||||||
|
import io.github.jan.supabase.auth.providers.builtin.Email
|
||||||
|
import io.github.jan.supabase.auth.status.SessionStatus
|
||||||
|
import io.github.jan.supabase.exceptions.BadRequestRestException
|
||||||
|
import io.github.jan.supabase.exceptions.RestException
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import net.tinsae.clocked.data.FormType
|
||||||
|
import net.tinsae.clocked.util.SupabaseClient
|
||||||
|
|
||||||
|
// Add this data class here for UI state
|
||||||
|
data class AuthUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val formType: FormType = FormType.LOGIN,
|
||||||
|
val signupSuccess: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthViewModel : ViewModel() {
|
||||||
|
|
||||||
|
// --- State from LoginViewModel is now here ---
|
||||||
|
var uiState by mutableStateOf(AuthUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val sessionStatus: StateFlow<SessionStatus> = SupabaseClient.client.auth.sessionStatus
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
// Initialize with the client's current status, which will be .Initializing on cold start
|
||||||
|
initialValue = SupabaseClient.client.auth.sessionStatus.value
|
||||||
|
)
|
||||||
|
|
||||||
|
fun login(email: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
SupabaseClient.client.auth.signInWith(Email) {
|
||||||
|
this.email = email
|
||||||
|
this.password = password
|
||||||
|
}
|
||||||
|
// No need to emit a success event. The isAuthenticated flow will automatically update.
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(error = parseError(e)) //e.message ?: "An unknown error occurred")
|
||||||
|
} finally {
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signUp(name: String, email: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
SupabaseClient.client.auth.signUpWith(Email) {
|
||||||
|
this.email = email
|
||||||
|
this.password = password
|
||||||
|
this.data = buildJsonObject {
|
||||||
|
put("full_name", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState = uiState.copy(signupSuccess = true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(error = parseError(e))
|
||||||
|
} finally {
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFormType(type: FormType) {
|
||||||
|
uiState = uiState.copy(formType = type, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
SupabaseClient.client.auth.signOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse error message from exception
|
||||||
|
private fun parseError(e: Exception): String {
|
||||||
|
val defaultError = "An unknown error occurred. Please try again."
|
||||||
|
val message = e.message ?: return defaultError
|
||||||
|
Log.w("AuthenticatorViewModel: ", e.message ?: "SOMETHING HAPPENED!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
||||||
|
return when (e) {
|
||||||
|
is BadRequestRestException -> { // server can be reached
|
||||||
|
message.lines().firstOrNull()?.trim() ?: defaultError
|
||||||
|
}
|
||||||
|
is RestException -> { // server cant be reached
|
||||||
|
"A network error occurred. Please check your connection."
|
||||||
|
}
|
||||||
|
// For any other unexpected exception
|
||||||
|
else -> defaultError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,86 +1,43 @@
|
|||||||
package net.tinsae.clocked
|
package net.tinsae.clocked
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.runtime.Composable
|
||||||
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.ui.Modifier
|
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.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
|
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(onLoginSuccess: () -> Unit, modifier: Modifier = Modifier) {
|
fun LoginScreen(viewModel: AuthViewModel) {
|
||||||
var email by rememberSaveable { mutableStateOf("") }
|
val modifier = Modifier.fillMaxWidth()
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
if (uiState.formType == FormType.LOGIN){
|
||||||
|
SignInForm(
|
||||||
|
modifier = modifier,
|
||||||
|
uiState = uiState,
|
||||||
|
onLogin = viewModel::login,
|
||||||
|
onTogleForm = viewModel::setFormType
|
||||||
|
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
|
|
||||||
// 1. Use imePadding() to automatically handle keyboard insets.
|
|
||||||
// 2. Add verticalScroll to allow scrolling on small screens.
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
.imePadding() // Automatically adds padding when the keyboard is open
|
|
||||||
.verticalScroll(rememberScrollState()) // Allows scrolling if content is too large
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Welcome to Clocked",
|
|
||||||
style = MaterialTheme.typography.headlineLarge
|
|
||||||
)
|
)
|
||||||
|
}else{
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
RegistrationForm(
|
||||||
|
modifier = modifier,
|
||||||
OutlinedTextField(
|
uiState = uiState,
|
||||||
value = email,
|
onRegister = viewModel::signUp,
|
||||||
onValueChange = { email = it },
|
onTogleForm = viewModel::setFormType
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoginScreenPreview() {
|
fun LoginScreenPreview() {
|
||||||
ClockedTheme {
|
ClockedTheme {
|
||||||
LoginScreen(onLoginSuccess = {})
|
LoginScreen(AuthViewModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
package net.tinsae.clocked
|
package net.tinsae.clocked
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.only
|
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.foundation.layout.safeDrawing
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
import androidx.compose.material.icons.filled.AccountBox
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
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.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -32,139 +47,216 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
|
||||||
import androidx.compose.ui.unit.dp
|
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.dashboard.DashboardScreen
|
||||||
|
import net.tinsae.clocked.data.Locale
|
||||||
|
import net.tinsae.clocked.data.Theme
|
||||||
import net.tinsae.clocked.history.HistoryScreen
|
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 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() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ClockedTheme {
|
// The single ViewModel instances from the Activity are passed down.
|
||||||
// The root of the app now decides whether to show Login or Main content
|
AppEntry(settingsViewModel, authViewModel)
|
||||||
AppEntry()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppEntry() {
|
fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
|
||||||
// For now, we'll fake authentication. In a real app, this would come from a ViewModel or repository.
|
val settingsState by settingsViewModel.uiState.collectAsState()
|
||||||
var isAuthenticated by rememberSaveable { mutableStateOf(true) } // Start as not authenticated
|
val sessionStatus by authViewModel.sessionStatus.collectAsState()
|
||||||
|
|
||||||
if (isAuthenticated) {
|
val useDarkTheme = when (settingsState.theme) {
|
||||||
// If authenticated, show the main app UI
|
Theme.LIGHT -> false
|
||||||
ClockedApp()
|
Theme.DARK -> true
|
||||||
|
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
updateLocale(context, settingsState.locale)
|
||||||
|
|
||||||
|
ClockedTheme(darkTheme = useDarkTheme) {
|
||||||
|
// Use a 'when' statement to handle all possible states.
|
||||||
|
when (sessionStatus) {
|
||||||
|
is SessionStatus.Authenticated -> {
|
||||||
|
// If we know the user is authenticated, show the main app.
|
||||||
|
ClockedApp(settingsViewModel, authViewModel,sessionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SessionStatus.NotAuthenticated -> {
|
||||||
|
// Only if we are certain the user is not logged in, show the login screen.
|
||||||
|
LoginScreen(authViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SessionStatus.Initializing -> {
|
||||||
|
// While Supabase is loading the session, show a loading indicator.
|
||||||
|
// This prevents the flicker.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLocale(context: Context, locale: Locale) {
|
||||||
|
val localeTag = locale.tag.ifEmpty { null }
|
||||||
|
val localeList = if (localeTag != null) {
|
||||||
|
LocaleListCompat.forLanguageTags(localeTag)
|
||||||
} else {
|
} else {
|
||||||
// If not, show the Login screen
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
LoginScreen(onLoginSuccess = { })
|
|
||||||
}
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewScreenSizes
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClockedApp() {
|
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel, sessionStatus: SessionStatus) {
|
||||||
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
|
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(), // Removed background for clarity
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// Apply padding ONLY for the top safe area (status bar)
|
.background(colorScheme.surface)
|
||||||
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues())
|
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()),
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
//contentAlignment = Alignment.CenterStart
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
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.
|
) { innerPadding ->
|
||||||
//val layoutDirection = LocalLayoutDirection.current
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val contentPadding = PaddingValues(
|
val contentPadding = PaddingValues(
|
||||||
// Respect the horizontal padding for gestures, etc.
|
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
//start = innerPadding.calculateStartPadding(layoutDirection),
|
end = innerPadding.calculateEndPadding(layoutDirection),
|
||||||
//end = innerPadding.calculateEndPadding(layoutDirection),
|
|
||||||
// Respect the top padding to stay below the top bar.
|
|
||||||
top = innerPadding.calculateTopPadding(),
|
top = innerPadding.calculateTopPadding(),
|
||||||
// CRITICAL: Ignore the bottom padding from the parent Scaffold.
|
|
||||||
bottom = 0.dp
|
bottom = 0.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
// Apply our custom-calculated padding.
|
|
||||||
modifier = Modifier.padding(contentPadding),
|
modifier = Modifier.padding(contentPadding),
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
AppDestinations.entries.forEach {
|
AppDestinations.entries.forEach { destination ->
|
||||||
item(
|
item(
|
||||||
icon = { Icon(it.icon, contentDescription = it.label) },
|
icon = { Icon(
|
||||||
label = { Text(it.label) },
|
imageVector = destination.icon,
|
||||||
selected = it == currentDestination,
|
contentDescription = stringResource(destination.label))
|
||||||
onClick = { currentDestination = it }
|
},
|
||||||
|
label = { Text(stringResource(destination.label)) },
|
||||||
|
selected = destination == currentDestination,
|
||||||
|
onClick = { currentDestination = destination }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// The router now shows the correct screen based on the destination.
|
|
||||||
when (currentDestination) {
|
when (currentDestination) {
|
||||||
AppDestinations.HOME -> DashboardScreen(modifier = Modifier.fillMaxSize())
|
AppDestinations.HOME -> DashboardScreen( modifier = Modifier.fillMaxSize())
|
||||||
AppDestinations.HISTORY -> HistoryScreen(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 icon: ImageVector,
|
|
||||||
) {
|
|
||||||
HOME("Home", Icons.Default.Home),
|
|
||||||
HISTORY("History", Icons.AutoMirrored.Filled.List),
|
|
||||||
PROFILE("Profile", Icons.Default.AccountBox),
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Scaffold(modifier = modifier) { innerPadding ->
|
|
||||||
Text(
|
|
||||||
text = "Hello $name!",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
ClockedTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
// --- PREVIEW THE ACTUAL APP ENTRY POINT ---
|
enum class AppDestinations(
|
||||||
|
val label: Int,
|
||||||
|
val icon: ImageVector,
|
||||||
|
) {
|
||||||
|
HOME(R.string.nav_home, Icons.Default.Home),
|
||||||
|
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
|
||||||
|
SETTING(R.string.nav_settings, Icons.Default.Settings),
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppEntryPreview() {
|
fun AppEntryPreview() {
|
||||||
ClockedTheme {
|
ClockedTheme {
|
||||||
// This preview will correctly show the LoginScreen because isAuthenticated starts as false.
|
AppEntry(SettingsViewModel(), AuthViewModel())
|
||||||
AppEntry()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = { _ ->}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt
Normal file
166
app/src/main/java/net/tinsae/clocked/anonymous/SignInForm.kt
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package net.tinsae.clocked.anonymous
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import net.tinsae.clocked.AuthUiState
|
||||||
|
import net.tinsae.clocked.R
|
||||||
|
import net.tinsae.clocked.data.FormType
|
||||||
|
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||||
|
import net.tinsae.clocked.ui.theme.red
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SignInForm(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
uiState: AuthUiState,
|
||||||
|
onLogin: (String, String) -> Unit,
|
||||||
|
onTogleForm: (FormType) -> Unit
|
||||||
|
) {
|
||||||
|
var email by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_welcome),
|
||||||
|
style = MaterialTheme.typography.headlineLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.error != null
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.login_password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.error != null
|
||||||
|
)
|
||||||
|
|
||||||
|
uiState.error?.let {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(text = it, color = red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onLogin(email, password) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.login_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("No account yet?")
|
||||||
|
TextButton(onClick = { onTogleForm(FormType.SIGNUP) }) {
|
||||||
|
Text(stringResource(R.string.signup_button), color = Color.Blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreenNormalPreview() {
|
||||||
|
ClockedTheme {
|
||||||
|
SignInForm(
|
||||||
|
uiState = AuthUiState(),
|
||||||
|
onLogin = { _, _ -> },
|
||||||
|
onTogleForm = { _ ->}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreenPreview() {
|
||||||
|
ClockedTheme {
|
||||||
|
SignInForm (
|
||||||
|
uiState = AuthUiState(error = "This is a sample error message"),
|
||||||
|
onLogin = { _, _ -> },
|
||||||
|
onTogleForm = { _ ->}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreenLoadingPreview() {
|
||||||
|
ClockedTheme {
|
||||||
|
SignInForm(
|
||||||
|
uiState = AuthUiState(isLoading = true),
|
||||||
|
onLogin = { _, _ -> },
|
||||||
|
onTogleForm = { _ ->}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -36,18 +34,15 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalConfiguration // Use stable LocalConfiguration
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import net.tinsae.clocked.R
|
||||||
import net.tinsae.clocked.data.Log
|
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.cyan
|
||||||
import net.tinsae.clocked.ui.theme.red
|
import net.tinsae.clocked.ui.theme.red
|
||||||
import net.tinsae.clocked.util.Util.formatDuration
|
import net.tinsae.clocked.util.Util.formatDuration
|
||||||
@@ -56,30 +51,28 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActivityList(
|
fun ActivityList(
|
||||||
items: List<Log>,
|
modifier: Modifier = Modifier,
|
||||||
modifier: Modifier = Modifier
|
onEditLog: (Log) -> Unit = {},
|
||||||
|
onDeleteLog: (Log) -> Unit = {},
|
||||||
|
recentLogs: List<Log>
|
||||||
) {
|
) {
|
||||||
var mutableItems by remember { mutableStateOf(items) }
|
//var mutableItems by remember { mutableStateOf(items) }
|
||||||
// State to hold the log that should be shown in the dialog. Null means no dialog.
|
// State to hold the log that should be shown in the dialog. Null means no dialog.
|
||||||
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Recent Activity",
|
text = stringResource(R.string.recent_activity),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(mutableItems, key = { it.id }) { log ->
|
items(recentLogs, key = { it.id }) { log ->
|
||||||
ActivityItem(
|
ActivityItem(
|
||||||
log = log,
|
log = log,
|
||||||
onEdit = { editLog(log) },
|
onEdit = { onEditLog(log) },
|
||||||
onDelete = {
|
onDelete = {onDeleteLog(log) },
|
||||||
if (deleteLog(log)) {
|
|
||||||
mutableItems = mutableItems.filter { it.id != log.id }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// When the item is clicked, set it as the selected log for the dialog
|
// When the item is clicked, set it as the selected log for the dialog
|
||||||
onClick = { selectedLogForDialog = log }
|
onClick = { selectedLogForDialog = log }
|
||||||
)
|
)
|
||||||
@@ -105,13 +98,13 @@ fun ActivityItem(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var offsetX by remember { mutableStateOf(0f) }
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
val animatedOffsetX by animateFloatAsState(targetValue = offsetX, label = "offset")
|
val animatedOffsetX by animateFloatAsState(targetValue = offsetX, label = "offset")
|
||||||
|
|
||||||
// Use stable LocalConfiguration
|
// Use stable LocalConfiguration
|
||||||
val density = LocalDensity.current
|
val screenWidthPx = LocalWindowInfo.current.containerSize.width
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
// Define thresholds for swiping
|
||||||
val editThreshold = screenWidthPx * 0.4f
|
val editThreshold = screenWidthPx * 0.4f
|
||||||
val deleteThreshold = -screenWidthPx * 0.4f
|
val deleteThreshold = -screenWidthPx * 0.4f
|
||||||
|
|
||||||
@@ -132,7 +125,7 @@ fun ActivityItem(
|
|||||||
if (animatedOffsetX > 0) { // Swiping right
|
if (animatedOffsetX > 0) { // Swiping right
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Edit,
|
imageVector = Icons.Default.Edit,
|
||||||
contentDescription = "Edit",
|
contentDescription = stringResource(R.string.edit),
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -140,7 +133,7 @@ fun ActivityItem(
|
|||||||
if (animatedOffsetX < 0) { // Swiping left
|
if (animatedOffsetX < 0) { // Swiping left
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
contentDescription = "Delete",
|
contentDescription = stringResource(R.string.delete),
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -201,5 +194,5 @@ fun ActivityItem(
|
|||||||
@Composable
|
@Composable
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
fun ActivityItemPreview() {
|
fun ActivityItemPreview() {
|
||||||
ActivityList(items = getRecentLogs())
|
ActivityList(recentLogs = emptyList(), onEditLog = {_->{}}, onDeleteLog = {_->{}})
|
||||||
}
|
}
|
||||||
|
|||||||
189
app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt
Normal file
189
app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package net.tinsae.clocked.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import net.tinsae.clocked.R
|
||||||
|
import net.tinsae.clocked.data.EntryType
|
||||||
|
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AddLogDialog(
|
||||||
|
type: EntryType,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (timestamp: Instant, duration: Duration, reason: String?) -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
var hours by remember { mutableStateOf("") }
|
||||||
|
var minutes by remember { mutableStateOf("") }
|
||||||
|
var reason by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var selectedInstant by remember { mutableStateOf(Clock.System.now()) }
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Custom date formatting logic using kotlinx-datetime
|
||||||
|
val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}"
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds())
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
selectedInstant = Instant.fromEpochMilliseconds(millis)
|
||||||
|
}
|
||||||
|
showDatePicker = false
|
||||||
|
}) {
|
||||||
|
Text(stringResource(id = R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDatePicker = false }) {
|
||||||
|
Text(stringResource(id = R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (type == EntryType.OVERTIME) stringResource(id = R.string.add_overtime_title) else stringResource(id = R.string.add_time_off_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
// Date Row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.date), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
TextButton(
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
) {
|
||||||
|
Text(formattedDate,
|
||||||
|
style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration Row
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.duration),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hours,
|
||||||
|
onValueChange = { hours = it.filter(Char::isDigit) },
|
||||||
|
label = { Text(stringResource(id = R.string.hours)) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(0.5f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = minutes,
|
||||||
|
onValueChange = {
|
||||||
|
val filtered = it.filter(Char::isDigit)
|
||||||
|
if (filtered.isEmpty() || filtered.toInt() < 60) {
|
||||||
|
minutes = filtered
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(id = R.string.minutes)) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason Field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = reason,
|
||||||
|
onValueChange = { reason = it },
|
||||||
|
label = { Text(stringResource(id = R.string.reason)) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(id = R.string.cancel))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val h = hours.toLongOrNull() ?: 0L
|
||||||
|
val m = minutes.toLongOrNull() ?: 0L
|
||||||
|
val totalMinutes = if (type == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m)
|
||||||
|
val duration = totalMinutes.minutes
|
||||||
|
onSave(selectedInstant, duration, reason.takeIf(String::isNotBlank))
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun AddLogDialogPreview(){
|
||||||
|
ClockedTheme {
|
||||||
|
AddLogDialog(
|
||||||
|
type = EntryType.OVERTIME,
|
||||||
|
onDismiss = {},
|
||||||
|
onSave = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,14 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
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.data.Log
|
||||||
import net.tinsae.clocked.util.Util.formatDuration
|
import net.tinsae.clocked.util.Util.formatDuration
|
||||||
import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString
|
import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString
|
||||||
@@ -35,23 +38,30 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) {
|
|||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
DetailRow("Date:", formatTimestampToLocalDateString(log.timestamp))
|
DetailRow(stringResource(R.string.date), formatTimestampToLocalDateString(log.timestamp))
|
||||||
DetailRow("Type:", log.type.toString())
|
DetailRow(stringResource(R.string.type), log.type.toString())
|
||||||
DetailRow("Duration:", formatDuration(log.duration))
|
DetailRow(stringResource(R.string.duration), formatDuration(log.duration))
|
||||||
DetailRow("Reason:", log.reason ?: "No reason provided")
|
DetailRow(stringResource(R.string.reason), log.reason ?: stringResource(R.string.details_no_reason))
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
modifier = Modifier.align(Alignment.End)
|
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
|
@Composable
|
||||||
private fun DetailRow(label: String, value: String) {
|
private fun DetailRow(label: String, value: String) {
|
||||||
// buildAnnotatedString allows mixing different styles in one Text composable.
|
// buildAnnotatedString allows mixing different styles in one Text composable.
|
||||||
|
|||||||
@@ -15,17 +15,24 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.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.ClockedTheme
|
||||||
import net.tinsae.clocked.ui.theme.cyan
|
import net.tinsae.clocked.ui.theme.cyan
|
||||||
import net.tinsae.clocked.ui.theme.green
|
import net.tinsae.clocked.ui.theme.green
|
||||||
@@ -38,6 +45,18 @@ fun DashboardScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
LogRepository.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showAddLogDialog) {
|
||||||
|
AddLogDialog(
|
||||||
|
type = uiState.dialogType,
|
||||||
|
onDismiss = viewModel::onDismissDialog,
|
||||||
|
onSave = viewModel::onSaveLog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -47,14 +66,14 @@ fun DashboardScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
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))
|
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))
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -62,16 +81,26 @@ fun DashboardScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
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))
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
ActivityList(
|
ActivityList(
|
||||||
items = uiState.recentActivities,
|
recentLogs = uiState.recentActivities,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
|
onEditLog = viewModel::editLog,
|
||||||
|
onDeleteLog = viewModel::deleteLog
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +135,7 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = value, style = MaterialTheme.typography.headlineLarge, color = cyan, fontWeight = FontWeight.Bold)
|
Text(text = value, style = MaterialTheme.typography.headlineLarge, color = cyan, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
@@ -114,10 +143,11 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionButton(text: String, modifier: Modifier = Modifier) {
|
fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { /*TODO*/ },
|
onClick = onClick,
|
||||||
modifier = modifier.fillMaxWidth() ) {
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Text(text = text, fontSize = 16.sp, modifier = Modifier.padding(6.dp))
|
Text(text = text, fontSize = 16.sp, modifier = Modifier.padding(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +156,6 @@ fun ActionButton(text: String, modifier: Modifier = Modifier) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreenPreview() {
|
fun DashboardScreenPreview() {
|
||||||
ClockedTheme {
|
ClockedTheme {
|
||||||
DashboardScreen(viewModel = DashboardViewModel())
|
//DashboardScreen(viewModel = DashboardViewModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,113 @@
|
|||||||
package net.tinsae.clocked.dashboard
|
package net.tinsae.clocked.dashboard
|
||||||
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.Log
|
||||||
import net.tinsae.clocked.service.getRecentLogs
|
import net.tinsae.clocked.data.LogRepository
|
||||||
import net.tinsae.clocked.service.getTotalOvertimeDuration
|
|
||||||
import net.tinsae.clocked.service.getTotalTimeOffDuration
|
|
||||||
import net.tinsae.clocked.util.Util.formatDuration
|
import net.tinsae.clocked.util.Util.formatDuration
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
|
||||||
data class DashboardUiState(
|
data class DashboardUiState(
|
||||||
val overtime: String = "0m",
|
val overtime: String = "0m",
|
||||||
val timeOff: String = "0m",
|
val timeOff: String = "0m",
|
||||||
val netBalance: String = "0m",
|
val netBalance: String = "0m",
|
||||||
val recentActivities: List<Log> = emptyList()
|
val balanceInDays: String = "0d",
|
||||||
|
val recentActivities: List<Log> = emptyList(),
|
||||||
|
val showAddLogDialog: Boolean = false,
|
||||||
|
val dialogType: EntryType = EntryType.OVERTIME // Default, will be updated
|
||||||
)
|
)
|
||||||
|
|
||||||
class DashboardViewModel : ViewModel() {
|
class DashboardViewModel : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
// This is the state holder for UI-driven events (e.g., showing a dialog)
|
||||||
val uiState: StateFlow<DashboardUiState> = _uiState
|
private val _internalState = MutableStateFlow(DashboardUiState())
|
||||||
|
|
||||||
init {
|
val uiState: StateFlow<DashboardUiState> = combine(
|
||||||
loadDashboardData()
|
LogRepository.logs, // Source 1: The List<Log>
|
||||||
}
|
_internalState // Source 2: The internal UI state
|
||||||
|
) { logs, internalState ->
|
||||||
|
// THE FIX: The 'logs' parameter is already the List<Log>.
|
||||||
|
// You do not need to call .first() on it.
|
||||||
|
val allLogs = logs
|
||||||
|
|
||||||
private fun loadDashboardData() {
|
val overtimeDuration = allLogs.filter{ it.type == EntryType.OVERTIME }
|
||||||
val recentLogs = getRecentLogs()
|
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
|
||||||
|
|
||||||
val overtimeDuration = getTotalOvertimeDuration()
|
val timeOffDuration = allLogs.filter { it.type == EntryType.TIME_OFF }
|
||||||
val timeOffDuration = getTotalTimeOffDuration()
|
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
|
||||||
|
|
||||||
// timeOffDuration is negative, so we add it to get the difference
|
// Construct the final state using data from both sources.
|
||||||
val netBalanceDuration = overtimeDuration.plus(timeOffDuration)
|
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),
|
overtime = formatDuration(overtimeDuration),
|
||||||
timeOff = formatDuration(timeOffDuration),
|
timeOff = formatDuration(timeOffDuration),
|
||||||
netBalance = formatDuration(netBalanceDuration),
|
netBalance = formatDuration(overtimeDuration + timeOffDuration),
|
||||||
recentActivities = recentLogs
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
object IntervalSerialiser : KSerializer<Duration> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PostgresInterval", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
// Serialization (sending data TO database) remains the same.
|
||||||
|
override fun serialize(encoder: Encoder, value: Duration) {
|
||||||
|
val totalSeconds = value.inWholeSeconds
|
||||||
|
encoder.encodeString("$totalSeconds seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialization (receiving data FROM database) is now custom.
|
||||||
|
override fun deserialize(decoder: Decoder): Duration {
|
||||||
|
val timeString = decoder.decodeString()
|
||||||
|
// Handle negative intervals from Postgres, which might look like "-01:30:00"
|
||||||
|
val isNegative = timeString.startsWith('-')
|
||||||
|
val absTimeString = if (isNegative) timeString.substring(1) else timeString
|
||||||
|
|
||||||
|
val parts = absTimeString.split(':').map { it.toDoubleOrNull() ?: 0.0 }
|
||||||
|
|
||||||
|
if (parts.size == 3) {
|
||||||
|
val (h, m, s) = parts
|
||||||
|
val totalSeconds = h * 3600 + m * 60 + s
|
||||||
|
return if (isNegative) (-totalSeconds).seconds else totalSeconds.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return Duration.ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/net/tinsae/clocked/data/Locale.kt
Normal file
19
app/src/main/java/net/tinsae/clocked/data/Locale.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
enum class Locale(val tag: String) {
|
||||||
|
SYSTEM(""),
|
||||||
|
ENGLISH("en"),
|
||||||
|
SPANISH("es"),
|
||||||
|
FRENCH("fr");
|
||||||
|
|
||||||
|
val title:String
|
||||||
|
@Composable
|
||||||
|
get() = when (this) {
|
||||||
|
SYSTEM -> "System"
|
||||||
|
ENGLISH -> "English"
|
||||||
|
SPANISH -> "Español"
|
||||||
|
FRENCH -> "Français"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
package net.tinsae.clocked.data
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
import java.time.Duration
|
import kotlinx.serialization.SerialName
|
||||||
import java.time.Instant
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class Log(
|
data class Log(
|
||||||
val id: Int,
|
val id: Long,
|
||||||
val duration: Duration,
|
val timestamp: Instant,
|
||||||
val reason: String?,
|
|
||||||
val type: EntryType,
|
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 {
|
enum class EntryType {
|
||||||
OVERTIME, TIME_OFF
|
OVERTIME, TIME_OFF
|
||||||
}
|
}
|
||||||
|
enum class FormType {
|
||||||
|
LOGIN, SIGNUP
|
||||||
|
}
|
||||||
|
|||||||
83
app/src/main/java/net/tinsae/clocked/data/LogRepository.kt
Normal file
83
app/src/main/java/net/tinsae/clocked/data/LogRepository.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
|
import android.util.Log as LOG
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.tinsae.clocked.service.*
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Singleton Repository that acts as the Single Source of Truth for Log data.
|
||||||
|
* It fetches data from the service and caches it, providing a Flow for ViewModels to observe.
|
||||||
|
*/
|
||||||
|
object LogRepository {
|
||||||
|
|
||||||
|
// A private mutable flow to hold the list of logs.
|
||||||
|
private val _logs = MutableStateFlow<List<Log>>(emptyList())
|
||||||
|
// A public, read-only StateFlow that ViewModels can collect.
|
||||||
|
val logs = _logs.asStateFlow()
|
||||||
|
|
||||||
|
private val repositoryScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Fetch initial data when the repository is first created.
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the latest logs from the service and updates the flow.
|
||||||
|
*/
|
||||||
|
fun fetchLogs() {
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
val latestLogs = getAllLogsFromDB()
|
||||||
|
_logs.update { latestLogs }
|
||||||
|
LOG.d("LogRepository", "Successfully fetched ${latestLogs.size} logs.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.e("LogRepository", "Failed to fetch logs", e)
|
||||||
|
// Optionally, you could expose an error state here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new log via the service and then triggers a refetch to update the flow.
|
||||||
|
*/
|
||||||
|
fun addLog(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
addLogToDB(type, timestamp, duration, reason)
|
||||||
|
// After adding, refetch the entire list to ensure consistency.
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.e("LogRepository", "Failed to add log", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun deleteLog(log: Log) {
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
deleteLogFromDB(log)
|
||||||
|
// After deleting, refetch the entire list to ensure consistency.
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.e("LogRepository", "Failed to delete log", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editLog(log: Log) {
|
||||||
|
repositoryScope.launch {
|
||||||
|
editLogFromDB(log)
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt
Normal file
20
app/src/main/java/net/tinsae/clocked/data/SupabaseClient.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
|
import io.github.jan.supabase.auth.Auth
|
||||||
|
import io.github.jan.supabase.createSupabaseClient
|
||||||
|
import io.github.jan.supabase.postgrest.Postgrest
|
||||||
|
import net.tinsae.clocked.BuildConfig
|
||||||
|
|
||||||
|
object SupabaseClient {
|
||||||
|
|
||||||
|
val client by lazy {
|
||||||
|
createSupabaseClient(
|
||||||
|
supabaseUrl = BuildConfig.SUPABASE_URL,
|
||||||
|
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
|
||||||
|
) {
|
||||||
|
install(Auth)
|
||||||
|
install(Postgrest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
19
app/src/main/java/net/tinsae/clocked/data/Theme.kt
Normal file
19
app/src/main/java/net/tinsae/clocked/data/Theme.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package net.tinsae.clocked.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import net.tinsae.clocked.R
|
||||||
|
|
||||||
|
enum class Theme {
|
||||||
|
SYSTEM,
|
||||||
|
LIGHT,
|
||||||
|
DARK;
|
||||||
|
|
||||||
|
val title: String
|
||||||
|
@Composable
|
||||||
|
get() = when (this) {
|
||||||
|
SYSTEM -> "System"
|
||||||
|
LIGHT -> "Light"
|
||||||
|
DARK -> "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package net.tinsae.clocked.history
|
package net.tinsae.clocked.history
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,102 +15,78 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import net.tinsae.clocked.R
|
||||||
import net.tinsae.clocked.components.ActivityItem
|
import net.tinsae.clocked.components.ActivityItem
|
||||||
import net.tinsae.clocked.components.DetailsDialog
|
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.ui.theme.ClockedTheme
|
||||||
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(modifier: Modifier = Modifier) {
|
fun HistoryScreen(
|
||||||
var allEntries by remember { mutableStateOf(getAllLogs()) }
|
modifier: Modifier = Modifier,
|
||||||
val overTimes by remember { mutableStateOf( getOvertimeLogs())}
|
viewModel: HistoryViewModel = viewModel()
|
||||||
val timeOffs by remember { mutableStateOf( getTimeOffLogs())}
|
) {
|
||||||
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
val tabTitles = listOf(
|
||||||
|
stringResource(id = R.string.all),
|
||||||
|
stringResource(id = R.string.overtime),
|
||||||
|
stringResource(id = R.string.time_off)
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(tabTitles) {
|
||||||
val tabs = listOf("All", "Overtime", "Time Off")
|
viewModel.setTabTitles(tabTitles)
|
||||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
// Filter the list based on the selected tab
|
|
||||||
// I want this done in service. so I have functions for them
|
|
||||||
val filteredEntries = remember(selectedTabIndex) {
|
|
||||||
when (tabs[selectedTabIndex]) {
|
|
||||||
"Overtime" -> overTimes
|
|
||||||
"Time Off" -> timeOffs
|
|
||||||
else -> allEntries
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the filtered entries by month and year
|
val uiState: HistoryUiState by viewModel.uiState.collectAsState()
|
||||||
val groupedEntries = remember(filteredEntries) {
|
|
||||||
// SIMPLIFIED: No need to destructure a Pair
|
|
||||||
filteredEntries.groupBy { log ->
|
|
||||||
formatTimestampToMonthYear(log.timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Column(
|
||||||
Column(
|
modifier = modifier.fillMaxSize()
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.fillMaxSize()
|
|
||||||
) {
|
) {
|
||||||
PrimaryTabRow (selectedTabIndex = selectedTabIndex) {
|
if (uiState.tabs.isNotEmpty()) {
|
||||||
tabs.forEachIndexed { index, title ->
|
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex) {
|
||||||
|
uiState.tabs.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTabIndex == index,
|
selected = uiState.selectedTabIndex == index,
|
||||||
onClick = { selectedTabIndex = index },
|
onClick = { viewModel.onTabSelected(index) },
|
||||||
text = { Text(title) }
|
text = { Text(title) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
//.padding(horizontal = 16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Iterate through the grouped map
|
uiState.groupedEntries.forEach { (monthYear, entries) ->
|
||||||
groupedEntries.forEach { (monthYear, entries) ->
|
|
||||||
// Add a sticky header for the month
|
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
MonthHeader(text = monthYear)
|
MonthHeader(text = monthYear)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the items for that month
|
|
||||||
items(entries, key = { it.id }) { entry ->
|
items(entries, key = { it.id }) { entry ->
|
||||||
ActivityItem(log = entry,
|
ActivityItem(
|
||||||
|
log = entry,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
onDelete = { deleteLog(entry) },
|
onDelete = { viewModel.deleteLog(entry) },
|
||||||
onEdit = { editLog(entry) },
|
onEdit = { viewModel.editLog(entry) },
|
||||||
onClick = { selectedLogForDialog = entry }
|
onClick = { viewModel.onLogSelected(entry) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a log is selected, show the dialog.
|
uiState.selectedLogForDialog?.let { log ->
|
||||||
selectedLogForDialog?.let { log ->
|
|
||||||
DetailsDialog(
|
DetailsDialog(
|
||||||
log = log,
|
log = log,
|
||||||
onDismiss = { selectedLogForDialog = null }
|
onDismiss = { viewModel.onDismissDialog() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +96,6 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// Use the theme's background color to overlay content correctly
|
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -131,11 +107,11 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ViewModelConstructorInComposable")
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreenPreview() {
|
fun HistoryScreenPreview() {
|
||||||
ClockedTheme {
|
ClockedTheme {
|
||||||
HistoryScreen()
|
HistoryScreen(modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package net.tinsae.clocked.history
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.tinsae.clocked.data.EntryType
|
||||||
|
import net.tinsae.clocked.data.Log
|
||||||
|
import net.tinsae.clocked.data.LogRepository
|
||||||
|
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
|
||||||
|
|
||||||
|
data class HistoryUiState(
|
||||||
|
val tabs: List<String> = emptyList(),
|
||||||
|
val selectedTabIndex: Int = 0,
|
||||||
|
val groupedEntries: Map<String, List<Log>> = emptyMap(),
|
||||||
|
val selectedLogForDialog: Log? = null,
|
||||||
|
val isLoading: Boolean = true // isLoading is true when the log list is empty
|
||||||
|
)
|
||||||
|
|
||||||
|
class HistoryViewModel : ViewModel() {
|
||||||
|
// This state now only holds things that are NOT derived from the main log list,
|
||||||
|
// like which tab is selected or which dialog is shown.
|
||||||
|
private val _internalState = MutableStateFlow(HistoryUiState())
|
||||||
|
|
||||||
|
// --- THE FIX IS HERE ---
|
||||||
|
val uiState: StateFlow<HistoryUiState> = combine(
|
||||||
|
LogRepository.logs, // Source 1: The list of all logs from the repository
|
||||||
|
_internalState // Source 2: The internal state (e.g., selected tab)
|
||||||
|
) { logs, internalState ->
|
||||||
|
val allLogs = logs // The 'logs' parameter is already the List<Log>
|
||||||
|
|
||||||
|
// Perform filtering based on the selected tab index from the internal state
|
||||||
|
val filteredEntries = when (internalState.selectedTabIndex) {
|
||||||
|
1 -> allLogs.filter { it.type == EntryType.OVERTIME }
|
||||||
|
2 -> allLogs.filter { it.type == EntryType.TIME_OFF }
|
||||||
|
else -> allLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform grouping on the filtered list
|
||||||
|
val grouped = filteredEntries.groupBy { log ->
|
||||||
|
formatTimestampToMonthYear(log.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the final UI state
|
||||||
|
HistoryUiState(
|
||||||
|
tabs = internalState.tabs,
|
||||||
|
selectedTabIndex = internalState.selectedTabIndex,
|
||||||
|
selectedLogForDialog = internalState.selectedLogForDialog,
|
||||||
|
groupedEntries = grouped,
|
||||||
|
isLoading = allLogs.isEmpty() // Show loading indicator if the log list is empty
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = HistoryUiState() // Start with a default empty state
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setTabTitles(titles: List<String>) {
|
||||||
|
_internalState.update { it.copy(tabs = titles) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTabSelected(index: Int) {
|
||||||
|
// When a tab is selected, we only need to update the internal state.
|
||||||
|
// The 'combine' block will automatically re-run and do the filtering.
|
||||||
|
_internalState.update { it.copy(selectedTabIndex = index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLogSelected(log: Log) {
|
||||||
|
_internalState.update { it.copy(selectedLogForDialog = log) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDismissDialog() {
|
||||||
|
_internalState.update { it.copy(selectedLogForDialog = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteLog(log: Log) {
|
||||||
|
// Deleting and editing are "write" operations, they should talk to the repository.
|
||||||
|
LogRepository.deleteLog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editLog(log: Log) {
|
||||||
|
LogRepository.editLog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old filterAndGroupEntries() function is no longer needed,
|
||||||
|
// as its logic is now inside the 'combine' block.
|
||||||
|
// private fun filterAndGroupEntries() { ... }
|
||||||
|
}
|
||||||
@@ -1,96 +1,90 @@
|
|||||||
package net.tinsae.clocked.service
|
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.EntryType
|
||||||
import net.tinsae.clocked.data.Log
|
import net.tinsae.clocked.data.Log
|
||||||
import android.util.Log as LOG
|
import android.util.Log as LOG
|
||||||
import java.time.Duration
|
import net.tinsae.clocked.data.LogEntry
|
||||||
import java.time.Instant
|
import net.tinsae.clocked.data.SupabaseClient
|
||||||
import java.time.temporal.ChronoUnit
|
import kotlin.time.Instant
|
||||||
import kotlin.random.Random
|
import kotlin.time.Duration
|
||||||
|
|
||||||
// --- Data for programmatic generation ---
|
// --- Public API for accessing and modifying logs --- //
|
||||||
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")
|
|
||||||
|
|
||||||
// UPDATED: Values are now in ISO 8601 format, matching Supabase's INTERVAL output
|
suspend fun getAllLogsFromDB(): List<Log> {
|
||||||
private val overtimeValues = listOf("PT1H", "PT2H30M", "PT45M", "PT3H15M", "PT1H45M")
|
try {
|
||||||
private val timeOffValues = listOf("-PT8H", "-PT4H", "-PT1H30M", "-PT2H")
|
val logs = SupabaseClient.client.postgrest.from("Logs").select {
|
||||||
|
order("timestamp", Order.DESCENDING)
|
||||||
|
}.decodeList<Log>()
|
||||||
|
LOG.d("LogService", "Fetched ${logs.size} logs")
|
||||||
|
return logs
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.e("LogService", "Error fetching logs", e)
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Programmatically generate a list of 30 random logs ---
|
suspend fun getRecentLogsFromDB(): List<Log> {
|
||||||
private fun generateRandomLogs(): List<Log> {
|
return getAllLogsFromDB().take(5)
|
||||||
val logs = mutableListOf<Log>()
|
}
|
||||||
for (i in 1..30) {
|
|
||||||
val randomDaysAgo = Random.nextLong(0, 365)
|
|
||||||
val timestamp = Instant.now().minus(randomDaysAgo, ChronoUnit.DAYS)
|
|
||||||
val isOvertime = Random.nextBoolean()
|
|
||||||
|
|
||||||
val log = if (isOvertime) {
|
suspend fun addLogToDB(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
|
||||||
Log(
|
val newLog = LogEntry(
|
||||||
id = i,
|
duration = duration,
|
||||||
// Use Duration.parse() to convert the string to a Duration object
|
reason = reason,
|
||||||
duration = Duration.parse(overtimeValues.random()),
|
type = type,
|
||||||
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
|
timestamp = timestamp
|
||||||
)
|
)
|
||||||
|
try {
|
||||||
|
SupabaseClient.client.postgrest["Logs"].insert(newLog)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.e("LogService", "Error adding log", e)
|
||||||
}
|
}
|
||||||
logs.add(log)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
return logs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun editLogFromDB(updatedLog: Log): Boolean {
|
||||||
val logs: List<Log> = generateRandomLogs().sortedByDescending { it.timestamp }
|
return try {
|
||||||
|
SupabaseClient.client.postgrest.from("Logs").update(
|
||||||
fun getAllLogs(): List<Log> {
|
{
|
||||||
return logs
|
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 getOvertimeLogs(): List<Log> {
|
// --- Calculation functions that operate on a given list of logs ---
|
||||||
return logs.filter { it.type == EntryType.OVERTIME }
|
|
||||||
|
fun getTotalOvertimeDuration(logs: List<Log>): Duration {
|
||||||
|
return logs.filter { it.type == EntryType.OVERTIME }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTimeOffLogs(): List<Log> {
|
fun getTotalTimeOffDuration(logs: List<Log>): Duration {
|
||||||
return logs.filter { it.type == EntryType.TIME_OFF }
|
return logs.filter { it.type == EntryType.TIME_OFF }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecentLogs(): List<Log> {
|
|
||||||
// This function can now be simplified as the main list is already sorted
|
|
||||||
// We'll just take the most recent 7 items.
|
|
||||||
return logs.take(7)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTotalOvertimeDuration(): Duration {
|
|
||||||
return logs
|
|
||||||
.filter { it.type == EntryType.OVERTIME }
|
|
||||||
.map { it.duration }
|
|
||||||
.fold(Duration.ZERO, Duration::plus)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTotalTimeOffDuration(): Duration {
|
|
||||||
return logs
|
|
||||||
.filter { it.type == EntryType.TIME_OFF }
|
|
||||||
.map { it.duration }
|
|
||||||
.fold(Duration.ZERO, Duration::plus)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun deleteLog(log: Log): Boolean {
|
|
||||||
logs.filter { it.id != log.id }
|
|
||||||
LOG.d("TAG","Deleted log with ID: ${log.id}")
|
|
||||||
return logs.contains(log)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun editLog(log: Log): Boolean{
|
|
||||||
LOG.d("TAG","Edited log with ID: ${log.id}")
|
|
||||||
return logs.contains(log)
|
|
||||||
}
|
}
|
||||||
|
|||||||
237
app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt
Normal file
237
app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package net.tinsae.clocked.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import net.tinsae.clocked.AuthViewModel
|
||||||
|
import net.tinsae.clocked.R
|
||||||
|
import net.tinsae.clocked.data.Locale
|
||||||
|
import net.tinsae.clocked.data.Theme
|
||||||
|
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SettingsViewModel = viewModel(),
|
||||||
|
authViewModel: AuthViewModel
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
if (uiState.showThemeDialog) {
|
||||||
|
ThemeSelectionDialog(
|
||||||
|
currentTheme = uiState.theme,
|
||||||
|
onThemeSelected = viewModel::onThemeSelected,
|
||||||
|
onDismiss = viewModel::onDismissThemeDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showLocaleDialog) {
|
||||||
|
LocaleSelectionDialog(
|
||||||
|
currentLocale = uiState.locale,
|
||||||
|
onLocaleSelected = viewModel::onLocaleSelected,
|
||||||
|
onDismiss = viewModel::onDismissLocaleDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_section_data)) {
|
||||||
|
SettingsItem(
|
||||||
|
title = stringResource(R.string.settings_item_export_csv),
|
||||||
|
onClick = viewModel::onExportClicked,
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ChevronRight,
|
||||||
|
contentDescription = stringResource(R.string.settings_item_navigate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_section_preferences)) {
|
||||||
|
SettingsItem(title = stringResource(R.string.settings_item_default_duration), value = uiState.defaultDuration, onClick = { /* TODO: Handle duration change */ })
|
||||||
|
SettingsItem(title = stringResource(R.string.settings_item_time_rounding), value = uiState.timeRounding, onClick = { /* TODO: Handle rounding change */ })
|
||||||
|
SettingsItem(title = stringResource(R.string.settings_item_week_starts_on), value = uiState.weekStartsOn, onClick = { /* TODO: Handle week start change */ })
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_section_appearance)) {
|
||||||
|
SettingsItem(title = stringResource(R.string.theme), value = uiState.theme.title, onClick = viewModel::onThemeClicked)
|
||||||
|
SettingsItem(title = stringResource(R.string.language), value = uiState.locale.title, onClick = viewModel::onLocaleClicked)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
SettingsSection(title = stringResource(R.string.about)) {
|
||||||
|
SettingsItem(title = stringResource(R.string.settings_item_app_version), value = uiState.appVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSection(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsItem(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
value: String? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
trailingContent: @Composable (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val itemModifier = if (onClick != null) {
|
||||||
|
modifier.clickable(onClick = onClick)
|
||||||
|
} else {
|
||||||
|
modifier
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = itemModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (value != null) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (trailingContent != null) {
|
||||||
|
trailingContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ThemeSelectionDialog(
|
||||||
|
currentTheme: Theme,
|
||||||
|
onThemeSelected: (Theme) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(R.string.theme)) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
Theme.entries.forEach { theme ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = (theme == currentTheme),
|
||||||
|
onClick = { onThemeSelected(theme) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (theme == currentTheme),
|
||||||
|
onClick = null // null recommended for accessibility with screenreaders
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = theme.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocaleSelectionDialog(
|
||||||
|
currentLocale: Locale,
|
||||||
|
onLocaleSelected: (Locale) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(R.string.language)) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
Locale.entries.forEach { locale ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = (locale == currentLocale),
|
||||||
|
onClick = { onLocaleSelected(locale) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (locale == currentLocale),
|
||||||
|
onClick = null // null recommended for accessibility with screenreaders
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = locale.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreenPreview() {
|
||||||
|
ClockedTheme {
|
||||||
|
SettingsScreen(viewModel = SettingsViewModel(), authViewModel = AuthViewModel())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package net.tinsae.clocked.settings
|
||||||
|
|
||||||
|
import androidx.activity.result.launch
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.tinsae.clocked.data.Locale
|
||||||
|
import net.tinsae.clocked.data.LogRepository
|
||||||
|
import net.tinsae.clocked.data.Theme
|
||||||
|
import net.tinsae.clocked.util.CsvExporter
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val defaultDuration: String = "8h",
|
||||||
|
val timeRounding: String = "15 min",
|
||||||
|
val weekStartsOn: String = "Monday",
|
||||||
|
val theme: Theme = Theme.SYSTEM,
|
||||||
|
val locale: Locale = Locale.SYSTEM,
|
||||||
|
val appVersion: String = "1.0.0",
|
||||||
|
val showThemeDialog: Boolean = false,
|
||||||
|
val showLocaleDialog: Boolean = false,
|
||||||
|
// Hold the content of the CSV to be saved. Null means no save operation is pending.
|
||||||
|
val pendingCsvContent: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class SettingsViewModel : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onThemeClicked() {
|
||||||
|
_uiState.update { it.copy(showThemeDialog = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onThemeSelected(theme: Theme) {
|
||||||
|
_uiState.update { it.copy(theme = theme, showThemeDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDismissThemeDialog() {
|
||||||
|
_uiState.update { it.copy(showThemeDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLocaleClicked() {
|
||||||
|
_uiState.update { it.copy(showLocaleDialog = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLocaleSelected(locale: Locale) {
|
||||||
|
_uiState.update { it.copy(locale = locale, showLocaleDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDismissLocaleDialog() {
|
||||||
|
_uiState.update { it.copy(showLocaleDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onExportClicked() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// 1. Fetch data from the service (suspend call)
|
||||||
|
val logs = LogRepository.logs
|
||||||
|
|
||||||
|
// 2. Pass the fetched data to the exporter to generate the CSV string
|
||||||
|
val csvContent = CsvExporter.exportLogsToCsv(logs.value)
|
||||||
|
|
||||||
|
// 3. Update the UI state with the generated content.
|
||||||
|
// MainActivity is already observing this and will trigger the file save.
|
||||||
|
_uiState.update { it.copy(pendingCsvContent = csvContent) }
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Handle any errors during fetching or exporting
|
||||||
|
android.util.Log.e("SettingsViewModel", "Failed to export CSV", e)
|
||||||
|
// Optionally show an error to the user
|
||||||
|
// _uiState.update { it.copy(error = "Export failed.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onExportHandled() {
|
||||||
|
// Clear the pending content after it has been saved or the user has cancelled.
|
||||||
|
_uiState.update { it.copy(pendingCsvContent = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,6 @@ val Typography = Typography(
|
|||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
66
app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt
Normal file
66
app/src/main/java/net/tinsae/clocked/util/CsvExporter.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package net.tinsae.clocked.util
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import android.util.Log as LOG
|
||||||
|
import net.tinsae.clocked.data.EntryType
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import net.tinsae.clocked.data.Log
|
||||||
|
|
||||||
|
object CsvExporter {
|
||||||
|
|
||||||
|
|
||||||
|
fun exportLogsToCsv(logs: List<Log>): String {
|
||||||
|
val csvBuilder = StringBuilder()
|
||||||
|
|
||||||
|
// --- 1. Totals Section with Excel Formulas ---
|
||||||
|
val dataStartRow = 7 // CSV data will start on this row
|
||||||
|
val dataEndRow = dataStartRow + logs.size - 1
|
||||||
|
|
||||||
|
// Totals row
|
||||||
|
csvBuilder.append("Key,Value\n")
|
||||||
|
// Overtime is now in column D, Time Off is in column E
|
||||||
|
csvBuilder.append("Total Overtime (hours),=SUM(D${dataStartRow}:D${dataEndRow})\n")
|
||||||
|
csvBuilder.append("Total Time Off (hours),=SUM(E${dataStartRow}:E${dataEndRow})\n")
|
||||||
|
// Net balance is Overtime + Time Off (since time off is negative)
|
||||||
|
csvBuilder.append("Net Balance (hours),=B2+B3\n")
|
||||||
|
csvBuilder.append("\n") // Spacer row
|
||||||
|
|
||||||
|
// --- 2. Data Table Section ---
|
||||||
|
|
||||||
|
// Headers (Timestamp column removed)
|
||||||
|
csvBuilder.append("ID,Date,Reason,Overtime (hours),Time Off (hours)\n")
|
||||||
|
|
||||||
|
// Date formatter for Excel compatibility
|
||||||
|
//val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
logs.forEachIndexed { index, log ->
|
||||||
|
val rowId = index + 1
|
||||||
|
|
||||||
|
// THE FIX: Format the kotlin.time.Instant correctly
|
||||||
|
val localDateTime = log.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
// Pad month and day with a leading zero if they are single-digit
|
||||||
|
val month = localDateTime.month.number.toString().padStart(2, '0')
|
||||||
|
val day = localDateTime.day.toString().padStart(2, '0')
|
||||||
|
val date = "${localDateTime.year}-${month}-${day}"
|
||||||
|
|
||||||
|
val reason = log.reason?.replace("\"", "\"\"") ?: ""
|
||||||
|
// Use the correct extension function for kotlin.time.Duration
|
||||||
|
val durationInHours = log.duration.inWholeMinutes / 60.0
|
||||||
|
|
||||||
|
val overtime = if (log.type == EntryType.OVERTIME) durationInHours else 0.0
|
||||||
|
val timeOff = if (log.type == EntryType.TIME_OFF) durationInHours else 0.0
|
||||||
|
|
||||||
|
csvBuilder.append(
|
||||||
|
"$rowId,$date,\"$reason\",$overtime,$timeOff\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val csvContent = csvBuilder.toString()
|
||||||
|
LOG.d("CsvExporter", "Generated CSV:\n$csvContent")
|
||||||
|
return csvContent
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt
Normal file
19
app/src/main/java/net/tinsae/clocked/util/SupabaseClient.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package net.tinsae.clocked.util
|
||||||
|
|
||||||
|
import io.github.jan.supabase.auth.Auth
|
||||||
|
import io.github.jan.supabase.createSupabaseClient
|
||||||
|
import io.github.jan.supabase.postgrest.Postgrest
|
||||||
|
import net.tinsae.clocked.BuildConfig
|
||||||
|
|
||||||
|
object SupabaseClient {
|
||||||
|
|
||||||
|
val client by lazy {
|
||||||
|
createSupabaseClient(
|
||||||
|
supabaseUrl = BuildConfig.SUPABASE_URL,
|
||||||
|
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
|
||||||
|
) {
|
||||||
|
install(Auth)
|
||||||
|
install(Postgrest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,43 @@
|
|||||||
package net.tinsae.clocked.util
|
package net.tinsae.clocked.util
|
||||||
|
|
||||||
import java.time.Instant
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.ZoneId
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import kotlin.time.Duration
|
||||||
import java.time.format.FormatStyle
|
import kotlin.time.Instant
|
||||||
import java.time.Duration
|
|
||||||
|
|
||||||
object Util {
|
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 {
|
fun formatTimestampToLocalDateString(instant: Instant): String {
|
||||||
val formatter = DateTimeFormatter
|
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
||||||
.ofLocalizedDate(FormatStyle.MEDIUM) // Creates a locale-aware date format
|
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
.withZone(ZoneId.systemDefault()) // Converts the UTC Instant to the user's local time zone
|
|
||||||
return formatter.format(instant)
|
// 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 {
|
fun formatDuration(duration: Duration): String {
|
||||||
if (duration.isZero) {
|
if (duration.inWholeMinutes == 0L) {
|
||||||
return "0m"
|
return "0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalMinutes = duration.toMinutes()
|
val totalMinutes = duration.inWholeMinutes
|
||||||
val sign = if (totalMinutes < 0) "−" else "+"
|
val sign = if (totalMinutes < 0) "−" else "+"
|
||||||
val absMinutes = kotlin.math.abs(totalMinutes)
|
val absMinutes = kotlin.math.abs(totalMinutes)
|
||||||
val hours = absMinutes / 60
|
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 {
|
fun formatTimestampToMonthYear(instant: Instant): String {
|
||||||
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy").withZone(ZoneId.systemDefault())
|
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
||||||
return formatter.format(instant)
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,71 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clocked</string>
|
<string name="app_name">Clocked</string>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<string name="nav_home">Home</string>
|
||||||
|
<string name="nav_history">History</string>
|
||||||
|
<string name="nav_settings">Settings</string>
|
||||||
|
|
||||||
|
<!-- Common Terms -->
|
||||||
|
<string name="all">All</string>
|
||||||
|
<string name="overtime">Overtime</string>
|
||||||
|
<string name="time_off">Time Off</string>
|
||||||
|
<string name="date">Date</string>
|
||||||
|
<string name="duration">Duration</string>
|
||||||
|
<string name="reason">Reason</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="ok">OK</string>
|
||||||
|
<string name="edit">Edit</string>
|
||||||
|
<string name="delete">Delete</string>
|
||||||
|
<string name="close">Close</string>
|
||||||
|
<string name="theme">Theme</string>
|
||||||
|
<string name="language">Language</string>
|
||||||
|
|
||||||
|
<!-- Add/Edit Log Dialog -->
|
||||||
|
<string name="add_overtime_title">Add Overtime</string>
|
||||||
|
<string name="add_time_off_title">Add Time Off</string>
|
||||||
|
<string name="hours">Hours</string>
|
||||||
|
<string name="minutes">Minutes</string>
|
||||||
|
<string name="reason_optional">Reason (Optional)</string>
|
||||||
|
|
||||||
|
<!-- Dashboard Screen -->
|
||||||
|
<string name="net_balance">Net Balance</string>
|
||||||
|
<string name="add_overtime">+ Overtime</string>
|
||||||
|
<string name="add_time_off">− Time Off</string>
|
||||||
|
<string name="recent_activity">Recent Activity</string>
|
||||||
|
|
||||||
|
<!-- Settings Screen -->
|
||||||
|
<string name="settings_section_data">Data</string>
|
||||||
|
<string name="settings_item_export_csv">Export to CSV</string>
|
||||||
|
<string name="settings_item_navigate">Navigate</string>
|
||||||
|
<string name="settings_section_preferences">Preferences</string>
|
||||||
|
<string name="settings_item_default_duration">Default duration</string>
|
||||||
|
<string name="settings_item_time_rounding">Time rounding</string>
|
||||||
|
<string name="settings_item_week_starts_on">Week starts on</string>
|
||||||
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
|
<string name="settings_item_app_version">App version</string>
|
||||||
|
<string name="about">About</string>
|
||||||
|
|
||||||
|
<!-- Details Dialog -->
|
||||||
|
<string name="details_title">Log Details</string>
|
||||||
|
<string name="details_no_reason">No reason provided</string>
|
||||||
|
<string name="type">Type</string>
|
||||||
|
|
||||||
|
<!-- Error messages -->
|
||||||
|
<string name="error_invalid_credentials">Invalid email or password.</string>
|
||||||
|
<string name="error_user_already_exists">A user with this email already exists.</string>
|
||||||
|
<string name="error_network_issue">A network error occurred. Please check your connection.</string>
|
||||||
|
<string name="error_unknown">An unknown error occurred. Please try again.</string>
|
||||||
|
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<string name="login_welcome">Welcome to Clocked</string>
|
||||||
|
<string name="login_email">Email</string>
|
||||||
|
<string name="login_password">Password</string>
|
||||||
|
<string name="login_button">Log In</string>
|
||||||
|
<string name="signup_button">Sign Up</string>
|
||||||
|
<string name="login_prompt_signup">Don\'t have an account? Sign up</string>
|
||||||
|
<string name="signup_prompt_login">Already have an account? Log in</string>
|
||||||
|
<string name="signup_success_message">Sign-up successful! Please check your email for a confirmation link.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -5,17 +5,22 @@ coreKtx = "1.17.0"
|
|||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
|
kotlinxDatetime = "0.7.1"
|
||||||
|
kotlinxSerializationJson = "1.9.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.2"
|
activityCompose = "1.12.2"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2025.12.01"
|
||||||
lifecycle = "2.8.0"
|
lifecycle = "2.10.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
desugarJdkLibs = "2.1.5"
|
desugarJdkLibs = "2.1.5"
|
||||||
material3WindowSizeClass = "1.4.0"
|
material3WindowSizeClass = "1.4.0"
|
||||||
materialIconsExtended = "1.7.8"
|
materialIconsExtended = "1.7.8"
|
||||||
|
supabase = "3.2.6"
|
||||||
|
ktor = "3.3.3"
|
||||||
|
|
||||||
|
|
||||||
[libraries]
|
[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-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" }
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user