Compare commits
4 Commits
4b03c52975
...
dd90f6932b
| Author | SHA1 | Date | |
|---|---|---|---|
| dd90f6932b | |||
| a9f08ebbdf | |||
| 11bb082f0f | |||
| e47ad799f1 |
@ -16,8 +16,8 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// Raised for every build published to the download page (TASK-066), or the app will not
|
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||||
// see it as newer.
|
// see it as newer.
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.2.1"
|
versionName = "0.2.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
@ -571,6 +606,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||||
}
|
}
|
||||||
@ -593,6 +630,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SplashOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
consumeCallback(intent)
|
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() {
|
private fun openDownloadPage() {
|
||||||
try {
|
try {
|
||||||
CustomTabsIntent.Builder()
|
CustomTabsIntent.Builder()
|
||||||
.build()
|
.build()
|
||||||
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD_PAGE))
|
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD))
|
||||||
} catch (_: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
Toast.makeText(this, AppUpdateLocations.DOWNLOAD_PAGE, Toast.LENGTH_LONG).show()
|
Toast.makeText(this, AppUpdateLocations.DOWNLOAD, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,13 +8,15 @@ import okhttp3.Request
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where new builds of the app are published (TASK-066): a page that hands out the APK behind a
|
* Where new builds of the app are published (TASK-070): the `apk` branch of the app's own
|
||||||
* password, and beside it a plain file naming the newest version. The app only ever reads that
|
* repository on the team's Gitea holds the APK and, beside it, a file naming the newest version.
|
||||||
* file; no Gitea token goes there.
|
* Both are public and read without signing in; the app's Gitea token is not sent with either.
|
||||||
*/
|
*/
|
||||||
object AppUpdateLocations {
|
object AppUpdateLocations {
|
||||||
const val DOWNLOAD_PAGE = "https://choimy0204.github.io/ubgitea/"
|
private const val RELEASES =
|
||||||
const val LATEST_VERSION = DOWNLOAD_PAGE + "version.json"
|
"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. */
|
/** The newest published build. */
|
||||||
@ -26,7 +28,7 @@ data class AppRelease(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads `{"versionCode": 2, "versionName": "0.2.0", "notes": "..."}`. Anything else is null, so
|
* 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 {
|
fun parseAppRelease(json: String): AppRelease? = try {
|
||||||
val root = JsonParser.parseString(json).asJsonObject
|
val root = JsonParser.parseString(json).asJsonObject
|
||||||
@ -49,7 +51,7 @@ class HttpAppReleaseSource(
|
|||||||
) : AppReleaseSource {
|
) : AppReleaseSource {
|
||||||
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
|
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
|
||||||
try {
|
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()
|
val request = Request.Builder().url(url).header("Cache-Control", "no-cache").build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) null else parseAppRelease(response.body.string())
|
if (!response.isSuccessful) null else parseAppRelease(response.body.string())
|
||||||
|
|||||||
@ -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)) {
|
||||||
@ -254,8 +268,11 @@ private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, si
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val DiffIntroRows = 30
|
||||||
|
|
||||||
private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp)
|
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
|
||||||
@ -471,8 +478,15 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
|
|||||||
.padding(4.dp),
|
.padding(4.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.graphicsLayer {
|
||||||
|
scaleX = pop()
|
||||||
|
scaleY = pop()
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
UBIcon(if (filled) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star, null, tint = color, size = 20.dp)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,46 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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
|
<group
|
||||||
android:pivotX="54"
|
android:pivotX="56"
|
||||||
android:pivotY="54"
|
android:pivotY="55"
|
||||||
android:scaleX="0.85"
|
android:scaleX="0.9"
|
||||||
android:scaleY="0.85"
|
android:scaleY="0.9"
|
||||||
android:translateX="-2">
|
android:translateX="-2"
|
||||||
|
android:translateY="-1">
|
||||||
|
<!-- Steam -->
|
||||||
<path
|
<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:strokeColor="#FFFFFFFF"
|
||||||
android:strokeWidth="6.5"
|
android:strokeAlpha="0.8"
|
||||||
|
android:strokeWidth="3"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#00000000" />
|
android:fillColor="#00000000" />
|
||||||
|
<!-- Handle -->
|
||||||
<path
|
<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:strokeColor="#FFFFFFFF"
|
||||||
android:strokeWidth="6.5"
|
android:strokeWidth="4"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#00000000" />
|
android:fillColor="#00000000" />
|
||||||
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
|
<!-- Cup -->
|
||||||
<path
|
<path
|
||||||
|
android:pathData="M39,50 H65 V56 a13,13 0 0 1 -26,0 Z"
|
||||||
android:fillColor="#FFFFFFFF"
|
android:fillColor="#FFFFFFFF"
|
||||||
android:fillType="evenOdd"
|
android:strokeColor="#FFFFFFFF"
|
||||||
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:strokeWidth="3"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
<!-- Saucer -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFFFF"
|
android:pathData="M41,76 H63"
|
||||||
android:fillType="evenOdd"
|
android:strokeColor="#FFFFFFFF"
|
||||||
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" />
|
android:strokeWidth="3.6"
|
||||||
<path
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#FFFFFFFF"
|
android:fillColor="#00000000" />
|
||||||
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" />
|
|
||||||
</group>
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@ -5,37 +5,42 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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
|
<group
|
||||||
android:pivotX="54"
|
android:pivotX="56"
|
||||||
android:pivotY="54"
|
android:pivotY="55"
|
||||||
android:scaleX="0.85"
|
android:scaleX="0.9"
|
||||||
android:scaleY="0.85"
|
android:scaleY="0.9"
|
||||||
android:translateX="-2">
|
android:translateX="-2"
|
||||||
|
android:translateY="-1">
|
||||||
|
<!-- Steam -->
|
||||||
<path
|
<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:strokeColor="#FF000000"
|
||||||
android:strokeWidth="6.5"
|
android:strokeAlpha="0.8"
|
||||||
|
android:strokeWidth="3"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#00000000" />
|
android:fillColor="#00000000" />
|
||||||
|
<!-- Handle -->
|
||||||
<path
|
<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:strokeColor="#FF000000"
|
||||||
android:strokeWidth="6.5"
|
android:strokeWidth="4"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#00000000" />
|
android:fillColor="#00000000" />
|
||||||
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
|
<!-- Cup -->
|
||||||
<path
|
<path
|
||||||
|
android:pathData="M39,50 H65 V56 a13,13 0 0 1 -26,0 Z"
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="#FF000000"
|
||||||
android:fillType="evenOdd"
|
android:strokeColor="#FF000000"
|
||||||
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:strokeWidth="3"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
<!-- Saucer -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
android:pathData="M41,76 H63"
|
||||||
android:fillType="evenOdd"
|
android:strokeColor="#FF000000"
|
||||||
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" />
|
android:strokeWidth="3.6"
|
||||||
<path
|
android:strokeLineCap="round"
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="#00000000" />
|
||||||
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" />
|
|
||||||
</group>
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
<string name="update_up_to_date">최신 버전입니다.</string>
|
<string name="update_up_to_date">최신 버전입니다.</string>
|
||||||
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
||||||
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</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_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_download">다운로드</string>
|
||||||
<string name="update_dialog_later">나중에</string>
|
<string name="update_dialog_later">나중에</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -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
|
- 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.
|
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.
|
- 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.
|
||||||
|
|||||||
47
tasks/068-app-icon-tea-cup.md
Normal file
47
tasks/068-app-icon-tea-cup.md
Normal 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.
|
||||||
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.
|
||||||
65
tasks/070-update-from-gitea.md
Normal file
65
tasks/070-update-from-gitea.md
Normal 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.
|
||||||
42
tasks/071-publish-0.2.2.md
Normal file
42
tasks/071-publish-0.2.2.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user