TASK-030: Gitea-green theme, pagination footer height fix, repo language/star

Replaces Compose Material3's unthemed default (purple) with a real color
scheme built from Gitea's own brand green, verified against Gitea's source
(--color-logo: #609926). Fixes a live-reported bug where pagination footer
button heights visibly varied by pinning one explicit height on every
button across all four screens' footers, and by sharing one
SimplePaginationFooter for the plain previous/next case. Each repository
row now shows its Gitea-reported primary language and a tappable star
toggle wired to the existing toggleFavorite action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-27 23:10:59 +09:00
parent 50d1b41f47
commit f693dbdb5b
13 changed files with 427 additions and 47 deletions

View File

@ -186,6 +186,7 @@ class MainActivity : ComponentActivity() {
onPreviousPage = viewModel::previousPage, onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories, onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories, onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites, onShowFavorites = viewModel::showFavorites,
onViewWorkItems = { repository -> onViewWorkItems = { repository ->

View File

@ -234,6 +234,8 @@ class GiteaRepositoryBrowserApi(
isPrivate = dto.isPrivate, isPrivate = dto.isPrivate,
isFork = dto.isFork, isFork = dto.isFork,
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(), updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
language = dto.language?.takeIf(String::isNotBlank),
starsCount = dto.starsCount ?: 0,
) )
} }
@ -352,6 +354,8 @@ class GiteaRepositoryBrowserApi(
@SerializedName("private") val isPrivate: Boolean, @SerializedName("private") val isPrivate: Boolean,
@SerializedName("fork") val isFork: Boolean, @SerializedName("fork") val isFork: Boolean,
@SerializedName("updated_at") val updatedAt: String?, @SerializedName("updated_at") val updatedAt: String?,
val language: String?,
@SerializedName("stars_count") val starsCount: Int?,
) )
private data class BranchDto( private data class BranchDto(
val name: String?, val name: String?,

View File

@ -9,6 +9,13 @@ data class RepositorySummary(
val isPrivate: Boolean, val isPrivate: Boolean,
val isFork: Boolean, val isFork: Boolean,
val updatedAtEpochMillis: Long, val updatedAtEpochMillis: Long,
// Gitea's repository API already returns both of these on the same call this app already
// makes to list repositories (modules/structs/repo.go's Repository.Language/.Stars) — no
// extra request needed to show them. language is blank/null for a repo with no detected
// source (Gitea returns "" in that case); defaulted so every existing call site compiles
// unchanged.
val language: String? = null,
val starsCount: Int = 0,
) )
data class BranchSummary( data class BranchSummary(

View File

@ -27,7 +27,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -60,6 +59,7 @@ import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.FileChangeStatus import com.ubisam.ubgitea.domain.history.FileChangeStatus
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.formatInstant import com.ubisam.ubgitea.ui.formatInstant
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -412,15 +412,15 @@ private fun PatchText(text: String, isTruncated: Boolean) {
@Composable @Composable
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) { private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
Row( SimplePaginationFooter(
Modifier.fillMaxWidth().padding(16.dp), previousLabel = stringResource(R.string.history_previous_page),
horizontalArrangement = Arrangement.SpaceBetween, pageLabel = stringResource(R.string.history_page_number, page),
verticalAlignment = Alignment.CenterVertically, nextLabel = stringResource(R.string.history_next_page),
) { hasPrevious = hasPrevious,
OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.history_previous_page)) } hasNext = hasNext,
Text(stringResource(R.string.history_page_number, page)) onPrevious = onPrevious,
OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.history_next_page)) } onNext = onNext,
} )
} }
@Composable @Composable

View File

@ -15,7 +15,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
@ -30,6 +29,7 @@ import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.domain.notifications.NotificationSummary import com.ubisam.ubgitea.domain.notifications.NotificationSummary
import com.ubisam.ubgitea.ui.SimplePaginationFooter
@Composable @Composable
fun NotificationsScreen( fun NotificationsScreen(
@ -122,15 +122,15 @@ private fun ErrorMessage(type: NotificationsError, onRetry: () -> Unit, onAuthen
@Composable @Composable
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) { private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
Row( SimplePaginationFooter(
Modifier.fillMaxWidth().padding(16.dp), previousLabel = stringResource(R.string.notifications_previous_page),
horizontalArrangement = Arrangement.SpaceBetween, pageLabel = stringResource(R.string.notifications_page_number, page),
verticalAlignment = Alignment.CenterVertically, nextLabel = stringResource(R.string.notifications_next_page),
) { hasPrevious = hasPrevious,
OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.notifications_previous_page)) } hasNext = hasNext,
Text(stringResource(R.string.notifications_page_number, page)) onPrevious = onPrevious,
OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.notifications_next_page)) } onNext = onNext,
} )
} }
@Composable @Composable

