TASK-075: chip border, quiet short loads, exit question, back to top

A selected filter chip is outlined; the loading cup waits 450 ms before it
shows; Back on 홈 asks before leaving; long lists offer a button back to the
top; AnimatedContent no longer animates its own size, which made sideways
moves drift diagonally.

Agent: main (opus), implementer (sonnet) for Chips.kt and ExitConfirmDialog.kt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 16:29:41 +09:00
parent bb546421fc
commit a140b56aec
17 changed files with 396 additions and 33 deletions

View File

@ -91,6 +91,8 @@ import com.ubisam.ubgitea.feature.work.WorkViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
import com.ubisam.ubgitea.ui.components.NoResize
import com.ubisam.ubgitea.ui.components.SplashOverlay
import com.ubisam.ubgitea.ui.components.TabPager
import com.ubisam.ubgitea.ui.navigation.Route
@ -277,6 +279,7 @@ class MainActivity : ComponentActivity() {
pop()
}
var confirmExit by remember { mutableStateOf(false) }
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val showNavigation = sessionReady && when (currentRoute) {
@ -394,9 +397,10 @@ class MainActivity : ComponentActivity() {
null
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home leaves the app.
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
selectTab(AppTab.HOME)
// Back on any tab but Home goes Home; back on Home asks before leaving the
// app, since it is easily pressed once too often.
BackHandler(enabled = sessionReady && currentRoute.isTab) {
if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME)
}
val repositoryList: @Composable () -> Unit = {
RepositoryListScreen(
@ -453,11 +457,12 @@ class MainActivity : ComponentActivity() {
// The session has just been found: the first screen takes the
// waiting one's place where it stands. Nothing was navigated, so
// nothing slides.
fadeIn(tween(200)).togetherWith(fadeOut(tween(90)))
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
} else {
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
.togetherWith(fadeOut(tween(90)))
.using(NoResize)
}
},
contentKey = { if (it.isTab) AppTab::class else it::class },
@ -691,6 +696,9 @@ class MainActivity : ComponentActivity() {
},
)
}
if (confirmExit) {
ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() })
}
SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null)
}
}

View File

