Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3bfb882
Migrate UIKit navigation to pure SwiftUI NavigationStack
g-enius Feb 25, 2026
2e19c60
Migrate UIKit navigation to pure SwiftUI NavigationStack
g-enius Feb 25, 2026
e0d0585
Fix stale references, update featured items, large nav titles
g-enius Feb 25, 2026
0a0ef42
Adapt config for navigation-stack branch
g-enius Feb 27, 2026
18a2229
Migrate UIKit navigation to pure SwiftUI NavigationStack
g-enius Feb 25, 2026
f582ebe
Fix stale references, update featured items, large nav titles
g-enius Feb 25, 2026
5743f2d
Replace Combine with AsyncSequence and @Observable, bump to iOS 17
g-enius Feb 25, 2026
b47d549
Orange app icon and unique bundle ID for async branch
g-enius Feb 25, 2026
5964692
Update featured items for async branch: AsyncSequence, @Observable, i…
g-enius Feb 25, 2026
8a1484b
Add concurrency patterns featured item
g-enius Feb 25, 2026
0bd3c55
Update concurrency description to match real implementations
g-enius Feb 25, 2026
c6042e6
Fix AppIcon size from 2048x2048 to 1024x1024
g-enius Feb 26, 2026
20c097d
Rename app display name to Fun Async
g-enius Feb 26, 2026
b6e4952
Fix search tests to work with debounce timing
g-enius Feb 26, 2026
f01d0b4
Use polling instead of fixed sleep in search tests
g-enius Feb 26, 2026
664a905
Adapt config for async-sequence branch
g-enius Feb 27, 2026
6d03b27
Remove polling from search tests
g-enius Feb 27, 2026
b85d997
Trigger CI
g-enius Feb 27, 2026
e277340
Fix dark mode not applied on app launch
g-enius Feb 27, 2026
adc802e
Extract named navigation methods on AppCoordinator
g-enius Feb 28, 2026
54d1d90
Split TechnologyDescriptions to fix type_body_length warning
g-enius Feb 28, 2026
b9cd14e
Register toast service in LoginSession and observe via serviceDidRegi…
g-enius Feb 28, 2026
0323432
Move routing table to AppCoordinator.destinationView(for:)
g-enius Feb 28, 2026
179ea90
Add @ViewBuilder to destinationView for future routing
g-enius Feb 28, 2026
908e879
Restore general rules deleted during async-sequence migration
g-enius Feb 28, 2026
089b742
Add @Bindable vs plain property rule and code comments
g-enius Mar 1, 2026
8cbdf6e
Remove stale migration guide comments
g-enius Mar 1, 2026
71d6cf2
Add Sendable doc comment to ServiceKey
g-enius Mar 1, 2026
1717d63
Remove non-AsyncSequence-specific iOS 17 unlocks from deploymentTarge…
github-actions[bot] Mar 1, 2026
1d6f274
Restore base branch content in TechnologyDescriptions+Extended.swift
github-actions[bot] Mar 1, 2026
2bbec1a
Restore TaskGroup example in concurrencyPatternsDescription to match …
github-actions[bot] Mar 1, 2026
6ef29ae
Rename TechnologyItem.combine → .asyncSequence across codebase
github-actions[bot] Mar 1, 2026
0b351cd
Replace HomeViewModel favorites example with ItemsViewModel debounce …
github-actions[bot] Mar 1, 2026
5345016
Sync AsyncStream example with TaskGroup logic in concurrencyPatternsD…
github-actions[bot] Mar 1, 2026
71336a2
Clarify AsyncStream vs TaskGroup tradeoffs in concurrency patterns
g-enius Mar 1, 2026
61bb2be
Use continuation.finish() instead of break in AsyncStream example
g-enius Mar 1, 2026
ab33fec
Fix remaining nanoseconds in ItemsViewModelTests
g-enius Mar 1, 2026
0498b56
Revert Combine sink example to match base branch
github-actions[bot] Mar 1, 2026
6855ba6
Rename *Changes stream properties to *Stream across all call sites
github-actions[bot] Mar 1, 2026
582e62c
Rename toastEvents → toastStream for consistent *Stream naming
g-enius Mar 1, 2026
8d10977
Fix StreamBroadcaster race: use eager AsyncStream continuation
g-enius Mar 1, 2026
7d5e745
Remove redundant [weak self] from inner Task in StreamBroadcaster
github-actions[bot] Mar 1, 2026
b225ab3
Clarify iOS 17 requirement in README and PR description
g-enius Mar 1, 2026
67bcb19
Rename feature/async-sequence to feature/observation
g-enius Mar 1, 2026
67d562a
Re-record snapshot references after rebase
g-enius Mar 2, 2026
f18a9cc
Rename bundle to Fun Observation / .observation
g-enius Mar 2, 2026
05e6d45
Fix search re-triggering on keyboard dismiss
g-enius Mar 2, 2026
555ee08
Add oldValue guards, fix tests, fix lint
g-enius Mar 2, 2026
7361d61
Fix @Bindable annotations and resolve merge conflict markers
g-enius Mar 9, 2026
ac743d4
Bump version to 3.0.0, update docs for Phase 2 DI
g-enius Mar 9, 2026
33ca656
Fix rebase conflicts: remove duplicate LoginContent, restore session-…
g-enius Mar 22, 2026
32b2245
fix rebase artifact: remove duplicate stale LoginTabContent with @Sta…
g-enius Apr 13, 2026
e2ddbd7
fix rebase artifact: remove stale destinationView method from navigat…
g-enius Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .claude/agents/change-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ Review all recent code changes thoroughly and provide a structured, actionable a

