Documentation Index
Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Every service in the iOS app follows the same pattern:
- A Protocol defines the interface
- A Real implementation (e.g.,
CognitoAuthService) talks to the backend
- A Fake implementation returns canned data for previews and tests
- All services are
@Observable and injected via @Environment
// The pattern — repeated for every service
protocol ScheduleServiceProtocol {
func fetchSchedule(for date: Date) async throws -> ScheduleResponse
}
@Observable
class RealScheduleService: ScheduleServiceProtocol { /* hits API */ }
@Observable
class FakeScheduleService: ScheduleServiceProtocol { /* returns mock data */ }
// Injection
@Environment(\.scheduleService) private var scheduleService
AppBootstrap
Responsibility: Cold-start phase gating.
AppBootstrap orchestrates the app launch sequence, ensuring services initialize in the correct order before the UI becomes interactive.
| Phase | What happens |
|---|
| 1. Config check | AppConfigService checks version + maintenance status |
| 2. Auth restore | AuthService restores the session from the Keychain |
| 3. Follow sync | FollowManager calls GET /user/follows to reconcile local state |
| 4. LA restore | LifecycleManager re-attaches observers to any surviving Live Activities |
| 5. Ready | UI unblocks, data sources begin polling |
If any phase fails (e.g., maintenance mode detected), AppBootstrap halts and shows the appropriate blocking screen. The user never sees a partially initialized app.
AuthService
Protocol: AuthService
Real implementation: CognitoAuthService
Handles all Cognito-based authentication flows.
| Method | Purpose |
|---|
signUp(email:password:) | Create a new Cognito user |
signIn(email:password:) | Authenticate and receive tokens |
verifyEmail(code:) | Confirm email with verification code |
resetPassword(email:) | Initiate forgot-password flow |
confirmResetPassword(code:newPassword:) | Complete password reset |
signOut() | Clear tokens and session |
currentUser | The currently authenticated user (if any) |
Admin detection
Admin status is determined by checking the JWT token’s cognito:groups claim. If the user belongs to the admin group, AppUser.isAdmin is true.
Admin detection is read-only on the client. The backend enforces admin permissions independently via its own JWT validation.
LiveActivityService (facade)
Protocol: LiveActivityService
Real implementation: RealLiveActivityService
The facade composes three managers and exposes a unified API to the rest of the app. Views and other services interact with LiveActivityService — never with the managers directly.
| Delegated to | Responsibilities |
|---|
LiveActivityTokenManager | Push-to-start token observation, per-activity update token observation, POST tokens to backend, deduplication |
LiveActivityLifecycleManager | Activity.request(), adopt push-started LAs, dismiss observation, restore on relaunch, stale check, content observation |
FollowManager | Follow/unfollow API, followedMatchIds persistence, retry pending unfollows, bell icon states, sync from GET /user/follows |
The LiveActivityService facade is the most stateful part of the app. See the Live Activities page for the full lifecycle, token management, and push-to-start flow.
NotificationService
Protocol: NotificationService
Real implementation: RealNotificationService
Handles push notification permissions and device registration with dual token management.
| Method | Purpose |
|---|
requestPermission() | Prompts the user for notification permission |
registerDevice(token:) | Sends the APNs device token to the backend |
unregisterDevice() | Tells the backend to stop sending pushes to this device |
Dual tokens
The app manages two distinct APNs tokens:
| Token | Purpose |
|---|
| Device token | Standard push notifications (alerts, badges, sounds) |
| Push-to-start token | Starting Live Activities remotely (managed by TokenManager, registered via NotificationService) |
Both tokens are registered with the backend on launch and whenever they rotate.
ScheduleService
Protocol: ScheduleService
Real implementation: RealScheduleService
Fetches and caches match schedules.
| Feature | Detail |
|---|
| Sliding-window cache | Keeps N days of schedule data in memory to avoid redundant fetches |
| Debouncing | Rapid date changes debounce API calls to avoid flooding the backend |
| Today refresh | Always re-fetches today’s schedule to ensure live matches are current |
DataSource protocol + ViewSubscriber modifier
A generic pattern for data that needs to be fetched, cached, and optionally polled.
DataSource protocol
protocol DataSource<T> {
associatedtype T
var data: T? { get }
func load() async throws -> T
func refresh() async throws -> T
func shouldPoll() -> Bool
var pollIntervalMs: Int { get }
}
| Method / Property | Purpose |
|---|
load() | Cache-first read. Returns cached data if available, otherwise fetches |
refresh() | Forces a fresh fetch, bypassing cache |
shouldPoll() | Returns whether this data source needs active polling right now |
pollIntervalMs | Polling interval in milliseconds (can be server-driven) |
ViewSubscriber modifier
A SwiftUI view modifier that binds a DataSource to a view’s lifecycle.
SomeView()
.viewSubscriber(dataSource: liveMatchesDataSource)
| Behavior | Detail |
|---|
| Lifecycle binding | Starts polling on onAppear, stops on onDisappear |
| ScenePhase awareness | Pauses polling when app enters background, resumes on foreground |
| Cache-first | On appear, immediately shows cached data while refreshing in the background |
ViewSubscriber eliminates the need for each view to manually manage polling timers and lifecycle events. Attach it once and the data source handles the rest.
LiveMatchesDataSource
| Feature | Detail |
|---|
| Always polls | shouldPoll() always returns true while there are live matches |
| Server-driven interval | pollIntervalMs comes from the backend’s retryAfterMs field in the response |
| Adapts dynamically | If the server says “poll again in 5s” during a key moment, the interval tightens automatically |
ScheduleDataSource
| Feature | Detail |
|---|
| Per-date | Each date has its own cache entry |
| Conditional polling | Only polls if the selected date is today (past dates are static) |
| Integrates with ScheduleService | Uses the sliding-window cache underneath |
MatchCacheStore
Three-layer cache for match data with intelligent invalidation.
Cache layers
| Layer | Component | Access time | Detail |
|---|
| 1. In-memory | MatchCacheStore | Microsecond | Date-indexed dictionary for O(1) schedule lookups. Fast reads for active views. |
| 2. Disk | DiskCacheStore via MatchDiskCacheWrapper | ~5ms | Survives app restarts. Uses schema-versioned invalidation — when MATCH_SCHEMA_VERSION bumps, the entire disk cache is discarded to avoid decoding stale formats. |
| 3. HTTP | URLSession to /matches | 500ms+ | Network fetch from the backend API. Only hit when layers 1 and 2 miss. |
Hydration on launch
loadFromDisk() is called during AppBootstrap to hydrate the in-memory cache from disk before other boot tasks run. This means the app can render cached schedules immediately, even before the first network response arrives.
TTL by state
| Match state | TTL behavior |
|---|
| Live | Short TTL — always refreshed from the network to ensure real-time accuracy |
| Completed | Long TTL — stable data, served from cache without re-fetching |
Swift 6 gotcha: Synthesized Codable conformance on structs is main-actor-isolated in Swift 6. The cache model structs require explicit nonisolated init(from:) and nonisolated encode(to:) implementations to allow background encoding/decoding by DiskCacheStore. Without this, the compiler will emit a concurrency error when disk I/O runs off the main actor.
ImageCacheService
Protocol: ImageCacheService
Real implementation: RealImageCacheService
Two-tier image cache for team logos and assets.
| Tier | Detail |
|---|
| Memory cache | NSCache-based, evicted under memory pressure |
| Disk cache | File-based in the app group shared container |
| Async prefetch | Prefetches images for upcoming matches so they are ready when needed |
The disk cache uses the app group container so both the main app and the widget target can access cached images. This avoids duplicate downloads for team logos shown in Live Activities.
AppConfigService
Protocol: AppConfigService
Real implementation: RealAppConfigService
Checks app configuration on launch.
| Feature | Detail |
|---|
| Version check | Compares local app version against minimum required version from backend |
| Force update | If the app is below minimum version, blocks usage and shows update prompt |
| Maintenance mode | If backend reports maintenance, shows a maintenance screen |
The version check runs on every app launch. The backend controls the minimum version, so you can force-update users without an App Store review cycle.
AdminService
Protocol: AdminService
Real implementation: RealAdminService
Debug-only service for controlling fake mode.
| Feature | Detail |
|---|
| Fake API controls | Toggle fake API mode, switch between canned responses |
| Tape playback | Replay recorded API responses for testing Live Activity flows |
This service is only available in debug builds. It connects to fake-api.sightscreen.app for testing.
AppLogger
Unified logging across three destinations.
| Destination | Purpose |
|---|
os.Logger | System-level structured logging (visible in Console.app and Xcode) |
| Sentry | Error and breadcrumb reporting for production crash analysis |
DebugLogService | In-app debug log overlay for on-device troubleshooting |
AppLogger.info("Token registered", category: .liveActivity)
AppLogger.error("Failed to adopt push-started LA", error: error, category: .liveActivity)
Use AppLogger for all logging. Never use print() or raw os_log calls. AppLogger ensures every log event reaches all three destinations with consistent formatting.
DI wiring
All services are wired at the app entry point and injected into the SwiftUI environment:
@main
struct SightscreenApp: App {
var body: some Scene {
WindowGroup {
RootView()
.environment(\.authService, RealCognitoAuthService())
.environment(\.scheduleService, RealScheduleService())
.environment(\.liveActivityService, RealLiveActivityService())
.environment(\.notificationService, RealNotificationService())
.environment(\.appConfigService, RealAppConfigService())
.environment(\.imageCacheService, RealImageCacheService())
.environment(\.matchCacheStore, RealMatchCacheStore())
}
}
}
For SwiftUI previews, swap in fakes:
#Preview {
MatchListView()
.environment(\.scheduleService, FakeScheduleService())
.environment(\.liveActivityService, FakeLiveActivityService())
}