TASK-055: first device feedback on the redesign
Repository names get a line to themselves; the repository, work and notification tabs lose their title; a today count on Home opens today's activity of that kind; the outline is a foldable tree of classes and methods without fields; Home can show only starred repositories and remembers the choice; settings has a light/dark/system theme choice. Whether the app is dark is now the app's decision (UBGiteaTheme.isDark), so the logo moved off a -night drawable. 420 unit tests pass, lint reports 0 errors. Installed on the product owner's Z Fold7. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
d1a45abc16
commit
9046985ba4
@ -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() },
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -22,7 +22,8 @@ import java.time.LocalDate
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`).
|
||||
* Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`,
|
||||
* `/repos/{owner}/{repo}/activities/feeds`).
|
||||
*
|
||||
* An activity's `content` has no schema in Gitea's API description; its shape depends on the
|
||||
* activity type and was taken from Gitea's source rather than confirmed against this instance: a
|
||||
@ -55,6 +56,14 @@ class GiteaActivityApi(
|
||||
return listFeed(support.apiUrl("orgs", organization, "activities", "feeds"), date, page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun listRepositoryFeed(
|
||||
repository: RepositoryCoordinates,
|
||||
date: LocalDate?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<ActivityItem> =
|
||||
listFeed(support.apiUrl("repos", repository.owner, repository.name, "activities", "feeds"), date, page, pageSize)
|
||||
|
||||
private suspend fun listFeed(url: HttpUrl.Builder, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem> =
|
||||
with(support) {
|
||||
validatePage(page, pageSize)
|
||||
|
||||
@ -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?,
|
||||
|
||||
@ -59,4 +59,6 @@ interface ActivityRepository {
|
||||
suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
||||
|
||||
suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
||||
|
||||
suspend fun listRepositoryFeed(repository: RepositoryCoordinates, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -92,6 +92,9 @@ import com.ubisam.ubgitea.feature.codebrowser.content.cppRoleOf
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.enclosingSymbols
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.findMatches
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.findSymbolNamed
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.isContainer
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.methodCountOf
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.outlineTree
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.firstMatchFrom
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
@ -159,6 +162,11 @@ fun CodeViewerScreen(
|
||||
if (matches.isEmpty()) SearchHighlight.None else SearchHighlight(matches.groupBy { it.line }, matches.getOrNull(currentMatch))
|
||||
}
|
||||
var outlineOpen by remember(state.path) { mutableStateOf(false) }
|
||||
// Start lines of the classes and namespaces folded shut in the outline.
|
||||
var collapsed by remember(state.path) { mutableStateOf(emptySet<Int>()) }
|
||||
val toggleCollapsed: (CodeSymbol) -> Unit = { symbol ->
|
||||
collapsed = if (symbol.line in collapsed) collapsed - symbol.line else collapsed + symbol.line
|
||||
}
|
||||
var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
|
||||
var sideBySide by rememberSaveable { mutableStateOf(true) }
|
||||
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
||||
@ -275,6 +283,8 @@ fun CodeViewerScreen(
|
||||
state = state,
|
||||
document = document,
|
||||
currentLine = firstVisibleLine,
|
||||
collapsed = collapsed,
|
||||
onToggleCollapsed = toggleCollapsed,
|
||||
onOpenFile = { onOpenSibling(it, null) },
|
||||
onOpenEntry = onOpenEntry,
|
||||
onSymbolSelected = { goToLine(it.line) },
|
||||
@ -313,6 +323,8 @@ fun CodeViewerScreen(
|
||||
OutlineSheet(
|
||||
document = document,
|
||||
currentLine = firstVisibleLine,
|
||||
collapsed = collapsed,
|
||||
onToggleCollapsed = toggleCollapsed,
|
||||
onSymbolSelected = {
|
||||
outlineOpen = false
|
||||
goToLine(it.line)
|
||||
@ -799,12 +811,15 @@ private fun SidePane(
|
||||
state: CodeBrowserUiState.File,
|
||||
document: CodeDocument,
|
||||
currentLine: Int,
|
||||
collapsed: Set<Int>,
|
||||
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||
onOpenFile: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
|
||||
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
||||
val current = currentIn(tree, document.symbols, currentLine)
|
||||
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
if (state.siblingEntries.isNotEmpty()) {
|
||||
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
|
||||
@ -821,10 +836,17 @@ private fun SidePane(
|
||||
}
|
||||
item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) }
|
||||
}
|
||||
if (document.symbols.isNotEmpty()) {
|
||||
if (tree.isNotEmpty()) {
|
||||
item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) }
|
||||
items(document.symbols, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||
SymbolRow(symbol, isCurrent = symbol == current, compact = true) { onSymbolSelected(symbol) }
|
||||
items(tree, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||
SymbolRow(
|
||||
symbol = symbol,
|
||||
isCurrent = symbol == current,
|
||||
compact = true,
|
||||
isCollapsed = symbol.line in collapsed,
|
||||
methodCount = methodCountOf(document.symbols, symbol),
|
||||
onToggle = { onToggleCollapsed(symbol) },
|
||||
) { onSymbolSelected(symbol) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -865,8 +887,23 @@ private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row to mark as "you are here": the innermost enclosing symbol that is on screen in the tree.
|
||||
* Inside a folded class that is the class itself, since its methods are hidden.
|
||||
*/
|
||||
private fun currentIn(tree: List<CodeSymbol>, symbols: List<CodeSymbol>, line: Int): CodeSymbol? =
|
||||
enclosingSymbols(symbols, line).lastOrNull { it in tree }
|
||||
|
||||
@Composable
|
||||
private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean, onClick: () -> Unit) {
|
||||
private fun SymbolRow(
|
||||
symbol: CodeSymbol,
|
||||
isCurrent: Boolean,
|
||||
compact: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
methodCount: Int,
|
||||
onToggle: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val (letter, color, kind) = when (symbol.kind) {
|
||||
SymbolKind.NAMESPACE -> Triple("N", MaterialTheme.colorScheme.onSurfaceVariant, R.string.codeviewer_symbol_namespace)
|
||||
@ -883,10 +920,23 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics { selected = isCurrent }
|
||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.padding(start = base + step * symbol.depth, end = base),
|
||||
.padding(start = (base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// A class or namespace folds; a method has nothing under it, and keeps the same indent.
|
||||
if (symbol.isContainer) {
|
||||
IconButton(onClick = onToggle, modifier = Modifier.size(if (compact) 32.dp else 40.dp)) {
|
||||
UBIcon(
|
||||
if (isCollapsed) R.drawable.ic_ub_chevron_right else R.drawable.ic_ub_chevron_down,
|
||||
stringResource(if (isCollapsed) R.string.codeviewer_outline_expand else R.string.codeviewer_outline_collapse),
|
||||
size = 18.dp,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(if (compact) 32.dp else 40.dp))
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = color,
|
||||
@ -902,12 +952,20 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
|
||||
symbol.label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = if (compact) 13.sp else 14.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
fontWeight = if (isCurrent || symbol.isContainer) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!compact) {
|
||||
if (symbol.isContainer && isCollapsed && methodCount > 0) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_method_count, methodCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = if (compact) 8.dp else 0.dp),
|
||||
)
|
||||
} else if (!compact) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_line, symbol.line + 1),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@ -923,13 +981,16 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
|
||||
private fun OutlineSheet(
|
||||
document: CodeDocument,
|
||||
currentLine: Int,
|
||||
collapsed: Set<Int>,
|
||||
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
onLineEntered: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var lineInput by remember { mutableStateOf("") }
|
||||
var outOfRange by remember { mutableStateOf(false) }
|
||||
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
|
||||
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
||||
val current = currentIn(tree, document.symbols, currentLine)
|
||||
fun submit() {
|
||||
val line = parseLineNumber(lineInput, document.lines.size)
|
||||
if (line == null) outOfRange = true else onLineEntered(line)
|
||||
@ -977,7 +1038,7 @@ private fun OutlineSheet(
|
||||
Text(stringResource(R.string.codeviewer_outline_go), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
if (document.symbols.isEmpty()) {
|
||||
if (tree.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@ -985,8 +1046,15 @@ private fun OutlineSheet(
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
items(document.symbols, key = { "${it.line}:${it.name}" }) { symbol ->
|
||||
SymbolRow(symbol, isCurrent = symbol == current, compact = false) { onSymbolSelected(symbol) }
|
||||
items(tree, key = { "${it.line}:${it.name}" }) { symbol ->
|
||||
SymbolRow(
|
||||
symbol = symbol,
|
||||
isCurrent = symbol == current,
|
||||
compact = false,
|
||||
isCollapsed = symbol.line in collapsed,
|
||||
methodCount = methodCountOf(document.symbols, symbol),
|
||||
onToggle = { onToggleCollapsed(symbol) },
|
||||
) { onSymbolSelected(symbol) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +93,25 @@ fun extractSymbols(lines: List<String>, language: SyntaxLanguage): List<CodeSymb
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether other symbols can sit inside this one: a namespace or a type. */
|
||||
val CodeSymbol.isContainer: Boolean
|
||||
get() = kind == SymbolKind.NAMESPACE || kind == SymbolKind.TYPE
|
||||
|
||||
/**
|
||||
* The outline as a tree of namespaces and types with their methods under them. Fields are left
|
||||
* out: a class has many, and they bury the methods a reader is looking for. A symbol inside a
|
||||
* collapsed container is hidden; [collapsed] holds the start lines of the collapsed containers.
|
||||
*/
|
||||
fun outlineTree(symbols: List<CodeSymbol>, collapsed: Set<Int> = emptySet()): List<CodeSymbol> {
|
||||
val shown = symbols.filter { it.kind != SymbolKind.FIELD }
|
||||
val closed = shown.filter { it.isContainer && it.line in collapsed }
|
||||
return shown.filter { symbol -> closed.none { symbol.line > it.line && symbol.line <= it.endLine } }
|
||||
}
|
||||
|
||||
/** How many methods a container holds, at any depth: shown beside a collapsed container. */
|
||||
fun methodCountOf(symbols: List<CodeSymbol>, container: CodeSymbol): Int =
|
||||
symbols.count { it.kind == SymbolKind.METHOD && it.line > container.line && it.line <= container.endLine }
|
||||
|
||||
/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
|
||||
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
|
||||
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -34,8 +35,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -57,6 +60,7 @@ import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
@ -71,11 +75,12 @@ import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatHeadlineDate
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Where a tap on the home screen leads. The screen names the place; the activity navigates. */
|
||||
sealed interface HomeDestination {
|
||||
data object Repositories : HomeDestination
|
||||
data class Work(val scope: WorkScope) : HomeDestination
|
||||
data class Work(val scope: WorkScope, val type: WorkTypeFilter = WorkTypeFilter.ALL) : HomeDestination
|
||||
data class Branch(val owner: String, val name: String, val ref: String, val isTag: Boolean) : HomeDestination
|
||||
data class WorkItem(val owner: String, val name: String, val number: Long, val isPullRequest: Boolean) : HomeDestination
|
||||
}
|
||||
@ -87,8 +92,10 @@ fun HomeScreen(
|
||||
instanceHost: String,
|
||||
isWide: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onSelectSource: (HomeSource) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onSetTodayOnly: (Boolean) -> Unit,
|
||||
onShowToday: (ActivityCategory) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onViewSettings: () -> Unit,
|
||||
@ -110,7 +117,9 @@ fun HomeScreen(
|
||||
onAction = if (expired) onAuthenticationExpired else onRefresh,
|
||||
)
|
||||
}
|
||||
is HomeUiState.Content -> HomeContent(state, isWide, onNavigate, onSelectOrganization, onSelectFilter, onLoadMore, onRefresh)
|
||||
is HomeUiState.Content -> HomeContent(
|
||||
state, isWide, onNavigate, onSelectSource, onSelectFilter, onSetTodayOnly, onShowToday, onLoadMore, onRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -121,27 +130,47 @@ private fun HomeContent(
|
||||
state: HomeUiState.Content,
|
||||
isWide: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onSelectSource: (HomeSource) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onSetTodayOnly: (Boolean) -> Unit,
|
||||
onShowToday: (ActivityCategory) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val timelineState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
// The timeline starts after the overview's items: headline, attention, and the two
|
||||
// active-repository items when there are any.
|
||||
val timelineIndex = if (state.activeRepositories.isEmpty()) 2 else 4
|
||||
// A "today" count leads to exactly what it counted: that kind of activity, today, in the
|
||||
// timeline -- not to another tab's full list, where older items would contradict the count.
|
||||
val showToday: (ActivityCategory) -> Unit = { category ->
|
||||
onShowToday(category)
|
||||
scope.launch {
|
||||
if (isWide) timelineState.animateScrollToItem(0) else listState.animateScrollToItem(timelineIndex)
|
||||
}
|
||||
}
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
if (isWide) {
|
||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||
// so the whole picture is on screen at once.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = true, onNavigate, onSelectOrganization)
|
||||
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
||||
}
|
||||
LazyColumn(Modifier.weight(0.54f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
||||
LazyColumn(
|
||||
Modifier.weight(0.54f).fillMaxHeight(),
|
||||
state = timelineState,
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = false, onNavigate, onSelectOrganization)
|
||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -151,7 +180,8 @@ private fun LazyListScope.overview(
|
||||
state: HomeUiState.Content,
|
||||
vertical: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onSelectSource: (HomeSource) -> Unit,
|
||||
onShowToday: (ActivityCategory) -> Unit,
|
||||
) {
|
||||
item(key = "headline") {
|
||||
Surface(
|
||||
@ -164,10 +194,9 @@ private fun LazyListScope.overview(
|
||||
stringResource(R.string.home_title),
|
||||
subtitle = stringResource(R.string.home_subtitle, formatHeadlineDate(state.today)),
|
||||
)
|
||||
if (state.organizations.isNotEmpty()) {
|
||||
OrganizationChips(state.organization, state.organizations, onSelectOrganization)
|
||||
}
|
||||
SummaryTiles(state.summary, onNavigate)
|
||||
SourceChips(state.source, state.organizations, onSelectSource)
|
||||
if (state.source == HomeSource.Favorites) FavoritesNote(state)
|
||||
SummaryTiles(state.summary, onNavigate, onShowToday)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -209,15 +238,22 @@ private fun LazyListScope.timeline(
|
||||
state: HomeUiState.Content,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onSetTodayOnly: (Boolean) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
) {
|
||||
item(key = "timeline-title") { SectionTitle(stringResource(R.string.home_timeline), Modifier.padding(top = 8.dp)) }
|
||||
item(key = "timeline-filter") { FilterChips(state.filter, onSelectFilter) }
|
||||
item(key = "timeline-filter") { FilterChips(state.filter, state.todayOnly, onSelectFilter, onSetTodayOnly) }
|
||||
val visible = state.visibleTimeline
|
||||
if (visible.isEmpty()) {
|
||||
item(key = "timeline-empty") {
|
||||
Text(
|
||||
stringResource(if (state.timeline.isEmpty()) R.string.home_timeline_empty else R.string.home_timeline_filter_empty),
|
||||
stringResource(
|
||||
when {
|
||||
state.timeline.isEmpty() -> R.string.home_timeline_empty
|
||||
state.todayOnly -> R.string.home_timeline_today_empty
|
||||
else -> R.string.home_timeline_filter_empty
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
)
|
||||
@ -262,8 +298,25 @@ private fun LazyListScope.timeline(
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what the favorites view covers, so an empty or partial timeline is not a mystery. */
|
||||
@Composable
|
||||
private fun OrganizationChips(selected: String?, organizations: List<OrganizationSummary>, onSelect: (String?) -> Unit) {
|
||||
private fun FavoritesNote(state: HomeUiState.Content) {
|
||||
val text = when {
|
||||
state.favoriteCount == 0 -> stringResource(R.string.home_favorites_none)
|
||||
state.favoritesRead < state.favoriteCount ->
|
||||
stringResource(R.string.home_favorites_partial, state.favoriteCount, state.favoritesRead)
|
||||
else -> stringResource(R.string.home_favorites_all, state.favoriteCount)
|
||||
}
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceChips(selected: HomeSource, organizations: List<OrganizationSummary>, onSelect: (HomeSource) -> Unit) {
|
||||
val label = stringResource(R.string.home_source_label)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
@ -273,11 +326,23 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
|
||||
.padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_source_mine)) })
|
||||
FilterChip(selected == HomeSource.Mine, { onSelect(HomeSource.Mine) }, { Text(stringResource(R.string.home_source_mine)) })
|
||||
FilterChip(
|
||||
selected = selected == HomeSource.Favorites,
|
||||
onClick = { onSelect(HomeSource.Favorites) },
|
||||
label = { Text(stringResource(R.string.home_source_favorites)) },
|
||||
leadingIcon = {
|
||||
UBIcon(
|
||||
if (selected == HomeSource.Favorites) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star,
|
||||
null,
|
||||
size = 18.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
organizations.forEach { organization ->
|
||||
FilterChip(
|
||||
selected == organization.name,
|
||||
{ onSelect(organization.name) },
|
||||
selected == HomeSource.Organization(organization.name),
|
||||
{ onSelect(HomeSource.Organization(organization.name)) },
|
||||
{ Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) },
|
||||
)
|
||||
}
|
||||
@ -285,20 +350,20 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryTiles(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit) {
|
||||
private fun SummaryTiles(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit, onShowToday: (ActivityCategory) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_commit, R.string.home_stat_pushes, summary.todayPushes, summary.todayIsLowerBound,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Repositories) }
|
||||
) { onShowToday(ActivityCategory.PUSH) }
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_pull_request, R.string.home_stat_open_pull_requests, summary.openPullRequests, false,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
|
||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE, WorkTypeFilter.PULL_REQUESTS)) }
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_issue, R.string.home_stat_issues, summary.todayIssuesOpened, summary.todayIsLowerBound,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
|
||||
) { onShowToday(ActivityCategory.ISSUE) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -460,11 +525,24 @@ private fun ActivityBars(counts: List<Int>, modifier: Modifier = Modifier) {
|
||||
private const val MIN_BAR_FRACTION = 0.12f
|
||||
|
||||
@Composable
|
||||
private fun FilterChips(selected: ActivityCategory?, onSelect: (ActivityCategory?) -> Unit) {
|
||||
private fun FilterChips(
|
||||
selected: ActivityCategory?,
|
||||
todayOnly: Boolean,
|
||||
onSelect: (ActivityCategory?) -> Unit,
|
||||
onSetTodayOnly: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip(
|
||||
todayOnly,
|
||||
{ onSetTodayOnly(!todayOnly) },
|
||||
{ Text(stringResource(R.string.home_filter_today)) },
|
||||
leadingIcon = if (todayOnly) ({ UBIcon(R.drawable.ic_ub_check, null, size = 18.dp) }) else null,
|
||||
)
|
||||
VerticalDivider(Modifier.height(24.dp), color = MaterialTheme.colorScheme.outlineVariant)
|
||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_filter_all)) })
|
||||
FilterChip(selected == ActivityCategory.PUSH, { onSelect(ActivityCategory.PUSH) }, { Text(stringResource(R.string.home_filter_push)) })
|
||||
FilterChip(
|
||||
|
||||
@ -19,8 +19,10 @@ import com.ubisam.ubgitea.domain.activity.ActivityCategory
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
||||
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
@ -34,6 +36,7 @@ import java.util.Collections
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -42,6 +45,37 @@ import kotlinx.coroutines.launch
|
||||
|
||||
enum class HomeError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, SERVER }
|
||||
|
||||
/** Whose activity Home shows. */
|
||||
sealed interface HomeSource {
|
||||
/** The signed-in account's dashboard feed: every repository that account follows. */
|
||||
data object Mine : HomeSource
|
||||
|
||||
/** Only the repositories the account has starred, each read from its own feed. */
|
||||
data object Favorites : HomeSource
|
||||
|
||||
data class Organization(val name: String) : HomeSource
|
||||
|
||||
/** How the choice is remembered between launches. */
|
||||
fun encode(): String = when (this) {
|
||||
Mine -> ENCODED_MINE
|
||||
Favorites -> ENCODED_FAVORITES
|
||||
is Organization -> ENCODED_ORGANIZATION_PREFIX + name
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ENCODED_MINE = "mine"
|
||||
private const val ENCODED_FAVORITES = "favorites"
|
||||
private const val ENCODED_ORGANIZATION_PREFIX = "org:"
|
||||
|
||||
fun decode(encoded: String?): HomeSource = when {
|
||||
encoded == ENCODED_FAVORITES -> Favorites
|
||||
encoded != null && encoded.startsWith(ENCODED_ORGANIZATION_PREFIX) ->
|
||||
encoded.removePrefix(ENCODED_ORGANIZATION_PREFIX).takeIf(String::isNotBlank)?.let(::Organization) ?: Mine
|
||||
else -> Mine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's numbers. Each is null while it is still loading or when it could not be loaded: the
|
||||
* timeline is what the screen is for, and a count that failed must not take the timeline down
|
||||
@ -67,8 +101,7 @@ sealed interface HomeUiState {
|
||||
|
||||
class Content(
|
||||
val accountLogin: String,
|
||||
/** The organization whose feed is shown; null for the signed-in account's own feed. */
|
||||
val organization: String?,
|
||||
val source: HomeSource,
|
||||
organizations: List<OrganizationSummary>,
|
||||
val filter: ActivityCategory?,
|
||||
timeline: List<ActivityItem>,
|
||||
@ -78,12 +111,20 @@ sealed interface HomeUiState {
|
||||
activeRepositories: List<ActiveRepository>,
|
||||
val summary: HomeSummary,
|
||||
val today: LocalDate,
|
||||
val zone: ZoneId,
|
||||
/** When set, the timeline shows only what happened today: what a "today" count leads to. */
|
||||
val todayOnly: Boolean = false,
|
||||
/** For [HomeSource.Favorites]: how many repositories are starred, and how many of them were read. */
|
||||
val favoriteCount: Int = 0,
|
||||
val favoritesRead: Int = 0,
|
||||
) : HomeUiState {
|
||||
val organizations: List<OrganizationSummary> = Collections.unmodifiableList(organizations.toList())
|
||||
val timeline: List<ActivityItem> = Collections.unmodifiableList(timeline.toList())
|
||||
val activeRepositories: List<ActiveRepository> = Collections.unmodifiableList(activeRepositories.toList())
|
||||
val visibleTimeline: List<ActivityItem> =
|
||||
if (filter == null) this.timeline else this.timeline.filter { it.kind.category == filter }
|
||||
val visibleTimeline: List<ActivityItem> = this.timeline.filter { item ->
|
||||
(filter == null || item.kind.category == filter) &&
|
||||
(!todayOnly || Instant.ofEpochMilli(item.createdAtEpochMillis).atZone(zone).toLocalDate() == today)
|
||||
}
|
||||
|
||||
internal fun copy(
|
||||
filter: ActivityCategory? = this.filter,
|
||||
@ -93,13 +134,14 @@ sealed interface HomeUiState {
|
||||
loadMoreFailed: Boolean = this.loadMoreFailed,
|
||||
activeRepositories: List<ActiveRepository> = this.activeRepositories,
|
||||
summary: HomeSummary = this.summary,
|
||||
todayOnly: Boolean = this.todayOnly,
|
||||
) = Content(
|
||||
accountLogin, organization, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
|
||||
activeRepositories, summary, today,
|
||||
accountLogin, source, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
|
||||
activeRepositories, summary, today, zone, todayOnly, favoriteCount, favoritesRead,
|
||||
)
|
||||
}
|
||||
|
||||
data class Error(val type: HomeError, val organization: String?) : HomeUiState
|
||||
data class Error(val type: HomeError, val source: HomeSource) : HomeUiState
|
||||
}
|
||||
|
||||
class HomeViewModel(
|
||||
@ -108,6 +150,9 @@ class HomeViewModel(
|
||||
private val activity: ActivityRepository,
|
||||
private val workSearch: WorkSearchRepository,
|
||||
private val organizations: OrganizationsRepository,
|
||||
private val favorites: RepositoryFavorites,
|
||||
private val savedSource: () -> String? = { null },
|
||||
private val saveSource: (String) -> Unit = {},
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val zone: () -> ZoneId = ZoneId::systemDefault,
|
||||
) : ViewModel() {
|
||||
@ -122,14 +167,15 @@ class HomeViewModel(
|
||||
/** Loads the feed unless it is already on screen; switching back to the tab must not refetch. */
|
||||
fun open() {
|
||||
if (mutableState.value is HomeUiState.Content || mutableState.value is HomeUiState.Loading) return
|
||||
load(organization = null)
|
||||
load(HomeSource.decode(savedSource()))
|
||||
}
|
||||
|
||||
fun refresh() = load(currentOrganization())
|
||||
fun refresh() = load(currentSource())
|
||||
|
||||
fun selectOrganization(organization: String?) {
|
||||
if (organization == currentOrganization() && mutableState.value is HomeUiState.Content) return
|
||||
load(organization)
|
||||
fun selectSource(source: HomeSource) {
|
||||
if (source == currentSource() && mutableState.value is HomeUiState.Content) return
|
||||
saveSource(source.encode())
|
||||
load(source)
|
||||
}
|
||||
|
||||
fun selectFilter(filter: ActivityCategory?) {
|
||||
@ -137,6 +183,17 @@ class HomeViewModel(
|
||||
mutableState.value = content.copy(filter = filter)
|
||||
}
|
||||
|
||||
fun setTodayOnly(todayOnly: Boolean) {
|
||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||
mutableState.value = content.copy(todayOnly = todayOnly)
|
||||
}
|
||||
|
||||
/** What a "today" count opens: the timeline narrowed to that kind of activity, today only. */
|
||||
fun showToday(filter: ActivityCategory) {
|
||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||
mutableState.value = content.copy(filter = filter, todayOnly = true)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||
val page = content.nextPage ?: return
|
||||
@ -144,7 +201,7 @@ class HomeViewModel(
|
||||
mutableState.value = content.copy(isLoadingMore = true, loadMoreFailed = false)
|
||||
moreJob = viewModelScope.launch {
|
||||
try {
|
||||
val result = feedPage(content.accountLogin, content.organization, date = null, page = page)
|
||||
val result = feedPage(content.accountLogin, content.source, date = null, page = page)
|
||||
update { current ->
|
||||
val merged = (current.timeline + result.items).distinctBy(ActivityItem::id)
|
||||
current.copy(
|
||||
@ -168,7 +225,7 @@ class HomeViewModel(
|
||||
mutableState.value = HomeUiState.Idle
|
||||
}
|
||||
|
||||
private fun load(organization: String?) {
|
||||
private fun load(source: HomeSource) {
|
||||
cancelAll()
|
||||
mutableState.value = HomeUiState.Loading
|
||||
operation = viewModelScope.launch {
|
||||
@ -176,14 +233,18 @@ class HomeViewModel(
|
||||
val login = accountLogin ?: authGateway.getAuthenticatedUser(tokenProvider.accessToken()).login
|
||||
.also { accountLogin = it }
|
||||
val today = Instant.ofEpochMilli(now()).atZone(zone()).toLocalDate()
|
||||
if (source == HomeSource.Favorites) {
|
||||
loadFavorites(login, today)
|
||||
return@launch
|
||||
}
|
||||
val (first, organizationList) = coroutineScope {
|
||||
val feed = async { feedPage(login, organization, date = null, page = 1) }
|
||||
val feed = async { feedPage(login, source, date = null, page = 1) }
|
||||
val orgs = async { loadOrganizations() }
|
||||
feed.await() to orgs.await()
|
||||
}
|
||||
val content = HomeUiState.Content(
|
||||
mutableState.value = HomeUiState.Content(
|
||||
accountLogin = login,
|
||||
organization = organization,
|
||||
source = source,
|
||||
organizations = organizationList,
|
||||
filter = null,
|
||||
timeline = first.items.distinctBy(ActivityItem::id),
|
||||
@ -193,39 +254,73 @@ class HomeViewModel(
|
||||
activeRepositories = activeRepositoriesOf(first.items, today, zone()),
|
||||
summary = HomeSummary(),
|
||||
today = today,
|
||||
zone = zone(),
|
||||
)
|
||||
mutableState.value = content
|
||||
loadSummary(login, organization, today)
|
||||
loadSummary(login, source, today)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
mutableState.value = HomeUiState.Error(failure.toHomeError(), organization)
|
||||
mutableState.value = HomeUiState.Error(failure.toHomeError(), source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea has no feed of "my starred repositories", so each one's own feed is read and the
|
||||
// results merged. A repository whose feed fails is left out rather than failing the rest;
|
||||
// only failing to list the starred repositories at all fails the screen.
|
||||
private suspend fun loadFavorites(login: String, today: LocalDate) {
|
||||
val (starred, organizationList) = coroutineScope {
|
||||
val stars = async { favorites.listStarred(1, FAVORITES_PAGE_SIZE).items }
|
||||
val orgs = async { loadOrganizations() }
|
||||
stars.await() to orgs.await()
|
||||
}
|
||||
val read = starred.sortedByDescending(RepositorySummary::updatedAtEpochMillis).take(MAX_FAVORITES_READ)
|
||||
val feeds = coroutineScope {
|
||||
read.map { repository ->
|
||||
async { tolerated { activity.listRepositoryFeed(repository.coordinates, null, 1, FAVORITE_FEED_PAGE_SIZE).items } }
|
||||
}.awaitAll()
|
||||
}
|
||||
val timeline = feeds.filterNotNull().flatten()
|
||||
.distinctBy(ActivityItem::id)
|
||||
.sortedByDescending(ActivityItem::createdAtEpochMillis)
|
||||
val counts = todayCountsOf(timeline, today, zone())
|
||||
mutableState.value = HomeUiState.Content(
|
||||
accountLogin = login,
|
||||
source = HomeSource.Favorites,
|
||||
organizations = organizationList,
|
||||
filter = null,
|
||||
timeline = timeline,
|
||||
// Every repository was read one page deep; there is no single "next page" of a merge.
|
||||
nextPage = null,
|
||||
isLoadingMore = false,
|
||||
loadMoreFailed = false,
|
||||
activeRepositories = activeRepositoriesOf(timeline, today, zone()),
|
||||
summary = HomeSummary(
|
||||
todayPushes = counts.pushes,
|
||||
todayIssuesOpened = counts.issuesOpened,
|
||||
// A repository with more activity today than one page holds is under-counted.
|
||||
todayIsLowerBound = feeds.any { it != null && it.size >= FAVORITE_FEED_PAGE_SIZE },
|
||||
openPullRequests = read.sumOf { it.openPullRequestCount ?: 0 }.takeIf { read.any { r -> r.openPullRequestCount != null } },
|
||||
),
|
||||
today = today,
|
||||
zone = zone(),
|
||||
favoriteCount = starred.size,
|
||||
favoritesRead = feeds.count { it != null },
|
||||
)
|
||||
loadAttention()
|
||||
}
|
||||
|
||||
// Every number is fetched on its own and tolerated on its own: one failing count leaves that
|
||||
// one tile blank instead of failing the screen.
|
||||
private fun loadSummary(login: String, organization: String?, today: LocalDate) {
|
||||
private fun loadSummary(login: String, source: HomeSource, today: LocalDate) {
|
||||
val owner = (source as? HomeSource.Organization)?.name
|
||||
summaryJob = viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val todays = async { tolerated { loadToday(login, organization, today) } }
|
||||
val todays = async { tolerated { loadToday(login, source, today) } }
|
||||
val openPullRequests = async {
|
||||
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = organization)) }
|
||||
}
|
||||
val reviewRequested = async {
|
||||
tolerated {
|
||||
workSearch.count(
|
||||
WorkSearchQuery(WorkScope.REVIEW_REQUESTED, WorkStateFilter.OPEN, WorkTypeFilter.PULL_REQUESTS, organization),
|
||||
)
|
||||
}
|
||||
}
|
||||
val assigned = async {
|
||||
tolerated {
|
||||
workSearch.count(
|
||||
WorkSearchQuery(WorkScope.ASSIGNED_TO_ME, WorkStateFilter.OPEN, WorkTypeFilter.ISSUES, organization),
|
||||
)
|
||||
}
|
||||
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = owner)) }
|
||||
}
|
||||
val reviewRequested = async { tolerated { workSearch.count(reviewRequestedQuery(owner)) } }
|
||||
val assigned = async { tolerated { workSearch.count(assignedQuery(owner)) } }
|
||||
val todayResult = todays.await()
|
||||
val summary = HomeSummary(
|
||||
todayPushes = todayResult?.counts?.pushes,
|
||||
@ -241,14 +336,33 @@ class HomeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// What waits on the signed-in person is about them, not about which repositories are shown.
|
||||
private fun loadAttention() {
|
||||
summaryJob = viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val reviewRequested = async { tolerated { workSearch.count(reviewRequestedQuery(null)) } }
|
||||
val assigned = async { tolerated { workSearch.count(assignedQuery(null)) } }
|
||||
val requested = reviewRequested.await()
|
||||
val assignedCount = assigned.await()
|
||||
update { it.copy(summary = it.summary.copy(reviewRequested = requested, assignedIssues = assignedCount, isLoading = false)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reviewRequestedQuery(owner: String?) =
|
||||
WorkSearchQuery(WorkScope.REVIEW_REQUESTED, WorkStateFilter.OPEN, WorkTypeFilter.PULL_REQUESTS, owner)
|
||||
|
||||
private fun assignedQuery(owner: String?) =
|
||||
WorkSearchQuery(WorkScope.ASSIGNED_TO_ME, WorkStateFilter.OPEN, WorkTypeFilter.ISSUES, owner)
|
||||
|
||||
private class Today(val counts: TodayCounts, val isLowerBound: Boolean)
|
||||
|
||||
private suspend fun loadToday(login: String, organization: String?, today: LocalDate): Today {
|
||||
private suspend fun loadToday(login: String, source: HomeSource, today: LocalDate): Today {
|
||||
val items = mutableListOf<ActivityItem>()
|
||||
var page: Int? = 1
|
||||
var fetched = 0
|
||||
while (page != null && fetched < TODAY_MAX_PAGES) {
|
||||
val result = feedPage(login, organization, date = today, page = page)
|
||||
val result = feedPage(login, source, date = today, page = page)
|
||||
items += result.items
|
||||
page = result.nextPage
|
||||
fetched++
|
||||
@ -256,11 +370,10 @@ class HomeViewModel(
|
||||
return Today(todayCountsOf(items, today, zone()), isLowerBound = page != null)
|
||||
}
|
||||
|
||||
private suspend fun feedPage(login: String, organization: String?, date: LocalDate?, page: Int): Page<ActivityItem> =
|
||||
if (organization == null) {
|
||||
activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
|
||||
} else {
|
||||
activity.listOrganizationFeed(organization, date, page, FEED_PAGE_SIZE)
|
||||
private suspend fun feedPage(login: String, source: HomeSource, date: LocalDate?, page: Int): Page<ActivityItem> =
|
||||
when (source) {
|
||||
is HomeSource.Organization -> activity.listOrganizationFeed(source.name, date, page, FEED_PAGE_SIZE)
|
||||
HomeSource.Mine, HomeSource.Favorites -> activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
|
||||
}
|
||||
|
||||
// The organization filter is a convenience. Failing to list organizations hides the filter;
|
||||
@ -284,10 +397,10 @@ class HomeViewModel(
|
||||
mutableState.value = transform(current)
|
||||
}
|
||||
|
||||
private fun currentOrganization(): String? = when (val current = mutableState.value) {
|
||||
is HomeUiState.Content -> current.organization
|
||||
is HomeUiState.Error -> current.organization
|
||||
else -> null
|
||||
private fun currentSource(): HomeSource = when (val current = mutableState.value) {
|
||||
is HomeUiState.Content -> current.source
|
||||
is HomeUiState.Error -> current.source
|
||||
else -> HomeSource.decode(savedSource())
|
||||
}
|
||||
|
||||
private fun cancelAll() {
|
||||
@ -302,13 +415,18 @@ class HomeViewModel(
|
||||
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
|
||||
val config = UBGiteaOAuthDefaults.config
|
||||
val tokenProvider = AuthSession.tokenProvider(application)
|
||||
val retry = { AppSettingsSession.store(application).isReactiveAuthRetryEnabled() }
|
||||
val settings = AppSettingsSession.store(application)
|
||||
val retry = { settings.isReactiveAuthRetryEnabled() }
|
||||
val browser = GiteaRepositoryBrowserApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry)
|
||||
return HomeViewModel(
|
||||
authGateway = AuthApi(config, OkHttpTransport()),
|
||||
tokenProvider = tokenProvider,
|
||||
activity = GiteaActivityApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
workSearch = GiteaWorkSearchApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
organizations = GiteaRepositoryBrowserApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
organizations = browser,
|
||||
favorites = browser,
|
||||
savedSource = settings::homeSource,
|
||||
saveSource = settings::setHomeSource,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
@ -317,6 +435,12 @@ class HomeViewModel(
|
||||
const val FEED_PAGE_SIZE = 50
|
||||
const val ORGANIZATION_PAGE_SIZE = 50
|
||||
const val TODAY_MAX_PAGES = 3
|
||||
const val FAVORITES_PAGE_SIZE = 50
|
||||
const val FAVORITE_FEED_PAGE_SIZE = 20
|
||||
|
||||
// One request per starred repository: enough for anyone's working set, bounded so that
|
||||
// someone who has starred a hundred repositories does not start a hundred requests.
|
||||
const val MAX_FAVORITES_READ = 20
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@ -14,6 +15,7 @@ data class SettingsUiState(
|
||||
val favoritePushNotificationsEnabled: Boolean,
|
||||
val codeWordWrapEnabled: Boolean = true,
|
||||
val codeFontSizeSp: Int = AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP,
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
)
|
||||
|
||||
/**
|
||||
@ -28,6 +30,7 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
||||
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
|
||||
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
|
||||
codeFontSizeSp = store.codeFontSizeSp(),
|
||||
themeMode = store.themeMode(),
|
||||
),
|
||||
)
|
||||
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
||||
@ -42,6 +45,11 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
||||
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
store.setThemeMode(mode)
|
||||
mutableState.value = mutableState.value.copy(themeMode = mode)
|
||||
}
|
||||
|
||||
fun setCodeWordWrapEnabled(enabled: Boolean) {
|
||||
store.setCodeWordWrapEnabled(enabled)
|
||||
mutableState.value = mutableState.value.copy(codeWordWrapEnabled = enabled)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@ -56,6 +56,9 @@
|
||||
<string name="codeviewer_outline_hint">줄 번호</string>
|
||||
<string name="codeviewer_outline_go">이동</string>
|
||||
<string name="codeviewer_outline_empty">이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다.</string>
|
||||
<string name="codeviewer_outline_expand">펼치기</string>
|
||||
<string name="codeviewer_outline_collapse">접기</string>
|
||||
<string name="codeviewer_outline_method_count">메서드 %1$d개</string>
|
||||
<string name="codeviewer_outline_line">줄 %1$d</string>
|
||||
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
|
||||
<string name="codeviewer_symbol_namespace">네임스페이스</string>
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
<string name="home_subtitle">%1$s · 오늘 기준</string>
|
||||
<string name="home_loading">팀 활동을 불러오는 중입니다.</string>
|
||||
<string name="home_source_mine">내 피드</string>
|
||||
<string name="home_source_favorites">즐겨찾기</string>
|
||||
<string name="home_favorites_none">즐겨찾기한 저장소가 없습니다. 저장소 탭에서 별을 눌러 추가하세요.</string>
|
||||
<string name="home_favorites_all">즐겨찾기한 저장소 %1$d개의 활동입니다.</string>
|
||||
<string name="home_favorites_partial">즐겨찾기한 저장소 %1$d개 중 %2$d개의 활동입니다.</string>
|
||||
<string name="home_source_label">활동 범위</string>
|
||||
<string name="home_stat_pushes">오늘 Push</string>
|
||||
<string name="home_stat_open_pull_requests">열린 PR</string>
|
||||
@ -18,6 +22,8 @@
|
||||
<string name="home_active_repository_footer">%1$s · %2$s</string>
|
||||
<string name="home_activity_bars_description">최근 7일 활동 %1$d건</string>
|
||||
<string name="home_timeline">타임라인</string>
|
||||
<string name="home_filter_today">오늘만</string>
|
||||
<string name="home_timeline_today_empty">오늘은 해당하는 활동이 없습니다.</string>
|
||||
<string name="home_filter_all">전체</string>
|
||||
<string name="home_filter_push">Push</string>
|
||||
<string name="home_filter_pull_request">PR</string>
|
||||
@ -57,8 +63,6 @@
|
||||
<string name="home_permission_denied">활동을 볼 권한이 없습니다.</string>
|
||||
<string name="home_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||
|
||||
<string name="work_title">작업</string>
|
||||
<string name="work_subtitle">모든 저장소의 이슈와 PR</string>
|
||||
<string name="work_loading">작업을 불러오는 중입니다.</string>
|
||||
<string name="work_scope_everyone">팀 전체</string>
|
||||
<string name="work_scope_review_requested">리뷰 요청</string>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<resources>
|
||||
<string name="repositories_notifications">알림</string>
|
||||
<string name="notifications_title">알림</string>
|
||||
<string name="notifications_filter_unread">읽지 않음</string>
|
||||
<string name="notifications_filter_all">전체</string>
|
||||
<string name="notifications_unread_dot_description">읽지 않음</string>
|
||||
|
||||
@ -41,5 +41,4 @@
|
||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||
<string name="repositories_work_items">PR/이슈</string>
|
||||
<string name="repositories_organization_all">전체</string>
|
||||
<string name="repositories_list_title">저장소</string>
|
||||
</resources>
|
||||
|
||||
@ -12,4 +12,10 @@
|
||||
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
|
||||
<string name="settings_code_font_size_title">글자 크기</string>
|
||||
<string name="settings_code_font_size_value">%1$dsp</string>
|
||||
<string name="settings_section_display">화면</string>
|
||||
<string name="settings_theme_title">화면 테마</string>
|
||||
<string name="settings_theme_description">코드 보기 색상도 함께 바뀝니다.</string>
|
||||
<string name="settings_theme_system">시스템 설정</string>
|
||||
<string name="settings_theme_light">라이트</string>
|
||||
<string name="settings_theme_dark">다크</string>
|
||||
</resources>
|
||||
|
||||
@ -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" }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
||||
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
@ -51,12 +52,16 @@ class HomeViewModelTest {
|
||||
activity: ActivityRepository,
|
||||
workSearch: WorkSearchRepository = FakeWorkSearchRepository { 0 },
|
||||
organizations: OrganizationsRepository = FakeOrganizationsRepository(),
|
||||
favorites: RepositoryFavorites = FakeFavorites(),
|
||||
savedSource: String? = null,
|
||||
) = HomeViewModel(
|
||||
authGateway = FakeAuthGateway(),
|
||||
tokenProvider = tokenProvider,
|
||||
activity = activity,
|
||||
workSearch = workSearch,
|
||||
organizations = organizations,
|
||||
favorites = favorites,
|
||||
savedSource = { savedSource },
|
||||
now = { 1_700_000_000_000L },
|
||||
zone = { zone },
|
||||
)
|
||||
@ -176,7 +181,111 @@ class HomeViewModelTest {
|
||||
assertEquals(HomeUiState.Idle, vm.state.value)
|
||||
}
|
||||
|
||||
private fun item(id: Long, kind: ActivityKind = ActivityKind.PUSH) = ActivityItem(
|
||||
@Test
|
||||
fun `favorites merge each starred repository's feed, newest first`() = runTest(dispatcher.scheduler) {
|
||||
val now = 1_700_000_000_000L
|
||||
val activity = FakeActivityRepository { source, _, _ ->
|
||||
when (source) {
|
||||
"repo:team/alpha" -> Page.of(listOf(item(1, at = now - 3_000L), item(2, at = now - 1_000L)), null)
|
||||
"repo:team/beta" -> Page.of(listOf(item(3, at = now - 2_000L)), null)
|
||||
else -> error("Unexpected feed $source")
|
||||
}
|
||||
}
|
||||
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha", 2), starred("beta", 1))))
|
||||
|
||||
vm.selectSource(HomeSource.Favorites)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(HomeSource.Favorites, state.source)
|
||||
assertEquals(listOf(2L, 3L, 1L), state.timeline.map(ActivityItem::id))
|
||||
assertEquals(2, state.favoriteCount)
|
||||
assertEquals(2, state.favoritesRead)
|
||||
assertEquals(3, state.summary.openPullRequests)
|
||||
assertNull(state.nextPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one starred repository failing leaves the others shown`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { source, _, _ ->
|
||||
if (source == "repo:team/alpha") throw ApiFailure.Server() else Page.of(listOf(item(3)), null)
|
||||
}
|
||||
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha"), starred("beta"))))
|
||||
|
||||
vm.selectSource(HomeSource.Favorites)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(listOf(3L), state.timeline.map(ActivityItem::id))
|
||||
assertEquals(1, state.favoritesRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failing to list starred repositories is an error`() = runTest(dispatcher.scheduler) {
|
||||
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(emptyList(), null) }, favorites = FakeFavorites(fail = true))
|
||||
|
||||
vm.selectSource(HomeSource.Favorites)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(HomeUiState.Error(HomeError.SERVER, HomeSource.Favorites), vm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open starts on the source that was chosen last`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(listOf(item(1)), null) }
|
||||
val vm = viewModel(activity, favorites = FakeFavorites(listOf(starred("alpha"))), savedSource = "favorites")
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(HomeSource.Favorites, (vm.state.value as HomeUiState.Content).source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a source round trips through its saved form`() {
|
||||
listOf(HomeSource.Mine, HomeSource.Favorites, HomeSource.Organization("team")).forEach { source ->
|
||||
assertEquals(source, HomeSource.decode(source.encode()))
|
||||
}
|
||||
assertEquals(HomeSource.Mine, HomeSource.decode(null))
|
||||
assertEquals(HomeSource.Mine, HomeSource.decode("org:"))
|
||||
assertEquals(HomeSource.Mine, HomeSource.decode("something else"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showToday narrows the timeline to that category and to today`() = runTest(dispatcher.scheduler) {
|
||||
val now = 1_700_000_000_000L
|
||||
val fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000L
|
||||
val items = listOf(
|
||||
item(1, kind = ActivityKind.PUSH, at = now - 60_000L),
|
||||
item(2, kind = ActivityKind.PUSH, at = fiveDaysAgo),
|
||||
item(3, kind = ActivityKind.ISSUE_OPENED, at = now - 60_000L),
|
||||
)
|
||||
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(items, null) })
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.showToday(ActivityCategory.PUSH)
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(listOf(1L), state.visibleTimeline.map(ActivityItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning todayOnly off brings older activity back`() = runTest(dispatcher.scheduler) {
|
||||
val now = 1_700_000_000_000L
|
||||
val items = listOf(item(1, at = now - 60_000L), item(2, at = now - 5 * 24 * 60 * 60 * 1000L))
|
||||
val vm = viewModel(FakeActivityRepository { _, _, _ -> Page.of(items, null) })
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.showToday(ActivityCategory.PUSH)
|
||||
vm.setTodayOnly(false)
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(listOf(1L, 2L), state.visibleTimeline.map(ActivityItem::id))
|
||||
}
|
||||
|
||||
private fun item(id: Long, kind: ActivityKind = ActivityKind.PUSH, at: Long = 0L) = ActivityItem(
|
||||
id = id,
|
||||
kind = kind,
|
||||
actorLogin = "octocat",
|
||||
@ -187,7 +296,7 @@ class HomeViewModelTest {
|
||||
subjectText = null,
|
||||
commits = emptyList(),
|
||||
commitCount = 0,
|
||||
createdAtEpochMillis = 0L,
|
||||
createdAtEpochMillis = at,
|
||||
)
|
||||
|
||||
private class FakeAuthGateway(private val login: String = "octocat") : AuthenticationGateway {
|
||||
@ -203,6 +312,8 @@ class HomeViewModelTest {
|
||||
) : ActivityRepository {
|
||||
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||
feed(null, date, page)
|
||||
override suspend fun listRepositoryFeed(repository: RepositoryCoordinates, date: LocalDate?, page: Int, pageSize: Int) =
|
||||
feed("repo:${repository.owner}/${repository.name}", date, page)
|
||||
override suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||
feed(organization, date, page)
|
||||
}
|
||||
@ -213,6 +324,28 @@ class HomeViewModelTest {
|
||||
override suspend fun count(query: WorkSearchQuery): Int? = counts(query)
|
||||
}
|
||||
|
||||
private class FakeFavorites(
|
||||
private val starred: List<RepositorySummary> = emptyList(),
|
||||
private val fail: Boolean = false,
|
||||
) : RepositoryFavorites {
|
||||
override suspend fun listStarred(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
if (fail) throw ApiFailure.Server()
|
||||
return Page.of(starred, null)
|
||||
}
|
||||
override suspend fun star(repository: RepositoryCoordinates) = error("Not used")
|
||||
override suspend fun unstar(repository: RepositoryCoordinates) = error("Not used")
|
||||
}
|
||||
|
||||
private fun starred(name: String, openPullRequests: Int? = null) = RepositorySummary(
|
||||
coordinates = RepositoryCoordinates.of("team", name),
|
||||
description = null,
|
||||
defaultBranch = null,
|
||||
isPrivate = true,
|
||||
isFork = false,
|
||||
updatedAtEpochMillis = 0L,
|
||||
openPullRequestCount = openPullRequests,
|
||||
)
|
||||
|
||||
private class FakeOrganizationsRepository(
|
||||
private val organizations: List<OrganizationSummary> = emptyList(),
|
||||
private val fail: Boolean = false,
|
||||
|
||||
Binary file not shown.
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
88
tasks/055-device-feedback-round.md
Normal file
88
tasks/055-device-feedback-round.md
Normal file
@ -0,0 +1,88 @@
|
||||
# TASK-055: First device feedback on the redesign
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, each item awaiting their confirmation
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
The redesign (TASK-050 to TASK-054) was installed on the human product owner's Galaxy Z Fold7
|
||||
(SM-F966N) on 2026-09-28. This task is what they asked for after using it.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
1. Repository list: the repository's name gets a line to itself. With the owner on the same
|
||||
line, a long organization name (`RobotAutomationDevelopment-Dept`) used the line up and the
|
||||
name was cut to `...`.
|
||||
2. The large title is removed from the 저장소, 작업 and 알림 tabs; the selected tab already says
|
||||
where you are. Home keeps "팀 활동", which says something the tab label does not.
|
||||
3. A "today" count on Home opens what it counted. "오늘 Push" used to open the repository list,
|
||||
where repositories pushed five days ago contradicted the word "today". It now narrows the
|
||||
timeline to today's pushes, and the timeline has an "오늘만" filter of its own.
|
||||
4. The outline is a tree of classes with their methods. Fields are left out -- there were too
|
||||
many, and they buried the methods -- and a class or namespace folds.
|
||||
5. Home can show only starred repositories ("즐겨찾기"), and remembers which source was chosen.
|
||||
6. Settings has a theme choice: follow the system, light, or dark.
|
||||
- Excluded: anything not raised by the product owner.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/home/`, `feature/repositories/RepositoryListScreen.kt`,
|
||||
`feature/work/`, `feature/notifications/NotificationsScreen.kt`, `feature/codebrowser/`,
|
||||
`feature/settings/`, `core/settings/AppSettingsStore.kt`, `ui/theme/`, `ui/components/`, `MainActivity.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/data/activity/`, `data/repositories/GiteaRepositoryBrowserApi.kt`,
|
||||
`domain/activity/`, `domain/model/RepositoryModels.kt`
|
||||
- `app/src/main/res/`, `app/src/test/`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A repository's name is never shortened to make room for its owner.
|
||||
- [x] The 저장소, 작업 and 알림 tabs have no title block; Home's is unchanged.
|
||||
- [x] Tapping "오늘 Push" or "새 이슈" shows only today's activity of that kind; "열린 PR" opens the Work tab on pull requests.
|
||||
- [x] The outline lists namespaces, types and methods, never fields; a folded class hides its methods and shows how many it has.
|
||||
- [x] The favorites view reads each starred repository's own feed and merges them, newest first.
|
||||
- [x] One starred repository failing to load leaves the others shown; failing to list the starred repositories is an error.
|
||||
- [x] The favorites view says how many starred repositories it covers.
|
||||
- [x] The chosen Home source is restored on the next launch.
|
||||
- [x] The theme setting overrides the system; code colors, search highlights, the logo and the system bar icons all follow it.
|
||||
- [x] Build, unit tests and lint pass.
|
||||
- [ ] Each item confirmed by the product owner on the device.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Read-only. The favorites view adds `/repos/{owner}/{repo}/activities/feeds`, within the scopes the
|
||||
app already requests. The stored settings are a theme name and a feed choice; neither is sensitive.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
Recorded on 2026-09-28 in the worktree `awesome-pascal-3f2ada`:
|
||||
|
||||
- The required checks pass. Unit tests: 420, 0 failures (409 before this task). Lint: 0 errors, 57 warnings.
|
||||
- New tests: outline tree and folding (`SymbolOutlineTest`), today-only filtering, favorites merging,
|
||||
tolerance of one failing repository, the remembered source (`HomeViewModelTest`).
|
||||
- Device: installed over wireless `adb` on the SM-F966N. The app starts with no crash in the log.
|
||||
Before this task's changes, two screens were captured from the device and looked at: the
|
||||
repository list (which is where item 1 was seen) and the Home timeline, which showed real
|
||||
activity parsed correctly -- commit messages, branch names, commit ids, "외 9개 커밋", a branch
|
||||
deletion. That settles TASK-051's open question about the feed's `content` payloads for pushes.
|
||||
- Not done: looking at this task's own changes on the device. Remote captures were stopped at the
|
||||
product owner's direction while they were using the phone.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The favorites view reads at most 20 starred repositories, the most recently updated first, and
|
||||
one page (20 activities) of each. It has no "load more".
|
||||
- Its open pull-request count is the sum of the counts Gitea reports per repository; it is blank
|
||||
if Gitea reports none.
|
||||
- The outline starts fully unfolded.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
Loading…
x
Reference in New Issue
Block a user