The tab bar retires — boards are the app, settings behind the gear, recency the default order

The boards stack becomes the root screen: Settings moves from its own
tab to a sheet behind a leading gearshape button, the sort pickers trade
their text segments for symbols (hand.draw, textformat.abc, clock) with
the words kept for accessibility, and every list now opens sorted by
most recent change. UI tests re-aimed at the gear button and Done-button
scoping fixed for the sheet-over-sheet nav bars — 4/4 green in the
simulator.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 13:07:44 -04:00
parent 53be63bb03
commit 871083e5ca
9 changed files with 65 additions and 48 deletions
+3 -16
View File
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
/// The iPhone app's entry point. Two tabs, per the mobile MVP charter: Boards (the whole /// The iPhone app's entry point. The boards navigation stack (board lane card) is the whole
/// board lane card navigation stack) and Settings. /// app; Settings lives behind the gear button on the board list rather than a tab of its own.
@main @main
struct MobileApp: App { struct MobileApp: App {
@@ -13,21 +13,8 @@ struct MobileApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootTabView() BoardsTabView()
.environment(boardIndex) .environment(boardIndex)
} }
} }
} }
struct RootTabView: View {
var body: some View {
TabView {
Tab("Boards", systemImage: "rectangle.stack") {
BoardsTabView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsTabView()
}
}
}
}
+4
View File
@@ -1,5 +1,9 @@
**August 2026** **August 2026**
Settings now lives behind the gear button on the board list instead of a separate tab, so boards fill the whole screen.
Lists now open sorted by most recent change.
Sort lanes and cards by your own arrangement, name, or most recent change. Sort lanes and cards by your own arrangement, name, or most recent change.
Boards, lanes, and cards now show their icon and color in the lists. Boards, lanes, and cards now show their icon and color in the lists.
+6 -6
View File
@@ -16,15 +16,15 @@ struct BoardScreen: View {
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s /// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`. /// `sortOrderRaw`.
@AppStorage("laneListSortOrder") private var sortOrderRaw = ItemSortOrder.manual.rawValue @AppStorage("laneListSortOrder") private var sortOrderRaw = ItemSortOrder.recent.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.manual` on anything the store didn't /// The stored raw value as the enum, defaulting to `.recent` on anything the store didn't
/// write itself an unset key or a stale raw value from a build that no longer has this case. /// write itself an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<ItemSortOrder> { private var sortOrder: Binding<ItemSortOrder> {
Binding( Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual }, get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue } set: { sortOrderRaw = $0.rawValue }
) )
} }
@@ -83,9 +83,9 @@ struct BoardScreen: View {
if case .ready = session.phase, let snapshot = session.snapshot, !snapshot.lanes.isEmpty { if case .ready = session.phase, let snapshot = session.snapshot, !snapshot.lanes.isEmpty {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Picker("Sort", selection: sortOrder) { Picker("Sort", selection: sortOrder) {
Text("Manual").tag(ItemSortOrder.manual) Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual)
Text("Name").tag(ItemSortOrder.name) Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent) Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(maxWidth: 260) .frame(maxWidth: 260)
+20 -6
View File
@@ -1,7 +1,8 @@
import SwiftUI import SwiftUI
/// The root of the boards navigation stack: board list lanes cards card detail /// The root of the boards navigation stack: board list lanes cards card detail
/// (`BoardRoute`'s destinations). /// (`BoardRoute`'s destinations) and, now that the tab bar is gone, the app's root screen.
/// Settings is presented from the leading gear button rather than a tab of its own.
/// ///
/// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice /// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice
/// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home /// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home
@@ -12,10 +13,11 @@ struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index @Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false @State private var isPresentingNewBoard = false
@State private var isPresentingSettings = false
/// Persisted as its raw value, not the enum itself `AppStorage` needs a property-list type, and /// Persisted as its raw value, not the enum itself `AppStorage` needs a property-list type, and
/// the raw `String` is exactly what `BoardSortOrder` promises to keep stable. /// the raw `String` is exactly what `BoardSortOrder` promises to keep stable.
@AppStorage("boardListSortOrder") private var sortOrderRaw = BoardSortOrder.name.rawValue @AppStorage("boardListSortOrder") private var sortOrderRaw = BoardSortOrder.recent.rawValue
/// The board whose settings sheet is up. A value, not a URL: the sheet renders the row's own /// The board whose settings sheet is up. A value, not a URL: the sheet renders the row's own
/// summary, and a successful move dismisses it before the stale copy could matter. /// summary, and a successful move dismisses it before the stale copy could matter.
@@ -25,6 +27,15 @@ struct BoardsTabView: View {
NavigationStack { NavigationStack {
content content
.navigationTitle("Boards") .navigationTitle("Boards")
.toolbar {
// Unconditional settings (About, license, changelog) must stay reachable
// even while the index is still loading or the board list is empty.
ToolbarItem(placement: .topBarLeading) {
Button("Settings", systemImage: "gearshape") {
isPresentingSettings = true
}
}
}
.toolbar { .toolbar {
if case .ready = index.phase { if case .ready = index.phase {
Button("New Board", systemImage: "plus") { Button("New Board", systemImage: "plus") {
@@ -61,13 +72,16 @@ struct BoardsTabView: View {
.sheet(item: $boardInSettings) { board in .sheet(item: $boardInSettings) { board in
BoardSettingsSheet(board: board) BoardSettingsSheet(board: board)
} }
.sheet(isPresented: $isPresentingSettings) {
SettingsScreen()
}
} }
/// The stored raw value as the enum, defaulting to `.name` on anything the store didn't write /// The stored raw value as the enum, defaulting to `.recent` on anything the store didn't write
/// itself an unset key or a stale raw value from a build that no longer has this case. /// itself an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<BoardSortOrder> { private var sortOrder: Binding<BoardSortOrder> {
Binding( Binding(
get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .name }, get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue } set: { sortOrderRaw = $0.rawValue }
) )
} }
@@ -83,8 +97,8 @@ struct BoardsTabView: View {
if case .ready = index.phase, !index.boards.isEmpty { if case .ready = index.phase, !index.boards.isEmpty {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Picker("Sort", selection: sortOrder) { Picker("Sort", selection: sortOrder) {
Text("Name").tag(BoardSortOrder.name) Label("Name", systemImage: "textformat.abc").tag(BoardSortOrder.name)
Text("Recent").tag(BoardSortOrder.recent) Label("Recent", systemImage: "clock").tag(BoardSortOrder.recent)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(maxWidth: 260) .frame(maxWidth: 260)
+6 -6
View File
@@ -20,15 +20,15 @@ struct LaneScreen: View {
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s /// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`. /// `sortOrderRaw`.
@AppStorage("cardListSortOrder") private var sortOrderRaw = ItemSortOrder.manual.rawValue @AppStorage("cardListSortOrder") private var sortOrderRaw = ItemSortOrder.recent.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.manual` on anything the store didn't /// The stored raw value as the enum, defaulting to `.recent` on anything the store didn't
/// write itself an unset key or a stale raw value from a build that no longer has this case. /// write itself an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<ItemSortOrder> { private var sortOrder: Binding<ItemSortOrder> {
Binding( Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual }, get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue } set: { sortOrderRaw = $0.rawValue }
) )
} }
@@ -98,9 +98,9 @@ struct LaneScreen: View {
if let lane, !lane.cards.isEmpty { if let lane, !lane.cards.isEmpty {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Picker("Sort", selection: sortOrder) { Picker("Sort", selection: sortOrder) {
Text("Manual").tag(ItemSortOrder.manual) Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual)
Text("Name").tag(ItemSortOrder.name) Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent) Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(maxWidth: 260) .frame(maxWidth: 260)
+10 -3
View File
@@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import IndieAbout import IndieAbout
/// The About section (indie-about skill). /// The About section (indie-about skill). Presented as a sheet from `BoardsTabView`'s gear
struct SettingsTabView: View { /// button, so it carries its own navigation stack and a Done button to dismiss itself.
struct SettingsScreen: View {
@Environment(BoardIndexStore.self) private var index @Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -19,9 +21,14 @@ struct SettingsTabView: View {
} }
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
} }
// Idempotent (BoardIndexStore.start()), and harmless if BoardsTabView already called it // Idempotent (BoardIndexStore.start()), and harmless if BoardsTabView already called it
// this tab can be the first one the user opens. // defensive only, since the boards screen is always what starts the index now.
.task { index.start() } .task { index.start() }
} }
} }
@@ -8,9 +8,9 @@ final class BoardsNavigationUITests: XCTestCase {
func testBoardsNavigationAndTitleEdit() throws { func testBoardsNavigationAndTitleEdit() throws {
let (app, root) = XCUIApplication.launchedWithFixtureBoard() let (app, root) = XCUIApplication.launchedWithFixtureBoard()
// Boards is the first tab (`RootTabView`), so no tab switch is needed here. The row's // Boards is the app's root screen (`BoardsTabView`), so no navigation is needed to reach
// label is the flattened `BoardSummaryRow` title plus the "N lanes · N cards · modified" // it. The row's label is the flattened `BoardSummaryRow` title plus the "N lanes · N
// subtitle `BoardIndexStore`'s first scan fills in. // cards · modified" subtitle `BoardIndexStore`'s first scan fills in.
let boardRow = app.element(labelContaining: RichBoard.title) let boardRow = app.element(labelContaining: RichBoard.title)
XCTAssertTrue( XCTAssertTrue(
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout), boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
@@ -156,9 +156,10 @@ extension XCUIApplication {
/// Swipes a board row open and taps its Settings action. /// Swipes a board row open and taps its Settings action.
/// ///
/// The label is scoped to the list because the tab bar carries a "Settings" button of its own /// The label is scoped to the list because the boards screen's own nav bar carries a
/// same word, entirely different destination and an unscoped `buttons["Settings"]` is a coin /// "Settings" button of its own the leading gear button that presents `SettingsScreen` as a
/// flip between them. /// sheet, same word, entirely different destination and an unscoped `buttons["Settings"]` is
/// a coin flip between them.
@MainActor @MainActor
func openBoardSettings(for title: String) { func openBoardSettings(for title: String) {
let row = boardRow(title) let row = boardRow(title)
@@ -1,6 +1,6 @@
import XCTest import XCTest
/// The Settings tab's last section `SettingsTabView`'s `IndieAbout` block: version/build (which /// Settings' only section `SettingsScreen`'s `IndieAbout` block: version/build (which
/// doubles as the changelog link), the License document link, and the copyright line. /// doubles as the changelog link), the License document link, and the copyright line.
final class SettingsAboutUITests: XCTestCase { final class SettingsAboutUITests: XCTestCase {
@@ -8,7 +8,7 @@ final class SettingsAboutUITests: XCTestCase {
func testSettingsAboutSection() throws { func testSettingsAboutSection() throws {
let (app, _) = XCUIApplication.launchedWithFixtureBoard() let (app, _) = XCUIApplication.launchedWithFixtureBoard()
app.tabBars.buttons["Settings"].tap() app.navigationBars.buttons["Settings"].tap()
// The About section is the only section in the form a speculative swipe settles the // The About section is the only section in the form a speculative swipe settles the
// list near the bottom before anything here is queried, harmless if the content already // list near the bottom before anything here is queried, harmless if the content already
@@ -32,7 +32,7 @@ final class SettingsAboutUITests: XCTestCase {
XCTAssertTrue(copyright.waitForExistence(timeout: XCUIApplication.uiTimeout), "the copyright text never appeared") XCTAssertTrue(copyright.waitForExistence(timeout: XCUIApplication.uiTimeout), "the copyright text never appeared")
// Tap the version link opens the changelog (`IndieAbout.versionLineView`'s // Tap the version link opens the changelog (`IndieAbout.versionLineView`'s
// `.onTapGesture`, wired to `changelogDocument: .changelog()` in `SettingsTabView`). // `.onTapGesture`, wired to `changelogDocument: .changelog()` in `SettingsScreen`).
app.scrollUntilHittable(versionLink) app.scrollUntilHittable(versionLink)
versionLink.tap() versionLink.tap()
let changelogText = app.element(labelContaining: "August 2026") let changelogText = app.element(labelContaining: "August 2026")
@@ -40,7 +40,10 @@ final class SettingsAboutUITests: XCTestCase {
changelogText.waitForExistence(timeout: XCUIApplication.uiTimeout), changelogText.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the changelog sheet never showed \"August 2026\" (KanbanMobile/CHANGELOG.md)" "the changelog sheet never showed \"August 2026\" (KanbanMobile/CHANGELOG.md)"
) )
app.navigationBars.buttons["Done"].tap() // Scoped to the "Changelog" nav bar specifically `SettingsScreen` is itself a sheet with
// its own "Done" button, and that nav bar stays in the accessibility tree underneath this
// one, so an unscoped `app.navigationBars.buttons["Done"]` would match both.
app.navigationBars["Changelog"].buttons["Done"].tap()
// Tap License opens the bundled ISC license. // Tap License opens the bundled ISC license.
app.scrollUntilHittable(licenseLink) app.scrollUntilHittable(licenseLink)
@@ -50,6 +53,7 @@ final class SettingsAboutUITests: XCTestCase {
licenseText.waitForExistence(timeout: XCUIApplication.uiTimeout), licenseText.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the license sheet never showed \"ISC License\" (LICENSE.md)" "the license sheet never showed \"ISC License\" (LICENSE.md)"
) )
app.navigationBars.buttons["Done"].tap() // Same scoping reason as the changelog Done tap above.
app.navigationBars["License"].buttons["Done"].tap()
} }
} }