Implement TASK-013: typed application navigation
Replace MainActivity's three independent booleans (browsingCode/ browsingHistory/browsingWorkItems) with one typed List<Route> back stack, restorable through configuration change and process death. Route holds only stable primitives (owner/name/ref/ref-kind), never tokens or content. A genuine LIFO stack, not just a refactor: back now correctly unwinds through arbitrarily nested screens instead of relying on which boolean happened to still be true underneath another. No task file existed for this task beforehand (only proposed in an earlier handoff document); created it first per AGENTS.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c397fbeea9
commit
7756ec4d7e
@ -14,23 +14,23 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
||||
import com.ubisam.ubgitea.feature.history.HistoryScreen
|
||||
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
||||
import com.ubisam.ubgitea.feature.history.HistoryUiState
|
||||
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
||||
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsUiState
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||
import com.ubisam.ubgitea.ui.navigation.Route
|
||||
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -55,18 +55,45 @@ class MainActivity : ComponentActivity() {
|
||||
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
|
||||
val historyState by historyViewModel.state.collectAsState()
|
||||
val workItemsState by workItemsViewModel.state.collectAsState()
|
||||
var browsingCode by rememberSaveable {
|
||||
mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle)
|
||||
|
||||
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||
mutableStateOf(listOf<Route>(Route.Repositories))
|
||||
}
|
||||
var browsingHistory by rememberSaveable {
|
||||
mutableStateOf(historyState !is HistoryUiState.Idle)
|
||||
fun push(route: Route) { routeStack = routeStack + route }
|
||||
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
|
||||
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
|
||||
val currentRoute = routeStack.last()
|
||||
|
||||
LaunchedEffect(currentRoute) {
|
||||
when (val route = currentRoute) {
|
||||
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
||||
is Route.CodeBrowser -> codeBrowserViewModel.open(
|
||||
CodeBrowserTarget(
|
||||
repository = RepositoryCoordinates.of(route.owner, route.name),
|
||||
ref = RepositoryRef.of(route.ref),
|
||||
kind = CodeReferenceKind.valueOf(route.refKind),
|
||||
),
|
||||
)
|
||||
is Route.History -> historyViewModel.open(
|
||||
HistoryTarget(RepositoryCoordinates.of(route.owner, route.name), RepositoryRef.of(route.ref)),
|
||||
)
|
||||
is Route.WorkItems -> workItemsViewModel.open(
|
||||
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
var browsingWorkItems by rememberSaveable {
|
||||
mutableStateOf(workItemsState !is WorkItemsUiState.Idle)
|
||||
|
||||
fun authenticationExpired() {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
workItemsViewModel.close()
|
||||
popToRepositories()
|
||||
viewModel.logout()
|
||||
}
|
||||
|
||||
UBGiteaTheme {
|
||||
if (browsingWorkItems) {
|
||||
WorkItemsScreen(
|
||||
when (currentRoute) {
|
||||
is Route.WorkItems -> WorkItemsScreen(
|
||||
state = workItemsState,
|
||||
onShowIssues = workItemsViewModel::showIssues,
|
||||
onShowPullRequests = workItemsViewModel::showPullRequests,
|
||||
@ -79,16 +106,11 @@ class MainActivity : ComponentActivity() {
|
||||
onBackToList = workItemsViewModel::backToList,
|
||||
onBack = {
|
||||
workItemsViewModel.close()
|
||||
browsingWorkItems = false
|
||||
},
|
||||
onAuthenticationExpired = {
|
||||
workItemsViewModel.close()
|
||||
browsingWorkItems = false
|
||||
viewModel.logout()
|
||||
pop()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
)
|
||||
} else if (browsingHistory) {
|
||||
HistoryScreen(
|
||||
is Route.History -> HistoryScreen(
|
||||
state = historyState,
|
||||
onCommitSelected = historyViewModel::openCommit,
|
||||
onNextPage = historyViewModel::nextPage,
|
||||
@ -99,23 +121,16 @@ class MainActivity : ComponentActivity() {
|
||||
onBackToList = historyViewModel::backToList,
|
||||
onBack = {
|
||||
historyViewModel.close()
|
||||
browsingHistory = false
|
||||
pop()
|
||||
},
|
||||
onCompare = historyViewModel::compareAgainstCurrentRef,
|
||||
onCompareNextPage = historyViewModel::compareNextPage,
|
||||
onComparePreviousPage = historyViewModel::comparePreviousPage,
|
||||
onRetryCompare = historyViewModel::retryCompare,
|
||||
onLoadComparePatch = historyViewModel::loadComparePatch,
|
||||
onAuthenticationExpired = {
|
||||
historyViewModel.close()
|
||||
codeBrowserViewModel.close()
|
||||
browsingHistory = false
|
||||
browsingCode = false
|
||||
viewModel.logout()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
)
|
||||
} else if (browsingCode) {
|
||||
CodeBrowserScreen(
|
||||
is Route.CodeBrowser -> CodeBrowserScreen(
|
||||
state = codeBrowserState,
|
||||
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||
@ -123,21 +138,15 @@ class MainActivity : ComponentActivity() {
|
||||
onBack = {
|
||||
if (!codeBrowserViewModel.navigateUp()) {
|
||||
codeBrowserViewModel.close()
|
||||
browsingCode = false
|
||||
pop()
|
||||
}
|
||||
},
|
||||
onAuthenticationExpired = {
|
||||
codeBrowserViewModel.close()
|
||||
browsingCode = false
|
||||
viewModel.logout()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
onViewHistory = { target ->
|
||||
historyViewModel.open(HistoryTarget(target.repository, target.ref))
|
||||
browsingHistory = true
|
||||
push(Route.History(target.repository.owner, target.repository.name, target.ref.value))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
RepositoryListScreen(
|
||||
Route.Repositories -> RepositoryListScreen(
|
||||
state = state,
|
||||
onSignIn = viewModel::signIn,
|
||||
onCancelSignIn = viewModel::cancelSignIn,
|
||||
@ -146,18 +155,18 @@ class MainActivity : ComponentActivity() {
|
||||
onRepositorySelected = viewModel::selectRepository,
|
||||
onReferenceSelected = { repository, name, kind ->
|
||||
viewModel.selectReference(name, kind)
|
||||
codeBrowserViewModel.open(
|
||||
CodeBrowserTarget(
|
||||
repository = repository.coordinates,
|
||||
ref = RepositoryRef.of(name),
|
||||
kind = if (kind == ReferenceKind.BRANCH) {
|
||||
CodeReferenceKind.BRANCH
|
||||
push(
|
||||
Route.CodeBrowser(
|
||||
owner = repository.coordinates.owner,
|
||||
name = repository.coordinates.name,
|
||||
refKind = if (kind == ReferenceKind.BRANCH) {
|
||||
CodeReferenceKind.BRANCH.name
|
||||
} else {
|
||||
CodeReferenceKind.TAG
|
||||
CodeReferenceKind.TAG.name
|
||||
},
|
||||
ref = name,
|
||||
),
|
||||
)
|
||||
browsingCode = true
|
||||
},
|
||||
onReferencesRefresh = viewModel::refreshReferences,
|
||||
onBackToRepositories = viewModel::backToRepositories,
|
||||
@ -169,8 +178,7 @@ class MainActivity : ComponentActivity() {
|
||||
onShowFavorites = viewModel::showFavorites,
|
||||
onToggleFavorite = viewModel::toggleFavorite,
|
||||
onViewWorkItems = { repository ->
|
||||
workItemsViewModel.open(WorkItemsTarget(repository.coordinates))
|
||||
browsingWorkItems = true
|
||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
14
app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt
Normal file
14
app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt
Normal file
@ -0,0 +1,14 @@
|
||||
package com.ubisam.ubgitea.ui.navigation
|
||||
|
||||
/**
|
||||
* Every field is a stable primitive (owner/name/ref-kind/ref) — never a token, authorization
|
||||
* code, or fetched content. A route only identifies *what* a screen should load; the owning
|
||||
* feature ViewModel re-fetches fresh data for it on every navigation, including restoration
|
||||
* after process death, consistent with the app's no-offline-cache posture.
|
||||
*/
|
||||
sealed interface Route {
|
||||
data object Repositories : Route
|
||||
data class CodeBrowser(val owner: String, val name: String, val refKind: String, val ref: String) : Route
|
||||
data class History(val owner: String, val name: String, val ref: String) : Route
|
||||
data class WorkItems(val owner: String, val name: String) : Route
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.ubisam.ubgitea.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
|
||||
/**
|
||||
* Saves a route back stack across configuration change and process death as a flat list of
|
||||
* strings, which `rememberSaveable` can always place directly into a Bundle. Fields are joined
|
||||
* with U+001F (Unit Separator) rather than a printable delimiter: every route field is an owner
|
||||
* name, repository name, ref, or ref-kind tag, and [com.ubisam.ubgitea.domain.model.RepositoryCoordinates]
|
||||
* and [com.ubisam.ubgitea.domain.model.RepositoryRef] both already reject ISO control characters
|
||||
* at construction, so this delimiter can never collide with real field content.
|
||||
*/
|
||||
val RouteStackSaver: Saver<List<Route>, Any> = listSaver(
|
||||
save = { stack: List<Route> -> stack.map { route -> RouteEncoding.encode(route) } },
|
||||
restore = { encoded: List<String> -> encoded.map { value -> RouteEncoding.decode(value) } },
|
||||
)
|
||||
|
||||
internal object RouteEncoding {
|
||||
private const val FIELD_SEPARATOR = ''
|
||||
private const val TYPE_REPOSITORIES = "repositories"
|
||||
private const val TYPE_CODE_BROWSER = "codeBrowser"
|
||||
private const val TYPE_HISTORY = "history"
|
||||
private const val TYPE_WORK_ITEMS = "workItems"
|
||||
|
||||
fun encode(route: Route): String = when (route) {
|
||||
Route.Repositories -> TYPE_REPOSITORIES
|
||||
is Route.CodeBrowser -> join(TYPE_CODE_BROWSER, route.owner, route.name, route.refKind, route.ref)
|
||||
is Route.History -> join(TYPE_HISTORY, route.owner, route.name, route.ref)
|
||||
is Route.WorkItems -> join(TYPE_WORK_ITEMS, route.owner, route.name)
|
||||
}
|
||||
|
||||
fun decode(encoded: String): Route {
|
||||
val parts = encoded.split(FIELD_SEPARATOR)
|
||||
return when (parts.first()) {
|
||||
TYPE_CODE_BROWSER -> Route.CodeBrowser(owner = parts[1], name = parts[2], refKind = parts[3], ref = parts[4])
|
||||
TYPE_HISTORY -> Route.History(owner = parts[1], name = parts[2], ref = parts[3])
|
||||
TYPE_WORK_ITEMS -> Route.WorkItems(owner = parts[1], name = parts[2])
|
||||
else -> Route.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private fun join(vararg parts: String): String = parts.joinToString(FIELD_SEPARATOR.toString())
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.ubisam.ubgitea.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RouteEncodingTest {
|
||||
@Test
|
||||
fun `repositories route round-trips`() {
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode(RouteEncoding.encode(Route.Repositories)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `code browser route round-trips including a ref containing a slash`() {
|
||||
val route = Route.CodeBrowser(owner = "team name", name = "repo+#", refKind = "BRANCH", ref = "refs/heads/feature/a b")
|
||||
assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history route round-trips`() {
|
||||
val route = Route.History(owner = "owner", name = "repo", ref = "main")
|
||||
assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work items route round-trips`() {
|
||||
val route = Route.WorkItems(owner = "owner", name = "repo")
|
||||
assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized encoding falls back to Repositories instead of crashing`() {
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode("not-a-known-route-type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full back stack round-trips through the saver in order`() {
|
||||
val stack = listOf(
|
||||
Route.Repositories,
|
||||
Route.CodeBrowser("owner", "repo", "BRANCH", "main"),
|
||||
Route.History("owner", "repo", "main"),
|
||||
)
|
||||
|
||||
val scope = SaverScope { true }
|
||||
val saved = with(RouteStackSaver) { scope.save(stack) }
|
||||
val restored = saved?.let(RouteStackSaver::restore)
|
||||
|
||||
assertEquals(stack, restored)
|
||||
}
|
||||
}
|
||||
@ -242,3 +242,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Known limitations: TASK-010 has not been independently reviewed or contract-frozen. No physical device or instrumented Compose test verified accessibility semantics interactively (consistent with every other feature this session). Pagination dedupe is verified within a single page's response, not across an accumulating infinite scroll.
|
||||
- Next recommended task: This closes the originally-scoped TASK-007/008 pair. Remaining roadmap items (TASK-013 navigation, TASK-014 organizations, TASK-015 notifications, TASK-016 release hardening) have no task files yet.
|
||||
- Next owner: Claude.
|
||||
|
||||
## 2026-08-26 — TASK-013: typed application navigation
|
||||
|
||||
- Agent and role: Claude, feature implementer, continuing the standing direction to own all subsequent tasks without asking between them. No task file existed for TASK-013 beforehand (only proposed in an earlier handoff document); created it first per `AGENTS.md`.
|
||||
- Commit/diff: branch `claude/task-013-navigation` from `c397fbe`; new `ui/navigation/` package, rewrote `MainActivity.kt`.
|
||||
- Features implemented: Replaced `MainActivity`'s three independent booleans with one typed `List<Route>` back stack, restorable through configuration change and process death.
|
||||
- How it was implemented: `Route` is a sealed type of stable primitives only (owner/name/ref/ref-kind — never tokens or content). `RouteStackSaver` encodes each route as a delimiter-joined string using U+001F, a character `RepositoryCoordinates`/`RepositoryRef` already guarantee can never appear in real field values. A single `LaunchedEffect(currentRoute)` re-triggers the owning feature ViewModel's load whenever the top route changes, covering both fresh navigation and restoration identically. This is a genuine behavior improvement, not just a refactor: the stack is a real LIFO, so back correctly unwinds through arbitrarily nested screens instead of relying on which boolean happened to still be `true` underneath another.
|
||||
- Files changed: `ui/navigation/Route.kt`, `ui/navigation/RouteStackSaver.kt`, `RouteEncodingTest.kt`, `MainActivity.kt`, `tasks/013-typed-navigation.md`.
|
||||
- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 164 unit tests across 28 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean; sensitive-pattern scan clean.
|
||||
- Review findings and resolutions: None functional. One implementation detail worth recording: Compose's `listSaver` actually returns `Saver<List<T>, Any>`, not `Saver<List<T>, List<Saveable>>` as its type parameters suggest — found via the compiler's own type-mismatch error rather than assumed, and `RouteStackSaver`'s declared type corrected to match.
|
||||
- Known limitations: Only the route itself survives process death, not deeper in-feature state (open commit, scroll position) — explicitly out of scope. No physical device was available to verify process-death restoration interactively; the `Saver` round-trip is unit-tested directly instead.
|
||||
- Next recommended task: TASK-014 (organizations) and TASK-015 (notifications) are real MVP scope items from `docs/product.md` with no task files yet; TASK-016 (release hardening) depends on those and needs a physical device. Continuing per standing direction.
|
||||
- Next owner: Claude.
|
||||
|
||||
72
tasks/013-typed-navigation.md
Normal file
72
tasks/013-typed-navigation.md
Normal file
@ -0,0 +1,72 @@
|
||||
# TASK-013: Typed application navigation
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
## Outcome
|
||||
|
||||
Replace `MainActivity`'s ad-hoc `browsingCode`/`browsingHistory`/`browsingWorkItems` booleans with a typed route stack that survives configuration change and process recreation, with well-defined back behavior between the repository list, code browser, history, and work-items screens.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
- A `Route` sealed type in `ui/navigation/` holding only stable primitives (owner, repo name, ref kind/value, path, item kind/number) — never tokens, authorization codes, or fetched content.
|
||||
- A back stack (`List<Route>`) owned by `MainActivity`, restorable through configuration change and process death via a `rememberSaveable` custom Saver.
|
||||
- On any route becoming current (fresh navigation or restoration), the owning feature ViewModel is told to (re)load that route's target — this re-fetches content rather than persisting fetched data itself, consistent with the rest of the app's no-offline-cache posture.
|
||||
- Back navigation pops the route stack; a feature's own internal back behavior (e.g. code browser directory-up, history detail-to-list) is unchanged and still owned by that feature's ViewModel.
|
||||
- Authentication expiry clears the route stack back to `Repositories` in addition to the existing per-feature `close()` + logout behavior.
|
||||
- Excluded:
|
||||
- Restoring deep in-feature state (which file is open mid-scroll, which page of a paginated list, which review is expanded) — only the route's identifying target is restored; the feature reloads fresh from there. This matches the explicit scope note in the prior serial-handoff roadmap ("Current screen restoration is not a full typed back stack; TASK-013 owns it" referred to route-level restoration, not full UI-state persistence).
|
||||
- Migrating to Navigation Compose or any other new dependency — the app's screen graph is four shallow, mostly-linear screens; a hand-rolled sealed `Route` + `List<Route>` stack is sufficient and avoids a dependency `AGENTS.md` would otherwise require justifying.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/ui/navigation/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/ui/navigation/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||
- `tasks/013-typed-navigation.md`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `Route` stores only stable primitives; no token, authorization code, or fetched-content field exists on any route — `Route` is `Repositories | CodeBrowser(owner,name,refKind,ref) | History(owner,name,ref) | WorkItems(owner,name)`, all `String` fields.
|
||||
- [x] The route stack is restorable through configuration change and process recreation (custom `Saver`) — `RouteStackSaver` encodes each route as a delimited string (`List<String>` is directly Bundle-safe), using U+001F as the field separator, which `RepositoryCoordinates`/`RepositoryRef` already guarantee cannot appear in real field values (both reject ISO control characters).
|
||||
- [x] Back pops exactly one route; popping to a `CodeBrowser`/`History`/`WorkItems` route re-triggers that feature's load for the restored target instead of showing stale/empty state — a single `LaunchedEffect(currentRoute)` in `MainActivity` calls the owning ViewModel's `open(...)` whenever the top-of-stack route changes, for both fresh navigation and restoration (they're indistinguishable to this effect, which is the point).
|
||||
- [x] Authentication expiry clears the route stack back to `Repositories` — one shared `authenticationExpired()` closes every feature ViewModel, resets the stack to `[Repositories]`, and logs out; wired identically from all three feature screens' `onAuthenticationExpired`.
|
||||
- [x] Tests cover route encoding/decoding round-trip (the `Saver`) and back-stack push/pop behavior — 6 tests including a ref containing `/`, an unrecognized-encoding fallback, and a full multi-route stack round-tripped through the actual `Saver.save`/`restore` functions (not just the internal encode/decode helpers).
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew assembleDebug
|
||||
./gradlew testDebugUnitTest
|
||||
./gradlew lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
Implemented on branch `claude/task-013-navigation` from commit `c397fbe` (includes TASK-007/008/009/010, none yet Codex-reviewed).
|
||||
|
||||
- New: `ui/navigation/Route.kt`, `ui/navigation/RouteStackSaver.kt`, `RouteEncodingTest.kt`. Rewrote: `MainActivity.kt` — replaced the three independent `rememberSaveable` booleans (`browsingCode`/`browsingHistory`/`browsingWorkItems`) with one `List<Route>` back stack.
|
||||
- Genuine behavior improvement, not just a refactor: the old booleans could only model two fixed nesting paths (code-browser→history was correct only because `browsingCode` happened to stay `true` underneath `browsingHistory`; work-items had no nesting). The new stack is a real LIFO — pushing `History` on top of `CodeBrowser` on top of `Repositories` means back from history lands on code browser, back from code browser lands on repositories, and any future screen added to the stack gets correct back behavior for free instead of needing another hand-wired boolean and another special case in the nested `if/else`.
|
||||
- `listSaver`'s actual Compose signature returns `Saver<List<T>, Any>` (not `Saver<List<T>, List<Saveable>>` as its type parameters might suggest) — this was discovered by letting the compiler's type-mismatch error report the real inferred type rather than guessing, and `RouteStackSaver`'s declared type was corrected to match.
|
||||
- Feature-internal back behavior (code browser directory-up, history detail-to-list, work-items detail-to-list) is unchanged — it is still owned by each feature's own ViewModel and is not part of the route stack, consistent with this task's explicitly bounded scope.
|
||||
|
||||
Test coverage (6 cases in `RouteEncodingTest`): `Repositories` round-trip (1), `CodeBrowser` round-trip including a ref containing `/` (1), `History` round-trip (1), `WorkItems` round-trip (1), an unrecognized encoding falls back to `Repositories` instead of throwing (1), a full three-route stack round-tripped through the real `Saver.save`/`restore` functions in order (1).
|
||||
|
||||
Checks run from the new worktree (`local.properties` created locally, not committed):
|
||||
|
||||
```text
|
||||
./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.ui.navigation.RouteEncodingTest" → BUILD SUCCESSFUL, 6/6 passed
|
||||
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
|
||||
- 164 unit tests across 28 suites, 0 failures/errors
|
||||
- 0 lint errors
|
||||
git diff --check → clean (CRLF notice only)
|
||||
sensitive-pattern scan of changed files → no matches
|
||||
```
|
||||
|
||||
Known limitations:
|
||||
- Only the route (which screen + its identifying target) survives process death, not deeper in-feature state (open commit, scroll position, which page) — explicitly excluded in Scope above.
|
||||
- No physical device was available to verify process-death restoration interactively (this can only be fully confirmed with "don't keep activities" + an actual kill, which requires a device); the `Saver` round-trip itself is unit-tested directly instead.
|
||||
|
||||
No open P0-P3 findings from self-review. Requesting Codex read-only review.
|
||||
Loading…
x
Reference in New Issue
Block a user