Skip to main content

StoryReader Architecture

The StoryReader displays PajamaScript stories with support for both solo reading and synchronized co-reading experiences.

The Core Complexity: Two Navigation Systems

The fundamental architectural complexity is that we have two separate navigation systems that must stay synchronized:

  1. SwiftUI Tap Navigation: Individual page views handle taps via onTapGestureDebounced
  2. UIKit Swipe Navigation: UIPageViewController handles swipe gestures

This split exists because UIPageViewController provides the page-turning animation we want, but it's a UIKit component that doesn't naturally integrate with SwiftUI's gesture system.

Interaction Flow Diagram

┌─────────────────────────────────────────────────────────────────┐
│ User Interactions │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Taps (SwiftUI) Swipes (UIKit) │
│ ↓ ↓ │
│ TextPageView UIPageViewController │
│ TitlePageView (via StoryPageController) │
│ ChoicePageView │ │
│ ↓ │ │
│ onTapGestureDebounced { │ │
│ viewModel.userTappedToAdvance() │ │
│ } ↓ │
│ ↓ pageViewController │
│ └──────────────┐ (viewControllerBefore/After) │
│ ↓ ↓ │
└───────────────────────┼─────────────────────┼───────────────────┘
↓ ↓
┌─────────────────────────────────────────┐
│ StoryReaderViewModel │
│ (Central State Coordinator) │
├─────────────────────────────────────────┤
│ │
│ userTappedToAdvance() ←─────────┐ │
│ navigateToLocation() ←──────┐ │ │
│ │ │ │
│ @Published currentLocation │ │ │
│ @Published displayablePage │ │ │
│ │ │ │
└─────────────┬───────────────┼───┼───────┘
│ │ │
↓ │ │
┌─────────────────────────────┼───┼──────┐
│ StoryNavigator │ │ │
│ (Pure Navigation Logic) │ │ │
├─────────────────────────────┼───┼──────┤
│ │ │ │
│ getPlan(for: .advance) ─────┘ │ │
│ canNavigateForward() │ │
│ updatePosition() │ │
│ │ │
└─────────────────────────────────┼──────┘

┌─────────────────────────────────┼──────┐
│ StoryPageController │ │
│ (UIPageViewController Bridge) │ │
├─────────────────────────────────┼──────┤
│ │ │
│ navigateToLocation() ←──────────┘ │
│ pageViewController:viewControllerBefore│
│ pageViewController:viewControllerAfter │
│ │
└────────────────────────────────────────┘

Component Architecture

┌─────────────────────────────────────────────────────────────────┐
│ User Interface Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────┐ ┌─────────────────────────────┐ │
│ │ UnifiedStoryReaderView │ │ Page Views │ │
│ │ │ │ │ │
│ │ - Reading controls │ │ - TextPageView │ │
│ │ - Mode delegation │ │ - ChoicePageView │ │
│ │ - Co-reading sync │ │ - TitlePageView │ │
│ │ - Embeds UIPageVC │ │ - EndingPageView │ │
│ └────────────────────────┘ │ - ErrorPageView │ │
│ │ - LoadingPageView │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ Coordination Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ StoryReaderViewModel │ │
│ │ │ │
│ │ State Management: Navigation: Mode Support: │ │
│ │ - currentLocation - userTappedToAdvance() │ │
│ │ - displayablePage - navigateToLocation() │ │
│ │ - loadingState - syncToCoReadingState() │ │
│ │ - currentError │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌────────┴───────┐ ┌─────────┴─────────┐ ┌────────┴─────────┐ │
│ │ StoryNavigator │ │StoryPageController│ |ReaderModeDelegate│ |
│ │ │ │ │ │ │ │
│ │ - Navigation │ │ - UIPageVC │ │ - Solo mode │ │
│ │ logic │ │ bridge │ │ - Co-reading │ │
│ │ - History │ │ - View factory │ │ coordination │ │
│ │ - Verification │ │ - Transitions │ │ │ │
│ └────────────────┘ └───────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ Content Management Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────┐ ┌─────────────────────────────┐ │
│ │ StoryContentManager │ │ StoryBeatManager │ │
│ │ │ │ │ │
│ │ - Coordinates loading │ │ - Beat caching │ │
│ │ - Image preloading │ │ - Actor pattern │ │
│ │ - Sound effects │ │ - Repository interface │ │
│ │ - Priority queues │ │ - Deduplication │ │
│ └────────────────────────┘ └─────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ StoryBeatLoadingCoordinator (Actor) │ │
│ │ │ │
│ │ - Thread-safe loading orchestration │ │
│ │ - Prevents duplicate network requests │ │
│ │ - Task management and cancellation │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ Data Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Repositories │ │ Domain Models │ │ Navigation │ │
│ │ │ │ │ │ Primitives │ │
│ │ - PajamaScript │ │ - Story │ │ │ │
│ │ BeatRepository │ │ - StoryBeat │ │ - StoryLocation │
│ │ - ImageLoading │ │ - PajamaScript │ │ - Navigation │ │
│ │ Service │ │ Page types │ │ Plan │ │
│ │ - Analytics │ │ - DisplayablePage│ │ - Navigation │ │
│ │ Service │ │ Content │ │ Action │ │
│ └───────────────────┘ └──────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘

