Compare commits

...

4 Commits

Author SHA1 Message Date
dd90f6932b TASK-071: version 0.2.2 for the build published to the team's Gitea
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:04:50 +09:00
a9f08ebbdf TASK-070: update check and download read the team's Gitea
The old download page asked for a password and is no longer used. The
app now reads version.json and fetches UBGitea.apk from the apk branch.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:01:33 +09:00
11bb082f0f 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>
2026-10-12 13:34:42 +09:00
e47ad799f1 TASK-068: app icon shows a small tea cup instead of the branch
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:19:13 +09:00
21 changed files with 789 additions and 100 deletions

View File

@ -16,8 +16,8 @@ android {
targetSdk = 36
// Raised for every build published to the download page (TASK-066), or the app will not
// see it as newer.
versionCode = 3
versionName = "0.2.1"
versionCode = 4
versionName = "0.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -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)
@ -622,14 +660,14 @@ class MainActivity : ComponentActivity() {
}
}
// TASK-066: the download page asks for its password itself, so it opens in the browser.
// TASK-070: the address is the APK itself, so the browser it opens in downloads it.
private fun openDownloadPage() {
try {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD_PAGE))
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, AppUpdateLocations.DOWNLOAD_PAGE, Toast.LENGTH_LONG).show()
Toast.makeText(this, AppUpdateLocations.DOWNLOAD, Toast.LENGTH_LONG).show()
}
}

View File

@ -8,13 +8,15 @@ import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
* Where new builds of the app are published (TASK-066): a page that hands out the APK behind a
* password, and beside it a plain file naming the newest version. The app only ever reads that
* file; no Gitea token goes there.
* Where new builds of the app are published (TASK-070): the `apk` branch of the app's own
* repository on the team's Gitea holds the APK and, beside it, a file naming the newest version.
* Both are public and read without signing in; the app's Gitea token is not sent with either.
*/
object AppUpdateLocations {
const val DOWNLOAD_PAGE = "https://choimy0204.github.io/ubgitea/"
const val LATEST_VERSION = DOWNLOAD_PAGE + "version.json"
private const val RELEASES =
"https://gitea.ubisampaju.synology.me/RobotAutomationDevelopment-Dept/Ubisam-MobileGit/raw/branch/apk/"
const val DOWNLOAD = RELEASES + "UBGitea.apk"
const val LATEST_VERSION = RELEASES + "version.json"
}
/** The newest published build. */
@ -26,7 +28,7 @@ data class AppRelease(
/**
* Reads `{"versionCode": 2, "versionName": "0.2.0", "notes": "..."}`. Anything else is null, so
* a page that is broken or not there yet never offers an update.
* a file that is broken or not there yet never offers an update.
*/
fun parseAppRelease(json: String): AppRelease? = try {
val root = JsonParser.parseString(json).asJsonObject
@ -49,7 +51,7 @@ class HttpAppReleaseSource(
) : AppReleaseSource {
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
try {
// no-cache: GitHub Pages is cached for minutes, and a check by hand should see a release at once.
// no-cache: a check by hand should see a release at once, whatever sits in between.
val request = Request.Builder().url(url).header("Cache-Control", "no-cache").build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else parseAppRelease(response.body.string())

View File

@ -32,8 +32,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -67,9 +71,12 @@ import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
import com.ubisam.ubgitea.ui.components.CautionBanner
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.StaggerStepMillis
import com.ubisam.ubgitea.ui.components.StaggeredReveal
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@ -232,10 +239,17 @@ private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, si
val numberWidth = with(density) { (characterWidthPx * digits).toDp() } + 18.dp
val cell = DiffCellStyle(style, characterWidthPx, numberWidth)
// The first screenful arrives row by row, once per file; rows scrolled to later are just there.
var intro by remember(path) { mutableStateOf(true) }
LaunchedEffect(path) {
delay(DiffIntroRows * StaggerStepMillis + 300)
intro = false
}
SelectionContainer {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
items(entries.size, key = { it }) { index ->
val entry = entries[index]
StaggeredReveal(index, active = intro && index < DiffIntroRows) {
when {
entry.isHunkHeader -> HunkHeader(entry.hunkLabel.orEmpty())
sideBySide -> Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
@ -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())
}
}
}
}
}
}
}
private const val DiffIntroRows = 30
private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp)
@Composable

