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">
|
<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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 = {
|
||||||
imageVector = destination.icon,
|
Icon(
|
||||||
contentDescription = stringResource(destination.label))
|
imageVector = destination.icon,
|
||||||
|
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,52 +182,40 @@ 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(
|
Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
) { 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),
|
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration Row
|
Row(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = stringResource(id = R.string.duration),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
) {
|
||||||
)
|
// 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(
|
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 = { _, _, _ -> }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(), {}, {}, {})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
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,115 +52,101 @@ 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) {
|
Column(
|
||||||
ShowLoadingScreen()
|
modifier = modifier
|
||||||
} else {
|
.fillMaxSize()
|
||||||
Column(
|
) {
|
||||||
modifier = modifier
|
//BalanceBanner(uiState.netBalance+" ( "+uiState.balanceInDays+")", cyan, uiState.isLoading)
|
||||||
.fillMaxSize()
|
TopBar(
|
||||||
.padding(top=16.dp)
|
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(
|
SummaryCard(
|
||||||
title = stringResource(id = R.string.net_balance),
|
title = stringResource(id = R.string.overtime),
|
||||||
value = uiState.netBalance+" ( "+uiState.balanceInDays+")",
|
value = uiState.overtime,
|
||||||
color = cyan,
|
color = green,
|
||||||
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,
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onEditLog = viewModel::editLog,
|
titleStyle = MaterialTheme.typography.titleMedium,
|
||||||
onDeleteLog = viewModel::deleteLog
|
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
|
@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))
|
||||||
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
|
@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()
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = stringResource(R.string.recent_activity),
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(16.dp)
|
.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 {
|
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(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user