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