diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index b27cb13..f981278 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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) } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index fff3d6d..6cd53d5 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -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) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt index a6e0b5c..0c1249a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt @@ -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)) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt index 8988b02..87d8a83 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt @@ -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) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt index 2b5f4d1..a89190d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt @@ -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) } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 24b42a0..8500b3c 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -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)) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt index d230c6a..9db77d3 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt @@ -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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt index 6addc1c..fe582ac 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt @@ -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}" } }, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt index a53c616..03e1bb8 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt @@ -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)) { diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt new file mode 100644 index 0000000..194e3a7 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt @@ -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, + ), + ) +} diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt index 98699a8..248bd72 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt @@ -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) + } + } } } diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt new file mode 100644 index 0000000..9a5190b --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt @@ -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(durationMillis = 180) diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt index 2b2c1b4..4a45ca5 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt @@ -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 TabGlide( val glide = tween(TabGlideMillis, easing = FastOutSlowInEasing) slideInHorizontally(glide) { it * direction } .togetherWith(slideOutHorizontally(glide) { -it * direction }) + .using(NoResize) }, contentKey = tabIndex, label = "tab", @@ -317,6 +336,13 @@ fun 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 diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt new file mode 100644 index 0000000..74f25fe --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt @@ -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 diff --git a/app/src/main/res/values/strings_shell.xml b/app/src/main/res/values/strings_shell.xml index 90dc1b5..372622c 100644 --- a/app/src/main/res/values/strings_shell.xml +++ b/app/src/main/res/values/strings_shell.xml @@ -12,4 +12,9 @@ %1$d시간 전 어제 %1$d일 전 + 앱을 종료할까요? + 뒤로 가기를 실수로 누르셨다면 계속 사용을 눌러 주세요. + 종료 + 계속 사용 + 맨 위로 diff --git a/docs/development-log.md b/docs/development-log.md index bdc5963..feab5b0 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/075-small-interaction-fixes.md b/tasks/075-small-interaction-fixes.md new file mode 100644 index 0000000..dfc2b00 --- /dev/null +++ b/tasks/075-small-interaction-fixes.md @@ -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.