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.Manifest
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -22,6 +26,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -196,7 +201,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
else -> true
|
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(
|
AppShell(
|
||||||
selectedTab = currentTab,
|
selectedTab = currentTab,
|
||||||
showNavigation = showNavigation,
|
showNavigation = showNavigation,
|
||||||
@ -261,7 +283,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
when (destination) {
|
when (destination) {
|
||||||
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
|
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
|
||||||
is HomeDestination.Work -> {
|
is HomeDestination.Work -> {
|
||||||
workViewModel.open(destination.scope)
|
workViewModel.open(destination.scope, destination.type)
|
||||||
selectTab(AppTab.WORK)
|
selectTab(AppTab.WORK)
|
||||||
}
|
}
|
||||||
is HomeDestination.Branch -> push(
|
is HomeDestination.Branch -> push(
|
||||||
@ -277,8 +299,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectOrganization = homeViewModel::selectOrganization,
|
onSelectSource = homeViewModel::selectSource,
|
||||||
onSelectFilter = homeViewModel::selectFilter,
|
onSelectFilter = homeViewModel::selectFilter,
|
||||||
|
onSetTodayOnly = homeViewModel::setTodayOnly,
|
||||||
|
onShowToday = homeViewModel::showToday,
|
||||||
onLoadMore = homeViewModel::loadMore,
|
onLoadMore = homeViewModel::loadMore,
|
||||||
onRefresh = homeViewModel::refresh,
|
onRefresh = homeViewModel::refresh,
|
||||||
onViewSettings = { push(Route.Settings) },
|
onViewSettings = { push(Route.Settings) },
|
||||||
@ -310,6 +334,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
instanceHost = instanceHost,
|
instanceHost = instanceHost,
|
||||||
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
||||||
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
||||||
|
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||||
onLogout = ::signOut,
|
onLogout = ::signOut,
|
||||||
onBack = { pop() },
|
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
|
* before this settings screen existed -- this toggle only adds an explicit way to turn *off* what
|
||||||
* was already always on, not a new default.
|
* was already always on, not a new default.
|
||||||
*/
|
*/
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
interface AppSettingsStore {
|
interface AppSettingsStore {
|
||||||
fun isReactiveAuthRetryEnabled(): Boolean
|
fun isReactiveAuthRetryEnabled(): Boolean
|
||||||
fun setReactiveAuthRetryEnabled(enabled: Boolean)
|
fun setReactiveAuthRetryEnabled(enabled: Boolean)
|
||||||
@ -26,6 +28,15 @@ interface AppSettingsStore {
|
|||||||
fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP
|
fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP
|
||||||
fun setCodeFontSizeSp(size: Int) = Unit
|
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 {
|
companion object {
|
||||||
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
||||||
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
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()
|
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 {
|
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_WORD_WRAP = "code_word_wrap_enabled"
|
||||||
const val KEY_CODE_FONT_SIZE = "code_font_size_sp"
|
const val KEY_CODE_FONT_SIZE = "code_font_size_sp"
|
||||||
const val PREFERENCES_NAME = "ubgitea_app_settings"
|
const val PREFERENCES_NAME = "ubgitea_app_settings"
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import java.time.LocalDate
|
|||||||
import okhttp3.HttpUrl
|
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
|
* 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
|
* 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)
|
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> =
|
private suspend fun listFeed(url: HttpUrl.Builder, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem> =
|
||||||
with(support) {
|
with(support) {
|
||||||
validatePage(page, pageSize)
|
validatePage(page, pageSize)
|
||||||
|
|||||||
@ -289,6 +289,7 @@ class GiteaRepositoryBrowserApi(
|
|||||||
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
|
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
|
||||||
language = dto.language?.takeIf(String::isNotBlank),
|
language = dto.language?.takeIf(String::isNotBlank),
|
||||||
starsCount = dto.starsCount ?: 0,
|
starsCount = dto.starsCount ?: 0,
|
||||||
|
openPullRequestCount = dto.openPullRequestCount?.takeIf { it >= 0 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,6 +410,7 @@ class GiteaRepositoryBrowserApi(
|
|||||||
@SerializedName("updated_at") val updatedAt: String?,
|
@SerializedName("updated_at") val updatedAt: String?,
|
||||||
val language: String?,
|
val language: String?,
|
||||||
@SerializedName("stars_count") val starsCount: Int?,
|
@SerializedName("stars_count") val starsCount: Int?,
|
||||||
|
@SerializedName("open_pr_counter") val openPullRequestCount: Int?,
|
||||||
)
|
)
|
||||||
private data class BranchDto(
|
private data class BranchDto(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
|
|||||||
@ -59,4 +59,6 @@ interface ActivityRepository {
|
|||||||
suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
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 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.
|
// unchanged.
|
||||||
val language: String? = null,
|
val language: String? = null,
|
||||||
val starsCount: Int = 0,
|
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(
|
data class BranchSummary(
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||||
import androidx.compose.foundation.gestures.scrollable
|
import androidx.compose.foundation.gestures.scrollable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.highlightLines
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||||
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -217,7 +217,7 @@ private class SearchHitColors(val other: Color, val current: Color, val onCurren
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchHitColors(): SearchHitColors {
|
private fun searchHitColors(): SearchHitColors {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = UBGiteaTheme.isDark
|
||||||
return remember(dark) {
|
return remember(dark) {
|
||||||
if (dark) {
|
if (dark) {
|
||||||
SearchHitColors(Color(0xFF5A4713), Color(0xFFFFB84D), Color(0xFF1A1200))
|
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.enclosingSymbols
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.findMatches
|
import com.ubisam.ubgitea.feature.codebrowser.content.findMatches
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.findSymbolNamed
|
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.firstMatchFrom
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber
|
import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
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))
|
if (matches.isEmpty()) SearchHighlight.None else SearchHighlight(matches.groupBy { it.line }, matches.getOrNull(currentMatch))
|
||||||
}
|
}
|
||||||
var outlineOpen by remember(state.path) { mutableStateOf(false) }
|
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 focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
|
||||||
var sideBySide by rememberSaveable { mutableStateOf(true) }
|
var sideBySide by rememberSaveable { mutableStateOf(true) }
|
||||||
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
||||||
@ -275,6 +283,8 @@ fun CodeViewerScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
document = document,
|
document = document,
|
||||||
currentLine = firstVisibleLine,
|
currentLine = firstVisibleLine,
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapsed = toggleCollapsed,
|
||||||
onOpenFile = { onOpenSibling(it, null) },
|
onOpenFile = { onOpenSibling(it, null) },
|
||||||
onOpenEntry = onOpenEntry,
|
onOpenEntry = onOpenEntry,
|
||||||
onSymbolSelected = { goToLine(it.line) },
|
onSymbolSelected = { goToLine(it.line) },
|
||||||
@ -313,6 +323,8 @@ fun CodeViewerScreen(
|
|||||||
OutlineSheet(
|
OutlineSheet(
|
||||||
document = document,
|
document = document,
|
||||||
currentLine = firstVisibleLine,
|
currentLine = firstVisibleLine,
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapsed = toggleCollapsed,
|
||||||
onSymbolSelected = {
|
onSymbolSelected = {
|
||||||
outlineOpen = false
|
outlineOpen = false
|
||||||
goToLine(it.line)
|
goToLine(it.line)
|
||||||
@ -799,12 +811,15 @@ private fun SidePane(
|
|||||||
state: CodeBrowserUiState.File,
|
state: CodeBrowserUiState.File,
|
||||||
document: CodeDocument,
|
document: CodeDocument,
|
||||||
currentLine: Int,
|
currentLine: Int,
|
||||||
|
collapsed: Set<Int>,
|
||||||
|
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||||
onOpenFile: (RepositoryPath) -> Unit,
|
onOpenFile: (RepositoryPath) -> Unit,
|
||||||
onOpenEntry: (TreeEntry) -> Unit,
|
onOpenEntry: (TreeEntry) -> Unit,
|
||||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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)) {
|
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
if (state.siblingEntries.isNotEmpty()) {
|
if (state.siblingEntries.isNotEmpty()) {
|
||||||
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
|
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) }
|
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)) }
|
item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) }
|
||||||
items(document.symbols, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
items(tree, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||||
SymbolRow(symbol, isCurrent = symbol == current, compact = true) { onSymbolSelected(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
|
@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 colors = rememberCodeSyntaxColors()
|
||||||
val (letter, color, kind) = when (symbol.kind) {
|
val (letter, color, kind) = when (symbol.kind) {
|
||||||
SymbolKind.NAMESPACE -> Triple("N", MaterialTheme.colorScheme.onSurfaceVariant, R.string.codeviewer_symbol_namespace)
|
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)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.semantics { selected = isCurrent }
|
.semantics { selected = isCurrent }
|
||||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
.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,
|
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(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
contentColor = color,
|
contentColor = color,
|
||||||
@ -902,12 +952,20 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
|
|||||||
symbol.label,
|
symbol.label,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = if (compact) 13.sp else 14.sp,
|
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,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
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(
|
Text(
|
||||||
stringResource(R.string.codeviewer_outline_line, symbol.line + 1),
|
stringResource(R.string.codeviewer_outline_line, symbol.line + 1),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@ -923,13 +981,16 @@ private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean,
|
|||||||
private fun OutlineSheet(
|
private fun OutlineSheet(
|
||||||
document: CodeDocument,
|
document: CodeDocument,
|
||||||
currentLine: Int,
|
currentLine: Int,
|
||||||
|
collapsed: Set<Int>,
|
||||||
|
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||||
onLineEntered: (Int) -> Unit,
|
onLineEntered: (Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var lineInput by remember { mutableStateOf("") }
|
var lineInput by remember { mutableStateOf("") }
|
||||||
var outOfRange by remember { mutableStateOf(false) }
|
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() {
|
fun submit() {
|
||||||
val line = parseLineNumber(lineInput, document.lines.size)
|
val line = parseLineNumber(lineInput, document.lines.size)
|
||||||
if (line == null) outOfRange = true else onLineEntered(line)
|
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)
|
Text(stringResource(R.string.codeviewer_outline_go), fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (document.symbols.isEmpty()) {
|
if (tree.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.codeviewer_outline_empty),
|
stringResource(R.string.codeviewer_outline_empty),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@ -985,8 +1046,15 @@ private fun OutlineSheet(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
items(document.symbols, key = { "${it.line}:${it.name}" }) { symbol ->
|
items(tree, key = { "${it.line}:${it.name}" }) { symbol ->
|
||||||
SymbolRow(symbol, isCurrent = symbol == current, compact = false) { onSymbolSelected(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. */
|
/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
|
||||||
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
|
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
|
||||||
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth)
|
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
|
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
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.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
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
|
* 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.
|
// fall below 4.5:1 on white at code size.
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
|
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = UBGiteaTheme.isDark
|
||||||
val text = MaterialTheme.colorScheme.onSurface
|
val text = MaterialTheme.colorScheme.onSurface
|
||||||
return remember(dark, text) {
|
return remember(dark, text) {
|
||||||
if (dark) {
|
if (dark) {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -34,8 +35,10 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -57,6 +60,7 @@ import com.ubisam.ubgitea.domain.activity.ActivityItem
|
|||||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
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.ActivityTone
|
||||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||||
@ -71,11 +75,12 @@ import com.ubisam.ubgitea.ui.components.UBIcon
|
|||||||
import com.ubisam.ubgitea.ui.formatHeadlineDate
|
import com.ubisam.ubgitea.ui.formatHeadlineDate
|
||||||
import com.ubisam.ubgitea.ui.formatRelative
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
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. */
|
/** Where a tap on the home screen leads. The screen names the place; the activity navigates. */
|
||||||
sealed interface HomeDestination {
|
sealed interface HomeDestination {
|
||||||
data object Repositories : 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 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
|
data class WorkItem(val owner: String, val name: String, val number: Long, val isPullRequest: Boolean) : HomeDestination
|
||||||
}
|
}
|
||||||
@ -87,8 +92,10 @@ fun HomeScreen(
|
|||||||
instanceHost: String,
|
instanceHost: String,
|
||||||
isWide: Boolean,
|
isWide: Boolean,
|
||||||
onNavigate: (HomeDestination) -> Unit,
|
onNavigate: (HomeDestination) -> Unit,
|
||||||
onSelectOrganization: (String?) -> Unit,
|
onSelectSource: (HomeSource) -> Unit,
|
||||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||||
|
onSetTodayOnly: (Boolean) -> Unit,
|
||||||
|
onShowToday: (ActivityCategory) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onViewSettings: () -> Unit,
|
onViewSettings: () -> Unit,
|
||||||
@ -110,7 +117,9 @@ fun HomeScreen(
|
|||||||
onAction = if (expired) onAuthenticationExpired else onRefresh,
|
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,
|
state: HomeUiState.Content,
|
||||||
isWide: Boolean,
|
isWide: Boolean,
|
||||||
onNavigate: (HomeDestination) -> Unit,
|
onNavigate: (HomeDestination) -> Unit,
|
||||||
onSelectOrganization: (String?) -> Unit,
|
onSelectSource: (HomeSource) -> Unit,
|
||||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||||
|
onSetTodayOnly: (Boolean) -> Unit,
|
||||||
|
onShowToday: (ActivityCategory) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onRefresh: () -> 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()) {
|
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||||
// so the whole picture is on screen at once.
|
// so the whole picture is on screen at once.
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
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)) {
|
LazyColumn(
|
||||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
Modifier.weight(0.54f).fillMaxHeight(),
|
||||||
|
state = timelineState,
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
overview(state, vertical = false, onNavigate, onSelectOrganization)
|
overview(state, vertical = false, onNavigate, onSelectSource, showToday)
|
||||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -151,7 +180,8 @@ private fun LazyListScope.overview(
|
|||||||
state: HomeUiState.Content,
|
state: HomeUiState.Content,
|
||||||
vertical: Boolean,
|
vertical: Boolean,
|
||||||
onNavigate: (HomeDestination) -> Unit,
|
onNavigate: (HomeDestination) -> Unit,
|
||||||
onSelectOrganization: (String?) -> Unit,
|
onSelectSource: (HomeSource) -> Unit,
|
||||||
|
onShowToday: (ActivityCategory) -> Unit,
|
||||||
) {
|
) {
|
||||||
item(key = "headline") {
|
item(key = "headline") {
|
||||||
Surface(
|
Surface(
|
||||||
@ -164,10 +194,9 @@ private fun LazyListScope.overview(
|
|||||||
stringResource(R.string.home_title),
|
stringResource(R.string.home_title),
|
||||||
subtitle = stringResource(R.string.home_subtitle, formatHeadlineDate(state.today)),
|
subtitle = stringResource(R.string.home_subtitle, formatHeadlineDate(state.today)),
|
||||||
)
|
)
|
||||||
if (state.organizations.isNotEmpty()) {
|
SourceChips(state.source, state.organizations, onSelectSource)
|
||||||
OrganizationChips(state.organization, state.organizations, onSelectOrganization)
|
if (state.source == HomeSource.Favorites) FavoritesNote(state)
|
||||||
}
|
SummaryTiles(state.summary, onNavigate, onShowToday)
|
||||||
SummaryTiles(state.summary, onNavigate)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,15 +238,22 @@ private fun LazyListScope.timeline(
|
|||||||
state: HomeUiState.Content,
|
state: HomeUiState.Content,
|
||||||
onNavigate: (HomeDestination) -> Unit,
|
onNavigate: (HomeDestination) -> Unit,
|
||||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||||
|
onSetTodayOnly: (Boolean) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
) {
|
) {
|
||||||
item(key = "timeline-title") { SectionTitle(stringResource(R.string.home_timeline), Modifier.padding(top = 8.dp)) }
|
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
|
val visible = state.visibleTimeline
|
||||||
if (visible.isEmpty()) {
|
if (visible.isEmpty()) {
|
||||||
item(key = "timeline-empty") {
|
item(key = "timeline-empty") {
|
||||||
Text(
|
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,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
|
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
|
@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)
|
val label = stringResource(R.string.home_source_label)
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
@ -273,11 +326,23 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
|
|||||||
.padding(bottom = 12.dp),
|
.padding(bottom = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.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 ->
|
organizations.forEach { organization ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected == organization.name,
|
selected == HomeSource.Organization(organization.name),
|
||||||
{ onSelect(organization.name) },
|
{ onSelect(HomeSource.Organization(organization.name)) },
|
||||||
{ Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) },
|
{ Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -285,20 +350,20 @@ private fun OrganizationChips(selected: String?, organizations: List<Organizatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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)) {
|
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
SummaryTile(
|
SummaryTile(
|
||||||
R.drawable.ic_ub_commit, R.string.home_stat_pushes, summary.todayPushes, summary.todayIsLowerBound,
|
R.drawable.ic_ub_commit, R.string.home_stat_pushes, summary.todayPushes, summary.todayIsLowerBound,
|
||||||
Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) { onNavigate(HomeDestination.Repositories) }
|
) { onShowToday(ActivityCategory.PUSH) }
|
||||||
SummaryTile(
|
SummaryTile(
|
||||||
R.drawable.ic_ub_pull_request, R.string.home_stat_open_pull_requests, summary.openPullRequests, false,
|
R.drawable.ic_ub_pull_request, R.string.home_stat_open_pull_requests, summary.openPullRequests, false,
|
||||||
Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
|
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE, WorkTypeFilter.PULL_REQUESTS)) }
|
||||||
SummaryTile(
|
SummaryTile(
|
||||||
R.drawable.ic_ub_issue, R.string.home_stat_issues, summary.todayIssuesOpened, summary.todayIsLowerBound,
|
R.drawable.ic_ub_issue, R.string.home_stat_issues, summary.todayIssuesOpened, summary.todayIsLowerBound,
|
||||||
Modifier.weight(1f),
|
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
|
private const val MIN_BAR_FRACTION = 0.12f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterChips(selected: ActivityCategory?, onSelect: (ActivityCategory?) -> Unit) {
|
private fun FilterChips(
|
||||||
|
selected: ActivityCategory?,
|
||||||
|
todayOnly: Boolean,
|
||||||
|
onSelect: (ActivityCategory?) -> Unit,
|
||||||
|
onSetTodayOnly: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 8.dp),
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(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 == 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(selected == ActivityCategory.PUSH, { onSelect(ActivityCategory.PUSH) }, { Text(stringResource(R.string.home_filter_push)) })
|
||||||
FilterChip(
|
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.ActivityItem
|
||||||
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
||||||
import com.ubisam.ubgitea.domain.model.Page
|
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.OrganizationSummary
|
||||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
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.WorkScope
|
||||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||||
@ -34,6 +36,7 @@ import java.util.Collections
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -42,6 +45,37 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
enum class HomeError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, SERVER }
|
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
|
* 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
|
* 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(
|
class Content(
|
||||||
val accountLogin: String,
|
val accountLogin: String,
|
||||||
/** The organization whose feed is shown; null for the signed-in account's own feed. */
|
val source: HomeSource,
|
||||||
val organization: String?,
|
|
||||||
organizations: List<OrganizationSummary>,
|
organizations: List<OrganizationSummary>,
|
||||||
val filter: ActivityCategory?,
|
val filter: ActivityCategory?,
|
||||||
timeline: List<ActivityItem>,
|
timeline: List<ActivityItem>,
|
||||||
@ -78,12 +111,20 @@ sealed interface HomeUiState {
|
|||||||
activeRepositories: List<ActiveRepository>,
|
activeRepositories: List<ActiveRepository>,
|
||||||
val summary: HomeSummary,
|
val summary: HomeSummary,
|
||||||
val today: LocalDate,
|
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 {
|
) : HomeUiState {
|
||||||
val organizations: List<OrganizationSummary> = Collections.unmodifiableList(organizations.toList())
|
val organizations: List<OrganizationSummary> = Collections.unmodifiableList(organizations.toList())
|
||||||
val timeline: List<ActivityItem> = Collections.unmodifiableList(timeline.toList())
|
val timeline: List<ActivityItem> = Collections.unmodifiableList(timeline.toList())
|
||||||
val activeRepositories: List<ActiveRepository> = Collections.unmodifiableList(activeRepositories.toList())
|
val activeRepositories: List<ActiveRepository> = Collections.unmodifiableList(activeRepositories.toList())
|
||||||
val visibleTimeline: List<ActivityItem> =
|
val visibleTimeline: List<ActivityItem> = this.timeline.filter { item ->
|
||||||
if (filter == null) this.timeline else this.timeline.filter { it.kind.category == filter }
|
(filter == null || item.kind.category == filter) &&
|
||||||
|
(!todayOnly || Instant.ofEpochMilli(item.createdAtEpochMillis).atZone(zone).toLocalDate() == today)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun copy(
|
internal fun copy(
|
||||||
filter: ActivityCategory? = this.filter,
|
filter: ActivityCategory? = this.filter,
|
||||||
@ -93,13 +134,14 @@ sealed interface HomeUiState {
|
|||||||
loadMoreFailed: Boolean = this.loadMoreFailed,
|
loadMoreFailed: Boolean = this.loadMoreFailed,
|
||||||
activeRepositories: List<ActiveRepository> = this.activeRepositories,
|
activeRepositories: List<ActiveRepository> = this.activeRepositories,
|
||||||
summary: HomeSummary = this.summary,
|
summary: HomeSummary = this.summary,
|
||||||
|
todayOnly: Boolean = this.todayOnly,
|
||||||
) = Content(
|
) = Content(
|
||||||
accountLogin, organization, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
|
accountLogin, source, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
|
||||||
activeRepositories, summary, today,
|
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(
|
class HomeViewModel(
|
||||||
@ -108,6 +150,9 @@ class HomeViewModel(
|
|||||||
private val activity: ActivityRepository,
|
private val activity: ActivityRepository,
|
||||||
private val workSearch: WorkSearchRepository,
|
private val workSearch: WorkSearchRepository,
|
||||||
private val organizations: OrganizationsRepository,
|
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 now: () -> Long = System::currentTimeMillis,
|
||||||
private val zone: () -> ZoneId = ZoneId::systemDefault,
|
private val zone: () -> ZoneId = ZoneId::systemDefault,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@ -122,14 +167,15 @@ class HomeViewModel(
|
|||||||
/** Loads the feed unless it is already on screen; switching back to the tab must not refetch. */
|
/** Loads the feed unless it is already on screen; switching back to the tab must not refetch. */
|
||||||
fun open() {
|
fun open() {
|
||||||
if (mutableState.value is HomeUiState.Content || mutableState.value is HomeUiState.Loading) return
|
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?) {
|
fun selectSource(source: HomeSource) {
|
||||||
if (organization == currentOrganization() && mutableState.value is HomeUiState.Content) return
|
if (source == currentSource() && mutableState.value is HomeUiState.Content) return
|
||||||
load(organization)
|
saveSource(source.encode())
|
||||||
|
load(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectFilter(filter: ActivityCategory?) {
|
fun selectFilter(filter: ActivityCategory?) {
|
||||||
@ -137,6 +183,17 @@ class HomeViewModel(
|
|||||||
mutableState.value = content.copy(filter = filter)
|
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() {
|
fun loadMore() {
|
||||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||||
val page = content.nextPage ?: return
|
val page = content.nextPage ?: return
|
||||||
@ -144,7 +201,7 @@ class HomeViewModel(
|
|||||||
mutableState.value = content.copy(isLoadingMore = true, loadMoreFailed = false)
|
mutableState.value = content.copy(isLoadingMore = true, loadMoreFailed = false)
|
||||||
moreJob = viewModelScope.launch {
|
moreJob = viewModelScope.launch {
|
||||||
try {
|
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 ->
|
update { current ->
|
||||||
val merged = (current.timeline + result.items).distinctBy(ActivityItem::id)
|
val merged = (current.timeline + result.items).distinctBy(ActivityItem::id)
|
||||||
current.copy(
|
current.copy(
|
||||||
@ -168,7 +225,7 @@ class HomeViewModel(
|
|||||||
mutableState.value = HomeUiState.Idle
|
mutableState.value = HomeUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load(organization: String?) {
|
private fun load(source: HomeSource) {
|
||||||
cancelAll()
|
cancelAll()
|
||||||
mutableState.value = HomeUiState.Loading
|
mutableState.value = HomeUiState.Loading
|
||||||
operation = viewModelScope.launch {
|
operation = viewModelScope.launch {
|
||||||
@ -176,14 +233,18 @@ class HomeViewModel(
|
|||||||
val login = accountLogin ?: authGateway.getAuthenticatedUser(tokenProvider.accessToken()).login
|
val login = accountLogin ?: authGateway.getAuthenticatedUser(tokenProvider.accessToken()).login
|
||||||
.also { accountLogin = it }
|
.also { accountLogin = it }
|
||||||
val today = Instant.ofEpochMilli(now()).atZone(zone()).toLocalDate()
|
val today = Instant.ofEpochMilli(now()).atZone(zone()).toLocalDate()
|
||||||
|
if (source == HomeSource.Favorites) {
|
||||||
|
loadFavorites(login, today)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val (first, organizationList) = coroutineScope {
|
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() }
|
val orgs = async { loadOrganizations() }
|
||||||
feed.await() to orgs.await()
|
feed.await() to orgs.await()
|
||||||
}
|
}
|
||||||
val content = HomeUiState.Content(
|
mutableState.value = HomeUiState.Content(
|
||||||
accountLogin = login,
|
accountLogin = login,
|
||||||
organization = organization,
|
source = source,
|
||||||
organizations = organizationList,
|
organizations = organizationList,
|
||||||
filter = null,
|
filter = null,
|
||||||
timeline = first.items.distinctBy(ActivityItem::id),
|
timeline = first.items.distinctBy(ActivityItem::id),
|
||||||
@ -193,39 +254,73 @@ class HomeViewModel(
|
|||||||
activeRepositories = activeRepositoriesOf(first.items, today, zone()),
|
activeRepositories = activeRepositoriesOf(first.items, today, zone()),
|
||||||
summary = HomeSummary(),
|
summary = HomeSummary(),
|
||||||
today = today,
|
today = today,
|
||||||
|
zone = zone(),
|
||||||
)
|
)
|
||||||
mutableState.value = content
|
loadSummary(login, source, today)
|
||||||
loadSummary(login, organization, today)
|
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
if (failure is CancellationException) throw failure
|
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
|
// 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.
|
// 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 {
|
summaryJob = viewModelScope.launch {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val todays = async { tolerated { loadToday(login, organization, today) } }
|
val todays = async { tolerated { loadToday(login, source, today) } }
|
||||||
val openPullRequests = async {
|
val openPullRequests = async {
|
||||||
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = organization)) }
|
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = owner)) }
|
||||||
}
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val reviewRequested = async { tolerated { workSearch.count(reviewRequestedQuery(owner)) } }
|
||||||
|
val assigned = async { tolerated { workSearch.count(assignedQuery(owner)) } }
|
||||||
val todayResult = todays.await()
|
val todayResult = todays.await()
|
||||||
val summary = HomeSummary(
|
val summary = HomeSummary(
|
||||||
todayPushes = todayResult?.counts?.pushes,
|
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 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>()
|
val items = mutableListOf<ActivityItem>()
|
||||||
var page: Int? = 1
|
var page: Int? = 1
|
||||||
var fetched = 0
|
var fetched = 0
|
||||||
while (page != null && fetched < TODAY_MAX_PAGES) {
|
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
|
items += result.items
|
||||||
page = result.nextPage
|
page = result.nextPage
|
||||||
fetched++
|
fetched++
|
||||||
@ -256,11 +370,10 @@ class HomeViewModel(
|
|||||||
return Today(todayCountsOf(items, today, zone()), isLowerBound = page != null)
|
return Today(todayCountsOf(items, today, zone()), isLowerBound = page != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun feedPage(login: String, organization: String?, date: LocalDate?, page: Int): Page<ActivityItem> =
|
private suspend fun feedPage(login: String, source: HomeSource, date: LocalDate?, page: Int): Page<ActivityItem> =
|
||||||
if (organization == null) {
|
when (source) {
|
||||||
activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
|
is HomeSource.Organization -> activity.listOrganizationFeed(source.name, date, page, FEED_PAGE_SIZE)
|
||||||
} else {
|
HomeSource.Mine, HomeSource.Favorites -> activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
|
||||||
activity.listOrganizationFeed(organization, date, page, FEED_PAGE_SIZE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The organization filter is a convenience. Failing to list organizations hides the filter;
|
// The organization filter is a convenience. Failing to list organizations hides the filter;
|
||||||
@ -284,10 +397,10 @@ class HomeViewModel(
|
|||||||
mutableState.value = transform(current)
|
mutableState.value = transform(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun currentOrganization(): String? = when (val current = mutableState.value) {
|
private fun currentSource(): HomeSource = when (val current = mutableState.value) {
|
||||||
is HomeUiState.Content -> current.organization
|
is HomeUiState.Content -> current.source
|
||||||
is HomeUiState.Error -> current.organization
|
is HomeUiState.Error -> current.source
|
||||||
else -> null
|
else -> HomeSource.decode(savedSource())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelAll() {
|
private fun cancelAll() {
|
||||||
@ -302,13 +415,18 @@ class HomeViewModel(
|
|||||||
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
|
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
|
||||||
val config = UBGiteaOAuthDefaults.config
|
val config = UBGiteaOAuthDefaults.config
|
||||||
val tokenProvider = AuthSession.tokenProvider(application)
|
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(
|
return HomeViewModel(
|
||||||
authGateway = AuthApi(config, OkHttpTransport()),
|
authGateway = AuthApi(config, OkHttpTransport()),
|
||||||
tokenProvider = tokenProvider,
|
tokenProvider = tokenProvider,
|
||||||
activity = GiteaActivityApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
activity = GiteaActivityApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||||
workSearch = GiteaWorkSearchApi(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
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -317,6 +435,12 @@ class HomeViewModel(
|
|||||||
const val FEED_PAGE_SIZE = 50
|
const val FEED_PAGE_SIZE = 50
|
||||||
const val ORGANIZATION_PAGE_SIZE = 50
|
const val ORGANIZATION_PAGE_SIZE = 50
|
||||||
const val TODAY_MAX_PAGES = 3
|
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.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.KindTile
|
import com.ubisam.ubgitea.ui.components.KindTile
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
|
||||||
import com.ubisam.ubgitea.ui.formatRelative
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
@ -74,8 +73,8 @@ fun NotificationsScreen(
|
|||||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
// No title: the selected tab already says this is 알림.
|
||||||
TabTitle(stringResource(R.string.notifications_title))
|
Column(Modifier.padding(top = 4.dp, bottom = 12.dp)) {
|
||||||
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
|
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.ubisam.ubgitea.feature.repositories
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@ -38,19 +37,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.LineBreak
|
import androidx.compose.ui.text.style.LineBreak
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
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.ScreenTopBar
|
||||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
|
||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
|
import com.ubisam.ubgitea.ui.components.UbiSamLogo
|
||||||
import com.ubisam.ubgitea.ui.formatInstant
|
import com.ubisam.ubgitea.ui.formatInstant
|
||||||
import com.ubisam.ubgitea.ui.formatRelative
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
@ -119,7 +114,6 @@ fun RepositoryListScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
TabTitle(stringResource(R.string.repositories_list_title))
|
|
||||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -202,11 +196,7 @@ private fun SignedOutState(message: String, action: String, onAction: () -> Unit
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
UbiSamLogo(Modifier.height(36.dp))
|
||||||
painter = painterResource(R.drawable.logo_ubisam),
|
|
||||||
contentDescription = stringResource(R.string.shell_logo_description),
|
|
||||||
modifier = Modifier.height(36.dp),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
// Korean breaks between any two characters by default; this keeps words whole.
|
// 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),
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
// No title: the selected tab already says this is 저장소.
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
TabTitle(stringResource(R.string.repositories_list_title))
|
|
||||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||||
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
||||||
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
|
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
|
||||||
@ -376,13 +366,20 @@ private fun RepositoryRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.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)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
repository.name,
|
||||||
withStyle(SpanStyle(color = ownerColor)) { append(repository.owner); append(" / ") }
|
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(repository.name) }
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
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.Avatar
|
||||||
import com.ubisam.ubgitea.ui.components.ListCard
|
import com.ubisam.ubgitea.ui.components.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
@ -44,6 +45,7 @@ fun SettingsScreen(
|
|||||||
instanceHost: String,
|
instanceHost: String,
|
||||||
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
|
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
|
||||||
onSetCodeFontSizeSp: (Int) -> Unit,
|
onSetCodeFontSizeSp: (Int) -> Unit,
|
||||||
|
onSetThemeMode: (ThemeMode) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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))
|
SectionTitle(stringResource(R.string.settings_section_code_view), Modifier.padding(top = 8.dp))
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
SwitchRow(
|
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
|
@Composable
|
||||||
private fun SwitchRow(
|
private fun SwitchRow(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -14,6 +15,7 @@ data class SettingsUiState(
|
|||||||
val favoritePushNotificationsEnabled: Boolean,
|
val favoritePushNotificationsEnabled: Boolean,
|
||||||
val codeWordWrapEnabled: Boolean = true,
|
val codeWordWrapEnabled: Boolean = true,
|
||||||
val codeFontSizeSp: Int = AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP,
|
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(),
|
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
|
||||||
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
|
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
|
||||||
codeFontSizeSp = store.codeFontSizeSp(),
|
codeFontSizeSp = store.codeFontSizeSp(),
|
||||||
|
themeMode = store.themeMode(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
||||||
@ -42,6 +45,11 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
|||||||
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
|
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) {
|
fun setCodeWordWrapEnabled(enabled: Boolean) {
|
||||||
store.setCodeWordWrapEnabled(enabled)
|
store.setCodeWordWrapEnabled(enabled)
|
||||||
mutableState.value = mutableState.value.copy(codeWordWrapEnabled = 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.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.KindTile
|
import com.ubisam.ubgitea.ui.components.KindTile
|
||||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
|
||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
import com.ubisam.ubgitea.ui.formatRelative
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
|
|
||||||
@ -74,14 +73,17 @@ fun WorkScreen(
|
|||||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
// No title: the selected tab already says this is 작업.
|
||||||
TabTitle(
|
Column(Modifier.padding(top = 4.dp, bottom = 12.dp)) {
|
||||||
stringResource(R.string.work_title),
|
|
||||||
subtitle = (state as? WorkUiState.Content)?.totalCount
|
|
||||||
?.let { stringResource(R.string.work_total, it) }
|
|
||||||
?: stringResource(R.string.work_subtitle),
|
|
||||||
)
|
|
||||||
QueryFilters(state.query, onSelectScope, onSelectState, onSelectType)
|
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) {
|
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. */
|
/** 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)
|
fun refresh() = load(mutableState.value.query, page = 1)
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.ubisam.ubgitea.ui.components
|
|||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
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. */
|
/** 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)
|
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. */
|
/** Initials on the brand blue. The text color is fixed dark: white on this blue is below 4.5:1. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 36.dp, contentDescription: String? = null) {
|
fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 36.dp, contentDescription: String? = null) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -24,7 +23,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@ -50,11 +48,7 @@ fun BrandTopBar(
|
|||||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Image(
|
UbiSamLogo(Modifier.height(20.dp))
|
||||||
painter = painterResource(R.drawable.logo_ubisam),
|
|
||||||
contentDescription = stringResource(R.string.shell_logo_description),
|
|
||||||
modifier = Modifier.height(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
actions()
|
actions()
|
||||||
ProfileMenu(accountName, onViewSettings, onLogout)
|
ProfileMenu(accountName, onViewSettings, onLogout)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
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
|
// 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.
|
// 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,
|
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
|
@Composable
|
||||||
fun UBGiteaTheme(
|
fun UBGiteaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
|
CompositionLocalProvider(
|
||||||
|
LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors,
|
||||||
|
LocalIsDarkTheme provides darkTheme,
|
||||||
|
) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||||
content = content,
|
content = content,
|
||||||
@ -96,4 +107,7 @@ fun UBGiteaTheme(
|
|||||||
object UBGiteaTheme {
|
object UBGiteaTheme {
|
||||||
val colors: UBGiteaExtendedColors
|
val colors: UBGiteaExtendedColors
|
||||||
@Composable @ReadOnlyComposable get() = LocalUBGiteaColors.current
|
@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_hint">줄 번호</string>
|
||||||
<string name="codeviewer_outline_go">이동</string>
|
<string name="codeviewer_outline_go">이동</string>
|
||||||
<string name="codeviewer_outline_empty">이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다.</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">줄 %1$d</string>
|
||||||
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
|
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
|
||||||
<string name="codeviewer_symbol_namespace">네임스페이스</string>
|
<string name="codeviewer_symbol_namespace">네임스페이스</string>
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
<string name="home_subtitle">%1$s · 오늘 기준</string>
|
<string name="home_subtitle">%1$s · 오늘 기준</string>
|
||||||
<string name="home_loading">팀 활동을 불러오는 중입니다.</string>
|
<string name="home_loading">팀 활동을 불러오는 중입니다.</string>
|
||||||
<string name="home_source_mine">내 피드</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_source_label">활동 범위</string>
|
||||||
<string name="home_stat_pushes">오늘 Push</string>
|
<string name="home_stat_pushes">오늘 Push</string>
|
||||||
<string name="home_stat_open_pull_requests">열린 PR</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_active_repository_footer">%1$s · %2$s</string>
|
||||||
<string name="home_activity_bars_description">최근 7일 활동 %1$d건</string>
|
<string name="home_activity_bars_description">최근 7일 활동 %1$d건</string>
|
||||||
<string name="home_timeline">타임라인</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_all">전체</string>
|
||||||
<string name="home_filter_push">Push</string>
|
<string name="home_filter_push">Push</string>
|
||||||
<string name="home_filter_pull_request">PR</string>
|
<string name="home_filter_pull_request">PR</string>
|
||||||
@ -57,8 +63,6 @@
|
|||||||
<string name="home_permission_denied">활동을 볼 권한이 없습니다.</string>
|
<string name="home_permission_denied">활동을 볼 권한이 없습니다.</string>
|
||||||
<string name="home_server_error">Gitea 서버에서 오류가 발생했습니다.</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_loading">작업을 불러오는 중입니다.</string>
|
||||||
<string name="work_scope_everyone">팀 전체</string>
|
<string name="work_scope_everyone">팀 전체</string>
|
||||||
<string name="work_scope_review_requested">리뷰 요청</string>
|
<string name="work_scope_review_requested">리뷰 요청</string>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="repositories_notifications">알림</string>
|
<string name="repositories_notifications">알림</string>
|
||||||
<string name="notifications_title">알림</string>
|
|
||||||
<string name="notifications_filter_unread">읽지 않음</string>
|
<string name="notifications_filter_unread">읽지 않음</string>
|
||||||
<string name="notifications_filter_all">전체</string>
|
<string name="notifications_filter_all">전체</string>
|
||||||
<string name="notifications_unread_dot_description">읽지 않음</string>
|
<string name="notifications_unread_dot_description">읽지 않음</string>
|
||||||
|
|||||||
@ -41,5 +41,4 @@
|
|||||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||||
<string name="repositories_work_items">PR/이슈</string>
|
<string name="repositories_work_items">PR/이슈</string>
|
||||||
<string name="repositories_organization_all">전체</string>
|
<string name="repositories_organization_all">전체</string>
|
||||||
<string name="repositories_list_title">저장소</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -12,4 +12,10 @@
|
|||||||
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
|
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
|
||||||
<string name="settings_code_font_size_title">글자 크기</string>
|
<string name="settings_code_font_size_title">글자 크기</string>
|
||||||
<string name="settings_code_font_size_value">%1$dsp</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>
|
</resources>
|
||||||
|
|||||||
@ -238,4 +238,57 @@ class SymbolOutlineTest {
|
|||||||
assertEquals(SymbolKind.FIELD, findSymbolNamed(symbols, "Foo")?.kind)
|
assertEquals(SymbolKind.FIELD, findSymbolNamed(symbols, "Foo")?.kind)
|
||||||
assertNull(findSymbolNamed(symbols, "Bar"))
|
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.model.RepositorySummary
|
||||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
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.WorkScope
|
||||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||||
@ -51,12 +52,16 @@ class HomeViewModelTest {
|
|||||||
activity: ActivityRepository,
|
activity: ActivityRepository,
|
||||||
workSearch: WorkSearchRepository = FakeWorkSearchRepository { 0 },
|
workSearch: WorkSearchRepository = FakeWorkSearchRepository { 0 },
|
||||||
organizations: OrganizationsRepository = FakeOrganizationsRepository(),
|
organizations: OrganizationsRepository = FakeOrganizationsRepository(),
|
||||||
|
favorites: RepositoryFavorites = FakeFavorites(),
|
||||||
|
savedSource: String? = null,
|
||||||
) = HomeViewModel(
|
) = HomeViewModel(
|
||||||
authGateway = FakeAuthGateway(),
|
authGateway = FakeAuthGateway(),
|
||||||
tokenProvider = tokenProvider,
|
tokenProvider = tokenProvider,
|
||||||
activity = activity,
|
activity = activity,
|
||||||
workSearch = workSearch,
|
workSearch = workSearch,
|
||||||
organizations = organizations,
|
organizations = organizations,
|
||||||
|
favorites = favorites,
|
||||||
|
savedSource = { savedSource },
|
||||||
now = { 1_700_000_000_000L },
|
now = { 1_700_000_000_000L },
|
||||||
zone = { zone },
|
zone = { zone },
|
||||||
)
|
)
|
||||||
@ -176,7 +181,111 @@ class HomeViewModelTest {
|
|||||||
assertEquals(HomeUiState.Idle, vm.state.value)
|
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,
|
id = id,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
actorLogin = "octocat",
|
actorLogin = "octocat",
|
||||||
@ -187,7 +296,7 @@ class HomeViewModelTest {
|
|||||||
subjectText = null,
|
subjectText = null,
|
||||||
commits = emptyList(),
|
commits = emptyList(),
|
||||||
commitCount = 0,
|
commitCount = 0,
|
||||||
createdAtEpochMillis = 0L,
|
createdAtEpochMillis = at,
|
||||||
)
|
)
|
||||||
|
|
||||||
private class FakeAuthGateway(private val login: String = "octocat") : AuthenticationGateway {
|
private class FakeAuthGateway(private val login: String = "octocat") : AuthenticationGateway {
|
||||||
@ -203,6 +312,8 @@ class HomeViewModelTest {
|
|||||||
) : ActivityRepository {
|
) : ActivityRepository {
|
||||||
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int) =
|
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||||
feed(null, date, page)
|
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) =
|
override suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||||
feed(organization, date, page)
|
feed(organization, date, page)
|
||||||
}
|
}
|
||||||
@ -213,6 +324,28 @@ class HomeViewModelTest {
|
|||||||
override suspend fun count(query: WorkSearchQuery): Int? = counts(query)
|
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 class FakeOrganizationsRepository(
|
||||||
private val organizations: List<OrganizationSummary> = emptyList(),
|
private val organizations: List<OrganizationSummary> = emptyList(),
|
||||||
private val fail: Boolean = false,
|
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
|
- Next recommended task: install on a phone and on an unfolded Z Fold, walk every tab, and fix what
|
||||||
only a device shows.
|
only a device shows.
|
||||||
- Next owner: Claude, after the product owner's device check.
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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
|
- 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