From 11bb082f0f5e464722c143cd4469db58b4cb36ab Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 13:34:42 +0900 Subject: [PATCH] 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 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 60 +++- .../ubgitea/feature/history/FileDiffScreen.kt | 17 + .../ubgitea/feature/history/HistoryScreen.kt | 3 +- .../ubisam/ubgitea/feature/home/HomeScreen.kt | 8 +- .../notifications/NotificationsScreen.kt | 7 +- .../repositories/RepositoryListScreen.kt | 32 +- .../ubisam/ubgitea/feature/work/WorkScreen.kt | 11 +- .../feature/workitems/WorkItemsScreen.kt | 6 +- .../ubisam/ubgitea/ui/components/Elements.kt | 6 +- .../ubisam/ubgitea/ui/components/Motion.kt | 316 ++++++++++++++++++ .../com/ubisam/ubgitea/ui/shell/AppShell.kt | 25 +- docs/development-log.md | 17 + tasks/069-in-app-motion.md | 71 ++++ 13 files changed, 534 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt create mode 100644 tasks/069-in-app-motion.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 8082ad1..6d9247c 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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, @@ -569,6 +604,8 @@ class MainActivity : ComponentActivity() { onClose = workbenchViewModel::closeCompare, ) } + } + } } } LaunchedEffect(viewModel) { @@ -593,6 +630,7 @@ class MainActivity : ComponentActivity() { }, ) } + SplashOverlay() } } consumeCallback(intent) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt index da22317..23ee393 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt @@ -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, 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)) { @@ -251,11 +265,14 @@ private fun DiffList(entries: List, path: String, fontSizeSp: Int, si 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) @Composable diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt index 88b6f32..a6e0b5c 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt @@ -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 -> diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt index 075b744..8988b02 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt @@ -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. diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt index 83d4091..2b5f4d1 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt @@ -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( diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 6763159..2bf4d2d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -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 @@ -472,7 +479,14 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState horizontalAlignment = Alignment.CenterHorizontally, 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) } } @@ -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()) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt index 8096842..6addc1c 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt @@ -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, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt index f611017..7a110d2 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt @@ -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) diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt index 713a39d..98699a8 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt @@ -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) } diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt new file mode 100644 index 0000000..8f55813 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt @@ -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()) } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt b/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt index 4cd7b28..997cb87 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt @@ -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) } } } diff --git a/docs/development-log.md b/docs/development-log.md index b8e0393..d9aaa61 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md new file mode 100644 index 0000000..4439fae --- /dev/null +++ b/tasks/069-in-app-motion.md @@ -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.