View File

@ -48,6 +48,7 @@ import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.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 ->

View File

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

View File

@ -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(

View File

@ -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()) {

View File

@ -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,

View File

@ -61,8 +61,10 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.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)

View File

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

View File

@ -0,0 +1,316 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlinx.coroutines.delay
import kotlin.math.PI
import kotlin.math.sin
// The app's moving parts. All of them draw the launcher icon's tea cup, so the app looks like one
// thing from the home screen inwards.
/**
* The launcher icon's cup on a 40 x 48 grid. [steam] is 0..1 through one rise of the steam, and
* [steamAlpha] fades both wisps together; 0 draws the cup alone.
*/
private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float = 1f) {
val unit = size.width / 40f
fun x(value: Float) = value * unit
val cup = Path().apply {
moveTo(x(3f), x(18f))
lineTo(x(29f), x(18f))
lineTo(x(29f), x(24f))
arcTo(Rect(x(3f), x(11f), x(29f), x(37f)), 0f, 180f, false)
close()
}
drawPath(cup, color)
drawPath(cup, color, style = Stroke(x(3f), join = StrokeJoin.Round))
val handle = Path().apply {
moveTo(x(30f), x(21f))
lineTo(x(32f), x(21f))
arcTo(Rect(x(26.5f), x(21f), x(37.5f), x(32f)), -90f, 180f, false)
lineTo(x(28f), x(32f))
}
drawPath(handle, color, style = Stroke(x(4f), cap = StrokeCap.Round, join = StrokeJoin.Round))
drawLine(color, Offset(x(5f), x(44f)), Offset(x(27f), x(44f)), x(3.6f), StrokeCap.Round)
if (steamAlpha <= 0f) return
// The second wisp runs half a cycle behind the first, so one is always on its way up.
for ((column, offset) in listOf(11f to 0f, 21f to 0.5f)) {
val phase = (steam + offset) % 1f
val rise = phase * 5f
val wisp = Path().apply {
moveTo(x(column), x(13f - rise))
cubicTo(x(column - 3f), x(9f - rise), x(column + 3f), x(7.5f - rise), x(column), x(3f - rise))
}
val alpha = sin(phase * PI).toFloat() * 0.8f * steamAlpha
drawPath(wisp, color.copy(alpha = color.alpha * alpha), style = Stroke(x(3f), cap = StrokeCap.Round))
}
}
@Composable
private fun rememberSteam(): () -> Float {
val transition = rememberInfiniteTransition(label = "steam")
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
return { steam }
}
/** The tea cup with rising steam: the app's "working on it" mark. */
@Composable
fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = UBGiteaTheme.colors.brand) {
val steam = rememberSteam()
Canvas(modifier.size(size, size * 1.2f)) { drawTeaCup(color, steam()) }
}
/**
* Grey placeholder rows with a light passing over them, shown while a list is on its way. [label]
* is what a screen reader says in place of the rows.
*/
@Composable
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
val transition = rememberInfiniteTransition(label = "shimmer")
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
Column(
modifier
.fillMaxSize()
.semantics { if (label != null) contentDescription = label }
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
ListCard {
repeat(rows) { row ->
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val shimmer = Modifier.drawShimmer(base, light) { sweep }
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Rows differ in length so the block does not read as a table.
Box(Modifier.fillMaxWidth(if (row % 2 == 0) 0.62f else 0.48f).height(14.dp).clip(CircleShape).then(shimmer))
Box(Modifier.fillMaxWidth(if (row % 3 == 0) 0.86f else 0.74f).height(10.dp).clip(CircleShape).then(shimmer))
}
}
}
}
}
}
// [sweep] is read while drawing, so the passing light costs a redraw per frame and no recomposition.
private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float): Modifier = drawBehind {
drawRect(base)
val centre = sweep() * size.width
drawRect(
Brush.horizontalGradient(
listOf(Color.Transparent, light, Color.Transparent),
startX = centre - size.width / 2f,
endX = centre + size.width / 2f,
),
)
}
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
@Composable
fun EmptyCup(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "empty")
val bob by transition.animateFloat(
-1f,
1f,
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
label = "bob",
)
val steam = rememberSteam()
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
Canvas(
modifier
.padding(bottom = 20.dp)
.size(56.dp, 67.dp)
.graphicsLayer {
translationY = bob * 3.dp.toPx()
rotationZ = bob * 2f
},
) { drawTeaCup(color, steam()) }
}
/**
* Pull to refresh with the cup in place of the stock spinner: it comes down with the finger, and
* the steam appears as the pull gets far enough to refresh.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeaPullToRefreshBox(onRefresh: () -> Unit, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = false,
onRefresh = onRefresh,
modifier = modifier,
state = state,
indicator = { TeaPullIndicator(state, Modifier.align(Alignment.TopCenter)) },
content = content,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeaPullIndicator(state: PullToRefreshState, modifier: Modifier = Modifier) {
// Nothing is composed, and nothing animates, until a pull starts.
val pulling by remember(state) { derivedStateOf { state.distanceFraction > 0f } }
if (!pulling) return
val steam = rememberSteam()
val color = UBGiteaTheme.colors.brand
Box(
modifier
.graphicsLayer {
val fraction = state.distanceFraction.coerceIn(0f, 1.3f)
translationY = fraction * 76.dp.toPx() - 52.dp.toPx()
alpha = fraction.coerceAtMost(1f)
val scale = 0.7f + 0.3f * fraction.coerceAtMost(1f)
scaleX = scale
scaleY = scale
}
.shadow(4.dp, CircleShape)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(25.dp, 30.dp)) {
// No steam for the first half of the pull, all of it at the point of release.
drawTeaCup(color, steam(), steamAlpha = ((state.distanceFraction - 0.5f) * 2f).coerceIn(0f, 1f))
}
}
}
/** 1 at rest; dips and springs back each time [key] changes after the first composition. */
@Composable
fun rememberPopScale(key: Any?): () -> Float {
val scale = remember { Animatable(1f) }
var shown by remember { mutableStateOf(key) }
LaunchedEffect(key) {
if (shown != key) {
shown = key
scale.snapTo(0.55f)
scale.animateTo(1f, spring(dampingRatio = 0.35f, stiffness = Spring.StiffnessMedium))
}
}
return { scale.value }
}
/**
* Fades and slides [content] in, later for a larger [index], so rows arrive one after another.
* With [active] false the content is simply there.
*/
@Composable
fun StaggeredReveal(index: Int, active: Boolean, content: @Composable () -> Unit) {
val reveal = remember { Animatable(if (active) 0f else 1f) }
LaunchedEffect(Unit) {
if (reveal.value < 1f) {
delay(index * StaggerStepMillis)
reveal.animateTo(1f, tween(220))
}
}
Box(
Modifier.graphicsLayer {
alpha = reveal.value
translationX = (1f - reveal.value) * 20.dp.toPx()
},
) { content() }
}
const val StaggerStepMillis = 26L
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
/**
* The launcher icon, enlarged and steaming, over the whole window for the first second after a
* cold start. It covers the app rather than delaying it: everything underneath loads meanwhile.
*/
@Composable
fun SplashOverlay() {
var finished by rememberSaveable { mutableStateOf(false) }
if (finished) return
val alpha = remember { Animatable(1f) }
val scale = remember { Animatable(0.85f) }
LaunchedEffect(Unit) {
scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow))
}
LaunchedEffect(Unit) {
delay(1100)
alpha.animateTo(0f, tween(280))
finished = true
}
val steam = rememberSteam()
Box(
Modifier
.fillMaxSize()
.graphicsLayer { this.alpha = alpha.value }
.background(Brush.linearGradient(SplashGradient))
// Swallows taps so nothing underneath is pressed through the cover.
.clickable(remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
Canvas(
Modifier.size(96.dp, 115.dp).graphicsLayer {
scaleX = scale.value
scaleY = scale.value
},
) { drawTeaCup(Color.White, steam()) }
}
}