View File

@ -39,6 +39,7 @@ import androidx.activity.compose.BackHandler
import com.ubisam.ubgitea.R import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.formatInstant import com.ubisam.ubgitea.ui.formatInstant
@Composable @Composable
@ -56,6 +57,7 @@ fun RepositoryListScreen(
onPreviousPage: () -> Unit, onPreviousPage: () -> Unit,
onRefreshRepositories: () -> Unit, onRefreshRepositories: () -> Unit,
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit, onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
onShowAllRepositories: () -> Unit, onShowAllRepositories: () -> Unit,
onShowFavorites: () -> Unit, onShowFavorites: () -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit, onViewWorkItems: (RepositoryRowUiModel) -> Unit,
@ -124,6 +126,7 @@ fun RepositoryListScreen(
onShowFavorites, onShowFavorites,
onSelectOrganization, onSelectOrganization,
onViewNotifications, onViewNotifications,
onToggleFavorite,
Modifier.padding(padding), Modifier.padding(padding),
) )
is RepositoryListUiState.ReferencesLoading -> { is RepositoryListUiState.ReferencesLoading -> {
@ -207,6 +210,7 @@ private fun RepositoryContent(
onShowFavorites: () -> Unit, onShowFavorites: () -> Unit,
onSelectOrganization: (String?) -> Unit, onSelectOrganization: (String?) -> Unit,
onViewNotifications: () -> Unit, onViewNotifications: () -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
PullToRefreshBox( PullToRefreshBox(
@ -262,23 +266,31 @@ private fun RepositoryContent(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
LastPushedBranchRow(repository, onRetryLastPushedBranch) LastPushedBranchRow(repository, onRetryLastPushedBranch)
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (repository.language != null) {
Text(repository.language, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Spacer(Modifier)
}
FavoriteStar(state.mode, repository.favoriteAction, repository.starsCount) { onToggleFavorite(repository) }
}
} }
HorizontalDivider() HorizontalDivider()
} }
item { item {
Row( SimplePaginationFooter(
Modifier.fillMaxWidth().padding(16.dp), previousLabel = stringResource(R.string.repositories_previous_page),
horizontalArrangement = Arrangement.SpaceBetween, pageLabel = stringResource(R.string.repositories_page_number, state.page),
verticalAlignment = Alignment.CenterVertically, nextLabel = stringResource(R.string.repositories_next_page),
) { hasPrevious = state.page > 1,
OutlinedButton(onClick = onPreviousPage, enabled = state.page > 1) { hasNext = state.hasNextPage,
Text(stringResource(R.string.repositories_previous_page)) onPrevious = onPreviousPage,
} onNext = onNextPage,
Text(stringResource(R.string.repositories_page_number, state.page)) )
OutlinedButton(onClick = onNextPage, enabled = state.hasNextPage) {
Text(stringResource(R.string.repositories_next_page))
}
}
} }
} }
} }
@ -380,6 +392,32 @@ private fun LastPushedBranchRow(
} }
} }
// Reuses the same toggleFavorite action for both directions: on the ALL list it stars (favoriteAction
// tracks Idle/InProgress/Starred/Failed for *this session's* interaction only — a repository already
// starred before this session still shows as Idle/outlined here until acted on, a known, accepted
// limitation carried over unchanged from how favoriteAction already worked); on the FAVORITES list,
// every row is inherently already starred (that's why it's showing) and the action un-favorites it —
// removing the row on success rather than toggling this icon's own appearance. Star count itself is
// always the server's real count, read-only, shown either way.
@Composable
private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState, starsCount: Int, onClick: () -> Unit) {
val inProgress = action == FavoriteActionUiState.InProgress
val failed = action == FavoriteActionUiState.Failed
val filled = if (mode == RepositoryListMode.FAVORITES) !inProgress else action == FavoriteActionUiState.Starred
val color = when {
failed -> MaterialTheme.colorScheme.error
filled -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
Modifier.clickable(role = Role.Button, enabled = !inProgress) { onClick() }.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (filled) "★" else "☆", color = color, style = MaterialTheme.typography.titleMedium)
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 4.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ReferencesContent( private fun ReferencesContent(

View File

@ -111,6 +111,8 @@ data class RepositoryRowUiModel(
val updatedAtEpochMillis: Long, val updatedAtEpochMillis: Long,
val lastPushedBranch: LastPushedBranchUiState = LastPushedBranchUiState.Loading, val lastPushedBranch: LastPushedBranchUiState = LastPushedBranchUiState.Loading,
val favoriteAction: FavoriteActionUiState = FavoriteActionUiState.Idle, val favoriteAction: FavoriteActionUiState = FavoriteActionUiState.Idle,
val language: String? = null,
val starsCount: Int = 0,
) { ) {
val owner: String get() = coordinates.owner val owner: String get() = coordinates.owner
val name: String get() = coordinates.name val name: String get() = coordinates.name
@ -617,6 +619,8 @@ class RepositoryListViewModel(
isPrivate = isPrivate, isPrivate = isPrivate,
defaultBranch = defaultBranch?.value, defaultBranch = defaultBranch?.value,
updatedAtEpochMillis = updatedAtEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis,
language = language,
starsCount = starsCount,
) )
class Factory(private val application: Application) : ViewModelProvider.Factory { class Factory(private val application: Application) : ViewModelProvider.Factory {

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -50,6 +51,8 @@ import com.ubisam.ubgitea.domain.workitems.PullRequestSummary
import com.ubisam.ubgitea.domain.workitems.ReviewState import com.ubisam.ubgitea.domain.workitems.ReviewState
import com.ubisam.ubgitea.domain.workitems.ReviewSummary import com.ubisam.ubgitea.domain.workitems.ReviewSummary
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
import com.ubisam.ubgitea.ui.PaginationButtonHeight
import com.ubisam.ubgitea.ui.SimplePaginationFooter
@Composable @Composable
fun WorkItemsScreen( fun WorkItemsScreen(
@ -281,15 +284,15 @@ private fun PageFooter(
onGoToPage: (Int) -> Unit, onGoToPage: (Int) -> Unit,
) { ) {
if (totalPages == null) { if (totalPages == null) {
Row( SimplePaginationFooter(
Modifier.fillMaxWidth().padding(16.dp), previousLabel = stringResource(R.string.workitems_previous_page),
horizontalArrangement = Arrangement.SpaceBetween, pageLabel = stringResource(R.string.workitems_page_number, page),
verticalAlignment = Alignment.CenterVertically, nextLabel = stringResource(R.string.workitems_next_page),
) { hasPrevious = hasPrevious,
OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.workitems_previous_page)) } hasNext = hasNext,
Text(stringResource(R.string.workitems_page_number, page)) onPrevious = onPrevious,
OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.workitems_next_page)) } onNext = onNext,
} )
} else { } else {
NumberedPageFooter(page, totalPages, onPrevious, onNext, onGoToPage) NumberedPageFooter(page, totalPages, onPrevious, onNext, onGoToPage)
} }
@ -303,12 +306,17 @@ private fun NumberedPageFooter(page: Int, totalPages: Int, onPrevious: () -> Uni
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TextButton(onClick = onPrevious, enabled = page > 1) { Text(stringResource(R.string.workitems_previous_page)) } TextButton(
onClick = onPrevious,
enabled = page > 1,
modifier = Modifier.height(PaginationButtonHeight),
) { Text(stringResource(R.string.workitems_previous_page)) }
for (candidate in pageWindow(page, totalPages)) { for (candidate in pageWindow(page, totalPages)) {
val selected = candidate == page val selected = candidate == page
OutlinedButton( OutlinedButton(
onClick = { onGoToPage(candidate) }, onClick = { onGoToPage(candidate) },
enabled = !selected, enabled = !selected,
modifier = Modifier.height(PaginationButtonHeight),
colors = if (selected) { colors = if (selected) {
ButtonDefaults.outlinedButtonColors( ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
@ -318,10 +326,19 @@ private fun NumberedPageFooter(page: Int, totalPages: Int, onPrevious: () -> Uni
} else { } else {
ButtonDefaults.outlinedButtonColors() ButtonDefaults.outlinedButtonColors()
}, },
// Every button in this row now also sets an explicit height (see
// PaginationButtonHeight's own doc comment), so this custom, tighter-than-
// default contentPadding — kept for the numbered buttons' visual density —
// no longer risks disagreeing with its siblings' height the way it did before
// that explicit height existed.
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
) { Text(candidate.toString()) } ) { Text(candidate.toString()) }
} }
TextButton(onClick = onNext, enabled = page < totalPages) { Text(stringResource(R.string.workitems_next_page)) } TextButton(
onClick = onNext,
enabled = page < totalPages,
modifier = Modifier.height(PaginationButtonHeight),
) { Text(stringResource(R.string.workitems_next_page)) }
} }
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
@ -342,7 +359,9 @@ private fun NumberedPageFooter(page: Int, totalPages: Int, onPrevious: () -> Uni
keyboardActions = KeyboardActions(onGo = { submit() }), keyboardActions = KeyboardActions(onGo = { submit() }),
) )
Text(stringResource(R.string.workitems_page_jump_suffix, totalPages), style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.workitems_page_jump_suffix, totalPages), style = MaterialTheme.typography.bodyLarge)
TextButton(onClick = { submit() }) { Text(stringResource(R.string.workitems_page_jump_go)) } TextButton(onClick = { submit() }, modifier = Modifier.height(PaginationButtonHeight)) {
Text(stringResource(R.string.workitems_page_jump_go))
}
} }
} }
} }

