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:
parent
bb546421fc
commit
a140b56aec
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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)) }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}" } },
|
||||
|
||||
@ -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)) {
|
||||
|
||||
39
app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt
Normal file
39
app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt
Normal 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
59
tasks/075-small-interaction-fixes.md
Normal file
59
tasks/075-small-interaction-fixes.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user