loading animation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 ->
|
||||
// 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<PostgresAction>(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<Log>()
|
||||
_logs.update { currentLogs ->
|
||||
(currentLogs + newLog).sortedByDescending { it.timestamp }
|
||||
}
|
||||
}
|
||||
is PostgresAction.Update -> {
|
||||
LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
|
||||
val updatedLog = action.decodeRecord<Log>()
|
||||
_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<Log>()
|
||||
_logs.update { currentLogs ->
|
||||
(currentLogs + newLog).sortedByDescending { it.timestamp }
|
||||
}
|
||||
}
|
||||
is PostgresAction.Update -> {
|
||||
LOG.d("LogRepository", "Realtime UPDATE: ${action.record}")
|
||||
val updatedLog = action.decodeRecord<Log>()
|
||||
_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 }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user