TASK-055: first device feedback on the redesign

Repository names get a line to themselves; the repository, work and notification tabs lose their title; a today count on Home opens today's activity of that kind; the outline is a foldable tree of classes and methods without fields; Home can show only starred repositories and remembers the choice; settings has a light/dark/system theme choice.

Whether the app is dark is now the app's decision (UBGiteaTheme.isDark), so the logo moved off a -night drawable. 420 unit tests pass, lint reports 0 errors. Installed on the product owner's Z Fold7.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-28 15:09:39 +09:00
parent d1a45abc16
commit 9046985ba4
33 changed files with 867 additions and 140 deletions

View File

@ -3,17 +3,21 @@ package com.ubisam.ubgitea
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -22,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
@ -196,7 +201,24 @@ class MainActivity : ComponentActivity() {
else -> true
}
UBGiteaTheme {
val darkTheme = when (settingsState.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// The status and navigation bar icons have to contrast with the app's own theme, which
// the setting can make different from the system's.
DisposableEffect(darkTheme) {
val style = if (darkTheme) {
SystemBarStyle.dark(Color.TRANSPARENT)
} else {
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
}
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
onDispose {}
}
UBGiteaTheme(darkTheme = darkTheme) {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
@ -261,7 +283,7 @@ class MainActivity : ComponentActivity() {
when (destination) {
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
is HomeDestination.Work -> {
workViewModel.open(destination.scope)
workViewModel.open(destination.scope, destination.type)
selectTab(AppTab.WORK)
}
is HomeDestination.Branch -> push(
@ -277,8 +299,10 @@ class MainActivity : ComponentActivity() {
)
}
},
onSelectOrganization = homeViewModel::selectOrganization,
onSelectSource = homeViewModel::selectSource,
onSelectFilter = homeViewModel::selectFilter,
onSetTodayOnly = homeViewModel::setTodayOnly,
onShowToday = homeViewModel::showToday,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onViewSettings = { push(Route.Settings) },
@ -310,6 +334,7 @@ class MainActivity : ComponentActivity() {
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onSetThemeMode = settingsViewModel::setThemeMode,
onLogout = ::signOut,
onBack = { pop() },
)

View File

@ -12,6 +12,8 @@ import android.content.Context
* before this settings screen existed -- this toggle only adds an explicit way to turn *off* what
* was already always on, not a new default.
*/
enum class ThemeMode { SYSTEM, LIGHT, DARK }
interface AppSettingsStore {
fun isReactiveAuthRetryEnabled(): Boolean
fun setReactiveAuthRetryEnabled(enabled: Boolean)
@ -26,6 +28,15 @@ interface AppSettingsStore {
fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP
fun setCodeFontSizeSp(size: Int) = Unit
// Which activity Home opens on (see HomeSource.encode); null until one has been chosen.
fun homeSource(): String? = null
fun setHomeSource(source: String) = Unit
// Light, dark, or whatever the system is set to (TASK-055). Follows the system by default,
// which is what the app did before there was a choice.
fun themeMode(): ThemeMode = ThemeMode.SYSTEM
fun setThemeMode(mode: ThemeMode) = Unit
companion object {
const val DEFAULT_CODE_FONT_SIZE_SP = 13
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
@ -64,7 +75,22 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
preferences.edit().putInt(KEY_CODE_FONT_SIZE, size).apply()
}
override fun homeSource(): String? = preferences.getString(KEY_HOME_SOURCE, null)
override fun setHomeSource(source: String) {
preferences.edit().putString(KEY_HOME_SOURCE, source).apply()
}
override fun themeMode(): ThemeMode =
ThemeMode.entries.firstOrNull { it.name == preferences.getString(KEY_THEME_MODE, null) } ?: ThemeMode.SYSTEM
override fun setThemeMode(mode: ThemeMode) {
preferences.edit().putString(KEY_THEME_MODE, mode.name).apply()
}
private companion object {
const val KEY_HOME_SOURCE = "home_source"
const val KEY_THEME_MODE = "theme_mode"
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
const val KEY_CODE_FONT_SIZE = "code_font_size_sp"
const val PREFERENCES_NAME = "ubgitea_app_settings"

View File

@ -22,7 +22,8 @@ import java.time.LocalDate
import okhttp3.HttpUrl
/**
* Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`).
* Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`,
* `/repos/{owner}/{repo}/activities/feeds`).
*
* An activity's `content` has no schema in Gitea's API description; its shape depends on the
* activity type and was taken from Gitea's source rather than confirmed against this instance: a
@ -55,6 +56,14 @@ class GiteaActivityApi(
return listFeed(support.apiUrl("orgs", organization, "activities", "feeds"), date, page, pageSize)
}
override suspend fun listRepositoryFeed(
repository: RepositoryCoordinates,
date: LocalDate?,
page: Int,
pageSize: Int,
): Page<ActivityItem> =
listFeed(support.apiUrl("repos", repository.owner, repository.name, "activities", "feeds"), date, page, pageSize)
private suspend fun listFeed(url: HttpUrl.Builder, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem> =
with(support) {
validatePage(page, pageSize)

View File

@ -289,6 +289,7 @@ class GiteaRepositoryBrowserApi(
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
language = dto.language?.takeIf(String::isNotBlank),
starsCount = dto.starsCount ?: 0,
openPullRequestCount = dto.openPullRequestCount?.takeIf { it >= 0 },
)
}
@ -409,6 +410,7 @@ class GiteaRepositoryBrowserApi(
@SerializedName("updated_at") val updatedAt: String?,
val language: String?,
@SerializedName("stars_count") val starsCount: Int?,
@SerializedName("open_pr_counter") val openPullRequestCount: Int?,
)
private data class BranchDto(
val name: String?,

View File

@ -59,4 +59,6 @@ interface ActivityRepository {
suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
suspend fun listRepositoryFeed(repository: RepositoryCoordinates, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
}

View File

@ -16,6 +16,8 @@ data class RepositorySummary(
// unchanged.
val language: String? = null,
val starsCount: Int = 0,
// Also on the same response (`open_pr_counter`); null when the server did not send it.
val openPullRequestCount: Int? = null,
)
data class BranchSummary(

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -55,6 +54,7 @@ import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -217,7 +217,7 @@ private class SearchHitColors(val other: Color, val current: Color, val onCurren
@Composable
private fun searchHitColors(): SearchHitColors {
val dark = isSystemInDarkTheme()
val dark = UBGiteaTheme.isDark
return remember(dark) {
if (dark) {
SearchHitColors(Color(0xFF5A4713), Color(0xFFFFB84D), Color(0xFF1A1200))

View File

@ -92,6 +92,9 @@ import com.ubisam.ubgitea.feature.codebrowser.content.cppRoleOf
import com.ubisam.ubgitea.feature.codebrowser.content.enclosingSymbols
import com.ubisam.ubgitea.feature.codebrowser.content.findMatches
import com.ubisam.ubgitea.feature.codebrowser.content.findSymbolNamed
import com.ubisam.ubgitea.feature.codebrowser.content.isContainer
import com.ubisam.ubgitea.feature.codebrowser.content.methodCountOf
import com.ubisam.ubgitea.feature.codebrowser.content.outlineTree
import com.ubisam.ubgitea.feature.codebrowser.content.firstMatchFrom
import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
@ -159,6 +162,11 @@ fun CodeViewerScreen(
if (matches.isEmpty()) SearchHighlight.None else SearchHighlight(matches.groupBy { it.line }, matches.getOrNull(currentMatch))
}
var outlineOpen by remember(state.path) { mutableStateOf(false) }
// Start lines of the classes and namespaces folded shut in the outline.
var collapsed by remember(state.path) { mutableStateOf(emptySet<Int>()) }
val toggleCollapsed: (CodeSymbol) -> Unit = { symbol ->
collapsed = if (symbol.line in collapsed) collapsed - symbol.line else collapsed + symbol.line
}
var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
var sideBySide by rememberSaveable { mutableStateOf(true) }
var syncPanes by rememberSaveable { mutableStateOf(true) }
@ -275,6 +283,8 @@ fun CodeViewerScreen(
state = state,
document = document,
currentLine = firstVisibleLine,
collapsed = collapsed,
onToggleCollapsed = toggleCollapsed,
onOpenFile = { onOpenSibling(it, null) },
onOpenEntry = onOpenEntry,
onSymbolSelected = { goToLine(it.line) },
@ -313,6 +323,8 @@ fun CodeViewerScreen(
OutlineSheet(
document = document,
currentLine = firstVisibleLine,
collapsed = collapsed,
onToggleCollapsed = toggleCollapsed,
onSymbolSelected = {
outlineOpen = false
goToLine(it.line)
@ -799,12 +811,15 @@ private fun SidePane(
state: CodeBrowserUiState.File,
document: CodeDocument,
currentLine: Int,
collapsed: Set<Int>,
onToggleCollapsed: (CodeSymbol) -> Unit,
onOpenFile: (RepositoryPath) -> Unit,
onOpenEntry: (TreeEntry) -> Unit,
onSymbolSelected: (CodeSymbol) -> Unit,
modifier: Modifier = Modifier,
) {
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
val current = currentIn(tree, document.symbols, currentLine)
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
if (state.siblingEntries.isNotEmpty()) {
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
@ -821,10 +836,17 @@ private fun SidePane(
}
item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) }
}
if (document.symbols.isNotEmpty()) {
if (tree.isNotEmpty()) {
item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) }
items(document.symbols, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
SymbolRow(symbol, isCurrent = symbol == current, compact = true) { onSymbolSelected(symbol) }
items(tree, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
SymbolRow(
symbol = symbol,
isCurrent = symbol == current,
compact = true,
isCollapsed = symbol.line in collapsed,
methodCount = methodCountOf(document.symbols, symbol),
onToggle = { onToggleCollapsed(symbol) },
) { onSymbolSelected(symbol) }
}
}
}
@ -865,8 +887,23 @@ private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean
}
}
/**
* The row to mark as "you are here": the innermost enclosing symbol that is on screen in the tree.
* Inside a folded class that is the class itself, since its methods are hidden.
*/
private fun currentIn(tree: List<CodeSymbol>, symbols: List<CodeSymbol>, line: Int): CodeSymbol? =
enclosingSymbols(symbols, line).lastOrNull { it in tree }
@Composable
private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean, onClick: () -> Unit) {
private fun SymbolRow(
symbol: CodeSymbol,
isCurrent: Boolean,
compact: Boolean,
isCollapsed: Boolean,
methodCount: Int,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
val colors = rememberCodeSyntaxColors()
val (letter, color, kind) = when (symbol.kind) {
SymbolKind.NAMESPACE -> Triple("N", MaterialTheme.colorScheme.onSurfaceVariant, R.string.codeviewer_symbol_namespace)
@ -883,10 +920,23 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
.clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isCurrent }
.heightIn(min = if (compact) 44.dp else 48.dp)
.padding(start = base + step * symbol.depth, end = base),
.padding(start = (base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// A class or namespace folds; a method has nothing under it, and keeps the same indent.
if (symbol.isContainer) {
IconButton(onClick = onToggle, modifier = Modifier.size(if (compact) 32.dp else 40.dp)) {
UBIcon(
if (isCollapsed) R.drawable.ic_ub_chevron_right else R.drawable.ic_ub_chevron_down,
stringResource(if (isCollapsed) R.string.codeviewer_outline_expand else R.string.codeviewer_outline_collapse),
size = 18.dp,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Spacer(Modifier.size(if (compact) 32.dp else 40.dp))
}
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = color,
@ -902,12 +952,20 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
symbol.label,
fontFamily = FontFamily.Monospace,
fontSize = if (compact) 13.sp else 14.sp,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
fontWeight = if (isCurrent || symbol.isContainer) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (!compact) {
if (symbol.isContainer && isCollapsed && methodCount > 0) {
Text(
stringResource(R.string.codeviewer_outline_method_count, methodCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.padding(end = if (compact) 8.dp else 0.dp),
)
} else if (!compact) {
Text(
stringResource(R.string.codeviewer_outline_line, symbol.line + 1),
style = MaterialTheme.typography.bodySmall,
@ -923,13 +981,16 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
private fun OutlineSheet(
document: CodeDocument,
currentLine: Int,
collapsed: Set<Int>,
onToggleCollapsed: (CodeSymbol) -> Unit,
onSymbolSelected: (CodeSymbol) -> Unit,
onLineEntered: (Int) -> Unit,
onDismiss: () -> Unit,
) {
var lineInput by remember { mutableStateOf("") }
var outOfRange by remember { mutableStateOf(false) }
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
val current = currentIn(tree, document.symbols, currentLine)
fun submit() {
val line = parseLineNumber(lineInput, document.lines.size)
if (line == null) outOfRange = true else onLineEntered(line)
@ -977,7 +1038,7 @@ private fun OutlineSheet(
Text(stringResource(R.string.codeviewer_outline_go), fontWeight = FontWeight.Bold)
}
}
if (document.symbols.isEmpty()) {
if (tree.isEmpty()) {
Text(
stringResource(R.string.codeviewer_outline_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -985,8 +1046,15 @@ private fun OutlineSheet(
)
} else {
LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) {
items(document.symbols, key = { "${it.line}:${it.name}" }) { symbol ->
SymbolRow(symbol, isCurrent = symbol == current, compact = false) { onSymbolSelected(symbol) }
items(tree, key = { "${it.line}:${it.name}" }) { symbol ->
SymbolRow(
symbol = symbol,
isCurrent = symbol == current,
compact = false,
isCollapsed = symbol.line in collapsed,
methodCount = methodCountOf(document.symbols, symbol),
onToggle = { onToggleCollapsed(symbol) },
) { onSymbolSelected(symbol) }
}
}
}

View File

@ -93,6 +93,25 @@ fun extractSymbols(lines: List<String>, language: SyntaxLanguage): List<CodeSymb
}
}
/** Whether other symbols can sit inside this one: a namespace or a type. */
val CodeSymbol.isContainer: Boolean
get() = kind == SymbolKind.NAMESPACE || kind == SymbolKind.TYPE
/**
* The outline as a tree of namespaces and types with their methods under them. Fields are left
* out: a class has many, and they bury the methods a reader is looking for. A symbol inside a
* collapsed container is hidden; [collapsed] holds the start lines of the collapsed containers.
*/
fun outlineTree(symbols: List<CodeSymbol>, collapsed: Set<Int> = emptySet()): List<CodeSymbol> {
val shown = symbols.filter { it.kind != SymbolKind.FIELD }
val closed = shown.filter { it.isContainer && it.line in collapsed }
return shown.filter { symbol -> closed.none { symbol.line > it.line && symbol.line <= it.endLine } }
}
/** How many methods a container holds, at any depth: shown beside a collapsed container. */
fun methodCountOf(symbols: List<CodeSymbol>, container: CodeSymbol): Int =
symbols.count { it.kind == SymbolKind.METHOD && it.line > container.line && it.line <= container.endLine }
/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth)

View File

@ -1,6 +1,5 @@
package com.ubisam.ubgitea.feature.codebrowser.content
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@ -10,6 +9,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
/**
* A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Lines are
@ -65,7 +65,7 @@ data class CodeSyntaxColors(
// fall below 4.5:1 on white at code size.
@Composable
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
val dark = isSystemInDarkTheme()
val dark = UBGiteaTheme.isDark
val text = MaterialTheme.colorScheme.onSurface
return remember(dark, text) {
if (dark) {

View File

@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@ -34,8 +35,10 @@ import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -57,6 +60,7 @@ import com.ubisam.ubgitea.domain.activity.ActivityItem
import com.ubisam.ubgitea.domain.activity.ActivityKind
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
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
@ -71,11 +75,12 @@ import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.formatHeadlineDate
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlinx.coroutines.launch
/** Where a tap on the home screen leads. The screen names the place; the activity navigates. */
sealed interface HomeDestination {
data object Repositories : HomeDestination
data class Work(val scope: WorkScope) : HomeDestination
data class Work(val scope: WorkScope, val type: WorkTypeFilter = WorkTypeFilter.ALL) : HomeDestination
data class Branch(val owner: String, val name: String, val ref: String, val isTag: Boolean) : HomeDestination
data class WorkItem(val owner: String, val name: String, val number: Long, val isPullRequest: Boolean) : HomeDestination
}
@ -87,8 +92,10 @@ fun HomeScreen(
instanceHost: String,
isWide: Boolean,
onNavigate: (HomeDestination) -> Unit,
onSelectOrganization: (String?) -> Unit,
onSelectSource: (HomeSource) -> Unit,
onSelectFilter: (ActivityCategory?) -> Unit,
onSetTodayOnly: (Boolean) -> Unit,
onShowToday: (ActivityCategory) -> Unit,
onLoadMore: () -> Unit,
onRefresh: () -> Unit,
onViewSettings: () -> Unit,
@ -110,7 +117,9 @@ fun HomeScreen(
onAction = if (expired) onAuthenticationExpired else onRefresh,
)
}
is HomeUiState.Content -> HomeContent(state, isWide, onNavigate, onSelectOrganization, onSelectFilter, onLoadMore, onRefresh)
is HomeUiState.Content -> HomeContent(
state, isWide, onNavigate, onSelectSource, onSelectFilter, onSetTodayOnly, onShowToday, onLoadMore, onRefresh,
)
}
}
}
@ -121,27 +130,47 @@ private fun HomeContent(
state: HomeUiState.Content,
isWide: Boolean,
onNavigate: (HomeDestination) -> Unit,
onSelectOrganization: (String?) -> Unit,
onSelectSource: (HomeSource) -> Unit,
onSelectFilter: (ActivityCategory?) -> Unit,
onSetTodayOnly: (Boolean) -> Unit,
onShowToday: (ActivityCategory) -> Unit,
onLoadMore: () -> Unit,
onRefresh: () -> Unit,
) {
val listState = rememberLazyListState()
val timelineState = rememberLazyListState()
val scope = rememberCoroutineScope()
// The timeline starts after the overview's items: headline, attention, and the two
// active-repository items when there are any.
val timelineIndex = if (state.activeRepositories.isEmpty()) 2 else 4
// A "today" count leads to exactly what it counted: that kind of activity, today, in the
// timeline -- not to another tab's full list, where older items would contradict the count.
val showToday: (ActivityCategory) -> Unit = { category ->
onShowToday(category)
scope.launch {
if (isWide) timelineState.animateScrollToItem(0) else listState.animateScrollToItem(timelineIndex)
}
}
PullToRefreshBox(isRefreshing = false, 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.
Row(Modifier.fillMaxSize()) {
LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
overview(state, vertical = true, onNavigate, onSelectOrganization)
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
}
LazyColumn(Modifier.weight(0.54f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
timeline(state, onNavigate, onSelectFilter, onLoadMore)
LazyColumn(
Modifier.weight(0.54f).fillMaxHeight(),
state = timelineState,
contentPadding = PaddingValues(bottom = 24.dp),
) {
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
}
}
} else {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) {
overview(state, vertical = false, onNavigate, onSelectOrganization)
timeline(state, onNavigate, onSelectFilter, onLoadMore)
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
}
}
}
@ -151,7 +180,8 @@ private fun LazyListScope.overview(
state: HomeUiState.Content,
vertical: Boolean,
onNavigate: (HomeDestination) -> Unit,
onSelectOrganization: (String?) -> Unit,
onSelectSource: (HomeSource) -> Unit,
onShowToday: (ActivityCategory) -> Unit,
) {
item(key = "headline") {
Surface(
@ -164,10 +194,9 @@ private fun LazyListScope.overview(
stringResource(R.string.home_title),
subtitle = stringResource(R.string.home_subtitle, formatHeadlineDate(state.today)),
)
if (state.organizations.isNotEmpty()) {
OrganizationChips(state.organization, state.organizations, onSelectOrganization)
}
SummaryTiles(state.summary, onNavigate)
SourceChips(state.source, state.organizations, onSelectSource)
if (state.source == HomeSource.Favorites) FavoritesNote(state)
SummaryTiles(state.summary, onNavigate, onShowToday)
}
}
}
@ -209,15 +238,22 @@ private fun LazyListScope.timeline(
state: HomeUiState.Content,
onNavigate: (HomeDestination) -> Unit,
onSelectFilter: (ActivityCategory?) -> Unit,
onSetTodayOnly: (Boolean) -> Unit,
onLoadMore: () -> Unit,
) {
item(key = "timeline-title") { SectionTitle(stringResource(R.string.home_timeline), Modifier.padding(top = 8.dp)) }
item(key = "timeline-filter") { FilterChips(state.filter, onSelectFilter) }
item(key = "timeline-filter") { FilterChips(state.filter, state.todayOnly, onSelectFilter, onSetTodayOnly) }
val visible = state.visibleTimeline
if (visible.isEmpty()) {
item(key = "timeline-empty") {
Text(
stringResource(if (state.timeline.isEmpty()) R.string.home_timeline_empty else R.string.home_timeline_filter_empty),
stringResource(
when {
state.timeline.isEmpty() -> R.string.home_timeline_empty
state.todayOnly -> R.string.home_timeline_today_empty
else -> R.string.home_timeline_filter_empty
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
)
@ -262,8 +298,25 @@ private fun LazyListScope.timeline(
}
}
/** Says what the favorites view covers, so an empty or partial timeline is not a mystery. */
@Composable
private fun OrganizationChips(selected: String?, organizations: List<OrganizationSummary>, onSelect: (String?) -> Unit) {
private fun FavoritesNote(state: HomeUiState.Content) {
val text = when {
state.favoriteCount == 0 -> stringResource(R.string.home_favorites_none)
state.favoritesRead < state.favoriteCount ->
stringResource(R.string.home_favorites_partial, state.favoriteCount, state.favoritesRead)
else -> stringResource(R.string.home_favorites_all, state.favoriteCount)
}
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 12.dp),
)
}
@Composable
private fun SourceChips(selected: HomeSource, organizations: List<OrganizationSummary>, onSelect: (HomeSource) -> Unit) {
val label = stringResource(R.string.home_source_label)
Row(
Modifier.fillMaxWidth()
@ -273,11 +326,23 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_source_mine)) })
FilterChip(selected == HomeSource.Mine, { onSelect(HomeSource.Mine) }, { Text(stringResource(R.string.home_source_mine)) })
FilterChip(
selected = selected == HomeSource.Favorites,
onClick = { onSelect(HomeSource.Favorites) },
label = { Text(stringResource(R.string.home_source_favorites)) },
leadingIcon = {
UBIcon(
if (selected == HomeSource.Favorites) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star,
null,
size = 18.dp,
)
},
)
organizations.forEach { organization ->
FilterChip(
selected == organization.name,
{ onSelect(organization.name) },
selected == HomeSource.Organization(organization.name),
{ onSelect(HomeSource.Organization(organization.name)) },
{ Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) },
)
}
@ -285,20 +350,20 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
}
@Composable
private fun SummaryTiles(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit) {
private fun SummaryTiles(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit, onShowToday: (ActivityCategory) -> Unit) {
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SummaryTile(
R.drawable.ic_ub_commit, R.string.home_stat_pushes, summary.todayPushes, summary.todayIsLowerBound,
Modifier.weight(1f),
) { onNavigate(HomeDestination.Repositories) }
) { onShowToday(ActivityCategory.PUSH) }
SummaryTile(
R.drawable.ic_ub_pull_request, R.string.home_stat_open_pull_requests, summary.openPullRequests, false,
Modifier.weight(1f),
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE, WorkTypeFilter.PULL_REQUESTS)) }
SummaryTile(
R.drawable.ic_ub_issue, R.string.home_stat_issues, summary.todayIssuesOpened, summary.todayIsLowerBound,
Modifier.weight(1f),
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
) { onShowToday(ActivityCategory.ISSUE) }
}
}
@ -460,11 +525,24 @@ private fun ActivityBars(counts: List<Int>, modifier: Modifier = Modifier) {
private const val MIN_BAR_FRACTION = 0.12f
@Composable
private fun FilterChips(selected: ActivityCategory?, onSelect: (ActivityCategory?) -> Unit) {
private fun FilterChips(
selected: ActivityCategory?,
todayOnly: Boolean,
onSelect: (ActivityCategory?) -> Unit,
onSetTodayOnly: (Boolean) -> Unit,
) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
todayOnly,
{ onSetTodayOnly(!todayOnly) },
{ Text(stringResource(R.string.home_filter_today)) },
leadingIcon = if (todayOnly) ({ UBIcon(R.drawable.ic_ub_check, null, size = 18.dp) }) else null,
)
VerticalDivider(Modifier.height(24.dp), color = MaterialTheme.colorScheme.outlineVariant)
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_filter_all)) })
FilterChip(selected == ActivityCategory.PUSH, { onSelect(ActivityCategory.PUSH) }, { Text(stringResource(R.string.home_filter_push)) })
FilterChip(

View File

@ -19,8 +19,10 @@ import com.ubisam.ubgitea.domain.activity.ActivityCategory
import com.ubisam.ubgitea.domain.activity.ActivityItem
import com.ubisam.ubgitea.domain.activity.ActivityRepository
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import com.ubisam.ubgitea.domain.workitems.WorkScope
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
@ -34,6 +36,7 @@ import java.util.Collections
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -42,6 +45,37 @@ import kotlinx.coroutines.launch
enum class HomeError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, SERVER }
/** Whose activity Home shows. */
sealed interface HomeSource {
/** The signed-in account's dashboard feed: every repository that account follows. */
data object Mine : HomeSource
/** Only the repositories the account has starred, each read from its own feed. */
data object Favorites : HomeSource
data class Organization(val name: String) : HomeSource
/** How the choice is remembered between launches. */
fun encode(): String = when (this) {
Mine -> ENCODED_MINE
Favorites -> ENCODED_FAVORITES
is Organization -> ENCODED_ORGANIZATION_PREFIX + name
}
companion object {
private const val ENCODED_MINE = "mine"
private const val ENCODED_FAVORITES = "favorites"
private const val ENCODED_ORGANIZATION_PREFIX = "org:"
fun decode(encoded: String?): HomeSource = when {
encoded == ENCODED_FAVORITES -> Favorites
encoded != null && encoded.startsWith(ENCODED_ORGANIZATION_PREFIX) ->
encoded.removePrefix(ENCODED_ORGANIZATION_PREFIX).takeIf(String::isNotBlank)?.let(::Organization) ?: Mine
else -> Mine
}
}
}
/**
* Today's numbers. Each is null while it is still loading or when it could not be loaded: the
* timeline is what the screen is for, and a count that failed must not take the timeline down
@ -67,8 +101,7 @@ sealed interface HomeUiState {
class Content(
val accountLogin: String,
/** The organization whose feed is shown; null for the signed-in account's own feed. */
val organization: String?,
val source: HomeSource,
organizations: List<OrganizationSummary>,
val filter: ActivityCategory?,
timeline: List<ActivityItem>,
@ -78,12 +111,20 @@ sealed interface HomeUiState {
activeRepositories: List<ActiveRepository>,
val summary: HomeSummary,
val today: LocalDate,
val zone: ZoneId,
/** When set, the timeline shows only what happened today: what a "today" count leads to. */
val todayOnly: Boolean = false,
/** For [HomeSource.Favorites]: how many repositories are starred, and how many of them were read. */
val favoriteCount: Int = 0,
val favoritesRead: Int = 0,
) : HomeUiState {
val organizations: List<OrganizationSummary> = Collections.unmodifiableList(organizations.toList())
val timeline: List<ActivityItem> = Collections.unmodifiableList(timeline.toList())
val activeRepositories: List<ActiveRepository> = Collections.unmodifiableList(activeRepositories.toList())
val visibleTimeline: List<ActivityItem> =
if (filter == null) this.timeline else this.timeline.filter { it.kind.category == filter }
val visibleTimeline: List<ActivityItem> = this.timeline.filter { item ->
(filter == null || item.kind.category == filter) &&
(!todayOnly || Instant.ofEpochMilli(item.createdAtEpochMillis).atZone(zone).toLocalDate() == today)
}
internal fun copy(
filter: ActivityCategory? = this.filter,
@ -93,13 +134,14 @@ sealed interface HomeUiState {
loadMoreFailed: Boolean = this.loadMoreFailed,
activeRepositories: List<ActiveRepository> = this.activeRepositories,
summary: HomeSummary = this.summary,
todayOnly: Boolean = this.todayOnly,
) = Content(
accountLogin, organization, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
activeRepositories, summary, today,
accountLogin, source, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
activeRepositories, summary, today, zone, todayOnly, favoriteCount, favoritesRead,
)
}
data class Error(val type: HomeError, val organization: String?) : HomeUiState
data class Error(val type: HomeError, val source: HomeSource) : HomeUiState
}
class HomeViewModel(
@ -108,6 +150,9 @@ class HomeViewModel(
private val activity: ActivityRepository,
private val workSearch: WorkSearchRepository,
private val organizations: OrganizationsRepository,
private val favorites: RepositoryFavorites,
private val savedSource: () -> String? = { null },
private val saveSource: (String) -> Unit = {},
private val now: () -> Long = System::currentTimeMillis,
private val zone: () -> ZoneId = ZoneId::systemDefault,
) : ViewModel() {
@ -122,14 +167,15 @@ class HomeViewModel(
/** Loads the feed unless it is already on screen; switching back to the tab must not refetch. */
fun open() {
if (mutableState.value is HomeUiState.Content || mutableState.value is HomeUiState.Loading) return
load(organization = null)
load(HomeSource.decode(savedSource()))
}
fun refresh() = load(currentOrganization())
fun refresh() = load(currentSource())
fun selectOrganization(organization: String?) {
if (organization == currentOrganization() && mutableState.value is HomeUiState.Content) return
load(organization)
fun selectSource(source: HomeSource) {
if (source == currentSource() && mutableState.value is HomeUiState.Content) return
saveSource(source.encode())
load(source)
}
fun selectFilter(filter: ActivityCategory?) {
@ -137,6 +183,17 @@ class HomeViewModel(
mutableState.value = content.copy(filter = filter)
}
fun setTodayOnly(todayOnly: Boolean) {
val content = mutableState.value as? HomeUiState.Content ?: return
mutableState.value = content.copy(todayOnly = todayOnly)
}
/** What a "today" count opens: the timeline narrowed to that kind of activity, today only. */
fun showToday(filter: ActivityCategory) {
val content = mutableState.value as? HomeUiState.Content ?: return
mutableState.value = content.copy(filter = filter, todayOnly = true)
}
fun loadMore() {
val content = mutableState.value as? HomeUiState.Content ?: return
val page = content.nextPage ?: return
@ -144,7 +201,7 @@ class HomeViewModel(
mutableState.value = content.copy(isLoadingMore = true, loadMoreFailed = false)
moreJob = viewModelScope.launch {
try {
val result = feedPage(content.accountLogin, content.organization, date = null, page = page)
val result = feedPage(content.accountLogin, content.source, date = null, page = page)
update { current ->
val merged = (current.timeline + result.items).distinctBy(ActivityItem::id)
current.copy(
@ -168,7 +225,7 @@ class HomeViewModel(
mutableState.value = HomeUiState.Idle
}
private fun load(organization: String?) {
private fun load(source: HomeSource) {
cancelAll()
mutableState.value = HomeUiState.Loading
operation = viewModelScope.launch {
@ -176,14 +233,18 @@ class HomeViewModel(
val login = accountLogin ?: authGateway.getAuthenticatedUser(tokenProvider.accessToken()).login
.also { accountLogin = it }
val today = Instant.ofEpochMilli(now()).atZone(zone()).toLocalDate()
if (source == HomeSource.Favorites) {
loadFavorites(login, today)
return@launch
}
val (first, organizationList) = coroutineScope {
val feed = async { feedPage(login, organization, date = null, page = 1) }
val feed = async { feedPage(login, source, date = null, page = 1) }
val orgs = async { loadOrganizations() }
feed.await() to orgs.await()
}
val content = HomeUiState.Content(
mutableState.value = HomeUiState.Content(
accountLogin = login,
organization = organization,
source = source,
organizations = organizationList,
filter = null,
timeline = first.items.distinctBy(ActivityItem::id),
@ -193,39 +254,73 @@ class HomeViewModel(
activeRepositories = activeRepositoriesOf(first.items, today, zone()),
summary = HomeSummary(),
today = today,
zone = zone(),
)
mutableState.value = content
loadSummary(login, organization, today)
loadSummary(login, source, today)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
mutableState.value = HomeUiState.Error(failure.toHomeError(), organization)
mutableState.value = HomeUiState.Error(failure.toHomeError(), source)
}
}
}
// Gitea has no feed of "my starred repositories", so each one's own feed is read and the
// results merged. A repository whose feed fails is left out rather than failing the rest;
// only failing to list the starred repositories at all fails the screen.
private suspend fun loadFavorites(login: String, today: LocalDate) {
val (starred, organizationList) = coroutineScope {
val stars = async { favorites.listStarred(1, FAVORITES_PAGE_SIZE).items }
val orgs = async { loadOrganizations() }
stars.await() to orgs.await()
}
val read = starred.sortedByDescending(RepositorySummary::updatedAtEpochMillis).take(MAX_FAVORITES_READ)
val feeds = coroutineScope {
read.map { repository ->
async { tolerated { activity.listRepositoryFeed(repository.coordinates, null, 1, FAVORITE_FEED_PAGE_SIZE).items } }
}.awaitAll()
}
val timeline = feeds.filterNotNull().flatten()
.distinctBy(ActivityItem::id)
.sortedByDescending(ActivityItem::createdAtEpochMillis)
val counts = todayCountsOf(timeline, today, zone())
mutableState.value = HomeUiState.Content(
accountLogin = login,
source = HomeSource.Favorites,
organizations = organizationList,
filter = null,
timeline = timeline,
// Every repository was read one page deep; there is no single "next page" of a merge.
nextPage = null,
isLoadingMore = false,
loadMoreFailed = false,
activeRepositories = activeRepositoriesOf(timeline, today, zone()),
summary = HomeSummary(
todayPushes = counts.pushes,
todayIssuesOpened = counts.issuesOpened,
// A repository with more activity today than one page holds is under-counted.
todayIsLowerBound = feeds.any { it != null && it.size >= FAVORITE_FEED_PAGE_SIZE },
openPullRequests = read.sumOf { it.openPullRequestCount ?: 0 }.takeIf { read.any { r -> r.openPullRequestCount != null } },
),
today = today,
zone = zone(),
favoriteCount = starred.size,
favoritesRead = feeds.count { it != null },
)
loadAttention()
}
// Every number is fetched on its own and tolerated on its own: one failing count leaves that
// one tile blank instead of failing the screen.
private fun loadSummary(login: String, organization: String?, today: LocalDate) {
private fun loadSummary(login: String, source: HomeSource, today: LocalDate) {
val owner = (source as? HomeSource.Organization)?.name
summaryJob = viewModelScope.launch {
coroutineScope {
val todays = async { tolerated { loadToday(login, organization, today) } }
val todays = async { tolerated { loadToday(login, source, today) } }
val openPullRequests = async {
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = organization)) }
}
val reviewRequested = async {
tolerated {
workSearch.count(
WorkSearchQuery(WorkScope.REVIEW_REQUESTED, WorkStateFilter.OPEN, WorkTypeFilter.PULL_REQUESTS, organization),
)
}
}
val assigned = async {
tolerated {
workSearch.count(
WorkSearchQuery(WorkScope.ASSIGNED_TO_ME, WorkStateFilter.OPEN, WorkTypeFilter.ISSUES, organization),
)
}
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = owner)) }
}
val reviewRequested = async { tolerated { workSearch.count(reviewRequestedQuery(owner)) } }
val assigned = async { tolerated { workSearch.count(assignedQuery(owner)) } }
val todayResult = todays.await()
val summary = HomeSummary(
todayPushes = todayResult?.counts?.pushes,
@ -241,14 +336,33 @@ class HomeViewModel(
}
}
// What waits on the signed-in person is about them, not about which repositories are shown.
private fun loadAttention() {
summaryJob = viewModelScope.launch {
coroutineScope {
val reviewRequested = async { tolerated { workSearch.count(reviewRequestedQuery(null)) } }
val assigned = async { tolerated { workSearch.count(assignedQuery(null)) } }
val requested = reviewRequested.await()
val assignedCount = assigned.await()
update { it.copy(summary = it.summary.copy(reviewRequested = requested, assignedIssues = assignedCount, isLoading = false)) }
}
}
}
private fun reviewRequestedQuery(owner: String?) =
WorkSearchQuery(WorkScope.REVIEW_REQUESTED, WorkStateFilter.OPEN, WorkTypeFilter.PULL_REQUESTS, owner)
private fun assignedQuery(owner: String?) =
WorkSearchQuery(WorkScope.ASSIGNED_TO_ME, WorkStateFilter.OPEN, WorkTypeFilter.ISSUES, owner)
private class Today(val counts: TodayCounts, val isLowerBound: Boolean)
private suspend fun loadToday(login: String, organization: String?, today: LocalDate): Today {
private suspend fun loadToday(login: String, source: HomeSource, today: LocalDate): Today {
val items = mutableListOf<ActivityItem>()
var page: Int? = 1
var fetched = 0
while (page != null && fetched < TODAY_MAX_PAGES) {
val result = feedPage(login, organization, date = today, page = page)
val result = feedPage(login, source, date = today, page = page)
items += result.items
page = result.nextPage
fetched++
@ -256,11 +370,10 @@ class HomeViewModel(
return Today(todayCountsOf(items, today, zone()), isLowerBound = page != null)
}
private suspend fun feedPage(login: String, organization: String?, date: LocalDate?, page: Int): Page<ActivityItem> =
if (organization == null) {
activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
} else {
activity.listOrganizationFeed(organization, date, page, FEED_PAGE_SIZE)
private suspend fun feedPage(login: String, source: HomeSource, date: LocalDate?, page: Int): Page<ActivityItem> =
when (source) {
is HomeSource.Organization -> activity.listOrganizationFeed(source.name, date, page, FEED_PAGE_SIZE)
HomeSource.Mine, HomeSource.Favorites -> activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
}
// The organization filter is a convenience. Failing to list organizations hides the filter;
@ -284,10 +397,10 @@ class HomeViewModel(
mutableState.value = transform(current)
}
private fun currentOrganization(): String? = when (val current = mutableState.value) {
is HomeUiState.Content -> current.organization
is HomeUiState.Error -> current.organization
else -> null
private fun currentSource(): HomeSource = when (val current = mutableState.value) {
is HomeUiState.Content -> current.source
is HomeUiState.Error -> current.source
else -> HomeSource.decode(savedSource())
}
private fun cancelAll() {
@ -302,13 +415,18 @@ class HomeViewModel(
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
val config = UBGiteaOAuthDefaults.config
val tokenProvider = AuthSession.tokenProvider(application)
val retry = { AppSettingsSession.store(application).isReactiveAuthRetryEnabled() }
val settings = AppSettingsSession.store(application)
val retry = { settings.isReactiveAuthRetryEnabled() }
val browser = GiteaRepositoryBrowserApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry)
return HomeViewModel(
authGateway = AuthApi(config, OkHttpTransport()),
tokenProvider = tokenProvider,
activity = GiteaActivityApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
workSearch = GiteaWorkSearchApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
organizations = GiteaRepositoryBrowserApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
organizations = browser,
favorites = browser,
savedSource = settings::homeSource,
saveSource = settings::setHomeSource,
) as T
}
}
@ -317,6 +435,12 @@ class HomeViewModel(
const val FEED_PAGE_SIZE = 50
const val ORGANIZATION_PAGE_SIZE = 50
const val TODAY_MAX_PAGES = 3
const val FAVORITES_PAGE_SIZE = 50
const val FAVORITE_FEED_PAGE_SIZE = 20
// One request per starred repository: enough for anyone's working set, bounded so that
// someone who has starred a hundred repositories does not start a hundred requests.
const val MAX_FAVORITES_READ = 20
}
}

View File

@ -45,7 +45,6 @@ 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.StatusBadge
import com.ubisam.ubgitea.ui.components.TabTitle
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@ -74,8 +73,8 @@ fun NotificationsScreen(
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.notifications_title))
// No title: the selected tab already says this is 알림.
Column(Modifier.padding(top = 4.dp, bottom = 12.dp)) {
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
}
}

View File

@ -1,7 +1,6 @@
package com.ubisam.ubgitea.feature.repositories
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.horizontalScroll
@ -38,19 +37,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
@ -65,8 +60,8 @@ 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.TabTitle
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.components.UbiSamLogo
import com.ubisam.ubgitea.ui.formatInstant
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@ -119,7 +114,6 @@ fun RepositoryListScreen(
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.repositories_list_title))
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
}
}
@ -202,11 +196,7 @@ private fun SignedOutState(message: String, action: String, onAction: () -> Unit
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(R.drawable.logo_ubisam),
contentDescription = stringResource(R.string.shell_logo_description),
modifier = Modifier.height(36.dp),
)
UbiSamLogo(Modifier.height(36.dp))
Text(
message,
// Korean breaks between any two characters by default; this keeps words whole.
@ -237,8 +227,8 @@ private fun RepositoryContent(
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.repositories_list_title))
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
@ -376,13 +366,20 @@ private fun RepositoryRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// The owner sits on its own line above the name. On one line, a long organization
// name used up the row and the repository's own name was cut off (seen on a device).
Text(
repository.owner,
style = MaterialTheme.typography.bodySmall,
color = ownerColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = ownerColor)) { append(repository.owner); append(" / ") }
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(repository.name) }
},
repository.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),

View File

@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.core.settings.AppSettingsStore
import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.ScreenTopBar
@ -44,6 +45,7 @@ fun SettingsScreen(
instanceHost: String,
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
onSetCodeFontSizeSp: (Int) -> Unit,
onSetThemeMode: (ThemeMode) -> Unit,
onLogout: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@ -81,6 +83,34 @@ fun SettingsScreen(
}
}
}
SectionTitle(stringResource(R.string.settings_section_display), Modifier.padding(top = 8.dp))
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_theme_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.settings_theme_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ThemeMode.entries.forEach { mode ->
FilterChip(
selected = state.themeMode == mode,
onClick = { onSetThemeMode(mode) },
label = { Text(stringResource(mode.label)) },
leadingIcon = if (state.themeMode == mode) {
{ UBIcon(R.drawable.ic_ub_check, null, size = 18.dp) }
} else {
null
},
)
}
}
}
}
SectionTitle(stringResource(R.string.settings_section_code_view), Modifier.padding(top = 8.dp))
ListCard(Modifier.padding(horizontal = 20.dp)) {
SwitchRow(
@ -127,6 +157,13 @@ fun SettingsScreen(
}
}
private val ThemeMode.label: Int
get() = when (this) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
}
@Composable
private fun SwitchRow(
title: String,

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.ubisam.ubgitea.core.settings.AppSettingsSession
import com.ubisam.ubgitea.core.settings.AppSettingsStore
import com.ubisam.ubgitea.core.settings.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -14,6 +15,7 @@ data class SettingsUiState(
val favoritePushNotificationsEnabled: Boolean,
val codeWordWrapEnabled: Boolean = true,
val codeFontSizeSp: Int = AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP,
val themeMode: ThemeMode = ThemeMode.SYSTEM,
)
/**
@ -28,6 +30,7 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
codeFontSizeSp = store.codeFontSizeSp(),
themeMode = store.themeMode(),
),
)
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
@ -42,6 +45,11 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
}
fun setThemeMode(mode: ThemeMode) {
store.setThemeMode(mode)
mutableState.value = mutableState.value.copy(themeMode = mode)
}
fun setCodeWordWrapEnabled(enabled: Boolean) {
store.setCodeWordWrapEnabled(enabled)
mutableState.value = mutableState.value.copy(codeWordWrapEnabled = enabled)

View File

@ -46,7 +46,6 @@ 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.StatusBadge
import com.ubisam.ubgitea.ui.components.TabTitle
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.formatRelative
@ -74,14 +73,17 @@ fun WorkScreen(
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(
stringResource(R.string.work_title),
subtitle = (state as? WorkUiState.Content)?.totalCount
?.let { stringResource(R.string.work_total, it) }
?: stringResource(R.string.work_subtitle),
)
// No title: the selected tab already says this is 작업.
Column(Modifier.padding(top = 4.dp, bottom = 12.dp)) {
QueryFilters(state.query, onSelectScope, onSelectState, onSelectType)
(state as? WorkUiState.Content)?.totalCount?.let { total ->
Text(
stringResource(R.string.work_total, total),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp),
)
}
}
}
when (state) {

View File

@ -59,7 +59,8 @@ class WorkViewModel(private val repository: WorkSearchRepository) : ViewModel()
}
/** Opens the tab on a specific scope, as the home screen's "needs my attention" card does. */
fun open(scope: WorkScope) = load(WorkSearchQuery(scope = scope), page = 1)
fun open(scope: WorkScope, type: WorkTypeFilter = WorkTypeFilter.ALL) =
load(WorkSearchQuery(scope = scope, type = type), page = 1)
fun refresh() = load(mutableState.value.query, page = 1)

View File

@ -2,6 +2,7 @@ package com.ubisam.ubgitea.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
@ -38,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
/** One of the app's own stroke icons (res/drawable/ic_ub_*), tinted with the current content color. */
@ -52,6 +55,16 @@ fun UBIcon(
Icon(painterResource(id), contentDescription, modifier.size(size), tint)
}
/** The UbiSam wordmark, in the artwork made for the theme the app is drawn in. */
@Composable
fun UbiSamLogo(modifier: Modifier = Modifier) {
Image(
painter = painterResource(if (UBGiteaTheme.isDark) R.drawable.logo_ubisam_dark else R.drawable.logo_ubisam),
contentDescription = stringResource(R.string.shell_logo_description),
modifier = modifier,
)
}
/** Initials on the brand blue. The text color is fixed dark: white on this blue is below 4.5:1. */
@Composable
fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 36.dp, contentDescription: String? = null) {

View File

@ -1,6 +1,5 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -24,7 +23,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
@ -50,11 +48,7 @@ fun BrandTopBar(
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.logo_ubisam),
contentDescription = stringResource(R.string.shell_logo_description),
modifier = Modifier.height(20.dp),
)
UbiSamLogo(Modifier.height(20.dp))
Spacer(Modifier.weight(1f))
actions()
ProfileMenu(accountName, onViewSettings, onLogout)

View File

@ -7,6 +7,7 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
// See Color.kt for the individual values and why each role has the color it has. Surfaces are
// white cards on a faintly blue page in light mode, so `surface` and `background` differ.
@ -80,12 +81,22 @@ private val DarkColorScheme = darkColorScheme(
inversePrimary = InversePrimaryDark,
)
/**
* Whether the app is drawn dark. This is the app's own answer -- the setting, or the system's when
* the setting says to follow it -- so anything that differs between light and dark reads this
* rather than asking the system, which may disagree with the setting.
*/
val LocalIsDarkTheme = staticCompositionLocalOf { false }
@Composable
fun UBGiteaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
CompositionLocalProvider(
LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors,
LocalIsDarkTheme provides darkTheme,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
content = content,
@ -96,4 +107,7 @@ fun UBGiteaTheme(
object UBGiteaTheme {
val colors: UBGiteaExtendedColors
@Composable @ReadOnlyComposable get() = LocalUBGiteaColors.current
val isDark: Boolean
@Composable @ReadOnlyComposable get() = LocalIsDarkTheme.current
}

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -56,6 +56,9 @@
<string name="codeviewer_outline_hint">줄 번호</string>
<string name="codeviewer_outline_go">이동</string>
<string name="codeviewer_outline_empty">이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다.</string>
<string name="codeviewer_outline_expand">펼치기</string>
<string name="codeviewer_outline_collapse">접기</string>
<string name="codeviewer_outline_method_count">메서드 %1$d개</string>
<string name="codeviewer_outline_line">줄 %1$d</string>
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
<string name="codeviewer_symbol_namespace">네임스페이스</string>

View File

@ -3,6 +3,10 @@
<string name="home_subtitle">%1$s · 오늘 기준</string>
<string name="home_loading">팀 활동을 불러오는 중입니다.</string>
<string name="home_source_mine">내 피드</string>
<string name="home_source_favorites">즐겨찾기</string>
<string name="home_favorites_none">즐겨찾기한 저장소가 없습니다. 저장소 탭에서 별을 눌러 추가하세요.</string>
<string name="home_favorites_all">즐겨찾기한 저장소 %1$d개의 활동입니다.</string>
<string name="home_favorites_partial">즐겨찾기한 저장소 %1$d개 중 %2$d개의 활동입니다.</string>
<string name="home_source_label">활동 범위</string>
<string name="home_stat_pushes">오늘 Push</string>
<string name="home_stat_open_pull_requests">열린 PR</string>
@ -18,6 +22,8 @@
<string name="home_active_repository_footer">%1$s · %2$s</string>
<string name="home_activity_bars_description">최근 7일 활동 %1$d건</string>
<string name="home_timeline">타임라인</string>
<string name="home_filter_today">오늘만</string>
<string name="home_timeline_today_empty">오늘은 해당하는 활동이 없습니다.</string>
<string name="home_filter_all">전체</string>
<string name="home_filter_push">Push</string>
<string name="home_filter_pull_request">PR</string>
@ -57,8 +63,6 @@
<string name="home_permission_denied">활동을 볼 권한이 없습니다.</string>
<string name="home_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
<string name="work_title">작업</string>
<string name="work_subtitle">모든 저장소의 이슈와 PR</string>
<string name="work_loading">작업을 불러오는 중입니다.</string>
<string name="work_scope_everyone">팀 전체</string>
<string name="work_scope_review_requested">리뷰 요청</string>

View File

@ -1,6 +1,5 @@
<resources>
<string name="repositories_notifications">알림</string>
<string name="notifications_title">알림</string>
<string name="notifications_filter_unread">읽지 않음</string>
<string name="notifications_filter_all">전체</string>
<string name="notifications_unread_dot_description">읽지 않음</string>

View File

@ -41,5 +41,4 @@
<string name="repositories_favorited">즐겨찾기됨</string>
<string name="repositories_work_items">PR/이슈</string>
<string name="repositories_organization_all">전체</string>
<string name="repositories_list_title">저장소</string>
</resources>

View File

@ -12,4 +12,10 @@
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
<string name="settings_code_font_size_title">글자 크기</string>
<string name="settings_code_font_size_value">%1$dsp</string>
<string name="settings_section_display">화면</string>
<string name="settings_theme_title">화면 테마</string>
<string name="settings_theme_description">코드 보기 색상도 함께 바뀝니다.</string>
<string name="settings_theme_system">시스템 설정</string>
<string name="settings_theme_light">라이트</string>
<string name="settings_theme_dark">다크</string>
</resources>

View File

@ -238,4 +238,57 @@ class SymbolOutlineTest {
assertEquals(SymbolKind.FIELD, findSymbolNamed(symbols, "Foo")?.kind)
assertNull(findSymbolNamed(symbols, "Bar"))
}
// --- outline tree ---
private val treeSource = listOf(
"namespace Demo",
"{",
" public class First",
" {",
" private readonly int _count = 1;",
" public int Get() { return _count; }",
" public void Reset() { }",
" }",
" public class Second",
" {",
" public void Run() { }",
" }",
"}",
)
@Test
fun `outline tree leaves fields out and keeps classes and methods in order`() {
val tree = outlineTree(extractSymbols(treeSource, SyntaxLanguage.CSHARP))
assertEquals(listOf("Demo", "First", "Get", "Reset", "Second", "Run"), tree.map(CodeSymbol::name))
assertTrue(tree.none { it.kind == SymbolKind.FIELD })
}
@Test
fun `a collapsed class hides its methods and nothing else`() {
val symbols = extractSymbols(treeSource, SyntaxLanguage.CSHARP)
val first = symbols.first { it.name == "First" }
val tree = outlineTree(symbols, collapsed = setOf(first.line))
assertEquals(listOf("Demo", "First", "Second", "Run"), tree.map(CodeSymbol::name))
}
@Test
fun `a collapsed namespace hides everything inside it`() {
val symbols = extractSymbols(treeSource, SyntaxLanguage.CSHARP)
val namespace = symbols.first { it.kind == SymbolKind.NAMESPACE }
assertEquals(listOf("Demo"), outlineTree(symbols, setOf(namespace.line)).map(CodeSymbol::name))
}
@Test
fun `method count covers only the methods inside the container`() {
val symbols = extractSymbols(treeSource, SyntaxLanguage.CSHARP)
assertEquals(2, methodCountOf(symbols, symbols.first { it.name == "First" }))
assertEquals(1, methodCountOf(symbols, symbols.first { it.name == "Second" }))
assertEquals(3, methodCountOf(symbols, symbols.first { it.name == "Demo" }))
}
}

View File

@ -14,6 +14,7 @@ import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import com.ubisam.ubgitea.domain.workitems.WorkScope
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
@ -51,12 +52,16 @@ class HomeViewModelTest {
activity: ActivityRepository,
workSearch: WorkSearchRepository = FakeWorkSearchRepository { 0 },
organizations: OrganizationsRepository = FakeOrganizationsRepository(),
favorites: RepositoryFavorites = FakeFavorites(),
savedSource: String? = null,
) = HomeViewModel(
authGateway = FakeAuthGateway(),
tokenProvider = tokenProvider,
activity = activity,
workSearch = workSearch,
organizations = organizations,
favorites = favorites,
savedSource = { savedSource },
now = { 1_700_000_000_000L },
zone = { zone },
)
@ -176,7 +181,111 @@ class HomeViewModelTest {
assertEquals(HomeUiState.Idle, vm.state.value)
}
private fun item(id: Long, kind: ActivityKind = ActivityKind.PUSH) = ActivityItem(
@Test
fun `favorites merge each starred repository's feed, newest first`() = runTest(dispatcher.scheduler) {
val now = 1_700_000_000_000L
val activity = FakeActivityRepository { source, _, _ ->
when (source) {
"repo:team/alpha" -> Page.of(listOf(item(1, at = now - 3_000L), item(2, at = now - 1_000L)), null)
"repo:team/beta" -> Page.of(listOf(item(3, at = now - 2_000L)), null)
else -> error("Unexpected feed $source")
}
}
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha", 2), starred("beta", 1))))
vm.selectSource(HomeSource.Favorites)
advanceUntilIdle()
val state = vm.state.value as HomeUiState.Content
assertEquals(HomeSource.Favorites, state.source)
assertEquals(listOf(2L, 3L, 1L), state.timeline.map(ActivityItem::id))
assertEquals(2, state.favoriteCount)
assertEquals(2, state.favoritesRead)
assertEquals(3, state.summary.openPullRequests)
assertNull(state.nextPage)
}
@Test
fun `one starred repository failing leaves the others shown`() = runTest(dispatcher.scheduler) {
val activity = FakeActivityRepository { source, _, _ ->
if (source == "repo:team/alpha") throw ApiFailure.Server() else Page.of(listOf(item(3)), null)
}
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha"), starred("beta"))))
vm.selectSource(HomeSource.Favorites)
advanceUntilIdle()
val state = vm.state.value as HomeUiState.Content
assertEquals(listOf(3L), state.timeline.map(ActivityItem::id))
assertEquals(1, state.favoritesRead)
}
@Test
fun `failing to list starred repositories is an error`() = runTest(dispatcher.scheduler) {
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(emptyList(), null) }, favorites = FakeFavorites(fail = true))
vm.selectSource(HomeSource.Favorites)
advanceUntilIdle()
assertEquals(HomeUiState.Error(HomeError.SERVER, HomeSource.Favorites), vm.state.value)
}
@Test
fun `open starts on the source that was chosen last`() = runTest(dispatcher.scheduler) {
val activity = FakeActivityRepository { _, _, _ -> Page.of(listOf(item(1)), null) }
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha"))), savedSource = "favorites")
vm.open()
advanceUntilIdle()
assertEquals(HomeSource.Favorites, (vm.state.value as HomeUiState.Content).source)
}
@Test
fun `a source round trips through its saved form`() {
listOf(HomeSource.Mine, HomeSource.Favorites, HomeSource.Organization("team")).forEach { source ->
assertEquals(source, HomeSource.decode(source.encode()))
}
assertEquals(HomeSource.Mine, HomeSource.decode(null))
assertEquals(HomeSource.Mine, HomeSource.decode("org:"))
assertEquals(HomeSource.Mine, HomeSource.decode("something else"))
}
@Test
fun `showToday narrows the timeline to that category and to today`() = runTest(dispatcher.scheduler) {
val now = 1_700_000_000_000L
val fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000L
val items = listOf(
item(1, kind = ActivityKind.PUSH, at = now - 60_000L),
item(2, kind = ActivityKind.PUSH, at = fiveDaysAgo),
item(3, kind = ActivityKind.ISSUE_OPENED, at = now - 60_000L),
)
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(items, null) })
vm.open()
advanceUntilIdle()
vm.showToday(ActivityCategory.PUSH)
val state = vm.state.value as HomeUiState.Content
assertEquals(listOf(1L), state.visibleTimeline.map(ActivityItem::id))
}
@Test
fun `turning todayOnly off brings older activity back`() = runTest(dispatcher.scheduler) {
val now = 1_700_000_000_000L
val items = listOf(item(1, at = now - 60_000L), item(2, at = now - 5 * 24 * 60 * 60 * 1000L))
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(items, null) })
vm.open()
advanceUntilIdle()
vm.showToday(ActivityCategory.PUSH)
vm.setTodayOnly(false)
val state = vm.state.value as HomeUiState.Content
assertEquals(listOf(1L, 2L), state.visibleTimeline.map(ActivityItem::id))
}
private fun item(id: Long, kind: ActivityKind = ActivityKind.PUSH, at: Long = 0L) = ActivityItem(
id = id,
kind = kind,
actorLogin = "octocat",
@ -187,7 +296,7 @@ class HomeViewModelTest {
subjectText = null,
commits = emptyList(),
commitCount = 0,
createdAtEpochMillis = 0L,
createdAtEpochMillis = at,
)
private class FakeAuthGateway(private val login: String = "octocat") : AuthenticationGateway {
@ -203,6 +312,8 @@ class HomeViewModelTest {
) : ActivityRepository {
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int) =
feed(null, date, page)
override suspend fun listRepositoryFeed(repository: RepositoryCoordinates, date: LocalDate?, page: Int, pageSize: Int) =
feed("repo:${repository.owner}/${repository.name}", date, page)
override suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int) =
feed(organization, date, page)
}
@ -213,6 +324,28 @@ class HomeViewModelTest {
override suspend fun count(query: WorkSearchQuery): Int? = counts(query)
}
private class FakeFavorites(
private val starred: List<RepositorySummary> = emptyList(),
private val fail: Boolean = false,
) : RepositoryFavorites {
override suspend fun listStarred(page: Int, pageSize: Int): Page<RepositorySummary> {
if (fail) throw ApiFailure.Server()
return Page.of(starred, null)
}
override suspend fun star(repository: RepositoryCoordinates) = error("Not used")
override suspend fun unstar(repository: RepositoryCoordinates) = error("Not used")
}
private fun starred(name: String, openPullRequests: Int? = null) = RepositorySummary(
coordinates = RepositoryCoordinates.of("team", name),
description = null,
defaultBranch = null,
isPrivate = true,
isFork = false,
updatedAtEpochMillis = 0L,
openPullRequestCount = openPullRequests,
)
private class FakeOrganizationsRepository(
private val organizations: List<OrganizationSummary> = emptyList(),
private val fail: Boolean = false,

Binary file not shown.

View File

@ -1331,3 +1331,23 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Next recommended task: install on a phone and on an unfolded Z Fold, walk every tab, and fix what
only a device shows.
- Next owner: Claude, after the product owner's device check.
## 2026-09-28 (continued) — TASK-055: first device feedback on the redesign
- Request: after the redesign was installed on their Z Fold7, the human product owner reported
that repository names had become hard to read, that the tab titles were unnecessary (Home's
excepted), that "오늘 Push" led to pushes from five days ago, that the outline listed too many
variables and should be a tree of classes and methods, and asked for a favorites-focused Home
and a dark-mode setting.
- What changed: see `tasks/055-device-feedback-round.md`, items 1 to 6.
- Why "오늘 Push" was wrong: it was a design mistake, not a data one. The count was right; the tile
navigated to the repository list, which is not a list of today's pushes.
- A thing worth knowing later: whether the app is dark is now the app's own decision
(`UBGiteaTheme.isDark`), not the system's. Anything new that differs between light and dark must
read that, not `isSystemInDarkTheme()` and not a `-night` resource qualifier -- both follow the
system and ignore the setting. The logo was moved off a `-night` drawable for this reason.
- Checks and results: the required checks pass; 420 unit tests, 0 failures; lint 0 errors.
- Live verification: installed on the SM-F966N over wireless `adb`; the app starts cleanly. The
Home timeline was seen on the device rendering real activity correctly. This task's own changes
have not been looked at on the device by the implementer.
- Next owner: the product owner, to confirm each item; then Claude for whatever that turns up.

View File

@ -53,6 +53,8 @@ The first release is read-oriented. Creating, merging, deleting, or administerin
- Minimum supported version: Android 9, API 28 (see ADR-001)
- Visual identity, icon, and color palette: bright sky blue + white, the blue of the UbiSam wordmark, with the wordmark at the top-left of every tab; the same design in dark mode (see TASK-050, which supersedes TASK-045's palette)
- Navigation: four tabs -- 홈, 저장소, 작업, 알림 -- at the bottom, or down the left edge from 600dp wide (see TASK-050)
- The theme follows the system unless the user chooses light or dark in settings (see TASK-055)
- Home can be narrowed to starred repositories, and remembers the choice (see TASK-055)
- Source highlighting follows Visual Studio's default colors; the code background follows the app theme rather than having a theme of its own (see TASK-053)
- Windows 600dp and wider -- an unfolded Z Fold, a phone on its side -- get two-pane layouts (see TASK-054)
- Repositories are cached for offline browsing at the list level only (branches/files inside a repository are not cached); see TASK-044

View File

@ -0,0 +1,88 @@
# TASK-055: First device feedback on the redesign
Status: implemented; required checks pass; installed on the product owner's Z Fold7, each item awaiting their confirmation
Owner: Claude
Reviewer: human product owner
## Outcome
The redesign (TASK-050 to TASK-054) was installed on the human product owner's Galaxy Z Fold7
(SM-F966N) on 2026-09-28. This task is what they asked for after using it.
## Scope
- Included:
1. Repository list: the repository's name gets a line to itself. With the owner on the same
line, a long organization name (`RobotAutomationDevelopment-Dept`) used the line up and the
name was cut to `...`.
2. The large title is removed from the 저장소, 작업 and 알림 tabs; the selected tab already says
where you are. Home keeps "팀 활동", which says something the tab label does not.
3. A "today" count on Home opens what it counted. "오늘 Push" used to open the repository list,
where repositories pushed five days ago contradicted the word "today". It now narrows the
timeline to today's pushes, and the timeline has an "오늘만" filter of its own.
4. The outline is a tree of classes with their methods. Fields are left out -- there were too
many, and they buried the methods -- and a class or namespace folds.
5. Home can show only starred repositories ("즐겨찾기"), and remembers which source was chosen.
6. Settings has a theme choice: follow the system, light, or dark.
- Excluded: anything not raised by the product owner.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/feature/home/`, `feature/repositories/RepositoryListScreen.kt`,
`feature/work/`, `feature/notifications/NotificationsScreen.kt`, `feature/codebrowser/`,
`feature/settings/`, `core/settings/AppSettingsStore.kt`, `ui/theme/`, `ui/components/`, `MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/data/activity/`, `data/repositories/GiteaRepositoryBrowserApi.kt`,
`domain/activity/`, `domain/model/RepositoryModels.kt`
- `app/src/main/res/`, `app/src/test/`
## Acceptance criteria
- [x] A repository's name is never shortened to make room for its owner.
- [x] The 저장소, 작업 and 알림 tabs have no title block; Home's is unchanged.
- [x] Tapping "오늘 Push" or "새 이슈" shows only today's activity of that kind; "열린 PR" opens the Work tab on pull requests.
- [x] The outline lists namespaces, types and methods, never fields; a folded class hides its methods and shows how many it has.
- [x] The favorites view reads each starred repository's own feed and merges them, newest first.
- [x] One starred repository failing to load leaves the others shown; failing to list the starred repositories is an error.
- [x] The favorites view says how many starred repositories it covers.
- [x] The chosen Home source is restored on the next launch.
- [x] The theme setting overrides the system; code colors, search highlights, the logo and the system bar icons all follow it.
- [x] Build, unit tests and lint pass.
- [ ] Each item confirmed by the product owner on the device.
## Security and privacy
Read-only. The favorites view adds `/repos/{owner}/{repo}/activities/feeds`, within the scopes the
app already requests. The stored settings are a theme name and a feed choice; neither is sensitive.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
Recorded on 2026-09-28 in the worktree `awesome-pascal-3f2ada`:
- The required checks pass. Unit tests: 420, 0 failures (409 before this task). Lint: 0 errors, 57 warnings.
- New tests: outline tree and folding (`SymbolOutlineTest`), today-only filtering, favorites merging,
tolerance of one failing repository, the remembered source (`HomeViewModelTest`).
- Device: installed over wireless `adb` on the SM-F966N. The app starts with no crash in the log.
Before this task's changes, two screens were captured from the device and looked at: the
repository list (which is where item 1 was seen) and the Home timeline, which showed real
activity parsed correctly -- commit messages, branch names, commit ids, "외 9개 커밋", a branch
deletion. That settles TASK-051's open question about the feed's `content` payloads for pushes.
- Not done: looking at this task's own changes on the device. Remote captures were stopped at the
product owner's direction while they were using the phone.
## Known limitations
- The favorites view reads at most 20 starred repositories, the most recently updated first, and
one page (20 activities) of each. It has no "load more".
- Its open pull-request count is the sum of the counts Gitea reports per repository; it is blank
if Gitea reports none.
- The outline starts fully unfolded.
## Review findings
None yet.