View File

@ -5,14 +5,14 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.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)
}
}
}

View File

@ -1,41 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App icon, front layer (TASK-061): a branch that leaves the trunk, its commits drawn as rings. It says "Git" without using Gitea's own logo. Everything stays inside the 66dp safe circle of the 108dp canvas, which is all that some launchers show -->
<!-- App icon, front layer (TASK-068): a small steaming tea cup, for the "tea" in Gitea. It replaces the branch drawing of TASK-061. Everything stays well inside the 66dp safe circle of the 108dp canvas -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Drawn large, then brought inside the safe circle. -->
<!-- Drawn around (56,55), then made smaller and centred: the cup is meant to sit small, with room around it. -->
<group
android:pivotX="54"
android:pivotY="54"
android:scaleX="0.85"
android:scaleY="0.85"
android:translateX="-2">
android:pivotX="56"
android:pivotY="55"
android:scaleX="0.9"
android:scaleY="0.9"
android:translateX="-2"
android:translateY="-1">
<!-- Steam -->
<path
android:pathData="M41,74 L41,34"
android:pathData="M47,44 c-3,-4 3,-5.5 0,-10 M57,44 c-3,-4 3,-5.5 0,-10"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="6.5"
android:strokeAlpha="0.8"
android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- Handle -->
<path
android:pathData="M41,74 C41,57 72,61 72,45"
android:pathData="M66,53 h2 a5.5,5.5 0 0 1 0,11 h-4"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="6.5"
android:strokeWidth="4"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
<!-- Cup -->
<path
android:pathData="M39,50 H65 V56 a13,13 0 0 1 -26,0 Z"
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M31.5,76 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,76 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3"
android:strokeLineJoin="round" />
<!-- Saucer -->
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M31.5,32 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,32 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M62.5,43 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M67.5,43 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
android:pathData="M41,76 H63"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="3.6"
android:strokeLineCap="round"
android:fillColor="#00000000" />
</group>
</vector>

