loading animation

This commit is contained in:
2026-01-03 00:45:46 +01:00
parent f729258419
commit 61002cada9
5 changed files with 183 additions and 108 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@@ -143,11 +143,16 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
updateLocale(context, settingsState.locale) updateLocale(context, settingsState.locale)
ClockedTheme(darkTheme = useDarkTheme) { ClockedTheme(darkTheme = useDarkTheme) {
when (sessionStatus) { if(sessionStatus is SessionStatus.NotAuthenticated){
is SessionStatus.Initializing -> { LoginScreen(authViewModel)
}else{
ClockedApp(settingsViewModel, authViewModel)
}
/*when (sessionStatus) {
/*is SessionStatus.Initializing -> {
// Only show loading for the initial session check. // Only show loading for the initial session check.
ShowLoadingScreen() ShowLoadingScreen()
} }*/
is SessionStatus.Authenticated -> { is SessionStatus.Authenticated -> {
// Just show the app. The ViewModels inside will manage their own loading UI. // Just show the app. The ViewModels inside will manage their own loading UI.
@@ -157,7 +162,7 @@ fun AppEntry(settingsViewModel: SettingsViewModel, authViewModel: AuthViewModel)
else -> { else -> {
LoginScreen(authViewModel) LoginScreen(authViewModel)
} }
} }*/
} }
} }

View File

@@ -1,30 +1,29 @@
package net.tinsae.clocked.data package net.tinsae.clocked.data
//import androidx.compose.ui.test.cancel
import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.status.SessionStatus 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.tinsae.clocked.service.addLogToDB import net.tinsae.clocked.service.addLogToDB
import net.tinsae.clocked.service.deleteLogFromDB import net.tinsae.clocked.service.deleteLogFromDB
import net.tinsae.clocked.service.editLogFromDB import net.tinsae.clocked.service.editLogFromDB
import net.tinsae.clocked.service.getAllLogsFromDB import net.tinsae.clocked.service.getAllLogsFromDB
import net.tinsae.clocked.util.SupabaseClient
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Instant import kotlin.time.Instant
import android.util.Log as LOG 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. * 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 repositoryScope = CoroutineScope(Dispatchers.IO)
private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel") private val realtimeChannel: RealtimeChannel = SupabaseClient.client.channel("logs-channel")
private var realtimeJob: Job? = null private var sessionJob: Job? = null
init { init {
repositoryScope.launch { 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<PostgresAction>(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 -> SupabaseClient.client.auth.sessionStatus.collect { status ->
// Cancel any existing data-fetching job to prevent race conditions on session changes.
sessionJob?.cancel()
when (status) { when (status) {
is SessionStatus.Authenticated -> { is SessionStatus.Authenticated -> {
LOG.d("LogRepository", "Auth: Authenticated. Fetching data and subscribing.") LOG.d("LogRepository", "Auth: Authenticated. Subscribing to channel and fetching initial data.")
// Cancel any previous realtime listener to avoid duplicates // Create a new job for the tasks associated with this authenticated session.
realtimeJob?.cancel() sessionJob = launch {
// Fetch initial data fetchLogs()
fetchLogs() // The postgresChangeFlow listener is already set up.
// Start listening for new changes and subscribe // subscribe() connects the websocket and starts receiving events.
listenForLogChanges() realtimeChannel.subscribe()
realtimeChannel.subscribe() }
} }
is SessionStatus.NotAuthenticated -> { is SessionStatus.NotAuthenticated -> {
LOG.d("LogRepository", "Auth: Not Authenticated. Clearing data.") LOG.d("LogRepository", "Auth: Not Authenticated. Unsubscribing from channel and clearing data.")
// User is signed out, clear local data and unsubscribe
clearLogs()
realtimeJob?.cancel()
realtimeChannel.unsubscribe() realtimeChannel.unsubscribe()
clearLogs()
} }
else -> { /* Handle other states if necessary */ } else -> { /* Handle other states if necessary */ }
} }
@@ -73,54 +101,39 @@ object LogRepository {
} }
} }
private fun listenForLogChanges() {
realtimeJob = repositoryScope.launch {
val changeFlow = realtimeChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "Logs" // The name of your table in Supabase
}
changeFlow // Extracted the when block to a separate function for clarity
.catch { e -> LOG.e("LogRepository", "Realtime error", e) } // Extracted the when block to a separate function for clarity
.collect { action -> private fun handleRealtimeAction(action: PostgresAction) {
when (action) { when (action) {
is PostgresAction.Insert -> { is PostgresAction.Insert -> {
LOG.d("LogRepository", "Realtime INSERT: ${action.record}") LOG.d("LogRepository", "Realtime INSERT: ${action.record}")
val newLog = action.decodeRecord<Log>() val newLog = action.decodeRecord<Log>()
_logs.update { currentLogs -> _logs.update { currentLogs ->
(currentLogs + newLog).sortedByDescending { it.timestamp } (currentLogs + newLog).sortedByDescending { it.timestamp }
} }
} }
is PostgresAction.Update -> { is PostgresAction.Update -> {
LOG.d("LogRepository", "Realtime UPDATE: ${action.record}") LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
val updatedLog = action.decodeRecord<Log>() val updatedLog = action.decodeRecord<Log>()
_logs.update { currentLogs -> _logs.update { currentLogs ->
currentLogs.map { if (it.id == updatedLog.id) updatedLog else it } currentLogs.map { if (it.id == updatedLog.id) updatedLog else it }
.sortedByDescending { it.timestamp } .sortedByDescending { it.timestamp }
} }
} }
is PostgresAction.Delete -> { is PostgresAction.Delete -> {
LOG.d("LogRepository", "Realtime DELETE: ${action.oldRecord}") LOG.d("LogRepository", "Realtime DELETE: ${action.oldRecord}")
val deletedId = action.oldRecord["id"]?.toString()?.toLongOrNull() val deletedId = action.oldRecord["id"]?.toString()?.toLongOrNull()
if (deletedId != null) { if (deletedId != null) {
_logs.update { currentLogs -> _logs.update { currentLogs ->
currentLogs.filterNot { it.id == deletedId } currentLogs.filterNot { it.id == deletedId }
}
}
}
else -> { /* Unhandled action */ }
} }
} }
}
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() { private suspend fun fetchLogs() {
if (_isLoading.value) return if (_isLoading.value) return
_isLoading.update { true } _isLoading.update { true }
@@ -128,7 +141,12 @@ object LogRepository {
val latestLogs = getAllLogsFromDB() val latestLogs = getAllLogsFromDB()
_logs.update { latestLogs } _logs.update { latestLogs }
} catch (e: Exception) { } 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 { } finally {
_isLoading.update { false } _isLoading.update { false }
} }

View File

@@ -1,57 +1,87 @@
package net.tinsae.clocked.ui.components package net.tinsae.clocked.ui.components
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.shape.CircleShape 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.Composable
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.clip import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import net.tinsae.clocked.ui.theme.cyan import androidx.compose.ui.unit.dp
@Composable @Composable
fun LoadingAnimation(modifier: Modifier = Modifier) { fun ShimmerBrush(showShimmer: Boolean = true, targetValue: Float = 1000f, color: Color = Color.LightGray): Brush {
val infiniteTransition = rememberInfiniteTransition(label = "ripple_transition") 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, initialValue = 0f,
targetValue = 1f, targetValue = targetValue,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(500), animation = tween(800),
repeatMode = RepeatMode.Restart repeatMode = RepeatMode.Restart
), ),
label = "ripple_scale" label = "shimmer_translate_animation"
) )
val alpha by infiniteTransition.animateFloat( return Brush.linearGradient(
initialValue = 1f, colors = shimmerColors,
targetValue = 0f, start = Offset.Zero,
animationSpec = infiniteRepeatable( end = Offset(x = translateAnimation, y = translateAnimation)
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) @Preview(showBackground = true)
fun ShowLoadingAnimation(){ @Composable
LoadingAnimation(modifier = Modifier.fillMaxSize()) 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))
)
}
}
} }

