diff --git a/app/src/main/kotlin/eu/peernetwork/app/ui/privacy/PrivacyScreen.kt b/app/src/main/kotlin/eu/peernetwork/app/ui/privacy/PrivacyScreen.kt index 376676e89..c3161c064 100644 --- a/app/src/main/kotlin/eu/peernetwork/app/ui/privacy/PrivacyScreen.kt +++ b/app/src/main/kotlin/eu/peernetwork/app/ui/privacy/PrivacyScreen.kt @@ -26,7 +26,7 @@ fun PrivacyScreen( webViewClient = WebViewClient() }) } - val link = remember { mutableStateOf(null) } + val link = remember(url) { mutableStateOf(url) } Column( modifier = Modifier .fillMaxSize() diff --git a/blog/ui/build.gradle.kts b/blog/ui/build.gradle.kts index b5ca7cd8d..274f13f9b 100644 --- a/blog/ui/build.gradle.kts +++ b/blog/ui/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { implementation(project(":blog:domain")) implementation(libs.dagger) ksp(libs.dagger.compiler) - + implementation(libs.lottie) testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.androidx.test) diff --git a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/MediaView.kt b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/MediaView.kt index 8b5c145d8..ede02c52d 100644 --- a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/MediaView.kt +++ b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/MediaView.kt @@ -1,10 +1,15 @@ package eu.peernetwork.blog.ui.compose import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -17,22 +22,79 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import eu.peernetwork.blog.ui.model.UiAuthor +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition import eu.peernetwork.blog.ui.model.UiAction +import eu.peernetwork.blog.ui.model.UiAuthor import eu.peernetwork.core.ui.R import eu.peernetwork.core.ui.design.compose.DesignTextButton import eu.peernetwork.core.ui.theme.PeerTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun BoxScope.ConfirmedLikeHeartOverlay( + isLiked: Boolean, + modifier: Modifier = Modifier, + size: Dp = 160.dp, + rawRes: Int = R.raw.like +) { + val (play, setPlay) = remember { mutableStateOf(false) } + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(rawRes)) + val progress by animateLottieCompositionAsState( + composition = composition, + isPlaying = play, + iterations = 1, + speed = 1f + ) + var prev by remember { mutableStateOf(isLiked) } + LaunchedEffect(isLiked) { + if (!prev && isLiked) { + setPlay(false) + setPlay(true) + } + prev = isLiked + } + LaunchedEffect(progress) { + if (progress >= 1f) setPlay(false) + } + + AnimatedVisibility( + visible = play && progress < 1f, + enter = fadeIn(animationSpec = tween(160)), + exit = fadeOut(animationSpec = tween(220)), + modifier = modifier + ) { + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.size(size) + ) + } +} @Composable fun MediaView( @@ -40,7 +102,9 @@ fun MediaView( description: String, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + isLiked: Boolean = false, onClick: () -> Unit = {}, + onDoubleClick: () -> Unit = {}, onAuthorClick: () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, engagements: @Composable RowScope.() -> Unit = {}, @@ -53,6 +117,12 @@ fun MediaView( val updatedCaption by rememberUpdatedState(caption) val updatedEngagements by rememberUpdatedState(engagements) val updatedModeration by rememberUpdatedState(moderation) + val click by rememberUpdatedState(onClick) + val dblClick by rememberUpdatedState(onDoubleClick) + + val scope = rememberCoroutineScope() + val (singleTapJob, setSingleTapJob) = remember { mutableStateOf(null) } + PostScaffold( modifier = modifier, header = {}, @@ -73,13 +143,30 @@ fun MediaView( footer = { } ) { Box( - modifier = Modifier.clickable( - enabled = true, - role = Role.Button, - onClick = onClick - ) + modifier = Modifier + .semantics { role = Role.Button } + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val job = scope.launch { + delay(120) + click() + } + setSingleTapJob(job) + }, + onDoubleTap = { + singleTapJob?.cancel() + dblClick() + } + ) } ) { updatedContent() + ConfirmedLikeHeartOverlay( + isLiked = isLiked, + modifier = Modifier.align(Alignment.Center), + size = 160.dp + ) + Image( painter = painterResource(R.drawable.overlay_gradient), contentDescription = null, @@ -120,6 +207,7 @@ fun PreviewMediaPreview() { isfollowed = false ), description = "Description...", + isLiked = false, engagements = { UiAction.ENGAGEMENTS.forEach { DesignTextButton( @@ -154,4 +242,4 @@ fun PreviewMediaPreview() { ) } } -} +} \ No newline at end of file diff --git a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostItem.kt b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostItem.kt index a99aef5ea..65455b0f6 100644 --- a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostItem.kt +++ b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostItem.kt @@ -1,3 +1,4 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) package eu.peernetwork.blog.ui.compose import androidx.compose.foundation.layout.RowScope @@ -16,7 +17,9 @@ import eu.peernetwork.blog.ui.model.UiPost fun LazyItemScope.PostItem( post: UiPost, position: Int, + isLiked: Boolean = false, onClick: () -> Unit = {}, + onDoubleClick: () -> Unit = {}, onAuthorClick: () -> Unit = {}, onMentionClick: (String) -> Unit = {}, onHashtagClick: (String) -> Unit = {}, @@ -37,6 +40,7 @@ fun LazyItemScope.PostItem( MediaView( author = post.author, description = post.time, + isLiked = isLiked, modifier = Modifier .padding(bottom = 16.dp), caption = { @@ -52,6 +56,7 @@ fun LazyItemScope.PostItem( engagements = engagements, moderation = moderation, onClick = onClick, + onDoubleClick = onDoubleClick, onAuthorClick = onAuthorClick, actions = actions ) { updatedContent(post) } @@ -59,11 +64,13 @@ fun LazyItemScope.PostItem( TextPreview( author = post.author, description = post.time, + isLiked = isLiked, modifier = Modifier.padding(bottom = 12.dp) .padding(horizontal = 8.dp), engagements = engagements, moderation = moderation, onClick = onClick, + onDoubleClick = onDoubleClick, onAuthorClick = onAuthorClick, actions = actions ) { diff --git a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostView.kt b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostView.kt index 031005de7..4b7867ebb 100644 --- a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostView.kt +++ b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/compose/PostView.kt @@ -1,8 +1,7 @@ package eu.peernetwork.blog.ui.compose -import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,24 +18,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import eu.peernetwork.blog.ui.model.UiAuthor import eu.peernetwork.blog.ui.model.UiAction +import eu.peernetwork.blog.ui.model.UiAuthor import eu.peernetwork.core.ui.design.compose.DesignTextButton import eu.peernetwork.core.ui.theme.PeerTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun TextPreview( author: UiAuthor, description: String, modifier: Modifier = Modifier, + isLiked: Boolean = false, onClick: () -> Unit = {}, + onDoubleClick: () -> Unit = {}, onAuthorClick: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues( top = 16.dp, @@ -53,6 +63,12 @@ fun TextPreview( val updatedContent by rememberUpdatedState(content) val updatedEngagements by rememberUpdatedState(engagements) val updatedModeration by rememberUpdatedState(moderation) + val tap by rememberUpdatedState(onClick) + val dblTap by rememberUpdatedState(onDoubleClick) + + val scope = rememberCoroutineScope() + val (singleTapJob, setSingleTapJob) = remember { mutableStateOf(null) } + PostScaffold( modifier = modifier, header = { @@ -73,12 +89,30 @@ fun TextPreview( .background( MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(24.dp) - ).clickable( - enabled = true, - role = Role.Button, - onClick = onClick ) - ) }, + .semantics { role = Role.Button } + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val job = scope.launch { + delay(120) + tap() + } + setSingleTapJob(job) + }, + onDoubleTap = { + singleTapJob?.cancel() + dblTap() + } + ) + } + ) { + ConfirmedLikeHeartOverlay( + isLiked = isLiked, + modifier = Modifier.align(Alignment.Center), + size = 140.dp + ) + } }, contentPadding = contentPadding, footer = { Row( @@ -94,7 +128,7 @@ fun TextPreview( } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview fun PreviewTextPostCard() { PeerTheme { TextPreview( @@ -108,6 +142,7 @@ fun PreviewTextPostCard() { isfollowed = false ), description = "Description...", + isLiked = false, engagements = { UiAction.ENGAGEMENTS.forEach { DesignTextButton( diff --git a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/photo/PhotoListing.kt b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/photo/PhotoListing.kt index 52b31296c..214897ad2 100644 --- a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/photo/PhotoListing.kt +++ b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/photo/PhotoListing.kt @@ -138,13 +138,20 @@ fun LazyItemScope.PhotoListing( content: @Composable (UiPost) -> Unit = {} ) { val uiContent = post.mapToContent() + val engagementModel = uiEngagementEvent.onLoad(uiContent) val clickHandler by rememberUpdatedState { onAuthorClick(post.author.id) } val updatedConnection by rememberUpdatedState(connection) val handleOnPostClick by rememberUpdatedState(onPostClick) PostItem( post = post, position = index, + isLiked = engagementModel.isLiked, onClick = { handleOnPostClick(post.id, index) }, + onDoubleClick = { + if (!engagementModel.isLiked) { + uiEngagementEvent.onLike(engagementModel) + } + }, onAuthorClick = clickHandler, onMentionClick = onMentionClick, onHashtagClick = onHashtagClick, diff --git a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/video/VideoListing.kt b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/video/VideoListing.kt index 0eabdf5f0..3ed19cad6 100644 --- a/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/video/VideoListing.kt +++ b/blog/ui/src/main/kotlin/eu/peernetwork/blog/ui/timeline/video/VideoListing.kt @@ -1,7 +1,6 @@ package eu.peernetwork.blog.ui.timeline.video import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -24,7 +23,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState @@ -145,10 +143,12 @@ fun VideoListing( val updatedContent by rememberUpdatedState(content) val updatedConnection by rememberUpdatedState(connection) val uiContent = post.mapToContent() + val engagementModel = uiEngagementEvent.onLoad(uiContent) MediaView( author = post.author, onAuthorClick = { clickHandler(post.author.id) }, description = post.time, + isLiked = engagementModel.isLiked, caption = { TextView( uiContent.author.username, @@ -183,11 +183,14 @@ fun VideoListing( ) ) } + }, + onClick = { selectHandler() }, + onDoubleClick = { + if (!engagementModel.isLiked) { + uiEngagementEvent.onLike(engagementModel) + } } ) { - Box(modifier = Modifier.clickable( - role = Role.Button, - onClick = { selectHandler() } - )) { updatedContent(post) } + updatedContent(post) } } diff --git a/core/ui/src/main/res/raw/like.lottie b/core/ui/src/main/res/raw/like.lottie new file mode 100644 index 000000000..fb0807712 Binary files /dev/null and b/core/ui/src/main/res/raw/like.lottie differ