diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 2dd2480dfc..65f9e9495d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -54,25 +56,33 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent @@ -81,6 +91,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareR import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -103,8 +114,7 @@ fun RenderSoftwareApplication( val name = remember(event) { event.name() ?: event.appId().orEmpty() } val summary = remember(event) { event.summary() } val description = remember(event) { event.content.trim() } - val platforms = remember(event) { event.platforms() } - val license = remember(event) { event.license() } + val images = remember(event) { event.images() } val latestVersion by produceLatestReleaseVersion(event) @@ -133,6 +143,7 @@ fun RenderSoftwareApplication( overflow = TextOverflow.Ellipsis, ) } + AppAuthorLine(note, accountViewModel, nav) summary?.takeIf { it.isNotBlank() }?.let { Text( text = it, @@ -160,9 +171,9 @@ fun RenderSoftwareApplication( ) } - if (platforms.isNotEmpty() || license != null) { + if (images.isNotEmpty()) { Spacer(StdVertSpacer) - PlatformLicenseRow(platforms = platforms, license = license) + ScreenshotsStrip(images, accountViewModel, imageHeight = 200.dp) } } @@ -231,12 +242,24 @@ fun AppIcon( name: String, sizeDp: Int = 56, ) { + val shape = RoundedCornerShape((sizeDp / 4).dp) Box( Modifier .size(sizeDp.dp) - .clip(RoundedCornerShape((sizeDp / 4).dp)) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape((sizeDp / 4).dp)), + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, shape), + contentAlignment = Alignment.Center, ) { + // Fallback underneath the image: visible when there is no icon url, + // while the icon downloads, and when the download fails (AsyncImage + // draws nothing in those states). + Text( + text = (name.firstOrNull() ?: '?').uppercase(), + fontSize = (sizeDp / 2).sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.grayText, + ) icon?.let { AsyncImage( model = it, @@ -248,6 +271,29 @@ fun AppIcon( } } +/** + * "by " line with a small clickable profile picture. Shared between + * the app feed card and the app detail screen header. + */ +@Composable +fun AppAuthorLine( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.nip82_by_author), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + Spacer(Modifier.width(4.dp)) + NoteAuthorPicture(note, Size20dp, accountViewModel = accountViewModel, nav = nav) + Spacer(Modifier.width(4.dp)) + NoteUsernameDisplay(note, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun PlatformLicenseRow( @@ -284,23 +330,44 @@ fun TopicChipFlow( } @Composable -fun ScreenshotsStrip(images: List) { +fun ScreenshotsStrip( + images: List, + accountViewModel: AccountViewModel, + contentPadding: PaddingValues = PaddingValues(0.dp), + imageHeight: Dp = 180.dp, +) { if (images.isEmpty()) return + + val mediaContents = + remember(images) { + images.map { MediaUrlImage(url = it) }.toImmutableList() + } + LazyRow( horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.height(180.dp), + contentPadding = contentPadding, + modifier = Modifier.height(imageHeight), ) { - items(images) { imageUrl -> - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .height(180.dp) - .clip(RoundedCornerShape(8.dp)) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)), - ) + items(mediaContents) { content -> + // Fixed-height tile whose width follows the image's aspect ratio + // once it is known; assumes a portrait phone screenshot before the + // first load fills the ratio cache. + val ratio = MediaAspectRatioCache.get(content.url) ?: (9f / 16f) + Box( + Modifier + .height(imageHeight) + .aspectRatio(ratio) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)), + ) { + ZoomableContentView( + content = content, + images = mediaContents, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt index 542d091ba7..5a7f142b21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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 @@ -54,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -66,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.types.AppAuthorLine import com.vitorpamplona.amethyst.ui.note.types.AppIcon import com.vitorpamplona.amethyst.ui.note.types.AppLinksColumn import com.vitorpamplona.amethyst.ui.note.types.Chip @@ -83,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.Thre import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText @@ -199,17 +203,20 @@ private fun SoftwareAppDetailBody( ) { item(key = "header") { AppDetailHeader( + note = note, icon = icon, name = name, summary = summary, latestVersion = latestRelease?.version(), + accountViewModel = accountViewModel, + nav = nav, ) } if (images.isNotEmpty()) { item(key = "screenshots") { Spacer(Modifier.height(12.dp)) - ScreenshotsStrip(images) + ScreenshotsStrip(images, accountViewModel, contentPadding = PaddingValues(horizontal = 12.dp)) } } @@ -252,28 +259,18 @@ private fun SoftwareAppDetailBody( } } - item(key = "reactions") { - Spacer(Modifier.height(12.dp)) - ReactionsRow( - baseNote = note, - showReactionDetail = true, - addPadding = false, - editState = null, - accountViewModel = accountViewModel, - nav = nav, - ) - } - if (latestRelease != null) { item(key = "latest-release") { Spacer(Modifier.height(12.dp)) - SectionLabel(stringRes(R.string.nip82_section_latest_release)) - RenderSoftwareReleaseBody( - event = latestRelease, - accountViewModel = accountViewModel, - nav = nav, - showAppId = false, - ) + Column(PaddingHorizontal12Modifier) { + SectionLabel(stringRes(R.string.nip82_section_latest_release)) + RenderSoftwareReleaseBody( + event = latestRelease, + accountViewModel = accountViewModel, + nav = nav, + showAppId = false, + ) + } } } @@ -292,25 +289,39 @@ private fun SoftwareAppDetailBody( key = { "older-${it.id}" }, ) { release -> Spacer(Modifier.height(8.dp)) - RenderSoftwareReleaseBody( - event = release, - accountViewModel = accountViewModel, - nav = nav, - showAppId = false, - ) + Column(PaddingHorizontal12Modifier) { + RenderSoftwareReleaseBody( + event = release, + accountViewModel = accountViewModel, + nav = nav, + showAppId = false, + ) + } } } } - item(key = "comments-header") { - Spacer(Modifier.height(16.dp)) - SectionLabel(stringRes(R.string.nip82_section_comments)) - if (comments.isEmpty()) { - Spacer(Modifier.height(4.dp)) + item(key = "reactions") { + Spacer(Modifier.height(12.dp)) + ReactionsRow( + baseNote = note, + showReactionDetail = true, + addPadding = true, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = DividerThickness) + } + + if (comments.isEmpty()) { + item(key = "no-comments") { + Spacer(Modifier.height(8.dp)) Text( text = stringRes(R.string.nip82_no_comments), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, + modifier = PaddingHorizontal12Modifier, ) } } @@ -320,8 +331,16 @@ private fun SoftwareAppDetailBody( key = { _, item -> item.idHex }, contentType = { _, _ -> "comment" }, ) { _, item -> + val level = threadViewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0) + NoteCompose( baseNote = item, + modifier = + Modifier.drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.placeholderText, + selected = MaterialTheme.colorScheme.placeholderText, + ), isBoostedNote = false, unPackReply = ReplyRenderType.NONE, quotesLeft = 3, @@ -335,12 +354,18 @@ private fun SoftwareAppDetailBody( @Composable private fun AppDetailHeader( + note: AddressableNote, icon: String?, name: String, summary: String?, latestVersion: String?, + accountViewModel: AccountViewModel, + nav: INav, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = PaddingHorizontal12Modifier, + ) { AppIcon(icon = icon, name = name, sizeDp = 72) Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { @@ -351,6 +376,7 @@ private fun AppDetailHeader( maxLines = 2, overflow = TextOverflow.Ellipsis, ) + AppAuthorLine(note, accountViewModel, nav) summary?.takeIf { it.isNotBlank() }?.let { Spacer(StdVertSpacer) Text( @@ -374,7 +400,7 @@ private fun Section( title: String, content: @Composable () -> Unit, ) { - Column { + Column(PaddingHorizontal12Modifier) { SectionLabel(title) Spacer(Modifier.height(6.dp)) content() @@ -401,6 +427,7 @@ private fun OlderReleasesToggle( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 12.dp) .clip(QuoteBorder) .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) .clickable { onToggle() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c761c94dac..15989c296d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -721,6 +721,7 @@ Latest release Comments Be the first to comment. + by Show older releases Hide older releases Calendars