## Project Context

- **Branch**: feature/navigation-stack — Pure SwiftUI, NavigationPath, single AppCoordinator (ObservableObject), Combine
- **Branch**: feature/observation — Pure SwiftUI, @Observable, AsyncSequence + StreamBroadcaster, zero Combine
- **Packages**: `FunCore` → `FunModel` → `FunViewModel` / `FunServices` → `FunUI` → `FunCoordinator`
- **Dependency direction**: Never import upward. ViewModel must NOT import UI or Coordinator.
- **UIKit**: Zero UIKit in this branch — flag any `import UIKit` as a critical issue
- **Combine**: Zero Combine in this branch — flag any `import Combine` as critical
- **DI**: ServiceLocator with `@Service` property wrapper, session-scoped (LoginSession / AuthenticatedSession)
- **Testing**: Swift Testing framework, mocks in FunModelTestSupport
- **Lint**: SwiftLint with custom rules (no_print, weak_coordinator_in_viewmodel, no_direct_userdefaults)
Expand All @@ -38,19 +39,23 @@ Review all recent code changes thoroughly and provide a structured, actionable a
### Step 3: Architecture Check
- Package dependency direction respected?
- No `import UIKit` — pure SwiftUI branch
- No `import Combine` — pure AsyncSequence branch
- No coordinator references in ViewModels (except weak closures)
- No `print()` — use LoggerService
- No `UserDefaults.standard` outside Services
- Navigation logic only in Coordinators (AppCoordinator)
- NavigationPath mutations only in coordinator, not in Views
- Protocols in Core (reusable) or Model (domain), never in Services/ViewModel/UI/Coordinator
- Reactive pattern: Combine (`@Published`, `@StateObject`, `@ObservedObject`, `.sink`)
- Reactive pattern: `@Observable`, `AsyncStream`, `StreamBroadcaster`, `for await`, `Task`
- `@ObservationIgnored` on services and non-UI state
- `@State` (not `@StateObject`) for owning @Observable objects