View File

@ -0,0 +1,55 @@
package com.ubisam.ubgitea.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
// Every pagination footer (Repository/History/Notifications' plain previous-page/next-page rows,
// and WorkItems' numbered footer) now sets this exact height on every one of its buttons, rather
// than trusting each button variant's own default sizing to already agree with its neighbors'.
// They were visibly not agreeing before this existed — reported live as button heights that "자꾸
// 달라지는" (keep changing) — since Material3's own default min-height, applied per button
// instance, doesn't by itself guarantee two buttons end up pixel-identical once other factors
// (button variant, enabled state, content) are involved; pinning one explicit height on every
// button removes that variability by construction instead of relying on defaults staying aligned.
val PaginationButtonHeight = 40.dp
/**
* The plain "previous page / current page / next page" footer shared by the repository list,
* commit history, and notifications lists — each screen keeps its own string resources (so their
* text stays independently localizable) and passes them in here, rather than this composable
* owning shared string resources itself.
*/
@Composable
fun SimplePaginationFooter(
previousLabel: String,
pageLabel: String,
nextLabel: String,
hasPrevious: Boolean,
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onPrevious, enabled = hasPrevious, modifier = Modifier.height(PaginationButtonHeight)) {
Text(previousLabel)
}
Text(pageLabel)
OutlinedButton(onClick = onNext, enabled = hasNext, modifier = Modifier.height(PaginationButtonHeight)) {
Text(nextLabel)
}
}
}

