Compare commits
10 Commits
0f96a8ed76
...
137480f30d
| Author | SHA1 | Date | |
|---|---|---|---|
| 137480f30d | |||
| ef47f0aef0 | |||
| cd8b1661fa | |||
| 4dab3a4ba4 | |||
| e1abdfc4ca | |||
| db8fc0574e | |||
| d9baf04a1d | |||
| a140b56aec | |||
| bb546421fc | |||
| a28feb1a47 |
@ -16,14 +16,17 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// Raised for every build published to the download page (TASK-066), or the app will not
|
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||||
// see it as newer.
|
// see it as newer.
|
||||||
versionCode = 6
|
versionCode = 7
|
||||||
versionName = "0.2.4"
|
versionName = "0.2.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
|||||||
@ -17,14 +17,6 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -48,7 +40,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
@ -94,7 +85,11 @@ import com.ubisam.ubgitea.feature.work.WorkViewModel
|
|||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
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.SplashOverlay
|
||||||
|
import com.ubisam.ubgitea.ui.components.TabPager
|
||||||
import com.ubisam.ubgitea.ui.navigation.Route
|
import com.ubisam.ubgitea.ui.navigation.Route
|
||||||
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
||||||
import com.ubisam.ubgitea.ui.shell.AppShell
|
import com.ubisam.ubgitea.ui.shell.AppShell
|
||||||
@ -268,19 +263,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
homeViewModel.close()
|
homeViewModel.close()
|
||||||
workViewModel.close()
|
workViewModel.close()
|
||||||
}
|
}
|
||||||
fun signOut() {
|
// Held, not made again on each pass: the screens are given these, and a screen given
|
||||||
closeFeatures()
|
// the same callbacks as before is left alone when something else here changes.
|
||||||
selectTab(AppTab.HOME)
|
val signOut: () -> Unit = remember {
|
||||||
viewModel.logout()
|
{
|
||||||
|
closeFeatures()
|
||||||
|
selectTab(AppTab.HOME)
|
||||||
|
viewModel.logout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun leaveBranchDetail() {
|
val leaveBranchDetail: () -> Unit = remember {
|
||||||
codeBrowserViewModel.close()
|
{
|
||||||
historyViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
pop()
|
historyViewModel.close()
|
||||||
|
pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var confirmExit by remember { mutableStateOf(false) }
|
||||||
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
||||||
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) {
|
||||||
|
CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
||||||
|
}
|
||||||
val showNavigation = sessionReady && when (currentRoute) {
|
val showNavigation = sessionReady && when (currentRoute) {
|
||||||
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
||||||
Route.Settings, Route.Compare -> false
|
Route.Settings, Route.Compare -> false
|
||||||
@ -317,23 +321,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
|
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val workbench = if (sessionReady) {
|
// Made again only when what it holds changes. Every screen is composed under it, and
|
||||||
CodeWorkbench(
|
// a new one, even one that says the same, has every screen on every tab drawn again:
|
||||||
tabs = workbenchState.tabs,
|
// that was a stall of several frames at the start of each tab change and each push.
|
||||||
pending = workbenchState.pending,
|
val workbench = remember(sessionReady, workbenchState.tabs, workbenchState.pending, workbenchState.fetching) {
|
||||||
fetching = workbenchState.fetching,
|
if (sessionReady) {
|
||||||
onToggleTab = workbenchViewModel::toggleTab,
|
CodeWorkbench(
|
||||||
onPin = workbenchViewModel::pin,
|
tabs = workbenchState.tabs,
|
||||||
onCompareWith = workbenchViewModel::compareWith,
|
pending = workbenchState.pending,
|
||||||
onClearPin = workbenchViewModel::clearPin,
|
fetching = workbenchState.fetching,
|
||||||
onUseCommitFile = { commitId, shortId, path, side ->
|
onToggleTab = workbenchViewModel::toggleTab,
|
||||||
(currentRoute as? Route.BranchDetail)?.let { route ->
|
onPin = workbenchViewModel::pin,
|
||||||
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
|
onCompareWith = workbenchViewModel::compareWith,
|
||||||
}
|
onClearPin = workbenchViewModel::clearPin,
|
||||||
},
|
onUseCommitFile = { commitId, shortId, path, side ->
|
||||||
)
|
(routeStack.last() as? Route.BranchDetail)?.let { route ->
|
||||||
} else {
|
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
|
||||||
null
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
|
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
|
||||||
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
|
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
|
||||||
@ -396,19 +405,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
) { isWide ->
|
) { isWide ->
|
||||||
// Back on any tab but Home goes Home; back on Home leaves the app.
|
// Back on any tab but Home goes Home; back on Home asks before leaving the
|
||||||
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
|
// app, since it is easily pressed once too often.
|
||||||
selectTab(AppTab.HOME)
|
BackHandler(enabled = sessionReady && currentRoute.isTab) {
|
||||||
|
if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME)
|
||||||
}
|
}
|
||||||
val repositoryList: @Composable () -> Unit = {
|
// The screens are built once and held, and read what changes from state. A
|
||||||
|
// tab press, which runs everything around them again, then leaves the four tab
|
||||||
|
// screens exactly as they were instead of drawing each of them again.
|
||||||
|
val repositoryList: @Composable () -> Unit = remember { {
|
||||||
RepositoryListScreen(
|
RepositoryListScreen(
|
||||||
state = state,
|
state = state,
|
||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onSignIn = viewModel::signIn,
|
onSignIn = remember { viewModel::signIn },
|
||||||
onCancelSignIn = viewModel::cancelSignIn,
|
onCancelSignIn = remember { viewModel::cancelSignIn },
|
||||||
onRetry = viewModel::retry,
|
onRetry = remember { viewModel::retry },
|
||||||
onLogout = ::signOut,
|
onLogout = signOut,
|
||||||
onRepositorySelected = viewModel::selectRepository,
|
onRepositorySelected = remember { viewModel::selectRepository },
|
||||||
onReferenceSelected = { repository, name, kind ->
|
onReferenceSelected = { repository, name, kind ->
|
||||||
viewModel.selectReference(name, kind)
|
viewModel.selectReference(name, kind)
|
||||||
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
|
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
|
||||||
@ -421,51 +434,34 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onReferencesRefresh = viewModel::refreshReferences,
|
onReferencesRefresh = remember { viewModel::refreshReferences },
|
||||||
onBackToRepositories = viewModel::backToRepositories,
|
onBackToRepositories = remember { viewModel::backToRepositories },
|
||||||
onNextPage = viewModel::nextPage,
|
onNextPage = remember { viewModel::nextPage },
|
||||||
onPreviousPage = viewModel::previousPage,
|
onPreviousPage = remember { viewModel::previousPage },
|
||||||
onRefreshRepositories = viewModel::refreshRepositories,
|
onRefreshRepositories = remember { viewModel::refreshRepositories },
|
||||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
onRetryLastPushedBranch = remember { viewModel::retryRowLastPushedBranch },
|
||||||
onToggleFavorite = viewModel::toggleFavorite,
|
onToggleFavorite = remember { viewModel::toggleFavorite },
|
||||||
onShowAllRepositories = viewModel::showAllRepositories,
|
onShowAllRepositories = remember { viewModel::showAllRepositories },
|
||||||
onShowFavorites = viewModel::showFavorites,
|
onShowFavorites = remember { viewModel::showFavorites },
|
||||||
onViewWorkItems = { repository ->
|
onViewWorkItems = { repository ->
|
||||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
||||||
},
|
},
|
||||||
onSelectOrganization = viewModel::selectOrganization,
|
onSelectOrganization = remember { viewModel::selectOrganization },
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
)
|
)
|
||||||
}
|
} }
|
||||||
// Entering a screen slides it in from the side it was opened towards, and a
|
// Opening a screen pushes it in from the right as the one behind leaves to the
|
||||||
// tab switch from the side that tab sits on in the bar. Keyed by the kind of
|
// left, and going back reverses that; it loads while it arrives. Keyed by
|
||||||
// screen, so moving between code tabs or pages of one screen does not replay it.
|
// the kind of screen, so moving between code tabs or pages of one screen does
|
||||||
|
// not replay it. The four tabs are one kind: between them the pager below glides.
|
||||||
val stackDepth = routeStack.size
|
val stackDepth = routeStack.size
|
||||||
val shownDepth = remember { intArrayOf(stackDepth) }
|
val shownDepth = remember { intArrayOf(stackDepth) }
|
||||||
SideEffect { shownDepth[0] = stackDepth }
|
val shownReady = remember { booleanArrayOf(sessionReady) }
|
||||||
AnimatedContent(
|
SideEffect {
|
||||||
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
shownDepth[0] = stackDepth
|
||||||
modifier = Modifier.fillMaxSize(),
|
shownReady[0] = sessionReady
|
||||||
transitionSpec = {
|
}
|
||||||
val fromTab = initialState.tab
|
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
|
||||||
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
when (route) {
|
when (route) {
|
||||||
Route.Home -> HomeScreen(
|
Route.Home -> HomeScreen(
|
||||||
@ -493,52 +489,52 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectSource = homeViewModel::selectSource,
|
onSelectSource = remember { homeViewModel::selectSource },
|
||||||
onSelectFilter = homeViewModel::selectFilter,
|
onSelectFilter = remember { homeViewModel::selectFilter },
|
||||||
onSetTodayOnly = homeViewModel::setTodayOnly,
|
onSetTodayOnly = remember { homeViewModel::setTodayOnly },
|
||||||
onShowToday = homeViewModel::showToday,
|
onShowToday = remember { homeViewModel::showToday },
|
||||||
onLoadMore = homeViewModel::loadMore,
|
onLoadMore = remember { homeViewModel::loadMore },
|
||||||
onRefresh = homeViewModel::refresh,
|
onRefresh = remember { homeViewModel::refresh },
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
onLogout = ::signOut,
|
onLogout = signOut,
|
||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = signOut,
|
||||||
)
|
)
|
||||||
Route.Work -> WorkScreen(
|
Route.Work -> WorkScreen(
|
||||||
state = workState,
|
state = workState,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onSelectScope = workViewModel::selectScope,
|
onSelectScope = remember { workViewModel::selectScope },
|
||||||
onSelectState = workViewModel::selectState,
|
onSelectState = remember { workViewModel::selectState },
|
||||||
onSelectType = workViewModel::selectType,
|
onSelectType = remember { workViewModel::selectType },
|
||||||
onItemSelected = { hit ->
|
onItemSelected = { hit ->
|
||||||
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
|
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
|
||||||
},
|
},
|
||||||
onNextPage = workViewModel::nextPage,
|
onNextPage = remember { workViewModel::nextPage },
|
||||||
onPreviousPage = workViewModel::previousPage,
|
onPreviousPage = remember { workViewModel::previousPage },
|
||||||
onRefresh = workViewModel::refresh,
|
onRefresh = remember { workViewModel::refresh },
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
onLogout = ::signOut,
|
onLogout = signOut,
|
||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = signOut,
|
||||||
)
|
)
|
||||||
Route.Settings -> SettingsScreen(
|
Route.Settings -> SettingsScreen(
|
||||||
state = settingsState,
|
state = settingsState,
|
||||||
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
onSetReactiveAuthRetryEnabled = remember { settingsViewModel::setReactiveAuthRetryEnabled },
|
||||||
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled },
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled },
|
||||||
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp },
|
||||||
onSetThemeMode = settingsViewModel::setThemeMode,
|
onSetThemeMode = remember { settingsViewModel::setThemeMode },
|
||||||
onLogout = ::signOut,
|
onLogout = signOut,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
update = updateState,
|
update = updateState,
|
||||||
onCheckUpdate = updateViewModel::checkNow,
|
onCheckUpdate = remember { updateViewModel::checkNow },
|
||||||
onInstallUpdate = ::startUpdate,
|
onInstallUpdate = remember { ::startUpdate },
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
|
onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly },
|
||||||
onShowAll = notificationsViewModel::showAll,
|
onShowAll = remember { notificationsViewModel::showAll },
|
||||||
onNotificationSelected = { notification ->
|
onNotificationSelected = { notification ->
|
||||||
push(
|
push(
|
||||||
Route.WorkItems(
|
Route.WorkItems(
|
||||||
@ -549,15 +545,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onNextPage = notificationsViewModel::nextPage,
|
onNextPage = remember { notificationsViewModel::nextPage },
|
||||||
onPreviousPage = notificationsViewModel::previousPage,
|
onPreviousPage = remember { notificationsViewModel::previousPage },
|
||||||
onRetry = notificationsViewModel::retry,
|
onRetry = remember { notificationsViewModel::retry },
|
||||||
onBack = { selectTab(AppTab.HOME) },
|
onBack = { selectTab(AppTab.HOME) },
|
||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = signOut,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
onLogout = ::signOut,
|
onLogout = signOut,
|
||||||
)
|
)
|
||||||
is Route.WorkItems -> {
|
is Route.WorkItems -> {
|
||||||
val leave = {
|
val leave = {
|
||||||
@ -566,65 +562,78 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
WorkItemsScreen(
|
WorkItemsScreen(
|
||||||
state = workItemsState,
|
state = workItemsState,
|
||||||
onShowIssues = workItemsViewModel::showIssues,
|
onShowIssues = remember { workItemsViewModel::showIssues },
|
||||||
onShowPullRequests = workItemsViewModel::showPullRequests,
|
onShowPullRequests = remember { workItemsViewModel::showPullRequests },
|
||||||
onIssueSelected = workItemsViewModel::openIssue,
|
onIssueSelected = remember { workItemsViewModel::openIssue },
|
||||||
onPullRequestSelected = workItemsViewModel::openPullRequest,
|
onPullRequestSelected = remember { workItemsViewModel::openPullRequest },
|
||||||
onNextPage = workItemsViewModel::nextPage,
|
onNextPage = remember { workItemsViewModel::nextPage },
|
||||||
onPreviousPage = workItemsViewModel::previousPage,
|
onPreviousPage = remember { workItemsViewModel::previousPage },
|
||||||
onGoToPage = workItemsViewModel::goToPage,
|
onGoToPage = remember { workItemsViewModel::goToPage },
|
||||||
onRetryList = workItemsViewModel::retryList,
|
onRetryList = remember { workItemsViewModel::retryList },
|
||||||
onRetryDetail = workItemsViewModel::retryDetail,
|
onRetryDetail = remember { workItemsViewModel::retryDetail },
|
||||||
// An item opened directly came from a list elsewhere (a search, the
|
// An item opened directly came from a list elsewhere (a search, the
|
||||||
// timeline, a notification), so back returns there, not to this
|
// timeline, a notification), so back returns there, not to this
|
||||||
// repository's own list.
|
// repository's own list.
|
||||||
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
|
onBackToList = if (route.number != null) leave else remember { workItemsViewModel::backToList },
|
||||||
onBack = leave,
|
onBack = leave,
|
||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = signOut,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Route.BranchDetail -> BranchDetailScreen(
|
is Route.BranchDetail -> BranchDetailScreen(
|
||||||
owner = route.owner,
|
owner = route.owner,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
ref = route.ref,
|
ref = route.ref,
|
||||||
codeBrowserState = codeBrowserState,
|
codeBrowserState = heldWhilePushedIn(codeBrowserState),
|
||||||
codeViewerPreferences = codeViewerPreferences,
|
codeViewerPreferences = codeViewerPreferences,
|
||||||
onEntrySelected = codeBrowserViewModel::openEntry,
|
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
||||||
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
||||||
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
onRetryCodeBrowser = remember { codeBrowserViewModel::retry },
|
||||||
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
||||||
onNavigateImage = codeBrowserViewModel::navigateImage,
|
onNavigateImage = remember { codeBrowserViewModel::navigateImage },
|
||||||
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
onSetCodeWordWrap = remember { settingsViewModel::setCodeWordWrapEnabled },
|
||||||
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
onSetCodeFontSize = remember { settingsViewModel::setCodeFontSizeSp },
|
||||||
onOpenSibling = codeBrowserViewModel::openSibling,
|
onOpenSibling = remember { codeBrowserViewModel::openSibling },
|
||||||
onToggleFolder = codeBrowserViewModel::toggleFolder,
|
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
|
||||||
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
|
||||||
historyState = historyState,
|
historyState = heldWhilePushedIn(historyState),
|
||||||
onCommitSelected = historyViewModel::openCommit,
|
onCommitSelected = remember { historyViewModel::openCommit },
|
||||||
onHistoryNextPage = historyViewModel::nextPage,
|
onHistoryNextPage = remember { historyViewModel::nextPage },
|
||||||
onHistoryPreviousPage = historyViewModel::previousPage,
|
onHistoryPreviousPage = remember { historyViewModel::previousPage },
|
||||||
onRetryHistoryList = historyViewModel::retryList,
|
onRetryHistoryList = remember { historyViewModel::retryList },
|
||||||
onRetryHistoryDetail = historyViewModel::retryDetail,
|
onRetryHistoryDetail = remember { historyViewModel::retryDetail },
|
||||||
onRetryPatch = historyViewModel::retryPatch,
|
onRetryPatch = remember { historyViewModel::retryPatch },
|
||||||
onFileSelected = historyViewModel::openFileDiff,
|
onFileSelected = remember { historyViewModel::openFileDiff },
|
||||||
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
|
onShowAdjacentFile = remember { historyViewModel::openAdjacentFileDiff },
|
||||||
onClearFileSelection = historyViewModel::clearFileSelection,
|
onClearFileSelection = remember { historyViewModel::clearFileSelection },
|
||||||
onHistoryBackToList = historyViewModel::backToList,
|
onHistoryBackToList = remember { historyViewModel::backToList },
|
||||||
onHistoryBack = ::leaveBranchDetail,
|
onHistoryBack = leaveBranchDetail,
|
||||||
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
|
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
|
||||||
onClose = ::leaveBranchDetail,
|
onClose = leaveBranchDetail,
|
||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = signOut,
|
||||||
)
|
)
|
||||||
Route.Repositories -> repositoryList()
|
Route.Repositories -> repositoryList()
|
||||||
Route.Compare -> CompareScreen(
|
Route.Compare -> CompareScreen(
|
||||||
state = workbenchState.compare,
|
state = workbenchState.compare,
|
||||||
fontSizeSp = settingsState.codeFontSizeSp,
|
fontSizeSp = settingsState.codeFontSizeSp,
|
||||||
onSwap = workbenchViewModel::swapSides,
|
onSwap = remember { workbenchViewModel::swapSides },
|
||||||
onClose = workbenchViewModel::closeCompare,
|
onClose = remember { workbenchViewModel::closeCompare },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} }
|
||||||
|
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
|
||||||
|
val target = currentRoute.takeIf { sessionReady } ?: Route.Repositories
|
||||||
|
val tabBelow = routeStack.lastOrNull { it.isTab }?.tab ?: AppTab.HOME
|
||||||
|
PushedOver(
|
||||||
|
above = target.takeUnless { sessionReady && it.isTab },
|
||||||
|
aboveKey = { it::class },
|
||||||
|
// The session has just been found: the first screen takes the waiting
|
||||||
|
// one's place where it stands. Nothing was navigated, so nothing slides.
|
||||||
|
direction = { if (shownReady[0]) (stackDepth - shownDepth[0]).coerceIn(-1, 1) else 0 },
|
||||||
|
// Tabs sit side by side: going from one to another passes the ones between.
|
||||||
|
below = if (sessionReady) ({ TabPager(AppTab.entries, tabBelow, content = tabScreen) }) else null,
|
||||||
|
content = screen,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
@ -685,7 +694,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SplashOverlay()
|
if (confirmExit) {
|
||||||
|
ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() })
|
||||||
|
}
|
||||||
|
SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
consumeCallback(intent)
|
consumeCallback(intent)
|
||||||
|
|||||||
@ -25,19 +25,19 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
@ -86,6 +86,7 @@ import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
|||||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
|
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
|
||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@ -127,7 +128,7 @@ fun CodeBrowserScreen(
|
|||||||
if (state.entries.isEmpty()) {
|
if (state.entries.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
|
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||||
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
||||||
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
|
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -51,6 +50,7 @@ import com.ubisam.ubgitea.ui.components.CenteredMessage
|
|||||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||||
import com.ubisam.ubgitea.ui.components.ListCard
|
import com.ubisam.ubgitea.ui.components.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
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.SectionTitle
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.formatInstant
|
import com.ubisam.ubgitea.ui.formatInstant
|
||||||
@ -89,7 +89,7 @@ fun HistoryScreen(
|
|||||||
if (state.commits.isEmpty()) {
|
if (state.commits.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.history_empty), decoration = { EmptyCup() })
|
CenteredMessage(stringResource(R.string.history_empty), decoration = { EmptyCup() })
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||||
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
||||||
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
|
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
|
||||||
}
|
}
|
||||||
@ -177,7 +177,7 @@ private fun CommitDetailView(
|
|||||||
onFileSelected: (FileChange) -> Unit,
|
onFileSelected: (FileChange) -> Unit,
|
||||||
) {
|
) {
|
||||||
val canOpenFileDiff = state.patch is PatchUiState.Loaded
|
val canOpenFileDiff = state.patch is PatchUiState.Loaded
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "commit") {
|
item(key = "commit") {
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -63,10 +62,12 @@ import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
|||||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
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.KindTile
|
||||||
import com.ubisam.ubgitea.ui.components.ListCard
|
import com.ubisam.ubgitea.ui.components.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
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.SectionTitle
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||||
@ -159,7 +160,7 @@ private fun HomeContent(
|
|||||||
LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
||||||
}
|
}
|
||||||
LazyColumn(
|
ScrollTopLazyColumn(
|
||||||
Modifier.weight(0.5f).fillMaxHeight(),
|
Modifier.weight(0.5f).fillMaxHeight(),
|
||||||
state = timelineState,
|
state = timelineState,
|
||||||
// Starts level with the headline card on the left.
|
// Starts level with the headline card on the left.
|
||||||
@ -169,7 +170,7 @@ private fun HomeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
||||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -43,8 +41,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
|
|||||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
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.KindTile
|
||||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
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.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.formatRelative
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
@ -120,7 +120,7 @@ private fun NotificationsList(
|
|||||||
onNextPage: () -> Unit,
|
onNextPage: () -> Unit,
|
||||||
onPreviousPage: () -> Unit,
|
onPreviousPage: () -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
|
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
|
||||||
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
|
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,14 +17,12 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -36,6 +34,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@ -63,12 +62,16 @@ import com.ubisam.ubgitea.ui.components.CautionBanner
|
|||||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
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.ListSkeleton
|
||||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
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.SectionTitle
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabGlide
|
import com.ubisam.ubgitea.ui.components.TabGlideMillis
|
||||||
|
import com.ubisam.ubgitea.ui.components.TabPager
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
import com.ubisam.ubgitea.ui.components.UbiSamLogo
|
import com.ubisam.ubgitea.ui.components.UbiSamLogo
|
||||||
@ -104,8 +107,50 @@ fun RepositoryListScreen(
|
|||||||
// Changing tab reloads through Loading, which does not say which tab it is loading. Keeping
|
// 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.
|
// 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) }
|
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) {
|
LaunchedEffect(state) {
|
||||||
if (state != RepositoryListUiState.Loading) requestedMode = null
|
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 = {
|
val showAllRepositories = {
|
||||||
requestedMode = RepositoryListMode.ALL
|
requestedMode = RepositoryListMode.ALL
|
||||||
@ -127,71 +172,6 @@ fun RepositoryListScreen(
|
|||||||
stringResource(R.string.repositories_cancel_login),
|
stringResource(R.string.repositories_cancel_login),
|
||||||
onCancelSignIn,
|
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 -> {
|
is RepositoryListUiState.Error -> {
|
||||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||||
@ -207,30 +187,123 @@ fun RepositoryListScreen(
|
|||||||
onSecondaryAction = onLogout,
|
onSecondaryAction = onLogout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.ReferencesLoading -> {
|
RepositoryListUiState.Loading,
|
||||||
BackHandler(onBack = onBackToRepositories)
|
is RepositoryListUiState.Empty,
|
||||||
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
is RepositoryListUiState.Content,
|
||||||
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
is RepositoryListUiState.ReferencesLoading,
|
||||||
}
|
is RepositoryListUiState.ReferencesContent,
|
||||||
is RepositoryListUiState.ReferencesContent -> {
|
is RepositoryListUiState.ReferencesError -> TabPager(
|
||||||
BackHandler(onBack = onBackToRepositories)
|
RepositoryDepth.entries,
|
||||||
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
if (inReferences) RepositoryDepth.REFERENCES else RepositoryDepth.LIST,
|
||||||
}
|
) { depth ->
|
||||||
is RepositoryListUiState.ReferencesError -> {
|
Column(Modifier.fillMaxSize()) {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
if (depth == RepositoryDepth.LIST) {
|
||||||
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
val listState = if (inReferences) seenMode?.let { seen[it] } ?: RepositoryListUiState.Loading else state
|
||||||
CenteredMessage(
|
val loadingMode = requestedMode ?: seenMode
|
||||||
stringResource(state.type.messageResource),
|
val shownState = if (listState == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: listState else listState
|
||||||
action = stringResource(R.string.repositories_retry),
|
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
onAction = onRetry,
|
val mode = when (shownState) {
|
||||||
secondaryAction = stringResource(R.string.repositories_back),
|
is RepositoryListUiState.Content -> shownState.mode
|
||||||
onSecondaryAction = onBackToRepositories,
|
is RepositoryListUiState.Empty -> shownState.mode
|
||||||
)
|
else -> loadingMode
|
||||||
|
}
|
||||||
|
if (mode == null) {
|
||||||
|
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
||||||
|
} else {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// No title: the selected tab already says this is 저장소.
|
||||||
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
|
ModeTabs(mode, showAllRepositories, showFavorites)
|
||||||
|
if (shownState is RepositoryListUiState.Content &&
|
||||||
|
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
|
||||||
|
) {
|
||||||
|
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both lists stay composed side by side, so changing tab only slides: building
|
||||||
|
// a list of rows in the frame the slide starts on is what made it stall.
|
||||||
|
TabPager(RepositoryListMode.entries, mode, Modifier.weight(1f).fillMaxWidth()) { page ->
|
||||||
|
val shown = if (page == mode) shownState else seen[page] ?: RepositoryListUiState.Loading
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
when (shown) {
|
||||||
|
is RepositoryListUiState.Content -> RepositoryContent(
|
||||||
|
shown,
|
||||||
|
onRepositorySelected,
|
||||||
|
onNextPage,
|
||||||
|
onPreviousPage,
|
||||||
|
onRefreshRepositories,
|
||||||
|
onRetryLastPushedBranch,
|
||||||
|
showAllRepositories,
|
||||||
|
showFavorites,
|
||||||
|
onToggleFavorite,
|
||||||
|
)
|
||||||
|
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
|
||||||
|
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
|
||||||
|
// above this message) is very likely non-empty. The only sensible primary
|
||||||
|
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
|
||||||
|
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
|
||||||
|
CenteredMessage(
|
||||||
|
stringResource(R.string.repositories_favorites_empty),
|
||||||
|
action = stringResource(R.string.repositories_favorites_empty_action),
|
||||||
|
onAction = showAllRepositories,
|
||||||
|
decoration = { EmptyCup() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CenteredMessage(
|
||||||
|
stringResource(R.string.repositories_empty_account, shown.accountName),
|
||||||
|
action = stringResource(R.string.repositories_logout),
|
||||||
|
onAction = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val arrived = if (inReferences) state else lastReferences
|
||||||
|
val shown = if (slidingIn && arrived is RepositoryListUiState.ReferencesContent) {
|
||||||
|
RepositoryListUiState.ReferencesLoading(arrived.accountName, arrived.repository)
|
||||||
|
} else {
|
||||||
|
arrived
|
||||||
|
}
|
||||||
|
when (shown) {
|
||||||
|
is RepositoryListUiState.ReferencesLoading -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
|
||||||
|
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
||||||
|
}
|
||||||
|
is RepositoryListUiState.ReferencesContent -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
ReferencesContent(shown, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
||||||
|
}
|
||||||
|
is RepositoryListUiState.ReferencesError -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
|
||||||
|
CenteredMessage(
|
||||||
|
stringResource(shown.type.messageResource),
|
||||||
|
action = stringResource(R.string.repositories_retry),
|
||||||
|
onAction = onRetry,
|
||||||
|
secondaryAction = stringResource(R.string.repositories_back),
|
||||||
|
onSecondaryAction = onBackToRepositories,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class RepositoryDepth { LIST, REFERENCES }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
|
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
@ -267,7 +340,7 @@ private fun RepositoryContent(
|
|||||||
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
|
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
|
||||||
}
|
}
|
||||||
TeaPullToRefreshBox(onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
TeaPullToRefreshBox(onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
ScrollTopLazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||||
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
|
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
@ -533,7 +606,7 @@ private fun ReferencesContent(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
|
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
|
||||||
if (state.branches.isEmpty()) {
|
if (state.branches.isEmpty()) {
|
||||||
item(key = "branches-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_branches_empty)) }
|
item(key = "branches-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_branches_empty)) }
|
||||||
|
|||||||
@ -185,6 +185,10 @@ class RepositoryListViewModel(
|
|||||||
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
|
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
|
||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
|
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
|
// 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
|
// 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
|
// quick double tap sent a star and an unstar together, and whichever reached the server last
|
||||||
@ -475,6 +479,7 @@ class RepositoryListViewModel(
|
|||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
repositoryListCache.clear()
|
repositoryListCache.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
cachedOrganizations = null
|
cachedOrganizations = null
|
||||||
@ -496,6 +501,7 @@ class RepositoryListViewModel(
|
|||||||
if (failure is ApiFailure.AuthenticationExpired) {
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
}
|
}
|
||||||
mutableState.value = RepositoryListUiState.Error(
|
mutableState.value = RepositoryListUiState.Error(
|
||||||
@ -527,6 +533,7 @@ class RepositoryListViewModel(
|
|||||||
if (failure is ApiFailure.AuthenticationExpired) {
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
}
|
}
|
||||||
val error = failure.toRepositoryListError()
|
val error = failure.toRepositoryListError()
|
||||||
@ -563,7 +570,10 @@ class RepositoryListViewModel(
|
|||||||
// showing) -- FavoriteStar's own rendering already handles that mode without consulting
|
// showing) -- FavoriteStar's own rendering already handles that mode without consulting
|
||||||
// favoriteAction at all, so the cross-reference below only matters for ALL.
|
// favoriteAction at all, so the cross-reference below only matters for ALL.
|
||||||
val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet()
|
val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet()
|
||||||
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) }
|
val rows = pageItems.map { summary ->
|
||||||
|
val row = summary.toUiModel(alreadyStarred = summary.coordinates in alreadyStarred)
|
||||||
|
knownRowBranches[row.coordinates]?.let { row.copy(lastPushedBranch = it) } ?: row
|
||||||
|
}
|
||||||
if (rows.isEmpty() && page == 1) {
|
if (rows.isEmpty() && page == 1) {
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
|
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
|
||||||
@ -580,12 +590,13 @@ class RepositoryListViewModel(
|
|||||||
)
|
)
|
||||||
repositoryContent = content
|
repositoryContent = content
|
||||||
mutableState.value = content
|
mutableState.value = content
|
||||||
loadRowBranches(content)
|
loadRowBranches(content, all = true)
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
if (failure is CancellationException) throw failure
|
if (failure is CancellationException) throw failure
|
||||||
if (failure is ApiFailure.AuthenticationExpired) {
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
}
|
}
|
||||||
@ -731,9 +742,9 @@ class RepositoryListViewModel(
|
|||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadRowBranches(content: RepositoryListUiState.Content) {
|
private fun loadRowBranches(content: RepositoryListUiState.Content, all: Boolean = false) {
|
||||||
content.repositories
|
content.repositories
|
||||||
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
.filter { all || it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
||||||
.forEach { launchRowBranchFetch(it.coordinates) }
|
.forEach { launchRowBranchFetch(it.coordinates) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -745,22 +756,21 @@ class RepositoryListViewModel(
|
|||||||
val latest = branches
|
val latest = branches
|
||||||
.filter { it.commitAtEpochMillis != null }
|
.filter { it.commitAtEpochMillis != null }
|
||||||
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
|
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
|
||||||
updateRow(coordinates) {
|
val found = latest?.let { branch ->
|
||||||
it.copy(
|
LastPushedBranchUiState.Loaded(branch.name.value)
|
||||||
lastPushedBranch = latest?.let { branch ->
|
} ?: if (branches.isEmpty()) {
|
||||||
LastPushedBranchUiState.Loaded(branch.name.value)
|
LastPushedBranchUiState.Empty
|
||||||
} ?: if (branches.isEmpty()) {
|
} else {
|
||||||
LastPushedBranchUiState.Empty
|
LastPushedBranchUiState.Failed
|
||||||
} else {
|
|
||||||
LastPushedBranchUiState.Failed
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (found != LastPushedBranchUiState.Failed) knownRowBranches[coordinates] = found
|
||||||
|
updateRow(coordinates) { it.copy(lastPushedBranch = found) }
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
if (failure is CancellationException) throw failure
|
if (failure is CancellationException) throw failure
|
||||||
if (failure is ApiFailure.AuthenticationExpired) {
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
clearSessionOnAuthenticationExpired()
|
clearSessionOnAuthenticationExpired()
|
||||||
} else {
|
} else if (coordinates !in knownRowBranches) {
|
||||||
|
// A check that fails leaves a branch already known where it is.
|
||||||
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
|
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -771,6 +781,7 @@ class RepositoryListViewModel(
|
|||||||
cancelRowBranchJobs()
|
cancelRowBranchJobs()
|
||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||||
@ -830,6 +841,7 @@ class RepositoryListViewModel(
|
|||||||
if (failure is ApiFailure.AuthenticationExpired) {
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
|
knownRowBranches.clear()
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@ -37,6 +36,7 @@ import com.ubisam.ubgitea.core.settings.ThemeMode
|
|||||||
import com.ubisam.ubgitea.core.update.AppUpdateUiState
|
import com.ubisam.ubgitea.core.update.AppUpdateUiState
|
||||||
import com.ubisam.ubgitea.core.update.UpdateCheck
|
import com.ubisam.ubgitea.core.update.UpdateCheck
|
||||||
import com.ubisam.ubgitea.ui.components.Avatar
|
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.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||||
|
|||||||
@ -11,11 +11,9 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -43,8 +41,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
|
|||||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
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.KindTile
|
||||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
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.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
@ -147,7 +147,7 @@ private fun WorkList(
|
|||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
items(
|
items(
|
||||||
count = state.items.size,
|
count = state.items.size,
|
||||||
key = { index -> state.items[index].let { "${it.repository.owner}/${it.repository.name}#${it.number}" } },
|
key = { index -> state.items[index].let { "${it.repository.owner}/${it.repository.name}#${it.number}" } },
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -68,6 +67,7 @@ import com.ubisam.ubgitea.ui.components.ListCard
|
|||||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
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.SectionTitle
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabGlide
|
import com.ubisam.ubgitea.ui.components.TabGlide
|
||||||
@ -183,7 +183,7 @@ private fun ColumnScope.ListBody(
|
|||||||
ListSkeleton()
|
ListSkeleton()
|
||||||
}
|
}
|
||||||
is WorkItemsUiState.IssueList -> {
|
is WorkItemsUiState.IssueList -> {
|
||||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
|
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
|
||||||
WorkItemRow(
|
WorkItemRow(
|
||||||
icon = R.drawable.ic_ub_issue,
|
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)
|
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||||
}
|
}
|
||||||
is WorkItemsUiState.PullRequestList -> {
|
is WorkItemsUiState.PullRequestList -> {
|
||||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
|
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
|
||||||
WorkItemRow(
|
WorkItemRow(
|
||||||
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
|
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
|
@Composable
|
||||||
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "header") {
|
item(key = "header") {
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
@ -395,7 +395,7 @@ private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
|
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "header") {
|
item(key = "header") {
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
|||||||
39
app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt
Normal file
39
app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material3.FilterChip as MaterialFilterChip
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's filter chip. A selected chip is outlined in the brand colour so the choice is visible
|
||||||
|
* on a white surface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FilterChip(
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
label: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIcon: (@Composable () -> Unit)? = null,
|
||||||
|
trailingIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
MaterialFilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onClick,
|
||||||
|
label = label,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
trailingIcon = trailingIcon,
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
enabled = enabled,
|
||||||
|
selected = selected,
|
||||||
|
selectedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedBorderWidth = 1.5.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -178,8 +178,13 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TeaCupLoader()
|
// Most loads finish before this would be noticed; the cup is for the ones that do not.
|
||||||
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
AfterShortWait {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
TeaCupLoader()
|
||||||
|
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.ubisam.ubgitea.R
|
||||||
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Leave the app?" confirmation shown when Back is pressed on the Home tab. Staying is the default:
|
||||||
|
* Back, a tap outside and the emphasised "계속 사용" button all call [onStay]; only "종료" calls [onExit].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ExitConfirmDialog(onStay: () -> Unit, onExit: () -> Unit) {
|
||||||
|
// Entrance: the card grows from 0.92 to 1 while fading in.
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(Unit) { progress.animateTo(1f, tween180) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onStay) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 340.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
val scale = 0.92f + 0.08f * progress.value
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.alpha(progress.value),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(84.dp)
|
||||||
|
.background(UBGiteaTheme.colors.brand.copy(alpha = 0.12f), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
TeaCupLoader(size = 40.dp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.shell_exit_title),
|
||||||
|
modifier = Modifier.padding(top = 20.dp),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onExit,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.shell_exit_confirm))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onStay,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.shell_exit_stay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val tween180 = androidx.compose.animation.core.tween<Float>(durationMillis = 180)
|
||||||
@ -1,7 +1,12 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
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.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Easing
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@ -9,8 +14,12 @@ import androidx.compose.animation.core.Spring
|
|||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
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.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@ -28,6 +37,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -36,13 +47,17 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -58,15 +73,20 @@ import androidx.compose.ui.graphics.StrokeJoin
|
|||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlinx.coroutines.delay
|
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
|
// 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.
|
// thing from the home screen inwards.
|
||||||
@ -109,14 +129,48 @@ private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether what is composed here can be seen. A tab kept composed beside the one shown is not, and
|
||||||
|
* nothing in it should keep the display redrawing: looping motion rests while this is false.
|
||||||
|
*/
|
||||||
|
val LocalOnScreen = compositionLocalOf { true }
|
||||||
|
|
||||||
|
/** A value going round from [from] to [to] for as long as it can be seen; [from] when it cannot. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberSteam(): () -> Float {
|
private fun rememberLoop(
|
||||||
val transition = rememberInfiniteTransition(label = "steam")
|
from: Float,
|
||||||
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
|
to: Float,
|
||||||
return { steam }
|
millis: Int,
|
||||||
|
easing: Easing = LinearEasing,
|
||||||
|
repeatMode: RepeatMode = RepeatMode.Restart,
|
||||||
|
label: String,
|
||||||
|
): () -> Float {
|
||||||
|
if (!LocalOnScreen.current) return { from }
|
||||||
|
val transition = rememberInfiniteTransition(label = label)
|
||||||
|
val value by transition.animateFloat(from, to, infiniteRepeatable(tween(millis, easing = easing), repeatMode), label = label)
|
||||||
|
return { value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSteam(): () -> Float = rememberLoop(0f, 1f, 1600, label = "steam")
|
||||||
|
|
||||||
/** The tea cup with rising steam: the app's "working on it" mark. */
|
/** 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
|
@Composable
|
||||||
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
|
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
|
||||||
val steam = rememberSteam()
|
val steam = rememberSteam()
|
||||||
@ -129,8 +183,7 @@ fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color =
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
|
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
|
||||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
val sweep = rememberLoop(-1f, 2f, 1300, 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 base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
|
||||||
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
|
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
|
||||||
Column(
|
Column(
|
||||||
@ -146,7 +199,7 @@ fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? =
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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))
|
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Rows differ in length so the block does not read as a table.
|
// Rows differ in length so the block does not read as a table.
|
||||||
@ -175,13 +228,7 @@ private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float):
|
|||||||
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
|
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyCup(modifier: Modifier = Modifier) {
|
fun EmptyCup(modifier: Modifier = Modifier) {
|
||||||
val transition = rememberInfiniteTransition(label = "empty")
|
val bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob")
|
||||||
val bob by transition.animateFloat(
|
|
||||||
-1f,
|
|
||||||
1f,
|
|
||||||
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
|
||||||
label = "bob",
|
|
||||||
)
|
|
||||||
val steam = rememberSteam()
|
val steam = rememberSteam()
|
||||||
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
||||||
Canvas(
|
Canvas(
|
||||||
@ -189,8 +236,8 @@ fun EmptyCup(modifier: Modifier = Modifier) {
|
|||||||
.padding(bottom = 20.dp)
|
.padding(bottom = 20.dp)
|
||||||
.size(56.dp, 67.dp)
|
.size(56.dp, 67.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationY = bob * 3.dp.toPx()
|
translationY = bob() * 3.dp.toPx()
|
||||||
rotationZ = bob * 2f
|
rotationZ = bob() * 2f
|
||||||
},
|
},
|
||||||
) { drawTeaCup(color, steam()) }
|
) { drawTeaCup(color, steam()) }
|
||||||
}
|
}
|
||||||
@ -302,6 +349,7 @@ fun <S> TabGlide(
|
|||||||
val glide = tween<IntOffset>(TabGlideMillis, easing = FastOutSlowInEasing)
|
val glide = tween<IntOffset>(TabGlideMillis, easing = FastOutSlowInEasing)
|
||||||
slideInHorizontally(glide) { it * direction }
|
slideInHorizontally(glide) { it * direction }
|
||||||
.togetherWith(slideOutHorizontally(glide) { -it * direction })
|
.togetherWith(slideOutHorizontally(glide) { -it * direction })
|
||||||
|
.using(NoResize)
|
||||||
},
|
},
|
||||||
contentKey = tabIndex,
|
contentKey = tabIndex,
|
||||||
label = "tab",
|
label = "tab",
|
||||||
@ -310,16 +358,172 @@ fun <S> TabGlide(
|
|||||||
|
|
||||||
const val TabGlideMillis = 320
|
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))
|
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
|
* 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.
|
* 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
|
@Composable
|
||||||
fun SplashOverlay() {
|
fun SplashOverlay(settled: Boolean) {
|
||||||
var finished by rememberSaveable { mutableStateOf(false) }
|
var finished by rememberSaveable { mutableStateOf(false) }
|
||||||
if (finished) return
|
if (finished) return
|
||||||
|
val settledNow by rememberUpdatedState(settled)
|
||||||
val alpha = remember { Animatable(1f) }
|
val alpha = remember { Animatable(1f) }
|
||||||
val scale = remember { Animatable(0.85f) }
|
val scale = remember { Animatable(0.85f) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@ -327,6 +531,11 @@ fun SplashOverlay() {
|
|||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(1100)
|
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))
|
alpha.animateTo(0f, tween(280))
|
||||||
finished = true
|
finished = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ubisam.ubgitea.R
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list that, once it has been scrolled a long way down, offers a small round button in its
|
||||||
|
* bottom corner that takes it back to the top. Takes what a LazyColumn takes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollTopLazyColumn(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: LazyListState = rememberLazyListState(),
|
||||||
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||||
|
content: LazyListScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Box(modifier) {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), state, contentPadding, verticalArrangement = verticalArrangement, content = content)
|
||||||
|
ScrollToTopButton(state, Modifier.align(Alignment.BottomEnd).padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScrollToTopButton(state: LazyListState, modifier: Modifier = Modifier) {
|
||||||
|
val far by remember(state) { derivedStateOf { state.firstVisibleItemIndex >= ScrollTopAfterRows } }
|
||||||
|
val shown by animateFloatAsState(if (far) 1f else 0f, tween(160), label = "top")
|
||||||
|
// Not composed at the top of a list, so it takes no taps and is not read out there.
|
||||||
|
if (shown == 0f) return
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
// From very far down, start near the top: the glide stays short and still reads as one.
|
||||||
|
if (state.firstVisibleItemIndex > ScrollTopGlideRows) state.scrollToItem(ScrollTopGlideRows)
|
||||||
|
state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = far,
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
shadowElevation = 3.dp,
|
||||||
|
modifier = modifier.size(40.dp).graphicsLayer {
|
||||||
|
alpha = shown
|
||||||
|
scaleX = 0.8f + 0.2f * shown
|
||||||
|
scaleY = 0.8f + 0.2f * shown
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.shell_scroll_to_top), size = 22.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ScrollTopAfterRows = 8
|
||||||
|
private const val ScrollTopGlideRows = 12
|
||||||
@ -12,4 +12,8 @@
|
|||||||
<string name="time_hours_ago">%1$d시간 전</string>
|
<string name="time_hours_ago">%1$d시간 전</string>
|
||||||
<string name="time_yesterday">어제</string>
|
<string name="time_yesterday">어제</string>
|
||||||
<string name="time_days_ago">%1$d일 전</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>
|
</resources>
|
||||||
|
|||||||
@ -1615,3 +1615,156 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
- Caution: published without having been seen on a device, at the product owner's decision.
|
- 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
|
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.
|
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
|
# TASK-069: In-app motion
|
||||||
|
|
||||||
Status: implemented; required checks pass; not yet seen on a device
|
Status: implemented; required checks pass; being adjusted from the product owner's look on a device
|
||||||
Owner: Claude
|
Owner: Claude
|
||||||
Reviewer: human product owner
|
Reviewer: human product owner
|
||||||
|
|
||||||
@ -22,6 +22,32 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
|||||||
9. Added after that (product owner, 2026-10-12): the tabs inside a screen glide the same way.
|
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
|
전체 저장소/즐겨찾기 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.
|
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
|
## Scope
|
||||||
|
|
||||||
@ -36,6 +62,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
|||||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.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/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/work/WorkScreen.kt`
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
|
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
|
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
|
||||||
@ -46,7 +73,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] Each of the nine items above is in the code and the app builds.
|
- [x] Each of the fifteen items above is in the code and the app builds.
|
||||||
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
|
- [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.
|
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
|
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen
|
||||||
|
|||||||
@ -48,7 +48,8 @@ repository being public was the product owner's choice in TASK-067.
|
|||||||
|
|
||||||
## Publishing a build from now on
|
## Publishing a build from now on
|
||||||
|
|
||||||
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleDebug`.
|
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleRelease`
|
||||||
|
(the release build since 0.2.5, TASK-076; `assembleDebug` before that).
|
||||||
2. On the `apk` branch, replace `UBGitea.apk` and set `version.json` to the same version.
|
2. On the `apk` branch, replace `UBGitea.apk` and set `version.json` to the same version.
|
||||||
3. Push the `apk` branch.
|
3. Push the `apk` branch.
|
||||||
|
|
||||||
|
|||||||
60
tasks/075-small-interaction-fixes.md
Normal file
60
tasks/075-small-interaction-fixes.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# TASK-075: Small interaction fixes from the product owner's first look
|
||||||
|
|
||||||
|
Status: implemented; required checks pass; installed on the product owner's device for a look
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
1. A selected filter chip is outlined in the brand colour, everywhere chips are used.
|
||||||
|
2. A short load shows no tea cup: the cup appears only after 450 ms of waiting, so opening a
|
||||||
|
repository from 활동 중인 저장소 goes straight to the code when it loads quickly.
|
||||||
|
3. Back on 홈 asks before leaving the app (계속 사용 is the default; 종료 leaves), with the
|
||||||
|
question alone: no explanatory line under it.
|
||||||
|
4. A list scrolled a long way down offers a small round button that returns it to the top.
|
||||||
|
5. Screens that move sideways no longer drift diagonally: `AnimatedContent` is told not to animate
|
||||||
|
its own size (it does so from the top left corner).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: the five items above.
|
||||||
|
- Excluded: code, diff and compare panes (they keep their own scrolling); the loading skeletons.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/**/*Screen.kt`
|
||||||
|
- `app/src/main/res/values/strings_shell.xml`
|
||||||
|
- `tasks/075-small-interaction-fixes.md`
|
||||||
|
- `docs/development-log.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Each of the five items is in the code and the app builds.
|
||||||
|
- [x] Chip call sites are unchanged: only the import differs.
|
||||||
|
- [x] The exit question closes on Back or a tap outside, staying in the app.
|
||||||
|
- [x] The top button is not composed while a list is near its top.
|
||||||
|
- [ ] Seen on the product owner's device.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
No additional concerns. No network, storage or credential code is touched.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
See the development log entry for TASK-075.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None yet.
|
||||||
47
tasks/076-publish-0.2.5.md
Normal file
47
tasks/076-publish-0.2.5.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# TASK-076: Publish 0.2.5 to the team's Gitea, as the release build
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
The product owner asked for the work so far (TASK-069's motion, TASK-075's small fixes) to be
|
||||||
|
published as a new version, and for the release build to be the one published (2026-10-12).
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/build.gradle.kts`
|
||||||
|
- `tasks/076-publish-0.2.5.md`
|
||||||
|
- `tasks/070-update-from-gitea.md`
|
||||||
|
- `docs/development-log.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Version 0.2.5, versionCode 7.
|
||||||
|
- [x] `assembleRelease` gives an installable APK signed with the key the published builds have
|
||||||
|
had, so it installs over them.
|
||||||
|
- [x] The source is on `main` of the team's repository.
|
||||||
|
- [x] `UBGitea.apk` and `version.json` on the `apk` branch are 0.2.5, and the downloaded APK is
|
||||||
|
byte-identical to the one built here.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
The APK is now the release build: not debuggable, and the debug-only request logging is off. It
|
||||||
|
is still signed with this PC's debug key and still not shrunk (R8 stays on hold under TASK-016).
|
||||||
|
No secret is in it or in the push.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
See the development log entry for TASK-076.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None.
|
||||||
Loading…
x
Reference in New Issue
Block a user