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:
최민영 2026-10-12 13:34:42 +09:00
parent e47ad799f1
commit 11bb082f0f
13 changed files with 534 additions and 45 deletions

View File

@ -16,38 +16,48 @@ 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.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.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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
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.setValue
import androidx.compose.runtime.saveable.rememberSaveable 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.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier 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.settings.ThemeMode
import com.ubisam.ubgitea.core.update.AppUpdateLocations import com.ubisam.ubgitea.core.update.AppUpdateLocations
import com.ubisam.ubgitea.core.update.AppUpdateViewModel 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.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget 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.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences 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.CodeTabStrip
import com.ubisam.ubgitea.feature.compare.CodeWorkbench import com.ubisam.ubgitea.feature.compare.CodeWorkbench
import com.ubisam.ubgitea.feature.compare.CodeWorkbenchViewModel 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.ReferenceKind
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListUiState 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.RepositoryListViewModel
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
import com.ubisam.ubgitea.feature.settings.SettingsScreen import com.ubisam.ubgitea.feature.settings.SettingsScreen
import com.ubisam.ubgitea.feature.settings.SettingsViewModel import com.ubisam.ubgitea.feature.settings.SettingsViewModel
import com.ubisam.ubgitea.feature.work.WorkScreen 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.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.SplashOverlay
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
@ -413,7 +424,31 @@ class MainActivity : ComponentActivity() {
onViewSettings = { push(Route.Settings) }, 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( Route.Home -> HomeScreen(
state = homeState, state = homeState,
accountName = accountName, accountName = accountName,
@ -569,6 +604,8 @@ class MainActivity : ComponentActivity() {
onClose = workbenchViewModel::closeCompare, onClose = workbenchViewModel::closeCompare,
) )
} }
}
}
} }
} }
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
@ -593,6 +630,7 @@ class MainActivity : ComponentActivity() {
}, },
) )
} }
SplashOverlay()
} }
} }
consumeCallback(intent) consumeCallback(intent)

View File

@ -32,8 +32,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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
@ -67,9 +71,12 @@ import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
import com.ubisam.ubgitea.ui.components.CautionBanner import com.ubisam.ubgitea.ui.components.CautionBanner
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.StaggerStepMillis
import com.ubisam.ubgitea.ui.components.StaggeredReveal
import com.ubisam.ubgitea.ui.components.UBIcon import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 numberWidth = with(density) { (characterWidthPx * digits).toDp() } + 18.dp
val cell = DiffCellStyle(style, characterWidthPx, numberWidth) 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 { SelectionContainer {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) { LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
items(entries.size, key = { it }) { index -> items(entries.size, key = { it }) { index ->
val entry = entries[index] val entry = entries[index]
StaggeredReveal(index, active = intro && index < DiffIntroRows) {
when { when {
entry.isHunkHeader -> HunkHeader(entry.hunkLabel.orEmpty()) entry.isHunkHeader -> HunkHeader(entry.hunkLabel.orEmpty())
sideBySide -> Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { sideBySide -> Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
@ -251,11 +265,14 @@ private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, si
if (entry.right != null) DiffCell(entry.right, entry.rightNumber, language, cell, Modifier.fillMaxWidth()) if (entry.right != null) DiffCell(entry.right, entry.rightNumber, language, cell, Modifier.fillMaxWidth())
} }
} }
}
} }
} }
} }
} }
private const val DiffIntroRows = 30
private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp) private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp)
@Composable @Composable

View File

