almost done

This commit is contained in:
2025-12-29 04:42:40 +01:00
parent 36d5fc5ce9
commit d07d369546
30 changed files with 380 additions and 318 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -12,14 +12,17 @@ import io.github.jan.supabase.auth.providers.builtin.Email
import io.github.jan.supabase.auth.status.SessionStatus
import io.github.jan.supabase.exceptions.BadRequestRestException
import io.github.jan.supabase.exceptions.RestException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import net.tinsae.clocked.data.FormType
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.util.SupabaseClient
// Add this data class here for UI state
@@ -52,7 +55,6 @@ class AuthViewModel : ViewModel() {
this.email = email
this.password = password
}
// No need to emit a success event. The isAuthenticated flow will automatically update.
} catch (e: Exception) {
uiState = uiState.copy(error = parseError(e)) //e.message ?: "An unknown error occurred")
} finally {

View File

@@ -12,7 +12,6 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -26,6 +25,7 @@ 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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Logout
@@ -36,17 +36,19 @@ 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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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
@@ -55,22 +57,23 @@ 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.components.ShowLoadingScreen
import net.tinsae.clocked.dashboard.DashboardScreen
import net.tinsae.clocked.data.Locale
import net.tinsae.clocked.data.LogRepository
import net.tinsae.clocked.data.Theme
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
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
import io.github.jan.supabase.auth.status.SessionStatus
import net.tinsae.clocked.data.LogRepository
class MainActivity : ComponentActivity() {
@@ -126,6 +129,25 @@ 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
}
}
}*/
LaunchedEffect(sessionStatus) {
if (sessionStatus is SessionStatus.Authenticated){
LogRepository.fetchLogs()
}
}
val useDarkTheme = when (settingsState.theme) {
Theme.LIGHT -> false
Theme.DARK -> true
@@ -136,29 +158,21 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
updateLocale(context, settingsState.locale)
ClockedTheme(darkTheme = useDarkTheme) {
// Use a 'when' statement to handle all possible states.
when (sessionStatus) {
is SessionStatus.Initializing -> {
// Only show loading for the initial session check.
ShowLoadingScreen()
}
is SessionStatus.Authenticated -> {
// If we know the user is authenticated, show the main app.
ClockedApp(settingsViewModel, authViewModel,sessionStatus)
// Just show the app. The ViewModels inside will manage their own loading UI.
ClockedApp(settingsViewModel, authViewModel)
}
is SessionStatus.NotAuthenticated -> {
// Only if we are certain the user is not logged in, show the login screen.
LoginScreen(authViewModel)
}
is SessionStatus.Initializing -> {
// While Supabase is loading the session, show a loading indicator.
// This prevents the flicker.
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {}
}
}
@@ -175,67 +189,69 @@ fun updateLocale(context: Context, locale: Locale) {
}
@Composable
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel, sessionStatus: SessionStatus) {
fun ClockedApp(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel) {
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.background(colorScheme.surface)
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()),
//contentAlignment = Alignment.CenterStart
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = stringResource(R.string.app_name),
style = typography.titleLarge,
fontWeight = FontWeight.Bold
NavigationSuiteScaffold(
navigationSuiteItems = {
AppDestinations.entries.forEach { destination ->
item(
icon = { Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label))
},
alwaysShowLabel = false,
label = { Text(stringResource(destination.label)) },
selected = destination == currentDestination,
onClick = { currentDestination = destination }
)
Spacer(modifier = Modifier.weight(1f))
// 2. Use IconButton to make the icon clickable with a ripple effect
IconButton(onClick = { authViewModel.logout() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout, // Replace with your logout icon
contentDescription = "Logout"
)
}
}
},
) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
val contentPadding = PaddingValues(
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection),
top = innerPadding.calculateTopPadding(),
bottom = 0.dp
)
}
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
NavigationSuiteScaffold(
modifier = Modifier.padding(contentPadding),
navigationSuiteItems = {
AppDestinations.entries.forEach { destination ->
item(
icon = { Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label))
},
label = { Text(stringResource(destination.label)) },
selected = destination == currentDestination,
onClick = { currentDestination = destination }
)
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
)
}
}
}
}
) {
},
) { innerPadding ->
val modifier = Modifier.fillMaxWidth().padding(innerPadding)
when (currentDestination) {
AppDestinations.HOME -> DashboardScreen( modifier = Modifier.fillMaxSize())
AppDestinations.HISTORY -> HistoryScreen(modifier = Modifier.fillMaxSize())
AppDestinations.HOME -> DashboardScreen( modifier = modifier)
AppDestinations.HISTORY -> HistoryScreen(modifier = modifier)
AppDestinations.SETTING -> SettingsScreen(
modifier = Modifier.fillMaxSize(),
modifier = Modifier,
viewModel = settingsViewModel,
authViewModel = authViewModel
)

View File

@@ -9,40 +9,40 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
// Import the new icon from the extended library
import androidx.compose.material.icons.filled.History
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalWindowInfo
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.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import net.tinsae.clocked.R
import net.tinsae.clocked.data.Log
import net.tinsae.clocked.data.LogRepository.getRecentLogs
import net.tinsae.clocked.ui.theme.cyan
import net.tinsae.clocked.ui.theme.red
import net.tinsae.clocked.util.Util.formatDuration
@@ -50,74 +50,30 @@ import net.tinsae.clocked.util.Util.formatTimestampToLocalDateString
import kotlin.math.roundToInt
@Composable
fun ActivityList(
modifier: Modifier = Modifier,
onEditLog: (Log) -> Unit = {},
onDeleteLog: (Log) -> Unit = {},
recentLogs: List<Log>
) {
//var mutableItems by remember { mutableStateOf(items) }
// State to hold the log that should be shown in the dialog. Null means no dialog.
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.recent_activity),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(recentLogs, key = { it.id }) { log ->
ActivityItem(
log = log,
onEdit = { onEditLog(log) },
onDelete = {onDeleteLog(log) },
// When the item is clicked, set it as the selected log for the dialog
onClick = { selectedLogForDialog = log }
)
}
}
}
// When a log is selected, show the dialog.
selectedLogForDialog?.let { log ->
DetailsDialog(
log = log,
onDismiss = { selectedLogForDialog = null }
)
}
}
@Composable
fun ActivityItem(
fun ListItem(
log: Log,
onDelete: () -> Unit,
onEdit: () -> Unit,
onClick: () -> Unit, // Add onClick callback
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
var offsetX by remember { mutableFloatStateOf(0f) }
val animatedOffsetX by animateFloatAsState(targetValue = offsetX, label = "offset")
// Use stable LocalConfiguration
val screenWidthPx = LocalWindowInfo.current.containerSize.width
// Define thresholds for swiping
val editThreshold = screenWidthPx * 0.4f
val deleteThreshold = -screenWidthPx * 0.4f
Box(
modifier = modifier
.padding(vertical = 4.dp)
.fillMaxWidth()
.clickable { onClick() } // Make the whole area clickable
) {
// Background content (icons)
// --- Background content (icons for swipe) ---
Row(
modifier = Modifier
.matchParentSize()
.clip(MaterialTheme.shapes.medium)
.background(if (animatedOffsetX > 0) cyan else red)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
@@ -139,8 +95,8 @@ fun ActivityItem(
}
}
// --- Foreground content (the Card) ---
Card(
// --- Foreground content (the new list item UI) ---
Surface( // Use Surface for background color and elevation control
modifier = Modifier
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
.pointerInput(Unit) {
@@ -152,6 +108,7 @@ fun ActivityItem(
onEdit()
offsetX = 0f
}
offsetX < deleteThreshold -> onDelete()
else -> offsetX = 0f
}
@@ -162,29 +119,48 @@ fun ActivityItem(
offsetX += dragAmount
}
}
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
// Use the onClick callback here
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface // Ensures background is opaque
) {
Column {
Row(
modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier
.fillMaxWidth()
.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),
tint = MaterialTheme.colorScheme.secondary // Changed to secondary for better contrast
)
Spacer(modifier = Modifier.width(16.dp))
// 2. Primary Text (using your existing log data)
Text(
modifier = Modifier.weight(1f),
text = formatTimestampToLocalDateString(log.timestamp),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
)
Text(text = formatDuration(log.duration), style = MaterialTheme.typography.bodyLarge)
// 3. Trailing Text (using your existing log data)
Text(
text = formatDuration(log.duration),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = log.reason ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp, top = 4.dp)
// 4. Divider
Divider(
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)
)
}
}
@@ -193,6 +169,6 @@ fun ActivityItem(
@Composable
@Preview(showBackground = true)
fun ActivityItemPreview() {
ActivityList(recentLogs = emptyList(), onEditLog = {_->{}}, onDeleteLog = {_->{}})
fun ListItemPreview() {
ListItem(getRecentLogs().first(), {}, {}, {})
}

View File

@@ -0,0 +1,18 @@
package net.tinsae.clocked.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ShowLoadingScreen(){
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

View File

@@ -1,5 +1,6 @@
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
@@ -9,30 +10,37 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 io.github.jan.supabase.auth.status.SessionStatus
import net.tinsae.clocked.R
import net.tinsae.clocked.components.ActivityList
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.data.LogRepository
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
@@ -45,9 +53,9 @@ fun DashboardScreen(
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
/*LaunchedEffect(Unit) {
LogRepository.fetchLogs()
}
}*/
if (uiState.showAddLogDialog) {
AddLogDialog(
@@ -57,57 +65,80 @@ fun DashboardScreen(
)
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp, 0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
if (uiState.isLoading) {
ShowLoadingScreen()
} else {
Column(
modifier = modifier
.fillMaxSize()
.padding(top=16.dp)
) {
SummaryCard(title = stringResource(id = R.string.overtime), value = uiState.overtime, color = green, modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(16.dp))
SummaryCard(title = stringResource(id = R.string.time_off), value = uiState.timeOff, color = red, modifier = Modifier.weight(1f))
}
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))
Spacer(modifier = Modifier.height(16.dp))
NetBalanceCard(value = uiState.netBalance+" / "+uiState.balanceInDays, modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
ActionButton(
text = stringResource(id = R.string.add_overtime),
onClick = { viewModel.onAddLogClicked(EntryType.OVERTIME) },
modifier = Modifier.weight(1f)
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.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))
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),
onEditLog = viewModel::editLog,
onDeleteLog = viewModel::deleteLog
)
}
Spacer(modifier = Modifier.height(16.dp))
ActivityList(
recentLogs = uiState.recentActivities,
modifier = Modifier.weight(1f),
onEditLog = viewModel::editLog,
onDeleteLog = viewModel::deleteLog
)
}
}
@Composable
fun SummaryCard(title: String, value: String, color: Color, modifier: Modifier = Modifier) {
fun SummaryCard(title: String, titleStyle: TextStyle, value: String, color: Color, modifier: Modifier = Modifier) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
@@ -117,31 +148,13 @@ fun SummaryCard(title: String, value: String, color: Color, modifier: Modifier =
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = title, style = titleStyle)
Spacer(modifier = Modifier.height(8.dp))
Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold)
}
}
}
@Composable
fun NetBalanceCard(value: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(id = R.string.net_balance), style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text(text = value, style = MaterialTheme.typography.headlineLarge, color = cyan, fontWeight = FontWeight.Bold)
}
}
}
@Composable
fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
@@ -152,10 +165,49 @@ fun ActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifie
}
}
@Preview(showBackground = true)
@Composable
fun DashboardScreenPreview() {
ClockedTheme {
//DashboardScreen(viewModel = DashboardViewModel())
fun ActivityList(
modifier: Modifier = Modifier,
onEditLog: (Log) -> Unit = {},
onDeleteLog: (Log) -> Unit = {},
recentLogs: List<Log>
) {
//var mutableItems by remember { mutableStateOf(items) }
// State to hold the log that should be shown in the dialog. Null means no dialog.
var selectedLogForDialog by remember { mutableStateOf<Log?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.recent_activity),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
}
LazyColumn {
items(recentLogs, key = { it.id }) { log ->
ListItem(
modifier = Modifier.padding(horizontal = 16.dp),
log = log,
onEdit = { onEditLog(log) },
onDelete = {onDeleteLog(log) },
// When the item is clicked, set it as the selected log for the dialog
onClick = { selectedLogForDialog = log }
)
}
}
}
// When a log is selected, show the dialog.
selectedLogForDialog?.let { log ->
DetailsDialog(
log = log,
onDismiss = { selectedLogForDialog = null }
)
}
}

