Compare commits

...

10 Commits

Author SHA1 Message Date
137480f30d TASK-076: version 0.2.5, published as the release build
The release build type is signed with the key the published builds have had,
so it installs over them.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:17:38 +09:00
ef47f0aef0 TASK-069: a pushed screen's content is there as it stops
What loaded during a push was held 80 ms past it and then took a frame to
build, so the screen stood there still loading. It is now released 40 ms
before the push ends, where the screen has almost no way left to go.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:00:50 +09:00
cd8b1661fa TASK-069: tabs stay under a screen opened from them, and its content waits for the push
Pressing a card under 활동 중인 저장소 hitched: the file list was built in one
long frame while the screen was still pushing in, and Back began with a frame
of about 120 ms because the four tab screens were built again.

PushedOver keeps the tab pager composed under a pushed screen, moved off to the
left with the push; heldWhilePushedIn shows what loaded during the push once
the screen stands still.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:49:10 +09:00
4dab3a4ba4 TASK-069: rows keep their branch while the repository list reloads
Going between the two repository tabs sent every row back to "checking"
and then to its branch chip, which changed each row's height and the
lines of its text twice per move. The view model keeps the branch last
found for each repository and starts a reloaded list with it, still
checking for a newer one.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:26:22 +09:00
e1abdfc4ca TASK-069: a move no longer starts by drawing every screen again
Measured on the product owner's phone over adb. The code workbench value,
provided through a static composition local, was rebuilt on each root
recomposition, which turned skipping off for all four tab screens on every
move. It is remembered now. Looping animations stop while their page is off
screen, the two repository lists stay composed side by side, and a
repository's branches open beside the list instead of replacing it.

First frame of a bottom tab move: 80-118 ms to 23-34 ms. The branches
slide has not been measured on the device yet.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:15:07 +09:00
db8fc0574e TASK-069: screens push in, repository tabs hold their list, tabs stay composed
Opening a screen pushes it in from the right and Back reverses it, in place
of a 90 ms fade that read as a flicker. The repository tabs keep each tab's
last list through a reload, so nothing settles after the glide. The four
bottom tabs stay composed; only the one shown answers Back.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:38:54 +09:00
d9baf04a1d TASK-075: the exit question stands alone
The line under it explained what the two buttons already say.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:34:51 +09:00
a140b56aec 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>
2026-10-12 16:29:41 +09:00
bb546421fc TASK-069: bottom tabs glide past the tabs between
The four tab screens are pages of one pager that a tab press scrolls, so 홈
to 알림 passes 저장소 and 작업 instead of sliding straight there. Swiping is
off; entering and leaving other screens is unchanged.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:42 +09:00
a28feb1a47 TASK-069: the first screen fades in under the cup instead of gliding
Until the session is known the route shown is the repository list; when it
becomes Home the change played the full-width tab glide just as the launch
cover was fading. That hand-over is now a plain fade, and the cover waits for
it (at most 1.5 s longer) before it leaves.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:16:17 +09:00
22 changed files with 1147 additions and 303 deletions

View File

@ -16,14 +16,17 @@ android {
targetSdk = 36
// Raised for every build published to the download page (TASK-066), or the app will not
// see it as newer.
versionCode = 6
versionName = "0.2.4"
versionCode = 7
versionName = "0.2.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
// The published build (TASK-076). It carries this PC's debug key, as every build
// published before it did, so it installs over them as an update.
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),

View File