@ -48,6 +48,7 @@ import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.ActivityTone import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.Avatar import com.ubisam.ubgitea.ui.components.Avatar
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.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.SectionTitle import com.ubisam.ubgitea.ui.components.SectionTitle
@ -86,7 +87,7 @@ fun HistoryScreen(
is HistoryUiState.CommitList -> { is HistoryUiState.CommitList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
if (state.commits.isEmpty()) { if (state.commits.isEmpty()) {
CenteredMessage(stringResource(R.string.history_empty)) CenteredMessage(stringResource(R.string.history_empty), decoration = { EmptyCup() })
} else { } else {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) { 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 ->

View File

@ -36,7 +36,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment 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.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.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage import com.ubisam.ubgitea.ui.components.CenteredMessage
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.MonoChip import com.ubisam.ubgitea.ui.components.MonoChip
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
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.formatHeadlineDate import com.ubisam.ubgitea.ui.formatHeadlineDate
import com.ubisam.ubgitea.ui.formatRelative 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)) } IconButton(onClick = onRefresh) { UBIcon(R.drawable.ic_ub_refresh, stringResource(R.string.home_refresh)) }
} }
when (state) { 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 -> { is HomeUiState.Error -> {
val expired = state.type == HomeError.AUTHENTICATION_EXPIRED val expired = state.type == HomeError.AUTHENTICATION_EXPIRED
CenteredMessage( CenteredMessage(
@ -151,7 +151,7 @@ private fun HomeContent(
if (isWide) timelineState.animateScrollToItem(0) else listState.animateScrollToItem(timelineIndex) 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) { if (isWide) {
// Unfolded: the overview stays put on the left while the timeline scrolls on the right, // Unfolded: the overview stays put on the left while the timeline scrolls on the right,
// so the whole picture is on screen at once. // so the whole picture is on screen at once.

View File

@ -41,9 +41,10 @@ import com.ubisam.ubgitea.domain.notifications.NotificationSummary
import com.ubisam.ubgitea.ui.SimplePaginationFooter import com.ubisam.ubgitea.ui.SimplePaginationFooter
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.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.KindTile 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.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
@ -79,9 +80,9 @@ fun NotificationsScreen(
} }
} }
when (state) { when (state) {
NotificationsUiState.Idle, NotificationsUiState.Loading -> CenteredLoading(null) NotificationsUiState.Idle, NotificationsUiState.Loading -> ListSkeleton()
is NotificationsUiState.Content -> NotificationsList(state, onNotificationSelected, onNextPage, onPreviousPage) 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 -> { is NotificationsUiState.Error -> {
val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED
CenteredMessage( CenteredMessage(

View File

@ -4,11 +4,8 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.horizontalScroll 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow 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.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.rememberScrollState
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
@ -32,10 +33,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role 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.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.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.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.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
import com.ubisam.ubgitea.ui.components.rememberPopScale
import com.ubisam.ubgitea.ui.formatInstant import com.ubisam.ubgitea.ui.formatInstant
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
@ -104,7 +109,7 @@ fun RepositoryListScreen(
) )
RepositoryListUiState.Loading -> { RepositoryListUiState.Loading -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout) BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
CenteredLoading(stringResource(R.string.repositories_loading)) ListSkeleton(label = stringResource(R.string.repositories_loading))
} }
is RepositoryListUiState.Empty -> { is RepositoryListUiState.Empty -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout) BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
@ -126,6 +131,7 @@ fun RepositoryListScreen(
stringResource(R.string.repositories_favorites_empty), stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action), action = stringResource(R.string.repositories_favorites_empty_action),
onAction = onShowAllRepositories, onAction = onShowAllRepositories,
decoration = { EmptyCup() },
) )
} else { } else {
CenteredMessage( CenteredMessage(
@ -238,7 +244,7 @@ private fun RepositoryContent(
state.cachedAtEpochMillis?.let { cachedAt -> state.cachedAtEpochMillis?.let { cachedAt ->
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(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( 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),
@ -461,6 +467,7 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled
val pop = rememberPopScale(filled)
val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite) val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite)
Column( Column(
Modifier Modifier
@ -472,7 +479,14 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
UBIcon(if (filled) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star, null, tint = color, size = 20.dp) 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) 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)) { 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()) {

View File

@ -20,7 +20,6 @@ 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
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.SimplePaginationFooter
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.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.KindTile 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.StatusBadge
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.formatRelative import com.ubisam.ubgitea.ui.formatRelative
@ -87,8 +88,8 @@ fun WorkScreen(
} }
} }
when (state) { when (state) {
is WorkUiState.Idle, is WorkUiState.Loading -> CenteredLoading(stringResource(R.string.work_loading)) is WorkUiState.Idle, is WorkUiState.Loading -> ListSkeleton(label = stringResource(R.string.work_loading))
is WorkUiState.Empty -> CenteredMessage(stringResource(R.string.work_empty)) is WorkUiState.Empty -> CenteredMessage(stringResource(R.string.work_empty), decoration = { EmptyCup() })
is WorkUiState.Error -> { is WorkUiState.Error -> {
val expired = state.type == WorkError.AUTHENTICATION_EXPIRED val expired = state.type == WorkError.AUTHENTICATION_EXPIRED
CenteredMessage( CenteredMessage(
@ -145,7 +146,7 @@ private fun WorkList(
onPreviousPage: () -> Unit, onPreviousPage: () -> Unit,
onRefresh: () -> 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)) { LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
items( items(
count = state.items.size, count = state.items.size,

View File

@ -61,8 +61,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.Avatar import com.ubisam.ubgitea.ui.components.Avatar
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.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.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.SectionTitle import com.ubisam.ubgitea.ui.components.SectionTitle
@ -94,7 +96,7 @@ fun WorkItemsScreen(
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ListHeader(state.target, onBack) ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests) ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenteredLoading(null) ListSkeleton()
} }
is WorkItemsUiState.IssueList -> { is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
@ -146,7 +148,7 @@ fun WorkItemsScreen(
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ListHeader(state.target, onBack) ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests) ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenteredMessage(stringResource(R.string.workitems_empty)) CenteredMessage(stringResource(R.string.workitems_empty), decoration = { EmptyCup() })
} }
is WorkItemsUiState.ListError -> { is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)

View File

@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size
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.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -179,7 +178,7 @@ fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
CircularProgressIndicator() 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)
} }
} }
@ -192,12 +191,15 @@ fun CenteredMessage(
onAction: () -> Unit = {}, onAction: () -> Unit = {},
secondaryAction: String? = null, secondaryAction: String? = null,
onSecondaryAction: () -> Unit = {}, onSecondaryAction: () -> Unit = {},
// Drawn above the message: the bobbing cup on a screen with nothing to list.
decoration: (@Composable () -> Unit)? = null,
) { ) {
Column( Column(
modifier.fillMaxSize().padding(24.dp), modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
decoration?.invoke()
// Korean breaks between any two characters by default; this keeps words whole. // 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) 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) } if (action != null) Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }

View 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()) }
}
}

View File

@ -5,14 +5,14 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -27,12 +27,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.ui.components.UBIcon 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) { enum class AppTab(@StringRes val label: Int, @DrawableRes val icon: Int) {
HOME(R.string.shell_tab_home, R.drawable.ic_ub_home), HOME(R.string.shell_tab_home, R.drawable.ic_ub_home),
@ -97,7 +99,7 @@ private fun BottomTabs(selected: AppTab, unreadNotifications: Int?, onSelected:
NavigationBarItem( NavigationBarItem(
selected = tab == selected, selected = tab == selected,
onClick = { onSelected(tab) }, onClick = { onSelected(tab) },
icon = { TabIcon(tab, unreadNotifications) }, icon = { TabIcon(tab, unreadNotifications, tab == selected) },
label = { TabLabel(tab, tab == selected) }, label = { TabLabel(tab, tab == selected) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
@ -118,7 +120,7 @@ private fun SideTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (A
NavigationRailItem( NavigationRailItem(
selected = tab == selected, selected = tab == selected,
onClick = { onSelected(tab) }, onClick = { onSelected(tab) },
icon = { TabIcon(tab, unreadNotifications) }, icon = { TabIcon(tab, unreadNotifications, tab == selected) },
label = { TabLabel(tab, tab == selected) }, label = { TabLabel(tab, tab == selected) },
colors = NavigationRailItemDefaults.colors( colors = NavigationRailItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
@ -134,13 +136,20 @@ private fun SideTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (A
} }
@Composable @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 } 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) { if (unread == null) {
UBIcon(tab.icon, null) UBIcon(tab.icon, null, modifier = popModifier)
} else { } else {
BadgedBox(badge = { Badge { Text(if (unread > MAX_BADGE_COUNT) "$MAX_BADGE_COUNT+" else unread.toString()) } }) { 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)
} }
} }
} }

View File

@ -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 - 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. 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). - 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.

View 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.