Skip to content

g-enius/tick-feed

Repository files navigation

TickFeed

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.

Screenshots

All Races Horses Only INTL Filter Empty State Dark Mode
All Horses INTL Empty Dark

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.

Xcode Previews

Xcode Previews

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.

Demo

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.Announcement fires 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

Requirements Met

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

Build & Run

# 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=NO

Requires 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.

Run Tests

xcodebuild test -workspace TickFeed.xcworkspace -scheme TickFeed \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  CODE_SIGNING_ALLOWED=NO

81 tests across 7 test suites using Swift Testing framework (parameterized tests expand to additional cases).

Architecture

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

Key Design Decisions

ViewModel-Driven Timing

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? TimelineView rebuilds 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 Format

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"
  • round rounding — 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

Filter Behavior — Deliberate Design Decision

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.

Consistent Countdown Badge Width

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.

Race Expiry

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.

Custom Decodable

  • Race: Flattens nested advertised_start.seconds to Date, maps category_id UUID strings to a RaceCategory enum, decodes race_name (optional), ignores unused API fields
  • RacingAPIResponse: Extracts races from race_summaries dictionary using next_to_go_ids as the key set; client-side sort by advertisedStart provides stable display order

Dependency Injection — ServiceLocator

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).

Testing Strategy

  • 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.

Accessibility

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.Announcement when focused race crosses 5-min threshold or starts
  • Voice Control: Clear labels on all filter buttons
  • Filter state: .isSelected trait with descriptive hints
  • Decorative icons: .accessibilityHidden(true) (info conveyed in combined label)
Filter Button Inspection Race Row Inspection
Filter Race Row

Accessibility Inspector demo: accessibility-demo.mov — shows live element inspection, audit results, and label updates as races change.

Linting

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

Development Process

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

Localization

All user-facing strings are managed via Localizable.strings with SwiftGen-generated Swift constants (L10n enum) for compile-time safety.

No Third-Party Dependencies

Pure Apple frameworks — Foundation, SwiftUI, Observation. No CocoaPods, SPM remote dependencies, or external libraries.

AI-Assisted Development

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.

Known Limitations & Future Improvements

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

Author

Charles Wang — GitHub

About

SwiftUI iOS app with live countdowns, category filtering, and automatic data refresh — Swift 6, multi-module SPM, MVVM with @observable

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors