From 66e3bbb004942b271ce85b340b701deeaa461575 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Tue, 30 Dec 2025 20:46:37 +0100 Subject: [PATCH] Biometric authentication implemented and integrated in dashboard --- .idea/deploymentTargetSelector.xml | 6 + app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 5 +- .../java/net/tinsae/clocked/MainActivity.kt | 136 +++++--------- .../clocked/biometric/AuthenticationDialog.kt | 42 +++++ .../biometric/AuthenticationManager.kt | 31 ++++ .../biometric/BiometricAuthenticator.kt | 54 ++++++ .../clocked/biometric/GlobalAuthenticator.kt | 34 ++++ .../tinsae/clocked/components/AddLogDialog.kt | 67 +++++-- .../clocked/components/DetailsDialog.kt | 6 - .../net/tinsae/clocked/components/ListItem.kt | 18 +- .../clocked/components/LoadingAnimation.kt | 57 ++++++ .../net/tinsae/clocked/components/TopBar.kt | 67 +++++++ .../clocked/dashboard/DashboardScreen.kt | 174 +++++++++--------- .../clocked/dashboard/DashboardViewModel.kt | 46 +++-- .../net/tinsae/clocked/data/LogRepository.kt | 2 +- .../tinsae/clocked/history/HistoryScreen.kt | 8 +- .../clocked/history/HistoryViewModel.kt | 12 +- .../tinsae/clocked/settings/SettingsScreen.kt | 17 +- app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 3 +- 21 files changed, 540 insertions(+), 250 deletions(-) create mode 100644 app/src/main/java/net/tinsae/clocked/biometric/AuthenticationDialog.kt create mode 100644 app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt create mode 100644 app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt create mode 100644 app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt create mode 100644 app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt create mode 100644 app/src/main/java/net/tinsae/clocked/components/TopBar.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..7bedc08 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -5,6 +5,12 @@ + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b0dfab..c81488a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.appcompat) + implementation("androidx.biometric:biometric:1.1.0") // Supabase implementation(libs.kotlinx.serialization.json) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0350e8e..3086a0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + + @@ -17,7 +17,6 @@ diff --git a/app/src/main/java/net/tinsae/clocked/MainActivity.kt b/app/src/main/java/net/tinsae/clocked/MainActivity.kt index ad16564..f6f79e5 100644 --- a/app/src/main/java/net/tinsae/clocked/MainActivity.kt +++ b/app/src/main/java/net/tinsae/clocked/MainActivity.kt @@ -4,39 +4,24 @@ 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.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -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 import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List 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.IconButton -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable @@ -48,20 +33,16 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.modifier.modifierLocalConsumer 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.unit.dp import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope -import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.status.SessionStatus import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import net.tinsae.clocked.biometric.GlobalAuthenticator import net.tinsae.clocked.components.ShowLoadingScreen import net.tinsae.clocked.dashboard.DashboardScreen import net.tinsae.clocked.data.Locale @@ -71,11 +52,10 @@ import net.tinsae.clocked.history.HistoryScreen import net.tinsae.clocked.settings.SettingsScreen import net.tinsae.clocked.settings.SettingsViewModel import net.tinsae.clocked.ui.theme.ClockedTheme -import net.tinsae.clocked.util.SupabaseClient import java.text.SimpleDateFormat import java.util.Date -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { private val settingsViewModel: SettingsViewModel by viewModels() private val authViewModel: AuthViewModel by viewModels() @@ -120,6 +100,7 @@ class MainActivity : ComponentActivity() { setContent { // The single ViewModel instances from the Activity are passed down. AppEntry(settingsViewModel, authViewModel) + GlobalAuthenticator() } } } @@ -128,19 +109,7 @@ class MainActivity : ComponentActivity() { fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) { val settingsState by settingsViewModel.uiState.collectAsState() val sessionStatus by authViewModel.sessionStatus.collectAsState() - - - /*LaunchedEffect(Unit) { - SupabaseClient.client.auth.sessionStatus.collect { status -> - if (status is SessionStatus.Authenticated && - SupabaseClient.client.auth.currentSessionOrNull() != null - ) { - LogRepository.fetchLogs() - } else { - return@collect - } - } - }*/ + // Trigger fetching of logs when the session status changes. LaunchedEffect(sessionStatus) { if (sessionStatus is SessionStatus.Authenticated){ LogRepository.fetchLogs() @@ -169,11 +138,9 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) ClockedApp(settingsViewModel, authViewModel) } - is SessionStatus.NotAuthenticated -> { + else -> { LoginScreen(authViewModel) } - - else -> {} } } } @@ -194,12 +161,16 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode NavigationSuiteScaffold( + modifier = Modifier.fillMaxSize(), navigationSuiteItems = { AppDestinations.entries.forEach { destination -> item( - icon = { Icon( - imageVector = destination.icon, - contentDescription = stringResource(destination.label)) + icon = { + Icon( + imageVector = destination.icon, + contentDescription = stringResource(destination.label), + modifier = Modifier.padding(1.dp).size(24.dp) + ) }, alwaysShowLabel = false, label = { Text(stringResource(destination.label)) }, @@ -211,52 +182,40 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode ) { Scaffold( modifier = Modifier.fillMaxSize(), + /*topBar = { + TopBar( + appName = stringResource(id = R.string.app_name, currentDestination.label), + modifier = Modifier.padding(horizontal = 16.dp), + isForContainer = true, + actions = { + IconButton( + onClick = { authViewModel.logout() }, modifier = Modifier.padding(10.dp), - topBar = { - Surface( - color = colorScheme.surfaceContainer, - shadowElevation = 2.dp, // Adds a subtle shadow to lift the bar - modifier = Modifier.fillMaxWidth() - // No padding needed here on the Surface itself - ) { - Row( - // Apply safe area padding to the Row to push content down - modifier = Modifier - .fillMaxWidth() - .padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Text( - modifier = Modifier.padding(start = 16.dp), // Vertical padding is handled by Row's alignment - text = stringResource(R.string.app_name), - style = typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.weight(1f)) - - IconButton(onClick = { authViewModel.logout() }, modifier = Modifier.padding(10.dp)) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Logout, - contentDescription = "Logout" // Use string resource - ) + ) { + Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout)) } } - } - }, - ) { innerPadding -> - val modifier = Modifier.fillMaxWidth().padding(innerPadding) - - when (currentDestination) { - AppDestinations.HOME -> DashboardScreen( modifier = modifier) - AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) - AppDestinations.SETTING -> SettingsScreen( - modifier = Modifier, - viewModel = settingsViewModel, - authViewModel = authViewModel ) + }*/ + + ) { innerPadding -> + val modifier = Modifier + .fillMaxWidth() + .padding(innerPadding) + + when (currentDestination) { + AppDestinations.HOME -> DashboardScreen(modifier = modifier) + AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) + AppDestinations.SETTING -> SettingsScreen( + modifier = modifier, + viewModel = settingsViewModel + ) + + AppDestinations.LOGOUT -> { + authViewModel.logout() + } + } } - } } } @@ -267,12 +226,5 @@ enum class AppDestinations( 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 { - AppEntry(SettingsViewModel(), AuthViewModel()) - } + LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout) } diff --git a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationDialog.kt b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationDialog.kt new file mode 100644 index 0000000..040f01a --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationDialog.kt @@ -0,0 +1,42 @@ +package net.tinsae.clocked.biometric + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.FragmentActivity + +@Composable +fun AuthenticationDialog( + title: String, + action: String, + onSuccess: () -> Unit, + onFailure: () -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + val activity = context as FragmentActivity + + LaunchedEffect(Unit) { + val biometricAuthenticator = BiometricAuthenticator(activity) + biometricAuthenticator.authenticate( + action = action, + title = title, + onSuccess = onSuccess, + onFailure = onFailure, + onCancel = onCancel + ) + } +} + +@Composable +@Preview(showBackground = true) +fun BiometricAuthenticationDialogPreview() { + AuthenticationDialog( + title = "Edit Log", + action = "Edit", + onSuccess = {}, + onFailure = {}, + onCancel = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt new file mode 100644 index 0000000..ea23a25 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/biometric/AuthenticationManager.kt @@ -0,0 +1,31 @@ +package net.tinsae.clocked.biometric + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +// A data class to represent an authentication request. +// The 'onSuccess' lambda is the protected action to run. +data class AuthRequest( + val action: String, + val onSuccess: () -> Unit +) + +// A singleton object to manage the authentication request state. +object AuthenticationManager { + + // A private MutableStateFlow to hold the current request. + // It's nullable, so 'null' means no request is active. + private val _request = MutableStateFlow(null) + val request = _request.asStateFlow() // Publicly expose as a read-only StateFlow + + // Any ViewModel can call this to request authentication. + fun requestAuth(action: String, onAuthenticated: () -> Unit) { + _request.update { AuthRequest(action = action, onSuccess = onAuthenticated) } + } + + // Call this to clear the request after it's been handled (success or fail). + fun clearRequest() { + _request.update { null } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt b/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt new file mode 100644 index 0000000..b678e1c --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/biometric/BiometricAuthenticator.kt @@ -0,0 +1,54 @@ +package net.tinsae.clocked.biometric + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +class BiometricAuthenticator(private val activity: FragmentActivity) { + + private val executor = ContextCompat.getMainExecutor(activity) + private val biometricManager = BiometricManager.from(activity) + + fun authenticate( + onSuccess: () -> Unit, + onFailure: () -> Unit, + onCancel: () -> Unit, + title: String, + action: String + ) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle("Authentication required to $action") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .build() + + val biometricPrompt = BiometricPrompt( + activity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + // Distinguish between user cancellation and other errors + if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + onCancel() + } else { + onFailure() + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + } + }) + + when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> onCancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt new file mode 100644 index 0000000..1708dc4 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/biometric/GlobalAuthenticator.kt @@ -0,0 +1,34 @@ +package net.tinsae.clocked.biometric + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import net.tinsae.clocked.R + +@Composable +fun GlobalAuthenticator() { + // Collect the current authentication request from the global manager. + val authRequest by AuthenticationManager.request.collectAsState() + + // If there is a request, show the AuthenticationDialog. + authRequest?.let { request -> + AuthenticationDialog( + title = stringResource(R.string.app_name), + action = request.action, + onSuccess = { + // First, execute the protected action (e.g., viewModel.deleteLog) + request.onSuccess() + // Then, clear the request from the manager. + AuthenticationManager.clearRequest() + }, + onFailure = { + // On failure or cancel, just clear the request. + AuthenticationManager.clearRequest() + }, + onCancel = { + AuthenticationManager.clearRequest() + } + ) + } +} diff --git a/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt b/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt index fe56b50..2f77a58 100644 --- a/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/components/AddLogDialog.kt @@ -15,6 +15,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState @@ -43,24 +46,30 @@ 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() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var hours by remember { mutableStateOf("") } var minutes by remember { mutableStateOf("") } var reason by remember { mutableStateOf("") } + // error if submit button is clicked with mandatory values not filled (duration) + val error = remember { mutableStateOf(null) } var selectedInstant by remember { mutableStateOf(Clock.System.now()) } var showDatePicker by remember { mutableStateOf(false) } + // State for the new EntryType selection + var selectedType by remember { mutableStateOf(EntryType.OVERTIME) } + val entryTypes = listOf(EntryType.OVERTIME, EntryType.TIME_OFF) + // 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 = { @@ -94,10 +103,27 @@ fun AddLogDialog( 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), + text = stringResource(R.string.add_entry), style = MaterialTheme.typography.titleLarge ) + // Entry Type Selector + PrimaryTabRow(selectedTabIndex = entryTypes.indexOf(selectedType)) { + entryTypes.forEachIndexed { index, type -> + Tab( + selected = index == entryTypes.indexOf(selectedType), + onClick = { selectedType = type }, + text = { + when(type) { + EntryType.OVERTIME -> Text(stringResource(R.string.overtime)) + EntryType.TIME_OFF -> Text(stringResource(R.string.time_off)) + } + } + ) + } + } + + // Date Row Row( modifier = Modifier.fillMaxWidth(), @@ -113,11 +139,24 @@ fun AddLogDialog( } } - // Duration Row - Text( - text = stringResource(id = R.string.duration), - style = MaterialTheme.typography.bodyLarge, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Duration Row + Text( + text = stringResource(id = R.string.duration), + style = MaterialTheme.typography.bodyLarge, + ) + if (error.value != null) { + Text( + text = error.value!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 16.dp) + ) + } + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -163,11 +202,18 @@ fun AddLogDialog( Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { + if(hours.isEmpty() || minutes.isEmpty()) { // duration values cannot be empty + error.value = "Please enter a duration or cancel" + return@Button + } + // Convert hours and minutes to) val h = hours.toLongOrNull() ?: 0L val m = minutes.toLongOrNull() ?: 0L - val totalMinutes = if (type == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m) + // Use the selectedType state to determine the sign + val totalMinutes = if (selectedType == EntryType.OVERTIME) h * 60 + m else -(h * 60 + m) val duration = totalMinutes.minutes - onSave(selectedInstant, duration, reason.takeIf(String::isNotBlank)) + + onSave(selectedInstant, duration, reason) } ) { Text(stringResource(id = R.string.save)) @@ -181,7 +227,6 @@ fun AddLogDialog( fun AddLogDialogPreview(){ ClockedTheme { AddLogDialog( - type = EntryType.OVERTIME, onDismiss = {}, onSave = { _, _, _ -> } ) diff --git a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt b/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt index c3f1023..01d46e9 100644 --- a/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt +++ b/app/src/main/java/net/tinsae/clocked/components/DetailsDialog.kt @@ -56,12 +56,6 @@ fun DetailsDialog(log: Log, onDismiss: () -> Unit) { } -@Composable -private fun getType(type: EntryType):String{ - return if(type==EntryType.OVERTIME) stringResource(R.string.overtime) else stringResource(R.string.time_off) -} - - @Composable private fun DetailRow(label: String, value: String) { // buildAnnotatedString allows mixing different styles in one Text composable. diff --git a/app/src/main/java/net/tinsae/clocked/components/ListItem.kt b/app/src/main/java/net/tinsae/clocked/components/ListItem.kt index 5d4482d..f8c42bf 100644 --- a/app/src/main/java/net/tinsae/clocked/components/ListItem.kt +++ b/app/src/main/java/net/tinsae/clocked/components/ListItem.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit // Import the new icon from the extended library import androidx.compose.material.icons.filled.History import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -55,7 +56,7 @@ fun ListItem( onDelete: () -> Unit, onEdit: () -> Unit, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier ) { val coroutineScope = rememberCoroutineScope() var offsetX by remember { mutableFloatStateOf(0f) } @@ -129,11 +130,10 @@ fun ListItem( .padding( 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - // 1. Leading Icon - CHANGED Icon( imageVector = Icons.Default.AccessTime, // Changed to a more relevant round icon - contentDescription = stringResource(R.string.duration_entry_icon), // Added content description - modifier = Modifier.size(40.dp), + contentDescription = stringResource(R.string.duration_entry_icon), + modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.secondary // Changed to secondary for better contrast ) @@ -155,20 +155,14 @@ fun ListItem( } // 4. Divider - Divider( + HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), modifier = Modifier .fillMaxWidth() // Indent the divider to align with text - .padding(start = 72.dp) // 16dp (padding) + 40dp (icon) + 16dp (spacer) + .padding(start = 56.dp) // 16dp (padding) + 40dp (icon) + 16dp (spacer) ) } } } } - -@Composable -@Preview(showBackground = true) -fun ListItemPreview() { - ListItem(getRecentLogs().first(), {}, {}, {}) -} diff --git a/app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt b/app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt new file mode 100644 index 0000000..5bedea1 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/components/LoadingAnimation.kt @@ -0,0 +1,57 @@ +package net.tinsae.clocked.components + + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.tooling.preview.Preview +import net.tinsae.clocked.ui.theme.cyan + +@Composable +fun LoadingAnimation(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "ripple_transition") + + val scale by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Restart + ), + label = "ripple_scale" + ) + + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween(1200), + repeatMode = RepeatMode.Restart + ), + label = "ripple_alpha" + ) + + Box( + modifier = modifier + .scale(scale) + .clip(CircleShape) + .background(cyan.copy(alpha = 0.5f)) + ) +} + +@Composable +@Preview(showBackground = true) +fun ShowLoadingAnimation(){ + LoadingAnimation(modifier = Modifier.fillMaxSize()) +} diff --git a/app/src/main/java/net/tinsae/clocked/components/TopBar.kt b/app/src/main/java/net/tinsae/clocked/components/TopBar.kt new file mode 100644 index 0000000..e56e983 --- /dev/null +++ b/app/src/main/java/net/tinsae/clocked/components/TopBar.kt @@ -0,0 +1,67 @@ +package net.tinsae.clocked.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * + */ +@Composable +fun TopBar( + modifier: Modifier = Modifier, + appName: String?, + actions: @Composable (RowScope.() -> Unit)? = null, + isForContainer: Boolean = false +) { + Surface( + color = colorScheme.surfaceContainer, + shadowElevation = 2.dp, // Adds a subtle shadow to lift the bar + modifier = if (isForContainer) Modifier + .fillMaxWidth() + .padding( + WindowInsets + .safeDrawing.only(WindowInsetsSides.Top) + .asPaddingValues() + ) + else Modifier.fillMaxWidth(), + // No padding needed here on the Surface itself + ) { + Row( + // Apply safe area padding to the Row to push content down + modifier = modifier, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + if (appName != null) { + Text( + //modifier = Modifier.padding(start = 16.dp), // Vertical padding is handled by Row's alignment + text = appName, + style = typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if(actions != null){ + actions() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt index 98d2fc3..772a1a3 100644 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardScreen.kt @@ -1,6 +1,5 @@ package net.tinsae.clocked.dashboard -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -30,22 +30,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle 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 net.tinsae.clocked.R -import net.tinsae.clocked.components.ListItem import net.tinsae.clocked.components.AddLogDialog import net.tinsae.clocked.components.DetailsDialog -import net.tinsae.clocked.components.ShowLoadingScreen -import net.tinsae.clocked.data.EntryType +import net.tinsae.clocked.components.ListItem +import net.tinsae.clocked.components.LoadingAnimation +import net.tinsae.clocked.components.TopBar import net.tinsae.clocked.data.Log -import net.tinsae.clocked.ui.theme.ClockedTheme import net.tinsae.clocked.ui.theme.cyan import net.tinsae.clocked.ui.theme.green import net.tinsae.clocked.ui.theme.red + @Composable fun DashboardScreen( modifier: Modifier = Modifier, @@ -53,115 +52,101 @@ fun DashboardScreen( ) { val uiState by viewModel.uiState.collectAsState() - /*LaunchedEffect(Unit) { - LogRepository.fetchLogs() - }*/ - if (uiState.showAddLogDialog) { AddLogDialog( - type = uiState.dialogType, - onDismiss = viewModel::onDismissDialog, + onDismiss = viewModel::toggleAddLogDialog, onSave = viewModel::onSaveLog ) } - if (uiState.isLoading) { - ShowLoadingScreen() - } else { - Column( - modifier = modifier - .fillMaxSize() - .padding(top=16.dp) + Column( + modifier = modifier + .fillMaxSize() + ) { + //BalanceBanner(uiState.netBalance+" ( "+uiState.balanceInDays+")", cyan, uiState.isLoading) + TopBar( + modifier = Modifier.padding(16.dp), + appName = stringResource(id = R.string.net_balance), + actions = { + Text( + text = uiState.netBalance+" ( "+uiState.balanceInDays+")", + style = MaterialTheme.typography.titleLarge, + color = cyan, + fontWeight = FontWeight.Bold + ) + } + ) + + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - SummaryCard( - title = stringResource(id = R.string.overtime), - value = uiState.overtime, - color = green, - modifier = Modifier.weight(1f), - titleStyle = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.width(16.dp)) - SummaryCard( - title = stringResource(id = R.string.time_off), - value = uiState.timeOff, - color = red, - modifier = Modifier.weight(1f), - titleStyle = MaterialTheme.typography.titleMedium - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - SummaryCard( - title = stringResource(id = R.string.net_balance), - value = uiState.netBalance+" ( "+uiState.balanceInDays+")", - color = cyan, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - titleStyle = MaterialTheme.typography.titleLarge - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceAround - ) { - 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 = stringResource(id = R.string.add_time_off), - onClick = { viewModel.onAddLogClicked(EntryType.TIME_OFF) }, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - ActivityList( - recentLogs = uiState.recentActivities, + title = stringResource(id = R.string.overtime), + value = uiState.overtime, + color = green, modifier = Modifier.weight(1f), - onEditLog = viewModel::editLog, - onDeleteLog = viewModel::deleteLog + titleStyle = MaterialTheme.typography.titleMedium, + isLoading = uiState.isLoading + ) + Spacer(modifier = Modifier.width(16.dp)) + SummaryCard( + title = stringResource(id = R.string.time_off), + value = uiState.timeOff, + color = red, + modifier = Modifier.weight(1f), + titleStyle = MaterialTheme.typography.titleMedium, + isLoading = uiState.isLoading ) } + ActivityList( + recentLogs = uiState.recentActivities, + modifier = Modifier.weight(1f), + onEditLog = viewModel::requestEditWithAuth, + onDeleteLog = viewModel::requestDeleteWithAuth, + viewModel = viewModel + ) } } @Composable -fun SummaryCard(title: String, titleStyle: TextStyle, value: String, color: Color, modifier: Modifier = Modifier) { +fun SummaryCard(title: String, titleStyle: TextStyle, value: String, color: Color, modifier: Modifier = Modifier, isLoading: Boolean) { Card( modifier = modifier, elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text(text = title, style = titleStyle) Spacer(modifier = Modifier.height(8.dp)) - Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold) + if(!isLoading) { + Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold) + } else { + LoadingAnimation(modifier = Modifier + .fillMaxWidth() + .height(36.dp)) + } } } } @Composable -fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { +fun ActionButton(text: String, onClick: () -> Unit) { Button( onClick = onClick, - modifier = modifier.fillMaxWidth() + shape = CircleShape ) { - Text(text = text, fontSize = 16.sp, modifier = Modifier.padding(6.dp)) + Text(text = text, fontSize = 16.sp) } } @@ -171,7 +156,8 @@ fun ActivityList( modifier: Modifier = Modifier, onEditLog: (Log) -> Unit = {}, onDeleteLog: (Log) -> Unit = {}, - recentLogs: List + recentLogs: List, + viewModel: DashboardViewModel ) { //var mutableItems by remember { mutableStateOf(items) } // State to hold the log that should be shown in the dialog. Null means no dialog. @@ -183,12 +169,26 @@ fun ActivityList( shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth() ) { - Text( - text = stringResource(R.string.recent_activity), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(16.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ){ + Text( + text = stringResource(R.string.recent_activity), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + + ActionButton( + text = "+ ${stringResource(id = R.string.new_entry)}", + onClick = { viewModel.toggleAddLogDialog() } + ) + } } + LazyColumn { items(recentLogs, key = { it.id }) { log -> ListItem( @@ -201,6 +201,7 @@ fun ActivityList( ) } } + } // When a log is selected, show the dialog. @@ -211,3 +212,4 @@ fun ActivityList( ) } } + diff --git a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt index 79c54e8..ca6e51d 100644 --- a/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/dashboard/DashboardViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import net.tinsae.clocked.biometric.AuthenticationManager import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.LogRepository @@ -19,6 +20,8 @@ import net.tinsae.clocked.data.LogRepository.getTotalTimeOffDuration import net.tinsae.clocked.util.Util.formatDuration import kotlin.time.Duration import kotlin.time.Instant +import android.util.Log as LOG + data class DashboardUiState( @@ -28,12 +31,12 @@ data class DashboardUiState( val balanceInDays: String = "0d", val recentActivities: List = emptyList(), val showAddLogDialog: Boolean = false, - val dialogType: EntryType = EntryType.OVERTIME, // Default, will be updated - val isLoading: Boolean = true + val dialogType: EntryType = EntryType.OVERTIME, + val isLoading: Boolean = true, ) class DashboardViewModel : ViewModel() { - // This is the state holder for UI-driven events (e.g., showing a dialog) + private val _internalState = MutableStateFlow(DashboardUiState()) val uiState: StateFlow = combine( @@ -44,13 +47,11 @@ class DashboardViewModel : ViewModel() { val timeOffDuration = getTotalTimeOffDuration() - - // Construct the final state using data from both sources. val netBalanceDuration = overtimeDuration + timeOffDuration val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0 - // 2. Then, calculate the balance in days (assuming an 8-hour workday) - // We format it to one decimal place for a clean look. + // calculate the balance in days (assuming an 8-hour workday) + // and format it to one decimal place for a clean look. val balanceInDaysValue = netBalanceInHours / 8.0 val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " @@ -73,14 +74,8 @@ class DashboardViewModel : ViewModel() { initialValue = DashboardUiState(isLoading = true) ) - - 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 toggleAddLogDialog(){ + _internalState.update { it.copy(showAddLogDialog = !it.showAddLogDialog) } } fun onSaveLog(timestamp: Instant, duration: Duration, reason: String?) { @@ -94,15 +89,18 @@ class DashboardViewModel : ViewModel() { reason = reason ) // On success, hide the dialog. The repository will trigger the data refresh. - onDismissDialog() + toggleAddLogDialog() } catch (e: Exception) { // Handle errors - android.util.Log.e("DashboardViewModel", "Failed to save log", e) + LOG.e("DashboardViewModel", "Failed to save log", e) } } } + + + fun editLog(log: Log) { LogRepository.editLog(log) } @@ -110,4 +108,18 @@ class DashboardViewModel : ViewModel() { fun deleteLog(log: Log) { LogRepository.deleteLog(log) } + + fun requestDeleteWithAuth(log: Log) { + AuthenticationManager.requestAuth( + action = "delete this log", + onAuthenticated = { deleteLog(log) } // Pass the function to run on success + ) + } + + fun requestEditWithAuth(log: Log) { + AuthenticationManager.requestAuth( + action = "edit this log", + onAuthenticated = { editLog(log) } + ) + } } diff --git a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt index 516579c..bbfd7b4 100644 --- a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt +++ b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt @@ -91,7 +91,7 @@ object LogRepository { fun getRecentLogs():List { - return logs.value.take(5) + return logs.value.take(7) } fun getTotalOvertimeDuration(): Duration { diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt b/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt index 1c69e84..313108e 100644 --- a/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/history/HistoryScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -53,11 +54,12 @@ fun HistoryScreen( if (uiState.tabs.isNotEmpty()) { PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, modifier = Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surfaceContainer) + .background(color = MaterialTheme.colorScheme.surfaceContainer), + containerColor = MaterialTheme.colorScheme.surfaceContainer ) { uiState.tabs.forEachIndexed { index, title -> Tab( - modifier = Modifier.padding( 6.dp), + modifier = Modifier.padding( 6.dp).alpha(1F), selected = uiState.selectedTabIndex == index, onClick = { viewModel.onTabSelected(index) }, text = { Text(title,style = MaterialTheme.typography.titleMedium) } @@ -69,7 +71,7 @@ fun HistoryScreen( LazyColumn( modifier = Modifier.fillMaxWidth() ) { - uiState.groupedEntries.forEach { (monthYear, entries) -> + uiState.groupedEntries?.forEach { (monthYear, entries) -> stickyHeader { MonthHeader(text = monthYear) } diff --git a/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt b/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt index 70ca19f..8fb030d 100644 --- a/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt +++ b/app/src/main/java/net/tinsae/clocked/history/HistoryViewModel.kt @@ -8,18 +8,20 @@ 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 +import android.util.Log as LOG + data class HistoryUiState( val tabs: List = emptyList(), val selectedTabIndex: Int = 0, - val groupedEntries: Map> = emptyMap(), + val groupedEntries: Map>? = emptyMap(), val selectedLogForDialog: Log? = null, - val isLoading: Boolean = true // isLoading is true when the log list is empty + val isLoading: Boolean = true, // isLoading is true when the log list is empty + val showBiometricDialog: Boolean = false ) class HistoryViewModel : ViewModel() { @@ -90,4 +92,8 @@ class HistoryViewModel : ViewModel() { // The old filterAndGroupEntries() function is no longer needed, // as its logic is now inside the 'combine' block. // private fun filterAndGroupEntries() { ... } + fun onToggleBiometricDialog() { + _internalState.update { it.copy(showBiometricDialog = !it.showBiometricDialog) } + LOG.d("HistoryViewModel", "Biometric dialog toggled") + } } diff --git a/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt b/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt index 52b76d6..5a26bda 100644 --- a/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt +++ b/app/src/main/java/net/tinsae/clocked/settings/SettingsScreen.kt @@ -27,20 +27,16 @@ 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 + viewModel: SettingsViewModel = viewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -61,8 +57,7 @@ fun SettingsScreen( } Column( - modifier = modifier - .padding(16.dp) + modifier = modifier.padding(16.dp), ) { SettingsSection(title = stringResource(R.string.settings_section_data)) { SettingsItem( @@ -227,11 +222,3 @@ fun LocaleSelectionDialog( ) } - -@Preview(showBackground = true) -@Composable -fun SettingsScreenPreview() { - ClockedTheme { - SettingsScreen(viewModel = SettingsViewModel(), authViewModel = AuthViewModel()) - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f457bb3..1a88e85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Add Overtime Add Time Off + New Hours Minutes Reason (Optional) @@ -70,5 +71,8 @@ Don\'t have an account? Sign up Already have an account? Log in Sign-up successful! Please check your email for a confirmation link. + Add a new entry + Log out + loading \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 61e0319..fe841a3 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ -