integration supabas
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// Read properties from local.properties
|
||||
val localProperties = Properties()
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.inputStream().use { localProperties.load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -18,6 +28,10 @@ android {
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Expose Supabase keys to the app via BuildConfig
|
||||
buildConfigField("String", "SUPABASE_URL", "\"${localProperties.getProperty("supabase.url")}\"")
|
||||
buildConfigField("String", "SUPABASE_ANON_KEY", "\"${localProperties.getProperty("supabase.anon.key")}\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -41,6 +55,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true // Enable BuildConfig generation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +73,21 @@ dependencies {
|
||||
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
||||
implementation(libs.androidx.compose.material3.window.size.class1)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
// Supabase
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
// --- SUPABASE DEPENDENCIES (Corrected) ---
|
||||
// 1. Implement the BOM using the platform() keyword.
|
||||
implementation(platform(libs.supabase.bom))
|
||||
|
||||
// 2. Implement the specific Supabase modules using the correct aliases.
|
||||
implementation(libs.supabase.auth)
|
||||
implementation(libs.supabase.postgrest)
|
||||
implementation(libs.ktor.client.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.tinsae.clocked.anonymous.RegistrationForm
|
||||
import net.tinsae.clocked.anonymous.SignInForm
|
||||
import net.tinsae.clocked.data.FormType
|
||||
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoginSuccess: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
fun LoginScreen(viewModel: AuthViewModel) {
|
||||
val modifier = Modifier.fillMaxWidth()
|
||||
val uiState = viewModel.uiState
|
||||
|
||||
if (uiState.formType == FormType.LOGIN){
|
||||
SignInForm(
|
||||
modifier = modifier,
|
||||
uiState = uiState,
|
||||
onLogin = viewModel::login,
|
||||
onTogleForm = viewModel::setFormType
|
||||
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
|
||||
// 1. Use imePadding() to automatically handle keyboard insets.
|
||||
// 2. Add verticalScroll to allow scrolling on small screens.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.imePadding() // Automatically adds padding when the keyboard is open
|
||||
.verticalScroll(rememberScrollState()) // Allows scrolling if content is too large
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome to Clocked",
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true
|
||||
}else{
|
||||
RegistrationForm(
|
||||
modifier = modifier,
|
||||
uiState = uiState,
|
||||
onRegister = viewModel::signUp,
|
||||
onTogleForm = viewModel::setFormType
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onLoginSuccess,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Log In")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun LoginScreenPreview() {
|
||||
fun LoginScreenPreview() {
|
||||
ClockedTheme {
|
||||
LoginScreen(onLoginSuccess = {})
|
||||
LoginScreen(AuthViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
package net.tinsae.clocked
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
@@ -17,14 +28,18 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.AccountBox
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -32,139 +47,216 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import net.tinsae.clocked.dashboard.DashboardScreen
|
||||
import net.tinsae.clocked.data.Locale
|
||||
import net.tinsae.clocked.data.Theme
|
||||
import net.tinsae.clocked.history.HistoryScreen
|
||||
import net.tinsae.clocked.settings.SettingsScreen
|
||||
import net.tinsae.clocked.settings.SettingsViewModel
|
||||
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import io.github.jan.supabase.auth.status.SessionStatus
|
||||
import net.tinsae.clocked.data.LogRepository
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
|
||||
private val createDocumentLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("text/csv")
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val content = settingsViewModel.uiState.value.pendingCsvContent
|
||||
if (content != null) {
|
||||
try {
|
||||
contentResolver.openOutputStream(it)?.use { outputStream ->
|
||||
outputStream.writer().use { writer ->
|
||||
writer.write(content)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error writing to file", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always reset the state
|
||||
settingsViewModel.onExportHandled()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
lifecycleScope.launch {
|
||||
settingsViewModel.uiState
|
||||
.map { it.pendingCsvContent }
|
||||
.distinctUntilChanged()
|
||||
.collect { csvContent ->
|
||||
if (csvContent != null) {
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US).format(Date())
|
||||
createDocumentLauncher.launch("Clocked-Export-$timestamp.csv")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ClockedTheme {
|
||||
// The root of the app now decides whether to show Login or Main content
|
||||
AppEntry()
|
||||
}
|
||||
// The single ViewModel instances from the Activity are passed down.
|
||||
AppEntry(settingsViewModel, authViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppEntry() {
|
||||
// For now, we'll fake authentication. In a real app, this would come from a ViewModel or repository.
|
||||
var isAuthenticated by rememberSaveable { mutableStateOf(true) } // Start as not authenticated
|
||||
fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
|
||||
val settingsState by settingsViewModel.uiState.collectAsState()
|
||||
val sessionStatus by authViewModel.sessionStatus.collectAsState()
|
||||
|
||||
if (isAuthenticated) {
|
||||
// If authenticated, show the main app UI
|
||||
ClockedApp()
|
||||
val useDarkTheme = when (settingsState.theme) {
|
||||
Theme.LIGHT -> false
|
||||
Theme.DARK -> true
|
||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
updateLocale(context, settingsState.locale)
|
||||
|
||||
ClockedTheme(darkTheme = useDarkTheme) {
|
||||
// Use a 'when' statement to handle all possible states.
|
||||
when (sessionStatus) {
|
||||
is SessionStatus.Authenticated -> {
|
||||
// If we know the user is authenticated, show the main app.
|
||||
ClockedApp(settingsViewModel, authViewModel,sessionStatus)
|
||||
}
|
||||
|
||||
is SessionStatus.NotAuthenticated -> {
|
||||
// Only if we are certain the user is not logged in, show the login screen.
|
||||
LoginScreen(authViewModel)
|
||||
}
|
||||
|
||||
is SessionStatus.Initializing -> {
|
||||
// While Supabase is loading the session, show a loading indicator.
|
||||
// This prevents the flicker.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLocale(context: Context, locale: Locale) {
|
||||
val localeTag = locale.tag.ifEmpty { null }
|
||||
val localeList = if (localeTag != null) {
|
||||
LocaleListCompat.forLanguageTags(localeTag)
|
||||
} else {
|
||||
// If not, show the Login screen
|
||||
LoginScreen(onLoginSuccess = { })
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
fun ClockedApp() {
|
||||
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel, sessionStatus: SessionStatus) {
|
||||
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(), // Removed background for clarity
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Box(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Apply padding ONLY for the top safe area (status bar)
|
||||
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues())
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
.background(colorScheme.surface)
|
||||
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()),
|
||||
//contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 2. Use IconButton to make the icon clickable with a ripple effect
|
||||
IconButton(onClick = { authViewModel.logout() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout, // Replace with your logout icon
|
||||
contentDescription = "Logout"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding -> // This padding from the parent Scaffold accounts for the TopBar.
|
||||
//val layoutDirection = LocalLayoutDirection.current
|
||||
) { innerPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val contentPadding = PaddingValues(
|
||||
// Respect the horizontal padding for gestures, etc.
|
||||
//start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
//end = innerPadding.calculateEndPadding(layoutDirection),
|
||||
// Respect the top padding to stay below the top bar.
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateEndPadding(layoutDirection),
|
||||
top = innerPadding.calculateTopPadding(),
|
||||
// CRITICAL: Ignore the bottom padding from the parent Scaffold.
|
||||
bottom = 0.dp
|
||||
)
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
// Apply our custom-calculated padding.
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
navigationSuiteItems = {
|
||||
AppDestinations.entries.forEach {
|
||||
AppDestinations.entries.forEach { destination ->
|
||||
item(
|
||||
icon = { Icon(it.icon, contentDescription = it.label) },
|
||||
label = { Text(it.label) },
|
||||
selected = it == currentDestination,
|
||||
onClick = { currentDestination = it }
|
||||
icon = { Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label))
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
selected = destination == currentDestination,
|
||||
onClick = { currentDestination = destination }
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
// The router now shows the correct screen based on the destination.
|
||||
when (currentDestination) {
|
||||
AppDestinations.HOME -> DashboardScreen(modifier = Modifier.fillMaxSize())
|
||||
AppDestinations.HOME -> DashboardScreen( modifier = Modifier.fillMaxSize())
|
||||
AppDestinations.HISTORY -> HistoryScreen(modifier = Modifier.fillMaxSize())
|
||||
AppDestinations.PROFILE -> Greeting(name = "Profile", modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
enum class AppDestinations(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
HOME("Home", Icons.Default.Home),
|
||||
HISTORY("History", Icons.AutoMirrored.Filled.List),
|
||||
PROFILE("Profile", Icons.Default.AccountBox),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
AppDestinations.SETTING -> SettingsScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = settingsViewModel,
|
||||
authViewModel = authViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
/*
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
ClockedTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// --- PREVIEW THE ACTUAL APP ENTRY POINT ---
|
||||
enum class AppDestinations(
|
||||
val label: Int,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
HOME(R.string.nav_home, Icons.Default.Home),
|
||||
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
|
||||
SETTING(R.string.nav_settings, Icons.Default.Settings),
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun AppEntryPreview() {
|
||||
ClockedTheme {
|
||||
// This preview will correctly show the LoginScreen because isAuthenticated starts as false.
|
||||
AppEntry()
|
||||
AppEntry(SettingsViewModel(), AuthViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -18,15 +17,14 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -36,18 +34,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration // Use stable LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.coroutines.launch
|
||||
import net.tinsae.clocked.R
|
||||
import net.tinsae.clocked.data.Log
|
||||
import net.tinsae.clocked.service.deleteLog
|
||||
import net.tinsae.clocked.service.editLog
|
||||
import net.tinsae.clocked.service.getRecentLogs
|
||||
import net.tinsae.clocked.ui.theme.cyan
|
||||
import net.tinsae.clocked.ui.theme.red
|
||||
import net.tinsae.clocked.util.Util.formatDuration
|
||||
@@ -56,30 +51,28 @@ import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ActivityList(
|
||||
items: List<Log>,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onEditLog: (Log) -> Unit = {},
|
||||
onDeleteLog: (Log) -> Unit = {},
|
||||
recentLogs: List<Log>
|
||||
) {
|
||||
var mutableItems by remember { mutableStateOf(items) }
|
||||
//var mutableItems by remember { mutableStateOf(items) }
|
||||
// State to hold the log that should be shown in the dialog. Null means no dialog.
|
||||
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Recent Activity",
|
||||
text = stringResource(R.string.recent_activity),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyColumn {
|
||||
items(mutableItems, key = { it.id }) { log ->
|
||||
items(recentLogs, key = { it.id }) { log ->
|
||||
ActivityItem(
|
||||
log = log,
|
||||
onEdit = { editLog(log) },
|
||||
onDelete = {
|
||||
if (deleteLog(log)) {
|
||||
mutableItems = mutableItems.filter { it.id != log.id }
|
||||
}
|
||||
},
|
||||
onEdit = { onEditLog(log) },
|
||||
onDelete = {onDeleteLog(log) },
|
||||
// When the item is clicked, set it as the selected log for the dialog
|
||||
onClick = { selectedLogForDialog = log }
|
||||
)
|
||||
@@ -105,13 +98,13 @@ fun ActivityItem(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
val animatedOffsetX by animateFloatAsState(targetValue = offsetX, label = "offset")
|
||||
|
||||
// Use stable LocalConfiguration
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
val screenWidthPx = LocalWindowInfo.current.containerSize.width
|
||||
|
||||
// Define thresholds for swiping
|
||||
val editThreshold = screenWidthPx * 0.4f
|
||||
val deleteThreshold = -screenWidthPx * 0.4f
|
||||
|
||||
@@ -132,7 +125,7 @@ fun ActivityItem(
|
||||
if (animatedOffsetX > 0) { // Swiping right
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
contentDescription = stringResource(R.string.edit),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
@@ -140,7 +133,7 @@ fun ActivityItem(
|
||||
if (animatedOffsetX < 0) { // Swiping left
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
@@ -201,5 +194,5 @@ fun ActivityItem(
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun ActivityItemPreview() {
|
||||
ActivityList(items = getRecentLogs())
|
||||
ActivityList(recentLogs = emptyList(), onEditLog = {_->{}}, onDeleteLog = {_->{}})
|
||||
}
|
||||
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.tinsae.clocked.R
|
||||
import net.tinsae.clocked.data.EntryType
|
||||
import net.tinsae.clocked.data.Log
|
||||
import net.tinsae.clocked.util.Util.formatDuration
|
||||
import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString
|
||||
@@ -35,23 +38,30 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) {
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = "Log Details", style = MaterialTheme.typography.titleLarge)
|
||||
Text(text = stringResource(R.string.details_title), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
DetailRow("Date:", formatTimestampToLocalDateString(log.timestamp))
|
||||
DetailRow("Type:", log.type.toString())
|
||||
DetailRow("Duration:", formatDuration(log.duration))
|
||||
DetailRow("Reason:", log.reason ?: "No reason provided")
|
||||
DetailRow(stringResource(R.string.date), formatTimestampToLocalDateString(log.timestamp))
|
||||
DetailRow(stringResource(R.string.type), log.type.toString())
|
||||
DetailRow(stringResource(R.string.duration), formatDuration(log.duration))
|
||||
DetailRow(stringResource(R.string.reason), log.reason ?: stringResource(R.string.details_no_reason))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Close")
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun getType(type: EntryType):String{
|
||||
return if(type==EntryType.OVERTIME) stringResource(R.string.overtime) else stringResource(R.string.time_off)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
// buildAnnotatedString allows mixing different styles in one Text composable.
|
||||
|
||||
@@ -15,17 +15,24 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.github.jan.supabase.auth.status.SessionStatus
|
||||
import net.tinsae.clocked.R
|
||||
import net.tinsae.clocked.components.ActivityList
|
||||
import net.tinsae.clocked.components.AddLogDialog
|
||||
import net.tinsae.clocked.data.EntryType
|
||||
import net.tinsae.clocked.data.LogRepository
|
||||
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||
import net.tinsae.clocked.ui.theme.cyan
|
||||
import net.tinsae.clocked.ui.theme.green
|
||||
@@ -38,6 +45,18 @@ fun DashboardScreen(
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LogRepository.fetchLogs()
|
||||
}
|
||||
|
||||
if (uiState.showAddLogDialog) {
|
||||
AddLogDialog(
|
||||
type = uiState.dialogType,
|
||||
onDismiss = viewModel::onDismissDialog,
|
||||
onSave = viewModel::onSaveLog
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -47,14 +66,14 @@ fun DashboardScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
SummaryCard(title = "Overtime", value = uiState.overtime, color = green, modifier = Modifier.weight(1f))
|
||||
SummaryCard(title = stringResource(id = R.string.overtime), value = uiState.overtime, color = green, modifier = Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
SummaryCard(title = "Time Off", value = uiState.timeOff, color = red, modifier = Modifier.weight(1f))
|
||||
SummaryCard(title = stringResource(id = R.string.time_off), value = uiState.timeOff, color = red, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NetBalanceCard(value = uiState.netBalance)
|
||||
NetBalanceCard(value = uiState.netBalance+" / "+uiState.balanceInDays, modifier = Modifier.fillMaxWidth())
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -62,16 +81,26 @@ fun DashboardScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
ActionButton(text = "+ Overtime", modifier = Modifier.weight(1f))
|
||||
ActionButton(
|
||||
text = stringResource(id = R.string.add_overtime),
|
||||
onClick = { viewModel.onAddLogClicked(EntryType.OVERTIME) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
ActionButton(text = "− Time Off", modifier = Modifier.weight(1f))
|
||||
ActionButton(
|
||||
text = stringResource(id = R.string.add_time_off),
|
||||
onClick = { viewModel.onAddLogClicked(EntryType.TIME_OFF) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ActivityList(
|
||||
items = uiState.recentActivities,
|
||||
modifier = Modifier.weight(1f)
|
||||
recentLogs = uiState.recentActivities,
|
||||
modifier = Modifier.weight(1f),
|
||||
onEditLog = viewModel::editLog,
|
||||
onDeleteLog = viewModel::deleteLog
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +135,7 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = "Net Balance", style = MaterialTheme. typography.titleLarge)
|
||||
Text(text = stringResource(id = R.string.net_balance), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = value, style = MaterialTheme.typography.headlineLarge, color = cyan, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
@@ -114,10 +143,11 @@ fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(text: String, modifier: Modifier = Modifier) {
|
||||
fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = modifier.fillMaxWidth() ) {
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = text, fontSize = 16.sp, modifier = Modifier.padding(6.dp))
|
||||
}
|
||||
}
|
||||
@@ -126,6 +156,6 @@ fun ActionButton(text: String, modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun DashboardScreenPreview() {
|
||||
ClockedTheme {
|
||||
DashboardScreen(viewModel = DashboardViewModel())
|
||||
//DashboardScreen(viewModel = DashboardViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,113 @@
|
||||
package net.tinsae.clocked.dashboard
|
||||
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import net.tinsae.clocked.data.EntryType
|
||||
import net.tinsae.clocked.data.Log
|
||||
import net.tinsae.clocked.service.getRecentLogs
|
||||
import net.tinsae.clocked.service.getTotalOvertimeDuration
|
||||
import net.tinsae.clocked.service.getTotalTimeOffDuration
|
||||
import net.tinsae.clocked.data.LogRepository
|
||||
import net.tinsae.clocked.util.Util.formatDuration
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
|
||||
data class DashboardUiState(
|
||||
val overtime: String = "0m",
|
||||
val timeOff: String = "0m",
|
||||
val netBalance: String = "0m",
|
||||
val recentActivities: List<Log> = emptyList()
|
||||
val balanceInDays: String = "0d",
|
||||
val recentActivities: List<Log> = emptyList(),
|
||||
val showAddLogDialog: Boolean = false,
|
||||
val dialogType: EntryType = EntryType.OVERTIME // Default, will be updated
|
||||
)
|
||||
|
||||
class DashboardViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||
val uiState: StateFlow<DashboardUiState> = _uiState
|
||||
// This is the state holder for UI-driven events (e.g., showing a dialog)
|
||||
private val _internalState = MutableStateFlow(DashboardUiState())
|
||||
|
||||
init {
|
||||
loadDashboardData()
|
||||
}
|
||||
val uiState: StateFlow<DashboardUiState> = combine(
|
||||
LogRepository.logs, // Source 1: The List<Log>
|
||||
_internalState // Source 2: The internal UI state
|
||||
) { logs, internalState ->
|
||||
// THE FIX: The 'logs' parameter is already the List<Log>.
|
||||
// You do not need to call .first() on it.
|
||||
val allLogs = logs
|
||||
|
||||
private fun loadDashboardData() {
|
||||
val recentLogs = getRecentLogs()
|
||||
val overtimeDuration = allLogs.filter{ it.type == EntryType.OVERTIME }
|
||||
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
|
||||
|
||||
val overtimeDuration = getTotalOvertimeDuration()
|
||||
val timeOffDuration = getTotalTimeOffDuration()
|
||||
val timeOffDuration = allLogs.filter { it.type == EntryType.TIME_OFF }
|
||||
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
|
||||
|
||||
// timeOffDuration is negative, so we add it to get the difference
|
||||
val netBalanceDuration = overtimeDuration.plus(timeOffDuration)
|
||||
// Construct the final state using data from both sources.
|
||||
val netBalanceDuration = overtimeDuration + timeOffDuration
|
||||
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
|
||||
|
||||
_uiState.value = DashboardUiState(
|
||||
// 2. Then, calculate the balance in days (assuming an 8-hour workday)
|
||||
// We format it to one decimal place for a clean look.
|
||||
val balanceInDaysValue = netBalanceInHours / 8.0
|
||||
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " days"
|
||||
|
||||
|
||||
|
||||
DashboardUiState(
|
||||
overtime = formatDuration(overtimeDuration),
|
||||
timeOff = formatDuration(timeOffDuration),
|
||||
netBalance = formatDuration(netBalanceDuration),
|
||||
recentActivities = recentLogs
|
||||
netBalance = formatDuration(overtimeDuration + timeOffDuration),
|
||||
recentActivities = allLogs.take(7),
|
||||
balanceInDays = balanceInDaysString,
|
||||
// Pass through the dialog state from the internal state holder
|
||||
showAddLogDialog = internalState.showAddLogDialog,
|
||||
dialogType = internalState.dialogType
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DashboardUiState()
|
||||
)
|
||||
|
||||
|
||||
fun onAddLogClicked(type: EntryType) {
|
||||
// This correctly updates the internal state, which triggers the 'combine' to re-run.
|
||||
_internalState.update { it.copy(showAddLogDialog = true, dialogType = type) }
|
||||
}
|
||||
|
||||
fun onDismissDialog() {
|
||||
_internalState.update { it.copy(showAddLogDialog = false) }
|
||||
}
|
||||
|
||||
fun onSaveLog(timestamp: Instant, duration: Duration, reason: String?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
LogRepository.addLog(
|
||||
// Use the dialogType from the internal state's current value
|
||||
type = _internalState.value.dialogType,
|
||||
timestamp = timestamp,
|
||||
duration = duration,
|
||||
reason = reason
|
||||
)
|
||||
// On success, hide the dialog. The repository will trigger the data refresh.
|
||||
onDismissDialog()
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Handle errors
|
||||
android.util.Log.e("DashboardViewModel", "Failed to save log", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun editLog(log: Log) {
|
||||
LogRepository.editLog(log)
|
||||
}
|
||||
|
||||
fun deleteLog(log: Log) {
|
||||
LogRepository.deleteLog(log)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class Log(
|
||||
val id: Int,
|
||||
val duration: Duration,
|
||||
val reason: String?,
|
||||
val id: Long,
|
||||
val timestamp: Instant,
|
||||
val type: EntryType,
|
||||
val timestamp: Instant
|
||||
@Serializable(with = IntervalSerialiser::class)
|
||||
val duration: Duration,
|
||||
val reason: String? = null,
|
||||
@SerialName("user_id")
|
||||
val userId: String? = null // This will be populated by Supabase
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LogEntry(
|
||||
val timestamp: Instant,
|
||||
val type: EntryType,
|
||||
@Serializable(with = IntervalSerialiser::class) // Can reuse the serializer
|
||||
val duration: Duration,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class EntryType {
|
||||
OVERTIME, TIME_OFF
|
||||
}
|
||||
enum class FormType {
|
||||
LOGIN, SIGNUP
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,102 +15,78 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import net.tinsae.clocked.R
|
||||
import net.tinsae.clocked.components.ActivityItem
|
||||
import net.tinsae.clocked.components.DetailsDialog
|
||||
import net.tinsae.clocked.data.Log
|
||||
import net.tinsae.clocked.service.deleteLog
|
||||
import net.tinsae.clocked.service.editLog
|
||||
import net.tinsae.clocked.service.getAllLogs
|
||||
import net.tinsae.clocked.service.getOvertimeLogs
|
||||
import net.tinsae.clocked.service.getTimeOffLogs
|
||||
import net.tinsae.clocked.ui.theme.ClockedTheme
|
||||
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun HistoryScreen(modifier: Modifier = Modifier) {
|
||||
var allEntries by remember { mutableStateOf(getAllLogs()) }
|
||||
val overTimes by remember { mutableStateOf( getOvertimeLogs())}
|
||||
val timeOffs by remember { mutableStateOf( getTimeOffLogs())}
|
||||
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
|
||||
fun HistoryScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HistoryViewModel = viewModel()
|
||||
) {
|
||||
val tabTitles = listOf(
|
||||
stringResource(id = R.string.all),
|
||||
stringResource(id = R.string.overtime),
|
||||
stringResource(id = R.string.time_off)
|
||||
)
|
||||
|
||||
|
||||
val tabs = listOf("All", "Overtime", "Time Off")
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Filter the list based on the selected tab
|
||||
// I want this done in service. so I have functions for them
|
||||
val filteredEntries = remember(selectedTabIndex) {
|
||||
when (tabs[selectedTabIndex]) {
|
||||
"Overtime" -> overTimes
|
||||
"Time Off" -> timeOffs
|
||||
else -> allEntries
|
||||
}
|
||||
LaunchedEffect(tabTitles) {
|
||||
viewModel.setTabTitles(tabTitles)
|
||||
}
|
||||
|
||||
// Group the filtered entries by month and year
|
||||
val groupedEntries = remember(filteredEntries) {
|
||||
// SIMPLIFIED: No need to destructure a Pair
|
||||
filteredEntries.groupBy { log ->
|
||||
formatTimestampToMonthYear(log.timestamp)
|
||||
}
|
||||
}
|
||||
val uiState: HistoryUiState by viewModel.uiState.collectAsState()
|
||||
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxSize()
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
PrimaryTabRow (selectedTabIndex = selectedTabIndex) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
if (uiState.tabs.isNotEmpty()) {
|
||||
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex) {
|
||||
uiState.tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { selectedTabIndex = index },
|
||||
selected = uiState.selectedTabIndex == index,
|
||||
onClick = { viewModel.onTabSelected(index) },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
//.padding(horizontal = 16.dp)
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Iterate through the grouped map
|
||||
groupedEntries.forEach { (monthYear, entries) ->
|
||||
// Add a sticky header for the month
|
||||
uiState.groupedEntries.forEach { (monthYear, entries) ->
|
||||
stickyHeader {
|
||||
MonthHeader(text = monthYear)
|
||||
}
|
||||
|
||||
// Add the items for that month
|
||||
items(entries, key = { it.id }) { entry ->
|
||||
ActivityItem(log = entry,
|
||||
ActivityItem(
|
||||
log = entry,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onDelete = { deleteLog(entry) },
|
||||
onEdit = { editLog(entry) },
|
||||
onClick = { selectedLogForDialog = entry }
|
||||
onDelete = { viewModel.deleteLog(entry) },
|
||||
onEdit = { viewModel.editLog(entry) },
|
||||
onClick = { viewModel.onLogSelected(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a log is selected, show the dialog.
|
||||
selectedLogForDialog?.let { log ->
|
||||
uiState.selectedLogForDialog?.let { log ->
|
||||
DetailsDialog(
|
||||
log = log,
|
||||
onDismiss = { selectedLogForDialog = null }
|
||||
onDismiss = { viewModel.onDismissDialog() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +96,6 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
// Use the theme's background color to overlay content correctly
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
Text(
|
||||
@@ -131,11 +107,11 @@ fun MonthHeader(text: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("ViewModelConstructorInComposable")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun HistoryScreenPreview() {
|
||||
ClockedTheme {
|
||||
HistoryScreen()
|
||||
HistoryScreen(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.runtime.retain.retain
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.query.Order
|
||||
import net.tinsae.clocked.data.EntryType
|
||||
import net.tinsae.clocked.data.Log
|
||||
import android.util.Log as LOG
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.random.Random
|
||||
import net.tinsae.clocked.data.LogEntry
|
||||
import net.tinsae.clocked.data.SupabaseClient
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.Duration
|
||||
|
||||
// --- Data for programmatic generation ---
|
||||
private val overtimeReasons = listOf("Project deadline", "Production hotfix", "Client demo preparation", "Server maintenance", "Completed quarterly reports", "Feature implementation")
|
||||
private val timeOffReasons = listOf("Left early", "Doctor's appointment", "Personal errand", "Family matter", "Vacation day - Completed quarterly reports")
|
||||
// --- Public API for accessing and modifying logs --- //
|
||||
|
||||
// UPDATED: Values are now in ISO 8601 format, matching Supabase's INTERVAL output
|
||||
private val overtimeValues = listOf("PT1H", "PT2H30M", "PT45M", "PT3H15M", "PT1H45M")
|
||||
private val timeOffValues = listOf("-PT8H", "-PT4H", "-PT1H30M", "-PT2H")
|
||||
suspend fun getAllLogsFromDB(): List<Log> {
|
||||
try {
|
||||
val logs = SupabaseClient.client.postgrest.from("Logs").select {
|
||||
order("timestamp", Order.DESCENDING)
|
||||
}.decodeList<Log>()
|
||||
LOG.d("LogService", "Fetched ${logs.size} logs")
|
||||
return logs
|
||||
} catch (e: Exception) {
|
||||
LOG.e("LogService", "Error fetching logs", e)
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Programmatically generate a list of 30 random logs ---
|
||||
private fun generateRandomLogs(): List<Log> {
|
||||
val logs = mutableListOf<Log>()
|
||||
for (i in 1..30) {
|
||||
val randomDaysAgo = Random.nextLong(0, 365)
|
||||
val timestamp = Instant.now().minus(randomDaysAgo, ChronoUnit.DAYS)
|
||||
val isOvertime = Random.nextBoolean()
|
||||
suspend fun getRecentLogsFromDB(): List<Log> {
|
||||
return getAllLogsFromDB().take(5)
|
||||
}
|
||||
|
||||
val log = if (isOvertime) {
|
||||
Log(
|
||||
id = i,
|
||||
// Use Duration.parse() to convert the string to a Duration object
|
||||
duration = Duration.parse(overtimeValues.random()),
|
||||
reason = if (Random.nextBoolean()) overtimeReasons.random() else null,
|
||||
type = EntryType.OVERTIME,
|
||||
timestamp = timestamp
|
||||
)
|
||||
} else {
|
||||
Log(
|
||||
id = i,
|
||||
// Use Duration.parse() here as well
|
||||
duration = Duration.parse(timeOffValues.random()),
|
||||
reason = if (Random.nextBoolean()) timeOffReasons.random() else null,
|
||||
type = EntryType.TIME_OFF,
|
||||
suspend fun addLogToDB(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
|
||||
val newLog = LogEntry(
|
||||
duration = duration,
|
||||
reason = reason,
|
||||
type = type,
|
||||
timestamp = timestamp
|
||||
)
|
||||
try {
|
||||
SupabaseClient.client.postgrest["Logs"].insert(newLog)
|
||||
} catch (e: Exception) {
|
||||
LOG.e("LogService", "Error adding log", e)
|
||||
}
|
||||
logs.add(log)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
val logs: List<Log> = generateRandomLogs().sortedByDescending { it.timestamp }
|
||||
|
||||
fun getAllLogs(): List<Log> {
|
||||
return logs
|
||||
suspend fun editLogFromDB(updatedLog: Log): Boolean {
|
||||
return try {
|
||||
SupabaseClient.client.postgrest.from("Logs").update(
|
||||
{
|
||||
set("timestamp", updatedLog.timestamp)
|
||||
set("type", updatedLog.type)
|
||||
set("duration", updatedLog.duration)
|
||||
set("reason", updatedLog.reason)
|
||||
}
|
||||
) {
|
||||
filter {
|
||||
eq("id", updatedLog.id)
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
LOG.e("LogService", "Error editing log", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getOvertimeLogs(): List<Log> {
|
||||
return logs.filter { it.type == EntryType.OVERTIME }
|
||||
// --- Calculation functions that operate on a given list of logs ---
|
||||
|
||||
fun getTotalOvertimeDuration(logs: List<Log>): Duration {
|
||||
return logs.filter { it.type == EntryType.OVERTIME }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
|
||||
}
|
||||
|
||||
fun getTimeOffLogs(): List<Log> {
|
||||
return logs.filter { it.type == EntryType.TIME_OFF }
|
||||
}
|
||||
|
||||
fun getRecentLogs(): List<Log> {
|
||||
// This function can now be simplified as the main list is already sorted
|
||||
// We'll just take the most recent 7 items.
|
||||
return logs.take(7)
|
||||
}
|
||||
|
||||
fun getTotalOvertimeDuration(): Duration {
|
||||
return logs
|
||||
.filter { it.type == EntryType.OVERTIME }
|
||||
.map { it.duration }
|
||||
.fold(Duration.ZERO, Duration::plus)
|
||||
}
|
||||
|
||||
fun getTotalTimeOffDuration(): Duration {
|
||||
return logs
|
||||
.filter { it.type == EntryType.TIME_OFF }
|
||||
.map { it.duration }
|
||||
.fold(Duration.ZERO, Duration::plus)
|
||||
}
|
||||
|
||||
|
||||
fun deleteLog(log: Log): Boolean {
|
||||
logs.filter { it.id != log.id }
|
||||
LOG.d("TAG","Deleted log with ID: ${log.id}")
|
||||
return logs.contains(log)
|
||||
}
|
||||
|
||||
fun editLog(log: Log): Boolean{
|
||||
LOG.d("TAG","Edited log with ID: ${log.id}")
|
||||
return logs.contains(log)
|
||||
fun getTotalTimeOffDuration(logs: List<Log>): Duration {
|
||||
return logs.filter { it.type == EntryType.TIME_OFF }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
*/
|
||||
|
||||
|
||||
)
|
||||
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
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.Duration
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
object Util {
|
||||
|
||||
|
||||
/**
|
||||
* Formats an Instant to a human-readable date string.
|
||||
* @param instant The Instant to format.
|
||||
* @return A formatted date string.
|
||||
*/
|
||||
fun formatTimestampToLocalDateString(instant: Instant): String {
|
||||
val formatter = DateTimeFormatter
|
||||
.ofLocalizedDate(FormatStyle.MEDIUM) // Creates a locale-aware date format
|
||||
.withZone(ZoneId.systemDefault()) // Converts the UTC Instant to the user's local time zone
|
||||
return formatter.format(instant)
|
||||
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
|
||||
// 2. Extract the components and build the string manually.
|
||||
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
||||
val day = localDateTime.day
|
||||
val year = localDateTime.year
|
||||
|
||||
return "$month $day, $year"
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Formats a Duration to a human-readable duration string.
|
||||
* @param duration The Duration to format.
|
||||
* @return A formatted duration string.
|
||||
*/
|
||||
fun formatDuration(duration: Duration): String {
|
||||
if (duration.isZero) {
|
||||
if (duration.inWholeMinutes == 0L) {
|
||||
return "0m"
|
||||
}
|
||||
|
||||
val totalMinutes = duration.toMinutes()
|
||||
val totalMinutes = duration.inWholeMinutes
|
||||
val sign = if (totalMinutes < 0) "−" else "+"
|
||||
val absMinutes = kotlin.math.abs(totalMinutes)
|
||||
val hours = absMinutes / 60
|
||||
@@ -41,8 +56,19 @@ object Util {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats an Instant to a human-readable month and year string.
|
||||
* @param instant The Instant to format.
|
||||
* @return A formatted month and year string.
|
||||
*/
|
||||
fun formatTimestampToMonthYear(instant: Instant): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy").withZone(ZoneId.systemDefault())
|
||||
return formatter.format(instant)
|
||||
// 1. Convert the Instant to a LocalDateTime in the system's current time zone.
|
||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
|
||||
// 2. Extract the components and build the string.
|
||||
val month = localDateTime.month.name.lowercase().replaceFirstChar { it.uppercaseChar() }
|
||||
val year = localDateTime.year
|
||||
|
||||
return "$month $year"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,71 @@
|
||||
<resources>
|
||||
<string name="app_name">Clocked</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_home">Home</string>
|
||||
<string name="nav_history">History</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
|
||||
<!-- Common Terms -->
|
||||
<string name="all">All</string>
|
||||
<string name="overtime">Overtime</string>
|
||||
<string name="time_off">Time Off</string>
|
||||
<string name="date">Date</string>
|
||||
<string name="duration">Duration</string>
|
||||
<string name="reason">Reason</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="language">Language</string>
|
||||
|
||||
<!-- Add/Edit Log Dialog -->
|
||||
<string name="add_overtime_title">Add Overtime</string>
|
||||
<string name="add_time_off_title">Add Time Off</string>
|
||||
<string name="hours">Hours</string>
|
||||
<string name="minutes">Minutes</string>
|
||||
<string name="reason_optional">Reason (Optional)</string>
|
||||
|
||||
<!-- Dashboard Screen -->
|
||||
<string name="net_balance">Net Balance</string>
|
||||
<string name="add_overtime">+ Overtime</string>
|
||||
<string name="add_time_off">− Time Off</string>
|
||||
<string name="recent_activity">Recent Activity</string>
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<string name="settings_section_data">Data</string>
|
||||
<string name="settings_item_export_csv">Export to CSV</string>
|
||||
<string name="settings_item_navigate">Navigate</string>
|
||||
<string name="settings_section_preferences">Preferences</string>
|
||||
<string name="settings_item_default_duration">Default duration</string>
|
||||
<string name="settings_item_time_rounding">Time rounding</string>
|
||||
<string name="settings_item_week_starts_on">Week starts on</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_item_app_version">App version</string>
|
||||
<string name="about">About</string>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<string name="details_title">Log Details</string>
|
||||
<string name="details_no_reason">No reason provided</string>
|
||||
<string name="type">Type</string>
|
||||
|
||||
<!-- Error messages -->
|
||||
<string name="error_invalid_credentials">Invalid email or password.</string>
|
||||
<string name="error_user_already_exists">A user with this email already exists.</string>
|
||||
<string name="error_network_issue">A network error occurred. Please check your connection.</string>
|
||||
<string name="error_unknown">An unknown error occurred. Please try again.</string>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<string name="login_welcome">Welcome to Clocked</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_button">Log In</string>
|
||||
<string name="signup_button">Sign Up</string>
|
||||
<string name="login_prompt_signup">Don\'t have an account? Sign up</string>
|
||||
<string name="signup_prompt_login">Already have an account? Log in</string>
|
||||
<string name="signup_success_message">Sign-up successful! Please check your email for a confirmation link.</string>
|
||||
|
||||
</resources>
|
||||
@@ -5,17 +5,22 @@ coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
kotlinxSerializationJson = "1.9.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.12.2"
|
||||
composeBom = "2025.12.01"
|
||||
lifecycle = "2.8.0"
|
||||
|
||||
lifecycle = "2.10.0"
|
||||
appcompat = "1.7.1"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
material3WindowSizeClass = "1.4.0"
|
||||
materialIconsExtended = "1.7.8"
|
||||
supabase = "3.2.6"
|
||||
ktor = "3.3.3"
|
||||
|
||||
|
||||
[libraries]
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-compose-material3-window-size-class1 = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "material3WindowSizeClass" }
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
@@ -36,8 +41,18 @@ androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compo
|
||||
android-desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
|
||||
|
||||
# supabase
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
supabase-bom = { group = "io.github.jan-tennert.supabase", name = "bom", version.ref = "supabase" }
|
||||
supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt" }
|
||||
supabase-auth = { group = "io.github.jan-tennert.supabase", name = "auth-kt" }
|
||||
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
|
||||
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user