### Step 4: Correctness Check
- **Logic errors**: Algorithms, conditions, control flow
- **Type safety**: Force unwraps, force casts, unsafe assumptions
- **Concurrency**: `@MainActor` isolation, `Sendable` conformance, Swift 6 strict
- **Memory management**: `[weak self]` and `[weak coordinator]` in closures
- **Memory management**: `[weak self]` and `[weak coordinator]` in closures, `guard let self` inside `for await` loops
- **Stream lifecycle**: Tasks stored for cancellation? Cleaned up properly?
- **API contracts**: Public interfaces used correctly

### Step 5: Quality Check
Expand Down
3 changes: 1 addition & 2 deletions .claude/skills/pull-request/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ Create a draft PR following the team's quality standards.
2. **Review changes**
- `git diff main...HEAD` to review all changes
- Verify package dependency direction isn't violated
- Check for any `print()`, `UserDefaults.standard`, or other anti-patterns
- Verify zero UIKit imports (this branch is pure SwiftUI)
- Check for any `print()`, `UserDefaults.standard`, `import Combine`, or `import UIKit`

3. **Accessibility checklist** (for UI changes)
- Dynamic Type: Do text elements scale with user font size preference?
Expand Down
4 changes: 3 additions & 1 deletion .claude/skills/review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ Review all recent code changes for completeness, correctness, and consistency wi
3. **Architecture check**
- Verify package dependency direction: `Coordinator → UI → ViewModel → Model → Core`, `Services → Model → Core`
- No `import UIKit` anywhere — this branch is pure SwiftUI
- No `import Combine` anywhere — this branch uses AsyncSequence, zero Combine
- No coordinator references in ViewModels (except weak closures)
- No `print()` — use LoggerService
- No `UserDefaults.standard` outside Services
- Navigation logic only in Coordinators
- Protocols in Core (reusable) or Model (domain), never in Services/ViewModel/UI/Coordinator
- Branch-specific: Combine + NavigationPath + single AppCoordinator (ObservableObject)
- Branch-specific: @Observable + AsyncStream + StreamBroadcaster (no Combine, no ObservableObject)

4. **Similar pattern search**
- Search the codebase for code that follows the same pattern as what changed
Expand All @@ -35,6 +36,7 @@ Review all recent code changes for completeness, correctness, and consistency wi
5. **Correctness check**
- Logic errors, type safety, concurrency (Swift 6 strict), memory management (`[weak self]`, `[weak coordinator]`)
- Verify `@MainActor` isolation, `Sendable` conformance where needed
- Check `@ObservationIgnored` on properties that shouldn't trigger view updates

6. **Cross-platform parity**
- Compare with `~/Documents/Source/Fun-Android/` for the same feature
Expand Down
25 changes: 13 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,42 +46,43 @@ Never import upward. ViewModel must NOT import UI or Coordinator. Model must NOT

## Anti-Patterns (Red Flags)
- `import UIKit` anywhere — this branch is pure SwiftUI, zero UIKit
- `import Combine` anywhere — this branch uses AsyncSequence, zero Combine
- Coordinator references in ViewModels (except weak optional closures) — retain cycle risk
- `print()` anywhere — use LoggerService
- `UserDefaults.standard` outside Services — use FeatureToggleService
- Adding `fatalError()` for missing services — ServiceLocator.resolve() already crashes with `fatalError` if a service isn't registered; don't add redundant guards
- Navigation logic in Views — all navigation (push, pop, tab switch, modal present/dismiss) must go through named AppCoordinator methods (`showDetail`, `selectTab`, `showProfile`, etc.), never inline property manipulation like `coordinator.homePath.append(item)` or `coordinator.isProfilePresented = true`
- Protocol definitions in Services — domain protocols go in Model, reusable abstractions in Core
- Wrong ownership annotations — tab content wrappers must use `@StateObject` to own ViewModels (not `@ObservedObject`). `@ObservedObject` on a ViewModel means it gets recreated on every re-render. Conversely, the coordinator must be `let` or `@ObservedObject` (not `@StateObject`) since the wrapper doesn't own it.
- Wrong ownership annotations — tab content wrappers must use `@State` to own ViewModels (not bare `var`). `@State` ensures the ViewModel survives re-renders. The coordinator must be `let` (not `@Bindable` or `@State`) since the wrapper doesn't own it.