View File

@@ -1,5 +1,6 @@
package net.tinsae.clocked.ui.screens.dashboard package net.tinsae.clocked.ui.screens.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 +13,7 @@ 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.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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
@@ -39,8 +41,7 @@ import net.tinsae.clocked.data.Log
import net.tinsae.clocked.ui.components.AddLogDialog import net.tinsae.clocked.ui.components.AddLogDialog
import net.tinsae.clocked.ui.components.DetailsDialog import net.tinsae.clocked.ui.components.DetailsDialog
import net.tinsae.clocked.ui.components.ListItem import net.tinsae.clocked.ui.components.ListItem
import net.tinsae.clocked.ui.components.LoadingAnimation import net.tinsae.clocked.ui.components.ShimmerBrush
import net.tinsae.clocked.ui.components.TopBar
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
@@ -98,7 +99,9 @@ fun DashboardScreen(
value = uiState.netBalance+" ( "+uiState.balanceInDays+")", value = uiState.netBalance+" ( "+uiState.balanceInDays+")",
//style = MaterialTheme.typography.titleLarge, //style = MaterialTheme.typography.titleLarge,
color = cyan, color = cyan,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
titleStyle = MaterialTheme.typography.titleMedium, titleStyle = MaterialTheme.typography.titleMedium,
isLoading = uiState.isLoading isLoading = uiState.isLoading
) )
@@ -131,11 +134,17 @@ fun SummaryCard(title: String, titleStyle: TextStyle, value: String, color: Colo
Text(text = title, style = titleStyle) Text(text = title, style = titleStyle)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if(!isLoading) { 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 { } else {
LoadingAnimation(modifier = Modifier Spacer(modifier = Modifier
.fillMaxWidth() .height(36.dp)
.height(36.dp)) .background(
ShimmerBrush(color = color),
shape = RoundedCornerShape(16.dp)
)
.fillMaxWidth())
} }
} }
} }