A SwiftUI iOS app with live countdowns, category filtering, and automatic data refresh — built with Swift 6 strict concurrency, multi-module SPM, and comprehensive testing.
For a larger-scale iOS project demonstrating multi-module SPM architecture, MVVM-C with Coordinators, and progressive migration from UIKit+Combine to pure SwiftUI+
@Observable+AsyncSequence, see Fun-iOS.
| All Races | Horses Only | INTL Filter | Empty State | Dark Mode |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
Dark mode screenshot shows negative countdown in red and the 5-minute approaching threshold. Empty state shown when greyhound filter matches no races from the 10 fetched.
Live countdowns ticking in Xcode canvas — previews use mock services via PreviewHelpers.swift so filtering, countdown, and error states all work without a running simulator.
Full.VoiceOver.walkthrough-compressed.mp4
Full VoiceOver walkthrough — every feature demonstrated in a single uncut recording:
- Auto-expiry: Races removed exactly 1 minute past advertised start (watch Woodbine Mohawk Park and Northfield Park disappear on schedule)
- Error recovery: Network failure triggers a dedicated error screen; Retry resumes the refresh loop from where it left off
- Adaptive layout: Race number and countdown stack vertically at xxxLarge Dynamic Type — no truncation, no clipping
- Visual accessibility: High contrast, dark mode, and fully scalable fonts across all Dynamic Type sizes
- Voice Control: All interactive elements respond to voice commands out of the box
- VoiceOver — label/value split: Static identity (category, meeting, venue) in
.accessibilityLabel, live countdown in.accessibilityValue— VoiceOver reads the current time on navigation without re-announcing every second, solving the fundamental TimelineView + VoiceOver incompatibility - VoiceOver — status change announcements:
AccessibilityNotification.Announcementfires the instant a race starts — a direct VoiceOver interruption that reads immediately, even mid-speech - Venue context: Each row shows state, country, and race distance decoded from the API
- Region filtering: AU/NZ and International toggle filters with the same empty-set-means-all behavior as category filters
| Requirement | Implementation |
|---|---|
| Time-ordered list ascending | Client-side sort by advertisedStart, tiebreak by meetingName |
| Races hidden 1 min past start | Client-side 60s expiry filter with boundary tests at -59s/-1m |
| Filter by Horse/Harness/Greyhound | Category toggle bar with custom SF Symbol icons |
| Deselect all = show all categories | Empty selection = no active filter = all categories shown |
| Meeting name, race number, countdown | RaceRowView shows meeting, venue info, race number, live countdown |
| Always 5 races, auto refresh | Up to 5 displayed from count=10; 10s refresh loop |
| Region filter (AU/NZ, INTL) | Client-side filter by venueCountry (AUS/NZ vs international) |
| Venue info | State, country, distance decoded from API and formatted per region |
# Open in Xcode
open TickFeed.xcworkspace
# Or build from command line
xcodebuild build -workspace TickFeed.xcworkspace -scheme TickFeed \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
CODE_SIGNING_ALLOWED=NORequires Xcode 26+ / iOS 26+ / Swift 6.
iOS 17+ compatible — no iOS 26-specific APIs are used. The codebase (
@Observable, Swift 6 concurrency) runs on iOS 17+ without changes. Building with Xcode 26 automatically applies liquid glass effects on navigation chrome when running on iOS 26.
xcodebuild test -workspace TickFeed.xcworkspace -scheme TickFeed \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
CODE_SIGNING_ALLOWED=NO81 tests across 7 test suites using Swift Testing framework (parameterized tests expand to additional cases).
MVVM with @Observable — same layered architecture as Fun-iOS, scaled down for a single-screen app (no Coordinator layer needed).
5 local SPM packages + 1 Xcode app target, unified by TickFeed.xcworkspace:
┌─────────────────────────────────────────┐
│ TickFeedApp │
├──────────┬──────────────────────────────┤
│ │ UI │
│ Services ├──────────────────────────────┤
│ │ ViewModel │
├──────────┴──────────────────────────────┤
│ Model │
├─────────────────────────────────────────┤
│ Core │
└─────────────────────────────────────────┘
| Package | Import as | Contents |
|---|---|---|
Core/ |
TickFeedCore |
ServiceLocator, @Service, L10n (SwiftGen) |
Model/ |
TickFeedModel |
Domain models, custom Decodable, service protocols |
Services/ |
TickFeedServices |
Actor-based API client |
ViewModel/ |
TickFeedViewModel |
@Observable @MainActor, holds races + filter state, countdown formatting |
UI/ |
TickFeedUI |
SwiftUI views — ViewModel-driven countdown display |
| Concern | Mechanism | Interval |
|---|---|---|
| Countdown display | ViewModel currentTime updated via Task.sleep loop |
1 second |
| API data refresh | Same loop, every 10th tick | 10 seconds |
A single Task.sleep loop in the ViewModel updates currentTime every second and fetches from the API every 10 seconds. Views read countdown text from ViewModel methods (countdownText(for:), isCountdownApproaching(for:)). The displayRaces(at:) function is pure — takes now as a parameter for testability.
Why not
TimelineView?TimelineViewrebuilds its content closure every tick, which destroys and recreates accessibility elements. VoiceOver treats each recreated element as new content and re-announces it — making the app unusable for VoiceOver users. See ACCESSIBILITY.md for the full investigation.
Countdown formatting analyzed from production web app behavior:
| Condition | Display | Color |
|---|---|---|
| >= 1 hour | 1h 23m |
Default |
| > 5 min | 7m |
Default |
| <= 5 min | 4m 32s |
Red (approaching) |
| 0-59 sec | 42s |
Red |
| Past start | -18s, -59s |
Red |
| >= 60s past | Removed | — |
- 5-minute "approaching" threshold turns countdown red
- Seconds omitted above 5 minutes and when zero — "1m" not "1m 0s"
roundrounding — each displayed value gets exactly 1 second centered on its integer (e.g. "1s" for (0.5, 1.5), "0s" for (-0.5, 0.5), "-1s" for (-1.5, -0.5)), preventing "0s" from lingering for 2 seconds
The task requires: "I can deselect all filters to show the next 5 of all racing categories."
Three implementations exist in the wild:
| Platform | Behavior | Deselect all? |
|---|---|---|
| Neds web | Snap-back: deselecting the last filter re-selects all three | No — always at least one selected |
| Neds iOS app | Prevents deselecting the last active filter entirely | No — always at least one selected |
| TickFeed (this app) | Empty selection = no active filter = all categories shown | Yes — matches the task requirement |
We chose the third approach to directly satisfy the requirement. When no category buttons are highlighted, all races are shown — the user can clearly deselect all filters and see all categories.
Countdown badges ("5m", "3m 53s", "-28s") vary in text length but need consistent capsule widths for visual alignment. A fixed minWidth doesn't scale with Dynamic Type. Instead, a hidden reference text ("00m 00s") sets the frame width, with the real countdown as an overlay:
Text("00m 00s") // hidden — sets the width
.font(.subheadline.weight(.bold).monospacedDigit())
.hidden()
.overlay { Text(actualCountdown) } // visible — inherits the frame.monospacedDigit() ensures all digits are equal width, so the hidden reference covers the widest common format. The width scales automatically with Dynamic Type — no magic numbers.
Races are removed 1 minute after their advertised start time (per task requirement). Until then, a negative countdown is shown in red (e.g. -18s, -59s). The boundary at exactly -1m is tested — race at -59s is visible, at -1m it's removed.
- Race: Flattens nested
advertised_start.secondstoDate, mapscategory_idUUID strings to aRaceCategoryenum, decodesrace_name(optional), ignores unused API fields - RacingAPIResponse: Extracts races from
race_summariesdictionary usingnext_to_go_idsas the key set; client-side sort byadvertisedStartprovides stable display order
Instance-based DI — no singleton. A ServiceLocator is created at app launch, services registered, and injected into the ViewModel via constructor. The @Service property wrapper resolves services via _enclosingInstance subscript from the enclosing type's serviceLocator (requires ServiceLocatorProvider conformance). Tests create a fresh ServiceLocator() per test — no shared state, no .serialized. Same pattern used in Fun-iOS with session-scoped DI (login vs authenticated sessions).
- TickFeedCoreTests — ServiceLocator register/resolve/reset
- CountdownTextTests — Format boundaries, negative minutes, truncation, approaching threshold, parameterized
- RaceTests — Custom Decodable, category ID mapping, optional
race_name, unknown field resilience - RacingAPIResponseTests — Order preservation, missing race handling, resilient decoding (skips unknown categories)
- RacingViewModelTests — Category + region filter toggle, 60s expiry boundary (-59s visible, -1m removed), time-sorted ordering, cancellation, auto-refresh lifecycle, error retry
- APIClientTests — URL construction, HTTP error handling, malformed JSON
- RacingServiceTests — Endpoint construction, race decoding, error propagation via MockAPIClient
Parameterized tests used for countdown boundaries and category ID mapping.
Full WCAG 2.1 AA compliance documented in ACCESSIBILITY.md, including a detailed investigation of VoiceOver + live countdown compatibility.
- Dynamic Type: Semantic fonts only — adaptive layout switches to vertical at
.xxxLarge(reachable from Control Center) - VoiceOver: Combined labels per row with context-aware wording ("starting in" / "starting soon" / "just started" / "started X ago")
- No re-reading: Label/value split + stable view identity keeps VoiceOver from re-announcing every second (see investigation)
- Status change announcements:
AccessibilityNotification.Announcementwhen focused race crosses 5-min threshold or starts - Voice Control: Clear labels on all filter buttons
- Filter state:
.isSelectedtrait with descriptive hints - Decorative icons:
.accessibilityHidden(true)(info conveyed in combined label)
| Filter Button Inspection | Race Row Inspection |
|---|---|
![]() |
![]() |
Accessibility Inspector demo: accessibility-demo.mov — shows live element inspection, audit results, and label updates as races change.
SwiftLint with strict rules enforcing:
- No
import UIKit(pure SwiftUI) - No
import Combine(async/await only) - No force unwrapping / force cast / force try (all errors)
- Sorted imports
Every change is reviewed via Pull Request — no direct commits to main. All AI-generated code is treated as a first draft: the human author reviews every line, validates architectural decisions, tests edge cases on device, and takes full ownership of the final code.
| Step | Description |
|---|---|
| 1. Branch | Create feature branch from main |
| 2. Implement | Write code + tests |
| 3. PR | Create PR with description and test plan |
| 4. Human review | Author reviews all changes — architecture, correctness, edge cases, accessibility |
| 5. Device validation | Run on physical device, verify UI behavior, VoiceOver audit |
| 6. CI | Automated lint + build + test |
| 7. Merge | Merge only after human review + CI pass |
All user-facing strings are managed via Localizable.strings with SwiftGen-generated Swift constants (L10n enum) for compile-time safety.
Pure Apple frameworks — Foundation, SwiftUI, Observation. No CocoaPods, SPM remote dependencies, or external libraries.
This project uses Claude Code with project-level configuration for architecture-aware assistance. All AI-generated code undergoes human review — the developer owns every architectural decision, validates correctness, and takes full responsibility for the final code.
| Component | Purpose |
|---|---|
CLAUDE.md + ai-rules/ |
Architecture rules, anti-patterns, build commands |
.claude/agents/change-reviewer.md |
Automated code review agent |
.claude/skills/ |
/review, /pull-request, /fix-issue workflows |
.github/workflows/claude.yml |
Auto PR review via GitHub Action |
Same workflow used in Fun-iOS, which demonstrates the full Claude Code setup with additional skills (/sync, /cross-platform), branch-aware rules, and session-scoped DI.
| Limitation | Detail | Potential Improvement |
|---|---|---|
| Category filter may show < 5 races | API (nextraces) returns next 10 races globally — no server-side category filtering. Tested category_id, category_ids, categories, type, and POST body; all ignored. |
If API adds category support, pass selected category_ids with debounced re-fetch on filter toggle |
| Client-side sorting | API's next_to_go_ids order shifts every refresh cycle, causing visual instability. Client-side sort by advertisedStart + meetingName provides stable ordering. |
Server-side sort if API supports it |
| Region filter is client-side only | API has no region/country filter parameter | Server-side region filtering if API adds support |
| Manual retry on network error | Auto-refresh loop stops on error to avoid wasteful retries while offline. User taps Retry to restart. | Add NWPathMonitor to detect connectivity changes and auto-restart the refresh loop when network returns |
Charles Wang — GitHub







