diff --git a/KanbanMobile/App/MobileApp.swift b/KanbanMobile/App/MobileApp.swift index 9c6a6c2..d27d49d 100644 --- a/KanbanMobile/App/MobileApp.swift +++ b/KanbanMobile/App/MobileApp.swift @@ -1,7 +1,7 @@ import SwiftUI -/// The iPhone app's entry point. Two tabs, per the mobile MVP charter: Boards (the whole -/// board → lane → card navigation stack) and Settings. +/// The iPhone app's entry point. The boards navigation stack (board → lane → card) is the whole +/// app; Settings lives behind the gear button on the board list rather than a tab of its own. @main struct MobileApp: App { @@ -13,21 +13,8 @@ struct MobileApp: App { var body: some Scene { WindowGroup { - RootTabView() + BoardsTabView() .environment(boardIndex) } } } - -struct RootTabView: View { - var body: some View { - TabView { - Tab("Boards", systemImage: "rectangle.stack") { - BoardsTabView() - } - Tab("Settings", systemImage: "gearshape") { - SettingsTabView() - } - } - } -} diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index 007f521..82bb5b5 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -1,5 +1,9 @@ **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. Boards, lanes, and cards now show their icon and color in the lists. diff --git a/KanbanMobile/Screens/BoardScreen.swift b/KanbanMobile/Screens/BoardScreen.swift index 58ef143..e933d87 100644 --- a/KanbanMobile/Screens/BoardScreen.swift +++ b/KanbanMobile/Screens/BoardScreen.swift @@ -16,15 +16,15 @@ struct BoardScreen: View { /// Persisted as its raw value, not the enum itself — same reasoning as `BoardsTabView`'s /// `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) } - /// 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. private var sortOrder: Binding { Binding( - get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual }, + get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent }, set: { sortOrderRaw = $0.rawValue } ) } @@ -83,9 +83,9 @@ struct BoardScreen: View { if case .ready = session.phase, let snapshot = session.snapshot, !snapshot.lanes.isEmpty { ToolbarItem(placement: .principal) { Picker("Sort", selection: sortOrder) { - Text("Manual").tag(ItemSortOrder.manual) - Text("Name").tag(ItemSortOrder.name) - Text("Recent").tag(ItemSortOrder.recent) + Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual) + Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name) + Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent) } .pickerStyle(.segmented) .frame(maxWidth: 260) diff --git a/KanbanMobile/Screens/BoardsTabView.swift b/KanbanMobile/Screens/BoardsTabView.swift index d160725..4e8c001 100644 --- a/KanbanMobile/Screens/BoardsTabView.swift +++ b/KanbanMobile/Screens/BoardsTabView.swift @@ -1,7 +1,8 @@ import SwiftUI /// 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 — /// 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 @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 /// 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 /// summary, and a successful move dismisses it before the stale copy could matter. @@ -25,6 +27,15 @@ struct BoardsTabView: View { NavigationStack { content .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 { if case .ready = index.phase { Button("New Board", systemImage: "plus") { @@ -61,13 +72,16 @@ struct BoardsTabView: View { .sheet(item: $boardInSettings) { board in 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. private var sortOrder: Binding { Binding( - get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .name }, + get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .recent }, set: { sortOrderRaw = $0.rawValue } ) } @@ -83,8 +97,8 @@ struct BoardsTabView: View { if case .ready = index.phase, !index.boards.isEmpty { ToolbarItem(placement: .principal) { Picker("Sort", selection: sortOrder) { - Text("Name").tag(BoardSortOrder.name) - Text("Recent").tag(BoardSortOrder.recent) + Label("Name", systemImage: "textformat.abc").tag(BoardSortOrder.name) + Label("Recent", systemImage: "clock").tag(BoardSortOrder.recent) } .pickerStyle(.segmented) .frame(maxWidth: 260) diff --git a/KanbanMobile/Screens/LaneScreen.swift b/KanbanMobile/Screens/LaneScreen.swift index fab62c6..9afc68d 100644 --- a/KanbanMobile/Screens/LaneScreen.swift +++ b/KanbanMobile/Screens/LaneScreen.swift @@ -20,15 +20,15 @@ struct LaneScreen: View { /// Persisted as its raw value, not the enum itself — same reasoning as `BoardsTabView`'s /// `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) } - /// 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. private var sortOrder: Binding { Binding( - get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual }, + get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent }, set: { sortOrderRaw = $0.rawValue } ) } @@ -98,9 +98,9 @@ struct LaneScreen: View { if let lane, !lane.cards.isEmpty { ToolbarItem(placement: .principal) { Picker("Sort", selection: sortOrder) { - Text("Manual").tag(ItemSortOrder.manual) - Text("Name").tag(ItemSortOrder.name) - Text("Recent").tag(ItemSortOrder.recent) + Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual) + Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name) + Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent) } .pickerStyle(.segmented) .frame(maxWidth: 260) diff --git a/KanbanMobile/Screens/SettingsScreen.swift b/KanbanMobile/Screens/SettingsScreen.swift index a870445..b4b673d 100644 --- a/KanbanMobile/Screens/SettingsScreen.swift +++ b/KanbanMobile/Screens/SettingsScreen.swift @@ -1,9 +1,11 @@ import SwiftUI import IndieAbout -/// The About section (indie-about skill). -struct SettingsTabView: View { +/// The About section (indie-about skill). Presented as a sheet from `BoardsTabView`'s gear +/// 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(\.dismiss) private var dismiss var body: some View { NavigationStack { @@ -19,9 +21,14 @@ struct SettingsTabView: View { } } .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } } // 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() } } } diff --git a/KanbanMobileUITests/BoardsNavigationUITests.swift b/KanbanMobileUITests/BoardsNavigationUITests.swift index c247777..ee35419 100644 --- a/KanbanMobileUITests/BoardsNavigationUITests.swift +++ b/KanbanMobileUITests/BoardsNavigationUITests.swift @@ -8,9 +8,9 @@ final class BoardsNavigationUITests: XCTestCase { func testBoardsNavigationAndTitleEdit() throws { let (app, root) = XCUIApplication.launchedWithFixtureBoard() - // Boards is the first tab (`RootTabView`), so no tab switch is needed here. The row's - // label is the flattened `BoardSummaryRow` — title plus the "N lanes · N cards · modified" - // subtitle `BoardIndexStore`'s first scan fills in. + // Boards is the app's root screen (`BoardsTabView`), so no navigation is needed to reach + // it. The row's label is the flattened `BoardSummaryRow` — title plus the "N lanes · N + // cards · modified" subtitle `BoardIndexStore`'s first scan fills in. let boardRow = app.element(labelContaining: RichBoard.title) XCTAssertTrue( boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout), diff --git a/KanbanMobileUITests/MobileUITestSupport.swift b/KanbanMobileUITests/MobileUITestSupport.swift index 5ea554f..2f40166 100644 --- a/KanbanMobileUITests/MobileUITestSupport.swift +++ b/KanbanMobileUITests/MobileUITestSupport.swift @@ -156,9 +156,10 @@ extension XCUIApplication { /// 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 — - /// same word, entirely different destination — and an unscoped `buttons["Settings"]` is a coin - /// flip between them. + /// The label is scoped to the list because the boards screen's own nav bar carries a + /// "Settings" button of its own — the leading gear button that presents `SettingsScreen` as a + /// sheet, same word, entirely different destination — and an unscoped `buttons["Settings"]` is + /// a coin flip between them. @MainActor func openBoardSettings(for title: String) { let row = boardRow(title) diff --git a/KanbanMobileUITests/SettingsAboutUITests.swift b/KanbanMobileUITests/SettingsAboutUITests.swift index fac66c4..609eed2 100644 --- a/KanbanMobileUITests/SettingsAboutUITests.swift +++ b/KanbanMobileUITests/SettingsAboutUITests.swift @@ -1,6 +1,6 @@ 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. final class SettingsAboutUITests: XCTestCase { @@ -8,7 +8,7 @@ final class SettingsAboutUITests: XCTestCase { func testSettingsAboutSection() throws { 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 // 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") // 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) versionLink.tap() let changelogText = app.element(labelContaining: "August 2026") @@ -40,7 +40,10 @@ final class SettingsAboutUITests: XCTestCase { changelogText.waitForExistence(timeout: XCUIApplication.uiTimeout), "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. app.scrollUntilHittable(licenseLink) @@ -50,6 +53,7 @@ final class SettingsAboutUITests: XCTestCase { licenseText.waitForExistence(timeout: XCUIApplication.uiTimeout), "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() } }