Skip to main content

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:
  1. A Protocol defines the interface
  2. A Real implementation (e.g., CognitoAuthService) talks to the backend
  3. A Fake implementation returns canned data for previews and tests
  4. 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.
PhaseWhat happens
1. Config checkAppConfigService checks version + maintenance status
2. Auth restoreAuthService restores the session from the Keychain
3. Follow syncFollowManager calls GET /user/follows to reconcile local state
4. LA restoreLifecycleManager re-attaches observers to any surviving Live Activities
5. ReadyUI 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.
MethodPurpose
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
currentUserThe 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 toResponsibilities
LiveActivityTokenManagerPush-to-start token observation, per-activity update token observation, POST tokens to backend, deduplication
LiveActivityLifecycleManagerActivity.request(), adopt push-started LAs, dismiss observation, restore on relaunch, stale check, content observation
FollowManagerFollow/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.
MethodPurpose
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:
TokenPurpose
Device tokenStandard push notifications (alerts, badges, sounds)
Push-to-start tokenStarting 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.
FeatureDetail
Sliding-window cacheKeeps N days of schedule data in memory to avoid redundant fetches
DebouncingRapid date changes debounce API calls to avoid flooding the backend
Today refreshAlways 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 / PropertyPurpose
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
pollIntervalMsPolling 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)
BehaviorDetail
Lifecycle bindingStarts polling on onAppear, stops on onDisappear
ScenePhase awarenessPauses polling when app enters background, resumes on foreground
Cache-firstOn 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

FeatureDetail
Always pollsshouldPoll() always returns true while there are live matches
Server-driven intervalpollIntervalMs comes from the backend’s retryAfterMs field in the response
Adapts dynamicallyIf the server says “poll again in 5s” during a key moment, the interval tightens automatically

ScheduleDataSource

FeatureDetail
Per-dateEach date has its own cache entry
Conditional pollingOnly polls if the selected date is today (past dates are static)
Integrates with ScheduleServiceUses the sliding-window cache underneath

MatchCacheStore

Three-layer cache for match data with intelligent invalidation.

Cache layers

LayerComponentAccess timeDetail
1. In-memoryMatchCacheStoreMicrosecondDate-indexed dictionary for O(1) schedule lookups. Fast reads for active views.
2. DiskDiskCacheStore via MatchDiskCacheWrapper~5msSurvives app restarts. Uses schema-versioned invalidation — when MATCH_SCHEMA_VERSION bumps, the entire disk cache is discarded to avoid decoding stale formats.
3. HTTPURLSession to /matches500ms+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 stateTTL behavior
LiveShort TTL — always refreshed from the network to ensure real-time accuracy
CompletedLong 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.
TierDetail
Memory cacheNSCache-based, evicted under memory pressure
Disk cacheFile-based in the app group shared container
Async prefetchPrefetches 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.
FeatureDetail
Version checkCompares local app version against minimum required version from backend
Force updateIf the app is below minimum version, blocks usage and shows update prompt
Maintenance modeIf 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.
FeatureDetail
Fake API controlsToggle fake API mode, switch between canned responses
Tape playbackReplay 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.
DestinationPurpose
os.LoggerSystem-level structured logging (visible in Console.app and Xcode)
SentryError and breadcrumb reporting for production crash analysis
DebugLogServiceIn-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())
}