View File

@@ -13,6 +13,9 @@ 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.data.LogRepository.getRecentLogs
import net.tinsae.clocked.data.LogRepository.getTotalOvertimeDuration
import net.tinsae.clocked.data.LogRepository.getTotalTimeOffDuration
import net.tinsae.clocked.util.Util.formatDuration
import kotlin.time.Duration
import kotlin.time.Instant
@@ -25,7 +28,8 @@ 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 dialogType: EntryType = EntryType.OVERTIME, // Default, will be updated
val isLoading: Boolean = true
)
class DashboardViewModel : ViewModel() {
@@ -33,18 +37,13 @@ class DashboardViewModel : ViewModel() {
private val _internalState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = combine(
LogRepository.logs, // Source 1: The List<Log>
_internalState // Source 2: The internal UI state
) { logs, internalState ->
// THE FIX: The 'logs' parameter is already the List<Log>.
// You do not need to call .first() on it.
val allLogs = logs
LogRepository.isLoading,
_internalState
) { isLoading, internalState ->
val overtimeDuration = getTotalOvertimeDuration()
val timeOffDuration = getTotalTimeOffDuration()
val overtimeDuration = allLogs.filter{ it.type == EntryType.OVERTIME }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
val timeOffDuration = allLogs.filter { it.type == EntryType.TIME_OFF }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
// Construct the final state using data from both sources.
val netBalanceDuration = overtimeDuration + timeOffDuration
@@ -53,7 +52,7 @@ class DashboardViewModel : ViewModel() {
// 2. Then, calculate the balance in days (assuming an 8-hour workday)
// We format it to one decimal place for a clean look.
val balanceInDaysValue = netBalanceInHours / 8.0
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " days"
val balanceInDaysString = "%.1f".format(balanceInDaysValue) + " d "
@@ -61,8 +60,9 @@ class DashboardViewModel : ViewModel() {
overtime = formatDuration(overtimeDuration),
timeOff = formatDuration(timeOffDuration),
netBalance = formatDuration(overtimeDuration + timeOffDuration),
recentActivities = allLogs.take(7),
recentActivities = getRecentLogs(),
balanceInDays = balanceInDaysString,
isLoading = isLoading,
// Pass through the dialog state from the internal state holder
showAddLogDialog = internalState.showAddLogDialog,
dialogType = internalState.dialogType
@@ -70,7 +70,7 @@ class DashboardViewModel : ViewModel() {
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
initialValue = DashboardUiState(isLoading = true)
)

View File

@@ -1,15 +1,18 @@
package net.tinsae.clocked.data
import android.util.Log as LOG
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.tinsae.clocked.service.*
import net.tinsae.clocked.service.addLogToDB
import net.tinsae.clocked.service.deleteLogFromDB
import net.tinsae.clocked.service.editLogFromDB
import net.tinsae.clocked.service.getAllLogsFromDB
import kotlin.time.Duration
import kotlin.time.Instant
import android.util.Log as LOG
/**
* A Singleton Repository that acts as the Single Source of Truth for Log data.
@@ -22,25 +25,30 @@ object LogRepository {
// A public, read-only StateFlow that ViewModels can collect.
val logs = _logs.asStateFlow()
private val repositoryScope = CoroutineScope(Dispatchers.IO)
// Testing code
private val _isLoading = MutableStateFlow(false) // Start as true
val isLoading = _isLoading.asStateFlow()
// testing end
init {
// Fetch initial data when the repository is first created.
fetchLogs()
}
private val repositoryScope = CoroutineScope(Dispatchers.IO)
/**
* Fetches the latest logs from the service and updates the flow.
*/
fun fetchLogs() {
// TEST CODE HERE
repositoryScope.launch {
if (_isLoading.value) return@launch // Prevent concurrent fetches
_isLoading.update { true }
try {
val latestLogs = getAllLogsFromDB()
_logs.update { latestLogs }
LOG.d("LogRepository", "Successfully fetched ${latestLogs.size} logs.")
} catch (e: Exception) {
LOG.e("LogRepository", "Failed to fetch logs", e)
// Optionally, you could expose an error state here.
} finally {
_isLoading.update { false }
}
}
}
@@ -80,4 +88,19 @@ object LogRepository {
fetchLogs()
}
}
fun getRecentLogs():List<Log> {
return logs.value.take(5)
}
fun getTotalOvertimeDuration(): Duration {
return logs.value.filter { it.type == EntryType.OVERTIME }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
}
fun getTotalTimeOffDuration(): Duration {
return logs.value.filter { it.type == EntryType.TIME_OFF }
.map { it.duration }.fold(Duration.ZERO, Duration::plus)
}
}

View File

@@ -1,20 +0,0 @@
package net.tinsae.clocked.data
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import net.tinsae.clocked.BuildConfig
object SupabaseClient {
val client by lazy {
createSupabaseClient(
supabaseUrl = BuildConfig.SUPABASE_URL,
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
) {
install(Auth)
install(Postgrest)
}
}
}

View File

@@ -25,7 +25,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import net.tinsae.clocked.R
import net.tinsae.clocked.components.ActivityItem
import net.tinsae.clocked.components.ListItem
import net.tinsae.clocked.components.DetailsDialog
import net.tinsae.clocked.ui.theme.ClockedTheme
@@ -51,12 +51,16 @@ fun HistoryScreen(
modifier = modifier.fillMaxSize()
) {
if (uiState.tabs.isNotEmpty()) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex) {
PrimaryTabRow(selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
) {
uiState.tabs.forEachIndexed { index, title ->
Tab(
modifier = Modifier.padding( 6.dp),
selected = uiState.selectedTabIndex == index,
onClick = { viewModel.onTabSelected(index) },
text = { Text(title) }
text = { Text(title,style = MaterialTheme.typography.titleMedium) }
)
}
}
@@ -71,7 +75,7 @@ fun HistoryScreen(
}
items(entries, key = { it.id }) { entry ->
ActivityItem(
ListItem(
log = entry,
modifier = Modifier.padding(horizontal = 16.dp),
onDelete = { viewModel.deleteLog(entry) },
@@ -94,15 +98,16 @@ fun HistoryScreen(
@Composable
fun MonthHeader(text: String, modifier: Modifier = Modifier) {
Surface(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
shadowElevation = 1.dp
) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp)
modifier = Modifier.padding(16.dp,10.dp)
)
}
}

View File

@@ -57,7 +57,7 @@ class HistoryViewModel : ViewModel() {
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = HistoryUiState() // Start with a default empty state
initialValue = HistoryUiState(isLoading = true) // Start with a default empty state
)
fun setTabTitles(titles: List<String>) {

View File

@@ -1,13 +1,14 @@
package net.tinsae.clocked.service
import androidx.compose.runtime.retain.retain
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import kotlinx.coroutines.delay
import net.tinsae.clocked.data.EntryType
import net.tinsae.clocked.data.Log
import android.util.Log as LOG
import net.tinsae.clocked.data.LogEntry
import net.tinsae.clocked.data.SupabaseClient
import net.tinsae.clocked.util.SupabaseClient
import kotlin.time.Instant
import kotlin.time.Duration
@@ -26,10 +27,6 @@ suspend fun getAllLogsFromDB(): List<Log> {
}
}
suspend fun getRecentLogsFromDB(): List<Log> {
return getAllLogsFromDB().take(5)
}
suspend fun addLogToDB(type: EntryType, timestamp: Instant, duration: Duration, reason: String?) {
val newLog = LogEntry(
duration = duration,
@@ -78,13 +75,3 @@ suspend fun editLogFromDB(updatedLog: Log): Boolean {
false
}
}
// --- Calculation functions that operate on a given list of logs ---
fun getTotalOvertimeDuration(logs: List<Log>): Duration {
return logs.filter { it.type == EntryType.OVERTIME }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
}
fun getTotalTimeOffDuration(logs: List<Log>): Duration {
return logs.filter { it.type == EntryType.TIME_OFF }.fold(Duration.ZERO) { acc, log -> acc + log.duration }
}

View File

@@ -1,8 +1,10 @@
package net.tinsae.clocked.util
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.realtime.Realtime
import io.ktor.client.engine.android.Android
import net.tinsae.clocked.BuildConfig
object SupabaseClient {
@@ -14,6 +16,8 @@ object SupabaseClient {
) {
install(Auth)
install(Postgrest)
install(Realtime)
httpEngine = Android.create()
}
}
}

View File

@@ -1,30 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#1B6184">
<group android:scaleX="0.464"
android:scaleY="0.464"
android:translateX="257.28"
android:translateY="257.28">
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
android:fillColor="@android:color/white"
android:pathData="M452,800L452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800Q452,800 452,800ZM160,880L160,800L240,800L240,680Q240,619 268.5,565.5Q297,512 348,480Q297,448 268.5,394.5Q240,341 240,280L240,160L160,160L160,80L800,80L800,160L720,160L720,280Q720,328 702,372Q684,416 651,449Q651,449 651,449Q651,449 651,449Q613,459 580,478Q547,497 520,525Q510,523 500.5,521.5Q491,520 480,520Q414,520 367,567Q320,614 320,680L320,800L452,800Q459,822 468.5,842Q478,862 491,880L160,880ZM480,440Q546,440 593,393Q640,346 640,280L640,160L320,160L320,280Q320,346 367,393Q414,440 480,440ZM720,920Q637,920 578.5,861.5Q520,803 520,720Q520,637 578.5,578.5Q637,520 720,520Q803,520 861.5,578.5Q920,637 920,720Q920,803 861.5,861.5Q803,920 720,920ZM480,160Q480,160 480,160Q480,160 480,160L480,160L480,160L480,160Q480,160 480,160Q480,160 480,160ZM692,810L834,668L804,638L692,750L636,694L606,724L692,810Z"/>
</group>
</vector>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -34,6 +34,9 @@
<string name="add_overtime">+ Overtime</string>
<string name="add_time_off"> Time Off</string>
<string name="recent_activity">Recent Activity</string>
<string name="or"> or </string>
<string name="duration_entry_icon">Duration entry icon</string>
<!-- Settings Screen -->
<string name="settings_section_data">Data</string>