@ -25,19 +25,19 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
@ -86,6 +86,7 @@ import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.UBIcon
import java.io.File
import java.io.IOException
@ -127,7 +128,7 @@ fun CodeBrowserScreen(
if (state.entries.isEmpty()) {
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
} else {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
}

View File

@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -51,6 +50,7 @@ import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.EmptyCup
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.formatInstant
@ -89,7 +89,7 @@ fun HistoryScreen(
if (state.commits.isEmpty()) {
CenteredMessage(stringResource(R.string.history_empty), decoration = { EmptyCup() })
} else {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
}
@ -177,7 +177,7 @@ private fun CommitDetailView(
onFileSelected: (FileChange) -> Unit,
) {
val canOpenFileDiff = state.patch is PatchUiState.Loaded
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "commit") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@ -28,7 +28,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -63,10 +62,12 @@ import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.BrandTopBar
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.FilterChip
import com.ubisam.ubgitea.ui.components.KindTile
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.ListSkeleton
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabTitle
@ -159,7 +160,7 @@ private fun HomeContent(
LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
}
LazyColumn(
ScrollTopLazyColumn(
Modifier.weight(0.5f).fillMaxHeight(),
state = timelineState,
// Starts level with the headline card on the left.
@ -169,7 +170,7 @@ private fun HomeContent(
}
}
} else {
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
}

View File

@ -13,11 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -43,8 +41,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.BrandTopBar
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.EmptyCup
import com.ubisam.ubgitea.ui.components.FilterChip
import com.ubisam.ubgitea.ui.components.KindTile
import com.ubisam.ubgitea.ui.components.ListSkeleton
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@ -120,7 +120,7 @@ private fun NotificationsList(
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
}

View File

@ -17,14 +17,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -63,9 +61,11 @@ import com.ubisam.ubgitea.ui.components.CautionBanner
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.EmptyCup
import com.ubisam.ubgitea.ui.components.FilterChip
import com.ubisam.ubgitea.ui.components.ListSkeleton
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabGlide
@ -267,7 +267,7 @@ private fun RepositoryContent(
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
}
TeaPullToRefreshBox(onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
LazyColumn(
ScrollTopLazyColumn(
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
) {
@ -533,7 +533,7 @@ private fun ReferencesContent(
},
)
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
if (state.branches.isEmpty()) {
item(key = "branches-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_branches_empty)) }

View File

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -37,6 +36,7 @@ import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.core.update.AppUpdateUiState
import com.ubisam.ubgitea.core.update.UpdateCheck
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.FilterChip
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle

View File

@ -11,11 +11,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -43,8 +41,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.BrandTopBar
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.EmptyCup
import com.ubisam.ubgitea.ui.components.FilterChip
import com.ubisam.ubgitea.ui.components.KindTile
import com.ubisam.ubgitea.ui.components.ListSkeleton
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
import com.ubisam.ubgitea.ui.components.UBIcon
@ -147,7 +147,7 @@ private fun WorkList(
onRefresh: () -> Unit,
) {
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
items(
count = state.items.size,
key = { index -> state.items[index].let { "${it.repository.owner}/${it.repository.name}#${it.number}" } },

View File

@ -13,7 +13,6 @@ 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.foundation.rememberScrollState
@ -68,6 +67,7 @@ import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.ListSkeleton
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabGlide
@ -183,7 +183,7 @@ private fun ColumnScope.ListBody(
ListSkeleton()
}
is WorkItemsUiState.IssueList -> {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
WorkItemRow(
icon = R.drawable.ic_ub_issue,
@ -203,7 +203,7 @@ private fun ColumnScope.ListBody(
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.PullRequestList -> {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
WorkItemRow(
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
@ -341,7 +341,7 @@ private fun WorkItemRow(
@Composable
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "header") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
@ -395,7 +395,7 @@ private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
@Composable
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "header") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {

View File

@ -0,0 +1,39 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.material3.FilterChip as MaterialFilterChip
/**
* The app's filter chip. A selected chip is outlined in the brand colour so the choice is visible
* on a white surface.
*/
@Composable
fun FilterChip(
selected: Boolean,
onClick: () -> Unit,
label: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
) {
MaterialFilterChip(
selected = selected,
onClick = onClick,
label = label,
modifier = modifier,
enabled = enabled,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
border = FilterChipDefaults.filterChipBorder(
enabled = enabled,
selected = selected,
selectedBorderColor = MaterialTheme.colorScheme.primary,
selectedBorderWidth = 1.5.dp,
),
)
}

View File

@ -178,8 +178,13 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
TeaCupLoader()
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
// Most loads finish before this would be noticed; the cup is for the ones that do not.
AfterShortWait {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
TeaCupLoader()
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
}
}
}
}

View File

@ -0,0 +1,120 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
/**
* "Leave the app?" confirmation shown when Back is pressed on the Home tab. Staying is the default:
* Back, a tap outside and the emphasised "계속 사용" button all call [onStay]; only "종료" calls [onExit].
*/
@Composable
fun ExitConfirmDialog(onStay: () -> Unit, onExit: () -> Unit) {
// Entrance: the card grows from 0.92 to 1 while fading in.
val progress = remember { Animatable(0f) }
LaunchedEffect(Unit) { progress.animateTo(1f, tween180) }
Dialog(onDismissRequest = onStay) {
Surface(
modifier = Modifier
.widthIn(max = 340.dp)
.graphicsLayer {
val scale = 0.92f + 0.08f * progress.value
scaleX = scale
scaleY = scale
}
.alpha(progress.value),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.background(UBGiteaTheme.colors.brand.copy(alpha = 0.12f), CircleShape),
contentAlignment = Alignment.Center,
) {
TeaCupLoader(size = 40.dp)
}
Text(
text = stringResource(R.string.shell_exit_title),
modifier = Modifier.padding(top = 20.dp),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.shell_exit_body),
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onExit,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.shell_exit_confirm))
}
Button(
onClick = onStay,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.shell_exit_stay))
}
}
}
}
}
}
private val tween180 = androidx.compose.animation.core.tween<Float>(durationMillis = 180)

View File

@ -1,6 +1,7 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
@ -9,6 +10,7 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
@ -124,6 +126,22 @@ private fun rememberSteam(): () -> Float {
}
/** The tea cup with rising steam: the app's "working on it" mark. */
/**
* Holds [content] back for [LoaderDelayMillis] and then fades it in. A wait shorter than that
* shows nothing, so a quick load goes straight to what was asked for.
*/
@Composable
fun AfterShortWait(content: @Composable () -> Unit) {
val shown = remember { Animatable(0f) }
LaunchedEffect(Unit) {
delay(LoaderDelayMillis)
shown.animateTo(1f, tween(180))
}
Box(Modifier.graphicsLayer { alpha = shown.value }) { content() }
}
const val LoaderDelayMillis = 450L
@Composable
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
val steam = rememberSteam()
@ -309,6 +327,7 @@ fun <S> TabGlide(
val glide = tween<IntOffset>(TabGlideMillis, easing = FastOutSlowInEasing)
slideInHorizontally(glide) { it * direction }
.togetherWith(slideOutHorizontally(glide) { -it * direction })
.using(NoResize)
},
contentKey = tabIndex,
label = "tab",
@ -317,6 +336,13 @@ fun <S> TabGlide(
const val TabGlideMillis = 320
/**
* For content that changes inside a fixed area. Left to itself, AnimatedContent also animates its
* own size from the top left corner whenever the two sides measure differently for a frame, which
* turns a sideways move into a diagonal one.
*/
val NoResize: SizeTransform = SizeTransform(clip = true) { _, _ -> snap() }
/**
* The bottom tabs' screens, laid side by side in the order of [tabs]. Changing [current] glides
* to it past whatever lies between, a little longer for each tab passed. Only a tab press moves

View File

@ -0,0 +1,84 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import kotlinx.coroutines.launch
/**
* A list that, once it has been scrolled a long way down, offers a small round button in its
* bottom corner that takes it back to the top. Takes what a LazyColumn takes.
*/
@Composable
fun ScrollTopLazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
content: LazyListScope.() -> Unit,
) {
Box(modifier) {
LazyColumn(Modifier.fillMaxSize(), state, contentPadding, verticalArrangement = verticalArrangement, content = content)
ScrollToTopButton(state, Modifier.align(Alignment.BottomEnd).padding(16.dp))
}
}
@Composable
private fun ScrollToTopButton(state: LazyListState, modifier: Modifier = Modifier) {
val far by remember(state) { derivedStateOf { state.firstVisibleItemIndex >= ScrollTopAfterRows } }
val shown by animateFloatAsState(if (far) 1f else 0f, tween(160), label = "top")
// Not composed at the top of a list, so it takes no taps and is not read out there.
if (shown == 0f) return
val scope = rememberCoroutineScope()
Surface(
onClick = {
scope.launch {
// From very far down, start near the top: the glide stays short and still reads as one.
if (state.firstVisibleItemIndex > ScrollTopGlideRows) state.scrollToItem(ScrollTopGlideRows)
state.animateScrollToItem(0)
}
},
enabled = far,
shape = CircleShape,
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shadowElevation = 3.dp,
modifier = modifier.size(40.dp).graphicsLayer {
alpha = shown
scaleX = 0.8f + 0.2f * shown
scaleY = 0.8f + 0.2f * shown
},
) {
Box(contentAlignment = Alignment.Center) {
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.shell_scroll_to_top), size = 22.dp)
}
}
}
private const val ScrollTopAfterRows = 8
private const val ScrollTopGlideRows = 12

