GiveHub Check-In iPad App — Keep Existing UI, Add Native Silent Printing
We do not want to rebuild the whole check-in feature for iPad.
We already have a strong check-in UI/flow. The goal is to reuse the existing check-in frontend and make it work on iPad with silent Brother printing.
Goal
Keep the existing GiveHub check-in UI and logic as intact as possible, but package it inside a native iPad app that adds:
-
silent printing to Brother QL-820NWB
-
no iOS print dialog
-
saved/default printer per iPad
-
optional QR scanning
-
kiosk-style app behavior
-
Required architecture
Build a native iPad wrapper app in Swift/SwiftUI that uses a WKWebView to load the existing GiveHub check-in web experience.
The native app should be responsible for:
-
loading the existing check-in UI in a webview
-
storing station/device config locally
-
storing the selected Brother printer locally
-
receiving “print this label now” commands from the web layer
-
printing silently through Brother’s native iOS SDK
-
handling printer status/errors
-
optionally handling camera QR scanning later
The existing GiveHub web check-in UI should remain responsible for:
-
phone number entry
-
family lookup
-
child selection
-
check-in flow
-
existing validation
-
existing screen design and UX
-
calling backend APIs as it already does
-
Important
Do not rebuild the current check-in screens in native unless absolutely necessary.
Instead, use a hybrid structure:
Native iPad app shell
→ hosts existing GiveHub check-in UI in WKWebView
→ web UI sends print requests to native bridge
→ native code prints silently to Brother printer
Why this approach
Brother’s official mobile SDK supports the QL-820NWB and supports Wi-Fi printing on iOS. Brother’s iOS setup docs also say that if we do not use Bluetooth Classic, we can ship using the WLAN-only framework path and do not need a Product Plan ID (PPID) for App Store publishing. That makes Wi-Fi-first the correct first implementation.
V1 recommendation
Use:
-
native iPad shell
-
WKWebView for the current GiveHub UI
-
Brother iOS SDK
-
Wi-Fi printing only
-
QL-820NWB as the initial supported printer
-
backend-rendered label images for printing
Do not use:
-
AirPrint
-
Safari printing
-
browser print dialogs
-
share-sheet printing
Web-to-native bridge
Use a WKWebView JavaScript bridge.
The web app should call a native message handler when it needs printing.
Example concept:
In web layer
When a check-in completes successfully, the web UI sends a message like:
{
"type": "printLabels",
"sessionId": "chk_123",
"labels": [
{
"labelId": "lbl_1",
"imageUrl": "https://.../lbl_1.png",
"sequence": 1
},
{
"labelId": "lbl_2",
"imageUrl": "https://.../lbl_2.png",
"sequence": 2
}
]
}
In native layer
The native app listens for this bridge message, downloads the label images, and prints them silently through Brother SDK to the saved printer.
Use Apple’s WKWebView script message handler pattern for this bridge.
Native responsibilities
Leap should build these native-only pieces:
1. Native app shell
-
Swift / SwiftUI iPad app
-
launches directly into GiveHub check-in web UI
-
kiosk-safe app wrapper
2. WKWebView bridge
-
exposes native handlers like:
-
printLabels
-
printerStatus
-
configurePrinter
-
testPrint
-
-
sends results back to web UI if needed
3. Printer setup screen
A hidden/admin-only native screen where staff can:
-
choose or manually enter printer IP
-
save default printer
-
run test print
-
assign station name
-
confirm printer status
4. Brother print service
Native service that:
-
uses Brother iOS SDK
-
connects by Wi-Fi to saved QL-820NWB
-
prints label images silently
-
checks printer state
-
returns structured errors
5. Optional QR scanner later
Can be native camera scanning that passes token into the existing web flow.
Existing web UI changes only
We want minimal changes to the existing check-in frontend.
Leap should only add the minimum needed to support the native bridge:
Add a small platform adapter in the web app
The existing web app should detect:
-
if running in normal browser → keep current behavior
-
if running inside GiveHub iPad app → send print requests to native instead of browser print flow
Example:
if GiveHubNativeApp exists:
send labels to native bridge
else:
use existing browser/desktop print behavior
Best implementation pattern
Create a tiny frontend wrapper like:
-
frontend/lib/native-checkin-bridge.ts
Responsibilities:
-
detect native iPad shell
-
send print payload to native
-
optionally ask native for printer status
-
gracefully fall back to web behavior outside app
Printer payload format
Leap should standardize a simple JSON payload from web → native:
{
"type": "printLabels",
"jobId": "printjob_123",
"sessionId": "chk_123",
"labels": [
{
"labelId": "lbl_child_1",
"labelType": "child",
"imageUrl": "https://api.givehub.com/labels/lbl_child_1.png",
"sequence": 1,
"copies": 1
},
{
"labelId": "lbl_guardian_1",
"labelType": "guardian",
"imageUrl": "https://api.givehub.com/labels/lbl_guardian_1.png",
"sequence": 2,
"copies": 1
}
]
}
Native prints in sequence order.
Label rendering
Do not rebuild label layout in the iPad app for V1.
Instead:
-
keep label rendering on backend
-
backend returns final Brother-ready image URLs
-
native app prints those images through Brother SDK
This keeps all current label logic centralized and avoids duplicating label layout rules in Swift.
Native file/module suggestions
Suggested native files:
iPadApp/
App/
GiveHubCheckInApp.swift
AppCoordinator.swift
Web/
CheckInWebView.swift
NativeBridgeController.swift
NativeBridgeModels.swift
Printer/
LabelPrinterService.swift
BrotherWiFiPrinterService.swift
PrinterSetupView.swift
PrinterConfigStore.swift
PrinterStatusModels.swift
Admin/
StationSettingsView.swift
HiddenAdminEntryView.swift
Web file/module suggestions
Suggested minimal web additions:
frontend/lib/native-checkin-bridge.ts
frontend/lib/platform-detect.ts
frontend/features/checkin/print-via-native.ts
Native bridge methods
Please expose native methods like:
-
printLabels(payload)
-
getPrinterStatus()
-
testPrint()
-
openPrinterSetup()
Optional callback/event messages back to web:
-
printSuccess
-
printFailure
-
printerOffline
Printer setup
Printer setup should happen once per iPad.
Each iPad should store:
-
orgId
-
stationId
-
station name
-
printer model
-
printer IP
-
label size
-
mode
Example local config:
{
"orgId": 1,
"stationId": "ipad-kiosk-01",
"stationName": "Lobby Kiosk 1",
"printer": {
"model": "Brother QL-820NWB",
"connectionType": "wifi",
"ipAddress": "192.168.1.88",
"labelSize": "62mm"
}
}
Error handling
If printer fails, native app should show a simple native overlay or return an error to the web UI.
Need clean handling for:
-
printer offline
-
out of labels
-
cover open
-
network timeout
-
partial print failure
Acceptance criteria
This project is successful if:
-
The existing GiveHub check-in UI still runs with minimal changes.
-
On iPad, after a successful check-in, labels print instantly with no iOS print dialog.
-
The printer is configured once and reused automatically.
-
The same web check-in flow can still run in browser outside the app.
-
Native code handles Brother printing; web code does not.
-
We do not duplicate the entire check-in feature in Swift.
Summary
We want a hybrid iPad app, not a rewrite.
Keep:
-
current check-in UI
-
current backend flows
-
current family/child logic
-
current label generation
Add:
-
native iPad shell
-
WKWebView bridge
-
Brother SDK printing
-
saved printer per device
-
silent printing
Do not do:
-
full native rewrite of the check-in feature
-
browser printing on iPad
-
AirPrint-based solution
GiveHub iPad App — WKWebView + Native Print Bridge (for Leap)
Objective
Embed the existing GiveHub Check-In web UI inside a native iPad app and allow the web layer to trigger silent Brother label printing via a native bridge.
File: CheckInWebView.swift
import SwiftUI import WebKit struct CheckInWebView: UIViewRepresentable { let url: URL func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() let contentController = WKUserContentController() // Register message handlers (web → native) contentController.add(context.coordinator, name: "printLabels") contentController.add(context.coordinator, name: "getPrinterStatus") config.userContentController = contentController let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = context.coordinator webView.load(URLRequest(url: url)) return webView } func updateUIView(_ webView: WKWebView, context: Context) {} // MARK: - Coordinator (Bridge Handler) class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { private let printerService = BrotherWiFiPrinterService() // Receive messages from web app func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { switch message.name { case "printLabels": guard let payload = message.body as? [String: Any] else { return } handlePrintLabels(payload: payload) case "getPrinterStatus": handlePrinterStatus() default: break } } // MARK: - Print Handler private func handlePrintLabels(payload: [String: Any]) { guard let labels = payload["labels"] as? [[String: Any]] else { return } Task { do { let jobs = try await downloadLabels(labels: labels) try await printerService.printLabels(jobs) print("✅ Print success") } catch { print("❌ Print error: \(error)") // TODO: send error back to web layer if needed } } } // MARK: - Download Labels private func downloadLabels(labels: [[String: Any]]) async throws -> [PrintLabelJob] { var jobs: [PrintLabelJob] = [] // Sort by sequence to ensure correct print order let sortedLabels = labels.sorted { ($0["sequence"] as? Int ?? 0) < ($1["sequence"] as? Int ?? 0) } for label in sortedLabels { guard let urlString = label["imageUrl"] as? String, let url = URL(string: urlString), let labelId = label["labelId"] as? String else { continue } let (data, _) = try await URLSession.shared.data(from: url) let job = PrintLabelJob( labelId: labelId, labelType: label["labelType"] as? String ?? "", imageData: data, sequence: label["sequence"] as? Int ?? 0 ) jobs.append(job) } return jobs } // MARK: - Printer Status (optional) private func handlePrinterStatus() { Task { let status = await printerService.getStatus() print("Printer status: \(status)") // TODO: optionally send back to web layer } } } }
Supporting Model: PrintLabelJob.swift
struct PrintLabelJob { let labelId: String let labelType: String let imageData: Data let sequence: Int }
Printer Service Stub: BrotherWiFiPrinterService.swift
class BrotherWiFiPrinterService { func printLabels(_ labels: [PrintLabelJob]) async throws { let sorted = labels.sorted { $0.sequence < $1.sequence } for label in sorted { try await printSingle(label) } } func printSingle(_ job: PrintLabelJob) async throws { // TODO: Integrate Brother iOS SDK here // Steps: // 1. Connect to printer via saved IP // 2. Convert imageData to printable format // 3. Send to printer // 4. Handle errors (offline, no media, etc.) print("Printing label: \(job.labelId)") } func getStatus() async -> String { // TODO: Return real printer status return "connected" } }
Web Integration (VERY IMPORTANT)
Add this helper in your frontend:
export function printLabelsNative(payload: any) { if ((window as any).webkit?.messageHandlers?.printLabels) { (window as any).webkit.messageHandlers.printLabels.postMessage(payload) } else { console.log("Native bridge not available — fallback to web print") } }
Example Payload from Web
{ "type": "printLabels", "sessionId": "chk_123", "labels": [ { "labelId": "lbl_child_1", "labelType": "child", "imageUrl": "https://api.givehub.com/labels/lbl1.png", "sequence": 1 }, { "labelId": "lbl_guardian_1", "labelType": "guardian", "imageUrl": "https://api.givehub.com/labels/lbl2.png", "sequence": 2 } ] }
Summary for Leap
-
Do NOT rebuild check-in UI
-
Load existing GiveHub check-in in WKWebView
-
Use WKScriptMessageHandler bridge
-
Web sends printLabels message
-
Native downloads label images
-
Native prints via Brother SDK
-
No AirPrint, no dialogs
Success Criteria
-
Tap “Check In” in web UI
-
Labels print instantly on iPad
-
No iOS print popup
-
Same web UI works unchanged outside the app
A few important notes for Leap first:
-
The QL-820NWB/QL-820NWBc is supported by Brother’s Print SDK for image/PDF printing over Wi-Fi, Bluetooth, and USB.
-
Brother’s current iOS docs show the core flow as: create a BRLMChannel with the printer IP, call BRLMPrinterDriverGenerator.open(...), create BRLMQLPrintSettings, set the QL label size, then call printImageWithURL.
-
For a Wi-Fi-only app, Brother says you can use Net/BRLMPrinterKit.xcframework; for Xcode 12+ network printer apps, add NSLocalNetworkUsageDescription and Bonjour services entries to Info.plist.
Brother SDK setup for Leap
1) Xcode / framework setup
Use the Wi-Fi-only framework path:
Net/BRLMPrinterKit.xcframework
Do not start with Bluetooth. Brother’s docs explicitly allow the Wi-Fi-only framework path for apps that do not use Bluetooth Classic.
2) Info.plist
Add these keys for local network discovery / network printer access on iOS:
<key>NSLocalNetworkUsageDescription</key>
<string>GiveHub needs local network access to connect to Brother label printers on your church network.</string>
<key>NSBonjourServices</key>
<array>
<string>_pdl-datastream._tcp</string>
<string>_printer._tcp</string>
<string>_ipp._tcp</string>
</array>
That matches Brother’s iOS setup guidance for network printers.
File: BrotherWiFiPrinterService.swift
import Foundation
import UIKit
import BRLMPrinterKit
enum PrinterServiceError: LocalizedError {
case missingPrinterIP
case invalidImageData
case failedToWriteTempFile
case openChannelFailed(code: Int)
case printFailed(code: Int, message: String)
case unsupportedLabelSize
case unknownStatus(String)
var errorDescription: String? {
switch self {
case .missingPrinterIP:
return "Missing printer IP address."
case .invalidImageData:
return "Invalid label image data."
case .failedToWriteTempFile:
return "Failed to write temporary label image file."
case .openChannelFailed(let code):
return "Could not open Brother printer channel. Code: \(code)"
case .printFailed(let code, let message):
return "Brother print failed. Code: \(code). \(message)"
case .unsupportedLabelSize:
return "Unsupported Brother label size."
case .unknownStatus(let message):
return message
}
}
}
struct PrinterConfig: Codable {
let model: String
let ipAddress: String
let labelSize: String // ex: "62mm"
}
struct PrintLabelJob {
let labelId: String
let labelType: String
let imageData: Data
let sequence: Int
}
struct PrinterStatusSummary: Codable {
let connected: Bool
let message: String
}
final class BrotherWiFiPrinterService {
private let configStore = PrinterConfigStore()
// MARK: - Public API
func printLabels(_ labels: [PrintLabelJob]) async throws {
let sorted = labels.sorted { $0.sequence < $1.sequence }
for label in sorted {
try await printSingle(label)
}
}
func printSingle(_ job: PrintLabelJob) async throws {
let printerConfig = try loadPrinterConfig()
// 1) Save image to a temp file because Brother's API prints from URL
let imageURL = try writeTempImage(data: job.imageData, labelId: job.labelId)
// 2) Open printer channel over Wi-Fi
let channel = BRLMChannel(wifiIPAddress: printerConfig.ipAddress)
let generateResult = BRLMPrinterDriverGenerator.open(channel)
guard generateResult.error.code == BRLMOpenChannelErrorCode.noError,
let printerDriver = generateResult.driver else {
throw PrinterServiceError.openChannelFailed(
code: Int(generateResult.error.code.rawValue)
)
}
defer {
printerDriver.closeChannel()
try? FileManager.default.removeItem(at: imageURL)
}
// 3) Create QL print settings
let settings = BRLMQLPrintSettings(
defaultPrintSettingsWithPrinterModel: mapQLPrinterModel(printerConfig.model)
)
// 4) Set label size
settings.labelSize = try mapQLLabelSize(printerConfig.labelSize)
// Optional but recommended defaults for kiosk labels
// Exact property names can vary slightly by SDK version.
// If a property name differs in your installed framework, adapt to the SDK's current symbol.
settings.autoCut = true
settings.halftone = .threshold
settings.scaleMode = .fitPaperAspect
// 5) Print
let printError = printerDriver.printImage(with: imageURL, settings: settings)
if printError.code != BRLMPrintErrorCode.noError {
throw PrinterServiceError.printFailed(
code: Int(printError.code.rawValue),
message: printError.description ?? "Unknown Brother print error"
)
}
}
func getStatus() async -> PrinterStatusSummary {
do {
let printerConfig = try loadPrinterConfig()
let channel = BRLMChannel(wifiIPAddress: printerConfig.ipAddress)
let generateResult = BRLMPrinterDriverGenerator.open(channel)
guard generateResult.error.code == BRLMOpenChannelErrorCode.noError,
let printerDriver = generateResult.driver else {
return PrinterStatusSummary(
connected: false,
message: "Printer not reachable. Open channel failed."
)
}
defer { printerDriver.closeChannel() }
// Depending on SDK version, a richer status API may be available.
// For V1, successful channel open is already a useful health check.
return PrinterStatusSummary(
connected: true,
message: "Printer reachable."
)
} catch {
return PrinterStatusSummary(
connected: false,
message: error.localizedDescription
)
}
}
// MARK: - Helpers
private func loadPrinterConfig() throws -> PrinterConfig {
guard let config = configStore.load() else {
throw PrinterServiceError.missingPrinterIP
}
return config
}
private func writeTempImage(data: Data, labelId: String) throws -> URL {
guard UIImage(data: data) != nil else {
throw PrinterServiceError.invalidImageData
}
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("label_\(labelId).png")
do {
try data.write(to: tempURL, options: .atomic)
return tempURL
} catch {
throw PrinterServiceError.failedToWriteTempFile
}
}
private func mapQLPrinterModel(_ model: String) -> BRLMPrinterModel {
// Start with the printer we actually support.
// Add more models later if needed.
switch model {
case "Brother QL-820NWB", "QL-820NWB":
return .QL_820NWB
default:
return .QL_820NWB
}
}
private func mapQLLabelSize(_ labelSize: String) throws -> BRLMQLPrintSettingsLabelSize {
switch labelSize {
case "62mm":
// Verify exact enum name in installed SDK.
// Brother docs show that PT/QL image printing requires setting the paper/label size.
return .dieCutRoll62x100
default:
throw PrinterServiceError.unsupportedLabelSize
}
}
}
File: PrinterConfigStore.swift
import Foundation
final class PrinterConfigStore {
private let key = "givehub.printer.config"
func save(_ config: PrinterConfig) {
if let data = try? JSONEncoder().encode(config) {
UserDefaults.standard.set(data, forKey: key)
}
}
func load() -> PrinterConfig? {
guard let data = UserDefaults.standard.data(forKey: key) else {
return nil
}
return try? JSONDecoder().decode(PrinterConfig.self, from: data)
}
func clear() {
UserDefaults.standard.removeObject(forKey: key)
}
}
File: NativeBridgeController.swift
This is the bridge piece that receives the web payload and hands it to the Brother service.
import Foundation
import WebKit
final class NativeBridgeController: NSObject, WKScriptMessageHandler {
private let printerService = BrotherWiFiPrinterService()
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
switch message.name {
case "printLabels":
guard let payload = message.body as? [String: Any] else { return }
handlePrintLabels(payload)
case "getPrinterStatus":
Task {
let status = await printerService.getStatus()
print("Printer status: \(status)")
}
default:
break
}
}
private func handlePrintLabels(_ payload: [String: Any]) {
guard let rawLabels = payload["labels"] as? [[String: Any]] else { return }
Task {
do {
let jobs = try await downloadLabels(rawLabels)
try await printerService.printLabels(jobs)
print("✅ Brother print success")
} catch {
print("❌ Brother print failed: \(error.localizedDescription)")
// Optional: send failure event back into JS here
}
}
}
private func downloadLabels(_ rawLabels: [[String: Any]]) async throws -> [PrintLabelJob] {
let sorted = rawLabels.sorted {
($0["sequence"] as? Int ?? 0) < ($1["sequence"] as? Int ?? 0)
}
var jobs: [PrintLabelJob] = []
for label in sorted {
guard
let urlString = label["imageUrl"] as? String,
let url = URL(string: urlString),
let labelId = label["labelId"] as? String
else { continue }
let (data, _) = try await URLSession.shared.data(from: url)
jobs.append(
PrintLabelJob(
labelId: labelId,
labelType: label["labelType"] as? String ?? "",
imageData: data,
sequence: label["sequence"] as? Int ?? 0
)
)
}
return jobs
}
}
File: CheckInWebView.swift
import SwiftUI
import WebKit
struct CheckInWebView: UIViewRepresentable {
let url: URL
let bridgeController = NativeBridgeController()
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()
contentController.add(bridgeController, name: "printLabels")
contentController.add(bridgeController, name: "getPrinterStatus")
config.userContentController = contentController
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {}
}
Minimal frontend bridge
Your existing check-in UI only needs a small adapter like this:
export function printLabelsNative(payload: any) {
const bridge = (window as any)?.webkit?.messageHandlers?.printLabels
if (bridge) {
bridge.postMessage(payload)
return
}
console.log("Native Brother bridge unavailable; use existing web fallback.")
}
Example payload from web → native
{
"type": "printLabels",
"sessionId": "chk_123",
"labels": [
{
"labelId": "lbl_child_1",
"labelType": "child",
"imageUrl": "https://api.givehub.com/labels/lbl_child_1.png",
"sequence": 1
},
{
"labelId": "lbl_guardian_1",
"labelType": "guardian",
"imageUrl": "https://api.givehub.com/labels/lbl_guardian_1.png",
"sequence": 2
}
]
}
Important notes for Leap
Brother’s official docs confirm:
-
use BRLMChannel(wifiIPAddress: ...)
-
open with BRLMPrinterDriverGenerator.open(...)
-
use BRLMQLPrintSettings
-
set the QL paper/label size before printing
-
print with printImageWithURL / Swift equivalent.
For printer discovery later, Brother also provides Wi-Fi discovery APIs via BRLMPrinterSearcher.startNetworkSearch(...), which would be useful for a hidden admin printer setup screen.
One thing Leap should verify
The exact enum names for:
-
BRLMPrinterModel.QL_820NWB
-
BRLMQLPrintSettingsLabelSize.dieCutRoll62x100
-
settings.autoCut
-
settings.scaleMode
-
settings.halftone
Those can differ slightly by SDK version, so the pattern above is correct, but the symbol names should be matched to the installed Brother framework headers/manual. The overall flow is straight from Brother’s official SDK docs.
