Compare commits
No commits in common. "137480f30d79baf2349c3e1ce35b11d01b177769" and "0f96a8ed76d641ada686051aec2fde475a590088" have entirely different histories.
137480f30d
...
0f96a8ed76
@ -16,17 +16,14 @@ 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 = 7
|
versionCode = 6
|
||||||
versionName = "0.2.5"
|
versionName = "0.2.4"
|
||||||
|
|
||||||
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,6 +17,14 @@ 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
|
||||||
@ -40,6 +48,7 @@ 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
|
||||||
@ -85,11 +94,7 @@ 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
|
||||||
@ -263,28 +268,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
homeViewModel.close()
|
homeViewModel.close()
|
||||||
workViewModel.close()
|
workViewModel.close()
|
||||||
}
|
}
|
||||||
// Held, not made again on each pass: the screens are given these, and a screen given
|
fun signOut() {
|
||||||
// the same callbacks as before is left alone when something else here changes.
|
|
||||||
val signOut: () -> Unit = remember {
|
|
||||||
{
|
|
||||||
closeFeatures()
|
closeFeatures()
|
||||||
selectTab(AppTab.HOME)
|
selectTab(AppTab.HOME)
|
||||||
viewModel.logout()
|
viewModel.logout()
|
||||||
}
|
}
|
||||||
}
|
fun leaveBranchDetail() {
|
||||||
val leaveBranchDetail: () -> Unit = remember {
|
|
||||||
{
|
|
||||||
codeBrowserViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
historyViewModel.close()
|
historyViewModel.close()
|
||||||
pop()
|
pop()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var confirmExit by remember { mutableStateOf(false) }
|
|
||||||
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
||||||
val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) {
|
val codeViewerPreferences = CodeViewerPreferences(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
|
||||||
@ -321,11 +317,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Made again only when what it holds changes. Every screen is composed under it, and
|
val workbench = if (sessionReady) {
|
||||||
// a new one, even one that says the same, has every screen on every tab drawn again:
|
|
||||||
// that was a stall of several frames at the start of each tab change and each push.
|
|
||||||
val workbench = remember(sessionReady, workbenchState.tabs, workbenchState.pending, workbenchState.fetching) {
|
|
||||||
if (sessionReady) {
|
|
||||||
CodeWorkbench(
|
CodeWorkbench(
|
||||||
tabs = workbenchState.tabs,
|
tabs = workbenchState.tabs,
|
||||||
pending = workbenchState.pending,
|
pending = workbenchState.pending,
|
||||||
@ -335,7 +327,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onCompareWith = workbenchViewModel::compareWith,
|
onCompareWith = workbenchViewModel::compareWith,
|
||||||
onClearPin = workbenchViewModel::clearPin,
|
onClearPin = workbenchViewModel::clearPin,
|
||||||
onUseCommitFile = { commitId, shortId, path, side ->
|
onUseCommitFile = { commitId, shortId, path, side ->
|
||||||
(routeStack.last() as? Route.BranchDetail)?.let { route ->
|
(currentRoute as? Route.BranchDetail)?.let { route ->
|
||||||
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
|
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -343,7 +335,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
null
|
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)
|
||||||
|
|
||||||
@ -405,23 +396,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
) { isWide ->
|
) { isWide ->
|
||||||
// Back on any tab but Home goes Home; back on Home asks before leaving the
|
// Back on any tab but Home goes Home; back on Home leaves the app.
|
||||||
// app, since it is easily pressed once too often.
|
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
|
||||||
BackHandler(enabled = sessionReady && currentRoute.isTab) {
|
selectTab(AppTab.HOME)
|
||||||
if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME)
|
|
||||||
}
|
}
|
||||||
// The screens are built once and held, and read what changes from state. A
|
val repositoryList: @Composable () -> Unit = {
|
||||||
// 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 = remember { viewModel::signIn },
|
onSignIn = viewModel::signIn,
|
||||||
onCancelSignIn = remember { viewModel::cancelSignIn },
|
onCancelSignIn = viewModel::cancelSignIn,
|
||||||
onRetry = remember { viewModel::retry },
|
onRetry = viewModel::retry,
|
||||||
onLogout = signOut,
|
onLogout = ::signOut,
|
||||||
onRepositorySelected = remember { viewModel::selectRepository },
|
onRepositorySelected = 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
|
||||||
@ -434,34 +421,51 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onReferencesRefresh = remember { viewModel::refreshReferences },
|
onReferencesRefresh = viewModel::refreshReferences,
|
||||||
onBackToRepositories = remember { viewModel::backToRepositories },
|
onBackToRepositories = viewModel::backToRepositories,
|
||||||
onNextPage = remember { viewModel::nextPage },
|
onNextPage = viewModel::nextPage,
|
||||||
onPreviousPage = remember { viewModel::previousPage },
|
onPreviousPage = viewModel::previousPage,
|
||||||
onRefreshRepositories = remember { viewModel::refreshRepositories },
|
onRefreshRepositories = viewModel::refreshRepositories,
|
||||||
onRetryLastPushedBranch = remember { viewModel::retryRowLastPushedBranch },
|
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
||||||
onToggleFavorite = remember { viewModel::toggleFavorite },
|
onToggleFavorite = viewModel::toggleFavorite,
|
||||||
onShowAllRepositories = remember { viewModel::showAllRepositories },
|
onShowAllRepositories = viewModel::showAllRepositories,
|
||||||
onShowFavorites = remember { viewModel::showFavorites },
|
onShowFavorites = 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 = remember { viewModel::selectOrganization },
|
onSelectOrganization = viewModel::selectOrganization,
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
)
|
)
|
||||||
} }
|
}
|
||||||
// Opening a screen pushes it in from the right as the one behind leaves to the
|
// Entering a screen slides it in from the side it was opened towards, and a
|
||||||
// left, and going back reverses that; it loads while it arrives. Keyed by
|
// tab switch from the side that tab sits on in the bar. Keyed by the kind of
|
||||||
// the kind of screen, so moving between code tabs or pages of one screen does
|
// screen, so moving between code tabs or pages of one screen does not replay it.
|
||||||
// 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) }
|
||||||
val shownReady = remember { booleanArrayOf(sessionReady) }
|
SideEffect { shownDepth[0] = stackDepth }
|
||||||
SideEffect {
|
AnimatedContent(
|
||||||
shownDepth[0] = stackDepth
|
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
||||||
shownReady[0] = sessionReady
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
transitionSpec = {
|
||||||
|
val fromTab = initialState.tab
|
||||||
|
val toTab = targetState.tab
|
||||||
|
val tabSwitch = stackDepth == shownDepth[0] && fromTab != null && toTab != null
|
||||||
|
if (tabSwitch) {
|
||||||
|
// Tabs sit side by side: the old one glides out as the new one
|
||||||
|
// glides in, the whole width, like turning to the next page.
|
||||||
|
val direction = (toTab.ordinal - fromTab.ordinal).coerceIn(-1, 1)
|
||||||
|
val glide = tween<IntOffset>(320, easing = FastOutSlowInEasing)
|
||||||
|
slideInHorizontally(glide) { it * direction }
|
||||||
|
.togetherWith(slideOutHorizontally(glide) { -it * direction })
|
||||||
|
} else {
|
||||||
|
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
|
||||||
|
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
|
||||||
|
.togetherWith(fadeOut(tween(90)))
|
||||||
}
|
}
|
||||||
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
|
},
|
||||||
|
contentKey = { it::class },
|
||||||
|
label = "route",
|
||||||
|
) { route ->
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
when (route) {
|
when (route) {
|
||||||
Route.Home -> HomeScreen(
|
Route.Home -> HomeScreen(
|
||||||
@ -489,52 +493,52 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectSource = remember { homeViewModel::selectSource },
|
onSelectSource = homeViewModel::selectSource,
|
||||||
onSelectFilter = remember { homeViewModel::selectFilter },
|
onSelectFilter = homeViewModel::selectFilter,
|
||||||
onSetTodayOnly = remember { homeViewModel::setTodayOnly },
|
onSetTodayOnly = homeViewModel::setTodayOnly,
|
||||||
onShowToday = remember { homeViewModel::showToday },
|
onShowToday = homeViewModel::showToday,
|
||||||
onLoadMore = remember { homeViewModel::loadMore },
|
onLoadMore = homeViewModel::loadMore,
|
||||||
onRefresh = remember { homeViewModel::refresh },
|
onRefresh = 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 = remember { workViewModel::selectScope },
|
onSelectScope = workViewModel::selectScope,
|
||||||
onSelectState = remember { workViewModel::selectState },
|
onSelectState = workViewModel::selectState,
|
||||||
onSelectType = remember { workViewModel::selectType },
|
onSelectType = 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 = remember { workViewModel::nextPage },
|
onNextPage = workViewModel::nextPage,
|
||||||
onPreviousPage = remember { workViewModel::previousPage },
|
onPreviousPage = workViewModel::previousPage,
|
||||||
onRefresh = remember { workViewModel::refresh },
|
onRefresh = 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 = remember { settingsViewModel::setReactiveAuthRetryEnabled },
|
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
||||||
onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled },
|
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled },
|
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
||||||
onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp },
|
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
||||||
onSetThemeMode = remember { settingsViewModel::setThemeMode },
|
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||||
onLogout = signOut,
|
onLogout = ::signOut,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
update = updateState,
|
update = updateState,
|
||||||
onCheckUpdate = remember { updateViewModel::checkNow },
|
onCheckUpdate = updateViewModel::checkNow,
|
||||||
onInstallUpdate = remember { ::startUpdate },
|
onInstallUpdate = ::startUpdate,
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly },
|
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
|
||||||
onShowAll = remember { notificationsViewModel::showAll },
|
onShowAll = notificationsViewModel::showAll,
|
||||||
onNotificationSelected = { notification ->
|
onNotificationSelected = { notification ->
|
||||||
push(
|
push(
|
||||||
Route.WorkItems(
|
Route.WorkItems(
|
||||||
@ -545,15 +549,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onNextPage = remember { notificationsViewModel::nextPage },
|
onNextPage = notificationsViewModel::nextPage,
|
||||||
onPreviousPage = remember { notificationsViewModel::previousPage },
|
onPreviousPage = notificationsViewModel::previousPage,
|
||||||
onRetry = remember { notificationsViewModel::retry },
|
onRetry = 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 = {
|
||||||
@ -562,78 +566,65 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
WorkItemsScreen(
|
WorkItemsScreen(
|
||||||
state = workItemsState,
|
state = workItemsState,
|
||||||
onShowIssues = remember { workItemsViewModel::showIssues },
|
onShowIssues = workItemsViewModel::showIssues,
|
||||||
onShowPullRequests = remember { workItemsViewModel::showPullRequests },
|
onShowPullRequests = workItemsViewModel::showPullRequests,
|
||||||
onIssueSelected = remember { workItemsViewModel::openIssue },
|
onIssueSelected = workItemsViewModel::openIssue,
|
||||||
onPullRequestSelected = remember { workItemsViewModel::openPullRequest },
|
onPullRequestSelected = workItemsViewModel::openPullRequest,
|
||||||
onNextPage = remember { workItemsViewModel::nextPage },
|
onNextPage = workItemsViewModel::nextPage,
|
||||||
onPreviousPage = remember { workItemsViewModel::previousPage },
|
onPreviousPage = workItemsViewModel::previousPage,
|
||||||
onGoToPage = remember { workItemsViewModel::goToPage },
|
onGoToPage = workItemsViewModel::goToPage,
|
||||||
onRetryList = remember { workItemsViewModel::retryList },
|
onRetryList = workItemsViewModel::retryList,
|
||||||
onRetryDetail = remember { workItemsViewModel::retryDetail },
|
onRetryDetail = 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 remember { workItemsViewModel::backToList },
|
onBackToList = if (route.number != null) leave else 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 = heldWhilePushedIn(codeBrowserState),
|
codeBrowserState = codeBrowserState,
|
||||||
codeViewerPreferences = codeViewerPreferences,
|
codeViewerPreferences = codeViewerPreferences,
|
||||||
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||||
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||||
onRetryCodeBrowser = remember { codeBrowserViewModel::retry },
|
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
||||||
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
||||||
onNavigateImage = remember { codeBrowserViewModel::navigateImage },
|
onNavigateImage = codeBrowserViewModel::navigateImage,
|
||||||
onSetCodeWordWrap = remember { settingsViewModel::setCodeWordWrapEnabled },
|
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
||||||
onSetCodeFontSize = remember { settingsViewModel::setCodeFontSizeSp },
|
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
||||||
onOpenSibling = remember { codeBrowserViewModel::openSibling },
|
onOpenSibling = codeBrowserViewModel::openSibling,
|
||||||
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
|
onToggleFolder = codeBrowserViewModel::toggleFolder,
|
||||||
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
|
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
||||||
historyState = heldWhilePushedIn(historyState),
|
historyState = historyState,
|
||||||
onCommitSelected = remember { historyViewModel::openCommit },
|
onCommitSelected = historyViewModel::openCommit,
|
||||||
onHistoryNextPage = remember { historyViewModel::nextPage },
|
onHistoryNextPage = historyViewModel::nextPage,
|
||||||
onHistoryPreviousPage = remember { historyViewModel::previousPage },
|
onHistoryPreviousPage = historyViewModel::previousPage,
|
||||||
onRetryHistoryList = remember { historyViewModel::retryList },
|
onRetryHistoryList = historyViewModel::retryList,
|
||||||
onRetryHistoryDetail = remember { historyViewModel::retryDetail },
|
onRetryHistoryDetail = historyViewModel::retryDetail,
|
||||||
onRetryPatch = remember { historyViewModel::retryPatch },
|
onRetryPatch = historyViewModel::retryPatch,
|
||||||
onFileSelected = remember { historyViewModel::openFileDiff },
|
onFileSelected = historyViewModel::openFileDiff,
|
||||||
onShowAdjacentFile = remember { historyViewModel::openAdjacentFileDiff },
|
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
|
||||||
onClearFileSelection = remember { historyViewModel::clearFileSelection },
|
onClearFileSelection = historyViewModel::clearFileSelection,
|
||||||
onHistoryBackToList = remember { historyViewModel::backToList },
|
onHistoryBackToList = 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 = remember { workbenchViewModel::swapSides },
|
onSwap = workbenchViewModel::swapSides,
|
||||||
onClose = remember { workbenchViewModel::closeCompare },
|
onClose = workbenchViewModel::closeCompare,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} }
|
}
|
||||||
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
|
|
||||||
val target = currentRoute.takeIf { sessionReady } ?: Route.Repositories
|
|
||||||
val tabBelow = routeStack.lastOrNull { it.isTab }?.tab ?: AppTab.HOME
|
|
||||||
PushedOver(
|
|
||||||
above = target.takeUnless { sessionReady && it.isTab },
|
|
||||||
aboveKey = { it::class },
|
|
||||||
// The session has just been found: the first screen takes the waiting
|
|
||||||
// one's place where it stands. Nothing was navigated, so nothing slides.
|
|
||||||
direction = { if (shownReady[0]) (stackDepth - shownDepth[0]).coerceIn(-1, 1) else 0 },
|
|
||||||
// Tabs sit side by side: going from one to another passes the ones between.
|
|
||||||
below = if (sessionReady) ({ TabPager(AppTab.entries, tabBelow, content = tabScreen) }) else null,
|
|
||||||
content = screen,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
@ -694,10 +685,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (confirmExit) {
|
SplashOverlay()
|
||||||
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.heightIn
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.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.lazy.LazyColumn
|
|
||||||
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
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
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,7 +86,6 @@ 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
|
||||||
@ -128,7 +127,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 {
|
||||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||||
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
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,6 +14,7 @@ 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
|
||||||
@ -50,7 +51,6 @@ 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 {
|
||||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||||
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
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
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "commit") {
|
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,6 +28,7 @@ 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
|
||||||
@ -62,12 +63,10 @@ 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
|
||||||
@ -160,7 +159,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)
|
||||||
}
|
}
|
||||||
ScrollTopLazyColumn(
|
LazyColumn(
|
||||||
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.
|
||||||
@ -170,7 +169,7 @@ private fun HomeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
||||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.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
|
||||||
@ -41,10 +43,8 @@ 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,
|
||||||
) {
|
) {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
|
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,12 +17,14 @@ 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
|
||||||
@ -34,7 +36,6 @@ 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
|
||||||
@ -62,16 +63,12 @@ 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.TabGlideMillis
|
import com.ubisam.ubgitea.ui.components.TabGlide
|
||||||
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
|
||||||
@ -107,50 +104,8 @@ 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
|
||||||
@ -172,40 +127,14 @@ fun RepositoryListScreen(
|
|||||||
stringResource(R.string.repositories_cancel_login),
|
stringResource(R.string.repositories_cancel_login),
|
||||||
onCancelSignIn,
|
onCancelSignIn,
|
||||||
)
|
)
|
||||||
is RepositoryListUiState.Error -> {
|
|
||||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
|
||||||
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
|
||||||
state.type == RepositoryListError.INVALID_CALLBACK
|
|
||||||
val baseMessage = state.accountName?.let {
|
|
||||||
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
|
||||||
} ?: stringResource(state.type.messageResource)
|
|
||||||
CenteredMessage(
|
|
||||||
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
|
|
||||||
action = stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
|
||||||
onAction = if (needsLogin) onSignIn else onRetry,
|
|
||||||
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
|
||||||
onSecondaryAction = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RepositoryListUiState.Loading,
|
RepositoryListUiState.Loading,
|
||||||
is RepositoryListUiState.Empty,
|
is RepositoryListUiState.Empty,
|
||||||
is RepositoryListUiState.Content,
|
is RepositoryListUiState.Content -> {
|
||||||
is RepositoryListUiState.ReferencesLoading,
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
is RepositoryListUiState.ReferencesContent,
|
val mode = when (state) {
|
||||||
is RepositoryListUiState.ReferencesError -> TabPager(
|
is RepositoryListUiState.Content -> state.mode
|
||||||
RepositoryDepth.entries,
|
is RepositoryListUiState.Empty -> state.mode
|
||||||
if (inReferences) RepositoryDepth.REFERENCES else RepositoryDepth.LIST,
|
else -> requestedMode
|
||||||
) { depth ->
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
if (depth == RepositoryDepth.LIST) {
|
|
||||||
val listState = if (inReferences) seenMode?.let { seen[it] } ?: RepositoryListUiState.Loading else state
|
|
||||||
val loadingMode = requestedMode ?: seenMode
|
|
||||||
val shownState = if (listState == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: listState else listState
|
|
||||||
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
|
||||||
val mode = when (shownState) {
|
|
||||||
is RepositoryListUiState.Content -> shownState.mode
|
|
||||||
is RepositoryListUiState.Empty -> shownState.mode
|
|
||||||
else -> loadingMode
|
|
||||||
}
|
}
|
||||||
if (mode == null) {
|
if (mode == null) {
|
||||||
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
||||||
@ -218,17 +147,14 @@ fun RepositoryListScreen(
|
|||||||
// No title: the selected tab already says this is 저장소.
|
// No title: the selected tab already says this is 저장소.
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
ModeTabs(mode, showAllRepositories, showFavorites)
|
ModeTabs(mode, showAllRepositories, showFavorites)
|
||||||
if (shownState is RepositoryListUiState.Content &&
|
if (state is RepositoryListUiState.Content &&
|
||||||
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
|
mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
|
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Both lists stay composed side by side, so changing tab only slides: building
|
TabGlide(mode to state, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
|
||||||
// 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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
when (shown) {
|
when (shown) {
|
||||||
is RepositoryListUiState.Content -> RepositoryContent(
|
is RepositoryListUiState.Content -> RepositoryContent(
|
||||||
@ -265,45 +191,46 @@ fun RepositoryListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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.Error -> {
|
||||||
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
|
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||||
|
state.type == RepositoryListError.INVALID_CALLBACK
|
||||||
|
val baseMessage = state.accountName?.let {
|
||||||
|
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
||||||
|
} ?: stringResource(state.type.messageResource)
|
||||||
|
CenteredMessage(
|
||||||
|
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
|
||||||
|
action = stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
||||||
|
onAction = if (needsLogin) onSignIn else onRetry,
|
||||||
|
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
||||||
|
onSecondaryAction = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
is RepositoryListUiState.ReferencesLoading -> {
|
is RepositoryListUiState.ReferencesLoading -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
|
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||||
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.ReferencesContent -> {
|
is RepositoryListUiState.ReferencesContent -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
ReferencesContent(shown, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.ReferencesError -> {
|
is RepositoryListUiState.ReferencesError -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
|
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||||
CenteredMessage(
|
CenteredMessage(
|
||||||
stringResource(shown.type.messageResource),
|
stringResource(state.type.messageResource),
|
||||||
action = stringResource(R.string.repositories_retry),
|
action = stringResource(R.string.repositories_retry),
|
||||||
onAction = onRetry,
|
onAction = onRetry,
|
||||||
secondaryAction = stringResource(R.string.repositories_back),
|
secondaryAction = stringResource(R.string.repositories_back),
|
||||||
onSecondaryAction = onBackToRepositories,
|
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(
|
||||||
@ -340,7 +267,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()) {
|
||||||
ScrollTopLazyColumn(
|
LazyColumn(
|
||||||
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),
|
||||||
) {
|
) {
|
||||||
@ -606,7 +533,7 @@ private fun ReferencesContent(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
|
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,10 +185,6 @@ 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
|
||||||
@ -479,7 +475,6 @@ 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
|
||||||
@ -501,7 +496,6 @@ 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(
|
||||||
@ -533,7 +527,6 @@ 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()
|
||||||
@ -570,10 +563,7 @@ 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 { summary ->
|
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) }
|
||||||
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)
|
||||||
@ -590,13 +580,12 @@ class RepositoryListViewModel(
|
|||||||
)
|
)
|
||||||
repositoryContent = content
|
repositoryContent = content
|
||||||
mutableState.value = content
|
mutableState.value = content
|
||||||
loadRowBranches(content, all = true)
|
loadRowBranches(content)
|
||||||
} 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
|
||||||
}
|
}
|
||||||
@ -742,9 +731,9 @@ class RepositoryListViewModel(
|
|||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadRowBranches(content: RepositoryListUiState.Content, all: Boolean = false) {
|
private fun loadRowBranches(content: RepositoryListUiState.Content) {
|
||||||
content.repositories
|
content.repositories
|
||||||
.filter { all || it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
||||||
.forEach { launchRowBranchFetch(it.coordinates) }
|
.forEach { launchRowBranchFetch(it.coordinates) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -756,21 +745,22 @@ class RepositoryListViewModel(
|
|||||||
val latest = branches
|
val latest = branches
|
||||||
.filter { it.commitAtEpochMillis != null }
|
.filter { it.commitAtEpochMillis != null }
|
||||||
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
|
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
|
||||||
val found = latest?.let { branch ->
|
updateRow(coordinates) {
|
||||||
|
it.copy(
|
||||||
|
lastPushedBranch = latest?.let { branch ->
|
||||||
LastPushedBranchUiState.Loaded(branch.name.value)
|
LastPushedBranchUiState.Loaded(branch.name.value)
|
||||||
} ?: if (branches.isEmpty()) {
|
} ?: if (branches.isEmpty()) {
|
||||||
LastPushedBranchUiState.Empty
|
LastPushedBranchUiState.Empty
|
||||||
} else {
|
} else {
|
||||||
LastPushedBranchUiState.Failed
|
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 if (coordinates !in knownRowBranches) {
|
} else {
|
||||||
// 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -781,7 +771,6 @@ 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)
|
||||||
@ -841,7 +830,6 @@ 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,6 +15,7 @@ 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
|
||||||
@ -36,7 +37,6 @@ 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,9 +11,11 @@ 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
|
||||||
@ -41,10 +43,8 @@ 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()) {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
LazyColumn(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,6 +13,7 @@ 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
|
||||||
@ -67,7 +68,6 @@ 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 -> {
|
||||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
|
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 -> {
|
||||||
ScrollTopLazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
|
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) {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "header") {
|
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) {
|
||||||
ScrollTopLazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item(key = "header") {
|
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)) {
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.material3.FilterChip as MaterialFilterChip
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's filter chip. A selected chip is outlined in the brand colour so the choice is visible
|
|
||||||
* on a white surface.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FilterChip(
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
label: @Composable () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
leadingIcon: (@Composable () -> Unit)? = null,
|
|
||||||
trailingIcon: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
MaterialFilterChip(
|
|
||||||
selected = selected,
|
|
||||||
onClick = onClick,
|
|
||||||
label = label,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
leadingIcon = leadingIcon,
|
|
||||||
trailingIcon = trailingIcon,
|
|
||||||
border = FilterChipDefaults.filterChipBorder(
|
|
||||||
enabled = enabled,
|
|
||||||
selected = selected,
|
|
||||||
selectedBorderColor = MaterialTheme.colorScheme.primary,
|
|
||||||
selectedBorderWidth = 1.5.dp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -178,14 +178,9 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
// Most loads finish before this would be noticed; the cup is for the ones that do not.
|
|
||||||
AfterShortWait {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
TeaCupLoader()
|
TeaCupLoader()
|
||||||
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.ubisam.ubgitea.R
|
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Leave the app?" confirmation shown when Back is pressed on the Home tab. Staying is the default:
|
|
||||||
* Back, a tap outside and the emphasised "계속 사용" button all call [onStay]; only "종료" calls [onExit].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ExitConfirmDialog(onStay: () -> Unit, onExit: () -> Unit) {
|
|
||||||
// Entrance: the card grows from 0.92 to 1 while fading in.
|
|
||||||
val progress = remember { Animatable(0f) }
|
|
||||||
LaunchedEffect(Unit) { progress.animateTo(1f, tween180) }
|
|
||||||
|
|
||||||
Dialog(onDismissRequest = onStay) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 340.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
val scale = 0.92f + 0.08f * progress.value
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
}
|
|
||||||
.alpha(progress.value),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
tonalElevation = 2.dp,
|
|
||||||
shadowElevation = 6.dp,
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(84.dp)
|
|
||||||
.background(UBGiteaTheme.colors.brand.copy(alpha = 0.12f), CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
TeaCupLoader(size = 40.dp)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.shell_exit_title),
|
|
||||||
modifier = Modifier.padding(top = 20.dp),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onExit,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(48.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.shell_exit_confirm))
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = onStay,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(48.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.shell_exit_stay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val tween180 = androidx.compose.animation.core.tween<Float>(durationMillis = 180)
|
|
||||||
@ -1,12 +1,7 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
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
|
||||||
@ -14,12 +9,8 @@ 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
|
||||||
@ -37,8 +28,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.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
|
||||||
@ -47,17 +36,13 @@ 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
|
||||||
@ -73,20 +58,15 @@ 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.
|
||||||
@ -129,48 +109,14 @@ private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether what is composed here can be seen. A tab kept composed beside the one shown is not, and
|
|
||||||
* nothing in it should keep the display redrawing: looping motion rests while this is false.
|
|
||||||
*/
|
|
||||||
val LocalOnScreen = compositionLocalOf { true }
|
|
||||||
|
|
||||||
/** A value going round from [from] to [to] for as long as it can be seen; [from] when it cannot. */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberLoop(
|
private fun rememberSteam(): () -> Float {
|
||||||
from: Float,
|
val transition = rememberInfiniteTransition(label = "steam")
|
||||||
to: Float,
|
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
|
||||||
millis: Int,
|
return { steam }
|
||||||
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()
|
||||||
@ -183,7 +129,8 @@ 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 sweep = rememberLoop(-1f, 2f, 1300, label = "shimmer")
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
|
||||||
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
|
val 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(
|
||||||
@ -199,7 +146,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.
|
||||||
@ -228,7 +175,13 @@ private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float):
|
|||||||
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
|
/** 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 bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob")
|
val transition = rememberInfiniteTransition(label = "empty")
|
||||||
|
val bob by transition.animateFloat(
|
||||||
|
-1f,
|
||||||
|
1f,
|
||||||
|
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
||||||
|
label = "bob",
|
||||||
|
)
|
||||||
val steam = rememberSteam()
|
val steam = rememberSteam()
|
||||||
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
||||||
Canvas(
|
Canvas(
|
||||||
@ -236,8 +189,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()) }
|
||||||
}
|
}
|
||||||
@ -349,7 +302,6 @@ 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",
|
||||||
@ -358,172 +310,16 @@ 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(settled: Boolean) {
|
fun SplashOverlay() {
|
||||||
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) {
|
||||||
@ -531,11 +327,6 @@ fun SplashOverlay(settled: Boolean) {
|
|||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,84 +0,0 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.ubisam.ubgitea.R
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list that, once it has been scrolled a long way down, offers a small round button in its
|
|
||||||
* bottom corner that takes it back to the top. Takes what a LazyColumn takes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ScrollTopLazyColumn(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
state: LazyListState = rememberLazyListState(),
|
|
||||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
|
||||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
|
||||||
content: LazyListScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Box(modifier) {
|
|
||||||
LazyColumn(Modifier.fillMaxSize(), state, contentPadding, verticalArrangement = verticalArrangement, content = content)
|
|
||||||
ScrollToTopButton(state, Modifier.align(Alignment.BottomEnd).padding(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ScrollToTopButton(state: LazyListState, modifier: Modifier = Modifier) {
|
|
||||||
val far by remember(state) { derivedStateOf { state.firstVisibleItemIndex >= ScrollTopAfterRows } }
|
|
||||||
val shown by animateFloatAsState(if (far) 1f else 0f, tween(160), label = "top")
|
|
||||||
// Not composed at the top of a list, so it takes no taps and is not read out there.
|
|
||||||
if (shown == 0f) return
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
Surface(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
// From very far down, start near the top: the glide stays short and still reads as one.
|
|
||||||
if (state.firstVisibleItemIndex > ScrollTopGlideRows) state.scrollToItem(ScrollTopGlideRows)
|
|
||||||
state.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = far,
|
|
||||||
shape = CircleShape,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
contentColor = MaterialTheme.colorScheme.primary,
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
shadowElevation = 3.dp,
|
|
||||||
modifier = modifier.size(40.dp).graphicsLayer {
|
|
||||||
alpha = shown
|
|
||||||
scaleX = 0.8f + 0.2f * shown
|
|
||||||
scaleY = 0.8f + 0.2f * shown
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.shell_scroll_to_top), size = 22.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val ScrollTopAfterRows = 8
|
|
||||||
private const val ScrollTopGlideRows = 12
|
|
||||||
@ -12,8 +12,4 @@
|
|||||||
<string name="time_hours_ago">%1$d시간 전</string>
|
<string name="time_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,156 +1615,3 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
- Caution: published without having been seen on a device, at the product owner's decision.
|
- 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; being adjusted from the product owner's look on a device
|
Status: implemented; required checks pass; not yet seen on a device
|
||||||
Owner: Claude
|
Owner: Claude
|
||||||
Reviewer: human product owner
|
Reviewer: human product owner
|
||||||
|
|
||||||
@ -22,32 +22,6 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
|||||||
9. Added after that (product owner, 2026-10-12): the tabs inside a screen glide the same way.
|
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
|
||||||
|
|
||||||
@ -62,7 +36,6 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
|||||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
- `app/src/main/java/com/ubisam/ubgitea/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`
|
||||||
@ -73,7 +46,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 fifteen items above is in the code and the app builds.
|
- [x] Each of the nine items above is in the code and the app builds.
|
||||||
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
|
- [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,8 +48,7 @@ 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 `assembleRelease`
|
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleDebug`.
|
||||||
(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.
|
||||||
|
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
# TASK-075: Small interaction fixes from the product owner's first look
|
|
||||||
|
|
||||||
Status: implemented; required checks pass; installed on the product owner's device for a look
|
|
||||||
Owner: Claude
|
|
||||||
Reviewer: human product owner
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
1. A selected filter chip is outlined in the brand colour, everywhere chips are used.
|
|
||||||
2. A short load shows no tea cup: the cup appears only after 450 ms of waiting, so opening a
|
|
||||||
repository from 활동 중인 저장소 goes straight to the code when it loads quickly.
|
|
||||||
3. Back on 홈 asks before leaving the app (계속 사용 is the default; 종료 leaves), with the
|
|
||||||
question alone: no explanatory line under it.
|
|
||||||
4. A list scrolled a long way down offers a small round button that returns it to the top.
|
|
||||||
5. Screens that move sideways no longer drift diagonally: `AnimatedContent` is told not to animate
|
|
||||||
its own size (it does so from the top left corner).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Included: the five items above.
|
|
||||||
- Excluded: code, diff and compare panes (they keep their own scrolling); the loading skeletons.
|
|
||||||
|
|
||||||
## Writable paths
|
|
||||||
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Chips.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ExitConfirmDialog.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/components/ScrollToTop.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/feature/**/*Screen.kt`
|
|
||||||
- `app/src/main/res/values/strings_shell.xml`
|
|
||||||
- `tasks/075-small-interaction-fixes.md`
|
|
||||||
- `docs/development-log.md`
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Each of the five items is in the code and the app builds.
|
|
||||||
- [x] Chip call sites are unchanged: only the import differs.
|
|
||||||
- [x] The exit question closes on Back or a tap outside, staying in the app.
|
|
||||||
- [x] The top button is not composed while a list is near its top.
|
|
||||||
- [ ] Seen on the product owner's device.
|
|
||||||
|
|
||||||
## Security and privacy
|
|
||||||
|
|
||||||
No additional concerns. No network, storage or credential code is touched.
|
|
||||||
|
|
||||||
## Required checks
|
|
||||||
|
|
||||||
```text
|
|
||||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
See the development log entry for TASK-075.
|
|
||||||
|
|
||||||
## Review findings
|
|
||||||
|
|
||||||
None yet.
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
# TASK-076: Publish 0.2.5 to the team's Gitea, as the release build
|
|
||||||
|
|
||||||
Status: done
|
|
||||||
Owner: Claude
|
|
||||||
Reviewer: human product owner
|
|
||||||
|
|
||||||
## Request
|
|
||||||
|
|
||||||
The product owner asked for the work so far (TASK-069's motion, TASK-075's small fixes) to be
|
|
||||||
published as a new version, and for the release build to be the one published (2026-10-12).
|
|
||||||
|
|
||||||
## Writable paths
|
|
||||||
|
|
||||||
- `app/build.gradle.kts`
|
|
||||||
- `tasks/076-publish-0.2.5.md`
|
|
||||||
- `tasks/070-update-from-gitea.md`
|
|
||||||
- `docs/development-log.md`
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Version 0.2.5, versionCode 7.
|
|
||||||
- [x] `assembleRelease` gives an installable APK signed with the key the published builds have
|
|
||||||
had, so it installs over them.
|
|
||||||
- [x] The source is on `main` of the team's repository.
|
|
||||||
- [x] `UBGitea.apk` and `version.json` on the `apk` branch are 0.2.5, and the downloaded APK is
|
|
||||||
byte-identical to the one built here.
|
|
||||||
|
|
||||||
## Security and privacy
|
|
||||||
|
|
||||||
The APK is now the release build: not debuggable, and the debug-only request logging is off. It
|
|
||||||
is still signed with this PC's debug key and still not shrunk (R8 stays on hold under TASK-016).
|
|
||||||
No secret is in it or in the push.
|
|
||||||
|
|
||||||
## Required checks
|
|
||||||
|
|
||||||
```text
|
|
||||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
|
||||||
./gradlew assembleRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
See the development log entry for TASK-076.
|
|
||||||
|
|
||||||
## Review findings
|
|
||||||
|
|
||||||
None.
|
|
||||||
Loading…
x
Reference in New Issue
Block a user