A modern iOS news aggregation app built with Clean Architecture, SwiftUI, and Combine. Powered by a self-hosted RSS feed aggregator backend with Guardian API fallback.
- Authentication: Firebase Auth with Google and Apple Sign-In (required before accessing app)
- Home Feed: Breaking news carousel, top headlines with infinite scrolling, and category tabs for filtering by followed topics (settings accessible via gear icon)
- Media: Browse and play Videos and Podcasts with in-app playback (YouTube videos open in YouTube app, podcasts use native AVPlayer)
- Feed: AI-powered Daily Digest summarizing articles read in the last 48 hours using on-device LLM (Llama 3.2-1B) (Premium)
- Article Summarization: On-device AI article summarization via sparkles button (Premium)
- Bookmarks: Save articles for offline reading with SwiftData persistence
- Search: Full-text search with 300ms debounce, suggestions, recent searches, and sort options
- Settings: Customize topics, notifications, theme, content filters, and account/logout (accessed from Home navigation bar)
The app uses iOS 26's liquid glass TabView style with tabs: Home, Media, Feed, Bookmarks, and Search. Users must sign in with Google or Apple before accessing the main app.
The app uses StoreKit 2 for subscription management. Two AI-powered features require a premium subscription:
| Feature | Description |
|---|---|
| AI Daily Digest | Personalized summaries of your reading activity |
| Article Summarization | On-device AI summaries for any article |
Non-premium users see a PremiumGateView with an "Unlock Premium" button that presents the native StoreKit subscription UI.
Pulse implements a Unidirectional Data Flow Architecture based on Clean Architecture principles, using Combine for reactive data binding:
┌─────────────────────────────────────────────────────────────┐
│ View Layer │
│ (SwiftUI + @ObservedObject ViewModel) │
└─────────────────────────────────────────────────────────────┘
│ ViewEvent ↑ @Published ViewState
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ViewModel (CombineViewModel) + EventActionMap + Reducer │
└─────────────────────────────────────────────────────────────┘
│ DomainAction ↑ DomainState (Combine)
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ Interactor (CombineInteractor + statePublisher) │
└─────────────────────────────────────────────────────────────┘
│ ↑
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Service Layer │
│ (Protocol-based + Live/Mock implementations) │
└─────────────────────────────────────────────────────────────┘
│ ↑
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Network Layer │
│ (EntropyCore + SwiftData) │
└─────────────────────────────────────────────────────────────┘
| Protocol | Purpose |
|---|---|
CombineViewModel |
Base protocol for ViewModels with viewState and handle(event:) |
CombineInteractor |
Base protocol for domain layer with statePublisher and dispatch(action:) |
ViewStateReducing |
Transforms DomainState → ViewState |
DomainEventActionMap |
Maps ViewEvent → DomainAction |
The app uses a Coordinator + Router pattern with per-tab NavigationPaths:
CoordinatorView (@StateObject Coordinator)
│
TabView (selection: $coordinator.selectedTab)
│
┌───┴───┬──────┬─────────┬───────┐
Home Feed Bookmarks Search
│ │ │ │
NavigationStack(path: $coordinator.homePath)
│
.navigationDestination(for: Page.self)
│
coordinator.build(page:)
- Coordinator: Central navigation manager owning all tab paths
- CoordinatorView: Root TabView with NavigationStacks per tab
- Page: Type-safe enum of all navigable destinations
- NavigationRouter: Feature-specific routers (conforming to EntropyCore protocol)
- DeeplinkRouter: Routes URL schemes through the Coordinator
Views are generic over their router type (HomeView<R: HomeNavigationRouter>) for testability.
Follow these steps to add a new feature module to Pulse:
Pulse/
└── MyFeature/
├── API/
│ ├── MyFeatureService.swift # Protocol
│ └── LiveMyFeatureService.swift # Implementation
├── Domain/
│ ├── MyFeatureDomainState.swift
│ ├── MyFeatureDomainAction.swift
│ ├── MyFeatureDomainInteractor.swift
│ ├── MyFeatureEventActionMap.swift
│ └── MyFeatureViewStateReducer.swift
├── ViewModel/
│ └── MyFeatureViewModel.swift
├── View/
│ └── MyFeatureView.swift
├── ViewEvents/
│ └── MyFeatureViewEvent.swift
├── ViewStates/
│ └── MyFeatureViewState.swift
└── Router/
└── MyFeatureNavigationRouter.swift
// API/MyFeatureService.swift
protocol MyFeatureService {
func fetchData() -> AnyPublisher<[MyModel], Error>
}// Domain/MyFeatureDomainState.swift
struct MyFeatureDomainState: Equatable {
var items: [MyModel] = []
var isLoading: Bool = false
var error: String?
}
// Domain/MyFeatureDomainAction.swift
enum MyFeatureDomainAction {
case loadData
case dataLoaded([MyModel])
case loadFailed(String)
}// Domain/MyFeatureDomainInteractor.swift
@MainActor
final class MyFeatureDomainInteractor: CombineInteractor {
typealias DomainState = MyFeatureDomainState
typealias DomainAction = MyFeatureDomainAction
private let stateSubject = CurrentValueSubject<DomainState, Never>(.init())
var statePublisher: AnyPublisher<DomainState, Never> { stateSubject.eraseToAnyPublisher() }
private let myService: MyFeatureService
init(serviceLocator: ServiceLocator) {
self.myService = try! serviceLocator.retrieve(MyFeatureService.self)
}
func dispatch(action: DomainAction) {
switch action {
case .loadData:
loadData()
case .dataLoaded(let items):
stateSubject.value.items = items
stateSubject.value.isLoading = false
case .loadFailed(let error):
stateSubject.value.error = error
stateSubject.value.isLoading = false
}
}
}In PulseSceneDelegate.setupServices():
serviceLocator.register(MyFeatureService.self, instance: LiveMyFeatureService())Add a case to Page.swift and implement build(page:) in Coordinator.
// Domain/MyFeatureEventActionMap.swift
struct MyFeatureEventActionMap: DomainEventActionMap {
func map(event: MyFeatureViewEvent) -> MyFeatureDomainAction? {
switch event {
case .onAppear:
return .loadData
case .onRefresh:
return .loadData
case .onItemTapped(let id):
return .selectItem(id)
}
}
}// Domain/MyFeatureViewStateReducer.swift
struct MyFeatureViewStateReducer: ViewStateReducing {
func reduce(domainState: MyFeatureDomainState) -> MyFeatureViewState {
MyFeatureViewState(
items: domainState.items.map { ItemViewItem(from: $0) },
isLoading: domainState.isLoading,
showEmptyState: domainState.items.isEmpty && !domainState.isLoading,
errorMessage: domainState.error
)
}
}// Router/MyFeatureNavigationRouter.swift
@MainActor
protocol MyFeatureNavigationRouter: NavigationRouter where NavigationEvent == MyFeatureNavigationEvent {}
enum MyFeatureNavigationEvent {
case itemDetail(MyModel)
case settings
}
@MainActor
final class MyFeatureNavigationRouterImpl: MyFeatureNavigationRouter {
private weak var coordinator: Coordinator?
init(coordinator: Coordinator? = nil) {
self.coordinator = coordinator
}
func route(navigationEvent: MyFeatureNavigationEvent) {
switch navigationEvent {
case .itemDetail(let item):
coordinator?.push(page: .itemDetail(item))
case .settings:
coordinator?.push(page: .settings)
}
}
}// In Tests
func createTestServiceLocator() -> ServiceLocator {
let serviceLocator = ServiceLocator()
serviceLocator.register(MyFeatureService.self, instance: MockMyFeatureService())
return serviceLocator
}
@Test func testDataLoading() async {
let serviceLocator = createTestServiceLocator()
let interactor = MyFeatureDomainInteractor(serviceLocator: serviceLocator)
interactor.dispatch(action: .loadData)
// Assert state changes...
}- Xcode 26.0.1+
- iOS 26.1+
- Swift 5.0+
brew install xcodegenmake setupopen Pulse.xcodeprojAPI keys are managed via Firebase Remote Config (primary) with environment variable fallback for CI/CD:
# For CI/CD or local development without Remote Config
export GUARDIAN_API_KEY="your_guardian_key"
# Supabase backend configuration (optional - falls back to Guardian API)
export SUPABASE_URL="https://your-project.supabase.co"
export SUPABASE_ANON_KEY="your_anon_key"The app fetches keys from Remote Config on launch. Environment variables are used as fallback when Remote Config is unavailable. If Supabase is not configured, the app automatically falls back to the Guardian API.
| Command | Description |
|---|---|
make setup |
Install XcodeGen and generate project |
make xcode |
Generate project and open in Xcode |
make build |
Build for development |
make build-release |
Build for release |
make bump-patch |
Increase patch version (0.0.x) |
make bump-minor |
Increase minor version (0.x.0) |
make bump-major |
Increase major version (x.0.0) |
make test |
Run all tests |
make test-unit |
Run unit tests only |
make test-ui |
Run UI tests only |
make test-snapshot |
Run snapshot tests only |
make coverage |
Run tests with coverage report |
make lint |
Run SwiftFormat and SwiftLint |
make format |
Auto-format code |
make clean |
Remove generated project |
Pulse/
├── Pulse/
│ ├── Authentication/ # Firebase Auth (Google + Apple Sign-In)
│ │ ├── API/ # AuthService protocol + Live/Mock implementations
│ │ ├── Domain/ # AuthDomainInteractor, State, Action
│ │ ├── ViewModel/ # SignInViewModel
│ │ ├── View/ # SignInView
│ │ └── Manager/ # AuthenticationManager (global state)
│ ├── Home/ # Home feed with category filtering
│ │ ├── API/ # NewsService, SupabaseAPI, SupabaseModels
│ │ ├── Domain/ # Interactor, State, Action, Reducer, EventActionMap
│ │ ├── ViewModel/ # HomeViewModel
│ │ ├── View/ # SwiftUI views (includes category tabs)
│ │ ├── ViewEvents/ # HomeViewEvent
│ │ ├── ViewStates/ # HomeViewState
│ │ └── Router/ # HomeNavigationRouter
│ ├── Media/ # Videos and Podcasts browsing
│ ├── MediaDetail/ # Video/Podcast playback (AVPlayer, WKWebView)
│ ├── Feed/ # AI-powered Daily Digest
│ ├── Search/ # Search functionality
│ ├── Bookmarks/ # Saved articles
│ ├── Settings/ # User preferences + account/logout
│ ├── ArticleDetail/ # Article view
│ ├── SplashScreen/ # Launch animation
│ └── Configs/
│ ├── Navigation/ # Coordinator, Page, CoordinatorView, DeeplinkRouter
│ ├── DesignSystem/ # ColorSystem, Typography, Components, HapticManager
│ ├── Extensions/ # SwipeBackGesture and other utilities
│ ├── Models/ # Article, NewsCategory, UserPreferences
│ ├── Networking/ # APIKeysProvider, BaseURLs, SupabaseConfig
│ ├── Storage/ # StorageService (SwiftData)
│ ├── Mocks/ # Mock services for testing
│ └── Widget/ # WidgetDataManager
├── PulseTests/ # Unit tests (Swift Testing)
├── PulseUITests/ # UI tests (XCTest)
├── PulseSnapshotTests/ # Snapshot tests (SnapshotTesting)
├── .github/workflows/ # CI/CD
└── .claude/commands/ # Claude Code integration
| Package | Purpose |
|---|---|
| EntropyCore | UDF architecture protocols, networking, DI container |
| Firebase | Authentication (Google + Apple Sign-In) |
| GoogleSignIn | Google Sign-In SDK |
| SnapshotTesting | Snapshot testing |
| Lottie | Animations |
| LocalLlama (local package) | On-device LLM inference via llama.cpp |
GitHub Actions workflows:
- ci.yml: Runs on PRs - code quality, build, tests
- scheduled-tests.yml: Daily test runs at 2 AM UTC
| Scheme | Purpose |
|---|---|
PulseDev |
Development with all tests |
PulseProd |
Production release |
PulseTests |
Unit tests only |
PulseUITests |
UI tests only |
PulseSnapshotTests |
Snapshot tests only |
| Deeplink | Description |
|---|---|
pulse://home |
Open home tab |
pulse://media |
Open Media tab (Videos & Podcasts) |
pulse://feed |
Open Feed tab (AI Daily Digest) |
pulse://bookmarks |
Open bookmarks tab |
pulse://search |
Open search tab |
pulse://search?q=query |
Search with query |
pulse://settings |
Open settings (pushes onto Home) |
pulse://article?id=path/to/article |
Open specific article by Guardian content ID |
Tests for ViewModels, Interactors, and business logic using Swift Testing framework.
End-to-end tests for navigation and user flows using XCTest.
Visual regression tests for UI components using SnapshotTesting.
- Fork the repository
- Create a feature branch
- Make your changes
- Run
make lintandmake test - Submit a pull request
MIT License - see LICENSE for details.
The app fetches articles from a self-hosted RSS feed aggregator backend that:
- Aggregates news from multiple RSS sources (Guardian, BBC, TechCrunch, Science Daily, etc.)
- Extracts high-resolution
og:imagefrom article pages for hero images - Extracts full article content using Mozilla Readability
- Stores articles in Supabase database with automatic cleanup
When Supabase is not configured, the app falls back to the Guardian API directly.
- Supabase - Backend database and REST API
- Guardian API - Fallback news data provider