Biometric authentication implemented and integrated in dashboard

This commit is contained in:
2025-12-30 20:46:37 +01:00
parent d07d369546
commit 66e3bbb004
21 changed files with 540 additions and 250 deletions

View File

@@ -5,6 +5,12 @@
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>
<SelectionState runConfigName="ShowLoadingAnimation">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="BiometricAuthenticationDialogPreview">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>

View File

@@ -74,6 +74,7 @@ dependencies {
implementation(libs.androidx.compose.material3.window.size.class1) implementation(libs.androidx.compose.material3.window.size.class1)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation("androidx.biometric:biometric:1.1.0")
// Supabase // Supabase
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
@@ -17,7 +17,6 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Clocked"> android:theme="@style/Theme.Clocked">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -4,39 +4,24 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon 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.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,20 +33,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.modifier.modifierLocalConsumer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.status.SessionStatus import io.github.jan.supabase.auth.status.SessionStatus
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.tinsae.clocked.biometric.GlobalAuthenticator
import net.tinsae.clocked.components.ShowLoadingScreen import net.tinsae.clocked.components.ShowLoadingScreen
import net.tinsae.clocked.dashboard.DashboardScreen import net.tinsae.clocked.dashboard.DashboardScreen
import net.tinsae.clocked.data.Locale 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.SettingsScreen
import net.tinsae.clocked.settings.SettingsViewModel import net.tinsae.clocked.settings.SettingsViewModel
import net.tinsae.clocked.ui.theme.ClockedTheme import net.tinsae.clocked.ui.theme.ClockedTheme
import net.tinsae.clocked.util.SupabaseClient
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
private val settingsViewModel: SettingsViewModel by viewModels() private val settingsViewModel: SettingsViewModel by viewModels()
private val authViewModel: AuthViewModel by viewModels() private val authViewModel: AuthViewModel by viewModels()
@@ -120,6 +100,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
// The single ViewModel instances from the Activity are passed down. // The single ViewModel instances from the Activity are passed down.
AppEntry(settingsViewModel, authViewModel) AppEntry(settingsViewModel, authViewModel)
GlobalAuthenticator()
} }
} }
} }
@@ -128,19 +109,7 @@ class MainActivity : ComponentActivity() {
fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) { fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
val settingsState by settingsViewModel.uiState.collectAsState() val settingsState by settingsViewModel.uiState.collectAsState()
val sessionStatus by authViewModel.sessionStatus.collectAsState() val sessionStatus by authViewModel.sessionStatus.collectAsState()
// Trigger fetching of logs when the session status changes.
/*LaunchedEffect(Unit) {
SupabaseClient.client.auth.sessionStatus.collect { status ->
if (status is SessionStatus.Authenticated &&
SupabaseClient.client.auth.currentSessionOrNull() != null
) {
LogRepository.fetchLogs()
} else {
return@collect
}
}
}*/
LaunchedEffect(sessionStatus) { LaunchedEffect(sessionStatus) {
if (sessionStatus is SessionStatus.Authenticated){ if (sessionStatus is SessionStatus.Authenticated){
LogRepository.fetchLogs() LogRepository.fetchLogs()
@@ -169,11 +138,9 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
ClockedApp(settingsViewModel, authViewModel) ClockedApp(settingsViewModel, authViewModel)
} }
is SessionStatus.NotAuthenticated -> { else -> {
LoginScreen(authViewModel) LoginScreen(authViewModel)
} }
else -> {}
} }
} }
} }
@@ -194,12 +161,16 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
NavigationSuiteScaffold( NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = { navigationSuiteItems = {
AppDestinations.entries.forEach { destination -> AppDestinations.entries.forEach { destination ->
item( item(
icon = { Icon( icon = {
Icon(
imageVector = destination.icon, imageVector = destination.icon,
contentDescription = stringResource(destination.label)) contentDescription = stringResource(destination.label),
modifier = Modifier.padding(1.dp).size(24.dp)
)
}, },
alwaysShowLabel = false, alwaysShowLabel = false,
label = { Text(stringResource(destination.label)) }, label = { Text(stringResource(destination.label)) },
@@ -211,50 +182,38 @@ fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewMode
) { ) {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), 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( Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
// 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
)
}
}
}
},
) { innerPadding -> ) { innerPadding ->
val modifier = Modifier.fillMaxWidth().padding(innerPadding) val modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
when (currentDestination) { when (currentDestination) {
AppDestinations.HOME -> DashboardScreen( modifier = modifier) AppDestinations.HOME -> DashboardScreen(modifier = modifier)
AppDestinations.HISTORY -> HistoryScreen(modifier = modifier) AppDestinations.HISTORY -> HistoryScreen(modifier = modifier)
AppDestinations.SETTING -> SettingsScreen( AppDestinations.SETTING -> SettingsScreen(
modifier = Modifier, modifier = modifier,
viewModel = settingsViewModel, viewModel = settingsViewModel
authViewModel = authViewModel
) )
AppDestinations.LOGOUT -> {
authViewModel.logout()
}
} }
} }
} }
@@ -267,12 +226,5 @@ enum class AppDestinations(
HOME(R.string.nav_home, Icons.Default.Home), HOME(R.string.nav_home, Icons.Default.Home),
HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List), HISTORY(R.string.nav_history, Icons.AutoMirrored.Filled.List),
SETTING(R.string.nav_settings, Icons.Default.Settings), SETTING(R.string.nav_settings, Icons.Default.Settings),
} LOGOUT(R.string.logout, Icons.AutoMirrored.Filled.Logout)
@Preview(showBackground = true)
@Composable
fun AppEntryPreview() {
ClockedTheme {
AppEntry(SettingsViewModel(), AuthViewModel())
}
} }

