Compare commits
No commits in common. "137480f30d79baf2349c3e1ce35b11d01b177769" and "0f96a8ed76d641ada686051aec2fde475a590088" have entirely different histories.
137480f30d
...
0f96a8ed76
@ -16,17 +16,14 @@ android {
|
||||
targetSdk = 36
|
||||
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||
// see it as newer.
|
||||
versionCode = 7
|
||||
versionName = "0.2.5"
|
||||
versionCode = 6
|
||||
versionName = "0.2.4"
|
||||
|
||||
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"),
|
||||
|
||||
@ -17,6 +17,14 @@ 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
|
||||
@ -40,6 +48,7 @@ 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
|
||||
@ -85,11 +94,7 @@ 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
|
||||
@ -263,28 +268,19 @@ class MainActivity : ComponentActivity() {
|
||||
homeViewModel.close()
|
||||
workViewModel.close()
|
||||
}
|
||||
// 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 signOut() {
|
||||
closeFeatures()
|
||||
selectTab(AppTab.HOME)
|
||||
viewModel.logout()
|
||||
}
|
||||
val leaveBranchDetail: () -> Unit = remember {
|
||||
{
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
pop()
|
||||
}
|
||||
fun leaveBranchDetail() {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
pop()
|
||||
}
|
||||
|
||||
var confirmExit by remember { mutableStateOf(false) }
|
||||
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
||||
val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) {
|
||||
CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
||||
}
|
||||
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
||||
val showNavigation = sessionReady && when (currentRoute) {
|
||||
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
||||
Route.Settings, Route.Compare -> false
|
||||
@ -321,28 +317,23 @@ class MainActivity : ComponentActivity() {
|
||||
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
|
||||
}
|
||||
}
|
||||
// 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 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
|
||||
}
|
||||
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
|
||||
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
|
||||
@ -405,23 +396,19 @@ class MainActivity : ComponentActivity() {
|
||||
null
|
||||
},
|
||||
) { isWide ->
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
// 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 { {
|
||||
val repositoryList: @Composable () -> Unit = {
|
||||
RepositoryListScreen(
|
||||
state = state,
|
||||
instanceHost = instanceHost,
|
||||
onSignIn = remember { viewModel::signIn },
|
||||
onCancelSignIn = remember { viewModel::cancelSignIn },
|
||||
onRetry = remember { viewModel::retry },
|
||||
onLogout = signOut,
|
||||
onRepositorySelected = remember { viewModel::selectRepository },
|
||||
onSignIn = viewModel::signIn,
|
||||
onCancelSignIn = viewModel::cancelSignIn,
|
||||
onRetry = viewModel::retry,
|
||||
onLogout = ::signOut,
|
||||
onRepositorySelected = viewModel::selectRepository,
|
||||
onReferenceSelected = { repository, name, kind ->
|
||||
viewModel.selectReference(name, kind)
|
||||
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
|
||||
@ -434,34 +421,51 @@ class MainActivity : ComponentActivity() {
|
||||
),
|
||||
)
|
||||
},
|
||||
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 },
|
||||
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,
|
||||
onViewWorkItems = { repository ->
|
||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
||||
},
|
||||
onSelectOrganization = remember { viewModel::selectOrganization },
|
||||
onSelectOrganization = viewModel::selectOrganization,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
)
|
||||
} }
|
||||
// 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.
|
||||
}
|
||||
// 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.
|
||||
val stackDepth = routeStack.size
|
||||
val shownDepth = remember { intArrayOf(stackDepth) }
|
||||
val shownReady = remember { booleanArrayOf(sessionReady) }
|
||||
SideEffect {
|
||||
shownDepth[0] = stackDepth
|
||||
shownReady[0] = sessionReady
|
||||
}
|
||||
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
|
||||
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 ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (route) {
|
||||
Route.Home -> HomeScreen(
|
||||
@ -489,52 +493,52 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
},
|
||||
onSelectSource = remember { homeViewModel::selectSource },
|
||||
onSelectFilter = remember { homeViewModel::selectFilter },
|
||||
onSetTodayOnly = remember { homeViewModel::setTodayOnly },
|
||||
onShowToday = remember { homeViewModel::showToday },
|
||||
onLoadMore = remember { homeViewModel::loadMore },
|
||||
onRefresh = remember { homeViewModel::refresh },
|
||||
onSelectSource = homeViewModel::selectSource,
|
||||
onSelectFilter = homeViewModel::selectFilter,
|
||||
onSetTodayOnly = homeViewModel::setTodayOnly,
|
||||
onShowToday = homeViewModel::showToday,
|
||||
onLoadMore = homeViewModel::loadMore,
|
||||
onRefresh = homeViewModel::refresh,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
onLogout = signOut,
|
||||
onAuthenticationExpired = signOut,
|
||||
onLogout = ::signOut,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
Route.Work -> WorkScreen(
|
||||
state = workState,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
onSelectScope = remember { workViewModel::selectScope },
|
||||
onSelectState = remember { workViewModel::selectState },
|
||||
onSelectType = remember { workViewModel::selectType },
|
||||
onSelectScope = workViewModel::selectScope,
|
||||
onSelectState = workViewModel::selectState,
|
||||
onSelectType = workViewModel::selectType,
|
||||
onItemSelected = { hit ->
|
||||
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
|
||||
},
|
||||
onNextPage = remember { workViewModel::nextPage },
|
||||
onPreviousPage = remember { workViewModel::previousPage },
|
||||
onRefresh = remember { workViewModel::refresh },
|
||||
onNextPage = workViewModel::nextPage,
|
||||
onPreviousPage = workViewModel::previousPage,
|
||||
onRefresh = workViewModel::refresh,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
onLogout = signOut,
|
||||
onAuthenticationExpired = signOut,
|
||||
onLogout = ::signOut,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
Route.Settings -> SettingsScreen(
|
||||
state = settingsState,
|
||||
onSetReactiveAuthRetryEnabled = remember { settingsViewModel::setReactiveAuthRetryEnabled },
|
||||
onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled },
|
||||
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
||||
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled },
|
||||
onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp },
|
||||
onSetThemeMode = remember { settingsViewModel::setThemeMode },
|
||||
onLogout = signOut,
|
||||
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
||||
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
||||
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||
onLogout = ::signOut,
|
||||
onBack = { pop() },
|
||||
update = updateState,
|
||||
onCheckUpdate = remember { updateViewModel::checkNow },
|
||||
onInstallUpdate = remember { ::startUpdate },
|
||||
onCheckUpdate = updateViewModel::checkNow,
|
||||
onInstallUpdate = ::startUpdate,
|
||||
)
|
||||
Route.Notifications -> NotificationsScreen(
|
||||
state = notificationsState,
|
||||
onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly },
|
||||
onShowAll = remember { notificationsViewModel::showAll },
|
||||
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
|
||||
onShowAll = notificationsViewModel::showAll,
|
||||
onNotificationSelected = { notification ->
|
||||
push(
|
||||
Route.WorkItems(
|
||||
@ -545,15 +549,15 @@ class MainActivity : ComponentActivity() {
|
||||
),
|
||||
)
|
||||
},
|
||||
onNextPage = remember { notificationsViewModel::nextPage },
|
||||
onPreviousPage = remember { notificationsViewModel::previousPage },
|
||||
onRetry = remember { notificationsViewModel::retry },
|
||||
onNextPage = notificationsViewModel::nextPage,
|
||||
onPreviousPage = notificationsViewModel::previousPage,
|
||||
onRetry = 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 = {
|
||||
@ -562,78 +566,65 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
WorkItemsScreen(
|
||||
state = workItemsState,
|
||||
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 },
|
||||
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,
|
||||
// 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 remember { workItemsViewModel::backToList },
|
||||
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
|
||||
onBack = leave,
|
||||
onAuthenticationExpired = signOut,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
}
|
||||
is Route.BranchDetail -> BranchDetailScreen(
|
||||
owner = route.owner,
|
||||
name = route.name,
|
||||
ref = route.ref,
|
||||
codeBrowserState = heldWhilePushedIn(codeBrowserState),
|
||||
codeBrowserState = codeBrowserState,
|
||||
codeViewerPreferences = codeViewerPreferences,
|
||||
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
||||
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
||||
onRetryCodeBrowser = remember { codeBrowserViewModel::retry },
|
||||
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
||||
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) 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,
|
||||
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,
|
||||
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 = remember { workbenchViewModel::swapSides },
|
||||
onClose = remember { workbenchViewModel::closeCompare },
|
||||
onSwap = workbenchViewModel::swapSides,
|
||||
onClose = 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) {
|
||||
@ -694,10 +685,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
)
|
||||
}
|
||||
if (confirmExit) {
|
||||
ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() })
|
||||
}
|
||||
SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null)
|
||||
SplashOverlay()
|
||||
}
|
||||
}
|
||||
consumeCallback(intent)
|
||||
|
||||
@ -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.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
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
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@ -86,7 +86,6 @@ 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
|
||||
@ -128,7 +127,7 @@ fun CodeBrowserScreen(
|
||||
if (state.entries.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
|
||||
} else {
|
||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
||||
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ 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
|
||||
@ -50,7 +51,6 @@ 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 {
|
||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||
LazyColumn(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
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "commit") {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@ -28,6 +28,7 @@ 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
|
||||
@ -62,12 +63,10 @@ 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
|
||||
@ -160,7 +159,7 @@ private fun HomeContent(
|
||||
LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
||||
}
|
||||
ScrollTopLazyColumn(
|
||||
LazyColumn(
|
||||
Modifier.weight(0.5f).fillMaxHeight(),
|
||||
state = timelineState,
|
||||
// Starts level with the headline card on the left.
|
||||
@ -170,7 +169,7 @@ private fun HomeContent(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||
}
|
||||
|
||||
@ -13,9 +13,11 @@ 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
|
||||
@ -41,10 +43,8 @@ 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,
|
||||
) {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
|
||||
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
|
||||
}
|
||||
|
||||
@ -17,12 +17,14 @@ 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
|
||||
@ -34,7 +36,6 @@ 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
|
||||
@ -62,16 +63,12 @@ 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.TabGlideMillis
|
||||
import com.ubisam.ubgitea.ui.components.TabPager
|
||||
import kotlinx.coroutines.delay
|
||||
import com.ubisam.ubgitea.ui.components.TabGlide
|
||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.components.UbiSamLogo
|
||||
@ -107,50 +104,8 @@ 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
|
||||
@ -172,6 +127,71 @@ 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 ||
|
||||
@ -187,123 +207,30 @@ fun RepositoryListScreen(
|
||||
onSecondaryAction = onLogout,
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class RepositoryDepth { LIST, REFERENCES }
|
||||
|
||||
@Composable
|
||||
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
|
||||
Column(
|
||||
@ -340,7 +267,7 @@ private fun RepositoryContent(
|
||||
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
|
||||
}
|
||||
TeaPullToRefreshBox(onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
||||
ScrollTopLazyColumn(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
|
||||
) {
|
||||
@ -606,7 +533,7 @@ private fun ReferencesContent(
|
||||
},
|
||||
)
|
||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
LazyColumn(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)) }
|
||||
|
||||
@ -185,10 +185,6 @@ 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
|
||||
@ -479,7 +475,6 @@ class RepositoryListViewModel(
|
||||
tokenVault.clear()
|
||||
repositoryListCache.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
cachedOrganizations = null
|
||||
@ -501,7 +496,6 @@ class RepositoryListViewModel(
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
}
|
||||
mutableState.value = RepositoryListUiState.Error(
|
||||
@ -533,7 +527,6 @@ class RepositoryListViewModel(
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
}
|
||||
val error = failure.toRepositoryListError()
|
||||
@ -570,10 +563,7 @@ 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 { summary ->
|
||||
val row = summary.toUiModel(alreadyStarred = summary.coordinates in alreadyStarred)
|
||||
knownRowBranches[row.coordinates]?.let { row.copy(lastPushedBranch = it) } ?: row
|
||||
}
|
||||
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) }
|
||||
if (rows.isEmpty() && page == 1) {
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
|
||||
@ -590,13 +580,12 @@ class RepositoryListViewModel(
|
||||
)
|
||||
repositoryContent = content
|
||||
mutableState.value = content
|
||||
loadRowBranches(content, all = true)
|
||||
loadRowBranches(content)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
}
|
||||
@ -742,9 +731,9 @@ class RepositoryListViewModel(
|
||||
return loaded
|
||||
}
|
||||
|
||||
private fun loadRowBranches(content: RepositoryListUiState.Content, all: Boolean = false) {
|
||||
private fun loadRowBranches(content: RepositoryListUiState.Content) {
|
||||
content.repositories
|
||||
.filter { all || it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
||||
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
||||
.forEach { launchRowBranchFetch(it.coordinates) }
|
||||
}
|
||||
|
||||
@ -756,21 +745,22 @@ class RepositoryListViewModel(
|
||||
val latest = branches
|
||||
.filter { it.commitAtEpochMillis != null }
|
||||
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
|
||||
val found = latest?.let { branch ->
|
||||
LastPushedBranchUiState.Loaded(branch.name.value)
|
||||
} ?: if (branches.isEmpty()) {
|
||||
LastPushedBranchUiState.Empty
|
||||
} else {
|
||||
LastPushedBranchUiState.Failed
|
||||
updateRow(coordinates) {
|
||||
it.copy(
|
||||
lastPushedBranch = 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 if (coordinates !in knownRowBranches) {
|
||||
// A check that fails leaves a branch already known where it is.
|
||||
} else {
|
||||
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
|
||||
}
|
||||
}
|
||||
@ -781,7 +771,6 @@ class RepositoryListViewModel(
|
||||
cancelRowBranchJobs()
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||
@ -841,7 +830,6 @@ class RepositoryListViewModel(
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
knownRowBranches.clear()
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||
|
||||
@ -15,6 +15,7 @@ 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
|
||||
@ -36,7 +37,6 @@ import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateUiState
|
||||
import com.ubisam.ubgitea.core.update.UpdateCheck
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.FilterChip
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
|
||||
@ -11,9 +11,11 @@ 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
|
||||
@ -41,10 +43,8 @@ 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()) {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
items(
|
||||
count = state.items.size,
|
||||
key = { index -> state.items[index].let { "${it.repository.owner}/${it.repository.name}#${it.number}" } },
|
||||
|
||||
@ -13,6 +13,7 @@ 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
|
||||
@ -67,7 +68,6 @@ 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 -> {
|
||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
LazyColumn(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 -> {
|
||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
LazyColumn(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) {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
LazyColumn(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) {
|
||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
LazyColumn(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)) {
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
package com.ubisam.ubgitea.ui.components
|
||||
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.FilterChip as MaterialFilterChip
|
||||
|
||||
/**
|
||||
* The app's filter chip. A selected chip is outlined in the brand colour so the choice is visible
|
||||
* on a white surface.
|
||||
*/
|
||||
@Composable
|
||||
fun FilterChip(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
label: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
MaterialFilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
label = label,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = enabled,
|
||||
selected = selected,
|
||||
selectedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
selectedBorderWidth = 1.5.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -178,13 +178,8 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.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)
|
||||
}
|
||||
}
|
||||
TeaCupLoader()
|
||||
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
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)
|
||||
@ -1,12 +1,7 @@
|
||||
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
|
||||
@ -14,12 +9,8 @@ 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
|
||||
@ -37,8 +28,6 @@ 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
|
||||
@ -47,17 +36,13 @@ 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
|
||||
@ -73,20 +58,15 @@ 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.
|
||||
@ -129,48 +109,14 @@ 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 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 }
|
||||
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 }
|
||||
}
|
||||
|
||||
@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()
|
||||
@ -183,7 +129,8 @@ fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color =
|
||||
*/
|
||||
@Composable
|
||||
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
|
||||
val sweep = rememberLoop(-1f, 2f, 1300, label = "shimmer")
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
|
||||
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
|
||||
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
|
||||
Column(
|
||||
@ -199,7 +146,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.
|
||||
@ -228,7 +175,13 @@ 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 bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob")
|
||||
val transition = rememberInfiniteTransition(label = "empty")
|
||||
val bob by transition.animateFloat(
|
||||
-1f,
|
||||
1f,
|
||||
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
||||
label = "bob",
|
||||
)
|
||||
val steam = rememberSteam()
|
||||
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
||||
Canvas(
|
||||
@ -236,8 +189,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()) }
|
||||
}
|
||||
@ -349,7 +302,6 @@ 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",
|
||||
@ -358,172 +310,16 @@ 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(settled: Boolean) {
|
||||
fun SplashOverlay() {
|
||||
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) {
|
||||
@ -531,11 +327,6 @@ fun SplashOverlay(settled: Boolean) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
package com.ubisam.ubgitea.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A list that, once it has been scrolled a long way down, offers a small round button in its
|
||||
* bottom corner that takes it back to the top. Takes what a LazyColumn takes.
|
||||
*/
|
||||
@Composable
|
||||
fun ScrollTopLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
Box(modifier) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state, contentPadding, verticalArrangement = verticalArrangement, content = content)
|
||||
ScrollToTopButton(state, Modifier.align(Alignment.BottomEnd).padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollToTopButton(state: LazyListState, modifier: Modifier = Modifier) {
|
||||
val far by remember(state) { derivedStateOf { state.firstVisibleItemIndex >= ScrollTopAfterRows } }
|
||||
val shown by animateFloatAsState(if (far) 1f else 0f, tween(160), label = "top")
|
||||
// Not composed at the top of a list, so it takes no taps and is not read out there.
|
||||
if (shown == 0f) return
|
||||
val scope = rememberCoroutineScope()
|
||||
Surface(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
// From very far down, start near the top: the glide stays short and still reads as one.
|
||||
if (state.firstVisibleItemIndex > ScrollTopGlideRows) state.scrollToItem(ScrollTopGlideRows)
|
||||
state.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
enabled = far,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shadowElevation = 3.dp,
|
||||
modifier = modifier.size(40.dp).graphicsLayer {
|
||||
alpha = shown
|
||||
scaleX = 0.8f + 0.2f * shown
|
||||
scaleY = 0.8f + 0.2f * shown
|
||||
},
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.shell_scroll_to_top), size = 22.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ScrollTopAfterRows = 8
|
||||
private const val ScrollTopGlideRows = 12
|
||||
@ -12,8 +12,4 @@
|
||||
<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>
|
||||
|
||||
@ -1615,156 +1615,3 @@ 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.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-069: In-app motion
|
||||
|
||||
Status: implemented; required checks pass; being adjusted from the product owner's look on a device
|
||||
Status: implemented; required checks pass; not yet seen on a device
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
@ -22,32 +22,6 @@ 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
|
||||
|
||||
@ -62,7 +36,6 @@ 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`
|
||||
@ -73,7 +46,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 fifteen items above is in the code and the app builds.
|
||||
- [x] Each of the nine 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
|
||||
|
||||
@ -48,8 +48,7 @@ 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 `assembleRelease`
|
||||
(the release build since 0.2.5, TASK-076; `assembleDebug` before that).
|
||||
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleDebug`.
|
||||
2. On the `apk` branch, replace `UBGitea.apk` and set `version.json` to the same version.
|
||||
3. Push the `apk` branch.
|
||||
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@ -1,47 +0,0 @@
|
||||
# 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.
|
||||
Loading…
x
Reference in New Issue
Block a user