View File

@ -5,37 +5,42 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Drawn large, then brought inside the safe circle. -->
<!-- Drawn around (56,55), then made smaller and centred: the cup is meant to sit small, with room around it. -->
<group
android:pivotX="54"
android:pivotY="54"
android:scaleX="0.85"
android:scaleY="0.85"
android:translateX="-2">
android:pivotX="56"
android:pivotY="55"
android:scaleX="0.9"
android:scaleY="0.9"
android:translateX="-2"
android:translateY="-1">
<!-- Steam -->
<path
android:pathData="M41,74 L41,34"
android:pathData="M47,44 c-3,-4 3,-5.5 0,-10 M57,44 c-3,-4 3,-5.5 0,-10"
android:strokeColor="#FF000000"
android:strokeWidth="6.5"
android:strokeAlpha="0.8"
android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- Handle -->
<path
android:pathData="M41,74 C41,57 72,61 72,45"
android:pathData="M66,53 h2 a5.5,5.5 0 0 1 0,11 h-4"
android:strokeColor="#FF000000"
android:strokeWidth="6.5"
android:strokeWidth="4"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
<!-- Cup -->
<path
android:pathData="M39,50 H65 V56 a13,13 0 0 1 -26,0 Z"
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M31.5,76 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,76 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
android:strokeColor="#FF000000"
android:strokeWidth="3"
android:strokeLineJoin="round" />
<!-- Saucer -->
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M31.5,32 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,32 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M62.5,43 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M67.5,43 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
android:pathData="M41,76 H63"
android:strokeColor="#FF000000"
android:strokeWidth="3.6"
android:strokeLineCap="round"
android:fillColor="#00000000" />
</group>
</vector>