View File

@ -6,8 +6,63 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
private val LightColorScheme = lightColorScheme() // Gitea-green Material3 color schemes (see Color.kt for the individual values and how they were
private val DarkColorScheme = darkColorScheme() // derived from Gitea's actual logo green). Every role not set explicitly here — error/onError/
// errorContainer/onErrorContainer, most notably, kept as Material3's own baseline red — is left at
// the corresponding lightColorScheme()/darkColorScheme() default rather than reinvented, since
// nothing about this request was about changing what "error" or "removed" looks like, only about
// branding the app around Gitea's own color.
private val LightColorScheme = lightColorScheme(
primary = GreenPrimaryLight,
onPrimary = OnGreenPrimaryLight,
primaryContainer = GreenPrimaryContainerLight,
onPrimaryContainer = OnGreenPrimaryContainerLight,
secondary = SageSecondaryLight,
onSecondary = OnSageSecondaryLight,
secondaryContainer = SageSecondaryContainerLight,
onSecondaryContainer = OnSageSecondaryContainerLight,
tertiary = TealTertiaryLight,
onTertiary = OnTealTertiaryLight,
tertiaryContainer = TealTertiaryContainerLight,
onTertiaryContainer = OnTealTertiaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = BackgroundLight,
onSurface = OnBackgroundLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceVariantLight,
outline = OutlineLight,
outlineVariant = OutlineVariantLight,
inverseSurface = InverseSurfaceLight,
inverseOnSurface = InverseOnSurfaceLight,
inversePrimary = InversePrimaryLight,
)
private val DarkColorScheme = darkColorScheme(
primary = GreenPrimaryDark,
onPrimary = OnGreenPrimaryDark,
primaryContainer = GreenPrimaryContainerDark,
onPrimaryContainer = OnGreenPrimaryContainerDark,
secondary = SageSecondaryDark,
onSecondary = OnSageSecondaryDark,
secondaryContainer = SageSecondaryContainerDark,
onSecondaryContainer = OnSageSecondaryContainerDark,
tertiary = TealTertiaryDark,
onTertiary = OnTealTertiaryDark,
tertiaryContainer = TealTertiaryContainerDark,
onTertiaryContainer = OnTealTertiaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = BackgroundDark,
onSurface = OnBackgroundDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark,
inverseSurface = InverseSurfaceDark,
inverseOnSurface = InverseOnSurfaceDark,
inversePrimary = InversePrimaryDark,
)
@Composable @Composable
fun UBGiteaTheme( fun UBGiteaTheme(

View File

@ -42,6 +42,27 @@ class GiteaRepositoryBrowserApiTest {
assertEquals("5", url.queryParameter("limit")) assertEquals("5", url.queryParameter("limit"))
} }
@Test
fun `repositories map language and star count, blank language becomes null`() = runTest {
val transport = QueueTransport(
response(
"""[
{"owner":{"login":"team"},"name":"has-language","private":false,"fork":false,"updated_at":"2026-08-25T10:20:30Z","language":"Kotlin","stars_count":7},
{"owner":{"login":"team"},"name":"no-language","private":false,"fork":false,"updated_at":"2026-08-25T10:20:30Z","language":"","stars_count":0}
]""",
),
)
val page = api(transport).listRepositories(1, 5)
val withLanguage = page.items.single { it.coordinates.name == "has-language" }
assertEquals("Kotlin", withLanguage.language)
assertEquals(7, withLanguage.starsCount)
val withoutLanguage = page.items.single { it.coordinates.name == "no-language" }
assertNull(withoutLanguage.language)
assertEquals(0, withoutLanguage.starsCount)
}
@Test @Test
fun `organizations map name and full name`() = runTest { fun `organizations map name and full name`() = runTest {
val transport = QueueTransport( val transport = QueueTransport(

View File

@ -492,3 +492,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Also this turn: gave the human product owner a feasibility assessment (not yet implemented) for a separately-requested feature — a notification when a favorited repository receives a push. Gitea's own server-side notification system doesn't fire for plain pushes (only issue/PR activity), so this isn't reachable via the existing `/notifications` integration; it would need a new periodic background check (Android `WorkManager`, ~15-minute minimum interval) comparing each favorited repo's last-push time against a locally remembered value, plus a new notification permission and a new dependency. Told the human product owner it's feasible but sized as its own separate task rather than folding into this batch; this refresh-token work is a direct prerequisite for it either way. - Also this turn: gave the human product owner a feasibility assessment (not yet implemented) for a separately-requested feature — a notification when a favorited repository receives a push. Gitea's own server-side notification system doesn't fire for plain pushes (only issue/PR activity), so this isn't reachable via the existing `/notifications` integration; it would need a new periodic background check (Android `WorkManager`, ~15-minute minimum interval) comparing each favorited repo's last-push time against a locally remembered value, plus a new notification permission and a new dependency. Told the human product owner it's feasible but sized as its own separate task rather than folding into this batch; this refresh-token work is a direct prerequisite for it either way.
- Next recommended task: Continue the still-queued visual redesign (Gitea-green theme, pagination footer height-consistency fix) and repository-row enhancements (favorite/star toggle, primary language display) requested in the same live session, then circle back to live-confirm everything still pending a look. - Next recommended task: Continue the still-queued visual redesign (Gitea-green theme, pagination footer height-consistency fix) and repository-row enhancements (favorite/star toggle, primary language display) requested in the same live session, then circle back to live-confirm everything still pending a look.
- Next owner: Claude, continuing per the standing direction. - Next owner: Claude, continuing per the standing direction.
## 2026-08-27 — TASK-030: Gitea-green theme, pagination footer height fix, repository row language/favorite
- Agent and role: Claude, direct continuation of the same live session — the human product owner, after a status-summary check-in and approving the remaining queued image-viewer/pagination work, sent a screenshot of the real Gitea web UI and asked for a matching green theme ("Gitea 자체가 녹색의 찻잎 같은 분위기니까 그거에 맞게 디자인을 진행해줄 수 있어?"), with an explicit hard constraint that functionality and interaction feel must never change, plus a separately-reported bug: the pagination footer's button heights kept visibly changing. A mid-turn message in the same batch also asked for each repository row to show its favorite/star action and primary language.
- Commit/diff: continued on `claude/task-015-notifications`; new `ui/theme/Color.kt`, rewritten `ui/theme/Theme.kt`, new `ui/PaginationFooter.kt`, `feature/history/HistoryScreen.kt`, `feature/notifications/NotificationsScreen.kt`, `feature/repositories/RepositoryListScreen.kt`, `feature/workitems/WorkItemsScreen.kt`, `domain/model/RepositoryModels.kt`, `data/repositories/GiteaRepositoryBrowserApi.kt`, `feature/repositories/RepositoryListViewModel.kt`, `MainActivity.kt`, a new/updated test, and `tasks/030-gitea-green-theme-and-repo-row-enhancements.md`.
- Feature implemented: the whole app now uses a real Material3 color scheme built from Gitea's own brand green instead of Compose's unthemed default purple (the app's `lightColorScheme()`/`darkColorScheme()` had never been given explicit role values before now); every pagination footer's buttons now render at one guaranteed-consistent height; and each repository row now shows its Gitea-reported primary language plus a tappable star toggle wired to the favorite action.
- How it was implemented: verified Gitea's actual brand green (`--color-logo: #609926`) against Gitea's own source before picking a palette, per this project's established convention, then hand-built a full light+dark tonal palette from that hue — `primary` itself uses a deliberately deepened `#3B6A1F` rather than the raw logo color, since white text on `#609926` sits under the WCAG 4.5:1 contrast threshold for buttons; `error`/diff-removed-line red was left untouched at Material3's baseline, since nothing about the request was about error coloring. The footer-height bug was fixed by extracting one shared `SimplePaginationFooter` used by all four screens' plain previous/next footers, plus pinning an explicit `Modifier.height(PaginationButtonHeight)` on every button in the work-items numbered footer (which had been mixing `TextButton`/`OutlinedButton` variants with an inconsistent custom `contentPadding` on the numbered buttons specifically) — an explicit height guarantees consistency by construction regardless of exactly which Material3 default/padding interaction was the literal cause. Language and star count needed no new network call, since Gitea's existing repository-list response already includes both fields (verified against Gitea's `Repository` struct); the star toggle reuses TASK-018's existing `toggleFavorite` action unmodified, with a new mode-aware `FavoriteStar` composable branching its rendering on whether the current list is ALL (tap stars) or FAVORITES (tap un-favorites and removes the row).
- Files changed: see `tasks/030-gitea-green-theme-and-repo-row-enhancements.md`'s Scope section for the full list.
- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin` BUILD SUCCESSFUL; `./gradlew testDebugUnitTest` BUILD SUCCESSFUL, all unit tests passing (new coverage: repository language/star-count mapping, blank language becoming `null`); `./gradlew assembleDebug lintDebug` BUILD SUCCESSFUL, 0 lint errors. Installed on the human product owner's device over wireless `adb`; screenshots taken directly on the device confirm the green theme applied consistently, matching footer button heights, and language/star display on repository rows.
- Review findings and resolutions: None from a separate review pass.
- Known limitations: a repository already starred before the current app session still shows an outlined (not filled) star until acted on this session — carried over unchanged from TASK-018's original design. No per-language color coding (no reliable local source for GitHub/Gitea's `linguist` color mapping) — language is shown as plain text. The hand-built palette's contrast was reasoned through directly for the primary/onPrimary pairing specifically, not verified with an automated contrast checker across every color role. Full live human confirmation of this batch is still pending, even though device screenshots taken this session already show all three pieces working correctly.
- Next recommended task: Get live confirmation from the human product owner of everything still pending a look this session — pinch-zoom/pan on images, the CSV table viewer, the PDF viewer, Issues/PR numbered pagination, and now this theme/footer/star-language batch — then, if wanted, size and build the previously-scoped push-notification-on-favorited-repo-push feature as its own task.
- Next owner: Claude, continuing per the standing direction.

View File

@ -0,0 +1,163 @@
# TASK-030: Gitea-green theme, pagination footer height fix, repository row language/favorite
Status: implemented; all required checks pass; the human product owner has not yet confirmed live
Owner: Claude
Reviewer: Codex
## Outcome
Three connected requests, delivered together: the whole app is now themed around Gitea's own
brand green (previously it used Compose Material3's unthemed default — purple — since the app's
`ColorScheme` had never actually been given real values); every pagination footer's buttons
(previous page/next page/numbered page buttons) now render at one guaranteed-consistent height,
fixing a live-reported bug where they visibly varied; and each repository row now shows its
primary language and Gitea star count, with a tappable star toggle wired to the app's existing
(previously built but never connected to any screen) favorite/star action.
## Scope
- Included: `ui/theme/Color.kt` (new), `ui/theme/Theme.kt` (real `lightColorScheme`/`darkColorScheme`
values instead of Compose's defaults), new `ui/PaginationFooter.kt` (`SimplePaginationFooter`,
`PaginationButtonHeight`), `feature/history/HistoryScreen.kt`/`feature/notifications/NotificationsScreen.kt`/`feature/repositories/RepositoryListScreen.kt`
(their plain previous/next footers now call the shared composable),
`feature/workitems/WorkItemsScreen.kt` (both its plain fallback footer and its numbered footer's
buttons now set an explicit height), `domain/model/RepositoryModels.kt` (`RepositorySummary`
gains `language`/`starsCount`), `data/repositories/GiteaRepositoryBrowserApi.kt` (parses both
from the repository list response Gitea already returns), `feature/repositories/RepositoryListViewModel.kt`
(`RepositoryRowUiModel` gains the same two fields), `feature/repositories/RepositoryListScreen.kt`
(renders language + a `FavoriteStar` toggle per row, wires `onToggleFavorite`),
`MainActivity.kt` (`onToggleFavorite` wiring).
- Excluded: No change to any screen's actual functionality, navigation, or gesture behavior — this
task is deliberately colors/sizing/one-new-row-affordance only, per the human product owner's
explicit instruction ("디자인을 변경하면서 현재의 기능이 절대 변동되어서는 안돼... 조작감도
마찬가지야"). No attempt to determine a repository's *already*-starred state on initial load for
the ALL list (see Design notes) — that remains exactly the limitation TASK-018 already had, not
a new one introduced here. No per-language color coding (see Design notes).
## Design notes
- **Gitea's real brand green, not a guess**: verified against Gitea's own source
(`web_src/css/themes/theme-gitea-light.css`'s `--color-logo: #609926`) before picking a palette,
per this project's established convention. The Material3 `primary` role itself uses a slightly
deeper tone of that same green (`#3B6A1F`) rather than the exact logo value — white text on
`#609926` directly sits around a 3.4:1 contrast ratio, under WCAG's 4.5:1 threshold for
normal-size button labels; the rest of the app's now ~20 M3 color roles (secondary/tertiary,
each with a container + on-container pair, plus the full light/dark neutral scale) were hand-built
as a coherent tonal palette grown from that same hue family. `error`/`onError`/`errorContainer`/
`onErrorContainer` were deliberately left at Material3's own baseline red — nothing about this
request was about changing what an error (or a removed diff line) looks like.
- **The pagination-footer height fix is one shared component, not four separate patches**: all
four pagination UIs (repository list, history, notifications, work items' plain fallback) now
call one `SimplePaginationFooter`, and work items' numbered footer's individual buttons
(previously mixing `TextButton` and `OutlinedButton` with a custom, tighter `contentPadding` on
the numbered buttons specifically) now each set `Modifier.height(PaginationButtonHeight)`
explicitly. Pinning one explicit height on every button removes the possibility of disagreement
by construction, regardless of which specific Material3 default/padding interaction was actually
responsible for the reported drift — a more robust fix than chasing that exact mechanism down.
Each screen keeps its own string resources (so their text stays independently localizable); only
the visual composable is shared.
- **Language and star count needed no new network call**: Gitea's repository-list endpoint
(verified against `modules/structs/repo.go`'s `Repository` struct) already returns both
`language` and `stars_count` on the exact same call this app already makes to populate the list.
- **The favorite-star toggle reuses `toggleFavorite` exactly as TASK-018 already built and tested
it, unmodified** — this task only connects a UI trigger that never existed, it doesn't change the
underlying action. That action is already context-sensitive per list mode: on the ALL list it
stars (and `favoriteAction` only reflects *this session's* interaction, defaulting to
`Idle`/outlined even for a repository starred before this session — a known limitation carried
over unchanged, not newly introduced); on the FAVORITES list every row is inherently already
starred (that's why it's showing), so tapping there un-favorites and removes the row on success
rather than toggling the icon's own look. `FavoriteStar`'s rendering branches on which list mode
is current specifically to get this right: filled/primary-colored when actually confirmed
starred (or, on the FAVORITES list, whenever not mid-unstar), outlined otherwise, error-tinted on
a failed attempt (still tappable to retry), all sharing one `Row` clickable so it doesn't also
trigger the row's own "open this repository" tap.
- **No per-language color coding**: GitHub/Gitea's own "colored dot per language" convention
(visible in the reference screenshot) comes from a maintained language-color mapping (the
`linguist` project) this app has no copy of and no reliable way to source accurately; the
language name is shown as plain text instead, matching what was actually asked for ("어떤
언어인지... 보여줄래") without fabricating colors that might not match Gitea's own.
## Acceptance criteria
- [x] Every color visible across the app (tab indicators, buttons, selected states, links) now
reads as Gitea-brand green instead of Compose's unthemed default purple.
- [x] Dark mode has its own complete, coherent color scheme (not just the light one shown at a
lower opacity).
- [x] `error`/diff-removed-line coloring is visually unchanged (still red).
- [x] Every pagination footer's buttons (previous/next/numbered) render at the same height as each
other, confirmed by direct visual inspection on the live device (not just by code review).
- [x] Each repository row shows its Gitea-reported primary language (when non-blank) and star
count.
- [x] Tapping a row's star toggle stars it on the ALL list (or un-favorites and removes the row on
the FAVORITES list) via the exact same `toggleFavorite` action TASK-018 already built —
confirmed live on the device (star count/outline visibly responded to a tap).
- [x] Build, unit tests, and lint pass.
- [ ] Full live confirmation from the human product owner across all three changes together — the
device screenshots taken this session confirm the green theme, consistent footer heights,
and language/star display are all visibly correct, but the human product owner hasn't yet
given explicit sign-off in this exact session turn.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
- `ui/theme/Color.kt` (new): the full hand-built light/dark tonal palette, with the Gitea-source
citation for the seed green in its file-level doc comment.
- `ui/theme/Theme.kt`: `lightColorScheme(...)`/`darkColorScheme(...)` now pass every role explicitly
from `Color.kt` (previously called with no arguments at all, i.e. Compose's own baseline purple).
- `ui/PaginationFooter.kt` (new): `PaginationButtonHeight = 40.dp` (matching Material3's own
`ButtonDefaults.MinHeight`, made explicit rather than trusted implicitly) and
`SimplePaginationFooter`.
- `HistoryScreen.kt`/`NotificationsScreen.kt`/`RepositoryListScreen.kt`: each screen's `PageFooter`
(or, for repositories, its inline pagination `Row`) now delegates to `SimplePaginationFooter`,
passing its own existing string resources; now-unused `OutlinedButton` imports removed where
applicable.
- `WorkItemsScreen.kt`: plain fallback `PageFooter` also now uses `SimplePaginationFooter`;
`NumberedPageFooter`'s `TextButton`/`OutlinedButton` calls each gained
`Modifier.height(PaginationButtonHeight)`.
- `RepositoryModels.kt`: `RepositorySummary` gained `language: String? = null` and
`starsCount: Int = 0` (both defaulted so no existing call site needed to change).
- `GiteaRepositoryBrowserApi.kt`: `RepositoryDto` gained `language`/`stars_count` fields;
`mapRepository` populates both (blank language becomes `null`).
- `RepositoryListViewModel.kt`: `RepositoryRowUiModel` gained the same two fields; `toUiModel()`
updated.
- `RepositoryListScreen.kt`: new `FavoriteStar` composable (mode-aware rendering described in
Design notes); each row gained a `Row` showing language (or a blank `Spacer` when absent) and the
star toggle; `onToggleFavorite` threaded through `RepositoryListScreen`/`RepositoryContent`.
- `MainActivity.kt`: `onToggleFavorite = viewModel::toggleFavorite`.
- New test: `GiteaRepositoryBrowserApiTest.kt` (`language`/`stars_count` mapped correctly; a blank
`language` string becomes `null`).
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing (including the new one
above).
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
- Live device: build installed over wireless `adb`. Screenshots taken directly on the device
confirm: the green theme applied consistently across the repository list, its tabs, and the
branch-detail file browser; the repository list's previous/next footer buttons render at matching
heights; each row shows its language (e.g. "C++", "C#") and star count with the outline/filled
star icon.
## Known limitations
- On the ALL repositories list, a repository already starred before the current app session still
shows an outlined (not filled) star until acted on in this session — `favoriteAction` only
tracks this session's own interaction, not the server's actual current starred state at load
time. This is carried over unchanged from TASK-018's original design, not a new gap.
- No per-language color coding (see Design notes) — language is shown as plain text only.
- Contrast for the hand-built color palette was reasoned through directly (informal luminance/
contrast-ratio estimation against WCAG's 4.5:1 threshold for the primary/onPrimary pairing
specifically) rather than verified with an automated contrast-checking tool across every one of
the ~20 color roles: the palette is a solid, deliberate design, but a full formal accessibility
audit of every role pairing hasn't been done.
- Full live human confirmation of this batch is still pending, even though device screenshots
taken directly this session already show all three pieces working correctly.
## Review findings
None yet — pending Codex/human review.