TASK-057: side pane cards, in-place folder tree, unfolded Home headline
- The viewer's side pane is two cards: folder structure, and classes and functions. A button swaps them and the choice is kept. - Folders open in place; files inside them open from the tree. - Unfolded Home: the headline is a card and the columns are equal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fa87a0b32f
commit
5cc11d8420
@ -400,6 +400,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
||||
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
||||
onOpenSibling = codeBrowserViewModel::openSibling,
|
||||
onToggleFolder = codeBrowserViewModel::toggleFolder,
|
||||
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
||||
historyState = historyState,
|
||||
onCommitSelected = historyViewModel::openCommit,
|
||||
|
||||
@ -37,6 +37,11 @@ interface AppSettingsStore {
|
||||
fun themeMode(): ThemeMode = ThemeMode.SYSTEM
|
||||
fun setThemeMode(mode: ThemeMode) = Unit
|
||||
|
||||
// Which half of the viewer's side pane is on top (TASK-057): the folder tree by default, the
|
||||
// classes and functions when the reader has swapped them.
|
||||
fun isOutlineAboveFolders(): Boolean = false
|
||||
fun setOutlineAboveFolders(above: Boolean) = Unit
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
||||
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
||||
@ -88,7 +93,14 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
||||
preferences.edit().putString(KEY_THEME_MODE, mode.name).apply()
|
||||
}
|
||||
|
||||
override fun isOutlineAboveFolders(): Boolean = preferences.getBoolean(KEY_OUTLINE_ABOVE_FOLDERS, false)
|
||||
|
||||
override fun setOutlineAboveFolders(above: Boolean) {
|
||||
preferences.edit().putBoolean(KEY_OUTLINE_ABOVE_FOLDERS, above).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_OUTLINE_ABOVE_FOLDERS = "code_outline_above_folders"
|
||||
const val KEY_HOME_SOURCE = "home_source"
|
||||
const val KEY_THEME_MODE = "theme_mode"
|
||||
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
|
||||
|
||||
@ -55,6 +55,7 @@ fun BranchDetailScreen(
|
||||
onSetCodeWordWrap: (Boolean) -> Unit,
|
||||
onSetCodeFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onToggleFolder: (RepositoryPath) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
historyState: HistoryUiState,
|
||||
onCommitSelected: (CommitSummary) -> Unit,
|
||||
@ -86,6 +87,7 @@ fun BranchDetailScreen(
|
||||
onSetWordWrap = onSetCodeWordWrap,
|
||||
onSetFontSize = onSetCodeFontSize,
|
||||
onOpenSibling = onOpenSibling,
|
||||
onToggleFolder = onToggleFolder,
|
||||
onOpenEntry = onEntrySelected,
|
||||
onLoadCounterpart = onLoadCounterpart,
|
||||
modifier = modifier,
|
||||
|
||||
@ -159,14 +159,15 @@ fun FileViewerScreen(
|
||||
onSetWordWrap: (Boolean) -> Unit,
|
||||
onSetFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onToggleFolder: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
|
||||
CodeViewerScreen(
|
||||
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry,
|
||||
onLoadCounterpart, modifier,
|
||||
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onToggleFolder,
|
||||
onOpenEntry, onLoadCounterpart, modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@ -93,6 +93,9 @@ sealed interface CodeBrowserUiState {
|
||||
// A symbol to bring into view once the file is shown: set when the file was reached by
|
||||
// following a symbol from its counterpart.
|
||||
val focusSymbol: String? = null,
|
||||
// The folder tree beside the code. It starts as the directory the file was opened from and
|
||||
// keeps its open folders while the reader moves between files in it.
|
||||
val tree: FileTree = FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
|
||||
) : CodeBrowserUiState
|
||||
data class UnsupportedEntry(
|
||||
val target: CodeBrowserTarget,
|
||||
@ -125,6 +128,7 @@ class CodeBrowserViewModel(
|
||||
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
private var counterpartOperation: Job? = null
|
||||
private val folderOperations = mutableMapOf<RepositoryPath, Job>()
|
||||
|
||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||
|
||||
@ -149,14 +153,47 @@ class CodeBrowserViewModel(
|
||||
if (index == -1) return
|
||||
val newIndex = index + delta
|
||||
if (newIndex !in siblings.indices) return
|
||||
loadFile(current.target, siblings[newIndex], current.siblingEntries)
|
||||
loadFile(current.target, siblings[newIndex], current.siblingEntries, tree = current.tree)
|
||||
}
|
||||
|
||||
/** Opens another file of the directory the current file came from, optionally at a symbol. */
|
||||
/**
|
||||
* Opens another file of the folder tree -- of the current file's directory, or of a folder
|
||||
* opened in the tree -- optionally at a symbol.
|
||||
*/
|
||||
fun openSibling(path: RepositoryPath, focusSymbol: String? = null) {
|
||||
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
||||
if (path == current.path) return
|
||||
loadFile(current.target, path, current.siblingEntries, focusSymbol)
|
||||
val directory = current.tree.entriesOf(CodeBrowserNavigation.parent(path)) ?: current.siblingEntries
|
||||
loadFile(current.target, path, directory, focusSymbol, current.tree)
|
||||
}
|
||||
|
||||
/** Opens a folder of the tree beside the code in place, or closes it if it is open. */
|
||||
fun toggleFolder(folder: RepositoryPath) {
|
||||
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
||||
folderOperations.remove(folder)?.cancel()
|
||||
if (folder in current.tree.folders) {
|
||||
mutableState.value = current.copy(tree = current.tree.without(folder))
|
||||
return
|
||||
}
|
||||
mutableState.value = current.copy(tree = current.tree.with(folder, FolderUiState.Loading))
|
||||
folderOperations[folder] = viewModelScope.launch {
|
||||
val result = try {
|
||||
val entries = withContext(defaultDispatcher) {
|
||||
loadAllPages { page -> browser.listTree(current.target.repository, current.target.ref, folder, page, PAGE_SIZE) }
|
||||
.distinctBy(TreeEntry::path)
|
||||
.sortedWith(LISTING_ORDER)
|
||||
}
|
||||
FolderUiState.Loaded(entries)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
FolderUiState.Failed
|
||||
}
|
||||
// Only if the folder is still waiting: it may have been closed, or the file left.
|
||||
val latest = mutableState.value as? CodeBrowserUiState.File ?: return@launch
|
||||
if (latest.tree.folders[folder] == FolderUiState.Loading) {
|
||||
mutableState.value = latest.copy(tree = latest.tree.with(folder, result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the current file's counterpart so both can be shown side by side. */
|
||||
@ -207,6 +244,8 @@ class CodeBrowserViewModel(
|
||||
operation?.cancel()
|
||||
operation = null
|
||||
counterpartOperation?.cancel()
|
||||
folderOperations.values.forEach(Job::cancel)
|
||||
folderOperations.clear()
|
||||
mutableState.value = CodeBrowserUiState.Idle
|
||||
}
|
||||
|
||||
@ -234,7 +273,7 @@ class CodeBrowserViewModel(
|
||||
}
|
||||
}
|
||||
}.distinctBy(TreeEntry::path)
|
||||
.sortedWith(compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() })
|
||||
.sortedWith(LISTING_ORDER)
|
||||
}
|
||||
mutableState.value = CodeBrowserUiState.Directory(target, path, entries)
|
||||
} catch (failure: Throwable) {
|
||||
@ -247,6 +286,7 @@ class CodeBrowserViewModel(
|
||||
path: RepositoryPath,
|
||||
siblingEntries: List<TreeEntry> = emptyList(),
|
||||
focusSymbol: String? = null,
|
||||
tree: FileTree? = null,
|
||||
) = startOperation {
|
||||
counterpartOperation?.cancel()
|
||||
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
||||
@ -264,6 +304,8 @@ class CodeBrowserViewModel(
|
||||
siblingEntries = siblingEntries,
|
||||
counterpartPath = counterpartOf(path, siblingFiles),
|
||||
focusSymbol = focusSymbol,
|
||||
// A folder still being fetched would never hear back in the new state.
|
||||
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
|
||||
)
|
||||
} catch (failure: Throwable) {
|
||||
handleFailure(target, path, failure, isFile = true)
|
||||
@ -332,6 +374,7 @@ class CodeBrowserViewModel(
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 100
|
||||
const val MAX_PAGES = 100
|
||||
val LISTING_ORDER = compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
@ -70,6 +71,8 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
@ -94,6 +97,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
@ -141,7 +145,8 @@ private enum class ViewerLayout {
|
||||
}
|
||||
|
||||
private val CompactHeightBreakpoint = 480.dp
|
||||
private val SidePaneWidth = 268.dp
|
||||
private val SidePaneWidth = 288.dp
|
||||
private val TreeIndent = 14.dp
|
||||
private val NarrowSidePaneWidth = 220.dp
|
||||
private val NarrowWideBreakpoint = 700.dp
|
||||
|
||||
@ -160,11 +165,20 @@ fun CodeViewerScreen(
|
||||
onSetWordWrap: (Boolean) -> Unit,
|
||||
onSetFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onToggleFolder: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val document = rememberCodeDocument(state.path, text.renderedText)
|
||||
// Which half of the side pane is on top is the reader's choice and is kept. It is read and
|
||||
// written here rather than passed down through every screen above: nothing else uses it.
|
||||
val settings = AppSettingsSession.store(LocalContext.current.applicationContext as Application)
|
||||
var outlineFirst by remember { mutableStateOf(settings.isOutlineAboveFolders()) }
|
||||
val swapPanes = {
|
||||
outlineFirst = !outlineFirst
|
||||
settings.setOutlineAboveFolders(outlineFirst)
|
||||
}
|
||||
val listState = remember(state.path) { LazyListState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstVisibleLine by rememberFirstVisibleLine(listState)
|
||||
@ -279,7 +293,8 @@ fun CodeViewerScreen(
|
||||
drawerContent = {
|
||||
if (!sidePaneIsShown) {
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
||||
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||
drawerShape = RoundedCornerShape(topEnd = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.width(drawerWidth),
|
||||
) {
|
||||
SidePane(
|
||||
@ -293,6 +308,9 @@ fun CodeViewerScreen(
|
||||
onOpenSibling(it, null)
|
||||
},
|
||||
onOpenEntry = onOpenEntry,
|
||||
onToggleFolder = onToggleFolder,
|
||||
outlineFirst = outlineFirst,
|
||||
onSwap = swapPanes,
|
||||
onSymbolSelected = {
|
||||
scope.launch { drawerState.close() }
|
||||
goToSymbol(it)
|
||||
@ -364,6 +382,9 @@ fun CodeViewerScreen(
|
||||
onToggleCollapsed = toggleCollapsed,
|
||||
onOpenFile = { onOpenSibling(it, null) },
|
||||
onOpenEntry = onOpenEntry,
|
||||
onToggleFolder = onToggleFolder,
|
||||
outlineFirst = outlineFirst,
|
||||
onSwap = swapPanes,
|
||||
onSymbolSelected = { goToSymbol(it) },
|
||||
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
||||
)
|
||||
@ -971,31 +992,99 @@ private fun SidePane(
|
||||
collapsed: Set<Int>,
|
||||
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||
onOpenFile: (RepositoryPath) -> Unit,
|
||||
onToggleFolder: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
outlineFirst: Boolean,
|
||||
onSwap: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
||||
val current = currentIn(tree, document.symbols, currentLine)
|
||||
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
if (state.siblingEntries.isNotEmpty()) {
|
||||
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
|
||||
items(state.siblingEntries, key = { "file:" + it.path.value }) { entry ->
|
||||
val isFile = entry.type == TreeEntryType.FILE
|
||||
val isCurrent = entry.path == state.path
|
||||
SidePaneRow(
|
||||
icon = if (entry.type == TreeEntryType.DIRECTORY) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
|
||||
text = entry.name,
|
||||
isCurrent = isCurrent,
|
||||
accent = entry.type == TreeEntryType.DIRECTORY,
|
||||
onClick = { if (isFile) onOpenFile(entry.path) else onOpenEntry(entry) },
|
||||
val outline = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
||||
val rows = remember(state.tree) { state.tree.rows() }
|
||||
val folders: @Composable (Modifier) -> Unit = { FolderCard(state, rows, onOpenFile, onToggleFolder, onOpenEntry, it) }
|
||||
val symbols: @Composable (Modifier) -> Unit = {
|
||||
SymbolCard(document, outline, currentLine, collapsed, onToggleCollapsed, onSymbolSelected, it)
|
||||
}
|
||||
Column(modifier.background(MaterialTheme.colorScheme.background).padding(horizontal = 10.dp, vertical = 10.dp)) {
|
||||
val half = Modifier.weight(1f).fillMaxWidth()
|
||||
when {
|
||||
// A file that was not opened from a listing has no folder to show.
|
||||
rows.isEmpty() -> symbols(half)
|
||||
else -> {
|
||||
if (outlineFirst) symbols(half) else folders(half)
|
||||
SwapButton(onSwap)
|
||||
if (outlineFirst) folders(half) else symbols(half)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderCard(
|
||||
state: CodeBrowserUiState.File,
|
||||
rows: List<FileTreeRow>,
|
||||
onOpenFile: (RepositoryPath) -> Unit,
|
||||
onToggleFolder: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
PaneCard(
|
||||
icon = R.drawable.ic_ub_folder,
|
||||
title = stringResource(R.string.codeviewer_files_title),
|
||||
caption = state.tree.root.value.ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||
modifier = modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
// Open on the file being read, not on the top of a long folder.
|
||||
LaunchedEffect(state.path) {
|
||||
val index = rows.indexOfFirst { it is FileTreeRow.Entry && it.entry.path == state.path }
|
||||
if (index > 0) listState.scrollToItem((index - 2).coerceAtLeast(0))
|
||||
}
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(vertical = 6.dp)) {
|
||||
items(rows, key = { if (it is FileTreeRow.Entry) "e:" + it.entry.path.value else "n:" + (it as FileTreeRow.Note).folder.value }) { row ->
|
||||
when (row) {
|
||||
is FileTreeRow.Entry -> FolderTreeRow(row, isCurrent = row.entry.path == state.path) {
|
||||
when (row.entry.type) {
|
||||
TreeEntryType.DIRECTORY -> onToggleFolder(row.entry.path)
|
||||
TreeEntryType.FILE -> onOpenFile(row.entry.path)
|
||||
else -> onOpenEntry(row.entry)
|
||||
}
|
||||
}
|
||||
is FileTreeRow.Note -> FolderNoteRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SymbolCard(
|
||||
document: CodeDocument,
|
||||
outline: List<CodeSymbol>,
|
||||
currentLine: Int,
|
||||
collapsed: Set<Int>,
|
||||
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val current = currentIn(outline, document.symbols, currentLine)
|
||||
PaneCard(
|
||||
icon = R.drawable.ic_ub_list,
|
||||
title = stringResource(R.string.codeviewer_symbols_title),
|
||||
caption = document.name,
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (outline.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_symbols_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
return@PaneCard
|
||||
}
|
||||
item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) }
|
||||
}
|
||||
if (tree.isNotEmpty()) {
|
||||
item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) }
|
||||
items(tree, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||
LazyColumn(contentPadding = PaddingValues(vertical = 6.dp)) {
|
||||
items(outline, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||
SymbolRow(
|
||||
symbol = symbol,
|
||||
isCurrent = symbol == current,
|
||||
@ -1009,41 +1098,139 @@ private fun SidePane(
|
||||
}
|
||||
}
|
||||
|
||||
/** One half of the side pane: a white card on the page color, named at the top. */
|
||||
@Composable
|
||||
private fun SidePaneTitle(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp).semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean, accent: Boolean, onClick: () -> Unit) {
|
||||
private fun PaneCard(
|
||||
@DrawableRes icon: Int,
|
||||
title: String,
|
||||
caption: String,
|
||||
modifier: Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics { selected = isCurrent }
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
UBIcon(icon, null, size = 18.dp, tint = if (accent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Box(
|
||||
Modifier.size(28.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(icon, null, size = 16.dp, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Text(
|
||||
text,
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Text(
|
||||
caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwapButton(onSwap: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
onClick = onSwap,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_swap_vertical, null, size = 16.dp)
|
||||
Text(stringResource(R.string.codeviewer_pane_swap), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderTreeRow(row: FileTreeRow.Entry, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
val isFolder = row.entry.type == TreeEntryType.DIRECTORY
|
||||
val folderState = if (!isFolder) null else stringResource(if (row.isOpen) R.string.codeviewer_folder_open else R.string.codeviewer_folder_closed)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics {
|
||||
selected = isCurrent
|
||||
if (folderState != null) stateDescription = folderState
|
||||
}
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(start = 6.dp + TreeIndent * row.depth, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (isFolder) {
|
||||
UBIcon(
|
||||
if (row.isOpen) R.drawable.ic_ub_chevron_down else R.drawable.ic_ub_chevron_right,
|
||||
null,
|
||||
size = 16.dp,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.size(16.dp))
|
||||
}
|
||||
UBIcon(
|
||||
if (isFolder) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
|
||||
null,
|
||||
size = 18.dp,
|
||||
tint = if (isFolder || isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
row.entry.name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
fontWeight = if (isCurrent || isFolder) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderNoteRow(row: FileTreeRow.Note) {
|
||||
Text(
|
||||
stringResource(
|
||||
when (row.note) {
|
||||
FolderNote.LOADING -> R.string.codeviewer_folder_loading
|
||||
FolderNote.FAILED -> R.string.codeviewer_folder_failed
|
||||
FolderNote.EMPTY -> R.string.codeviewer_folder_empty
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (row.note == FolderNote.FAILED) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 34.dp + TreeIndent * row.depth, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@ -1073,11 +1260,13 @@ private fun SymbolRow(
|
||||
val base: Dp = if (compact) 14.dp else 20.dp
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
|
||||
// In the side pane a row is a rounded pill inside its card; in the sheet it spans it.
|
||||
.then(if (compact) Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)) else Modifier)
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics { selected = isCurrent }
|
||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.padding(start = (base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base),
|
||||
.padding(start = (if (compact) 0.dp else base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
|
||||
/** What is known about a folder that has been opened in the viewer's folder tree. */
|
||||
sealed interface FolderUiState {
|
||||
data object Loading : FolderUiState
|
||||
data class Loaded(val entries: List<TreeEntry>) : FolderUiState
|
||||
data object Failed : FolderUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder tree beside the code (TASK-057): the directory the file was opened from, and under it
|
||||
* whichever folders have been opened since. A folder is in [folders] exactly while it is open.
|
||||
*/
|
||||
data class FileTree(
|
||||
val root: RepositoryPath,
|
||||
val entries: List<TreeEntry>,
|
||||
val folders: Map<RepositoryPath, FolderUiState> = emptyMap(),
|
||||
) {
|
||||
/** The listing of [directory], if this tree holds it. */
|
||||
fun entriesOf(directory: RepositoryPath): List<TreeEntry>? =
|
||||
if (directory == root) entries else (folders[directory] as? FolderUiState.Loaded)?.entries
|
||||
|
||||
fun with(folder: RepositoryPath, state: FolderUiState): FileTree = copy(folders = folders + (folder to state))
|
||||
|
||||
/** Closes [folder], and with it every folder that was open inside it. */
|
||||
fun without(folder: RepositoryPath): FileTree =
|
||||
copy(folders = folders.filterKeys { it != folder && !it.value.startsWith(folder.value + "/") })
|
||||
|
||||
/** Without the folders still being fetched: what can be carried over to another file. */
|
||||
fun settled(): FileTree = copy(folders = folders.filterValues { it != FolderUiState.Loading })
|
||||
}
|
||||
|
||||
enum class FolderNote { LOADING, FAILED, EMPTY }
|
||||
|
||||
sealed interface FileTreeRow {
|
||||
val depth: Int
|
||||
|
||||
data class Entry(val entry: TreeEntry, override val depth: Int, val isOpen: Boolean) : FileTreeRow
|
||||
|
||||
/** Stands where an open folder's contents would be, when there are none to show. */
|
||||
data class Note(val folder: RepositoryPath, override val depth: Int, val note: FolderNote) : FileTreeRow
|
||||
}
|
||||
|
||||
/** The tree as the rows of a list, top to bottom, each open folder followed by what is in it. */
|
||||
fun FileTree.rows(): List<FileTreeRow> = buildList { addRows(this@rows, entries, depth = 0) }
|
||||
|
||||
private fun MutableList<FileTreeRow>.addRows(tree: FileTree, entries: List<TreeEntry>, depth: Int) {
|
||||
for (entry in entries) {
|
||||
val folder = if (entry.type == TreeEntryType.DIRECTORY) tree.folders[entry.path] else null
|
||||
add(FileTreeRow.Entry(entry, depth, isOpen = folder != null))
|
||||
when (folder) {
|
||||
null -> Unit
|
||||
FolderUiState.Loading -> add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.LOADING))
|
||||
FolderUiState.Failed -> add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.FAILED))
|
||||
is FolderUiState.Loaded ->
|
||||
if (folder.entries.isEmpty()) add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.EMPTY))
|
||||
// A depth limit so that a listing that names itself cannot recurse without end.
|
||||
else if (depth < MAX_TREE_DEPTH) addRows(tree, folder.entries, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_TREE_DEPTH = 32
|
||||
@ -156,13 +156,14 @@ private fun HomeContent(
|
||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||
// so the whole picture is on screen at once.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
||||
}
|
||||
LazyColumn(
|
||||
Modifier.weight(0.54f).fillMaxHeight(),
|
||||
Modifier.weight(0.5f).fillMaxHeight(),
|
||||
state = timelineState,
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
// Starts level with the headline card on the left.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp),
|
||||
) {
|
||||
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||
}
|
||||
@ -184,10 +185,14 @@ private fun LazyListScope.overview(
|
||||
onShowToday: (ActivityCategory) -> Unit,
|
||||
) {
|
||||
item(key = "headline") {
|
||||
// On a phone the headline hangs from the top bar, edge to edge. Unfolded it has only the
|
||||
// left column to hang from, and a white block that stops at the middle of the screen
|
||||
// reads as a different design from the timeline beside it -- so there it is a card on the
|
||||
// page like every other block in both columns.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = if (vertical) RoundedCornerShape(24.dp) else RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = if (vertical) Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp) else Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 20.dp)) {
|
||||
TabTitle(
|
||||
|
||||
21
app/src/main/res/drawable/ic_ub_swap_vertical.xml
Normal file
21
app/src/main/res/drawable/ic_ub_swap_vertical.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,19V5M4,9l4,-4 4,4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M16,5v14M12,15l4,4 4,-4" />
|
||||
</vector>
|
||||
@ -66,7 +66,15 @@
|
||||
<string name="codeviewer_symbol_type">타입</string>
|
||||
<string name="codeviewer_symbol_method">메서드</string>
|
||||
<string name="codeviewer_symbol_field">필드</string>
|
||||
<string name="codeviewer_files_title">파일</string>
|
||||
<string name="codeviewer_files_title">폴더 구조</string>
|
||||
<string name="codeviewer_symbols_title">클래스 · 함수</string>
|
||||
<string name="codeviewer_symbols_empty">이 파일에는 표시할 클래스나 함수가 없습니다.</string>
|
||||
<string name="codeviewer_pane_swap">위아래 바꾸기</string>
|
||||
<string name="codeviewer_folder_open">펼쳐짐</string>
|
||||
<string name="codeviewer_folder_closed">접힘</string>
|
||||
<string name="codeviewer_folder_loading">불러오는 중…</string>
|
||||
<string name="codeviewer_folder_failed">불러오지 못했습니다. 폴더를 다시 눌러 주세요.</string>
|
||||
<string name="codeviewer_folder_empty">비어 있음</string>
|
||||
<string name="codeviewer_pair_label">짝 파일</string>
|
||||
<string name="codeviewer_pair_header">선언</string>
|
||||
<string name="codeviewer_pair_source">구현</string>
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class FileTreeTest {
|
||||
private fun dir(path: String) = TreeEntry(path.substringAfterLast('/'), RepositoryPath.of(path), TreeEntryType.DIRECTORY, null)
|
||||
private fun file(path: String) = TreeEntry(path.substringAfterLast('/'), RepositoryPath.of(path), TreeEntryType.FILE, 1L)
|
||||
|
||||
private val tree = FileTree(RepositoryPath.of("src"), listOf(dir("src/core"), dir("src/ui"), file("src/Main.cpp")))
|
||||
|
||||
private fun describe(tree: FileTree) = tree.rows().map {
|
||||
when (it) {
|
||||
is FileTreeRow.Entry -> "${it.depth}:${it.entry.name}" + if (it.isOpen) "+" else ""
|
||||
is FileTreeRow.Note -> "${it.depth}:<${it.note}>"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closed tree is its entries`() {
|
||||
assertEquals(listOf("0:core", "0:ui", "0:Main.cpp"), describe(tree))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open folder is followed by what is in it, one level in`() {
|
||||
val open = tree
|
||||
.with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(dir("src/core/net"), file("src/core/A.h"))))
|
||||
.with(RepositoryPath.of("src/core/net"), FolderUiState.Loaded(listOf(file("src/core/net/B.h"))))
|
||||
|
||||
assertEquals(listOf("0:core+", "1:net+", "2:B.h", "1:A.h", "0:ui", "0:Main.cpp"), describe(open))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a folder with nothing to show says why`() {
|
||||
val open = tree
|
||||
.with(RepositoryPath.of("src/core"), FolderUiState.Loading)
|
||||
.with(RepositoryPath.of("src/ui"), FolderUiState.Loaded(emptyList()))
|
||||
|
||||
assertEquals(listOf("0:core+", "1:<LOADING>", "0:ui+", "1:<EMPTY>", "0:Main.cpp"), describe(open))
|
||||
assertEquals(listOf("0:core+", "1:<FAILED>", "0:ui", "0:Main.cpp"), describe(tree.with(RepositoryPath.of("src/core"), FolderUiState.Failed)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing a folder closes the folders inside it but not one that only shares its prefix`() {
|
||||
val open = tree
|
||||
.with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(dir("src/core/net"))))
|
||||
.with(RepositoryPath.of("src/core/net"), FolderUiState.Loaded(emptyList()))
|
||||
.with(RepositoryPath.of("src/core2"), FolderUiState.Loaded(emptyList()))
|
||||
|
||||
assertEquals(setOf(RepositoryPath.of("src/core2")), open.without(RepositoryPath.of("src/core")).folders.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries are found for the root and for loaded folders only`() {
|
||||
val open = tree
|
||||
.with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(file("src/core/A.h"))))
|
||||
.with(RepositoryPath.of("src/ui"), FolderUiState.Loading)
|
||||
|
||||
assertEquals(3, open.entriesOf(RepositoryPath.of("src"))?.size)
|
||||
assertEquals(1, open.entriesOf(RepositoryPath.of("src/core"))?.size)
|
||||
assertNull(open.entriesOf(RepositoryPath.of("src/ui")))
|
||||
assertEquals(setOf(RepositoryPath.of("src/core")), open.settled().folders.keys)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -1367,3 +1367,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Live verification: installed on the SM-F966N. Nothing in this task has been seen working on the
|
||||
device by the implementer; endpoint timings were taken from a PC.
|
||||
- Next owner: the product owner, to confirm on the device.
|
||||
|
||||
## 2026-09-28 (continued) — TASK-057: side pane redesign, folder tree, unfolded Home
|
||||
|
||||
- Request: the human product owner confirmed the faster listing, then asked for the pane drawn
|
||||
from the left edge to be redesigned (it was a plain rectangle), split into folder structure and
|
||||
classes/functions with the two halves swappable, and reported that on the unfolded Home the
|
||||
left and right halves did not look like one design.
|
||||
- What changed: see `tasks/057-side-pane-redesign-and-folder-tree.md`.
|
||||
- Why the unfolded Home looked split: the headline was drawn edge to edge, hanging from the top
|
||||
bar, which is right on a phone; unfolded, it covered only the left column.
|
||||
- Checks and results: the required checks pass; 432 unit tests, 0 failures; lint 0 errors.
|
||||
- Live verification: installed on the SM-F966N. None of it has been seen on a screen by the
|
||||
implementer.
|
||||
- Next owner: the product owner, to confirm on the device.
|
||||
|
||||
80
tasks/057-side-pane-redesign-and-folder-tree.md
Normal file
80
tasks/057-side-pane-redesign-and-folder-tree.md
Normal file
@ -0,0 +1,80 @@
|
||||
# TASK-057: Side pane redesign, folder tree, and the unfolded Home
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
Third round of requests from the human product owner after using the app on their Galaxy Z Fold7
|
||||
(SM-F966N), 2026-09-28. They confirmed that directory listing (TASK-056) is now much faster.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
1. The pane drawn out from the left edge of the code viewer is no longer a plain rectangle: the
|
||||
drawer has rounded outer corners and the page color, and holds two white cards.
|
||||
2. The pane is split in half: the folder structure, and the open file's classes and functions.
|
||||
Choosing a class or function goes to it.
|
||||
3. A "위아래 바꾸기" button between the halves swaps them. The choice is kept across launches.
|
||||
4. The folder half is a tree: a folder opens in place, one level at a time, and a file inside it
|
||||
opens from there. Before, choosing a folder left the viewer for the directory list.
|
||||
5. Home, unfolded: the headline block is a card like the rest, and the two columns are equal.
|
||||
Before, it was a white block hanging from the top bar over the left column only, so the left
|
||||
and right halves of the screen looked like two designs.
|
||||
- Excluded:
|
||||
- Dragging the boundary between the two halves; they are equal.
|
||||
- Showing folders above the directory the file was opened from. The tree starts there.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||
- `app/src/main/res/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/`
|
||||
- `tasks/`, `docs/`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The same pane is used in the drawer (phone, landscape, side-by-side pair) and beside the code
|
||||
on a wide window.
|
||||
- [x] A folder shows that it is loading, that it is empty, or that it failed; choosing a failed
|
||||
folder twice tries again.
|
||||
- [x] Closing a folder closes the folders open inside it.
|
||||
- [x] Open folders are kept while moving between files of the tree.
|
||||
- [x] A file opened from a folder of the tree finds its `.h`/`.cpp` counterpart in that folder.
|
||||
- [x] A file with no classes or functions says so instead of showing an empty card.
|
||||
- [x] Unit tests cover the tree's rows, closing, and lookup (`FileTreeTest`).
|
||||
|
||||
## Security and privacy
|
||||
|
||||
No additional concerns. The only thing stored is which half is on top, a boolean in the existing
|
||||
settings preferences.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- Required checks pass: 432 unit tests, 0 failures; lint 0 errors.
|
||||
- Installed on the SM-F966N over wireless `adb`.
|
||||
- Not seen by the implementer on any screen: the drawer's new look, the tree, the swap, and the
|
||||
unfolded Home. The Home change was made from reading the layout code, not from a screenshot.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- `toggleFolder` in the view model has no unit test of its own; the tree it edits has.
|
||||
- On the unfolded Home the three summary tiles are narrower than before, because the headline
|
||||
card is now inset. A long label may wrap to two lines.
|
||||
- The preference for which half is on top is read in the viewer itself rather than passed down
|
||||
from `MainActivity` like the other viewer preferences.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
Loading…
x
Reference in New Issue
Block a user