TASK-069: in-app motion built on the tea cup
Splash, cup loader, list skeleton, empty-screen cup, pull to refresh indicator, star and tab icon spring, screen and tab transitions, and a row by row diff intro. Compose animation APIs only. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e47ad799f1
commit
11bb082f0f
@ -16,38 +16,48 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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()) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
316
app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt
Normal file
316
app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
// The app's moving parts. All of them draw the launcher icon's tea cup, so the app looks like one
|
||||||
|
// thing from the home screen inwards.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher icon's cup on a 40 x 48 grid. [steam] is 0..1 through one rise of the steam, and
|
||||||
|
* [steamAlpha] fades both wisps together; 0 draws the cup alone.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float = 1f) {
|
||||||
|
val unit = size.width / 40f
|
||||||
|
fun x(value: Float) = value * unit
|
||||||
|
val cup = Path().apply {
|
||||||
|
moveTo(x(3f), x(18f))
|
||||||
|
lineTo(x(29f), x(18f))
|
||||||
|
lineTo(x(29f), x(24f))
|
||||||
|
arcTo(Rect(x(3f), x(11f), x(29f), x(37f)), 0f, 180f, false)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(cup, color)
|
||||||
|
drawPath(cup, color, style = Stroke(x(3f), join = StrokeJoin.Round))
|
||||||
|
val handle = Path().apply {
|
||||||
|
moveTo(x(30f), x(21f))
|
||||||
|
lineTo(x(32f), x(21f))
|
||||||
|
arcTo(Rect(x(26.5f), x(21f), x(37.5f), x(32f)), -90f, 180f, false)
|
||||||
|
lineTo(x(28f), x(32f))
|
||||||
|
}
|
||||||
|
drawPath(handle, color, style = Stroke(x(4f), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||||
|
drawLine(color, Offset(x(5f), x(44f)), Offset(x(27f), x(44f)), x(3.6f), StrokeCap.Round)
|
||||||
|
if (steamAlpha <= 0f) return
|
||||||
|
// The second wisp runs half a cycle behind the first, so one is always on its way up.
|
||||||
|
for ((column, offset) in listOf(11f to 0f, 21f to 0.5f)) {
|
||||||
|
val phase = (steam + offset) % 1f
|
||||||
|
val rise = phase * 5f
|
||||||
|
val wisp = Path().apply {
|
||||||
|
moveTo(x(column), x(13f - rise))
|
||||||
|
cubicTo(x(column - 3f), x(9f - rise), x(column + 3f), x(7.5f - rise), x(column), x(3f - rise))
|
||||||
|
}
|
||||||
|
val alpha = sin(phase * PI).toFloat() * 0.8f * steamAlpha
|
||||||
|
drawPath(wisp, color.copy(alpha = color.alpha * alpha), style = Stroke(x(3f), cap = StrokeCap.Round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSteam(): () -> Float {
|
||||||
|
val transition = rememberInfiniteTransition(label = "steam")
|
||||||
|
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
|
||||||
|
return { steam }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tea cup with rising steam: the app's "working on it" mark. */
|
||||||
|
@Composable
|
||||||
|
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
|
||||||
|
val steam = rememberSteam()
|
||||||
|
Canvas(modifier.size(size, size * 1.2f)) { drawTeaCup(color, steam()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grey placeholder rows with a light passing over them, shown while a list is on its way. [label]
|
||||||
|
* is what a screen reader says in place of the rows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
|
||||||
|
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
|
||||||
|
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.semantics { if (label != null) contentDescription = label }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
ListCard {
|
||||||
|
repeat(rows) { row ->
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val shimmer = Modifier.drawShimmer(base, light) { sweep }
|
||||||
|
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// Rows differ in length so the block does not read as a table.
|
||||||
|
Box(Modifier.fillMaxWidth(if (row % 2 == 0) 0.62f else 0.48f).height(14.dp).clip(CircleShape).then(shimmer))
|
||||||
|
Box(Modifier.fillMaxWidth(if (row % 3 == 0) 0.86f else 0.74f).height(10.dp).clip(CircleShape).then(shimmer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [sweep] is read while drawing, so the passing light costs a redraw per frame and no recomposition.
|
||||||
|
private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float): Modifier = drawBehind {
|
||||||
|
drawRect(base)
|
||||||
|
val centre = sweep() * size.width
|
||||||
|
drawRect(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
listOf(Color.Transparent, light, Color.Transparent),
|
||||||
|
startX = centre - size.width / 2f,
|
||||||
|
endX = centre + size.width / 2f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
|
||||||
|
@Composable
|
||||||
|
fun EmptyCup(modifier: Modifier = Modifier) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "empty")
|
||||||
|
val bob by transition.animateFloat(
|
||||||
|
-1f,
|
||||||
|
1f,
|
||||||
|
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
||||||
|
label = "bob",
|
||||||
|
)
|
||||||
|
val steam = rememberSteam()
|
||||||
|
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
|
||||||
|
Canvas(
|
||||||
|
modifier
|
||||||
|
.padding(bottom = 20.dp)
|
||||||
|
.size(56.dp, 67.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationY = bob * 3.dp.toPx()
|
||||||
|
rotationZ = bob * 2f
|
||||||
|
},
|
||||||
|
) { drawTeaCup(color, steam()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull to refresh with the cup in place of the stock spinner: it comes down with the finger, and
|
||||||
|
* the steam appears as the pull gets far enough to refresh.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TeaPullToRefreshBox(onRefresh: () -> Unit, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||||
|
val state = rememberPullToRefreshState()
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = false,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
indicator = { TeaPullIndicator(state, Modifier.align(Alignment.TopCenter)) },
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TeaPullIndicator(state: PullToRefreshState, modifier: Modifier = Modifier) {
|
||||||
|
// Nothing is composed, and nothing animates, until a pull starts.
|
||||||
|
val pulling by remember(state) { derivedStateOf { state.distanceFraction > 0f } }
|
||||||
|
if (!pulling) return
|
||||||
|
val steam = rememberSteam()
|
||||||
|
val color = UBGiteaTheme.colors.brand
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
val fraction = state.distanceFraction.coerceIn(0f, 1.3f)
|
||||||
|
translationY = fraction * 76.dp.toPx() - 52.dp.toPx()
|
||||||
|
alpha = fraction.coerceAtMost(1f)
|
||||||
|
val scale = 0.7f + 0.3f * fraction.coerceAtMost(1f)
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.shadow(4.dp, CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.size(25.dp, 30.dp)) {
|
||||||
|
// No steam for the first half of the pull, all of it at the point of release.
|
||||||
|
drawTeaCup(color, steam(), steamAlpha = ((state.distanceFraction - 0.5f) * 2f).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1 at rest; dips and springs back each time [key] changes after the first composition. */
|
||||||
|
@Composable
|
||||||
|
fun rememberPopScale(key: Any?): () -> Float {
|
||||||
|
val scale = remember { Animatable(1f) }
|
||||||
|
var shown by remember { mutableStateOf(key) }
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
if (shown != key) {
|
||||||
|
shown = key
|
||||||
|
scale.snapTo(0.55f)
|
||||||
|
scale.animateTo(1f, spring(dampingRatio = 0.35f, stiffness = Spring.StiffnessMedium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { scale.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades and slides [content] in, later for a larger [index], so rows arrive one after another.
|
||||||
|
* With [active] false the content is simply there.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StaggeredReveal(index: Int, active: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val reveal = remember { Animatable(if (active) 0f else 1f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (reveal.value < 1f) {
|
||||||
|
delay(index * StaggerStepMillis)
|
||||||
|
reveal.animateTo(1f, tween(220))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.graphicsLayer {
|
||||||
|
alpha = reveal.value
|
||||||
|
translationX = (1f - reveal.value) * 20.dp.toPx()
|
||||||
|
},
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const val StaggerStepMillis = 26L
|
||||||
|
|
||||||
|
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher icon, enlarged and steaming, over the whole window for the first second after a
|
||||||
|
* cold start. It covers the app rather than delaying it: everything underneath loads meanwhile.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SplashOverlay() {
|
||||||
|
var finished by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (finished) return
|
||||||
|
val alpha = remember { Animatable(1f) }
|
||||||
|
val scale = remember { Animatable(0.85f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow))
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(1100)
|
||||||
|
alpha.animateTo(0f, tween(280))
|
||||||
|
finished = true
|
||||||
|
}
|
||||||
|
val steam = rememberSteam()
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer { this.alpha = alpha.value }
|
||||||
|
.background(Brush.linearGradient(SplashGradient))
|
||||||
|
// Swallows taps so nothing underneath is pressed through the cover.
|
||||||
|
.clickable(remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
|
Modifier.size(96.dp, 115.dp).graphicsLayer {
|
||||||
|
scaleX = scale.value
|
||||||
|
scaleY = scale.value
|
||||||
|
},
|
||||||
|
) { drawTeaCup(Color.White, steam()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,14 +5,14 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
71
tasks/069-in-app-motion.md
Normal file
71
tasks/069-in-app-motion.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# TASK-069: In-app motion
|
||||||
|
|
||||||
|
Status: implemented; required checks pass; not yet seen on a device
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The app moves where it used to jump, and what moves is the launcher icon's tea cup.
|
||||||
|
|
||||||
|
1. Cold start: the cup on the icon's blue, steaming, for about a second, then it fades.
|
||||||
|
2. Loading: lists show grey placeholder rows with a light passing over them; everything else shows
|
||||||
|
the steaming cup in place of the stock spinner.
|
||||||
|
3. The favourite star springs when its state changes.
|
||||||
|
4. A screen with nothing to list shows the cup bobbing above its message.
|
||||||
|
5. Entering and leaving a screen slides it a short way in from the side it was opened towards.
|
||||||
|
6. Pull to refresh brings the cup down with the finger; the steam appears near the release point.
|
||||||
|
7. A file's diff arrives row by row the first time it is shown.
|
||||||
|
8. Added while this was in progress (product owner, 2026-10-12): switching bottom tabs slides the
|
||||||
|
screen in from the side that tab sits on, and the tab's icon springs.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: Compose's own animation APIs only. No new dependency.
|
||||||
|
- Excluded: videos made with Claude Motion; any change to what the screens load or show.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt`
|
||||||
|
- `tasks/069-in-app-motion.md`
|
||||||
|
- `docs/development-log.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Each of the eight items above is in the code and the app builds.
|
||||||
|
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
|
||||||
|
underneath, and taps do not pass through the cover.
|
||||||
|
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen
|
||||||
|
transition.
|
||||||
|
- [x] Nothing animates off screen: the pull indicator composes only during a pull, and the diff
|
||||||
|
rows animate once per file, not on scroll.
|
||||||
|
- [x] A list's loading message is still what a screen reader says.
|
||||||
|
- [ ] Seen on the product owner's device.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
No additional concerns. No network, storage or credential code is touched.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
See the development log entry for TASK-069.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None yet.
|
||||||
Loading…
x
Reference in New Issue
Block a user