@ -17,14 +17,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -48,7 +40,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
@ -94,7 +85,11 @@ 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.PushedOver
import com.ubisam.ubgitea.ui.components.heldWhilePushedIn
import com.ubisam.ubgitea.ui.components.SplashOverlay
import com.ubisam.ubgitea.ui.components.TabPager
import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.shell.AppShell
@ -268,19 +263,28 @@ class MainActivity : ComponentActivity() {
homeViewModel.close()
workViewModel.close()
}
fun signOut() {
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
// Held, not made again on each pass: the screens are given these, and a screen given
// the same callbacks as before is left alone when something else here changes.
val signOut: () -> Unit = remember {
{
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
}
}
fun leaveBranchDetail() {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
val leaveBranchDetail: () -> Unit = remember {
{
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
}
var confirmExit by remember { mutableStateOf(false) }
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) {
CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
}
val showNavigation = sessionReady && when (currentRoute) {
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
Route.Settings, Route.Compare -> false
@ -317,23 +321,28 @@ class MainActivity : ComponentActivity() {
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
}
}
val workbench = if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(currentRoute as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
// Made again only when what it holds changes. Every screen is composed under it, and
// a new one, even one that says the same, has every screen on every tab drawn again:
// that was a stall of several frames at the start of each tab change and each push.
val workbench = remember(sessionReady, workbenchState.tabs, workbenchState.pending, workbenchState.fetching) {
if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(routeStack.last() as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
}
}
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
@ -396,19 +405,23 @@ 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 = {
// The screens are built once and held, and read what changes from state. A
// tab press, which runs everything around them again, then leaves the four tab
// screens exactly as they were instead of drawing each of them again.
val repositoryList: @Composable () -> Unit = remember { {
RepositoryListScreen(
state = state,
instanceHost = instanceHost,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = ::signOut,
onRepositorySelected = viewModel::selectRepository,
onSignIn = remember { viewModel::signIn },
onCancelSignIn = remember { viewModel::cancelSignIn },
onRetry = remember { viewModel::retry },
onLogout = signOut,
onRepositorySelected = remember { viewModel::selectRepository },
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
@ -421,51 +434,34 @@ class MainActivity : ComponentActivity() {
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onReferencesRefresh = remember { viewModel::refreshReferences },
onBackToRepositories = remember { viewModel::backToRepositories },
onNextPage = remember { viewModel::nextPage },
onPreviousPage = remember { viewModel::previousPage },
onRefreshRepositories = remember { viewModel::refreshRepositories },
onRetryLastPushedBranch = remember { viewModel::retryRowLastPushedBranch },
onToggleFavorite = remember { viewModel::toggleFavorite },
onShowAllRepositories = remember { viewModel::showAllRepositories },
onShowFavorites = remember { viewModel::showFavorites },
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onSelectOrganization = remember { viewModel::selectOrganization },
onViewSettings = { push(Route.Settings) },
)
}
// Entering a screen slides it in from the side it was opened towards, and a
// tab switch from the side that tab sits on in the bar. Keyed by the kind of
// screen, so moving between code tabs or pages of one screen does not replay it.
} }
// Opening a screen pushes it in from the right as the one behind leaves to the
// left, and going back reverses that; it loads while it arrives. Keyed by
// the kind of screen, so moving between code tabs or pages of one screen does
// not replay it. The four tabs are one kind: between them the pager below glides.
val stackDepth = routeStack.size
val shownDepth = remember { intArrayOf(stackDepth) }
SideEffect { shownDepth[0] = stackDepth }
AnimatedContent(
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
val fromTab = initialState.tab
val toTab = targetState.tab
val tabSwitch = stackDepth == shownDepth[0] && fromTab != null && toTab != null
if (tabSwitch) {
// Tabs sit side by side: the old one glides out as the new one
// glides in, the whole width, like turning to the next page.
val direction = (toTab.ordinal - fromTab.ordinal).coerceIn(-1, 1)
val glide = tween<IntOffset>(320, easing = FastOutSlowInEasing)
slideInHorizontally(glide) { it * direction }
.togetherWith(slideOutHorizontally(glide) { -it * direction })
} else {
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
.togetherWith(fadeOut(tween(90)))
}
},
contentKey = { it::class },
label = "route",
) { route ->
val shownReady = remember { booleanArrayOf(sessionReady) }
SideEffect {
shownDepth[0] = stackDepth
shownReady[0] = sessionReady
}
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
Box(Modifier.fillMaxSize()) {
when (route) {
Route.Home -> HomeScreen(
@ -493,52 +489,52 @@ class MainActivity : ComponentActivity() {
)
}
},
onSelectSource = homeViewModel::selectSource,
onSelectFilter = homeViewModel::selectFilter,
onSetTodayOnly = homeViewModel::setTodayOnly,
onShowToday = homeViewModel::showToday,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onSelectSource = remember { homeViewModel::selectSource },
onSelectFilter = remember { homeViewModel::selectFilter },
onSetTodayOnly = remember { homeViewModel::setTodayOnly },
onShowToday = remember { homeViewModel::showToday },
onLoadMore = remember { homeViewModel::loadMore },
onRefresh = remember { homeViewModel::refresh },
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
onLogout = signOut,
onAuthenticationExpired = signOut,
)
Route.Work -> WorkScreen(
state = workState,
accountName = accountName,
instanceHost = instanceHost,
onSelectScope = workViewModel::selectScope,
onSelectState = workViewModel::selectState,
onSelectType = workViewModel::selectType,
onSelectScope = remember { workViewModel::selectScope },
onSelectState = remember { workViewModel::selectState },
onSelectType = remember { workViewModel::selectType },
onItemSelected = { hit ->
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
},
onNextPage = workViewModel::nextPage,
onPreviousPage = workViewModel::previousPage,
onRefresh = workViewModel::refresh,
onNextPage = remember { workViewModel::nextPage },
onPreviousPage = remember { workViewModel::previousPage },
onRefresh = remember { workViewModel::refresh },
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
onLogout = signOut,
onAuthenticationExpired = signOut,
)
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
onSetReactiveAuthRetryEnabled = remember { settingsViewModel::setReactiveAuthRetryEnabled },
onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled },
accountName = accountName,
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onSetThemeMode = settingsViewModel::setThemeMode,
onLogout = ::signOut,
onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled },
onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp },
onSetThemeMode = remember { settingsViewModel::setThemeMode },
onLogout = signOut,
onBack = { pop() },
update = updateState,
onCheckUpdate = updateViewModel::checkNow,
onInstallUpdate = ::startUpdate,
onCheckUpdate = remember { updateViewModel::checkNow },
onInstallUpdate = remember { ::startUpdate },
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly },
onShowAll = remember { notificationsViewModel::showAll },
onNotificationSelected = { notification ->
push(
Route.WorkItems(
@ -549,15 +545,15 @@ class MainActivity : ComponentActivity() {
),
)
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onNextPage = remember { notificationsViewModel::nextPage },
onPreviousPage = remember { notificationsViewModel::previousPage },
onRetry = remember { notificationsViewModel::retry },
onBack = { selectTab(AppTab.HOME) },
onAuthenticationExpired = ::signOut,
onAuthenticationExpired = signOut,
accountName = accountName,
instanceHost = instanceHost,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onLogout = signOut,
)
is Route.WorkItems -> {
val leave = {
@ -566,65 +562,78 @@ class MainActivity : ComponentActivity() {
}
WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
onShowIssues = remember { workItemsViewModel::showIssues },
onShowPullRequests = remember { workItemsViewModel::showPullRequests },
onIssueSelected = remember { workItemsViewModel::openIssue },
onPullRequestSelected = remember { workItemsViewModel::openPullRequest },
onNextPage = remember { workItemsViewModel::nextPage },
onPreviousPage = remember { workItemsViewModel::previousPage },
onGoToPage = remember { workItemsViewModel::goToPage },
onRetryList = remember { workItemsViewModel::retryList },
onRetryDetail = remember { workItemsViewModel::retryDetail },
// An item opened directly came from a list elsewhere (a search, the
// timeline, a notification), so back returns there, not to this
// repository's own list.
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
onBackToList = if (route.number != null) leave else remember { workItemsViewModel::backToList },
onBack = leave,
onAuthenticationExpired = ::signOut,
onAuthenticationExpired = signOut,
)
}
is Route.BranchDetail -> BranchDetailScreen(
owner = route.owner,
name = route.name,
ref = route.ref,
codeBrowserState = codeBrowserState,
codeBrowserState = heldWhilePushedIn(codeBrowserState),
codeViewerPreferences = codeViewerPreferences,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onEntrySelected = remember { codeBrowserViewModel::openEntry },
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
onRetryCodeBrowser = remember { codeBrowserViewModel::retry },
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
onNavigateImage = codeBrowserViewModel::navigateImage,
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
onOpenSibling = codeBrowserViewModel::openSibling,
onToggleFolder = codeBrowserViewModel::toggleFolder,
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = ::leaveBranchDetail,
onNavigateImage = remember { codeBrowserViewModel::navigateImage },
onSetCodeWordWrap = remember { settingsViewModel::setCodeWordWrapEnabled },
onSetCodeFontSize = remember { settingsViewModel::setCodeFontSizeSp },
onOpenSibling = remember { codeBrowserViewModel::openSibling },
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
historyState = heldWhilePushedIn(historyState),
onCommitSelected = remember { historyViewModel::openCommit },
onHistoryNextPage = remember { historyViewModel::nextPage },
onHistoryPreviousPage = remember { historyViewModel::previousPage },
onRetryHistoryList = remember { historyViewModel::retryList },
onRetryHistoryDetail = remember { historyViewModel::retryDetail },
onRetryPatch = remember { historyViewModel::retryPatch },
onFileSelected = remember { historyViewModel::openFileDiff },
onShowAdjacentFile = remember { historyViewModel::openAdjacentFileDiff },
onClearFileSelection = remember { historyViewModel::clearFileSelection },
onHistoryBackToList = remember { historyViewModel::backToList },
onHistoryBack = leaveBranchDetail,
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
onClose = ::leaveBranchDetail,
onAuthenticationExpired = ::signOut,
onClose = leaveBranchDetail,
onAuthenticationExpired = signOut,
)
Route.Repositories -> repositoryList()
Route.Compare -> CompareScreen(
state = workbenchState.compare,
fontSizeSp = settingsState.codeFontSizeSp,
onSwap = workbenchViewModel::swapSides,
onClose = workbenchViewModel::closeCompare,
onSwap = remember { workbenchViewModel::swapSides },
onClose = remember { workbenchViewModel::closeCompare },
)
}
}
}
} }
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
val target = currentRoute.takeIf { sessionReady } ?: Route.Repositories
val tabBelow = routeStack.lastOrNull { it.isTab }?.tab ?: AppTab.HOME
PushedOver(
above = target.takeUnless { sessionReady && it.isTab },
aboveKey = { it::class },
// The session has just been found: the first screen takes the waiting
// one's place where it stands. Nothing was navigated, so nothing slides.
direction = { if (shownReady[0]) (stackDepth - shownDepth[0]).coerceIn(-1, 1) else 0 },
// Tabs sit side by side: going from one to another passes the ones between.
below = if (sessionReady) ({ TabPager(AppTab.entries, tabBelow, content = tabScreen) }) else null,
content = screen,
)
}
}
LaunchedEffect(viewModel) {
@ -685,7 +694,10 @@ class MainActivity : ComponentActivity() {
},
)
}
SplashOverlay()
if (confirmExit) {
ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() })
}
SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null)
}
}
consumeCallback(intent)

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
@ -36,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -63,12 +62,16 @@ 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
import com.ubisam.ubgitea.ui.components.TabGlideMillis
import com.ubisam.ubgitea.ui.components.TabPager
import kotlinx.coroutines.delay
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.components.UbiSamLogo
@ -104,8 +107,50 @@ fun RepositoryListScreen(
// Changing tab reloads through Loading, which does not say which tab it is loading. Keeping
// the tab that was asked for lets the tab row stay where it is while the list under it glides.
var requestedMode by remember { mutableStateOf<RepositoryListMode?>(null) }
// What each tab last showed. A tab change, a refresh and a page turn all pass through
// Loading; showing the last list meanwhile keeps the header and the rows where they are, so
// the new list replaces the old one in place and nothing appears to settle afterwards.
val seen = remember { mutableStateMapOf<RepositoryListMode, RepositoryListUiState>() }
var seenMode by remember { mutableStateOf<RepositoryListMode?>(null) }
// A repository's branches open beside the list, not in place of it. The list stays built
// underneath and the branches stay built on the way out, so going in and coming back are
// both a slide, and neither starts by building a screen of rows.
var lastReferences by remember { mutableStateOf<RepositoryListUiState?>(null) }
val inReferences = state is RepositoryListUiState.ReferencesLoading ||
state is RepositoryListUiState.ReferencesContent ||
state is RepositoryListUiState.ReferencesError
LaunchedEffect(state) {
if (state != RepositoryListUiState.Loading) requestedMode = null
when (state) {
is RepositoryListUiState.Content -> {
seen[state.mode] = state
seenMode = state.mode
}
is RepositoryListUiState.Empty -> {
seen[state.mode] = state
seenMode = state.mode
}
// Another account may sign in next: its lists are not these.
RepositoryListUiState.SignedOut -> {
seen.clear()
seenMode = null
lastReferences = null
}
is RepositoryListUiState.ReferencesLoading,
is RepositoryListUiState.ReferencesContent,
is RepositoryListUiState.ReferencesError -> lastReferences = state
else -> Unit
}
}
// Branches that arrive while their screen is still sliding in wait for it to stop: rows built
// in the middle of the slide made it stall.
var slidingIn by remember { mutableStateOf(false) }
LaunchedEffect(inReferences) {
if (inReferences) {
slidingIn = true
delay(TabGlideMillis.toLong())
slidingIn = false
}
}
val showAllRepositories = {
requestedMode = RepositoryListMode.ALL
@ -127,71 +172,6 @@ fun RepositoryListScreen(
stringResource(R.string.repositories_cancel_login),
onCancelSignIn,
)
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (state) {
is RepositoryListUiState.Content -> state.mode
is RepositoryListUiState.Empty -> state.mode
else -> requestedMode
}
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites)
if (state is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()
) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
}
}
}
TabGlide(mode to state, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(
shown,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
showAllRepositories,
showFavorites,
onToggleFavorite,
)
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
// above this message) is very likely non-empty. The only sensible primary
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = showAllRepositories,
decoration = { EmptyCup() },
)
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, shown.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
}
}
}
}
}
is RepositoryListUiState.Error -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
@ -207,30 +187,123 @@ fun RepositoryListScreen(
onSecondaryAction = onLogout,
)
}
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredLoading(stringResource(R.string.repositories_references_loading))
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredMessage(
stringResource(state.type.messageResource),
action = stringResource(R.string.repositories_retry),
onAction = onRetry,
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content,
is RepositoryListUiState.ReferencesLoading,
is RepositoryListUiState.ReferencesContent,
is RepositoryListUiState.ReferencesError -> TabPager(
RepositoryDepth.entries,
if (inReferences) RepositoryDepth.REFERENCES else RepositoryDepth.LIST,
) { depth ->
Column(Modifier.fillMaxSize()) {
if (depth == RepositoryDepth.LIST) {
val listState = if (inReferences) seenMode?.let { seen[it] } ?: RepositoryListUiState.Loading else state
val loadingMode = requestedMode ?: seenMode
val shownState = if (listState == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: listState else listState
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (shownState) {
is RepositoryListUiState.Content -> shownState.mode
is RepositoryListUiState.Empty -> shownState.mode
else -> loadingMode
}
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites)
if (shownState is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
) {
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
}
}
}
// Both lists stay composed side by side, so changing tab only slides: building
// a list of rows in the frame the slide starts on is what made it stall.
TabPager(RepositoryListMode.entries, mode, Modifier.weight(1f).fillMaxWidth()) { page ->
val shown = if (page == mode) shownState else seen[page] ?: RepositoryListUiState.Loading
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(
shown,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
showAllRepositories,
showFavorites,
onToggleFavorite,
)
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
// above this message) is very likely non-empty. The only sensible primary
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = showAllRepositories,
decoration = { EmptyCup() },
)
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, shown.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
}
}
}
}
} else {
val arrived = if (inReferences) state else lastReferences
val shown = if (slidingIn && arrived is RepositoryListUiState.ReferencesContent) {
RepositoryListUiState.ReferencesLoading(arrived.accountName, arrived.repository)
} else {
arrived
}
when (shown) {
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
CenteredLoading(stringResource(R.string.repositories_references_loading))
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(shown, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
CenteredMessage(
stringResource(shown.type.messageResource),
action = stringResource(R.string.repositories_retry),
onAction = onRetry,
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
}
else -> Unit
}
}
}
}
}
}
}
private enum class RepositoryDepth { LIST, REFERENCES }
@Composable
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
Column(
@ -267,7 +340,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 +606,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

@ -185,6 +185,10 @@ class RepositoryListViewModel(
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
private var operation: Job? = null
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
// The branch each repository was last found to have been pushed to. A list that is loaded
// again (a tab change, a refresh, a page turn) shows this while it checks, where it used to
// show "checking" on every row: the rows changed height and their text changed line, twice.
private val knownRowBranches = mutableMapOf<RepositoryCoordinates, LastPushedBranchUiState>()
// Repositories whose star/unstar request is still on its way. A second tap on the same row is
// ignored until the first request settles: the tap flips the star at once, so without this a
// quick double tap sent a star and an unstar together, and whichever reached the server last
@ -475,6 +479,7 @@ class RepositoryListViewModel(
tokenVault.clear()
repositoryListCache.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
repositoryContent = null
cachedOrganizations = null
@ -496,6 +501,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
}
mutableState.value = RepositoryListUiState.Error(
@ -527,6 +533,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
}
val error = failure.toRepositoryListError()
@ -563,7 +570,10 @@ class RepositoryListViewModel(
// showing) -- FavoriteStar's own rendering already handles that mode without consulting
// favoriteAction at all, so the cross-reference below only matters for ALL.
val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet()
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) }
val rows = pageItems.map { summary ->
val row = summary.toUiModel(alreadyStarred = summary.coordinates in alreadyStarred)
knownRowBranches[row.coordinates]?.let { row.copy(lastPushedBranch = it) } ?: row
}
if (rows.isEmpty() && page == 1) {
repositoryContent = null
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
@ -580,12 +590,13 @@ class RepositoryListViewModel(
)
repositoryContent = content
mutableState.value = content
loadRowBranches(content)
loadRowBranches(content, all = true)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
repositoryContent = null
}
@ -731,9 +742,9 @@ class RepositoryListViewModel(
return loaded
}
private fun loadRowBranches(content: RepositoryListUiState.Content) {
private fun loadRowBranches(content: RepositoryListUiState.Content, all: Boolean = false) {
content.repositories
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
.filter { all || it.lastPushedBranch is LastPushedBranchUiState.Loading }
.forEach { launchRowBranchFetch(it.coordinates) }
}
@ -745,22 +756,21 @@ class RepositoryListViewModel(
val latest = branches
.filter { it.commitAtEpochMillis != null }
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
updateRow(coordinates) {
it.copy(
lastPushedBranch = latest?.let { branch ->
LastPushedBranchUiState.Loaded(branch.name.value)
} ?: if (branches.isEmpty()) {
LastPushedBranchUiState.Empty
} else {
LastPushedBranchUiState.Failed
},
)
val found = latest?.let { branch ->
LastPushedBranchUiState.Loaded(branch.name.value)
} ?: if (branches.isEmpty()) {
LastPushedBranchUiState.Empty
} else {
LastPushedBranchUiState.Failed
}
if (found != LastPushedBranchUiState.Failed) knownRowBranches[coordinates] = found
updateRow(coordinates) { it.copy(lastPushedBranch = found) }
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
clearSessionOnAuthenticationExpired()
} else {
} else if (coordinates !in knownRowBranches) {
// A check that fails leaves a branch already known where it is.
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
}
}
@ -771,6 +781,7 @@ class RepositoryListViewModel(
cancelRowBranchJobs()
tokenVault.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
@ -830,6 +841,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false
repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)

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,113 @@
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,
)
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,7 +1,12 @@
package com.ubisam.ubgitea.ui.components
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@ -9,8 +14,12 @@ 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.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@ -28,6 +37,8 @@ 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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@ -36,13 +47,17 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -58,15 +73,20 @@ import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
// The app's moving parts. All of them draw the launcher icon's tea cup, so the app looks like one
// thing from the home screen inwards.
@ -109,14 +129,48 @@ private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float =
}
}
/**
* Whether what is composed here can be seen. A tab kept composed beside the one shown is not, and
* nothing in it should keep the display redrawing: looping motion rests while this is false.
*/
val LocalOnScreen = compositionLocalOf { true }
/** A value going round from [from] to [to] for as long as it can be seen; [from] when it cannot. */
@Composable
private fun rememberSteam(): () -> Float {
val transition = rememberInfiniteTransition(label = "steam")
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
return { steam }
private fun rememberLoop(
from: Float,
to: Float,
millis: Int,
easing: Easing = LinearEasing,
repeatMode: RepeatMode = RepeatMode.Restart,
label: String,
): () -> Float {
if (!LocalOnScreen.current) return { from }
val transition = rememberInfiniteTransition(label = label)
val value by transition.animateFloat(from, to, infiniteRepeatable(tween(millis, easing = easing), repeatMode), label = label)
return { value }
}
@Composable
private fun rememberSteam(): () -> Float = rememberLoop(0f, 1f, 1600, label = "steam")
/** 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()
@ -129,8 +183,7 @@ fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color =
*/
@Composable
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
val transition = rememberInfiniteTransition(label = "shimmer")
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
val sweep = rememberLoop(-1f, 2f, 1300, label = "shimmer")
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
Column(
@ -146,7 +199,7 @@ fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? =
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val shimmer = Modifier.drawShimmer(base, light) { sweep }
val shimmer = Modifier.drawShimmer(base, light, sweep)
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Rows differ in length so the block does not read as a table.
@ -175,13 +228,7 @@ private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float):
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
@Composable
fun EmptyCup(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "empty")
val bob by transition.animateFloat(
-1f,
1f,
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
label = "bob",
)
val bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob")
val steam = rememberSteam()
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
Canvas(
@ -189,8 +236,8 @@ fun EmptyCup(modifier: Modifier = Modifier) {
.padding(bottom = 20.dp)
.size(56.dp, 67.dp)
.graphicsLayer {
translationY = bob * 3.dp.toPx()
rotationZ = bob * 2f
translationY = bob() * 3.dp.toPx()
rotationZ = bob() * 2f
},
) { drawTeaCup(color, steam()) }
}
@ -302,6 +349,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",
@ -310,16 +358,172 @@ 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
* it: swiping stays with what is inside the screens.
*/
@Composable
fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
val pager = rememberPagerState(initialPage = tabs.indexOf(current)) { tabs.size }
LaunchedEffect(current) {
val page = tabs.indexOf(current)
val passed = abs(page - pager.currentPage)
if (passed > 0 || pager.isScrollInProgress) {
pager.animateScrollToPage(
page,
animationSpec = tween(TabGlideMillis + TabGlideStepMillis * (passed - 1).coerceAtLeast(0), easing = FastOutSlowInEasing),
)
}
}
// Every tab stays composed, so a glide never waits for a screen to be built. Only the tab
// being shown may answer Back: the others are handed a dispatcher nothing presses.
val backOwner = LocalOnBackPressedDispatcherOwner.current
val noBack = rememberNoBackOwner()
// What a page reads about the tab shown, it reads from state: a tab press then recomposes the
// two small scopes below and not the screens inside them.
val shown = rememberUpdatedState(current)
// A pager inside a tab: its pages are on screen only while that tab is.
val pagerOnScreen = LocalOnScreen.current
HorizontalPager(
pager,
modifier.fillMaxSize(),
beyondViewportPageCount = tabs.size - 1,
userScrollEnabled = false,
key = { it },
) { page ->
val owner = if (tabs[page] == shown.value && backOwner != null) backOwner else noBack
val onScreen by remember(page) {
derivedStateOf { abs(pager.currentPage + pager.currentPageOffsetFraction - page) < 1f }
}
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner, LocalOnScreen provides (pagerOnScreen && onScreen)) {
content(tabs[page])
}
}
}
const val TabGlideStepMillis = 50
/** A Back dispatcher nothing presses, for screens that are composed but not the one shown. */
@Composable
private fun rememberNoBackOwner(): OnBackPressedDispatcherOwner {
val lifecycleOwner = LocalLifecycleOwner.current
return remember(lifecycleOwner) {
object : OnBackPressedDispatcherOwner {
override val lifecycle get() = lifecycleOwner.lifecycle
override val onBackPressedDispatcher = OnBackPressedDispatcher()
}
}
}
/**
* [below], and [above] it whatever was opened from it. A screen opened pushes in from the right
* as the one behind leaves to the left, and Back reverses that. [direction] says which, when
* [above] changes: 1 opened, -1 gone back, 0 neither, which fades in place.
*
* [below] stays composed while it is covered, off to the left, so Back slides back to it as it
* was left and does not wait for it to be built. Covered, it answers no Back and nothing in it
* loops.
*/
@Composable
fun <T : Any> PushedOver(
above: T?,
aboveKey: (T) -> Any,
direction: () -> Int,
below: (@Composable () -> Unit)?,
content: @Composable (T) -> Unit,
) {
Box(Modifier.fillMaxSize()) {
val covered = above != null
val away by updateTransition(covered, label = "below").animateFloat(
transitionSpec = { if (direction() == 0) snap() else tween(ScreenPushMillis, easing = FastOutSlowInEasing) },
label = "away",
) { if (it) 1f else 0f }
if (below != null) {
val onScreen by remember { derivedStateOf { away < 1f } }
val backOwner = LocalOnBackPressedDispatcherOwner.current
val noBack = rememberNoBackOwner()
// It takes the place of what waited for the session: it fades in where it stands.
val appear = remember { Animatable(0f) }
LaunchedEffect(Unit) { appear.animateTo(1f, tween(200)) }
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = -size.width * away
alpha = appear.value
}
.then(if (covered) Modifier.clearAndSetSemantics { } else Modifier),
) {
CompositionLocalProvider(
LocalOnBackPressedDispatcherOwner provides (if (covered || backOwner == null) noBack else backOwner),
LocalOnScreen provides onScreen,
) { below() }
}
}
AnimatedContent(
targetState = above,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
val towards = direction()
if (towards == 0) {
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
} else {
val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
slideInHorizontally(push) { it * towards }
.togetherWith(slideOutHorizontally(push) { -it * towards })
.using(NoResize)
}
},
contentKey = { it?.let(aboveKey) },
label = "route",
) { shown -> if (shown != null) content(shown) }
}
}
/** How long a screen takes to push in over the one it was opened from, or to leave on Back. */
const val ScreenPushMillis = 300
/**
* [value] as it stood when this screen began to push in, until it has all but arrived. What
* loads meanwhile is built in the push's last frames, where the screen moves a few pixels, so
* building it neither holds up the push nor leaves the screen standing there still loading.
*/
@Composable
fun <T> heldWhilePushedIn(value: T): T {
val first = remember { value }
var arrived by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(ScreenPushMillis - 40L)
arrived = true
}
return if (arrived) value else first
}
const val SplashLongestWaitMillis = 1500L
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
/**
* The launcher icon, enlarged and steaming, over the whole window for the first second after a
* cold start. It covers the app rather than delaying it: everything underneath loads meanwhile.
*
* It leaves once [settled] says whether there is a session, so its fade does not share frames
* with the first screen arriving underneath, and never stays longer than [SplashLongestWaitMillis]
* past its own second.
*/
@Composable
fun SplashOverlay() {
fun SplashOverlay(settled: Boolean) {
var finished by rememberSaveable { mutableStateOf(false) }
if (finished) return
val settledNow by rememberUpdatedState(settled)
val alpha = remember { Animatable(1f) }
val scale = remember { Animatable(0.85f) }
LaunchedEffect(Unit) {
@ -327,6 +531,11 @@ fun SplashOverlay() {
}
LaunchedEffect(Unit) {
delay(1100)
if (!settledNow) {
withTimeoutOrNull(SplashLongestWaitMillis) { snapshotFlow { settledNow }.first { it } }
// The first screen fades in over the waiting one; let that finish under the cover.
delay(250)
}
alpha.animateTo(0f, tween(280))
finished = true
}

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,8 @@
<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_confirm">종료</string>
<string name="shell_exit_stay">계속 사용</string>
<string name="shell_scroll_to_top">맨 위로</string>
</resources>

View File

@ -1615,3 +1615,156 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Caution: published without having been seen on a device, at the product owner's decision.
0.2.3 and older still update through the browser download; the in-app update is first used
going from 0.2.4 to whatever comes next, so it stays unproven until then.
## 2026-10-12 (continued) — TASK-069: the start no longer stutters as the cup leaves
- Request: the product owner reported that at launch the cup seemed to move off towards the top
left as it disappeared, and that this part stuttered.
- Cause found in the code: until the session is known the route shown is the repository list;
once it is known the route becomes Home. Both are tabs, so the change played the full-width tab
glide, at about the moment the cover was fading, on top of Home's first composition.
- What changed: `MainActivity` gives that hand-over a plain fade and no slide. `SplashOverlay`
takes `settled` and, after its own 1.1 seconds, waits until the session is known (at most 1.5
seconds more) and for the fade underneath before it leaves.
- Checks and results: the five required checks pass.
- Caution: diagnosed from the code, not seen on a device. If the stutter is still there, the
remaining suspect is the cost of Home's first frames on that phone.
## 2026-10-12 (continued) — TASK-069: bottom tabs pass the tabs between
- Request: going from 홈 to 알림 went straight there; the product owner wants it to glide
quickly past 저장소 and 작업.
- What changed: the four tab screens are pages of one `TabPager` (`Motion.kt`), which a tab
press scrolls with 320 ms plus 50 ms per extra tab passed; swiping is off, so gestures inside
the screens are unchanged. In `MainActivity` the tab routes share one `AnimatedContent` key,
and the two-screen tab glide there is removed. Entering and leaving other screens is as before.
- Design decision: a tab that is only passed shows whatever its ViewModel last held (its list,
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.
## 2026-10-12 (continued) — TASK-069: pushes, steadier repository tabs, tabs kept composed
- Request, from the product owner's device: opening a repository from 활동 중인 저장소 flickered
and should push in from the right while it loads; after sliding between the repository tabs
something seemed to refresh or realign; tab moves should have no stutter at all.
- What changed: `MainActivity` opens and closes screens with a full-width push (300 ms) in place
of the 90 ms fade. `RepositoryListScreen` remembers what each tab last showed and keeps showing
it through Loading, so the account line, the organisation chips and the rows no longer vanish
and return on a tab change, refresh or page turn. `TabPager` keeps all four tabs composed
(`beyondViewportPageCount`), and gives the tabs not being shown a Back dispatcher nothing
presses, so a hidden tab cannot answer Back.
- Design decision: a remembered list may be a moment out of date (a favourite just starred);
the fresh one replaces it in place when it arrives. It is dropped on sign-out.
- Checks and results: the five required checks pass.
- Caution: what is installed for these looks is the debug build, which Compose runs noticeably
less smoothly than a minified release build; the published APK is the same kind (R8 is on hold
under TASK-016). Remaining stutter may be that and not the transitions.
## 2026-10-12 (continued) — TASK-069: the stall at the start of every move
- Request: the product owner asked that the stutter be looked at on their phone directly. It was
driven over adb (taps, Back) and measured with `dumpsys gfxinfo framestats` and method
sampling (`am profile`), on the debug build, on the 120 Hz cover screen.
- What was found: every move began with one frame of 45 to 118 ms (5 to 14 frames lost).
1. `LocalCodeWorkbench` is a static composition local and its value was built again on every
recomposition of the root, which every route change causes. A changed static local turns
skipping off beneath it, so all four tab screens were composed again on each move.
2. The loading placeholder's light and the empty screen's cup kept animating on the tabs kept
composed off screen: the app drew 120 frames a second while nothing moved.
3. Changing repository tab built the incoming list of rows in the frame the slide started on.
4. A repository's branches replaced the list in place, with no movement, and Back built the
whole list again in one frame.
- What changed: the workbench value is remembered and made again only when its contents change.
`LocalOnScreen` tells a page whether it is on screen and the looping animations stop when it
is not. `MainActivity` hands the screens remembered callbacks and a remembered screen lambda.
The two repository lists sit in a `TabPager`, and so do the list and the branches of the
repository opened from it; branches that arrive during the slide wait for it to finish.
- Measured, first frame of the move, before to after: bottom tabs 80–118 ms to 23–34 ms;
repository tab 69–77 ms to 34–52 ms; idle, 120 frames a second to none.
- Not measured: the branches opening beside the list, and Back from them. The phone's screen was
turned off before that run; the change builds and passes the checks but has not been seen.
Before it, opening was a cut followed by a 49–59 ms frame when the branches arrived, and Back
was a cut with one frame of 69–105 ms.
- Design decision: the branches screen now slides in like a pushed screen, where it used to
replace the list at once. This is a visible change made for smoothness and is the product
owner's to keep or reverse.
- Checks and results: the five required checks pass.
- Caution: what remains (about 25 to 50 ms on the first frame) is in good part the debug build;
a non-debuggable build of the same code measured about a third less. R8 is still on hold
under TASK-016.
## 2026-10-12 (continued) — TASK-069: rows keep their lines between repository tabs
- Request: the product owner turned the screen back on for the measurement left open, and
reported that going between 전체 저장소 and 즐겨찾기 kept changing the lines of text.
- Measured, the branches beside the list: opening starts with a frame of 30–42 ms and slides for
the full 390 ms, and the rows are built in one 52–64 ms frame after the slide has stopped;
Back starts with 13–17 ms and slides without a slow frame (it was a cut with 69–105 ms).
- What was found: a capture taken 120 ms into the slide showed every row saying
"마지막 push 브랜치 확인 중…". Each load of the list began every row at that text and
replaced it with the branch chip when the check returned; the chip is taller and, with a long
branch name, pushes the date to a line of its own, so each row changed height twice per move.
- What changed: `RepositoryListViewModel` keeps the branch last found for each repository and a
list loaded again starts its rows with it, still checking every row for a newer one. A check
that fails leaves a known branch in place. The record is dropped with the session.
- Measured after: the same capture shows the chips in place during the slide; the repository
tab change starts with 35–53 ms and has no frame over 25 ms after it.
- Checks and results: the five required checks pass.
## 2026-10-12 (continued) — TASK-069: opening from 활동 중인 저장소, and Back from it
- Request: the product owner reported a hitch on pressing a card under 활동 중인 저장소.
- Measured on their device over adb, before: opening had a frame of 79–95 ms about 220 ms into
the 300 ms push, when the file list arrived and its twelve rows were built at once; Back
began with a frame of 119–123 ms. The screen transition dropped the tab pager when a screen
covered it, so Back built all four tab screens again in its first frame.
- What changed: `PushedOver` in `Motion.kt` takes the transition's place in `MainActivity`. The
tab pager stays composed under a screen opened from it, moved off to the left with the same
curve as the push; covered, it answers no Back, is hidden from accessibility and nothing in
it loops. `heldWhilePushedIn` gives the branch screen its code and history state as they stood
when it began to push in until it has arrived.
- Measured after: opening has no slow frame between the first (52–72 ms, building the screen)
and the end of the push, and the rows are built in one 80–111 ms frame once it stands still;
Back begins with 35–66 ms and has no slow frame until it has stopped.
- Checks and results: the five required checks pass.
- Caution: the first frame of opening is not shorter; it delays the push's start and does not
interrupt it.
- Follow-up the same day: the product owner, on a release build, found the screen often still
loading after it had arrived. The state was held 80 ms past the push and then took a frame
to build. It is now released 40 ms before the push ends, where the screen has under 2% of
its way left, so the list is there as the screen stops.
## 2026-10-12 (continued) — TASK-076: 0.2.5 published, as the release build
- Request: the product owner asked for the work so far to be published as a new version, and
for the release build to be the one published.
- What changed: version 0.2.5 (versionCode 7) with TASK-069's motion and TASK-075's fixes. The
release build type is signed with this PC's debug key, the key every published build has
had, so `assembleRelease` gives an APK that installs over them. Source pushed to `main`;
`UBGitea.apk` and `version.json` on the `apk` branch replaced.
- Why the release build: measured on the product owner's device, the same code's first frame
of a move was 52–72 ms as the debug build and 21–29 ms as the release build.
- Checks and results: the five required checks and `assembleRelease` pass; lint 0 errors. The
release APK's signing certificate is the debug APK's. Installed over 0.2.4 on the product
owner's device as an update, it starts signed in and opens a repository. The APK downloaded
from the `apk` branch has the same SHA-256 as the one built here.
- Caution: R8 is still off (TASK-016) and no baseline profile is included; both were explained
to the product owner, who put them off. The in-app update from 0.2.4 is used for the first
time by this version and was not watched on a device.

View File

@ -1,6 +1,6 @@
# TASK-069: In-app motion
Status: implemented; required checks pass; not yet seen on a device
Status: implemented; required checks pass; being adjusted from the product owner's look on a device
Owner: Claude
Reviewer: human product owner
@ -22,6 +22,32 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
9. Added after that (product owner, 2026-10-12): the tabs inside a screen glide the same way.
전체 저장소/즐겨찾기 and 이슈/풀 리퀘스트 keep their header and tab row still while the list under
them changes sides. The branch screen's tabs already slid (a pager) and are unchanged.
10. Reported by the product owner (2026-10-12): the start stuttered as the cup left. The first
screen was gliding in sideways underneath, as if a tab had been switched, while the cover was
fading. The first screen now fades in where it stands, and the cover waits for it (at most
1.5 seconds longer) before it leaves.
11. Asked by the product owner (2026-10-12): going from 홈 to 알림 should pass 저장소 and 작업
on the way, quickly, not jump straight there. The four tab screens now sit side by side in a
pager that only a tab press moves.
12. After the product owner's first look on a device (2026-10-12): opening a screen pushes it in
from the right, the full width, as the one behind leaves to the left, and Back reverses it;
this replaces the short fade of item 5, which read as a flicker. The repository tabs keep
showing each tab's last list while it reloads, so nothing settles after the glide. All four
bottom tabs stay composed, so a glide does not wait for a screen to be built.
13. Measured on the product owner's device over adb (2026-10-12), at their request: every tab
change, repository tab change, opening and Back began with one frame of 45 to 118 ms on a
120 Hz screen. A move now starts without every screen being drawn again; the two repository
lists stay built side by side; a repository's branches open beside the list and not in place
of it; nothing that loops is drawn while it is off screen.
14. Reported by the product owner (2026-10-12): going between 전체 저장소 and 즐겨찾기 changed
the lines of text in the rows. Each row keeps the branch it was last found to have been
pushed to while the list is loaded again, where it went back to "확인 중" and then to the
branch, changing the row's height twice.
15. Reported by the product owner (2026-10-12): pressing a card under 활동 중인 저장소 hitched.
Measured: the file list was built in one long frame while the screen was still pushing in,
and Back began with a frame of about 120 ms because the four tab screens, dropped when the
screen covered them, were all built again. The tab screens now stay as they were under a
screen opened from them, and what loads during a push is shown once the screen stands still.
## Scope
@ -36,6 +62,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
@ -46,7 +73,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
## Acceptance criteria
- [x] Each of the nine items above is in the code and the app builds.
- [x] Each of the fifteen items above is in the code and the app builds.
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
underneath, and taps do not pass through the cover.
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen

View File

@ -48,7 +48,8 @@ repository being public was the product owner's choice in TASK-067.
## Publishing a build from now on
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleDebug`.
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleRelease`
(the release build since 0.2.5, TASK-076; `assembleDebug` before that).
2. On the `apk` branch, replace `UBGitea.apk` and set `version.json` to the same version.
3. Push the `apk` branch.

View File

@ -0,0 +1,60 @@
# 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), with the
question alone: no explanatory line under it.
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.

View File

@ -0,0 +1,47 @@
# TASK-076: Publish 0.2.5 to the team's Gitea, as the release build
Status: done
Owner: Claude
Reviewer: human product owner
## Request
The product owner asked for the work so far (TASK-069's motion, TASK-075's small fixes) to be
published as a new version, and for the release build to be the one published (2026-10-12).
## Writable paths
- `app/build.gradle.kts`
- `tasks/076-publish-0.2.5.md`
- `tasks/070-update-from-gitea.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] Version 0.2.5, versionCode 7.
- [x] `assembleRelease` gives an installable APK signed with the key the published builds have
had, so it installs over them.
- [x] The source is on `main` of the team's repository.
- [x] `UBGitea.apk` and `version.json` on the `apk` branch are 0.2.5, and the downloaded APK is
byte-identical to the one built here.
## Security and privacy
The APK is now the release build: not debuggable, and the debug-only request logging is off. It
is still signed with this PC's debug key and still not shrunk (R8 stays on hold under TASK-016).
No secret is in it or in the push.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
./gradlew assembleRelease
```
## Evidence
See the development log entry for TASK-076.
## Review findings
None.