From 9046985ba498259b28b7a4c6fdfe87961c80235d Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 28 Sep 2026 15:09:39 +0900 Subject: [PATCH] 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 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 31 ++- .../ubgitea/core/settings/AppSettingsStore.kt | 26 ++ .../ubgitea/data/activity/GiteaActivityApi.kt | 11 +- .../repositories/GiteaRepositoryBrowserApi.kt | 2 + .../ubgitea/domain/activity/ActivityModels.kt | 2 + .../ubgitea/domain/model/RepositoryModels.kt | 2 + .../ubgitea/feature/codebrowser/CodePane.kt | 4 +- .../feature/codebrowser/CodeViewerScreen.kt | 94 +++++++- .../codebrowser/content/SymbolOutline.kt | 19 ++ .../codebrowser/content/SyntaxHighlighter.kt | 4 +- .../ubisam/ubgitea/feature/home/HomeScreen.kt | 130 ++++++++-- .../ubgitea/feature/home/HomeViewModel.kt | 224 ++++++++++++++---- .../notifications/NotificationsScreen.kt | 5 +- .../repositories/RepositoryListScreen.kt | 31 ++- .../feature/settings/SettingsScreen.kt | 37 +++ .../feature/settings/SettingsViewModel.kt | 8 + .../ubisam/ubgitea/feature/work/WorkScreen.kt | 18 +- .../ubgitea/feature/work/WorkViewModel.kt | 3 +- .../ubisam/ubgitea/ui/components/Elements.kt | 13 + .../ubgitea/ui/components/UBGiteaTopBar.kt | 8 +- .../java/com/ubisam/ubgitea/ui/theme/Theme.kt | 16 +- .../logo_ubisam_dark.png} | Bin .../main/res/values/strings_codebrowser.xml | 3 + app/src/main/res/values/strings_home.xml | 8 +- .../main/res/values/strings_notifications.xml | 1 - .../main/res/values/strings_repositories.xml | 1 - app/src/main/res/values/strings_settings.xml | 6 + .../codebrowser/content/SymbolOutlineTest.kt | 53 +++++ .../ubgitea/feature/home/HomeViewModelTest.kt | 137 ++++++++++- docs/UBGitea_기술소개.pptx | Bin 725764 -> 725798 bytes docs/development-log.md | 20 ++ docs/product.md | 2 + tasks/055-device-feedback-round.md | 88 +++++++ 33 files changed, 867 insertions(+), 140 deletions(-) rename app/src/main/res/{drawable-night-nodpi/logo_ubisam.png => drawable-nodpi/logo_ubisam_dark.png} (100%) create mode 100644 tasks/055-device-feedback-round.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index c364b75..821c44f 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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() }, ) diff --git a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt index 47065b5..a736e2c 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt @@ -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" diff --git a/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt index 0a34bb5..c56ad8a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt @@ -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 = + 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 = with(support) { validatePage(page, pageSize) diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt index c84a182..ebd74a0 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt @@ -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?, diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt index 74c62cc..1c9dca6 100644 --- a/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt +++ b/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt @@ -59,4 +59,6 @@ interface ActivityRepository { suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int): Page + + suspend fun listRepositoryFeed(repository: RepositoryCoordinates, date: LocalDate?, page: Int, pageSize: Int): Page } diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt index 6f8cec8..b2fdddc 100644 --- a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt +++ b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt @@ -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( diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt index 030dd05..01a1a4b 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt @@ -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)) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt index 026da3c..1ce6f91 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -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()) } + 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(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, + 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, symbols: List, 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, + 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) } } } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt index 62ea011..210be31 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt @@ -93,6 +93,25 @@ fun extractSymbols(lines: List, language: SyntaxLanguage): List, collapsed: Set = emptySet()): List { + 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, 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, line: Int): List = symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt index 77bf906..8241b82 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt @@ -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) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt index 9708a22..a893889 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt @@ -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, 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, 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 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 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, 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( diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeViewModel.kt index 4be261c..84ea4a2 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeViewModel.kt @@ -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, val filter: ActivityCategory?, timeline: List, @@ -78,12 +111,20 @@ sealed interface HomeUiState { activeRepositories: List, 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 = Collections.unmodifiableList(organizations.toList()) val timeline: List = Collections.unmodifiableList(timeline.toList()) val activeRepositories: List = Collections.unmodifiableList(activeRepositories.toList()) - val visibleTimeline: List = - if (filter == null) this.timeline else this.timeline.filter { it.kind.category == filter } + val visibleTimeline: List = 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 = 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() 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 = - 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 = + 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 } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt index a272e9f..83d4091 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt @@ -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) } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index ea2ef49..6763159 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -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), diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt index 72b7965..ca16d89 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsViewModel.kt index 3e8a534..d719c2f 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsViewModel.kt @@ -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 = 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) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt index b1c8b36..8096842 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt @@ -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) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkViewModel.kt index 2518974..cfc9ea1 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/work/WorkViewModel.kt @@ -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) diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt index 1f4ba0c..713a39d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt @@ -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) { diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/UBGiteaTopBar.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/UBGiteaTopBar.kt index 66a3bea..92c8e13 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/UBGiteaTopBar.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/UBGiteaTopBar.kt @@ -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) diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/theme/Theme.kt b/app/src/main/java/com/ubisam/ubgitea/ui/theme/Theme.kt index 26e3795..04db0ea 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/theme/Theme.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/theme/Theme.kt @@ -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 } diff --git a/app/src/main/res/drawable-night-nodpi/logo_ubisam.png b/app/src/main/res/drawable-nodpi/logo_ubisam_dark.png similarity index 100% rename from app/src/main/res/drawable-night-nodpi/logo_ubisam.png rename to app/src/main/res/drawable-nodpi/logo_ubisam_dark.png diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index 42eb6f6..0c688e4 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -56,6 +56,9 @@ 줄 번호 이동 이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다. + 펼치기 + 접기 + 메서드 %1$d개 줄 %1$d 1~%1$d 사이의 줄 번호를 입력하세요. 네임스페이스 diff --git a/app/src/main/res/values/strings_home.xml b/app/src/main/res/values/strings_home.xml index ae57802..07973a1 100644 --- a/app/src/main/res/values/strings_home.xml +++ b/app/src/main/res/values/strings_home.xml @@ -3,6 +3,10 @@ %1$s · 오늘 기준 팀 활동을 불러오는 중입니다. 내 피드 + 즐겨찾기 + 즐겨찾기한 저장소가 없습니다. 저장소 탭에서 별을 눌러 추가하세요. + 즐겨찾기한 저장소 %1$d개의 활동입니다. + 즐겨찾기한 저장소 %1$d개 중 %2$d개의 활동입니다. 활동 범위 오늘 Push 열린 PR @@ -18,6 +22,8 @@ %1$s · %2$s 최근 7일 활동 %1$d건 타임라인 + 오늘만 + 오늘은 해당하는 활동이 없습니다. 전체 Push PR @@ -57,8 +63,6 @@ 활동을 볼 권한이 없습니다. Gitea 서버에서 오류가 발생했습니다. - 작업 - 모든 저장소의 이슈와 PR 작업을 불러오는 중입니다. 팀 전체 리뷰 요청 diff --git a/app/src/main/res/values/strings_notifications.xml b/app/src/main/res/values/strings_notifications.xml index 545012f..d0afe8d 100644 --- a/app/src/main/res/values/strings_notifications.xml +++ b/app/src/main/res/values/strings_notifications.xml @@ -1,6 +1,5 @@ 알림 - 알림 읽지 않음 전체 읽지 않음 diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index e2f292f..bd9617c 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -41,5 +41,4 @@ 즐겨찾기됨 PR/이슈 전체 - 저장소 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 0fcf09c..b67b8a6 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -12,4 +12,10 @@ 코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다. 글자 크기 %1$dsp + 화면 + 화면 테마 + 코드 보기 색상도 함께 바뀝니다. + 시스템 설정 + 라이트 + 다크 diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt index e59c7de..715750b 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt @@ -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" })) + } } diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/home/HomeViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/home/HomeViewModelTest.kt index b693f76..ce5c3be 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/home/HomeViewModelTest.kt @@ -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 = emptyList(), + private val fail: Boolean = false, + ) : RepositoryFavorites { + override suspend fun listStarred(page: Int, pageSize: Int): Page { + 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 = emptyList(), private val fail: Boolean = false, diff --git a/docs/UBGitea_기술소개.pptx b/docs/UBGitea_기술소개.pptx index 7497dcfcce8c526cb3fabd951894e66aa5630def..bdbd3664d784eaf2bb1292eae15ab61fc3c8525e 100644 GIT binary patch delta 5045 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 delta 4913 zcmZWt3s_Xu7M^p?oSDNugA7;$%BX|yWJF8>1$>}cDJt-lm8M9kT{ZKO_nL;8BpNS8 z9AKgG(lAqr4+A@tAX?sAmqH=mrKa}yk~xzQD)JETejI#o`Iy;f@3r>&_y4cG_gV1C zC-%fo?55~QL63CgXPiC5)St)SfrH5R%QpP&Sk7BA(#@p5dkE=I!2PSZ{LxrbtQ#M!V97fxKM4q(Q1FMYEID7cKNw;74s%+nwExa)(bkwNOpfQfoVKW~* zCx?wD-qm3PJxKfew0neH*>7*YN&7MJ(>dIC6s-T93~zaFo{v5ob9|*_zF-_!7su!c3m7B4F394EUv)A6&NM;{pR6IuaV8ho*GiUbg7K zF9fCR+2X3_sp-R~hnGN94cEVH`0eY|GJS?o8L*J2Uv&`D9*g6OU+si_O@F23Y?Op9;2^ z3vE1G%$FTJ7fakL=Lj0BC0<`$p{$f!a|JDPD_f#Ir86OnlM(ziM+i~Oqxp3lBrFmJ z(e#Wdn$&zE8mA|krbnq+BwVsat2sr^lpLFDXRb4Sr)xTE!Gi+o~pg((3KhJR%RNB!hIsYMxTUIj#77K5|B^;2NmLU9Si1shCC^+&>Md=!q>pj1Ax zA~iqBxi!b87H(AY_Y$C;cuf^)&iq^qX(#B!KZNe7E+)=`WXvSlnYIxQne?k6`i_3S zg4Yu4O@;Q{H+~??l1)YBQ%Xkad$N2PPv6tMp^uAP@NiLE>O5Rge*G90C(0d?GJ3i65~;kTBtL z5hU2f;Y#sc@hZ7$lx=O|I}6BzG1j0>_l{|QmrhqE#Tg3O%wq#%YUoU;b(2w9@6J*V z>K7Zxg{`zMHbl@6;XibRxGJB!!9u)qvMYq(_V0BJeEL}f0}nWFm_cX07iwgpq=p-7 z8R8_bsouty3qcJWrOe74V~6rFBU3gfw$pw#{@9lf9P9AQ^oP7~J7-Xodb$A>EupJz31qfLeF&XQthVG`bduEbK@ z#oO10FKkzKazj}qy|a}Hd|>7 zb1~v$Jd=bNw^w2t)Kn~Kn{U95gP~y4>A*%3U{NNQNJ(ZakWzb8iZ@aP{2wTQ@0D(A zwheWqm)dP}Y_9Zje14yTRguC><>Y^)GH$qQXOcG4=E|(ZRyB_XAa6G{FT{EFw@VrHEZ6zCy-j_d>Fsu=f%xGT2)=ha;rz4*yX<6L$^&Pz4js{Ys^Ks_BK#+uqHJsyG zp*I|DM;2%w=;O~*uh)Cij^f1hTX>zPBbxQaacQwmGIP|WfV)OYo^qEdxZq7 zeQR0wh0-dHNGIgAu|;HwvIWP@>LXjZ(yUOK$d?r-TZOVY0#Pq3o7qe7Q1x7aZh)*m zWs?7_c0880Kwxs=;?6=-|FD?rPNo1>axzOjt#WAUv%jD?fU6w@@x09P(SC7NFDBj| z;CPveHvS3a$xq zPY4ic79c{zYkOgUNcpT~>4fcM@U=lAr2^k;u2=9VA83b_k~{$VVO+ZVMvM>7m^W9a zBcI2$n-U<+n5qP&CTytQyxzYbXo8%)wx1 z8|j9ERYts)@3J7>sBMXgsPfZ5b&9#k095SCv>*i;(TST6ev-`27cIu9Kmo8qVgenM z@G2&^iBQlcdiQvI_rpzMtej_{LIuVe5Cpn0TI1cYrDnc?skP3H37bYncfMg@Nyl}Y zg>JR_fMoE%e@l|j<8qZOHs~BpJ{IsrS`w8_>4D_OU(zOu~t9 zN|wnBBin?dcuY1jez;0R5NVQ4HgcXGR*Kz84x9pq-0}8HF)@|$a1{RMQ;;Cby#UvZ z2(I8DWR)t3f1}>Wj?+Yc5$x`oF0_KA1S>9R__&m`Q0Rw6WOwM=p9nO&KsF@py1|sa zKU$(0jSG?xRQJ#jXw^pLbq|qPUQf7d^3v*!qF)*(G4_Xhuw7Z(L$>OhCP+F*Odm`4 z4dm&83AOAYaNh8ohfMy*0&!$e+$*tH<1P=G{O1Nr$mp4aG`b)Bg?%ZvhxK1~ZZ|%I zL1udULZlhbEkDU%aQP)enptBtX6*V#c45q@*AUN`WiOgxT9r{&)(aXgF3;FxvZM%4 ze8rE1SWlVNSfp!&&pc&XO*P=I;L$F@%yy|i_dO9AX8O*yZVc$7npm>X2Ul($t&}JDHV*U diff --git a/docs/development-log.md b/docs/development-log.md index 7bf1bb9..b87ba25 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/docs/product.md b/docs/product.md index c15e511..b4e8afe 100644 --- a/docs/product.md +++ b/docs/product.md @@ -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 diff --git a/tasks/055-device-feedback-round.md b/tasks/055-device-feedback-round.md new file mode 100644 index 0000000..77da337 --- /dev/null +++ b/tasks/055-device-feedback-round.md @@ -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.