View File

@@ -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 = {}
)
}

View File

@@ -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<AuthRequest?>(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 }
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
)
}
}

View File

@@ -15,6 +15,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
@@ -43,24 +46,30 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddLogDialog( fun AddLogDialog(
type: EntryType,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (timestamp: Instant, duration: Duration, reason: String?) -> Unit onSave: (timestamp: Instant, duration: Duration, reason: String?) -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var hours by remember { mutableStateOf("") } var hours by remember { mutableStateOf("") }
var minutes by remember { mutableStateOf("") } var minutes by remember { mutableStateOf("") }
var reason by remember { mutableStateOf("") } var reason by remember { mutableStateOf("") }
// error if submit button is clicked with mandatory values not filled (duration)
val error = remember { mutableStateOf<String?>(null) }
var selectedInstant by remember { mutableStateOf(Clock.System.now()) } var selectedInstant by remember { mutableStateOf(Clock.System.now()) }
var showDatePicker by remember { mutableStateOf(false) } 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 // Custom date formatting logic using kotlinx-datetime
val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault()) val localDateTime = selectedInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}" val formattedDate = "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} ${localDateTime.day}, ${localDateTime.year}"
if (showDatePicker) { if (showDatePicker) {
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds()) val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedInstant.toEpochMilliseconds())
DatePickerDialog( DatePickerDialog(
onDismissRequest = { showDatePicker = false }, onDismissRequest = { showDatePicker = false },
confirmButton = { confirmButton = {
@@ -94,10 +103,27 @@ fun AddLogDialog(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( 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 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 // Date Row
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -113,11 +139,24 @@ fun AddLogDialog(
} }
} }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Duration Row // Duration Row
Text( Text(
text = stringResource(id = R.string.duration), text = stringResource(id = R.string.duration),
style = MaterialTheme.typography.bodyLarge, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -163,11 +202,18 @@ fun AddLogDialog(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { 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 h = hours.toLongOrNull() ?: 0L
val m = minutes.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 val duration = totalMinutes.minutes
onSave(selectedInstant, duration, reason.takeIf(String::isNotBlank))
onSave(selectedInstant, duration, reason)
} }
) { ) {
Text(stringResource(id = R.string.save)) Text(stringResource(id = R.string.save))
@@ -181,7 +227,6 @@ fun AddLogDialog(
fun AddLogDialogPreview(){ fun AddLogDialogPreview(){
ClockedTheme { ClockedTheme {
AddLogDialog( AddLogDialog(
type = EntryType.OVERTIME,
onDismiss = {}, onDismiss = {},
onSave = { _, _, _ -> } onSave = { _, _, _ -> }
) )

View File

@@ -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 @Composable
private fun DetailRow(label: String, value: String) { private fun DetailRow(label: String, value: String) {
// buildAnnotatedString allows mixing different styles in one Text composable. // buildAnnotatedString allows mixing different styles in one Text composable.

View File

@@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit
// Import the new icon from the extended library // Import the new icon from the extended library
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -55,7 +56,7 @@ fun ListItem(
onDelete: () -> Unit, onDelete: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var offsetX by remember { mutableFloatStateOf(0f) } var offsetX by remember { mutableFloatStateOf(0f) }
@@ -129,11 +130,10 @@ fun ListItem(
.padding( 12.dp), .padding( 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// 1. Leading Icon - CHANGED
Icon( Icon(
imageVector = Icons.Default.AccessTime, // Changed to a more relevant round icon imageVector = Icons.Default.AccessTime, // Changed to a more relevant round icon
contentDescription = stringResource(R.string.duration_entry_icon), // Added content description contentDescription = stringResource(R.string.duration_entry_icon),
modifier = Modifier.size(40.dp), modifier = Modifier.size(30.dp),
tint = MaterialTheme.colorScheme.secondary // Changed to secondary for better contrast tint = MaterialTheme.colorScheme.secondary // Changed to secondary for better contrast
) )
@@ -155,20 +155,14 @@ fun ListItem(
} }
// 4. Divider // 4. Divider
Divider( HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// Indent the divider to align with text // 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(), {}, {}, {})
}

View File

@@ -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())
}

View File

@@ -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()
}
}
}
}

View File

@@ -1,6 +1,5 @@
package net.tinsae.clocked.dashboard package net.tinsae.clocked.dashboard
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults 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.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.components.ListItem
import net.tinsae.clocked.components.AddLogDialog import net.tinsae.clocked.components.AddLogDialog
import net.tinsae.clocked.components.DetailsDialog import net.tinsae.clocked.components.DetailsDialog
import net.tinsae.clocked.components.ShowLoadingScreen import net.tinsae.clocked.components.ListItem
import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.components.LoadingAnimation
import net.tinsae.clocked.components.TopBar
import net.tinsae.clocked.data.Log 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.cyan
import net.tinsae.clocked.ui.theme.green import net.tinsae.clocked.ui.theme.green
import net.tinsae.clocked.ui.theme.red import net.tinsae.clocked.ui.theme.red
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -53,28 +52,36 @@ fun DashboardScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
/*LaunchedEffect(Unit) {
LogRepository.fetchLogs()
}*/
if (uiState.showAddLogDialog) { if (uiState.showAddLogDialog) {
AddLogDialog( AddLogDialog(
type = uiState.dialogType, onDismiss = viewModel::toggleAddLogDialog,
onDismiss = viewModel::onDismissDialog,
onSave = viewModel::onSaveLog onSave = viewModel::onSaveLog
) )
} }
if (uiState.isLoading) {
ShowLoadingScreen()
} else {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(top=16.dp)
) { ) {
//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( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
SummaryCard( SummaryCard(
@@ -82,7 +89,8 @@ fun DashboardScreen(
value = uiState.overtime, value = uiState.overtime,
color = green, color = green,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
titleStyle = MaterialTheme.typography.titleMedium titleStyle = MaterialTheme.typography.titleMedium,
isLoading = uiState.isLoading
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
SummaryCard( SummaryCard(
@@ -90,78 +98,55 @@ fun DashboardScreen(
value = uiState.timeOff, value = uiState.timeOff,
color = red, color = red,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
titleStyle = MaterialTheme.typography.titleMedium titleStyle = MaterialTheme.typography.titleMedium,
isLoading = uiState.isLoading
) )
} }
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( ActivityList(
recentLogs = uiState.recentActivities, recentLogs = uiState.recentActivities,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onEditLog = viewModel::editLog, onEditLog = viewModel::requestEditWithAuth,
onDeleteLog = viewModel::deleteLog onDeleteLog = viewModel::requestDeleteWithAuth,
viewModel = viewModel
) )
} }
}
} }
@Composable @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( Card(
modifier = modifier, modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text(text = title, style = titleStyle) Text(text = title, style = titleStyle)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if(!isLoading) {
Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold) Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold)
} else {
LoadingAnimation(modifier = Modifier
.fillMaxWidth()
.height(36.dp))
}
} }
} }
} }
@Composable @Composable
fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { fun ActionButton(text: String, onClick: () -> Unit) {
Button( Button(
onClick = onClick, 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, modifier: Modifier = Modifier,
onEditLog: (Log) -> Unit = {}, onEditLog: (Log) -> Unit = {},
onDeleteLog: (Log) -> Unit = {}, onDeleteLog: (Log) -> Unit = {},
recentLogs: List<Log> recentLogs: List<Log>,
viewModel: DashboardViewModel
) { ) {
//var mutableItems by remember { mutableStateOf(items) } //var mutableItems by remember { mutableStateOf(items) }
// State to hold the log that should be shown in the dialog. Null means no dialog. // State to hold the log that should be shown in the dialog. Null means no dialog.
@@ -183,12 +169,26 @@ fun ActivityList(
shadowElevation = 2.dp, shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
){
Text( Text(
text = stringResource(R.string.recent_activity), text = stringResource(R.string.recent_activity),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
ActionButton(
text = "+ ${stringResource(id = R.string.new_entry)}",
onClick = { viewModel.toggleAddLogDialog() }
)
} }
}
LazyColumn { LazyColumn {
items(recentLogs, key = { it.id }) { log -> items(recentLogs, key = { it.id }) { log ->
ListItem( ListItem(
@@ -201,6 +201,7 @@ fun ActivityList(
) )
} }
} }
} }
// When a log is selected, show the dialog. // When a log is selected, show the dialog.
@@ -211,3 +212,4 @@ fun ActivityList(
) )
} }
} }

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.tinsae.clocked.biometric.AuthenticationManager
import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository 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 net.tinsae.clocked.util.Util.formatDuration
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Instant import kotlin.time.Instant
import android.util.Log as LOG
data class DashboardUiState( data class DashboardUiState(
@@ -28,12 +31,12 @@ data class DashboardUiState(
val balanceInDays: String = "0d", val balanceInDays: String = "0d",
val recentActivities: List<Log> = emptyList(), val recentActivities: List<Log> = emptyList(),
val showAddLogDialog: Boolean = false, val showAddLogDialog: Boolean = false,
val dialogType: EntryType = EntryType.OVERTIME, // Default, will be updated val dialogType: EntryType = EntryType.OVERTIME,
val isLoading: Boolean = true val isLoading: Boolean = true,
) )
class DashboardViewModel : ViewModel() { class DashboardViewModel : ViewModel() {
// This is the state holder for UI-driven events (e.g., showing a dialog)
private val _internalState = MutableStateFlow(DashboardUiState()) private val _internalState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = combine( val uiState: StateFlow<DashboardUiState> = combine(
@@ -44,13 +47,11 @@ class DashboardViewModel : ViewModel() {
val timeOffDuration = getTotalTimeOffDuration() val timeOffDuration = getTotalTimeOffDuration()
// Construct the final state using data from both sources.
val netBalanceDuration = overtimeDuration + timeOffDuration val netBalanceDuration = overtimeDuration + timeOffDuration
val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0 val netBalanceInHours = netBalanceDuration.inWholeMinutes / 60.0
// 2. Then, calculate the balance in days (assuming an 8-hour workday) // calculate the balance in days (assuming an 8-hour workday)
// We format it to one decimal place for a clean look. // and format it to one decimal place for a clean look.
val balanceInDaysValue = netBalanceInHours / 8.0 val balanceInDaysValue = netBalanceInHours / 8.0
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d " val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d "
@@ -73,14 +74,8 @@ class DashboardViewModel : ViewModel() {
initialValue = DashboardUiState(isLoading = true) initialValue = DashboardUiState(isLoading = true)
) )
fun toggleAddLogDialog(){
fun onAddLogClicked(type: EntryType) { _internalState.update { it.copy(showAddLogDialog = !it.showAddLogDialog) }
// 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?) { fun onSaveLog(timestamp: Instant, duration: Duration, reason: String?) {
@@ -94,15 +89,18 @@ class DashboardViewModel : ViewModel() {
reason = reason reason = reason
) )
// On success, hide the dialog. The repository will trigger the data refresh. // On success, hide the dialog. The repository will trigger the data refresh.
onDismissDialog() toggleAddLogDialog()
} catch (e: Exception) { } catch (e: Exception) {
// Handle errors // Handle errors
android.util.Log.e("DashboardViewModel", "Failed to save log", e) LOG.e("DashboardViewModel", "Failed to save log", e)
} }
} }
} }
fun editLog(log: Log) { fun editLog(log: Log) {
LogRepository.editLog(log) LogRepository.editLog(log)
} }
@@ -110,4 +108,18 @@ class DashboardViewModel : ViewModel() {
fun deleteLog(log: Log) { fun deleteLog(log: Log) {
LogRepository.deleteLog(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) }
)
}
} }

