Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
00b1ea4
Replace Combine with AsyncSequence and @Observable, bump to iOS 17
g-enius Feb 25, 2026
8275d7c
Fix stale Combine and UIKit references in descriptions and CLAUDE.md
g-enius Feb 25, 2026
f77e16b
Orange app icon and unique bundle ID for async branch
g-enius Feb 25, 2026
3a7c8c7
Update featured items for async branch: AsyncSequence, @Observable, i…
g-enius Feb 25, 2026
37c033c
Add concurrency patterns featured item
g-enius Feb 25, 2026
e6404e9
Update concurrency description to match real implementations
g-enius Feb 25, 2026
91f8550
Fix AppIcon size from 2048x2048 to 1024x1024
g-enius Feb 26, 2026
dbd85ac
Rename app display name to Fun Async
g-enius Feb 26, 2026
e7c2227
Fix search tests to work with debounce timing
g-enius Feb 26, 2026
c6ee6b3
Use polling instead of fixed sleep in search tests
g-enius Feb 26, 2026
40c0c2b
Adapt config for async-sequence branch
g-enius Feb 27, 2026
af9466a
Remove polling from search tests
g-enius Feb 27, 2026
cb66c9e
Trigger CI
g-enius Feb 27, 2026
b93bdaa
Fix dark mode not applied on app launch
g-enius Feb 27, 2026
51c03e5
Extract named navigation methods on AppCoordinator
g-enius Feb 28, 2026
8d2ab1b
Split TechnologyDescriptions to fix type_body_length warning
g-enius Feb 28, 2026
df81cde
Register toast service in LoginSession and observe via serviceDidRegi…
g-enius Feb 28, 2026
9e5e395
Move routing table to AppCoordinator.destinationView(for:)
g-enius Feb 28, 2026
e628a8d
Add @ViewBuilder to destinationView for future routing
g-enius Feb 28, 2026
7a624ee
Document ownership wrapper pattern and add anti-pattern rule
g-enius Feb 28, 2026
25d3225
Restore general rules deleted during async-sequence migration
g-enius Feb 28, 2026
0386d0b
Split toastObservation into registrationObservation + toastObservation
g-enius Feb 28, 2026
7a70439
Unify service registration observation for toast and dark mode
g-enius Feb 28, 2026
564d638
Remove redundant toastObservation cancel in transitionToLoginFlow
g-enius Feb 28, 2026
9a32473
Add @Bindable vs plain property rule and code comments
g-enius Mar 1, 2026
21a922e
Remove stale migration guide comments
g-enius Mar 1, 2026
a40640c
Add Sendable doc comment to ServiceKey
g-enius Mar 1, 2026
e639abc
Remove non-AsyncSequence-specific iOS 17 unlocks from deploymentTarge…
github-actions[bot] Mar 1, 2026
a6e99be
Restore base branch content in TechnologyDescriptions+Extended.swift
github-actions[bot] Mar 1, 2026
a0afbeb
Restore TaskGroup example in concurrencyPatternsDescription to match …
github-actions[bot] Mar 1, 2026
6b90133
Rename TechnologyItem.combine → .asyncSequence across codebase
github-actions[bot] Mar 1, 2026
08b1c86
Replace HomeViewModel favorites example with ItemsViewModel debounce …
github-actions[bot] Mar 1, 2026
6b53e24
Sync AsyncStream example with TaskGroup logic in concurrencyPatternsD…
github-actions[bot] Mar 1, 2026
220ae7c
Clarify AsyncStream vs TaskGroup tradeoffs in concurrency patterns
g-enius Mar 1, 2026
c510a4a
Use continuation.finish() instead of break in AsyncStream example
g-enius Mar 1, 2026
14d4780
Fix remaining nanoseconds in ItemsViewModelTests
g-enius Mar 1, 2026
5718a3f
Revert Combine sink example to match base branch
github-actions[bot] Mar 1, 2026
7c5a113
Rename *Changes stream properties to *Stream across all call sites
github-actions[bot] Mar 1, 2026
02757ba
Rename toastEvents → toastStream for consistent *Stream naming
g-enius Mar 1, 2026
5791fad
Remove stale onPop/onShare from navigation closures list
g-enius Mar 1, 2026
ad17a8e
Fix StreamBroadcaster race: use eager AsyncStream continuation
g-enius Mar 1, 2026
87041ee
Remove unnecessary @Bindable from ItemsView outer struct
github-actions[bot] Mar 1, 2026
36783c6
Remove redundant [weak self] from inner Task in StreamBroadcaster
github-actions[bot] Mar 1, 2026
9813b16
Clarify iOS 17 requirement in README and PR description
g-enius Mar 1, 2026
35a741a
Rename feature/async-sequence to feature/observation
g-enius Mar 1, 2026
e696163
Re-record snapshot references after rebase
g-enius Mar 2, 2026
e017499
Rename bundle to Fun Observation / .observation
g-enius Mar 2, 2026
9dcdfe6
Fix search re-triggering on keyboard dismiss
g-enius Mar 2, 2026
7545fc6
Add oldValue guards, fix tests, fix lint
g-enius Mar 2, 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
23 changes: 12 additions & 11 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` 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**: ServiceLocator with `@Service` property wrapper, session-scoped (LoginSession / AuthenticatedSession)
- **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
- ServiceLocator with @Service property wrapper
- Combine over NotificationCenter for reactive state
- ServiceLocator with @Service property wrapper (assertionFailure, not fatalError)

## 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
104 changes: 60 additions & 44 deletions Coordinator/Sources/Coordinator/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,64 +5,74 @@
// 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 {
@Observable
public final class AppCoordinator {

// MARK: - Services

@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
private var currentSession: Session?
@ObservationIgnored private let sessionFactory: SessionFactory
@ObservationIgnored private var currentSession: Session?

// 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: - Service Observation

@ObservationIgnored private var registrationObservation: Task<Void, Never>?
@ObservationIgnored private var toastObservation: Task<Void, Never>?
@ObservationIgnored private var darkModeObservation: Task<Void, Never>?

// MARK: - Toast

@Published public var activeToast: ToastEvent?
private var cancellables = Set<AnyCancellable>()
public var activeToast: ToastEvent?

// MARK: - Dark Mode

@Published public var appearanceMode: AppearanceMode = .system
private var darkModeCancellable: AnyCancellable?
public var appearanceMode: AppearanceMode = .system

// MARK: - Init

public init(sessionFactory: SessionFactory) {
self.sessionFactory = sessionFactory
}

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

// MARK: - Start

public func start() {
activateSession(for: currentFlow)
observeToastEvents()
observeDarkMode()
observeServiceRegistrations()
}

// MARK: - Session Lifecycle
Expand Down Expand Up @@ -171,23 +181,34 @@ public final class AppCoordinator: ObservableObject {
}
}

// MARK: - Toast
// MARK: - Service Registration Observation

private func observeToastEvents() {
ServiceLocator.shared.serviceDidRegisterPublisher
.filter { $0 == .toast }
.sink { [weak self] _ in
self?.subscribeToToasts()
private func observeServiceRegistrations() {
registrationObservation?.cancel()
let registrations = ServiceLocator.shared.serviceRegistrations
registrationObservation = Task { [weak self] in
for await key in registrations {
guard let self else { break }
switch key {
case .toast: self.subscribeToToasts()
case .featureToggles: self.subscribeToDarkMode()
default: break
}
}
.store(in: &cancellables)
}
}

// MARK: - Toast

private func subscribeToToasts() {
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
}
.store(in: &cancellables)
}
}

public func dismissToast() {
Expand All @@ -196,20 +217,15 @@ public final class AppCoordinator: ObservableObject {

// MARK: - Dark Mode Observation

private func observeDarkMode() {
ServiceLocator.shared.serviceDidRegisterPublisher
.filter { $0 == .featureToggles }
.sink { [weak self] _ in
self?.subscribeToDarkMode()
}
.store(in: &cancellables)
}

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
Loading