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
gymhandles 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