diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/net/tinsae/clocked/MainActivity.kt b/app/src/main/java/net/tinsae/clocked/MainActivity.kt
index ba58214..922d42c 100644
--- a/app/src/main/java/net/tinsae/clocked/MainActivity.kt
+++ b/app/src/main/java/net/tinsae/clocked/MainActivity.kt
@@ -143,11 +143,16 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
updateLocale(context, settingsState.locale)
ClockedTheme(darkTheme = useDarkTheme) {
- when (sessionStatus) {
- is SessionStatus.Initializing -> {
+ if(sessionStatus is SessionStatus.NotAuthenticated){
+ LoginScreen(authViewModel)
+ }else{
+ ClockedApp(settingsViewModel, authViewModel)
+ }
+ /*when (sessionStatus) {
+ /*is SessionStatus.Initializing -> {
// Only show loading for the initial session check.
ShowLoadingScreen()
- }
+ }*/
is SessionStatus.Authenticated -> {
// Just show the app. The ViewModels inside will manage their own loading UI.
@@ -157,7 +162,7 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
else -> {
LoginScreen(authViewModel)
}
- }
+ }*/
}
}
diff --git a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt
index 1564464..11fa274 100644
--- a/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt
+++ b/app/src/main/java/net/tinsae/clocked/data/LogRepository.kt
@@ -1,30 +1,29 @@
package net.tinsae.clocked.data
+//import androidx.compose.ui.test.cancel
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.status.SessionStatus
-import io.github.jan.supabase.realtime.Realtime
+import io.github.jan.supabase.realtime.PostgresAction
+import io.github.jan.supabase.realtime.RealtimeChannel
+import io.github.jan.supabase.realtime.channel
+import io.github.jan.supabase.realtime.decodeRecord
+import io.github.jan.supabase.realtime.postgresChangeFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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 net.tinsae.clocked.util.SupabaseClient
import kotlin.time.Duration
import kotlin.time.Instant
import android.util.Log as LOG
-import io.github.jan.supabase.realtime.PostgresAction
-import io.github.jan.supabase.realtime.RealtimeChannel
-import io.github.jan.supabase.realtime.channel
-import io.github.jan.supabase.realtime.decodeRecord
-import io.github.jan.supabase.realtime.postgresChangeFlow
-import io.github.jan.supabase.realtime.realtime
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.catch
-import net.tinsae.clocked.util.SupabaseClient
/**
* A Singleton Repository that acts as the Single Source of Truth for Log data.
@@ -44,28 +43,57 @@ object LogRepository {
private val repositoryScope = CoroutineScope(Dispatchers.IO)
private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel")
- private var realtimeJob: Job? = null
+ private var sessionJob: Job? = null
init {
repositoryScope.launch {
+ // Define the realtime listener flow once. This flow is cold and does not
+ // do anything until it's collected.
+ val changeFlow = realtimeChannel.postgresChangeFlow(schema = "public") {
+ table = "Logs"
+ }
+
+ // Launch a long-lived collector for the realtime changes. This coroutine
+ // will run for the lifetime of the repositoryScope.
+ launch {
+ changeFlow
+ .catch { e ->
+ // This handles terminal errors in the flow itself.
+ // The Supabase client should automatically try to recover from
+ // transient network issues like SocketException.
+ LOG.e("LogRepository", "Realtime flow collection error", e)
+ }
+ .collect { action ->
+ // By placing the try-catch here, we ensure that a single
+ // malformed event doesn't stop us from processing subsequent events.
+ try {
+ handleRealtimeAction(action)
+ } catch (e: Exception) {
+ LOG.e("LogRepository", "Error handling realtime action", e)
+ }
+ }
+ }
+
+ // Collect session status to manage the subscription lifecycle and data fetching.
SupabaseClient.client.auth.sessionStatus.collect { status ->
+ // Cancel any existing data-fetching job to prevent race conditions on session changes.
+ sessionJob?.cancel()
+
when (status) {
is SessionStatus.Authenticated -> {
- LOG.d("LogRepository", "Auth: Authenticated. Fetching data and subscribing.")
- // Cancel any previous realtime listener to avoid duplicates
- realtimeJob?.cancel()
- // Fetch initial data
- fetchLogs()
- // Start listening for new changes and subscribe
- listenForLogChanges()
- realtimeChannel.subscribe()
+ LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.")
+ // Create a new job for the tasks associated with this authenticated session.
+ sessionJob = launch {
+ fetchLogs()
+ // The postgresChangeFlow listener is already set up.
+ // subscribe() connects the websocket and starts receiving events.
+ realtimeChannel.subscribe()
+ }
}
is SessionStatus.NotAuthenticated -> {
- LOG.d("LogRepository", "Auth: Not Authenticated. Clearing data.")
- // User is signed out, clear local data and unsubscribe
- clearLogs()
- realtimeJob?.cancel()
+ LOG.d("LogRepository", "Auth: Not Authenticated. Unsubscribing from channel and clearing data.")
realtimeChannel.unsubscribe()
+ clearLogs()
}
else -> { /* Handle other states if necessary */ }
}
@@ -73,54 +101,39 @@ object LogRepository {
}
}
- private fun listenForLogChanges() {
- realtimeJob = repositoryScope.launch {
- val changeFlow = realtimeChannel.postgresChangeFlow(schema = "public") {
- table = "Logs" // The name of your table in Supabase
- }
- changeFlow
- .catch { e -> LOG.e("LogRepository", "Realtime error", e) }
- .collect { action ->
- when (action) {
- is PostgresAction.Insert -> {
- LOG.d("LogRepository", "Realtime INSERT: ${action.record}")
- val newLog = action.decodeRecord()
- _logs.update { currentLogs ->
- (currentLogs + newLog).sortedByDescending { it.timestamp }
- }
- }
- is PostgresAction.Update -> {
- LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
- val updatedLog = action.decodeRecord()
- _logs.update { currentLogs ->
- currentLogs.map { if (it.id == updatedLog.id) updatedLog else it }
- .sortedByDescending { it.timestamp }
- }
- }
- is PostgresAction.Delete -> {
- LOG.d("LogRepository", "Realtime DELETE: ${action.oldRecord}")
- val deletedId = action.oldRecord["id"]?.toString()?.toLongOrNull()
- if (deletedId != null) {
- _logs.update { currentLogs ->
- currentLogs.filterNot { it.id == deletedId }
- }
- }
- }
- else -> { /* Unhandled action */ }
+ // Extracted the when block to a separate function for clarity
+ // Extracted the when block to a separate function for clarity
+ private fun handleRealtimeAction(action: PostgresAction) {
+ when (action) {
+ is PostgresAction.Insert -> {
+ LOG.d("LogRepository", "Realtime INSERT: ${action.record}")
+ val newLog = action.decodeRecord()
+ _logs.update { currentLogs ->
+ (currentLogs + newLog).sortedByDescending { it.timestamp }
+ }
+ }
+ is PostgresAction.Update -> {
+ LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
+ val updatedLog = action.decodeRecord()
+ _logs.update { currentLogs ->
+ currentLogs.map { if (it.id == updatedLog.id) updatedLog else it }
+ .sortedByDescending { it.timestamp }
+ }
+ }
+ is PostgresAction.Delete -> {
+ LOG.d("LogRepository", "Realtime DELETE: ${action.oldRecord}")
+ val deletedId = action.oldRecord["id"]?.toString()?.toLongOrNull()
+ if (deletedId != null) {
+ _logs.update { currentLogs ->
+ currentLogs.filterNot { it.id == deletedId }
}
}
+ }
+ else -> { /* Unhandled action */ }
}
}
- /**
- * Fetches the latest logs from the service and updates the flow.
- */
-
- /**
- * Fetches the initial list of logs from the service.
- * This is now a suspend function to ensure proper sequencing.
- */
private suspend fun fetchLogs() {
if (_isLoading.value) return
_isLoading.update { true }
@@ -128,7 +141,12 @@ object LogRepository {
val latestLogs = getAllLogsFromDB()
_logs.update { latestLogs }
} catch (e: Exception) {
- LOG.e("LogRepository", "Failed to fetch logs", e)
+ // The CancellationException from the log will be caught here
+ if (e is java.util.concurrent.CancellationException) {
+ LOG.w("LogRepository", "fetchLogs was cancelled, likely due to session change. This is expected.")
+ } else {
+ LOG.e("LogRepository", "Failed to fetch logs", e)
+ }
} finally {
_isLoading.update { false }
}
diff --git a/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt
index 17fc1cd..6bc3e6c 100644
--- a/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt
+++ b/app/src/main/java/net/tinsae/clocked/ui/components/LoadingAnimation.kt
@@ -1,57 +1,87 @@
package net.tinsae.clocked.ui.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.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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
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.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
-import net.tinsae.clocked.ui.theme.cyan
+import androidx.compose.ui.unit.dp
@Composable
-fun LoadingAnimation(modifier: Modifier = Modifier) {
- val infiniteTransition = rememberInfiniteTransition(label = "ripple_transition")
+fun ShimmerBrush(showShimmer: Boolean = true, targetValue: Float = 1000f, color: Color = Color.LightGray): Brush {
+ if (!showShimmer) {
+ return Brush.horizontalGradient(listOf(Color.Transparent))
+ }
- val scale by infiniteTransition.animateFloat(
+ val shimmerColors = listOf(
+ color.copy(alpha = 0.6f),
+ Color.LightGray.copy(alpha = 0.2f),
+ color.copy(alpha = 0.6f),
+ )
+
+ val transition = rememberInfiniteTransition(label = "shimmer_transition")
+ val translateAnimation by transition.animateFloat(
initialValue = 0f,
- targetValue = 1f,
+ targetValue = targetValue,
animationSpec = infiniteRepeatable(
- animation = tween(500),
+ animation = tween(800),
repeatMode = RepeatMode.Restart
),
- label = "ripple_scale"
+ label = "shimmer_translate_animation"
)
- 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))
+ return Brush.linearGradient(
+ colors = shimmerColors,
+ start = Offset.Zero,
+ end = Offset(x = translateAnimation, y = translateAnimation)
)
}
-@Composable
@Preview(showBackground = true)
-fun ShowLoadingAnimation(){
- LoadingAnimation(modifier = Modifier.fillMaxSize())
+@Composable
+fun ShimmerListItemPreview() {
+ val brush = ShimmerBrush()
+ Row(modifier = Modifier.padding(16.dp)) {
+ // Placeholder for a video thumbnail or user avatar
+ Spacer(
+ modifier = Modifier
+ .size(100.dp)
+ .background(brush, shape = RoundedCornerShape(8.dp))
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ // Placeholder for a title line
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(25.dp)
+ .background(brush, shape = RoundedCornerShape(4.dp))
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ // Placeholder for a subtitle line
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .height(25.dp)
+ .background(brush, shape = RoundedCornerShape(4.dp))
+ )
+ }
+ }
}
diff --git a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt
index 7ba2a07..21aae03 100644
--- a/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/net/tinsae/clocked/ui/screens/dashboard/DashboardScreen.kt
@@ -1,5 +1,6 @@
package net.tinsae.clocked.ui.screens.dashboard
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -39,8 +41,7 @@ import net.tinsae.clocked.data.Log
import net.tinsae.clocked.ui.components.AddLogDialog
import net.tinsae.clocked.ui.components.DetailsDialog
import net.tinsae.clocked.ui.components.ListItem
-import net.tinsae.clocked.ui.components.LoadingAnimation
-import net.tinsae.clocked.ui.components.TopBar
+import net.tinsae.clocked.ui.components.ShimmerBrush
import net.tinsae.clocked.ui.theme.cyan
import net.tinsae.clocked.ui.theme.green
import net.tinsae.clocked.ui.theme.red
@@ -98,7 +99,9 @@ fun DashboardScreen(
value = uiState.netBalance+" ( "+uiState.balanceInDays+")",
//style = MaterialTheme.typography.titleLarge,
color = cyan,
- modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
titleStyle = MaterialTheme.typography.titleMedium,
isLoading = uiState.isLoading
)
@@ -131,11 +134,17 @@ fun SummaryCard(title: String, titleStyle: TextStyle, value: String, color: Colo
Text(text = title, style = titleStyle)
Spacer(modifier = Modifier.height(8.dp))
if(!isLoading) {
- Text(text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold)
+ Text(
+ text = value, style = MaterialTheme.typography.headlineMedium, color = color, fontWeight = FontWeight.Bold)
} else {
- LoadingAnimation(modifier = Modifier
- .fillMaxWidth()
- .height(36.dp))
+ Spacer(modifier = Modifier
+ .height(36.dp)
+ .background(
+ ShimmerBrush(color = color),
+ shape = RoundedCornerShape(16.dp)
+ )
+ .fillMaxWidth())
+
}
}
}