## Architecture (this branch: feature/navigation-stack)
## Architecture (this branch: feature/observation)
- **Entry point**: SwiftUI `@main App` struct (`FunApp.swift`) — no AppDelegate or SceneDelegate
- **Navigation**: Single `AppCoordinator: ObservableObject, SessionProvider` with per-tab `NavigationPath`
- **Navigation**: Single `@Observable AppCoordinator` with per-tab `NavigationPath`
- **Views**: Pure SwiftUI views, no UIHostingController or UIViewControllers
- **Reactive**: Combine (`@Published`, `@StateObject`, `@ObservedObject`, `.sink`)
- **Reactive**: AsyncSequence + `StreamBroadcaster` (zero Combine). Services yield events via `StreamBroadcaster.yield()`, consumers iterate with `for await event in stream`
- **Observation**: `@Observable` (not ObservableObject), `@ObservationIgnored` for non-observed state, `@State` (not @StateObject) in app entry
- **ViewModel → Coordinator**: Optional closures wired in tab content wrappers via `.task { viewModel.onShowDetail = { ... } }`
- **Tab bar**: SwiftUI `TabView(selection: $coordinator.selectedTab)`
- **Push nav**: `coordinator.showDetail(item, in: .home)` — named methods on AppCoordinator
- **Modals**: `.sheet(isPresented: $coordinator.isProfilePresented)`
- **DI**: Session-scoped ServiceLocator — no `.shared` singleton. Each `Session` creates and owns its own `ServiceLocator`. On session transition, the old ServiceLocator is released with the session (no stale services). `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `ServiceLocatorProvider` conformance). Coordinators and ViewModels receive the current session via constructor injection.
- **DI**: Session-scoped ServiceLocator — no `.shared` singleton. Each `Session` creates and owns its own `ServiceLocator`. On session transition, the old ServiceLocator is released with the session (no stale services). `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `SessionProvider` conformance + `@ObservationIgnored` on `@Service` properties since `@Observable` can't observe them). Coordinators and ViewModels receive the current session via constructor injection.
- **Coordinator-owned views**: `AppRootView`, `MainTabView`, and tab content wrappers live in `Coordinator` (not `FunUI`) because they depend on `AppCoordinator`. Moving them to `FunUI` would create a circular dependency (`Coordinator → UI → Coordinator`). Pure reusable views (`HomeView`, `DetailView`, etc.) stay in `FunUI`.
- **Ownership wrappers**: Tab content wrappers (`HomeTabContent`, `ItemsTabContent`, etc.) use `@StateObject` to **own** their ViewModel and `@ObservedObject` (or `let`) for the coordinator passed from the parent. `@StateObject` ensures the ViewModel survives re-renders; `@ObservedObject` means the wrapper doesn't own the coordinator. Pure views in `FunUI` take `@ObservedObject var viewModel` since the wrapper owns it.
- **Ownership wrappers**: Tab content wrappers (`HomeTabContent`, `ItemsTabContent`, etc.) use `@State` to **own** their ViewModel and `let` for the coordinator passed from the parent. `@State` ensures the ViewModel survives re-renders; `let` means the wrapper doesn't own the coordinator. Pure views in `FunUI` take the ViewModel as a parameter since the wrapper owns it.

## Rule Index
Consult these files for detailed guidance (not auto-loaded — read on demand):
- `ai-rules/general.md` — Architecture deep-dive, MVVM-C patterns, DI, sessions, testing
- `ai-rules/swift-style.md` — Swift 6 concurrency, naming, Combine patterns, SwiftLint rules
- `ai-rules/swift-style.md` — Swift 6 concurrency, naming, AsyncSequence patterns, SwiftLint rules
- `ai-rules/ci-cd.md` — GitHub Actions CI workflow patterns

