- Swift 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
PlatformAPI had a `baseURL` fixed to development, used by nothing. A constant with that name sitting among the endpoint definitions is a trap: the next call site to reach for it would have talked to dev.numuhq.tech from the App Store build, and nothing would have said so. Gone, with a note where it was. Every endpoint is a path with no host in it, and the single `#if DEV_PLATFORM` in AppContainer is the only thing that picks a host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| NUMUFieldApp | ||
| NUMUFieldAppUITests | ||
| Packages | ||
| .gitignore | ||
| project.yml | ||
| README.md | ||
Okhama — NUMU CX Field App
Shift control and badge data transfer for frontline retail workers. Native iOS/iPadOS, SwiftUI, iPad-first, built on a small unidirectional-data-flow runtime and split into Swift packages.
Setup
The Xcode project is generated, not checked in. project.yml is the source
of truth; NUMUFieldApp.xcodeproj is built from it by
XcodeGen and is in .gitignore. A
fresh clone has no project file until you generate one.
brew install xcodegen
xcodegen generate && open NUMUFieldApp.xcodeproj
Run xcodegen generate again after pulling changes that touch project.yml,
and after adding or deleting source files — they are picked up from the
directory tree, so no project file needs editing by hand.
Changes made through Xcode's UI are discarded by the next generate. Signing
team, capabilities, build settings, Info.plist keys and the version number live
in project.yml. Editing the generated project instead is the one mistake this
layout invites: a version set in Xcode's General tab reverts silently, and the
archive goes out with the old one.
The signing team is the usual way to meet that mistake, so it is set once at the
top of project.yml under settings.base rather than on a target — every
target has to carry it, the UI test bundle included, because that bundle is
signed too and both schemes build it. A team set on the app target alone leaves
the test target unsigned, and Xcode asks for one on every build. After editing
it, run xcodegen generate: until you do, the project still holds the old value
and the prompt keeps coming back.
Requirements: a Mac with Homebrew and Xcode 16 or later (the suites use Swift Testing); developed against Xcode 27.0. Deployment target is iOS 17.0. No other dependencies — every package is local.
Apps, schemes and configurations
Two apps are built from this one target, and each is built two ways. That is two dimensions, so six configurations and four schemes — and every name states both, because the one thing worse than six configurations is three that mean different things depending on what you remember.
| App | Bundle id | Platform | App Store Connect |
|---|---|---|---|
| Okhama | com.numuhq.fieldapp |
numuhq.tech |
the live record |
| NUMU Frontliner | com.numuhq.frontliner |
dev.numuhq.tech |
its own record |
They are separate apps on purpose: separate bundle ids mean separate records,
separate TestFlight, and — the point — both installed on one device at once,
with their own keychain items, their own UserDefaults and their own
Documents. They share an icon, so the home-screen label is what tells them
apart.
| Build kind | What it is |
|---|---|
Debug |
Unoptimised, for Xcode. |
Beta |
Optimised, plus INTERNAL_TOOLS: the debug switcher, the mock repositories and the stub badge. TestFlight. |
Release |
Optimised, and nothing else. |
The configurations are every pairing — Debug Dev, Debug Prod, Beta Dev,
Beta Prod, Release Dev, Release Prod — and the four schemes are the four
things that get uploaded:
| Scheme | Runs | Archives |
|---|---|---|
Okhama |
Debug Prod |
Release Prod — App Store submissions. |
Okhama Beta |
Beta Prod |
Beta Prod — TestFlight for the live app. |
NUMU Frontliner |
Debug Dev |
Release Dev |
NUMU Frontliner Beta |
Beta Dev |
Beta Dev — TestFlight for the dev app, and the scheme for developing against the mocks. |
Develop with NUMU Frontliner. The Okhama scheme runs Debug Prod,
which talks to the live platform: running it from Xcode writes to a worker's
real data. That it is possible at all is deliberate — debugging against live
data is sometimes the only way to see a problem — but it should be a choice,
not what you get by opening the project.
The store schemes are kept apart from the Beta ones for the same reason. If
Okhama archived a Beta configuration, a submission made the usual way would
quietly ship the mocks and the maintenance tooling with it.
Which platform a build talks to is decided once, in AppContainer, from
DEV_PLATFORM. It cannot live beside the URLs in PlatformEnvironment,
because the Swift packages never see the app target's compilation conditions: a
#if there would give the same answer in every build. INTERNAL_TOOLS is set
the same way and read by nothing else. The two used to be one — the platform
was implied by the build kind — which is why "an optimised beta against the
live platform" could not be asked for.
Adding a flavour
Everything that differs between the two apps is in project.yml, under the app
target's settings.configs: the bundle id, APP_DISPLAY_NAME (which the
generated Info.plist reads for CFBundleDisplayName), and the compilation
conditions. Nothing in the source tree names a flavour; the two places that
knew an app's identity now read it — KeychainStore's service is
Bundle.main.bundleIdentifier, and the debug panel's "Files → …" text is the
display name.
SWIFT_ACTIVE_COMPILATION_CONDITIONS is written whole in each configuration,
DEBUG included. It is otherwise supplied by the configuration's type, and
that default is lost the moment the key is set at all.
Version
Both numbers live in project.yml, under targets.NUMUFieldApp.settings.base:
MARKETING_VERSION: "1.1.12"
CURRENT_PROJECT_VERSION: "60"
Raise CURRENT_PROJECT_VERSION for every upload — App Store Connect rejects a
build number it has already seen — then xcodegen generate before archiving. The
block above is the real current version, not an example: update it in the same
commit as the bump.
Packages
| Package | Contents |
|---|---|
Core |
Architecture (the store/reducer/effect runtime), Networking, Persistence |
Domain |
Models, policies and the protocols the features talk to |
Data |
Repositories, the platform API client, the badge adapter, mocks |
BadgeLink |
BadgeProtocol (wire format) and BadgeLink (CoreBluetooth and a stub) |
DesignSystem |
Colours, type, components |
Features |
FeatureKit plus the feature modules, assembled by AppFeature |
Nothing in BadgeLink knows about this app — it speaks badge vocabulary so it
can be lifted into other tooling. Data/BadgeLink/BadgeLinkService is the one
place that translates between the two.
Tests
Each package tests on its own. The umbrella scheme is <Package>-Package where
one exists (Core, BadgeLink, Features); the others are named after the
package.
cd Packages/Features && xcodebuild test -scheme Features-Package -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5)'
for p in Domain Data; do (cd Packages/$p && xcodebuild test -scheme $p -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5)'); done
DesignSystem has no suite of its own — it is looked at, not asserted on.
The app's own UI smoke test walks a whole shift over the in-memory
repositories, which exist only where INTERNAL_TOOLS is defined — so every
scheme's test action runs Beta Dev, whichever app the scheme builds. Dev
because a test that signs in should not be doing it against the live platform.