feat: modernize Git Repository, Issue, and Patch card design

Replace plain text layout in NoteCompose Git renderers with a unified
card style: icon badges, type chips, a compact repository header pill,
and icon-led link rows for web/clone URLs. Patches now surface the
short commit hash in the header.
This commit is contained in:
Claude
2026-05-15 14:37:19 +00:00
parent 3a7e9a90a9
commit 5e65bccb54
@@ -20,13 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -34,12 +39,18 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -54,14 +65,16 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size8dp
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.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -70,6 +83,101 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
private val CardShape = QuoteBorder
private val ChipShape = RoundedCornerShape(8.dp)
private val CardPadding = PaddingValues(Size10dp)
private val HeaderSpacing = Arrangement.spacedBy(Size8dp)
private val LinkRowSpacing = Arrangement.spacedBy(Size8dp)
@Composable
private fun GitCardContainer(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val border = MaterialTheme.colorScheme.subtleBorder
Column(
modifier =
modifier
.fillMaxWidth()
.clip(CardShape)
.border(1.dp, border, CardShape)
.padding(CardPadding),
) {
content()
}
}
@Composable
private fun IconBadge(
symbol: MaterialSymbol,
contentDescription: String?,
background: Color,
tint: Color,
size: Dp = 28.dp,
iconSize: Dp = Size16dp,
) {
Row(
modifier =
Modifier
.size(size)
.clip(CircleShape)
.background(background),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
modifier = Modifier.size(iconSize),
tint = tint,
)
}
}
@Composable
private fun TypeChip(
text: String,
background: Color,
contentColor: Color,
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = contentColor,
maxLines = 1,
modifier =
Modifier
.clip(ChipShape)
.background(background)
.padding(horizontal = Size8dp, vertical = 2.dp),
)
}
@Composable
private fun LinkRow(
symbol: MaterialSymbol,
contentDescription: String?,
url: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = LinkRowSpacing,
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
modifier = Modifier.size(Size16dp),
tint = MaterialTheme.colorScheme.grayText,
)
ClickableUrl(
url = url,
urlText = url.removePrefix("https://").removePrefix("http://"),
)
}
}
@Composable
fun RenderGitPatchEvent(
baseNote: Note,
@@ -101,29 +209,48 @@ private fun RenderShortRepositoryHeader(
nav: INav,
) {
val noteEvent by observeNoteEvent<GitRepositoryEvent>(baseNote, accountViewModel)
Column(
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
) {
val title = noteEvent?.name() ?: baseNote.dTag()
Text(
text = stringRes(id = R.string.git_repository, title),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
val summary = noteEvent?.description()
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(ChipShape)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f))
.padding(horizontal = Size10dp, vertical = Size8dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = HeaderSpacing,
) {
IconBadge(
symbol = MaterialSymbols.Code,
contentDescription = null,
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
tint = MaterialTheme.colorScheme.primary,
size = 24.dp,
iconSize = 14.dp,
)
noteEvent?.description()?.let {
Spacer(modifier = DoubleVertSpacer)
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun RenderGitPatchEvent(
@@ -136,17 +263,40 @@ private fun RenderGitPatchEvent(
accountViewModel: AccountViewModel,
nav: INav,
) {
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
GitCardContainer {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = HeaderSpacing,
) {
TypeChip(
text = stringRes(id = R.string.kind_git_patch),
background = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f),
contentColor = MaterialTheme.colorScheme.tertiary,
)
val commit = remember(noteEvent) { noteEvent.commit() }
commit?.takeIf { it.isNotBlank() }?.let { hash ->
Text(
text = hash.take(7),
style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
if (repository != null) {
Spacer(modifier = StdVertSpacer)
LoadAddressableNote(repository, accountViewModel) {
if (it != null) {
RenderShortRepositoryHeader(it, accountViewModel, nav)
Spacer(modifier = DoubleVertSpacer)
}
}
}
Spacer(modifier = HalfDoubleVertSpacer)
LoadDecryptedContent(note, accountViewModel) { body ->
val eventContent by
remember(note.event) {
@@ -206,6 +356,7 @@ private fun RenderGitPatchEvent(
}
}
}
}
@Composable
fun RenderGitIssueEvent(
@@ -242,17 +393,25 @@ private fun RenderGitIssueEvent(
accountViewModel: AccountViewModel,
nav: INav,
) {
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
GitCardContainer {
TypeChip(
text = stringRes(id = R.string.kind_git_issue),
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
contentColor = MaterialTheme.colorScheme.primary,
)
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
if (repository != null) {
Spacer(modifier = StdVertSpacer)
LoadAddressableNote(repository, accountViewModel) {
if (it != null) {
RenderShortRepositoryHeader(it, accountViewModel, nav)
Spacer(modifier = DoubleVertSpacer)
}
}
}
Spacer(modifier = HalfDoubleVertSpacer)
LoadDecryptedContent(note, accountViewModel) { body ->
val eventContent by
remember(note.event) {
@@ -307,6 +466,7 @@ private fun RenderGitIssueEvent(
}
}
}
}
@Composable
fun RenderGitRepositoryEvent(
@@ -331,62 +491,62 @@ private fun RenderGitRepositoryEvent(
val web = noteEvent.web()
val clone = noteEvent.clone()
GitCardContainer {
Row(
modifier =
Modifier
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
).padding(Size10dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = HeaderSpacing,
modifier = Modifier.fillMaxWidth(),
) {
Column {
IconBadge(
symbol = MaterialSymbols.Code,
contentDescription = null,
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(
text = stringRes(id = R.string.git_repository, title),
text = stringRes(id = R.string.kind_git_repo),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
summary?.let {
Spacer(modifier = HalfDoubleVertSpacer)
Text(
text = it,
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp),
maxLines = 3,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(thickness = DividerThickness)
if (web != null || clone != null) {
Spacer(modifier = HalfDoubleVertSpacer)
Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
web?.let {
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
Text(
text = stringRes(id = R.string.git_web_address),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
ClickableUrl(
LinkRow(
symbol = MaterialSymbols.Public,
contentDescription = stringRes(id = R.string.git_web_address),
url = it,
urlText = it.removePrefix("https://").removePrefix("http://"),
)
}
}
clone?.let {
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
Text(
text = stringRes(id = R.string.git_clone_address),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
ClickableUrl(
LinkRow(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(id = R.string.git_clone_address),
url = it,
urlText = it.removePrefix("https://").removePrefix("http://"),
)
}
}