Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
241e122
Migrate UIKit navigation to pure SwiftUI NavigationStack
g-enius Feb 25, 2026
a323823
Fix stale references, update featured items, large nav titles
g-enius Feb 25, 2026
c9a7978
Adapt config for navigation-stack branch
g-enius Feb 27, 2026
2c3847a
Migrate UIKit navigation to pure SwiftUI NavigationStack
g-enius Feb 25, 2026
bab2512
Fix stale references, update featured items, large nav titles
g-enius Feb 25, 2026
7340029
Replace Combine with AsyncSequence and @Observable, bump to iOS 17
g-enius Feb 25, 2026
39f57cf
Orange app icon and unique bundle ID for async branch
g-enius Feb 25, 2026
5652e9c
Update featured items for async branch: AsyncSequence, @Observable, i…
g-enius Feb 25, 2026
886428c
Add concurrency patterns featured item
g-enius Feb 25, 2026
9787ec9
Update concurrency description to match real implementations
g-enius Feb 25, 2026
7bcac8e
Fix AppIcon size from 2048x2048 to 1024x1024
g-enius Feb 26, 2026
b87fe5b
Rename app display name to Fun Async
g-enius Feb 26, 2026
6b440c3
Fix search tests to work with debounce timing
g-enius Feb 26, 2026
4896633
Use polling instead of fixed sleep in search tests
g-enius Feb 26, 2026
43e4ab7
Adapt config for async-sequence branch
g-enius Feb 27, 2026
017c7ec
Remove polling from search tests
g-enius Feb 27, 2026
106a9c9
Trigger CI
g-enius Feb 27, 2026
2f414d2
Fix dark mode not applied on app launch
g-enius Feb 27, 2026
5732b46
Extract named navigation methods on AppCoordinator
g-enius Feb 28, 2026
710f42f
Split TechnologyDescriptions to fix type_body_length warning
g-enius Feb 28, 2026
4cf1d95
Register toast service in LoginSession and observe via serviceDidRegi…
g-enius Feb 28, 2026
65964a3
Move routing table to AppCoordinator.destinationView(for:)
g-enius Feb 28, 2026
049baa7
Add @ViewBuilder to destinationView for future routing
g-enius Feb 28, 2026
efa39f6
Restore general rules deleted during async-sequence migration
g-enius Feb 28, 2026
38f2442
Add @Bindable vs plain property rule and code comments
g-enius Mar 1, 2026
d4e78b3
Remove stale migration guide comments
g-enius Mar 1, 2026
be3b80f
Add Sendable doc comment to ServiceKey
g-enius Mar 1, 2026
c029953
Remove non-AsyncSequence-specific iOS 17 unlocks from deploymentTarge…
github-actions[bot] Mar 1, 2026
fc3ae9c
Restore base branch content in TechnologyDescriptions+Extended.swift
github-actions[bot] Mar 1, 2026
ae784d7
Restore TaskGroup example in concurrencyPatternsDescription to match …
github-actions[bot] Mar 1, 2026
c629b59
Rename TechnologyItem.combine → .asyncSequence across codebase
github-actions[bot] Mar 1, 2026
1671b58
Replace HomeViewModel favorites example with ItemsViewModel debounce …
github-actions[bot] Mar 1, 2026
958625c
Sync AsyncStream example with TaskGroup logic in concurrencyPatternsD…
github-actions[bot] Mar 1, 2026
1b4f7af
Clarify AsyncStream vs TaskGroup tradeoffs in concurrency patterns
g-enius Mar 1, 2026
ad5bd7a
Use continuation.finish() instead of break in AsyncStream example
g-enius Mar 1, 2026
e9f7c74
Fix remaining nanoseconds in ItemsViewModelTests
g-enius Mar 1, 2026
832e6fa
Revert Combine sink example to match base branch
github-actions[bot] Mar 1, 2026
6f07346
Rename *Changes stream properties to *Stream across all call sites
github-actions[bot] Mar 1, 2026
c34f6c4
Rename toastEvents → toastStream for consistent *Stream naming
g-enius Mar 1, 2026
4b563e2
Fix StreamBroadcaster race: use eager AsyncStream continuation
g-enius Mar 1, 2026
9f0df28
Remove redundant [weak self] from inner Task in StreamBroadcaster
github-actions[bot] Mar 1, 2026
da1ec92
Clarify iOS 17 requirement in README and PR description
g-enius Mar 1, 2026
3ab4af6
Rename feature/async-sequence to feature/observation
g-enius Mar 1, 2026
1b21607
Re-record snapshot references after rebase
g-enius Mar 2, 2026
b3158c8
Rename bundle to Fun Observation / .observation
g-enius Mar 2, 2026
77ceba6
Fix search re-triggering on keyboard dismiss
g-enius Mar 2, 2026
f8073a9
Add oldValue guards, fix tests, fix lint
g-enius Mar 2, 2026
58fea2a
Fix @Bindable annotations and resolve merge conflict markers
g-enius Mar 9, 2026
8545aa6
Bump version to 3.0.0, update docs for Phase 2 DI
g-enius Mar 9, 2026
28334ab
Fix rebase conflicts: remove duplicate LoginContent, restore session-…
g-enius Mar 22, 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
74 changes: 47 additions & 27 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 @@ -110,7 +116,8 @@ public final class AppCoordinator: ObservableObject, SessionProvider {

// 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:
// @ViewBuilder not required for a single expression today, but keeps
// this method ready for switch/if-else routing as destination types grow.
//
// switch item.category {
// case .article: ArticleDetailView(item: item)
Expand All @@ -130,16 +137,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 +193,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 +210,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
}
}
}
}
3 changes: 2 additions & 1 deletion Coordinator/Sources/Coordinator/AppRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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 Down
32 changes: 0 additions & 32 deletions Coordinator/Sources/Coordinator/LoginContent.swift

This file was deleted.

Loading