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

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

View File

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

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.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<String?>(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 = { _, _, _ -> }
)

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
private fun DetailRow(label: String, value: String) {
// 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 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(), {}, {}, {})
}

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
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<Log>
recentLogs: List<Log>,
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(
)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<String> = emptyList(),
val selectedTabIndex: Int = 0,
val groupedEntries: Map<String, List<Log>> = emptyMap(),
val groupedEntries: Map<String, List<Log>>? = 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")
}
}

View File

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

View File

@@ -25,6 +25,7 @@
<!-- Add/Edit Log Dialog -->
<string name="add_overtime_title">Add Overtime</string>
<string name="add_time_off_title">Add Time Off</string>
<string name="new_entry">New</string>
<string name="hours">Hours</string>
<string name="minutes">Minutes</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="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="add_entry">Add a new entry</string>
<string name="logout">Log out</string>
<string name="loading">loading</string>
</resources>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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>