mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #3078 from vitorpamplona/claude/pensive-turing-6UC1W
Add dedicated NIP-82 software app detail screen
This commit is contained in:
@@ -186,6 +186,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||
@@ -265,6 +266,7 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -149,6 +150,10 @@ fun routeForInner(
|
||||
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
is SoftwareApplicationEvent -> {
|
||||
Route.SoftwareAppDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
// Calendar appointments route to their dedicated detail screen rather than the generic
|
||||
// Route.Note that AddressableEvent would fall through to — without this the notification
|
||||
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
|
||||
|
||||
@@ -83,6 +83,18 @@ sealed class Route {
|
||||
|
||||
@Serializable object SoftwareApps : Route()
|
||||
|
||||
@Serializable data class SoftwareAppDetail(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Calendars : Route()
|
||||
|
||||
@Serializable object CalendarCollections : Route()
|
||||
|
||||
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -55,11 +58,15 @@ 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.model.LocalCache
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.LinkIcon
|
||||
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
|
||||
@@ -73,10 +80,16 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
|
||||
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.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* NIP-82 kind 32267 — Software Application card. Renders icon, name, summary,
|
||||
* a horizontal screenshot strip, hashtag/platform chips, and quick links.
|
||||
* NIP-82 kind 32267 — compact feed card. Renders icon, name, latest version
|
||||
* chip, summary, description, and platforms/license. Tapping the card opens
|
||||
* the dedicated [Route.SoftwareAppDetail] screen with screenshots, full
|
||||
* description, links, releases, and comments. The bottom of the card hosts
|
||||
* the standard [ReactionsRow] so zaps / likes / replies are visible inline.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderSoftwareApplication(
|
||||
@@ -86,17 +99,15 @@ fun RenderSoftwareApplication(
|
||||
) {
|
||||
val event = note.event as? SoftwareApplicationEvent ?: return
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val icon = remember(event) { event.icon() }
|
||||
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
|
||||
val summary = remember(event) { event.summary() }
|
||||
val images = remember(event) { event.images() }
|
||||
val topics = remember(event) { event.topics() }
|
||||
val description = remember(event) { event.content.trim() }
|
||||
val platforms = remember(event) { event.platforms() }
|
||||
val website = remember(event) { event.url() }
|
||||
val repo = remember(event) { event.repository() }
|
||||
val license = remember(event) { event.license() }
|
||||
|
||||
val latestVersion by produceLatestReleaseVersion(event)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -104,24 +115,11 @@ fun RenderSoftwareApplication(
|
||||
.padding(top = Size5dp)
|
||||
.clip(QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
|
||||
.clickable { nav.nav(Route.SoftwareAppDetail(event.kind, event.pubKey, event.dTag())) }
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
icon?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
AppIcon(icon = icon, name = name)
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -135,7 +133,7 @@ fun RenderSoftwareApplication(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
summary?.let {
|
||||
summary?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -145,83 +143,215 @@ fun RenderSoftwareApplication(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (images.isNotEmpty()) {
|
||||
Spacer(StdVertSpacer)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.height(180.dp),
|
||||
) {
|
||||
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)),
|
||||
)
|
||||
}
|
||||
latestVersion?.let { version ->
|
||||
Spacer(Modifier.width(8.dp))
|
||||
VersionChip(version)
|
||||
}
|
||||
}
|
||||
|
||||
if (platforms.isNotEmpty() || topics.isNotEmpty() || license != null) {
|
||||
if (description.isNotBlank()) {
|
||||
Spacer(StdVertSpacer)
|
||||
ChipFlowRow(platforms = platforms, topics = topics, license = license)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (website != null || repo != null) {
|
||||
if (platforms.isNotEmpty() || license != null) {
|
||||
Spacer(StdVertSpacer)
|
||||
Column {
|
||||
website?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
ClickableTextPrimary(
|
||||
text = it.removePrefix("https://").removePrefix("http://"),
|
||||
onClick = { runCatching { uri.openUri(it) } },
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
repo?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
ClickableTextPrimary(
|
||||
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
|
||||
onClick = { runCatching { uri.openUri(it) } },
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
PlatformLicenseRow(platforms = platforms, license = license)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionsRow(
|
||||
baseNote = note,
|
||||
showReactionDetail = true,
|
||||
addPadding = true,
|
||||
editState = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the latest NIP-82 [SoftwareReleaseEvent] for [app] from
|
||||
* [LocalCache] and exposes the version string. Recomputes on event identity
|
||||
* change; relay-driven recompositions of the surrounding feed will pick up
|
||||
* newer releases via re-keying.
|
||||
*/
|
||||
@Composable
|
||||
fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) =
|
||||
produceState<String?>(initialValue = null, key1 = app.id) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
findLatestNip82Release(app)?.version()
|
||||
}
|
||||
}
|
||||
|
||||
fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? {
|
||||
val prefix = "${app.dTag()}@"
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
|
||||
val ev = addr.event ?: return@filterIntoSet false
|
||||
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
|
||||
}
|
||||
}
|
||||
return notes
|
||||
.mapNotNull {
|
||||
when (val ev = it.event) {
|
||||
is SoftwareReleaseEvent -> ev
|
||||
null -> null
|
||||
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
|
||||
}
|
||||
}.maxByOrNull { it.createdAt }
|
||||
}
|
||||
|
||||
fun findAllNip82Releases(app: SoftwareApplicationEvent): List<SoftwareReleaseEvent> {
|
||||
val prefix = "${app.dTag()}@"
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
|
||||
val ev = addr.event ?: return@filterIntoSet false
|
||||
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
|
||||
}
|
||||
return notes
|
||||
.mapNotNull {
|
||||
when (val ev = it.event) {
|
||||
is SoftwareReleaseEvent -> ev
|
||||
null -> null
|
||||
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
|
||||
}
|
||||
}.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipFlowRow(
|
||||
platforms: List<String>,
|
||||
topics: List<String>,
|
||||
license: String?,
|
||||
) {
|
||||
// FlowRow is in compose foundation but we use a simple LazyRow to keep this compatible.
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(platforms) { Chip(it) }
|
||||
items(topics) { Chip("#$it") }
|
||||
license?.let { item { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(
|
||||
text: String,
|
||||
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
fun AppIcon(
|
||||
icon: String?,
|
||||
name: String,
|
||||
sizeDp: Int = 56,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(sizeDp.dp)
|
||||
.clip(RoundedCornerShape((sizeDp / 4).dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape((sizeDp / 4).dp)),
|
||||
) {
|
||||
icon?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(sizeDp.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun PlatformLicenseRow(
|
||||
platforms: List<String>,
|
||||
license: String?,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
platforms.forEach { Chip(it) }
|
||||
license?.let { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TopicChipFlow(
|
||||
topics: List<String>,
|
||||
nav: INav,
|
||||
) {
|
||||
if (topics.isEmpty()) return
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
topics.forEach { tag ->
|
||||
Chip(
|
||||
text = "#$tag",
|
||||
modifier = Modifier.clickable { nav.nav(Route.Hashtag(tag.lowercase())) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenshotsStrip(images: List<String>) {
|
||||
if (images.isEmpty()) return
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.height(180.dp),
|
||||
) {
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppLinksColumn(
|
||||
website: String?,
|
||||
repository: String?,
|
||||
) {
|
||||
if (website == null && repository == null) return
|
||||
val uri = LocalUriHandler.current
|
||||
Column {
|
||||
website?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
ClickableTextPrimary(
|
||||
text = it.removePrefix("https://").removePrefix("http://"),
|
||||
onClick = { runCatching { uri.openUri(it) } },
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
repository?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
ClickableTextPrimary(
|
||||
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
|
||||
onClick = { runCatching { uri.openUri(it) } },
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionChip(version: String) {
|
||||
Chip(
|
||||
text = stringRes(R.string.nip82_version_label, version),
|
||||
tint = MaterialTheme.colorScheme.primaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Chip(
|
||||
text: String,
|
||||
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(tint)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
@@ -254,11 +384,21 @@ fun RenderSoftwareRelease(
|
||||
return
|
||||
}
|
||||
|
||||
RenderSoftwareReleaseBody(event = event, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderSoftwareReleaseBody(
|
||||
event: SoftwareReleaseEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
showAppId: Boolean = true,
|
||||
) {
|
||||
val appId = remember(event) { event.appId() }
|
||||
val version = remember(event) { event.version() }
|
||||
val channel = remember(event) { event.channel() }
|
||||
val assets = remember(event) { event.assets() }
|
||||
val notes = remember(event) { event.content }
|
||||
val notes = event.content
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
@@ -271,14 +411,16 @@ fun RenderSoftwareRelease(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
appId?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (showAppId) {
|
||||
appId?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
version?.let {
|
||||
Text(
|
||||
@@ -354,6 +496,7 @@ private fun LoadAssetNote(
|
||||
content(note)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SoftwareAssetRow(
|
||||
note: Note,
|
||||
@@ -416,8 +559,11 @@ private fun SoftwareAssetRow(
|
||||
}
|
||||
if (platforms.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(platforms) { Chip(it) }
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
platforms.forEach { Chip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +581,7 @@ private fun SoftwareAssetRow(
|
||||
* NIP-82 kind 3063 — Software Asset card. A compact descriptor of a single
|
||||
* install artifact: MIME type, version, size, platforms, and a download link.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderSoftwareAsset(
|
||||
note: Note,
|
||||
@@ -499,15 +646,18 @@ fun RenderSoftwareAsset(
|
||||
|
||||
if (mimeType != null || platforms.isNotEmpty()) {
|
||||
Spacer(StdVertSpacer)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
mimeType?.let { item { Chip(prettyMime(it)) } }
|
||||
items(platforms) { Chip(it) }
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
mimeType?.let { Chip(prettyMime(it)) }
|
||||
platforms.forEach { Chip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prettyMime(mime: String): String =
|
||||
internal fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
@@ -528,7 +678,7 @@ private fun prettyMime(mime: String): String =
|
||||
else -> mime
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024L) return "$bytes B"
|
||||
val kb = bytes / 1024.0
|
||||
if (kb < 1024) return "%.1f KB".format(kb)
|
||||
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps
|
||||
|
||||
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.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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
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.AppIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AppLinksColumn
|
||||
import com.vitorpamplona.amethyst.ui.note.types.Chip
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PlatformLicenseRow
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareReleaseBody
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ScreenshotsStrip
|
||||
import com.vitorpamplona.amethyst.ui.note.types.TopicChipFlow
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VersionChip
|
||||
import com.vitorpamplona.amethyst.ui.note.types.findAllNip82Releases
|
||||
import com.vitorpamplona.amethyst.ui.note.types.findLatestNip82Release
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription
|
||||
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.QuoteBorder
|
||||
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
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
|
||||
@Composable
|
||||
fun SoftwareAppDetailScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let {
|
||||
SoftwareAppDetailScreenContent(
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftwareAppDetailScreenContent(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event by observeNoteEvent<SoftwareApplicationEvent>(note, accountViewModel)
|
||||
|
||||
// Drive the comments thread off the app's address tag — ThreadAssembler
|
||||
// resolves that to the application note and then walks replies.
|
||||
val addressTag = note.idHex
|
||||
val threadViewModel: ThreadFeedViewModel =
|
||||
viewModel(
|
||||
key = addressTag + "SoftwareAppDetailThread",
|
||||
factory = ThreadFeedViewModel.Factory(accountViewModel.account, addressTag),
|
||||
)
|
||||
WatchLifecycleAndUpdateModel(threadViewModel)
|
||||
ThreadFilterAssemblerSubscription(addressTag, accountViewModel)
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
caption = event?.name() ?: event?.appId() ?: note.dTag(),
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val current = event
|
||||
if (current == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.loading_feed),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SoftwareAppDetailBody(
|
||||
note = note,
|
||||
event = current,
|
||||
threadViewModel = threadViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftwareAppDetailBody(
|
||||
note: AddressableNote,
|
||||
event: SoftwareApplicationEvent,
|
||||
threadViewModel: ThreadFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val icon = remember(event) { event.icon() }
|
||||
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
|
||||
val summary = remember(event) { event.summary() }
|
||||
val description = remember(event) { event.content.trim() }
|
||||
val images = remember(event) { event.images() }
|
||||
val topics = remember(event) { event.topics() }
|
||||
val platforms = remember(event) { event.platforms() }
|
||||
val license = remember(event) { event.license() }
|
||||
val website = remember(event) { event.url() }
|
||||
val repo = remember(event) { event.repository() }
|
||||
|
||||
val latestRelease = remember(event) { findLatestNip82Release(event) }
|
||||
val olderReleases = remember(event) { findAllNip82Releases(event).drop(1) }
|
||||
|
||||
val threadState by threadViewModel.feedState.feedContent.collectAsStateWithLifecycle()
|
||||
val comments: List<Note> =
|
||||
when (val s = threadState) {
|
||||
is FeedState.Loaded ->
|
||||
s.feed
|
||||
.collectAsStateWithLifecycle()
|
||||
.value.list
|
||||
.filter { it.idHex != note.idHex }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
var showOlder by rememberSaveable(event.id) { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = threadViewModel.llState,
|
||||
) {
|
||||
item(key = "header") {
|
||||
AppDetailHeader(
|
||||
icon = icon,
|
||||
name = name,
|
||||
summary = summary,
|
||||
latestVersion = latestRelease?.version(),
|
||||
)
|
||||
}
|
||||
|
||||
if (images.isNotEmpty()) {
|
||||
item(key = "screenshots") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ScreenshotsStrip(images)
|
||||
}
|
||||
}
|
||||
|
||||
if (description.isNotBlank()) {
|
||||
item(key = "about") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Section(title = stringRes(R.string.nip82_section_about)) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platforms.isNotEmpty() || license != null) {
|
||||
item(key = "platforms") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Section(title = stringRes(R.string.nip82_section_platforms)) {
|
||||
PlatformLicenseRow(platforms = platforms, license = license)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (topics.isNotEmpty()) {
|
||||
item(key = "topics") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Section(title = stringRes(R.string.nip82_section_topics)) {
|
||||
TopicChipFlow(topics = topics, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (website != null || repo != null) {
|
||||
item(key = "links") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Section(title = stringRes(R.string.nip82_section_links)) {
|
||||
AppLinksColumn(website = website, repository = repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (olderReleases.isNotEmpty()) {
|
||||
item(key = "older-releases-toggle") {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OlderReleasesToggle(
|
||||
count = olderReleases.size,
|
||||
expanded = showOlder,
|
||||
onToggle = { showOlder = !showOlder },
|
||||
)
|
||||
}
|
||||
if (showOlder) {
|
||||
items(
|
||||
olderReleases,
|
||||
key = { "older-${it.id}" },
|
||||
) { release ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
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))
|
||||
Text(
|
||||
text = stringRes(R.string.nip82_no_comments),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
comments,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, _ -> "comment" },
|
||||
) { _, item ->
|
||||
NoteCompose(
|
||||
baseNote = item,
|
||||
isBoostedNote = false,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppDetailHeader(
|
||||
icon: String?,
|
||||
name: String,
|
||||
summary: String?,
|
||||
latestVersion: String?,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(icon = icon, name = name, sizeDp = 72)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
summary?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(StdVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
latestVersion?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
VersionChip(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
SectionLabel(title)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OlderReleasesToggle(
|
||||
count: Int,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
|
||||
.clickable { onToggle() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
if (expanded) {
|
||||
stringRes(R.string.nip82_older_releases_hide)
|
||||
} else {
|
||||
stringRes(R.string.nip82_older_releases_show)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Chip(text = count.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,15 @@
|
||||
<string name="nip82_repository_label">Source: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Download</string>
|
||||
<string name="nip82_section_about">About</string>
|
||||
<string name="nip82_section_platforms">Platforms</string>
|
||||
<string name="nip82_section_topics">Topics</string>
|
||||
<string name="nip82_section_links">Links</string>
|
||||
<string name="nip82_section_latest_release">Latest release</string>
|
||||
<string name="nip82_section_comments">Comments</string>
|
||||
<string name="nip82_no_comments">Be the first to comment.</string>
|
||||
<string name="nip82_older_releases_show">Show older releases</string>
|
||||
<string name="nip82_older_releases_hide">Hide older releases</string>
|
||||
<string name="calendars">Calendars</string>
|
||||
<string name="shorts">Shorts</string>
|
||||
<string name="public_chats">Public Chats</string>
|
||||
|
||||
Reference in New Issue
Block a user