TASK-069: in-app motion built on the tea cup
Splash, cup loader, list skeleton, empty-screen cup, pull to refresh indicator, star and tab icon spring, screen and tab transitions, and a row by row diff intro. Compose animation APIs only. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e47ad799f1
commit
11bb082f0f
@ -16,38 +16,48 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateLocations
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
||||
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
|
||||
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.feature.compare.CodeTabStrip
|
||||
import com.ubisam.ubgitea.feature.compare.CodeWorkbench
|
||||
import com.ubisam.ubgitea.feature.compare.CodeWorkbenchViewModel
|
||||
@ -66,8 +76,8 @@ import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
|
||||
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListUiState
|
||||
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
||||
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
|
||||
import com.ubisam.ubgitea.feature.settings.SettingsScreen
|
||||
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
|
||||
import com.ubisam.ubgitea.feature.work.WorkScreen
|
||||
@ -75,6 +85,7 @@ import com.ubisam.ubgitea.feature.work.WorkViewModel
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||
import com.ubisam.ubgitea.ui.components.SplashOverlay
|
||||
import com.ubisam.ubgitea.ui.navigation.Route
|
||||
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
||||
import com.ubisam.ubgitea.ui.shell.AppShell
|
||||
@ -413,7 +424,31 @@ class MainActivity : ComponentActivity() {
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
)
|
||||
}
|
||||
when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) {
|
||||
// Entering a screen slides it in from the side it was opened towards, and a
|
||||
// tab switch from the side that tab sits on in the bar. Keyed by the kind of
|
||||
// screen, so moving between code tabs or pages of one screen does not replay it.
|
||||
val stackDepth = routeStack.size
|
||||
val shownDepth = remember { intArrayOf(stackDepth) }
|
||||
SideEffect { shownDepth[0] = stackDepth }
|
||||
AnimatedContent(
|
||||
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
transitionSpec = {
|
||||
val fromTab = initialState.tab
|
||||
val toTab = targetState.tab
|
||||
val direction = when {
|
||||
stackDepth != shownDepth[0] -> stackDepth - shownDepth[0]
|
||||
fromTab != null && toTab != null -> toTab.ordinal - fromTab.ordinal
|
||||
else -> 0
|
||||
}.coerceIn(-1, 1)
|
||||
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
|
||||
.togetherWith(fadeOut(tween(90)))
|
||||
},
|
||||
contentKey = { it::class },
|
||||
label = "route",
|
||||
) { route ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (route) {
|
||||
Route.Home -> HomeScreen(
|
||||
state = homeState,
|
||||
accountName = accountName,
|
||||
@ -571,6 +606,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||
}
|
||||
@ -593,6 +630,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
)
|
||||
}
|
||||
SplashOverlay()
|
||||
}
|
||||
}
|
||||
consumeCallback(intent)
|
||||
|
||||
@ -32,8 +32,12 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -67,9 +71,12 @@ import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.StaggerStepMillis
|
||||
import com.ubisam.ubgitea.ui.components.StaggeredReveal
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@ -232,10 +239,17 @@ private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, si
|
||||
val numberWidth = with(density) { (characterWidthPx * digits).toDp() } + 18.dp
|
||||
val cell = DiffCellStyle(style, characterWidthPx, numberWidth)
|
||||
|
||||
// The first screenful arrives row by row, once per file; rows scrolled to later are just there.
|
||||
var intro by remember(path) { mutableStateOf(true) }
|
||||
LaunchedEffect(path) {
|
||||
delay(DiffIntroRows * StaggerStepMillis + 300)
|
||||
intro = false
|
||||
}
|
||||
SelectionContainer {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries.size, key = { it }) { index ->
|
||||
val entry = entries[index]
|
||||
StaggeredReveal(index, active = intro && index < DiffIntroRows) {
|
||||
when {
|
||||
entry.isHunkHeader -> HunkHeader(entry.hunkLabel.orEmpty())
|
||||
sideBySide -> Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
@ -255,6 +269,9 @@ private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, si
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DiffIntroRows = 30
|
||||
|
||||
private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp)
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
@ -86,7 +87,7 @@ fun HistoryScreen(
|
||||
is HistoryUiState.CommitList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
if (state.commits.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.history_empty))
|
||||
CenteredMessage(stringResource(R.string.history_empty), decoration = { EmptyCup() })
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
||||
|
||||
@ -36,7 +36,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -63,14 +62,15 @@ import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatHeadlineDate
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
@ -108,7 +108,7 @@ fun HomeScreen(
|
||||
IconButton(onClick = onRefresh) { UBIcon(R.drawable.ic_ub_refresh, stringResource(R.string.home_refresh)) }
|
||||
}
|
||||
when (state) {
|
||||
HomeUiState.Idle, HomeUiState.Loading -> CenteredLoading(stringResource(R.string.home_loading))
|
||||
HomeUiState.Idle, HomeUiState.Loading -> ListSkeleton(label = stringResource(R.string.home_loading))
|
||||
is HomeUiState.Error -> {
|
||||
val expired = state.type == HomeError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
@ -151,7 +151,7 @@ private fun HomeContent(
|
||||
if (isWide) timelineState.animateScrollToItem(0) else listState.animateScrollToItem(timelineIndex)
|
||||
}
|
||||
}
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
if (isWide) {
|
||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||
// so the whole picture is on screen at once.
|
||||
|
||||
@ -41,9 +41,10 @@ import com.ubisam.ubgitea.domain.notifications.NotificationSummary
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
@ -79,9 +80,9 @@ fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
NotificationsUiState.Idle, NotificationsUiState.Loading -> CenteredLoading(null)
|
||||
NotificationsUiState.Idle, NotificationsUiState.Loading -> ListSkeleton()
|
||||
is NotificationsUiState.Content -> NotificationsList(state, onNotificationSelected, onNextPage, onPreviousPage)
|
||||
is NotificationsUiState.Empty -> CenteredMessage(stringResource(R.string.notifications_empty))
|
||||
is NotificationsUiState.Empty -> CenteredMessage(stringResource(R.string.notifications_empty), decoration = { EmptyCup() })
|
||||
is NotificationsUiState.Error -> {
|
||||
val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
|
||||
@ -4,11 +4,8 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
@ -20,6 +17,10 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -32,10 +33,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@ -56,12 +57,16 @@ import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.components.UbiSamLogo
|
||||
import com.ubisam.ubgitea.ui.components.rememberPopScale
|
||||
import com.ubisam.ubgitea.ui.formatInstant
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
@ -104,7 +109,7 @@ fun RepositoryListScreen(
|
||||
)
|
||||
RepositoryListUiState.Loading -> {
|
||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||
CenteredLoading(stringResource(R.string.repositories_loading))
|
||||
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
||||
}
|
||||
is RepositoryListUiState.Empty -> {
|
||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||
@ -126,6 +131,7 @@ fun RepositoryListScreen(
|
||||
stringResource(R.string.repositories_favorites_empty),
|
||||
action = stringResource(R.string.repositories_favorites_empty_action),
|
||||
onAction = onShowAllRepositories,
|
||||
decoration = { EmptyCup() },
|
||||
)
|
||||
} else {
|
||||
CenteredMessage(
|
||||
@ -238,7 +244,7 @@ private fun RepositoryContent(
|
||||
state.cachedAtEpochMillis?.let { cachedAt ->
|
||||
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
|
||||
}
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
||||
TeaPullToRefreshBox(onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
|
||||
@ -461,6 +467,7 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled
|
||||
val pop = rememberPopScale(filled)
|
||||
val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite)
|
||||
Column(
|
||||
Modifier
|
||||
@ -471,8 +478,15 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier.graphicsLayer {
|
||||
scaleX = pop()
|
||||
scaleY = pop()
|
||||
},
|
||||
) {
|
||||
UBIcon(if (filled) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star, null, tint = color, size = 20.dp)
|
||||
}
|
||||
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
@ -496,7 +510,7 @@ private fun ReferencesContent(
|
||||
}
|
||||
},
|
||||
)
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
|
||||
if (state.branches.isEmpty()) {
|
||||
|
||||
@ -20,7 +20,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -42,10 +41,12 @@ import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
|
||||
@ -87,8 +88,8 @@ fun WorkScreen(
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
is WorkUiState.Idle, is WorkUiState.Loading -> CenteredLoading(stringResource(R.string.work_loading))
|
||||
is WorkUiState.Empty -> CenteredMessage(stringResource(R.string.work_empty))
|
||||
is WorkUiState.Idle, is WorkUiState.Loading -> ListSkeleton(label = stringResource(R.string.work_loading))
|
||||
is WorkUiState.Empty -> CenteredMessage(stringResource(R.string.work_empty), decoration = { EmptyCup() })
|
||||
is WorkUiState.Error -> {
|
||||
val expired = state.type == WorkError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
@ -145,7 +146,7 @@ private fun WorkList(
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
TeaPullToRefreshBox(onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
items(
|
||||
count = state.items.size,
|
||||
|
||||
@ -61,8 +61,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.EmptyCup
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.ListSkeleton
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
@ -94,7 +96,7 @@ fun WorkItemsScreen(
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenteredLoading(null)
|
||||
ListSkeleton()
|
||||
}
|
||||
is WorkItemsUiState.IssueList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
@ -146,7 +148,7 @@ fun WorkItemsScreen(
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenteredMessage(stringResource(R.string.workitems_empty))
|
||||
CenteredMessage(stringResource(R.string.workitems_empty), decoration = { EmptyCup() })
|
||||
}
|
||||
is WorkItemsUiState.ListError -> {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -179,7 +178,7 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
TeaCupLoader()
|
||||
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
@ -192,12 +191,15 @@ fun CenteredMessage(
|
||||
onAction: () -> Unit = {},
|
||||
secondaryAction: String? = null,
|
||||
onSecondaryAction: () -> Unit = {},
|
||||
// Drawn above the message: the bobbing cup on a screen with nothing to list.
|
||||
decoration: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
decoration?.invoke()
|
||||
// Korean breaks between any two characters by default; this keeps words whole.
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Heading), textAlign = TextAlign.Center)
|
||||
if (action != null) Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
|
||||
|
||||
316
app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt
Normal file
316
app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt
Normal file
@ -0,0 +1,316 @@
|
||||
package com.ubisam.ubgitea.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* The launcher icon's cup on a 40 x 48 grid. [steam] is 0..1 through one rise of the steam, and
|
||||
* [steamAlpha] fades both wisps together; 0 draws the cup alone.
|
||||
*/
|
||||
private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float = 1f) {
|
||||
val unit = size.width / 40f
|
||||
fun x(value: Float) = value * unit
|
||||
val cup = Path().apply {
|
||||
moveTo(x(3f), x(18f))
|
||||
lineTo(x(29f), x(18f))
|
||||
lineTo(x(29f), x(24f))
|
||||
arcTo(Rect(x(3f), x(11f), x(29f), x(37f)), 0f, 180f, false)
|
||||
close()
|
||||
}
|
||||
drawPath(cup, color)
|
||||
drawPath(cup, color, style = Stroke(x(3f), join = StrokeJoin.Round))
|
||||
val handle = Path().apply {
|
||||
moveTo(x(30f), x(21f))
|
||||
lineTo(x(32f), x(21f))
|
||||
arcTo(Rect(x(26.5f), x(21f), x(37.5f), x(32f)), -90f, 180f, false)
|
||||
lineTo(x(28f), x(32f))
|
||||
}
|
||||
drawPath(handle, color, style = Stroke(x(4f), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
drawLine(color, Offset(x(5f), x(44f)), Offset(x(27f), x(44f)), x(3.6f), StrokeCap.Round)
|
||||
if (steamAlpha <= 0f) return
|
||||
// The second wisp runs half a cycle behind the first, so one is always on its way up.
|
||||
for ((column, offset) in listOf(11f to 0f, 21f to 0.5f)) {
|
||||
val phase = (steam + offset) % 1f
|
||||
val rise = phase * 5f
|
||||
val wisp = Path().apply {
|
||||
moveTo(x(column), x(13f - rise))
|
||||
cubicTo(x(column - 3f), x(9f - rise), x(column + 3f), x(7.5f - rise), x(column), x(3f - rise))
|
||||
}
|
||||
val alpha = sin(phase * PI).toFloat() * 0.8f * steamAlpha
|
||||
drawPath(wisp, color.copy(alpha = color.alpha * alpha), style = Stroke(x(3f), cap = StrokeCap.Round))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSteam(): () -> Float {
|
||||
val transition = rememberInfiniteTransition(label = "steam")
|
||||
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
|
||||
return { steam }
|
||||
}
|
||||
|
||||
/** The tea cup with rising steam: the app's "working on it" mark. */
|
||||
@Composable
|
||||
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
|
||||
val steam = rememberSteam()
|
||||
Canvas(modifier.size(size, size * 1.2f)) { drawTeaCup(color, steam()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Grey placeholder rows with a light passing over them, shown while a list is on its way. [label]
|
||||
* is what a screen reader says in place of the rows.
|
||||
*/
|
||||
@Composable
|
||||
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
|
||||
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
|
||||
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.semantics { if (label != null) contentDescription = label }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
ListCard {
|
||||
repeat(rows) { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val shimmer = Modifier.drawShimmer(base, light) { sweep }
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Rows differ in length so the block does not read as a table.
|
||||
Box(Modifier.fillMaxWidth(if (row % 2 == 0) 0.62f else 0.48f).height(14.dp).clip(CircleShape).then(shimmer))
|
||||
Box(Modifier.fillMaxWidth(if (row % 3 == 0) 0.86f else 0.74f).height(10.dp).clip(CircleShape).then(shimmer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [sweep] is read while drawing, so the passing light costs a redraw per frame and no recomposition.
|
||||
private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float): Modifier = drawBehind {
|
||||
drawRect(base)
|
||||
val centre = sweep() * size.width
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color.Transparent, light, Color.Transparent),
|
||||
startX = centre - size.width / 2f,
|
||||
endX = centre + size.width / 2f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
|
||||
@Composable
|
||||
fun EmptyCup(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition(label = "empty")
|
||||
val bob by transition.animateFloat(
|
||||
-1f,
|
||||
1f,
|
||||
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
||||
label = "bob",
|
||||
)
|
||||
val steam = rememberSteam()
|
||||
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
||||
Canvas(
|
||||
modifier
|
||||
.padding(bottom = 20.dp)
|
||||
.size(56.dp, 67.dp)
|
||||
.graphicsLayer {
|
||||
translationY = bob * 3.dp.toPx()
|
||||
rotationZ = bob * 2f
|
||||
},
|
||||
) { drawTeaCup(color, steam()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull to refresh with the cup in place of the stock spinner: it comes down with the finger, and
|
||||
* the steam appears as the pull gets far enough to refresh.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TeaPullToRefreshBox(onRefresh: () -> Unit, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||
val state = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
isRefreshing = false,
|
||||
onRefresh = onRefresh,
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
indicator = { TeaPullIndicator(state, Modifier.align(Alignment.TopCenter)) },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TeaPullIndicator(state: PullToRefreshState, modifier: Modifier = Modifier) {
|
||||
// Nothing is composed, and nothing animates, until a pull starts.
|
||||
val pulling by remember(state) { derivedStateOf { state.distanceFraction > 0f } }
|
||||
if (!pulling) return
|
||||
val steam = rememberSteam()
|
||||
val color = UBGiteaTheme.colors.brand
|
||||
Box(
|
||||
modifier
|
||||
.graphicsLayer {
|
||||
val fraction = state.distanceFraction.coerceIn(0f, 1.3f)
|
||||
translationY = fraction * 76.dp.toPx() - 52.dp.toPx()
|
||||
alpha = fraction.coerceAtMost(1f)
|
||||
val scale = 0.7f + 0.3f * fraction.coerceAtMost(1f)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(25.dp, 30.dp)) {
|
||||
// No steam for the first half of the pull, all of it at the point of release.
|
||||
drawTeaCup(color, steam(), steamAlpha = ((state.distanceFraction - 0.5f) * 2f).coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 1 at rest; dips and springs back each time [key] changes after the first composition. */
|
||||
@Composable
|
||||
fun rememberPopScale(key: Any?): () -> Float {
|
||||
val scale = remember { Animatable(1f) }
|
||||
var shown by remember { mutableStateOf(key) }
|
||||
LaunchedEffect(key) {
|
||||
if (shown != key) {
|
||||
shown = key
|
||||
scale.snapTo(0.55f)
|
||||
scale.animateTo(1f, spring(dampingRatio = 0.35f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
}
|
||||
return { scale.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fades and slides [content] in, later for a larger [index], so rows arrive one after another.
|
||||
* With [active] false the content is simply there.
|
||||
*/
|
||||
@Composable
|
||||
fun StaggeredReveal(index: Int, active: Boolean, content: @Composable () -> Unit) {
|
||||
val reveal = remember { Animatable(if (active) 0f else 1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (reveal.value < 1f) {
|
||||
delay(index * StaggerStepMillis)
|
||||
reveal.animateTo(1f, tween(220))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.graphicsLayer {
|
||||
alpha = reveal.value
|
||||
translationX = (1f - reveal.value) * 20.dp.toPx()
|
||||
},
|
||||
) { content() }
|
||||
}
|
||||
|
||||
const val StaggerStepMillis = 26L
|
||||
|
||||
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
||||
|
||||
/**
|
||||
* The launcher icon, enlarged and steaming, over the whole window for the first second after a
|
||||
* cold start. It covers the app rather than delaying it: everything underneath loads meanwhile.
|
||||
*/
|
||||
@Composable
|
||||
fun SplashOverlay() {
|
||||
var finished by rememberSaveable { mutableStateOf(false) }
|
||||
if (finished) return
|
||||
val alpha = remember { Animatable(1f) }
|
||||
val scale = remember { Animatable(0.85f) }
|
||||
LaunchedEffect(Unit) {
|
||||
scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
delay(1100)
|
||||
alpha.animateTo(0f, tween(280))
|
||||
finished = true
|
||||
}
|
||||
val steam = rememberSteam()
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { this.alpha = alpha.value }
|
||||
.background(Brush.linearGradient(SplashGradient))
|
||||
// Swallows taps so nothing underneath is pressed through the cover.
|
||||
.clickable(remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(
|
||||
Modifier.size(96.dp, 115.dp).graphicsLayer {
|
||||
scaleX = scale.value
|
||||
scaleY = scale.value
|
||||
},
|
||||
) { drawTeaCup(Color.White, steam()) }
|
||||
}
|
||||
}
|
||||
@ -5,14 +5,14 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -27,12 +27,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.components.rememberPopScale
|
||||
|
||||
enum class AppTab(@StringRes val label: Int, @DrawableRes val icon: Int) {
|
||||
HOME(R.string.shell_tab_home, R.drawable.ic_ub_home),
|
||||
@ -97,7 +99,7 @@ private fun BottomTabs(selected: AppTab, unreadNotifications: Int?, onSelected:
|
||||
NavigationBarItem(
|
||||
selected = tab == selected,
|
||||
onClick = { onSelected(tab) },
|
||||
icon = { TabIcon(tab, unreadNotifications) },
|
||||
icon = { TabIcon(tab, unreadNotifications, tab == selected) },
|
||||
label = { TabLabel(tab, tab == selected) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
@ -118,7 +120,7 @@ private fun SideTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (A
|
||||
NavigationRailItem(
|
||||
selected = tab == selected,
|
||||
onClick = { onSelected(tab) },
|
||||
icon = { TabIcon(tab, unreadNotifications) },
|
||||
icon = { TabIcon(tab, unreadNotifications, tab == selected) },
|
||||
label = { TabLabel(tab, tab == selected) },
|
||||
colors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
@ -134,13 +136,20 @@ private fun SideTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (A
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabIcon(tab: AppTab, unreadNotifications: Int?) {
|
||||
private fun TabIcon(tab: AppTab, unreadNotifications: Int?, selected: Boolean) {
|
||||
val unread = unreadNotifications?.takeIf { tab == AppTab.NOTIFICATIONS && it > 0 }
|
||||
// The tab being arrived at springs once; the one being left just changes colour.
|
||||
val pop = rememberPopScale(selected)
|
||||
val popModifier = Modifier.graphicsLayer {
|
||||
val scale = if (selected) pop() else 1f
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
if (unread == null) {
|
||||
UBIcon(tab.icon, null)
|
||||
UBIcon(tab.icon, null, modifier = popModifier)
|
||||
} else {
|
||||
BadgedBox(badge = { Badge { Text(if (unread > MAX_BADGE_COUNT) "$MAX_BADGE_COUNT+" else unread.toString()) } }) {
|
||||
UBIcon(tab.icon, null)
|
||||
UBIcon(tab.icon, null, modifier = popModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1499,3 +1499,20 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- What changed: `ic_launcher_foreground.xml` and `ic_launcher_monochrome.xml` draw a small steaming
|
||||
cup with a handle and a saucer line. The background layer is unchanged.
|
||||
- Verification: the five required checks pass. Not yet seen on a device (no device was connected).
|
||||
|
||||
## 2026-10-12 — TASK-069: in-app motion
|
||||
|
||||
- Request: the human product owner asked for the in-app motion items listed after the Claude
|
||||
Motion question, and, while it was being built, for motion on bottom tab switches too.
|
||||
- What changed: a new `ui/components/Motion.kt` draws the launcher icon's cup in code and builds on
|
||||
it: a splash over the first second of a cold start, the cup with steam in place of the stock
|
||||
spinner, shimmering placeholder rows while lists load, a bobbing cup on empty screens, a pull to
|
||||
refresh indicator, a spring for the favourite star and the selected tab icon, and a row by row
|
||||
arrival for the first rows of a diff. `MainActivity` wraps the screens in `AnimatedContent`: a
|
||||
pushed screen slides in from the right, a popped one from the left, a tab from the side it sits
|
||||
on. No dependency added.
|
||||
- Checks and results: the five required checks pass; 471 unit tests, 0 failures; lint 0 errors.
|
||||
- Caution: none of it has been seen on a device (none was connected). Timing and distances are
|
||||
first guesses. During the 90 ms a screen fades out it is drawn from the current view model
|
||||
state, so a screen whose state is cleared on leaving may flash its loading view.
|
||||
- Next owner: the product owner, to look at it on the device.
|
||||
|
||||
71
tasks/069-in-app-motion.md
Normal file
71
tasks/069-in-app-motion.md
Normal file
@ -0,0 +1,71 @@
|
||||
# TASK-069: In-app motion
|
||||
|
||||
Status: implemented; required checks pass; not yet seen on a device
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
The app moves where it used to jump, and what moves is the launcher icon's tea cup.
|
||||
|
||||
1. Cold start: the cup on the icon's blue, steaming, for about a second, then it fades.
|
||||
2. Loading: lists show grey placeholder rows with a light passing over them; everything else shows
|
||||
the steaming cup in place of the stock spinner.
|
||||
3. The favourite star springs when its state changes.
|
||||
4. A screen with nothing to list shows the cup bobbing above its message.
|
||||
5. Entering and leaving a screen slides it a short way in from the side it was opened towards.
|
||||
6. Pull to refresh brings the cup down with the finger; the steam appears near the release point.
|
||||
7. A file's diff arrives row by row the first time it is shown.
|
||||
8. Added while this was in progress (product owner, 2026-10-12): switching bottom tabs slides the
|
||||
screen in from the side that tab sits on, and the tab's icon springs.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: Compose's own animation APIs only. No new dependency.
|
||||
- Excluded: videos made with Claude Motion; any change to what the screens load or show.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `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/ui/shell/AppShell.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/repositories/RepositoryListScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt`
|
||||
- `tasks/069-in-app-motion.md`
|
||||
- `docs/development-log.md`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Each of the eight items above is in the code and the app builds.
|
||||
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
|
||||
underneath, and taps do not pass through the cover.
|
||||
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen
|
||||
transition.
|
||||
- [x] Nothing animates off screen: the pull indicator composes only during a pull, and the diff
|
||||
rows animate once per file, not on scroll.
|
||||
- [x] A list's loading message is still what a screen reader says.
|
||||
- [ ] 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-069.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
Loading…
x
Reference in New Issue
Block a user