## Code Style
- Swift 6 strict concurrency, iOS 17+
- Pure SwiftUI (NavigationStack), MVVM-C with Combine
- Single AppCoordinator: ObservableObject with @Published NavigationPath per tab
- ViewModels use closures for navigation, wired in tab content wrappers
- Pure SwiftUI (NavigationStack), MVVM-C with AsyncSequence + @Observable
- Zero Combine — AsyncStream + StreamBroadcaster for reactive service events, @Observable for ViewModel state
- Navigation closures on ViewModels, wired by single AppCoordinator
- Navigation logic ONLY in Coordinators, never in Views
- Protocol placement: Core = reusable abstractions, Model = domain-specific
- Session-scoped ServiceLocator with `@Service` property wrapper — ViewModels conform to `SessionProvider`, store `let session: Session`
- Combine over NotificationCenter for reactive state
- Session-scoped ServiceLocator with `@Service` property wrapper — ViewModels conform to `SessionProvider`, store `let session: Session` (use `@ObservationIgnored` on `@Service` properties)

## Testing
- Swift Testing framework (`import Testing`, `@Test`, `#expect`, `@Suite`)
Expand Down
4 changes: 2 additions & 2 deletions Coordinator/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import PackageDescription
let package = Package(
name: "Coordinator",
platforms: [
.iOS(.v16),
.macCatalyst(.v16),
.iOS(.v17),
.macCatalyst(.v17),
],
products: [
.library(name: "FunCoordinator", targets: ["FunCoordinator"]),
Expand Down
87 changes: 45 additions & 42 deletions Coordinator/Sources/Coordinator/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,52 @@
// SwiftUI-based coordinator managing navigation state and app flow
//

import Combine
import Observation
import SwiftUI

import FunCore
import FunModel

@MainActor
public final class AppCoordinator: ObservableObject, SessionProvider {
@Observable
public final class AppCoordinator: SessionProvider {

// MARK: - DI

public private(set) var session: Session
@Service(.logger) private var logger: LoggerService
@Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol
@Service(.toast) private var toastService: ToastServiceProtocol
@ObservationIgnored @Service(.logger) private var logger: LoggerService
@ObservationIgnored @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol
@ObservationIgnored @Service(.toast) private var toastService: ToastServiceProtocol

// MARK: - Session Management

private let sessionFactory: SessionFactory
@ObservationIgnored private let sessionFactory: SessionFactory

// MARK: - App Flow State

@Published public var currentFlow: AppFlow = .login
public var currentFlow: AppFlow = .login

// MARK: - Navigation State

@Published public var selectedTab: TabIndex = .home
@Published public var homePath = NavigationPath()
@Published public var itemsPath = NavigationPath()
@Published public var settingsPath = NavigationPath()
@Published public var isProfilePresented = false
public var selectedTab: TabIndex = .home
public var homePath = NavigationPath()
public var itemsPath = NavigationPath()
public var settingsPath = NavigationPath()
public var isProfilePresented = false

// MARK: - Deep Link

private var pendingDeepLink: DeepLink?
@ObservationIgnored private var pendingDeepLink: DeepLink?

// MARK: - Toast

@Published public var activeToast: ToastEvent?
private var toastCancellable: AnyCancellable?
public var activeToast: ToastEvent?
@ObservationIgnored private var toastObservation: Task<Void, Never>?

// MARK: - Dark Mode

@Published public var appearanceMode: AppearanceMode = .system
private var darkModeCancellable: AnyCancellable?
public var appearanceMode: AppearanceMode = .system
@ObservationIgnored private var darkModeObservation: Task<Void, Never>?

// MARK: - Init

Expand All @@ -58,6 +59,11 @@ public final class AppCoordinator: ObservableObject, SessionProvider {
self.session = sessionFactory.makeSession(for: .login)
}

deinit {
toastObservation?.cancel()
darkModeObservation?.cancel()
}

// MARK: - Start

public func start() {
Expand Down Expand Up @@ -106,22 +112,6 @@ public final class AppCoordinator: ObservableObject, SessionProvider {
settingsPath = NavigationPath()
}

// MARK: - Routing

// Centralised routing table — called from both homeTab and itemsTab
// .navigationDestination closures, so destination logic lives in one place.
// As destination types grow, expand with a switch:
//
// switch item.category {
// case .article: ArticleDetailView(item: item)
// case .video: VideoPlayerView(item: item)
// default: DetailContent(item: item)
// }
@ViewBuilder
func destinationView(for item: FeaturedItem) -> some View {
DetailContent(item: item, coordinator: self)
}

// MARK: - Flow Transitions

public func transitionToMainFlow() {
Expand All @@ -130,16 +120,22 @@ public final class AppCoordinator: ObservableObject, SessionProvider {
observeToastEvents()
subscribeToDarkMode()

// Execute pending deep link after main flow is ready
if let deepLink = pendingDeepLink {
pendingDeepLink = nil
executeDeepLink(deepLink)
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 100_000_000)
self?.executeDeepLink(deepLink)
}
}
}

public func transitionToLoginFlow() {
currentFlow = .login
pendingDeepLink = nil
activateSession(for: .login)
subscribeToDarkMode()
toastObservation?.cancel()

// Reset navigation state
popToRoot()
Expand Down Expand Up @@ -180,11 +176,14 @@ public final class AppCoordinator: ObservableObject, SessionProvider {
// MARK: - Toast

private func observeToastEvents() {
toastCancellable?.cancel()
toastCancellable = toastService.toastPublisher
.sink { [weak self] event in
self?.activeToast = event
toastObservation?.cancel()
let stream = toastService.toastStream
toastObservation = Task { [weak self] in
for await event in stream {
guard let self else { break }
self.activeToast = event
}
}
}

public func dismissToast() {
Expand All @@ -194,10 +193,14 @@ public final class AppCoordinator: ObservableObject, SessionProvider {
// MARK: - Dark Mode Observation

private func subscribeToDarkMode() {
darkModeCancellable?.cancel()
darkModeCancellable = featureToggleService.appearanceModePublisher
.sink { [weak self] mode in
self?.appearanceMode = mode
darkModeObservation?.cancel()
appearanceMode = featureToggleService.appearanceMode
let stream = featureToggleService.appearanceModeStream
darkModeObservation = Task { [weak self] in
for await mode in stream {
guard let self else { break }
self.appearanceMode = mode
}
}
}
}
9 changes: 4 additions & 5 deletions Coordinator/Sources/Coordinator/AppRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
// AppRootView.swift
// Coordinator
//
// Root SwiftUI view that switches between login and main tab flow.
// Lives in Coordinator (not FunUI) because it depends on AppCoordinator.
// Moving to FunUI would create a circular dependency: Coordinator → UI → Coordinator.
// Root SwiftUI view that switches between login and main tab flow
//

import SwiftUI
Expand All @@ -15,7 +13,8 @@ import FunUI
import FunViewModel

public struct AppRootView: View {
@ObservedObject var coordinator: AppCoordinator
// Plain var — only reads coordinator properties, no $ bindings needed
var coordinator: AppCoordinator

public init(coordinator: AppCoordinator) {
self.coordinator = coordinator
Expand All @@ -25,7 +24,7 @@ public struct AppRootView: View {
Group {
switch coordinator.currentFlow {
case .login:
LoginContent(coordinator: coordinator)
LoginTabContent(coordinator: coordinator)
case .main:
MainTabView(coordinator: coordinator)
}
Expand Down
32 changes: 0 additions & 32 deletions Coordinator/Sources/Coordinator/LoginContent.swift

This file was deleted.

Loading
Loading