Appendix T: ReportMaker (Shop Manual)

Status: In development, distribution-ready binary. GPL v3. Public repo at github.com/fluhartyml/ReportMaker. Build-Along chapter: Part VII Build-Along 11. ReportMaker is a focused business-expense tracker for one job: capture spending during a trip, then produce a clean PDF the user can email to whoever reimburses them. The teaching value of the app is the bundle — Swift Charts + PDFKit + PhotosUI + SwiftData appearing together in one product instead of being demoed in isolation across four chapters.

What it is

The user creates a Trip with a name, dates, and a purpose. Inside that trip they add Expenses one at a time — date, category, vendor, amount, optional receipt photo. The trip's detail view shows a running total, a breakdown by category as a Swift Charts BarMark, and a per-day spend line when the trip spans more than one day. Tapping Generate Report produces a multi-page PDF (cover sheet + line-item table + one page per receipt photo) and presents the system share sheet so the user can email it.

Apple does not ship one of these. Expensify, SAP Concur, and FreshBooks do it for a monthly fee. ReportMaker is the small, native, no-account-required alternative.

Architecture overview

SwiftUI + SwiftData app with three optional frameworks layered in: Swift Charts (dashboard), PDFKit (report), PhotosUI (receipt capture). Twelve Swift files total.

Data model

@Model
final class Trip {
    var name: String
    var startDate: Date
    var endDate: Date
    var purpose: String

    @Relationship(deleteRule: .cascade) var expenses: [Expense]

    init(/* ... */) { /* ... */ }
}

@Model
final class Expense {
    var date: Date
    var vendor: String
    var amount: Double
    var categoryRaw: String       // ExpenseCategory raw String value
    var receiptImageData: Data?
    var trip: Trip?

    var category: ExpenseCategory {
        get { ExpenseCategory(rawValue: categoryRaw) ?? .other }
        set { categoryRaw = newValue.rawValue }
    }
}

enum ExpenseCategory: String, CaseIterable {
    case meals, lodging, transit, supplies, entertainment, other
}

The CloudKit-safe enum pattern

Note that Expense stores categoryRaw: String, not category: ExpenseCategory. CloudKit doesn't sync Swift enums directly — it syncs String. The model stores the raw value, and a computed category property wraps the enum back on top for Swift code to read. This is the canonical pattern for any SwiftData-with-CloudKit app that wants enums in its public API but needs the underlying storage to round-trip through CloudKit.

Swift Charts dashboard (Book 17 reference)

Two charts on the trip dashboard, both pure Swift Charts — no third-party library, no canvas drawing, no UIKit fallback:

The data shape Charts wants is one row per data point. The Trip model exposes totalsByCategory and totalsByDay as computed arrays of tuples that the chart consumes directly via Chart(data, id: \.0).

PDFKit pipeline (Book 17 reference)

ReportPDFRenderer drives a multi-page PDF with UIGraphicsPDFRenderer:

  1. Cover page — title, dates, purpose, total, category breakdown summary.
  2. Expenses page — per-line table with date / category / vendor / amount columns.
  3. Receipt pages — one page per receipt photo at the back.

Each page is drawn with NSAttributedString placement on a CGContext. No HTML-to-PDF conversion, no UIView snapshotting — direct text and image draws. This is the canonical Apple approach for native PDF generation; every "Export PDF" feature in iOS apps reaches for the same building blocks.

Receipt capture — PhotosPicker

Adding a receipt is a PhotosPicker bound to a Data binding. The picker returns a PhotosPickerItem; the code asks for its Transferable data and stores the bytes directly in the Expense model's receiptImageData field. No NSPhotoLibraryUsageDescription string required — PhotosPicker is the user-mediated pattern.

Storing image data inside the model keeps the demo simple. A production-scale app with serious receipt volume would offload images to the file system (or to CKAsset for CloudKit) and hold only a URL on the @Model — image bytes in SwiftData blow up the store fast.

Share pipeline

iOS doesn't yet ship a SwiftUI-native share sheet for arbitrary file URLs, so ShareSheet wraps UIActivityViewController in UIViewControllerRepresentable. Tapping Generate Report writes the PDF to the temporary directory and shows the share sheet, which lets the user email, AirDrop, or save the file to Files.app.

About icon fix (2026-05-10)

The About page used to render a blank space where the app icon should appear. The cause: Image("icon-light-1024") tried to load an asset from AppIcon.appiconset, but Asset Catalog AppIcon images aren't addressable by filename in SwiftUI. The fix: add an AboutIcon.imageset that copies the same light + dark 1024 PNGs and reference it as Image("AboutIcon"). This is the canonical Apple pattern — any image you want to Image(...) from SwiftUI needs to live in its own non-AppIcon imageset.

Build + deployment

Known gotchas

Sources