View File

@@ -91,7 +91,7 @@ object LogRepository {
fun getRecentLogs():List<Log> { fun getRecentLogs():List<Log> {
return logs.value.take(5) return logs.value.take(7)
} }
fun getTotalOvertimeDuration(): Duration { fun getTotalOvertimeDuration(): Duration {

View File

@@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -53,11 +54,12 @@ fun HistoryScreen(
if (uiState.tabs.isNotEmpty()) { if (uiState.tabs.isNotEmpty()) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex, PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surfaceContainer) .background(color = MaterialTheme.colorScheme.surfaceContainer),
containerColor = MaterialTheme.colorScheme.surfaceContainer
) { ) {
uiState.tabs.forEachIndexed { index, title -> uiState.tabs.forEachIndexed { index, title ->
Tab( Tab(
modifier = Modifier.padding( 6.dp), modifier = Modifier.padding( 6.dp).alpha(1F),
selected = uiState.selectedTabIndex == index, selected = uiState.selectedTabIndex == index,
onClick = { viewModel.onTabSelected(index) }, onClick = { viewModel.onTabSelected(index) },
text = { Text(title,style = MaterialTheme.typography.titleMedium) } text = { Text(title,style = MaterialTheme.typography.titleMedium) }
@@ -69,7 +71,7 @@ fun HistoryScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
uiState.groupedEntries.forEach { (monthYear, entries) -> uiState.groupedEntries?.forEach { (monthYear, entries) ->
stickyHeader { stickyHeader {
MonthHeader(text = monthYear) MonthHeader(text = monthYear)
} }

View File

@@ -8,18 +8,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.tinsae.clocked.data.EntryType import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.util.Util.formatTimestampToMonthYear import net.tinsae.clocked.util.Util.formatTimestampToMonthYear
import android.util.Log as LOG
data class HistoryUiState( data class HistoryUiState(
val tabs: List<String> = emptyList(), val tabs: List<String> = emptyList(),
val selectedTabIndex: Int = 0, val selectedTabIndex: Int = 0,
val groupedEntries: Map<String, List<Log>> = emptyMap(), val groupedEntries: Map<String, List<Log>>? = emptyMap(),
val selectedLogForDialog: Log? = null, 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() { class HistoryViewModel : ViewModel() {
@@ -90,4 +92,8 @@ class HistoryViewModel : ViewModel() {
// The old filterAndGroupEntries() function is no longer needed, // The old filterAndGroupEntries() function is no longer needed,
// as its logic is now inside the 'combine' block. // as its logic is now inside the 'combine' block.
// private fun filterAndGroupEntries() { ... } // private fun filterAndGroupEntries() { ... }
fun onToggleBiometricDialog() {
_internalState.update { it.copy(showBiometricDialog = !it.showBiometricDialog) }
LOG.d("HistoryViewModel", "Biometric dialog toggled")
}
} }

View File

@@ -27,20 +27,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.AuthViewModel
import net.tinsae.clocked.R import net.tinsae.clocked.R
import net.tinsae.clocked.data.Locale import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.Theme import net.tinsae.clocked.data.Theme
import net.tinsae.clocked.ui.theme.ClockedTheme
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(), viewModel: SettingsViewModel = viewModel()
authViewModel: AuthViewModel
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -61,8 +57,7 @@ fun SettingsScreen(
} }
Column( Column(
modifier = modifier modifier = modifier.padding(16.dp),
.padding(16.dp)
) { ) {
SettingsSection(title = stringResource(R.string.settings_section_data)) { SettingsSection(title = stringResource(R.string.settings_section_data)) {
SettingsItem( SettingsItem(
@@ -227,11 +222,3 @@ fun LocaleSelectionDialog(
) )
} }
@Preview(showBackground = true)
@Composable
fun SettingsScreenPreview() {
ClockedTheme {
SettingsScreen(viewModel = SettingsViewModel(), authViewModel = AuthViewModel())
}
}

View File

@@ -25,6 +25,7 @@
<!-- Add/Edit Log Dialog --> <!-- Add/Edit Log Dialog -->
<string name="add_overtime_title">Add Overtime</string> <string name="add_overtime_title">Add Overtime</string>
<string name="add_time_off_title">Add Time Off</string> <string name="add_time_off_title">Add Time Off</string>
<string name="new_entry">New</string>
<string name="hours">Hours</string> <string name="hours">Hours</string>
<string name="minutes">Minutes</string> <string name="minutes">Minutes</string>
<string name="reason_optional">Reason (Optional)</string> <string name="reason_optional">Reason (Optional)</string>
@@ -70,5 +71,8 @@
<string name="login_prompt_signup">Don\'t have an account? 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_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> <string name="signup_success_message">Sign-up successful! Please check your email for a confirmation link.</string>
<string name="add_entry">Add a new entry</string>
<string name="logout">Log out</string>
<string name="loading">loading</string>
</resources> </resources>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Clocked" parent="android:Theme.Material.Light.NoActionBar" /> <!--style name="Theme.Clocked" parent="android:Theme.Material.Light.NoActionBar" /-->
<style name="Theme.Clocked" parent="Theme.AppCompat.DayNight.NoActionBar"/>
</resources> </resources>