When User Taps (SwiftUI Path)

1. TextPageView.onTapGestureDebounced fires
2. → await viewModel.userTappedToAdvance()
3. → modeDelegate.userTappedToAdvance() [co-reading check]
4. → navigator.getPlan(for: .advance)
5. → pageController.navigateToLocation(plan.destination)
6. → Creates UIHostingController with new page view
7. → UIPageViewController.setViewControllers() programmatically
8. → Animation completes → ViewModel updates currentLocation

When User Swipes (UIKit Path)

1. UIPageViewController detects swipe gesture
2. → Calls pageViewController:viewControllerBefore/After on StoryPageController
3. → StoryPageController asks ViewModel.navigator for next/previous location
4. → Creates UIHostingController for that location
5. → Returns it to UIPageViewController
6. → UIPageViewController animates the transition
7. → pageViewControllerDidFinishAnimating → ViewModel updates currentLocation

Key Architectural Decisions

1. Why This Split Architecture?

  • UIPageViewController: Provides the page curl animation users expect
  • SwiftUI Views: Modern, reactive UI for page content
  • Result: We bridge two worlds, accepting the complexity for UX

2. StoryLocation as Navigation Primitive

StoryLocation (see Core/StoryNavigation/StoryLocation.swift) serves as the universal coordinate system with beatId, pageId, and lineIndex properties for precise positioning.

3. Actor Pattern for Beat Loading

StoryBeatLoadingCoordinator (see StoryReader/StoryBeatManager.swift) prevents race conditions by tracking activeTasks and reusing in-flight requests when multiple components request the same beat.

4. Line-by-Line Text Display

For text pages with textMultilineEntryStrategy = .lineByLine:

  • Initial display shows only first line
  • Each tap reveals next line (updates lineIndex in StoryLocation)
  • Only after all lines shown can navigation advance to next page
  • This is why StoryLocation includes lineIndex

Content Loading Strategy

Beat Loading Lifecycle

  1. On-demand: Beats load when navigated to
  2. Prefetch: Next beat loads in background
  3. Cache: Loaded beats stay in memory (no eviction currently)
  4. Deduplication: Actor pattern prevents duplicate requests

Image Preloading Priority

1. Current page images → Immediate
2. Next 2 pages → High priority
3. Previous 1 page → Medium priority
4. Rest of story → Low priority background

Co-Reading Synchronization

Owner Navigation Flow

Owner taps/swipes

StoryReaderViewModel updates location

ReaderModeDelegate.userDidNavigate()

CoReadingViewModel updates session state

PubSub broadcasts to followers

Follower Sync Flow