View File

@ -7,9 +7,9 @@
<string name="update_up_to_date">최신 버전입니다.</string>
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
<string name="update_download">다운로드 페이지 열기</string>
<string name="update_download">새 버전 받기</string>
<string name="update_dialog_title">새 버전이 있습니다</string>
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요.</string>
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드를 누르면 설치 파일을 받습니다. 받은 파일을 열어 설치하세요.</string>
<string name="update_dialog_download">다운로드</string>
<string name="update_dialog_later">나중에</string>
</resources>

View File

@ -1491,3 +1491,54 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- The repository is public and the product owner chose to publish as is. The in-app update check
still reads the download page's `version.json` (versionCode 2), so 0.2.1 is not offered there.
- Verification: the five required checks pass on the merged branch; 471 unit tests, 0 failures.
## 2026-10-12 — TASK-068: app icon shows a tea cup
- Request: the human product owner wanted a different app icon. After several rounds of sketches
they chose the current blue background with a small tea cup in place of the Git drawing.
- 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.
## 2026-10-12 — TASK-070: update check and download from the team's Gitea
- Request: the update notice opened the old password page; the product owner no longer uses that
page and asked for Gitea.
- What changed: `AppUpdateLocations` names `version.json` and `UBGitea.apk` on the `apk` branch of
the team's repository; the wording drops the password and the "download page". `version.json`
(0.2.1, versionCode 3) was added to the `apk` branch to match the APK already there.
- Checks and results: the five required checks pass; lint 0 errors. Both addresses answer 200
without signing in.
- Caution: no build with this change is published yet. Copies installed so far still read the
old page, so they will not be told about builds published on Gitea; they need one install by
hand. `tools/publish-download.py` and the old page are left alone and unused.
- Next owner: the product owner, to say when a build with the new icon, motion and this change
should be published as 0.2.2.
## 2026-10-12 — TASK-071: 0.2.2 published to the team's Gitea
- Request: the product owner asked for 0.2.2 to be published.
- What changed: version 0.2.2 (versionCode 4) with the tea cup icon, the in-app motion and the
Gitea update check. Source pushed to `main`; `UBGitea.apk` and `version.json` on the `apk`
branch replaced.
- Checks and results: the five required checks pass; lint 0 errors. The APK downloaded from the
`apk` branch has the same SHA-256 as the one built here.
- Caution: published without having been seen on a device, at the product owner's decision. The
APK is the debug build signed with this PC's key.

View File