View File

@ -12,4 +12,9 @@
<string name="time_hours_ago">%1$d시간 전</string>
<string name="time_yesterday">어제</string>
<string name="time_days_ago">%1$d일 전</string>
<string name="shell_exit_title">앱을 종료할까요?</string>
<string name="shell_exit_body">뒤로 가기를 실수로 누르셨다면 계속 사용을 눌러 주세요.</string>
<string name="shell_exit_confirm">종료</string>
<string name="shell_exit_stay">계속 사용</string>
<string name="shell_scroll_to_top">맨 위로</string>
</resources>

View File

@ -1642,3 +1642,18 @@ This is the append-only implementation and handoff record shared by Codex and Cl
or its loading state if never opened); it is not loaded for the pass.
- Checks and results: the five required checks pass.
- Caution: not seen on a device.
## 2026-10-12 (continued) — TASK-075: small interaction fixes
- Request: the product owner asked for a border on the selected timeline chip, no tea cup on
short loads, a question before leaving from 홈, a button back to the top of long lists, and
said that sideways moves looked diagonal (top left to bottom right), also at launch.
- What changed: `Chips.kt` wraps Material's FilterChip with a selected border and the five
screens import it; `CenteredLoading` shows its cup only after 450 ms (`AfterShortWait`);
`ExitConfirmDialog` is shown by Back on 홈; `ScrollTopLazyColumn` replaces the LazyColumn of
the list screens; `NoResize` is applied to the route transition and to `TabGlide`.
- Design decision: the diagonal drift is attributed to AnimatedContent's default size animation,
which grows from the top left; this was not confirmed on a device before the change. The exit
question was designed in code in the app's style, not on the Claude Design canvas first.
- Agents: chips and the exit dialog were written by implementer (sonnet); the rest by main (opus).
- Checks and results: the five required checks pass.

View File

@ -0,0 +1,59 @@
# TASK-075: Small interaction fixes from the product owner's first look
Status: implemented; required checks pass; installed on the product owner's device for a look
Owner: Claude
Reviewer: human product owner
## Outcome
1. A selected filter chip is outlined in the brand colour, everywhere chips are used.
2. A short load shows no tea cup: the cup appears only after 450 ms of waiting, so opening a
repository from 활동 중인 저장소 goes straight to the code when it loads quickly.
3. Back on 홈 asks before leaving the app (계속 사용 is the default; 종료 leaves).
4. A list scrolled a long way down offers a small round button that returns it to the top.
5. Screens that move sideways no longer drift diagonally: `AnimatedContent` is told not to animate
its own size (it does so from the top left corner).
## Scope
- Included: the five items above.
- Excluded: code, diff and compare panes (they keep their own scrolling); the loading skeletons.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/**/*Screen.kt`
- `app/src/main/res/values/strings_shell.xml`
- `tasks/075-small-interaction-fixes.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] Each of the five items is in the code and the app builds.
- [x] Chip call sites are unchanged: only the import differs.
- [x] The exit question closes on Back or a tap outside, staying in the app.
- [x] The top button is not composed while a list is near its top.
- [ ] Seen on the product owner's device.
## Security and privacy
No additional concerns. No network, storage or credential code is touched.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-075.
## Review findings
None yet.