PubSub state update received

CoReadingViewModel.delegate.didUpdateWithBeatId()

UnifiedStoryReaderView.syncToCoReadingState()

StoryReaderViewModel.syncToCoReadingState()

If page changed: pageController.navigateToLocation()
If line changed: just update displayablePage

Common Issues & Solutions

  • Check if beat is loading (shows LoadingPageView)
  • LoadingStateManager blocks navigation during async ops
  • Co-reading followers wait for owner actions

"Swipe doesn't work"

  • Only owners can swipe (followers have dataSource = nil)
  • Check StoryNavigator.canNavigateForward/Backward()
  • Verify beat is loaded

"Images not loading"

  • Check ImageLoadingService and Firebase permissions
  • Memory pressure may evict image cache
  • Network issues cause silent failures

"Line-by-line not working"

  • Verify page.textMultilineEntryStrategy == .lineByLine
  • Check currentLocation.lineIndex updates
  • Ensure TextPageView receives location changes

Key Files by Responsibility

  • StoryNavigator.swift - Pure navigation rules
  • StoryNavigating.swift - Navigation protocols
  • StoryLocation.swift - Navigation coordinate system

UI/UIKit Bridge

  • StoryPageController.swift - UIPageViewController coordination
  • ReaderUIPageViewController.swift - SwiftUI wrapper
  • StoryPageHostingControllerFactory.swift - Creates UIHostingControllers

Content Management

  • StoryBeatManager.swift - Beat loading with actor pattern
  • StoryContentManager.swift - Coordinates all content loading
  • StoryReaderViewModel.swift - Central state coordinator

Page Views

  • TextPageView.swift - Handles tap gestures for text
  • ChoicePageView.swift - Interactive choice selection
  • TitlePageView.swift - Story intro pages

Testing Approach

Critical Test Scenarios

  1. Tap/Swipe Sync: Ensure both navigation paths update same state
  2. Line-by-Line: Test partial text reveal and navigation blocking
  3. Beat Loading: Mock repository, test deduplication
  4. Co-Reading Sync: Test owner/follower state synchronization

Integration Tests

Test navigation consistency by comparing StoryReaderViewModel.userTappedToAdvance() results with StoryPageController swipe navigation to ensure both paths reach the same destination.

Error Handling Architecture

StoryReader implements a comprehensive error handling system with user-friendly recovery options and clear visual feedback.

Error Types and Classification

StoryNavigationError (see Core/StoryNavigation/StoryNavigationError.swift) - Navigation-specific errors with user-friendly descriptions via userFriendlyDescription property.

Error Categories:

  • contentLoading: Issues fetching beats, images, or story data
  • navigation: Problems moving between pages or determining location

Error Display Strategy

1. In-Page Error Display (Primary)

StoryPageController.showError() method creates and displays error pages within the story reading flow.

