PlanBoard — Implementation Guide

Total estimated time: 80–100 hours. The Mac shell alone consumes a large chunk.

Week 1 — Foundation

Day 1. Project setup

Xcode → New → Multiplatform → App. Two targets: PlanBoard-iOS, PlanBoard-macOS.

Add SwiftPM packages: PlanBoardCore, PlanBoardShared, PlanBoardiOS, PlanBoardMac, PlanBoardIntents.

Day 2. SwiftData schema

Implement Board, Column, Card per architecture.md. Configure container with CloudKit.

@main struct PlanBoardApp: App {
    let container: ModelContainer = {
        let config = ModelConfiguration(
            cloudKitDatabase: .private("iCloud.com.yourorg.planboard")
        )
        return try! ModelContainer(for: Board.self, Column.self, Card.self,
                                  configurations: config)
    }()
    var body: some Scene {
        WindowGroup { RootView() }.modelContainer(container)
    }
}

Checkpoint: launch on both targets. Create a Board in code. It persists and survives relaunch.

Day 3. Shared views

Build CardView, ColumnView, BoardView in PlanBoardShared. Each takes a SwiftData model via @Bindable and looks reasonable on all platforms.

Checkpoint: in a SwiftUI preview, CardView(card: …) renders identically across iOS Preview and macOS Preview.

Week 1 — iOS shell

Day 4. iPhone navigation

// PlanBoardiOS/RootView.swift
public struct RootView: View {
    @Query(sort: \Board.sortOrder) var boards: [Board]
    public init() {}
    public var body: some View {
        NavigationStack {
            BoardListView(boards: boards)
                .navigationTitle("PlanBoard")
        }
    }
}

Day 5. iPad split view

#if os(iOS)
public struct iPadRootView: View {
    @State private var selectedBoard: Board?
    @State private var selectedCard: Card?
    @Query var boards: [Board]
    public var body: some View {
        NavigationSplitView {
            BoardListView(boards: boards, selection: $selectedBoard)
        } content: {
            if let board = selectedBoard { BoardView(board: board, selection: $selectedCard) }
        } detail: {
            if let card = selectedCard { CardInspector(card: card) }
        }
    }
}
#endif

Dispatch in RootView:

#if os(iOS)
@Environment(\.horizontalSizeClass) var sizeClass
public var body: some View {
    if sizeClass == .regular {
        iPadRootView()
    } else {
        iPhoneRootView()
    }
}
#endif

Checkpoint: app feels right on iPhone (stack) and iPad (3-pane).

Week 2 — Mac shell

Day 6. Mac root + toolbar

// PlanBoardMac/RootView.swift
public struct MacRootView: View {
    @State private var selectedBoard: Board?
    @State private var selectedCard: Card?
    @Query var boards: [Board]

    public var body: some View {
        NavigationSplitView {
            BoardListView(boards: boards, selection: $selectedBoard)
                .navigationSplitViewColumnWidth(min: 200, ideal: 240)
        } content: {
            if let b = selectedBoard {
                BoardView(board: b, selection: $selectedCard)
                    .navigationSplitViewColumnWidth(min: 600, ideal: 800)
            } else {
                ContentUnavailableView("Select a board", systemImage: "rectangle.3.group")
            }
        } detail: {
            if let c = selectedCard { CardInspector(card: c) } else { Color.clear }
        }
        .navigationSplitViewStyle(.balanced)
        .toolbar { MacToolbar() }
    }
}

struct MacToolbar: ToolbarContent {
    var body: some ToolbarContent {
        ToolbarItemGroup {
            Button { /* new card */ } label: { Label("New Card", systemImage: "plus") }
            Button { /* sync */ } label: { Label("Sync", systemImage: "arrow.triangle.2.circlepath") }
        }
    }
}

Day 7. CommandMenu

@main struct PlanBoardMacApp: App {
    var body: some Scene {
        WindowGroup { MacRootView() }
            .commands {
                CommandGroup(replacing: .newItem) {
                    Button("New Card") { /* dispatch */ }.keyboardShortcut("n")
                    Button("New Board") { /* dispatch */ }.keyboardShortcut("n", modifiers: [.command, .shift])
                }
                CommandMenu("Sync") {
                    Button("Sync Now") { /* sync */ }.keyboardShortcut("r")
                }
                CommandGroup(after: .toolbar) {
                    Button("Toggle Inspector") { /* dispatch */ }.keyboardShortcut("i")
                }
            }
    }
}

Day 8. Multi-window

WindowGroup(id: "board", for: UUID.self) { $boardID in
    if let id = boardID {
        BoardWindowView(boardID: id)
    }
}

Open new window for a board:

@Environment(\.openWindow) var openWindow
openWindow(id: "board", value: board.id)

Day 9. Menu bar item

MenuBarExtra("PlanBoard", systemImage: "rectangle.3.group") {
    MenuBarContent()
}
.menuBarExtraStyle(.window)

MenuBarContent shows top 3 cards of the primary board with quick-add input.

Checkpoint: Mac app passes the “Mac-app smell test.” Sidebar collapses with ⌘0. ⌘N creates a card. Menu bar item works. Multiple board windows can be open simultaneously.

Week 2 — Widget

Day 10. iOS widget extension

struct PlanBoardWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "PlanBoardTop3", provider: TopCardsProvider()) { entry in
            TopCardsView(entry: entry)
        }
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

struct TopCardsProvider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
        let cards = SharedStorage.topCards(count: 3)
        completion(Timeline(entries: [Entry(date: .now, cards: cards)], policy: .after(.now.addingTimeInterval(1800))))
    }
}

Share data via App Group so the widget reads the SwiftData container.

Checkpoint: Add widget on iOS home screen. Shows current top 3 cards. Move a card in the app — widget updates within minutes (or call WidgetCenter.shared.reloadAllTimelines() for instant update).

Week 2 — AppIntents

Day 11. AppIntents package

public struct AddCardIntent: AppIntent {
    public static var title: LocalizedStringResource = "Add Card"
    @Parameter(title: "Title") public var title: String
    @Parameter(title: "Board") public var board: BoardEntity?

    public init() {}

    @MainActor
    public func perform() async throws -> some IntentResult & ReturnsValue<CardEntity> {
        let card = try await BoardStore.shared.addCard(title: title, to: board?.id)
        return .result(value: CardEntity(card))
    }
}

Verify in Shortcuts on iOS, iPadOS, macOS.

Checkpoint: Shortcuts on Mac shows “Add Card to PlanBoard” — runs without launching the app.

Week 3 — Polish + ship

Day 12–14. Cross-platform polish

  • Drag-and-drop card across columns (works on all three; macOS needs slight different gesture sensitivity)
  • Apple Pencil scribble in card detail (iPad only)
  • VoiceOver across all platforms
  • Universal clipboard from iOS to macOS (free with system; verify it works for “copy card link, paste on Mac”)

Day 15–17. Maintain platform-decision-record.md

For every #if os(...) block you’ve added, write the rationale. Done at write time, not after — that’s the point.

Day 18–21. App Store submission for both platforms

  • Separate App Store Connect records for iOS app and macOS app
  • Shared bundle ID across platforms is fine
  • Mac app needs notarization (Fastlane gym handles it)
  • Screenshots: 6.7“ + 6.1“ + iPad 12.9“ + Mac 16:10 (e.g., 1280x800 or 2560x1600)
  • App Preview videos optional but recommended for the Mac listing

Next: Hardening checklist