diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..3d733c7 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt b/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt index 1b81bb6..c3ff7dc 100644 --- a/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt +++ b/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt @@ -15,26 +15,23 @@ data class MaskedEmail( val url: String?, val emailPrefix: String?, val createdAt: Instant?, - val lastMessageAt: Instant? + val lastMessageAt: Instant?, + val formattedCreatedAt: String? = createdAt?.let { formatInstant(it) }, + val formattedLastMessageAt: String? = lastMessageAt?.let { formatInstant(it) } ) { val displayName: String get() = description?.takeIf { it.isNotBlank() } ?: forDomain?.takeIf { it.isNotBlank() } ?: email.substringBefore("@") - val formattedCreatedAt: String? - get() = createdAt?.let { formatInstant(it) } - - val formattedLastMessageAt: String? - get() = lastMessageAt?.let { formatInstant(it) } - val isActive: Boolean get() = state == EmailState.ENABLED || state == EmailState.PENDING - private fun formatInstant(instant: Instant): String { - val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + companion object { + private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) .withZone(ZoneId.systemDefault()) - return formatter.format(instant) + + fun formatInstant(instant: Instant): String = formatter.format(instant) } } diff --git a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt index c681446..3bdeae5 100644 --- a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt +++ b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt @@ -46,7 +46,8 @@ fun MaskedEmailCard( onClick: () -> Unit, sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isScrolling: Boolean = false ) { val haptic = LocalHapticFeedback.current @@ -61,9 +62,13 @@ fun MaskedEmailCard( Card( modifier = modifier .fillMaxWidth() - .sharedBounds( - sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope + .then( + if (!isScrolling) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -85,10 +90,12 @@ fun MaskedEmailCard( ) { StatusIcon( state = maskedEmail.state, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "icon-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "icon-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -97,10 +104,12 @@ fun MaskedEmailCard( style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "title-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "title-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) Text( text = maskedEmail.email, @@ -108,10 +117,12 @@ fun MaskedEmailCard( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "email-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "email-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) maskedEmail.forDomain?.let { domain -> Text( diff --git a/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt b/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt new file mode 100644 index 0000000..3060e6e --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt @@ -0,0 +1,131 @@ +package com.fastmask.ui.components + +import androidx.compose.animation.core.LinearEasing +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerEmailCard( + modifier: Modifier = Modifier +) { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.2f), + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f) + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 500f, translateAnim - 500f), + end = Offset(translateAnim, translateAnim) + ) + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status icon placeholder + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(brush) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + // Title placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Email placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + Spacer(modifier = Modifier.height(6.dp)) + // Domain placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + } + } + } +} + +@Composable +fun ShimmerEmailList( + modifier: Modifier = Modifier, + itemCount: Int = 8 +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(itemCount) { + ShimmerEmailCard() + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt index b7043d5..f684a3b 100644 --- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt @@ -57,8 +57,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.hilt.navigation.compose.hiltViewModel import com.fastmask.domain.model.MaskedEmail import com.fastmask.ui.components.ErrorMessage -import com.fastmask.ui.components.LoadingIndicator import com.fastmask.ui.components.MaskedEmailCard +import com.fastmask.ui.components.ShimmerEmailList import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @@ -77,6 +77,10 @@ fun MaskedEmailListScreen( val listState = rememberLazyListState() val haptic = LocalHapticFeedback.current + val isScrolling by remember { + derivedStateOf { listState.isScrollInProgress } + } + val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 @@ -196,7 +200,7 @@ fun MaskedEmailListScreen( when { uiState.isLoading && uiState.emails.isEmpty() -> { - LoadingIndicator() + ShimmerEmailList() } uiState.error != null && uiState.emails.isEmpty() -> { @@ -214,7 +218,8 @@ fun MaskedEmailListScreen( onEmailClick = { email -> onNavigateToDetail(email.id) }, listState = listState, sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope + animatedContentScope = animatedContentScope, + isScrolling = isScrolling ) } } @@ -310,7 +315,8 @@ private fun EmailList( onEmailClick: (MaskedEmail) -> Unit, listState: LazyListState, sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope + animatedContentScope: AnimatedContentScope, + isScrolling: Boolean ) { PullToRefreshBox( isRefreshing = isRefreshing, @@ -343,7 +349,8 @@ private fun EmailList( onClick = { onEmailClick(email) }, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope, - modifier = Modifier.animateItem() + modifier = Modifier.animateItem(), + isScrolling = isScrolling ) } }