A modern iOS news aggregation app built with Clean Architecture, SwiftUI, and Combine. Powered by a self-hosted Supabase RSS feed aggregator backend.
- Authentication: Firebase Auth with Google and Apple Sign-In (required before accessing app)
- Home Feed: Breaking news carousel, recently read section for quick access to previous articles, 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 latest news articles from the API using on-device LLM (Gemma 3 1B) (Premium)
- Article Summarization: On-device AI article summarization via sparkles button (Premium)
- Text-to-Speech: Listen to articles read aloud using native
AVSpeechSynthesizerwith play/pause controls, speed presets (1x/1.25x/1.5x/2x), language-aware voices, and a floating mini-player bar - Offline Experience: Tiered cache (in-memory L1 + persistent disk L2), automatic retry with exponential backoff for transient failures, network monitoring via NWPathMonitor, offline banner, and graceful degradation preserving cached content
- Bookmarks: Save articles for offline reading with SwiftData persistence
- Reading History: Automatic tracking of read articles with SwiftData persistence, visual indicators on cards, and a dedicated history view accessible from Settings
- Related Articles: After reading an article, discover related articles from the same category displayed in a horizontal carousel below the content
- Enhanced Sharing: Share articles with rich context — shared content includes the article title and source name alongside the URL for better social previews
- Search: Full-text search with 300ms debounce, suggestions, recent searches, and sort options
- Localization: Full multi-language support (English, Portuguese, Spanish) — both UI labels and content filtering follow the in-app language preference (via
AppLocalizationsingleton), no app restart required - Settings: Customize topics, notifications, theme, content language, content filters, and account/logout (accessed from Home navigation bar)
- Accessibility: Dynamic Type layout adaptation (HStack-to-VStack at accessibility sizes), VoiceOver heading hierarchy, focus management, and live announcements for async state changes
- Security: Input validation (deeplink sanitization, YouTube URL allowlisting, search query length limit), sign-out data cleanup (clears all caches, bookmarks, preferences, keychain, widget data), file-protected disk cache, privacy manifest (
PrivacyInfo.xcprivacy), and Keychain-based app lock with biometric + passcode fallback - Onboarding: 4-page first-launch experience shown once after sign-in, highlighting key features before entering the app
- Analytics & Crash Reporting: Firebase Analytics (25 type-safe events, including 4 CloudKit sync events) and Crashlytics for crash/non-fatal error tracking
- Widget: Home screen widget showing recent headlines (WidgetKit extension)
- App Intents & Siri Shortcuts: Five
AppIntenttypes (OpenPulseIntent,OpenDailyDigestIntent,OpenBookmarksIntent,SearchPulseIntent,OpenPulseSettingsIntent) exposed to Siri, Spotlight, and the Shortcuts app viaPulseAppShortcuts, with natural-language phrases (e.g., "Show my Daily Digest in Pulse"). All intents dispatch viaDeeplinkManagerto reuse in-app navigation - Live Activities: TTS playback surfaces on the Lock Screen and Dynamic Island via ActivityKit —
TTSActivityAttributes(shared between app + widget extension),TTSLiveActivityController(@MainActorsingleton) wired intoArticleDetailDomainInteractor, andTTSLockScreenView/TTSLiveActivityrendering expanded/compact/minimal presentations.LiveTextToSpeechServiceintegratesMPNowPlayingInfoCenter+MPRemoteCommandCenterso AirPods, CarPlay, and Lock Screen media controls drive TTS - Share Extension:
PulseShareExtensionaccepts apublic.urlfrom Safari/any iOS share sheet and offers Summarize with AI in Pulse. Because Gemma 3 1B exceeds the extension's ~120 MB memory budget, the extension serializes aSharedURLIteminto an App Group JSON-backed queue (SharedURLQueue) and opens the host app viapulse://shared; the main app drains the queue on foreground viaSharedURLImportService - Home Screen Quick Actions: Four long-press shortcuts (Search, Daily Digest, Bookmarks, Breaking News) registered dynamically at scene-connect time so titles localize per the in-app
AppLocalizationsetting.QuickActionHandlerroutes each throughDeeplinkManager(en/pt/es) - iCloud Cross-Device Sync: Bookmarks, reading history, and user preferences sync across a user's iOS devices via SwiftData's native CloudKit integration (
NSPersistentCloudKitContainer). Always-on, zero-UI — theCloudSyncService+CloudSyncDomainInteractorpair surfaces sync state via Combine publishers and posts.cloudSyncDidCompleteso Bookmarks, Reading History, Settings, and Home interactors reload from storage after remote merges - iPad Adaptive Layouts: Root shell swaps on
@Environment(\.horizontalSizeClass)— iPhone keeps the iOS 26 Liquid GlassTabView; iPad (regular width) renders aNavigationSplitViewwith a sidebar listing Home / Media / Feed / Bookmarks / Search. Article lists switch fromLazyVStacktoLazyVGrid(.adaptive(minimum: 360))on regular width; article detail caps content at 720pt for comfortable reading. Mac Catalyst groundwork is in place (stubs ready) but the flag is currently disabled pending a Catalyst slice for the vendoredswift-llama-cppxcframework
The app uses iOS 26's Liquid Glass TabView style on iPhone with tabs: Home, Media, Feed, Bookmarks, and Search. On iPad, the same tabs render as a NavigationSplitView sidebar. Users must sign in with Google or Apple before accessing the main app. A 4-page onboarding flow is shown once after first sign-in.
The app uses StoreKit 2 for subscription management. Two AI-powered features require a premium subscription:
| Feature | Description |
|---|---|
| AI Daily Digest | Summaries of the latest news across all categories |
| Article Summarization | On-device AI summaries for any article |
Non-premium users see a PremiumGateView on Feed or a paywall sheet when tapping the summarization button; both present 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 Media 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.registerLiveServices() (and add a mock in test setup if needed):
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.4+
- iOS 26.4+
- Swift 6.2+
brew install xcodegenmake setupopen Pulse.xcodeprojAPI keys are managed via Firebase Remote Config (primary) with environment variable fallback for DEBUG builds only:
# Supabase backend configuration (DEBUG builds only)
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 variable fallbacks are gated behind #if DEBUG and unavailable in release builds. Remote Config keys are validated for minimum length (10+ characters).
| Command | Description |
|---|---|
make init |
Setup Mint, SwiftFormat, and SwiftLint |
make install-xcodegen |
Install XcodeGen using Homebrew |
make generate |
Generate project from project.yml |
make setup |
install-xcodegen + generate |
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 test-debug |
Verbose unit test output |
make coverage |
Run tests with coverage report |
make coverage-report |
Per-file coverage report |
make coverage-badge |
Generate SVG coverage badge |
make lint |
Run SwiftFormat and SwiftLint |
make format |
Auto-format code |
make deeplink-test |
Run deeplink tests |
make clean |
Remove generated project |
make clean-packages |
Clean SPM caches |
make docs |
Generate DocC documentation |
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
│ ├── Media/ # Videos and Podcasts browsing
│ ├── MediaDetail/ # Video/Podcast playback (AVPlayer, WKWebView)
│ ├── Feed/ # AI-powered Daily Digest (Premium)
│ ├── Digest/ # On-device LLM infra (LLMService, LLMModelManager, prompts)
│ ├── Summarization/ # Article summarization (Premium)
│ ├── Search/ # Search functionality
│ ├── Bookmarks/ # Saved articles
│ ├── ReadingHistory/ # Reading history tracking (SwiftData)
│ ├── CloudSync/ # CloudKit sync lifecycle (domain interactor + notifications)
│ │ └── Domain/ # CloudSyncDomainState, Action, Interactor, Notifications
│ ├── Settings/ # User preferences + account/logout
│ ├── Notifications/ # Push notification permission and registration
│ ├── ArticleDetail/ # Article view + TTS (AVSpeechSynthesizer)
│ │ └── LiveActivities/ # TTSActivityAttributes, TTSLiveActivityController, TTSLockScreenView
│ ├── Intents/ # AppIntents for Siri / Spotlight / Shortcuts (OpenPulse, OpenDailyDigest, SearchPulse, etc.)
│ ├── QuickActions/ # Home screen quick actions (QuickActionType, QuickActionHandler)
│ ├── SharedURL/ # SharedURLImportService (drains Share Extension queue on foreground)
│ ├── AppLock/ # Biometric/passcode app lock
│ ├── Onboarding/ # First-launch onboarding flow
│ ├── Paywall/ # StoreKit paywall UI
│ ├── SplashScreen/ # Launch animation
│ └── Configs/
│ ├── Navigation/ # Coordinator, Page, CoordinatorView, DeeplinkRouter, AnimatedTabView
│ ├── DesignSystem/ # ColorSystem, Typography, Components, DynamicTypeHelpers, HapticManager
│ ├── Models/ # Article, NewsCategory, UserPreferences, AppLocalization
│ ├── Networking/ # APIKeysProvider, BaseURLs, SupabaseConfig, RemoteConfig, NetworkMonitorService, NetworkResilience
│ ├── Storage/ # StorageService (SwiftData + CloudKit private DB)
│ ├── CloudSync/ # CloudSyncService protocol + LiveCloudSyncService
│ ├── Analytics/ # AnalyticsService protocol + LiveAnalyticsService
│ ├── Mocks/ # Mock services for testing
│ └── Widget/ # WidgetDataManager
├── PulseWidgetExtension/ # WidgetKit extension + TTS Live Activity (TTSLiveActivity.swift)
├── PulseShareExtension/ # Share extension (accepts URLs, enqueues to App Group for main app summarization)
├── 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, Analytics, Crashlytics |
| GoogleSignIn | Google Sign-In SDK |
| SnapshotTesting | Snapshot testing |
| Lottie | Animations |
| swift-llama-cpp | On-device LLM inference (SwiftLlama wrapper around llama.cpp) |
GitHub Actions workflows:
- ci.yml: Runs on PRs - code quality, build, tests
- claude.yml: Claude Code on @claude mentions (issues/PRs/comments)
- claude-code-review.yml: Claude Code review on PR open/sync
- 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 article ID |
Tests for ViewModels, Interactors, and business logic using Swift Testing framework.
End-to-end tests for navigation and user flows using XCTest, plus accessibility audits (performAccessibilityAudit()) on all main screens.
Visual regression tests for UI components using SnapshotTesting, including Dynamic Type accessibility snapshot tests validating layout adaptation at large text sizes.
- 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 go-readability (Mozilla Readability port)
- Stores articles in Supabase database with automatic cleanup
- Supabase - Backend database and REST API