Biometric authentication implemented and integrated in dashboard
This commit is contained in:
6
.idea/deploymentTargetSelector.xml
generated
6
.idea/deploymentTargetSelector.xml
generated
@@ -5,6 +5,12 @@
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="ShowLoadingAnimation">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="BiometricAuthenticationDialogPreview">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = { _, _, _ -> }
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(), {}, {}, {})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
67
app/src/main/java/net/tinsae/clocked/components/TopBar.kt
Normal file
67
app/src/main/java/net/tinsae/clocked/components/TopBar.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ object LogRepository {
|
||||
|
||||
|
||||
fun getRecentLogs():List<Log> {
|
||||
return logs.value.take(5)
|
||||
return logs.value.take(7)
|
||||
}
|
||||
|
||||
fun getTotalOvertimeDuration(): Duration {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user