ErrorPageView Components:

  • Large warning icon (system exclamation triangle)
  • "Error" title with custom Fredoka font
  • User-friendly error message (error.userFriendlyDescription)
  • "Return to Title" button that calls viewModel.restartStory()
  • Maintains story reader UI context (doesn't exit to library)

2. Loading State Management

LoadingStateManager (see Core/Components/LoadingStateManager.swift) provides timeout handling, retry mechanisms, and user feedback with a 3-second timeout for responsiveness.

LoadingStateManager Features:

  • Timeout Handling: Shows retry button after 3 seconds
  • Retry Mechanism: Re-executes failed operations with cleanup
  • Cancellation Support: Proper cleanup of async operations
  • User Feedback: Clear loading messages ("Loading Story Content...", "Admiring your choice...")

Error Flow Patterns

1. Beat Loading Errors

User taps to advance

StoryNavigator.getPlan() → throws .beatNotLoaded(beatId)

StoryReaderViewModel.handleTapToAdvanceNavigationError()

LoadingStateManager.executeWithRetry() with timeout/retry

If still fails → StoryPageController.showError() → ErrorPageView

2. Navigation Logic Errors

User makes choice

StoryNavigator.getLocationAfter() → throws .invalidChoiceIndex()

Immediate StoryPageController.showError() → ErrorPageView

User clicks "Return to Title" → StoryReaderViewModel.restartStory()

3. Content Loading Errors

StoryContentManager fails to load beat

StoryBeatManager publishes .failure(error, beatId)

StoryReaderViewModel observes failure

Converts to StoryNavigationError → pageController.showError()

User Recovery Actions

ErrorPageView Actions:

  • "Return to Title" - Calls restartStory() to reload from beginning
  • Maintains reading context (doesn't exit to library)
  • Preserves co-reading session if active

LoadingStateManager Actions:

  • "Retry" Button - Re-attempts the same operation after cleanup
  • Cancel Action - User can dismiss loading (calls operation's cancelAction)
  • Automatic Recovery - Retry succeeds → continues normal flow

Loading Overlay Actions:

  • Timeout → Retry - Shows retry button after 3 seconds
  • Background Retry - Automatic retry with exponential backoff
  • Cancel Option - Available for long-running operations

Error Context Preservation

Co-Reading Error Handling:

  • Owner Errors - Shown to owner, followers see loading state
  • Follower Errors - Local only, doesn't affect owner
  • Session Preservation - Errors don't break co-reading sync
  • Recovery - Owner restart syncs followers to new state

Navigation Context:

  • Errors maintain current StoryLocation when possible
  • restartStory() always returns to beginning with known-good state
  • Error pages integrate with UIPageViewController navigation
  • Swipe gestures work on error pages (can navigate backward)

Error Prevention Strategies

Proactive Loading:

  • StoryContentManager.beginLoadingAllStoryContent() prefetches content
  • StoryNavigator.canNavigateForward() validates navigation before attempting

Defensive Programming:

  • Always validate page existence via StoryBeatManager.getPage(for:)
  • Call StoryPageController.showError() for invalid references

Timeout Management:

  • LoadingStateManager.executeWithRetry() provides timeout and retry functionality
  • Operations include cancel actions for proper cleanup

Error Debugging

Key Log Patterns:

  • Error occurrence: AppLogger.error() in StoryReaderViewModel
  • Error handling: AppLogger.debug() in StoryPageController.showError()
  • Recovery attempts: AppLogger.warn() in LoadingStateManager

Debug Checklist for Error States:

  • Check error type and user-friendly message
  • Verify LoadingStateManager state
  • Confirm error page displays correctly
  • Test "Return to Title" action
  • Verify co-reading sync during errors
  • Check retry functionality
  • Validate error doesn't break navigation

Performance Considerations

Current Issues

  1. No Beat Eviction: All beats stay in memory
  2. Image Cache Unbounded: Can grow without limit
  3. Heavy View Computations: Complex view hierarchies with multiple layers may impact scrolling performance

Optimization Opportunities

  1. LRU Beat Cache: Evict distant beats
  2. Predictive Prefetch: Learn reading patterns
  3. Async UI Updates: Move more work off main thread

Debugging Checklist

When debugging navigation issues:

  • Which path triggered navigation? (tap vs swipe)
  • Current location? viewModel.currentLocation
  • Beat loaded? beatManager.loadedBeats[beatId]
  • Navigation allowed? navigator.canNavigateForward()
  • Loading state? loadingStateManager.isLoading
  • Co-reading role? modeDelegate.isOwner
  • Any errors? viewModel.currentError

Future Architecture Improvements

  1. Unified Gesture System: Eliminate tap/swipe split
  2. Pure SwiftUI: Replace UIPageViewController when SwiftUI supports page curl
  3. State Machine: Formalize navigation states
  4. Event Sourcing: Record all navigation events for replay/debugging

The current architecture successfully bridges UIKit and SwiftUI to achieve the desired page curl animation. Understanding the two navigation paths is crucial for maintaining this system.