@ -0,0 +1,47 @@
# TASK-068: App icon — a small tea cup instead of the branch
Status: implemented; required checks pass; not yet seen on a device
Owner: Claude
Reviewer: human product owner
## Outcome
The app icon keeps its sky-blue background and shows a small white steaming tea cup (the "tea" in
Gitea) where the Git branch drawing was.
## Scope
- Included: the icon's front layer and its themed (monochrome) layer.
- Excluded: the background layer, the notification icon, the download page.
## Writable paths
- `app/src/main/res/drawable/ic_launcher_foreground.xml`
- `app/src/main/res/drawable/ic_launcher_monochrome.xml`
- `tasks/068-app-icon-tea-cup.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] The front layer is the cup the product owner chose (option A of the sketches, 2026-10-12):
cup, handle, two wisps of steam, saucer line; no Git drawing.
- [x] The themed layer has the same shape.
- [x] The drawing stays inside the 66dp safe circle.
## Security and privacy
No additional concerns.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-068.
## Review findings
None.

View File

@ -0,0 +1,71 @@
# TASK-069: In-app motion
Status: implemented; required checks pass; not yet seen on a device
Owner: Claude
Reviewer: human product owner
## Outcome
The app moves where it used to jump, and what moves is the launcher icon's tea cup.
1. Cold start: the cup on the icon's blue, steaming, for about a second, then it fades.
2. Loading: lists show grey placeholder rows with a light passing over them; everything else shows
the steaming cup in place of the stock spinner.
3. The favourite star springs when its state changes.
4. A screen with nothing to list shows the cup bobbing above its message.
5. Entering and leaving a screen slides it a short way in from the side it was opened towards.
6. Pull to refresh brings the cup down with the finger; the steam appears near the release point.
7. A file's diff arrives row by row the first time it is shown.
8. Added while this was in progress (product owner, 2026-10-12): switching bottom tabs slides the
screen in from the side that tab sits on, and the tab's icon springs.
## Scope
- Included: Compose's own animation APIs only. No new dependency.
- Excluded: videos made with Claude Motion; any change to what the screens load or show.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt`
- `app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt`
- `tasks/069-in-app-motion.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] Each of the eight items above is in the code and the app builds.
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
underneath, and taps do not pass through the cover.
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen
transition.
- [x] Nothing animates off screen: the pull indicator composes only during a pull, and the diff
rows animate once per file, not on scroll.
- [x] A list's loading message is still what a screen reader says.
- [ ] Seen on the product owner's device.
## Security and privacy
No additional concerns. No network, storage or credential code is touched.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-069.
## Review findings
None yet.

View File

@ -0,0 +1,65 @@
# TASK-070: The app's update check and download use the team's Gitea
Status: implemented; required checks pass; not yet seen on a device
Owner: Claude
Reviewer: human product owner
## Request
The update notice in the app opened the old download page, which asks for a password. The product
owner no longer uses that page (2026-10-12) and asked for the app to use the Gitea link instead.
## Outcome
- The app reads the newest version from `version.json` on the `apk` branch of
`RobotAutomationDevelopment-Dept/Ubisam-MobileGit`, and "download" opens `UBGitea.apk` on that
branch. Neither asks for a password or a sign-in.
- The wording no longer mentions a password or a download page.
## Scope
- Included: the two addresses, the wording, `version.json` on the `apk` branch.
- Excluded: `tools/publish-download.py` and the `choimy0204.github.io/ubgitea/` page. Both are left
as they are and are no longer part of publishing.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/res/values/strings_update.xml`
- `tasks/070-update-from-gitea.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] No address of the old page is left in the app.
- [x] `version.json` is on the `apk` branch and names the APK that is there.
- [ ] Seen on a device: the notice appears for a newer build and "download" fetches the APK.
## Security and privacy
The two addresses are public and are fetched with a client that carries no Gitea token. The
repository being public was the product owner's choice in TASK-067.
## Publishing a build from now on
1. Raise `versionCode` and `versionName` in `app/build.gradle.kts`, then `assembleDebug`.
2. On the `apk` branch, replace `UBGitea.apk` and set `version.json` to the same version.
3. Push the `apk` branch.
Copies installed before this task still read the old page's `version.json`, so they are never told
about a build published here; they need one install by hand from the Gitea link.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-070.
## Review findings
None yet.

View File

@ -0,0 +1,42 @@
# TASK-071: Publish 0.2.2 to the team's Gitea
Status: done
Owner: Claude
Reviewer: human product owner
## Request
The product owner asked for the build with the tea cup icon (TASK-068), the in-app motion
(TASK-069) and the Gitea update check (TASK-070) to be published as 0.2.2 (2026-10-12).
## Writable paths
- `app/build.gradle.kts`
- `tasks/071-publish-0.2.2.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] Version 0.2.2, versionCode 4.
- [x] The source is on `main` of the team's repository.
- [x] `UBGitea.apk` and `version.json` on the `apk` branch are 0.2.2, and the downloaded APK is
byte-identical to the one built here.
## Security and privacy
The APK is the debug build, signed with this PC's debug key, as before. No secret is in it or in
the push.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-071.
## Review findings
None.