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:
- SwiftUI Tap Navigation: Individual page views handle taps via
onTapGestureDebounced - 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 │ │
│ └───────────────────┘ └──────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Navigation Flow
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
- On-demand: Beats load when navigated to
- Prefetch: Next beat loads in background
- Cache: Loaded beats stay in memory (no eviction currently)
- 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
"Navigation feels laggy"
- 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
Navigation Logic
StoryNavigator.swift- Pure navigation rulesStoryNavigating.swift- Navigation protocolsStoryLocation.swift- Navigation coordinate system
UI/UIKit Bridge
StoryPageController.swift- UIPageViewController coordinationReaderUIPageViewController.swift- SwiftUI wrapperStoryPageHostingControllerFactory.swift- Creates UIHostingControllers
Content Management
StoryBeatManager.swift- Beat loading with actor patternStoryContentManager.swift- Coordinates all content loadingStoryReaderViewModel.swift- Central state coordinator
Page Views
TextPageView.swift- Handles tap gestures for textChoicePageView.swift- Interactive choice selectionTitlePageView.swift- Story intro pages
Testing Approach
Critical Test Scenarios
- Tap/Swipe Sync: Ensure both navigation paths update same state
- Line-by-Line: Test partial text reveal and navigation blocking
- Beat Loading: Mock repository, test deduplication
- 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 datanavigation: 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
StoryLocationwhen 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 contentStoryNavigator.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()inStoryReaderViewModel - Error handling:
AppLogger.debug()inStoryPageController.showError() - Recovery attempts:
AppLogger.warn()inLoadingStateManager
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
- No Beat Eviction: All beats stay in memory
- Image Cache Unbounded: Can grow without limit
- Heavy View Computations: Complex view hierarchies with multiple layers may impact scrolling performance
Optimization Opportunities
- LRU Beat Cache: Evict distant beats
- Predictive Prefetch: Learn reading patterns
- 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
- Unified Gesture System: Eliminate tap/swipe split
- Pure SwiftUI: Replace UIPageViewController when SwiftUI supports page curl
- State Machine: Formalize navigation states
- 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.