diff --git a/.claude/agents/change-reviewer.md b/.claude/agents/change-reviewer.md index 9f3c8142..9ca6f3f3 100644 --- a/.claude/agents/change-reviewer.md +++ b/.claude/agents/change-reviewer.md @@ -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) @@ -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 diff --git a/.claude/skills/pull-request/SKILL.md b/.claude/skills/pull-request/SKILL.md index cffd7b97..af07a2bc 100644 --- a/.claude/skills/pull-request/SKILL.md +++ b/.claude/skills/pull-request/SKILL.md @@ -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? diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 826c8c4a..cb024555 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ef58e585..2d9d026a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/Coordinator/Package.swift b/Coordinator/Package.swift index a01b355c..aa0abaa2 100644 --- a/Coordinator/Package.swift +++ b/Coordinator/Package.swift @@ -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"]), diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 7a280dab..e14c8f64 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -5,51 +5,56 @@ // 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? + @ObservationIgnored private var toastObservation: Task? + @ObservationIgnored private var darkModeObservation: Task? // MARK: - Toast - @Published public var activeToast: ToastEvent? - private var cancellables = Set() + public var activeToast: ToastEvent? // MARK: - Dark Mode - @Published public var appearanceMode: AppearanceMode = .system - private var darkModeCancellable: AnyCancellable? + public var appearanceMode: AppearanceMode = .system // MARK: - Init @@ -57,12 +62,17 @@ public final class AppCoordinator: ObservableObject { self.sessionFactory = sessionFactory } + deinit { + registrationObservation?.cancel() + toastObservation?.cancel() + darkModeObservation?.cancel() + } + // MARK: - Start public func start() { activateSession(for: currentFlow) - observeToastEvents() - observeDarkMode() + observeServiceRegistrations() } // MARK: - Session Lifecycle @@ -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() { @@ -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 } + } } } diff --git a/Coordinator/Sources/Coordinator/AppRootView.swift b/Coordinator/Sources/Coordinator/AppRootView.swift index a15b2113..f7077ac9 100644 --- a/Coordinator/Sources/Coordinator/AppRootView.swift +++ b/Coordinator/Sources/Coordinator/AppRootView.swift @@ -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 diff --git a/Coordinator/Sources/Coordinator/MainTabView.swift b/Coordinator/Sources/Coordinator/MainTabView.swift index 8bfde47d..e4af055e 100644 --- a/Coordinator/Sources/Coordinator/MainTabView.swift +++ b/Coordinator/Sources/Coordinator/MainTabView.swift @@ -15,7 +15,8 @@ import FunUI import FunViewModel struct MainTabView: View { - @ObservedObject var coordinator: AppCoordinator + // @Bindable — needs $ bindings for TabView selection, NavigationStack paths, .sheet + @Bindable var coordinator: AppCoordinator var body: some View { TabView(selection: $coordinator.selectedTab) { @@ -47,7 +48,6 @@ struct MainTabView: View { .navigationDestination(for: FeaturedItem.self) { item in coordinator.destinationView(for: item) } - // Chain more .navigationDestination(for:) to handle additional pushable types. } .tabItem { Label(L10n.Tabs.home, systemImage: "house") @@ -62,7 +62,6 @@ struct MainTabView: View { .navigationDestination(for: FeaturedItem.self) { item in coordinator.destinationView(for: item) } - // Chain more .navigationDestination(for:) to handle additional pushable types. } .tabItem { Label(L10n.Tabs.items, systemImage: "list.bullet") @@ -88,7 +87,7 @@ struct MainTabView: View { /// Wrapper that creates HomeViewModel with navigation closures wired to coordinator struct HomeTabContent: View { let coordinator: AppCoordinator - @StateObject private var viewModel = HomeViewModel() + @State private var viewModel = HomeViewModel() var body: some View { HomeView(viewModel: viewModel) @@ -106,7 +105,7 @@ struct HomeTabContent: View { /// Wrapper that creates ItemsViewModel with navigation closures wired to coordinator struct ItemsTabContent: View { let coordinator: AppCoordinator - @StateObject private var viewModel = ItemsViewModel() + @State private var viewModel = ItemsViewModel() var body: some View { ItemsView(viewModel: viewModel) @@ -120,7 +119,7 @@ struct ItemsTabContent: View { /// Wrapper that creates SettingsViewModel struct SettingsTabContent: View { - @StateObject private var viewModel = SettingsViewModel() + @State private var viewModel = SettingsViewModel() var body: some View { SettingsView(viewModel: viewModel) @@ -129,10 +128,10 @@ struct SettingsTabContent: View { /// Wrapper that creates DetailViewModel for a pushed item struct DetailTabContent: View { - @StateObject private var viewModel: DetailViewModel + @State private var viewModel: DetailViewModel init(item: FeaturedItem) { - _viewModel = StateObject(wrappedValue: DetailViewModel(item: item)) + _viewModel = State(initialValue: DetailViewModel(item: item)) } var body: some View { @@ -143,7 +142,7 @@ struct DetailTabContent: View { /// Wrapper that creates ProfileViewModel with navigation closures struct ProfileTabContent: View { let coordinator: AppCoordinator - @StateObject private var viewModel = ProfileViewModel() + @State private var viewModel = ProfileViewModel() var body: some View { ProfileView(viewModel: viewModel) @@ -166,7 +165,7 @@ struct ProfileTabContent: View { /// Wrapper that creates LoginViewModel with login success closure struct LoginTabContent: View { let coordinator: AppCoordinator - @StateObject private var viewModel = LoginViewModel() + @State private var viewModel = LoginViewModel() var body: some View { LoginView(viewModel: viewModel) diff --git a/Core/Package.swift b/Core/Package.swift index e61a8491..14843bca 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -5,8 +5,8 @@ let package = Package( name: "Core", defaultLocalization: "en", platforms: [ - .iOS(.v16), - .macCatalyst(.v16), + .iOS(.v17), + .macCatalyst(.v17), ], products: [ .library(name: "FunCore", targets: ["FunCore"]), diff --git a/Core/Sources/Core/ServiceLocator.swift b/Core/Sources/Core/ServiceLocator.swift index a3a84f40..a1400555 100644 --- a/Core/Sources/Core/ServiceLocator.swift +++ b/Core/Sources/Core/ServiceLocator.swift @@ -5,13 +5,18 @@ // Central registry for dependency injection // -import Combine import Foundation // MARK: - Service Key /// Enum defining all available services -public enum ServiceKey { +/// +/// `Sendable` conformance is required because `ServiceKey` is used as the element type of +/// `StreamBroadcaster`, which enforces `Element: Sendable`. `AsyncStream` values +/// flow across actor/concurrency boundaries between producer and consumer, so Swift 6 strict +/// concurrency requires the element type to be `Sendable`. Combine's `PassthroughSubject` had +/// no such requirement. +public enum ServiceKey: Sendable { case network case logger case favorites @@ -32,10 +37,10 @@ public class ServiceLocator { /// Registered services private var services: [ServiceKey: Any] = [:] - /// Emits the key whenever a service is registered - private let registrationSubject = PassthroughSubject() - public var serviceDidRegisterPublisher: AnyPublisher { - registrationSubject.eraseToAnyPublisher() + /// Broadcasts a key whenever a service is registered + private let registrationBroadcaster = StreamBroadcaster() + public var serviceRegistrations: AsyncStream { + registrationBroadcaster.makeStream() } private init() {} @@ -43,7 +48,7 @@ public class ServiceLocator { /// Register a service public func register(_ service: T, for key: ServiceKey) { services[key] = service - registrationSubject.send(key) + registrationBroadcaster.yield(key) } /// Resolve a service (crashes if not registered) diff --git a/Core/Sources/Core/StreamBroadcaster.swift b/Core/Sources/Core/StreamBroadcaster.swift new file mode 100644 index 00000000..990c3f60 --- /dev/null +++ b/Core/Sources/Core/StreamBroadcaster.swift @@ -0,0 +1,47 @@ +// +// StreamBroadcaster.swift +// Core +// +// Multi-consumer AsyncStream broadcaster. Replaces Combine Subjects +// for one-to-many reactive state distribution. +// + +import Foundation + +@MainActor +public final class StreamBroadcaster { + + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + public init() {} + + /// Creates a new AsyncStream that receives all future yielded values. + /// Each caller gets an independent stream — safe for multiple consumers. + /// Uses eager continuation registration so values yielded before iteration are buffered. + public func makeStream() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream(of: Element.self) + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.continuations.removeValue(forKey: id) + } + } + return stream + } + + /// Sends a value to all active streams. + public func yield(_ value: Element) { + for continuation in continuations.values { + continuation.yield(value) + } + } + + /// Finishes all active streams. + public func finish() { + for continuation in continuations.values { + continuation.finish() + } + continuations.removeAll() + } +} diff --git a/FunApp/FunApp.xcodeproj/project.pbxproj b/FunApp/FunApp.xcodeproj/project.pbxproj index e68fe133..c7003623 100644 --- a/FunApp/FunApp.xcodeproj/project.pbxproj +++ b/FunApp/FunApp.xcodeproj/project.pbxproj @@ -229,7 +229,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.swiftui; + PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.observation; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -260,7 +260,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.swiftui; + PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.observation; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -324,7 +324,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -381,7 +381,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png index af467781..56f6abac 100644 Binary files a/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/FunApp/FunApp/FunApp.swift b/FunApp/FunApp/FunApp.swift index 7c6c110b..9ade2e01 100644 --- a/FunApp/FunApp/FunApp.swift +++ b/FunApp/FunApp/FunApp.swift @@ -12,7 +12,7 @@ import FunModel @main struct FunApp: App { - @StateObject private var coordinator = AppCoordinator( + @State private var coordinator = AppCoordinator( sessionFactory: AppSessionFactory() ) diff --git a/FunApp/FunApp/Info.plist b/FunApp/FunApp/Info.plist index f825c8cf..c43a07b9 100644 --- a/FunApp/FunApp/Info.plist +++ b/FunApp/FunApp/Info.plist @@ -3,7 +3,7 @@ CFBundleDisplayName - Fun SwiftUI + Fun Observation CFBundleURLTypes diff --git a/Model/Package.swift b/Model/Package.swift index 2ed4baec..7d516dbf 100644 --- a/Model/Package.swift +++ b/Model/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Model", platforms: [ - .iOS(.v16), - .macCatalyst(.v16), + .iOS(.v17), + .macCatalyst(.v17), ], products: [ .library(name: "FunModel", targets: ["FunModel"]), diff --git a/Model/Sources/Model/FeaturedItem.swift b/Model/Sources/Model/FeaturedItem.swift index a214ecc0..57ab4281 100644 --- a/Model/Sources/Model/FeaturedItem.swift +++ b/Model/Sources/Model/FeaturedItem.swift @@ -62,10 +62,10 @@ public extension FeaturedItem { category: "Concurrency" ) - static let combine = FeaturedItem( - id: TechnologyItem.combine.rawValue, - title: "Combine", - subtitle: "Reactive programming", + static let asyncSequence = FeaturedItem( + id: TechnologyItem.asyncSequence.rawValue, + title: "AsyncSequence", + subtitle: "Reactive streams (zero Combine)", iconName: "arrow.triangle.merge", iconColor: .orange, category: "Reactive" @@ -75,7 +75,7 @@ public extension FeaturedItem { static let swiftUI = FeaturedItem( id: TechnologyItem.swiftUI.rawValue, title: "SwiftUI", - subtitle: "Pure SwiftUI + NavigationStack", + subtitle: "Pure SwiftUI + @Observable", iconName: "swift", iconColor: .blue, category: "UI Framework" @@ -84,7 +84,7 @@ public extension FeaturedItem { static let coordinator = FeaturedItem( id: TechnologyItem.coordinator.rawValue, title: "Coordinator", - subtitle: "Single ObservableObject", + subtitle: "Single @Observable AppCoordinator", iconName: "arrow.triangle.branch", iconColor: .purple, category: "Navigation" @@ -94,7 +94,7 @@ public extension FeaturedItem { static let mvvm = FeaturedItem( id: TechnologyItem.mvvm.rawValue, title: "MVVM", - subtitle: "Architecture pattern", + subtitle: "@Observable ViewModels", iconName: "square.stack.3d.up", iconColor: .indigo, category: "Architecture" @@ -189,7 +189,7 @@ public extension FeaturedItem { static let concurrencyPatterns = FeaturedItem( id: TechnologyItem.concurrencyPatterns.rawValue, title: "Concurrency Patterns", - subtitle: "Callbacks vs Combine vs async/await", + subtitle: "Callbacks vs AsyncStream vs async/await", iconName: "arrow.triangle.2.circlepath", iconColor: .orange, category: "Concurrency" @@ -197,7 +197,7 @@ public extension FeaturedItem { static let deploymentTarget = FeaturedItem( id: TechnologyItem.deploymentTarget.rawValue, - title: "iOS 16+", + title: "iOS 17+", subtitle: "Minimum deployment target", iconName: "iphone.gen3", iconColor: .yellow, @@ -205,7 +205,7 @@ public extension FeaturedItem { ) // Carousel sets (2 items per page) - private static let carouselSet1: [FeaturedItem] = [.asyncAwait, .combine] + private static let carouselSet1: [FeaturedItem] = [.asyncAwait, .asyncSequence] private static let carouselSet2: [FeaturedItem] = [.swiftUI, .coordinator] private static let carouselSet3: [FeaturedItem] = [.mvvm, .spmModules] private static let carouselSet4: [FeaturedItem] = [.serviceLocator, .protocolOriented] diff --git a/Model/Sources/Model/Services/FavoritesServiceProtocol.swift b/Model/Sources/Model/Services/FavoritesServiceProtocol.swift index 826bbc0e..11a6ed17 100644 --- a/Model/Sources/Model/Services/FavoritesServiceProtocol.swift +++ b/Model/Sources/Model/Services/FavoritesServiceProtocol.swift @@ -5,15 +5,14 @@ // Protocol for favorites service // -import Combine import Foundation @MainActor public protocol FavoritesServiceProtocol { var favorites: Set { get } - /// Publisher that emits when favorites change - var favoritesDidChange: AnyPublisher, Never> { get } + /// Stream that emits when favorites change + var favoritesStream: AsyncStream> { get } func isFavorited(_ itemId: String) -> Bool func toggleFavorite(_ itemId: String) @@ -23,27 +22,3 @@ public protocol FavoritesServiceProtocol { /// Clear all favorites and reset to default state func resetFavorites() } - -// MARK: - Swift Concurrency Alternative (iOS 15+) -// -// AsyncStream replaces AnyPublisher for service event delivery. -// No Combine import needed. Available from iOS 15 (same as this branch). -// -// // Protocol -// var favoritesChanges: AsyncStream> { get } -// -// // Consumer — Task cancellation replaces AnyCancellable -// let stream = favoritesService.favoritesChanges -// observation = Task { [weak self] in -// for await favorites in stream { -// guard let self else { break } // guard INSIDE loop to avoid retain cycle -// self.favoriteIds = favorites -// } -// } -// -// Key difference: AsyncStream only delivers future values (unlike @Published which emits -// the current value on subscribe). Read the property directly at init time: -// favoriteIds = favoritesService.favorites // current -// // then subscribe to favoritesChanges // future -// -// See feature/observation for the full implementation. diff --git a/Model/Sources/Model/Services/FeatureToggleServiceProtocol.swift b/Model/Sources/Model/Services/FeatureToggleServiceProtocol.swift index c27862d2..c14ec1f2 100644 --- a/Model/Sources/Model/Services/FeatureToggleServiceProtocol.swift +++ b/Model/Sources/Model/Services/FeatureToggleServiceProtocol.swift @@ -5,7 +5,6 @@ // Protocol for feature toggle service // -import Combine import Foundation @MainActor @@ -15,6 +14,6 @@ public protocol FeatureToggleServiceProtocol: AnyObject { var aiSummary: Bool { get set } var appearanceMode: AppearanceMode { get set } - var featuredCarouselPublisher: AnyPublisher { get } - var appearanceModePublisher: AnyPublisher { get } + var featuredCarouselStream: AsyncStream { get } + var appearanceModeStream: AsyncStream { get } } diff --git a/Model/Sources/Model/Services/ToastServiceProtocol.swift b/Model/Sources/Model/Services/ToastServiceProtocol.swift index b92a61f3..d731a695 100644 --- a/Model/Sources/Model/Services/ToastServiceProtocol.swift +++ b/Model/Sources/Model/Services/ToastServiceProtocol.swift @@ -5,7 +5,6 @@ // Protocol for toast notification service // -import Combine import Foundation public enum ToastType: Sendable { @@ -36,6 +35,6 @@ public struct ToastEvent: Sendable { public protocol ToastServiceProtocol { func showToast(message: String, type: ToastType) - /// Publisher for toast events - var toastPublisher: AnyPublisher { get } + /// Stream for toast events + var toastStream: AsyncStream { get } } diff --git a/Model/Sources/Model/TechnologyDescriptions+Extended.swift b/Model/Sources/Model/TechnologyDescriptions+Extended.swift index 5675c990..1598f36b 100644 --- a/Model/Sources/Model/TechnologyDescriptions+Extended.swift +++ b/Model/Sources/Model/TechnologyDescriptions+Extended.swift @@ -41,13 +41,11 @@ extension TechnologyDescriptions { """ static let deploymentTargetDescription = """ - This branch requires iOS 16.0 as the minimum deployment target. + This branch requires iOS 17.0 as the minimum deployment target. - iOS 16 unlocks: - • NavigationStack + NavigationPath for programmatic navigation - • .navigationDestination(for:) type-safe routing - • SwiftUI TabView improvements - • ShareLink and other modern SwiftUI APIs + iOS 17 unlocks: + • @Observable macro (Observation framework) — per-property tracking + • @Bindable for two-way bindings with @Observable classes Three branches demonstrate progressive iOS version requirements: • main: iOS 15+ (UIKit navigation + Combine) @@ -82,21 +80,25 @@ extension TechnologyDescriptions { Note: A serial queue would execute fetches one at a time. The concurrent \ queue runs all 3 in parallel, and the barrier flag ensures thread-safe writes. - 2. Combine (Publishers.MergeMany): + 2. AsyncStream (makeStream + continuation): ```swift - let publishers = (0..<3).map { page in - Future<[Item], Never> { promise in - promise(.success(fetchPage(page))) - } + let (stream, continuation) = AsyncStream.makeStream(of: (Int, [Item]).self) + for page in 0..<3 { + Task { continuation.yield((page, await fetchPage(page))) } } - Publishers.MergeMany(publishers) - .collect() - .map { $0.flatMap { $0 } } - .sink { items in self.allItems = items } - .store(in: &cancellables) + var results: [(Int, [Item])] = [] + for await result in stream { + results.append(result) + if results.count == 3 { continuation.finish() } + } + let items = results.sorted { $0.0 < $1.0 }.flatMap { $0.1 } ``` + Zero Combine — this branch uses AsyncStream for all reactive patterns. + Note: AsyncStream needs manual termination (`continuation.finish()` when count \ + reached) because the stream has no concept of "all producers finished" — unlike \ + TaskGroup which tracks its children automatically. - 3. async/await (TaskGroup): + 3. async/await (TaskGroup) — preferred for parallel work: ```swift let items = await withTaskGroup(of: (Int, [Item]).self) { group in for page in 0..<3 { @@ -107,7 +109,9 @@ extension TechnologyDescriptions { return results.sorted { $0.0 < $1.0 }.flatMap { $0.1 } } ``` - - All three produce identical results. async/await is the cleanest syntax. + TaskGroup is structured concurrency: all children are scoped to the group, \ + cancelling the parent cancels all children, and `for await` ends automatically \ + when all children complete. Prefer TaskGroup for parallel work you own end-to-end; \ + use AsyncStream for bridging event/callback APIs or reactive service streams. """ } diff --git a/Model/Sources/Model/TechnologyDescriptions.swift b/Model/Sources/Model/TechnologyDescriptions.swift index 1ac7523f..08fef417 100644 --- a/Model/Sources/Model/TechnologyDescriptions.swift +++ b/Model/Sources/Model/TechnologyDescriptions.swift @@ -9,7 +9,7 @@ import Foundation public enum TechnologyItem: String, CaseIterable, Sendable { case asyncAwait = "asyncawait" - case combine = "combine" + case asyncSequence = "asyncsequence" case swiftUI = "swiftui" case coordinator = "coordinator" case mvvm = "mvvm" @@ -40,7 +40,7 @@ public enum TechnologyDescriptions { private static let descriptions: [TechnologyItem: String] = [ .asyncAwait: asyncAwaitDescription, - .combine: combineDescription, + .asyncSequence: asyncSequenceDescription, .swiftUI: swiftUIDescription, .coordinator: coordinatorDescription, .mvvm: mvvmDescription, @@ -76,21 +76,36 @@ public enum TechnologyDescriptions { ``` """ - private static let combineDescription = """ - Combine framework powers the reactive data flow throughout the app: + private static let asyncSequenceDescription = """ + This branch replaced Combine entirely with Swift Concurrency: - • @Published properties for automatic UI updates - • Debounced search input (600ms) in Items screen - • Feature toggle change notifications - • Favorites state synchronization across views - • Scene lifecycle observation + • AsyncStream for service event delivery + • StreamBroadcaster for multi-consumer streams + • Task-based observation with for-await loops + • @Observable macro for ViewModel state + • didSet + Task.sleep for debounced search - Example from ItemsViewModel: + Example from ItemsViewModel (replacing .debounce): ```swift + // Before (Combine) $searchText .debounce(for: .milliseconds(600), scheduler: RunLoop.main) .sink { self.performSearch() } .store(in: &cancellables) + + // After (AsyncSequence) + var searchText: String = "" { + didSet { handleSearchTextChanged() } + } + + private func handleSearchTextChanged() { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled, let self else { return } + self.processSearchText() + } + } ``` """ @@ -99,7 +114,8 @@ public enum TechnologyDescriptions { • All views built with SwiftUI (HomeView, ItemsView, etc.) • NavigationStack + NavigationPath for programmatic navigation - • @ObservedObject for ViewModel binding + • @Bindable for two-way ViewModel binding + • @State for ViewModel ownership • Modern modifiers: .refreshable, .swipeActions, .searchable Navigation: @@ -116,9 +132,9 @@ public enum TechnologyDescriptions { private static let coordinatorDescription = """ A single AppCoordinator manages all navigation: - • ObservableObject owning NavigationPath per tab + • @Observable class owning NavigationPath per tab • Programmatic push via path.append() - • Modal presentation via @Published booleans + • Modal presentation via @Bindable booleans • ViewModels receive navigation closures, not coordinator refs Flow: @@ -135,9 +151,9 @@ public enum TechnologyDescriptions { • Model: Data structures and protocols Each screen follows this pattern: - HomeView (@ObservedObject viewModel) + HomeView (@Bindable viewModel) ↓ binds to - HomeViewModel (@Published state) + HomeViewModel (@Observable, per-property tracking) ↓ uses Services (Network, Favorites, etc.) @@ -213,14 +229,15 @@ public enum TechnologyDescriptions { Runtime feature flags with reactive updates: • Persisted via UserDefaults - • Combine publisher for cross-component sync + • AsyncStream for cross-component sync • Toggle carousel visibility in Settings Usage: ```swift - featureToggleService.featuredCarouselPublisher - .sink { newValue in self.isCarouselEnabled = newValue } - .store(in: &cancellables) + let stream = featureToggleService.featuredCarouselStream + Task { for await newValue in stream { + self.isCarouselEnabled = newValue + }} ``` Try it: Go to Settings → Toggle "Featured Carousel" @@ -253,7 +270,7 @@ public enum TechnologyDescriptions { Example: ```swift @MainActor - public class HomeViewModel: ObservableObject { + @Observable public class HomeViewModel { // All UI-related code is main-thread safe } diff --git a/Model/Sources/ModelTestSupport/MockFavoritesService.swift b/Model/Sources/ModelTestSupport/MockFavoritesService.swift index fe21a200..b74bdb99 100644 --- a/Model/Sources/ModelTestSupport/MockFavoritesService.swift +++ b/Model/Sources/ModelTestSupport/MockFavoritesService.swift @@ -5,7 +5,7 @@ // Mock implementation of FavoritesServiceProtocol for testing // -import Combine +import FunCore import FunModel @MainActor @@ -13,15 +13,14 @@ public final class MockFavoritesService: FavoritesServiceProtocol { public private(set) var favorites: Set - private let favoritesSubject: CurrentValueSubject, Never> + private let favoritesBroadcaster = StreamBroadcaster>() - public var favoritesDidChange: AnyPublisher, Never> { - favoritesSubject.eraseToAnyPublisher() + public var favoritesStream: AsyncStream> { + favoritesBroadcaster.makeStream() } public init(initialFavorites: Set = []) { self.favorites = initialFavorites - self.favoritesSubject = CurrentValueSubject(initialFavorites) } public func isFavorited(_ itemId: String) -> Bool { @@ -34,21 +33,21 @@ public final class MockFavoritesService: FavoritesServiceProtocol { } else { favorites.insert(itemId) } - favoritesSubject.send(favorites) + favoritesBroadcaster.yield(favorites) } public func addFavorite(_ itemId: String) { favorites.insert(itemId) - favoritesSubject.send(favorites) + favoritesBroadcaster.yield(favorites) } public func removeFavorite(_ itemId: String) { favorites.remove(itemId) - favoritesSubject.send(favorites) + favoritesBroadcaster.yield(favorites) } public func resetFavorites() { favorites.removeAll() - favoritesSubject.send(favorites) + favoritesBroadcaster.yield(favorites) } } diff --git a/Model/Sources/ModelTestSupport/MockFeatureToggleService.swift b/Model/Sources/ModelTestSupport/MockFeatureToggleService.swift index 86489bc2..58489778 100644 --- a/Model/Sources/ModelTestSupport/MockFeatureToggleService.swift +++ b/Model/Sources/ModelTestSupport/MockFeatureToggleService.swift @@ -5,23 +5,36 @@ // Mock implementation of FeatureToggleServiceProtocol for testing // -import Combine +import FunCore import FunModel @MainActor public final class MockFeatureToggleService: FeatureToggleServiceProtocol { - @Published public var featuredCarousel: Bool - @Published public var simulateErrors: Bool - @Published public var aiSummary: Bool - @Published public var appearanceMode: AppearanceMode + public var featuredCarousel: Bool { + didSet { + guard featuredCarousel != oldValue else { return } + carouselBroadcaster.yield(featuredCarousel) + } + } + public var simulateErrors: Bool + public var aiSummary: Bool + public var appearanceMode: AppearanceMode { + didSet { + guard appearanceMode != oldValue else { return } + appearanceBroadcaster.yield(appearanceMode) + } + } + + private let carouselBroadcaster = StreamBroadcaster() + private let appearanceBroadcaster = StreamBroadcaster() - public var featuredCarouselPublisher: AnyPublisher { - $featuredCarousel.removeDuplicates().eraseToAnyPublisher() + public var featuredCarouselStream: AsyncStream { + carouselBroadcaster.makeStream() } - public var appearanceModePublisher: AnyPublisher { - $appearanceMode.removeDuplicates().eraseToAnyPublisher() + public var appearanceModeStream: AsyncStream { + appearanceBroadcaster.makeStream() } public init(featuredCarousel: Bool = true, simulateErrors: Bool = false, aiSummary: Bool = true, appearanceMode: AppearanceMode = .system) { diff --git a/Model/Sources/ModelTestSupport/MockToastService.swift b/Model/Sources/ModelTestSupport/MockToastService.swift index e1d5c07d..dae6323c 100644 --- a/Model/Sources/ModelTestSupport/MockToastService.swift +++ b/Model/Sources/ModelTestSupport/MockToastService.swift @@ -5,16 +5,16 @@ // Mock implementation of ToastServiceProtocol for testing // -import Combine +import FunCore import FunModel @MainActor public final class MockToastService: ToastServiceProtocol { - private let toastSubject = PassthroughSubject() + private let toastBroadcaster = StreamBroadcaster() - public var toastPublisher: AnyPublisher { - toastSubject.eraseToAnyPublisher() + public var toastStream: AsyncStream { + toastBroadcaster.makeStream() } public var showToastCalled = false @@ -30,6 +30,6 @@ public final class MockToastService: ToastServiceProtocol { lastType = type let event = ToastEvent(message: message, type: type) toastHistory.append(event) - toastSubject.send(event) + toastBroadcaster.yield(event) } } diff --git a/Model/Tests/ModelTests/DeepLinkTests.swift b/Model/Tests/ModelTests/DeepLinkTests.swift index e1f0b738..1e41138c 100644 --- a/Model/Tests/ModelTests/DeepLinkTests.swift +++ b/Model/Tests/ModelTests/DeepLinkTests.swift @@ -58,10 +58,10 @@ struct DeepLinkTests { @Test("Parse item deep link with different ID") func parseItemWithDifferentId() { - let url = URL(string: "funapp://item/combine")! + let url = URL(string: "funapp://item/asyncsequence")! let deepLink = DeepLink(url: url) - #expect(deepLink == DeepLink.item(id: "combine")) + #expect(deepLink == DeepLink.item(id: "asyncsequence")) } // MARK: - Profile Deep Link diff --git a/Model/Tests/ModelTests/TechnologyDescriptionsTests.swift b/Model/Tests/ModelTests/TechnologyDescriptionsTests.swift index 543663ad..a44fef74 100644 --- a/Model/Tests/ModelTests/TechnologyDescriptionsTests.swift +++ b/Model/Tests/ModelTests/TechnologyDescriptionsTests.swift @@ -64,10 +64,10 @@ struct TechnologyDescriptionsTests { #expect(description.lowercased().contains("async")) } - @Test("combine description mentions reactive") - func testCombineContent() { - let description = TechnologyDescriptions.description(for: TechnologyItem.combine.rawValue) - #expect(description.lowercased().contains("reactive") || description.lowercased().contains("combine")) + @Test("asyncSequence description mentions stream or observable") + func testAsyncSequenceContent() { + let description = TechnologyDescriptions.description(for: TechnologyItem.asyncSequence.rawValue) + #expect(description.lowercased().contains("asyncstream") || description.lowercased().contains("observable")) } // MARK: - Alignment with FeaturedItem Tests diff --git a/README.md b/README.md index 431ba4ce..51dc4aa5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A modern iOS application demonstrating clean architecture (MVVM-C), Swift Concur Three branches show progressive modernization: - UIKit + SwiftUI + Combine (iOS 15+) — [`main`](https://github.com/g-enius/Fun-iOS/tree/main) - Pure SwiftUI + Combine (iOS 16+) — [`navigation-stack`](https://github.com/g-enius/Fun-iOS/tree/feature/navigation-stack) - [PR](https://github.com/g-enius/Fun-iOS/pull/3) -- Pure SwiftUI + AsyncSequence (iOS 17+) — [`observation`](https://github.com/g-enius/Fun-iOS/tree/feature/observation) - [PR](https://github.com/g-enius/Fun-iOS/pull/6) +- Pure SwiftUI + @Observable + AsyncSequence (iOS 17+) — [`observation`](https://github.com/g-enius/Fun-iOS/tree/feature/observation) - [PR](https://github.com/g-enius/Fun-iOS/pull/6) Android counterpart: [Fun-Android](https://github.com/g-enius/Fun-Android). @@ -29,7 +29,7 @@ Three branches demonstrate progressive modernization — same app, three archite |---|---|---|---| | **Best for** | **iOS 15+** | [![iOS 16+](https://img.shields.io/badge/iOS_16+-blue)](#) | [![iOS 17+](https://img.shields.io/badge/iOS_17+-blue)](#) | | **UI framework** | **UIKit + SwiftUI** | **SwiftUI** [![🚫 UIKit](https://img.shields.io/badge/🚫_UIKit-blue)](#) | ← same | -| **Reactive** | **Combine** | ← same | **AsyncSequence** [![🚫 Combine](https://img.shields.io/badge/🚫_Combine-blue)](#) | +| **Reactive** | **Combine** | ← same | **@Observable** + **AsyncStream** [![🚫 Combine](https://img.shields.io/badge/🚫_Combine-blue)](#) | | **ViewModel** | `ObservableObject` + `@Published` | ← same | **@Observable** macro | | **View binding** | `@ObservedObject` | ← same | **@Bindable** / **@State** | | **Service events** | `AnyPublisher` + `Subject` | ← same | **AsyncStream** + **StreamBroadcaster** | @@ -40,6 +40,8 @@ Three branches demonstrate progressive modernization — same app, three archite | LLM | Foundation Models (iOS 26+) | ← same | ← same | | Testing | Swift Testing, swift-snapshot-testing | ← same | ← same | +> **Why iOS 17?** The `observation` branch replaces Combine with two independent technologies: **`@Observable`** (Observation framework, iOS 17) for ViewModel → View reactivity, and **`AsyncStream`** for service event streams. `AsyncSequence`/`AsyncStream` themselves are available since iOS 13, but [`AsyncStream.makeStream(of:)`](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0388-async-stream-factory.md) (SE-0388) — used for eager continuation registration in `StreamBroadcaster` — requires iOS 17. Neither depends on the other; they coincidentally share the same deployment target. + ### UIKit + SwiftUI vs Pure SwiftUI | Aspect | `main` (UIKit + SwiftUI) | `navigation-stack` / `observation` (Pure SwiftUI) | @@ -185,7 +187,7 @@ Deep links received during login are queued and executed after authentication. ## Features - **Session-Scoped DI**: Clean service lifecycle per app flow — no stale state -- **Reactive Data Flow**: Combine framework with `@Published` properties +- **Reactive Data Flow**: `@Observable` for ViewModel state, `AsyncStream` + `StreamBroadcaster` for service events - **Feature Toggles**: Runtime flags persisted via services - **AI Summary**: On-device LLM summarisation using Apple Intelligence / Foundation Models (iOS 26+) - **Error Handling**: Centralized `AppError` enum with toast notifications @@ -205,7 +207,7 @@ Deep links received during login are queued and executed after authentication. ### Requirements - Xcode 16.0+ -- iOS 15.0+ +- iOS 17.0+ - Swift 6.0 ### Installation diff --git a/Services/Package.swift b/Services/Package.swift index 034e1c00..1979802a 100644 --- a/Services/Package.swift +++ b/Services/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Services", platforms: [ - .iOS(.v16), - .macCatalyst(.v16), + .iOS(.v17), + .macCatalyst(.v17), ], products: [ .library(name: "FunServices", targets: ["FunServices"]), diff --git a/Services/Sources/Services/CoreServices/DefaultFavoritesService.swift b/Services/Sources/Services/CoreServices/DefaultFavoritesService.swift index 49f1929f..37a2eb53 100644 --- a/Services/Sources/Services/CoreServices/DefaultFavoritesService.swift +++ b/Services/Sources/Services/CoreServices/DefaultFavoritesService.swift @@ -5,10 +5,10 @@ // Default implementation of FavoritesServiceProtocol // -import Combine import Foundation import OSLog +import FunCore import FunModel @MainActor @@ -19,39 +19,18 @@ public final class DefaultFavoritesService: FavoritesServiceProtocol { public private(set) var favorites: Set { didSet { + guard favorites != oldValue else { return } saveFavorites() - favoritesSubject.send(favorites) + favoritesBroadcaster.yield(favorites) } } - private let favoritesSubject: CurrentValueSubject, Never> + private let favoritesBroadcaster = StreamBroadcaster>() - public var favoritesDidChange: AnyPublisher, Never> { - favoritesSubject.eraseToAnyPublisher() + public var favoritesStream: AsyncStream> { + favoritesBroadcaster.makeStream() } - // MARK: - Swift Concurrency Alternative (iOS 15+) - // - // Replace CurrentValueSubject with StreamBroadcaster (a multi-consumer AsyncStream utility): - // - // private let favoritesBroadcaster = StreamBroadcaster>() - // - // var favoritesChanges: AsyncStream> { - // favoritesBroadcaster.makeStream() // each consumer gets its own stream - // } - // - // // In didSet: - // didSet { - // saveFavorites() - // favoritesBroadcaster.yield(favorites) // delivers to all active consumers - // } - // - // StreamBroadcaster.makeStream() returns a new AsyncStream per consumer — unlike - // CurrentValueSubject where all subscribers share one publisher. Each consumer's - // stream is cleaned up automatically when the consuming Task is cancelled. - // - // See Core/Sources/Core/StreamBroadcaster.swift on feature/observation. - public init() { let loaded: Set if let data = UserDefaults.standard.data(forKey: .favorites) { @@ -65,7 +44,6 @@ public final class DefaultFavoritesService: FavoritesServiceProtocol { loaded = Self.defaultFavorites } self.favorites = loaded - self.favoritesSubject = CurrentValueSubject(loaded) } public func isFavorited(_ itemId: String) -> Bool { diff --git a/Services/Sources/Services/CoreServices/DefaultFeatureToggleService.swift b/Services/Sources/Services/CoreServices/DefaultFeatureToggleService.swift index 02658215..6d0308a3 100644 --- a/Services/Sources/Services/CoreServices/DefaultFeatureToggleService.swift +++ b/Services/Sources/Services/CoreServices/DefaultFeatureToggleService.swift @@ -5,9 +5,9 @@ // Default implementation of FeatureToggleServiceProtocol // -import Combine import Foundation +import FunCore import FunModel @MainActor @@ -15,27 +15,45 @@ public final class DefaultFeatureToggleService: FeatureToggleServiceProtocol { // MARK: - Feature Toggles - @Published public var featuredCarousel: Bool { - didSet { UserDefaults.standard.set(featuredCarousel, forKey: .featureCarousel) } + public var featuredCarousel: Bool { + didSet { + guard featuredCarousel != oldValue else { return } + UserDefaults.standard.set(featuredCarousel, forKey: .featureCarousel) + carouselBroadcaster.yield(featuredCarousel) + } } - @Published public var simulateErrors: Bool { - didSet { UserDefaults.standard.set(simulateErrors, forKey: .simulateErrors) } + + public var simulateErrors: Bool { + didSet { + UserDefaults.standard.set(simulateErrors, forKey: .simulateErrors) + } } - @Published public var aiSummary: Bool { - didSet { UserDefaults.standard.set(aiSummary, forKey: .aiSummary) } + + public var aiSummary: Bool { + didSet { + UserDefaults.standard.set(aiSummary, forKey: .aiSummary) + } } - @Published public var appearanceMode: AppearanceMode { - didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: .appearanceMode) } + + public var appearanceMode: AppearanceMode { + didSet { + guard appearanceMode != oldValue else { return } + UserDefaults.standard.set(appearanceMode.rawValue, forKey: .appearanceMode) + appearanceBroadcaster.yield(appearanceMode) + } } - // MARK: - Publishers + // MARK: - Streams + + private let carouselBroadcaster = StreamBroadcaster() + private let appearanceBroadcaster = StreamBroadcaster() - public var featuredCarouselPublisher: AnyPublisher { - $featuredCarousel.removeDuplicates().eraseToAnyPublisher() + public var featuredCarouselStream: AsyncStream { + carouselBroadcaster.makeStream() } - public var appearanceModePublisher: AnyPublisher { - $appearanceMode.removeDuplicates().eraseToAnyPublisher() + public var appearanceModeStream: AsyncStream { + appearanceBroadcaster.makeStream() } // MARK: - Initialization diff --git a/Services/Sources/Services/CoreServices/DefaultToastService.swift b/Services/Sources/Services/CoreServices/DefaultToastService.swift index 4e18bba5..38c4bae1 100644 --- a/Services/Sources/Services/CoreServices/DefaultToastService.swift +++ b/Services/Sources/Services/CoreServices/DefaultToastService.swift @@ -5,20 +5,20 @@ // Default implementation of ToastServiceProtocol // -import Combine import Foundation +import FunCore import FunModel @MainActor public final class DefaultToastService: ToastServiceProtocol { - // MARK: - Combine Publisher + // MARK: - Stream - private let toastSubject = PassthroughSubject() + private let toastBroadcaster = StreamBroadcaster() - public var toastPublisher: AnyPublisher { - toastSubject.eraseToAnyPublisher() + public var toastStream: AsyncStream { + toastBroadcaster.makeStream() } // MARK: - Initialization @@ -28,6 +28,6 @@ public final class DefaultToastService: ToastServiceProtocol { // MARK: - ToastServiceProtocol public func showToast(message: String, type: ToastType) { - toastSubject.send(ToastEvent(message: message, type: type)) + toastBroadcaster.yield(ToastEvent(message: message, type: type)) } } diff --git a/Services/Tests/ServicesTests/DefaultFavoritesServiceTests.swift b/Services/Tests/ServicesTests/DefaultFavoritesServiceTests.swift index 2f0138a1..6d968331 100644 --- a/Services/Tests/ServicesTests/DefaultFavoritesServiceTests.swift +++ b/Services/Tests/ServicesTests/DefaultFavoritesServiceTests.swift @@ -7,7 +7,6 @@ import Testing import Foundation -import Combine @testable import FunServices @testable import FunModel @@ -167,48 +166,42 @@ struct DefaultFavoritesServiceTests { #expect(service.favorites.count == countBefore) } - // MARK: - Publisher Tests + // MARK: - Stream Tests - @Test("favoritesDidChange publishes when favorites change") - func testFavoritesDidChangePublisher() async { + @Test("favoritesStream emits when favorites change") + func testFavoritesChangesStream() async { clearUserDefaults() let service = DefaultFavoritesService() - var receivedFavorites: Set? - var cancellables = Set() - - service.favoritesDidChange - .dropFirst() // Skip the initial value from CurrentValueSubject - .sink { favorites in - receivedFavorites = favorites - } - .store(in: &cancellables) - + // Eager continuation: stream registered, values buffered before iteration + let stream = service.favoritesStream service.addFavorite("item2") + var iterator = stream.makeAsyncIterator() + let receivedFavorites = await iterator.next() + #expect(receivedFavorites != nil) #expect(receivedFavorites?.contains("item2") == true) } - @Test("favoritesDidChange publishes on toggle") - func testFavoritesDidChangeOnToggle() async { + @Test("favoritesStream emits on toggle") + func testFavoritesChangesOnToggle() async { clearUserDefaults() let service = DefaultFavoritesService() - var publishCount = 0 - var cancellables = Set() + let stream = service.favoritesStream + service.toggleFavorite("item1") + service.toggleFavorite("item1") - service.favoritesDidChange - .dropFirst() // Skip the initial value from CurrentValueSubject - .sink { _ in - publishCount += 1 + var emitCount = 0 + var iterator = stream.makeAsyncIterator() + for _ in 0..<2 { + if await iterator.next() != nil { + emitCount += 1 } - .store(in: &cancellables) + } - service.toggleFavorite( "item1") - service.toggleFavorite( "item1") - - #expect(publishCount == 2) + #expect(emitCount == 2) } // MARK: - Reset Tests @@ -232,25 +225,19 @@ struct DefaultFavoritesServiceTests { #expect(!service.favorites.contains("item3")) } - @Test("resetFavorites publishes default set") - func testResetFavoritesPublishesDefault() async { + @Test("resetFavorites emits default set via stream") + func testResetFavoritesEmitsDefault() async { clearUserDefaults() let service = DefaultFavoritesService() service.addFavorite("item2") - var receivedFavorites: Set? - var cancellables = Set() - - service.favoritesDidChange - .dropFirst() - .sink { favorites in - receivedFavorites = favorites - } - .store(in: &cancellables) - + let stream = service.favoritesStream service.resetFavorites() + var iterator = stream.makeAsyncIterator() + let receivedFavorites = await iterator.next() + #expect(receivedFavorites != nil) #expect(receivedFavorites == Set(["item1"])) } diff --git a/Services/Tests/ServicesTests/DefaultFeatureToggleServiceTests.swift b/Services/Tests/ServicesTests/DefaultFeatureToggleServiceTests.swift index 9ee6d2f7..1fe21460 100644 --- a/Services/Tests/ServicesTests/DefaultFeatureToggleServiceTests.swift +++ b/Services/Tests/ServicesTests/DefaultFeatureToggleServiceTests.swift @@ -7,7 +7,6 @@ import Testing import Foundation -import Combine @testable import FunServices @testable import FunModel @@ -47,23 +46,18 @@ struct DefaultFeatureToggleServiceTests { #expect(UserDefaults.standard.bool(forKey: UserDefaultsKey.featureCarousel.rawValue) == true) } - // MARK: - Combine Publisher Tests + // MARK: - Stream Tests - @Test("Setting featured carousel emits via publisher") - func testFeaturedCarouselEmitsViaPublisher() async { + @Test("Setting featured carousel emits via stream") + func testFeaturedCarouselEmitsViaStream() async { clearUserDefaults() let service = DefaultFeatureToggleService() - var receivedValue: Bool? - var cancellables = Set() - - service.featuredCarouselPublisher - .sink { receivedValue = $0 } - .store(in: &cancellables) + let stream = service.featuredCarouselStream service.featuredCarousel = false - // Yield to allow publisher propagation - await Task.yield() + var iterator = stream.makeAsyncIterator() + let receivedValue = await iterator.next() #expect(receivedValue == false) } @@ -130,20 +124,16 @@ struct DefaultFeatureToggleServiceTests { #expect(UserDefaults.standard.string(forKey: UserDefaultsKey.appearanceMode.rawValue) == "system") } - @Test("AppearanceMode emits via publisher") - func testAppearanceModeEmitsViaPublisher() async { + @Test("AppearanceMode emits via stream") + func testAppearanceModeEmitsViaStream() async { clearUserDefaults() let service = DefaultFeatureToggleService() - var receivedValue: AppearanceMode? - var cancellables = Set() - - service.appearanceModePublisher - .sink { receivedValue = $0 } - .store(in: &cancellables) + let stream = service.appearanceModeStream service.appearanceMode = .dark - await Task.yield() + var iterator = stream.makeAsyncIterator() + let receivedValue = await iterator.next() #expect(receivedValue == .dark) } diff --git a/Services/Tests/ServicesTests/DefaultToastServiceTests.swift b/Services/Tests/ServicesTests/DefaultToastServiceTests.swift index 54d6c966..9a21d727 100644 --- a/Services/Tests/ServicesTests/DefaultToastServiceTests.swift +++ b/Services/Tests/ServicesTests/DefaultToastServiceTests.swift @@ -7,7 +7,6 @@ import Testing import Foundation -import Combine @testable import FunServices @testable import FunModel @@ -15,37 +14,19 @@ import Combine @MainActor struct DefaultToastServiceTests { - // MARK: - Initialization Tests - - @Test("Service initializes with no pending events") - func testInitialization() async { - let service = DefaultToastService() - var eventCount = 0 - var cancellables = Set() - - service.toastPublisher - .sink { _ in eventCount += 1 } - .store(in: &cancellables) - - #expect(eventCount == 0) - } - // MARK: - Show Toast Tests - @Test("showToast emits event via publisher") + @Test("showToast emits event via stream") func testShowToastEmitsEvent() async { let service = DefaultToastService() - var receivedEvent: ToastEvent? - var cancellables = Set() - - service.toastPublisher - .sink { event in - receivedEvent = event - } - .store(in: &cancellables) + // Eager continuation: stream registered, values buffered before iteration + let stream = service.toastStream service.showToast(message: "Test message", type: .success) + var iterator = stream.makeAsyncIterator() + let receivedEvent = await iterator.next() + #expect(receivedEvent != nil) #expect(receivedEvent?.message == "Test message") #expect(receivedEvent?.type == .success) @@ -54,17 +35,13 @@ struct DefaultToastServiceTests { @Test("showToast with error type") func testShowToastErrorType() async { let service = DefaultToastService() - var receivedEvent: ToastEvent? - var cancellables = Set() - - service.toastPublisher - .sink { event in - receivedEvent = event - } - .store(in: &cancellables) + let stream = service.toastStream service.showToast(message: "Error occurred", type: .error) + var iterator = stream.makeAsyncIterator() + let receivedEvent = await iterator.next() + #expect(receivedEvent?.type == .error) #expect(receivedEvent?.message == "Error occurred") } @@ -72,17 +49,13 @@ struct DefaultToastServiceTests { @Test("showToast with info type") func testShowToastInfoType() async { let service = DefaultToastService() - var receivedEvent: ToastEvent? - var cancellables = Set() - - service.toastPublisher - .sink { event in - receivedEvent = event - } - .store(in: &cancellables) + let stream = service.toastStream service.showToast(message: "Info message", type: .info) + var iterator = stream.makeAsyncIterator() + let receivedEvent = await iterator.next() + #expect(receivedEvent?.type == .info) } @@ -91,58 +64,48 @@ struct DefaultToastServiceTests { @Test("Multiple toasts all emit events") func testMultipleToastsEmitEvents() async { let service = DefaultToastService() - var receivedEvents: [ToastEvent] = [] - var cancellables = Set() - - service.toastPublisher - .sink { event in - receivedEvents.append(event) - } - .store(in: &cancellables) + let stream = service.toastStream service.showToast(message: "First", type: .success) service.showToast(message: "Second", type: .error) service.showToast(message: "Third", type: .info) + var receivedEvents: [ToastEvent] = [] + var iterator = stream.makeAsyncIterator() + for _ in 0..<3 { + if let event = await iterator.next() { + receivedEvents.append(event) + } + } + #expect(receivedEvents.count == 3) #expect(receivedEvents[0].message == "First") #expect(receivedEvents[1].message == "Second") #expect(receivedEvents[2].message == "Third") } - // MARK: - Publisher Behavior Tests - - @Test("No events received before showToast is called") - func testNoEventsBeforeShowToast() async { - let service = DefaultToastService() - var eventCount = 0 - var cancellables = Set() - - service.toastPublisher - .sink { _ in - eventCount += 1 - } - .store(in: &cancellables) - - #expect(eventCount == 0) - } + // MARK: - Stream Behavior Tests @Test("Late subscriber does not receive past events") func testLateSubscriberMissesPastEvents() async { let service = DefaultToastService() - var cancellables = Set() - // Emit before subscribing + // Emit before subscribing — no continuation exists, value is lost service.showToast(message: "Before subscribe", type: .info) var receivedEvents: [ToastEvent] = [] - service.toastPublisher - .sink { event in + let stream = service.toastStream + let task = Task { + for await event in stream { receivedEvents.append(event) + break } - .store(in: &cancellables) + } + + // Verify absence: yield to let any pending work run + await Task.yield() - // PassthroughSubject does not replay - should be empty #expect(receivedEvents.isEmpty) + task.cancel() } } diff --git a/UI/Package.swift b/UI/Package.swift index ac291be4..56455c35 100644 --- a/UI/Package.swift +++ b/UI/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "UI", platforms: [ - .iOS(.v16), - .macCatalyst(.v16), + .iOS(.v17), + .macCatalyst(.v17), ], products: [ .library(name: "FunUI", targets: ["FunUI"]), diff --git a/UI/Sources/UI/Detail/DetailView.swift b/UI/Sources/UI/Detail/DetailView.swift index 1b668ffd..4c377c98 100644 --- a/UI/Sources/UI/Detail/DetailView.swift +++ b/UI/Sources/UI/Detail/DetailView.swift @@ -11,7 +11,7 @@ import FunCore import FunViewModel public struct DetailView: View { - @ObservedObject private var viewModel: DetailViewModel + private var viewModel: DetailViewModel public init(viewModel: DetailViewModel) { self.viewModel = viewModel diff --git a/UI/Sources/UI/Home/HomeView.swift b/UI/Sources/UI/Home/HomeView.swift index cc62ec6c..b22c0100 100644 --- a/UI/Sources/UI/Home/HomeView.swift +++ b/UI/Sources/UI/Home/HomeView.swift @@ -12,7 +12,7 @@ import FunModel import FunViewModel public struct HomeView: View { - @ObservedObject private var viewModel: HomeViewModel + @Bindable private var viewModel: HomeViewModel public init(viewModel: HomeViewModel) { self.viewModel = viewModel diff --git a/UI/Sources/UI/Items/ItemsView.swift b/UI/Sources/UI/Items/ItemsView.swift index 86be00bd..23b939b1 100644 --- a/UI/Sources/UI/Items/ItemsView.swift +++ b/UI/Sources/UI/Items/ItemsView.swift @@ -12,7 +12,7 @@ import FunModel import FunViewModel public struct ItemsView: View { - @ObservedObject private var viewModel: ItemsViewModel + private var viewModel: ItemsViewModel public init(viewModel: ItemsViewModel) { self.viewModel = viewModel @@ -28,7 +28,7 @@ public struct ItemsView: View { // MARK: - Content View private struct ItemsMainContent: View { - @ObservedObject var viewModel: ItemsViewModel + @Bindable var viewModel: ItemsViewModel @FocusState private var isSearchFocused: Bool @State private var hasAppeared = false @@ -58,7 +58,7 @@ private struct ItemsMainContent: View { // MARK: - Shared Components private struct CategoryFilterView: View { - @ObservedObject var viewModel: ItemsViewModel + var viewModel: ItemsViewModel var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -92,7 +92,7 @@ private struct CategoryFilterView: View { } private struct ItemsContentView: View { - @ObservedObject var viewModel: ItemsViewModel + var viewModel: ItemsViewModel var isSearchFocused: FocusState.Binding var body: some View { @@ -143,7 +143,7 @@ private struct ItemsContentView: View { private struct ItemRowView: View { let item: FeaturedItem - @ObservedObject var viewModel: ItemsViewModel + var viewModel: ItemsViewModel var isSearchFocused: FocusState.Binding private var isFavorited: Bool { diff --git a/UI/Sources/UI/Login/LoginView.swift b/UI/Sources/UI/Login/LoginView.swift index a1ac823e..1fca0f6e 100644 --- a/UI/Sources/UI/Login/LoginView.swift +++ b/UI/Sources/UI/Login/LoginView.swift @@ -11,7 +11,7 @@ import FunCore import FunViewModel public struct LoginView: View { - @ObservedObject private var viewModel: LoginViewModel + private var viewModel: LoginViewModel public init(viewModel: LoginViewModel) { self.viewModel = viewModel diff --git a/UI/Sources/UI/Preview Content/PreviewHelper.swift b/UI/Sources/UI/Preview Content/PreviewHelper.swift index cc691cc4..7a1dfd65 100644 --- a/UI/Sources/UI/Preview Content/PreviewHelper.swift +++ b/UI/Sources/UI/Preview Content/PreviewHelper.swift @@ -5,7 +5,6 @@ // Helper utilities for SwiftUI previews // -import Combine import SwiftUI import FunCore @@ -88,31 +87,34 @@ private final class PreviewLoggerService: LoggerService { @MainActor private final class PreviewFavoritesService: FavoritesServiceProtocol { var favorites: Set - private let subject: CurrentValueSubject, Never> - var favoritesDidChange: AnyPublisher, Never> { subject.eraseToAnyPublisher() } + private let broadcaster = StreamBroadcaster>() + var favoritesStream: AsyncStream> { broadcaster.makeStream() } init(initialFavorites: Set = []) { self.favorites = initialFavorites - self.subject = CurrentValueSubject(initialFavorites) } func isFavorited(_ itemId: String) -> Bool { favorites.contains(itemId) } func toggleFavorite(_ itemId: String) { if favorites.contains(itemId) { favorites.remove(itemId) } else { favorites.insert(itemId) } - subject.send(favorites) + broadcaster.yield(favorites) } - func addFavorite(_ itemId: String) { favorites.insert(itemId); subject.send(favorites) } - func removeFavorite(_ itemId: String) { favorites.remove(itemId); subject.send(favorites) } - func resetFavorites() { favorites.removeAll(); subject.send(favorites) } + func addFavorite(_ itemId: String) { favorites.insert(itemId); broadcaster.yield(favorites) } + func removeFavorite(_ itemId: String) { favorites.remove(itemId); broadcaster.yield(favorites) } + func resetFavorites() { favorites.removeAll(); broadcaster.yield(favorites) } } @MainActor private final class PreviewFeatureToggleService: FeatureToggleServiceProtocol { - @Published var featuredCarousel: Bool = true - @Published var simulateErrors: Bool = false - @Published var aiSummary: Bool = true - @Published var appearanceMode: AppearanceMode = .system - var featuredCarouselPublisher: AnyPublisher { $featuredCarousel.eraseToAnyPublisher() } - var appearanceModePublisher: AnyPublisher { $appearanceMode.eraseToAnyPublisher() } + var featuredCarousel: Bool = true + var simulateErrors: Bool = false + var aiSummary: Bool = true + var appearanceMode: AppearanceMode = .system + + private let carouselBroadcaster = StreamBroadcaster() + private let appearanceBroadcaster = StreamBroadcaster() + + var featuredCarouselStream: AsyncStream { carouselBroadcaster.makeStream() } + var appearanceModeStream: AsyncStream { appearanceBroadcaster.makeStream() } } @MainActor @@ -133,7 +135,7 @@ private final class PreviewNetworkService: NetworkService { @MainActor private final class PreviewToastService: ToastServiceProtocol { - private let subject = PassthroughSubject() - var toastPublisher: AnyPublisher { subject.eraseToAnyPublisher() } - func showToast(message: String, type: ToastType) { subject.send(ToastEvent(message: message, type: type)) } + private let broadcaster = StreamBroadcaster() + var toastStream: AsyncStream { broadcaster.makeStream() } + func showToast(message: String, type: ToastType) { broadcaster.yield(ToastEvent(message: message, type: type)) } } diff --git a/UI/Sources/UI/Profile/ProfileView.swift b/UI/Sources/UI/Profile/ProfileView.swift index cfac569e..7f174237 100644 --- a/UI/Sources/UI/Profile/ProfileView.swift +++ b/UI/Sources/UI/Profile/ProfileView.swift @@ -11,7 +11,7 @@ import FunCore import FunViewModel public struct ProfileView: View { - @ObservedObject private var viewModel: ProfileViewModel + private var viewModel: ProfileViewModel public init(viewModel: ProfileViewModel) { self.viewModel = viewModel diff --git a/UI/Sources/UI/Settings/SettingsView.swift b/UI/Sources/UI/Settings/SettingsView.swift index e3bc4c93..84cee5ac 100644 --- a/UI/Sources/UI/Settings/SettingsView.swift +++ b/UI/Sources/UI/Settings/SettingsView.swift @@ -12,7 +12,7 @@ import FunModel import FunViewModel public struct SettingsView: View { - @ObservedObject private var viewModel: SettingsViewModel + @Bindable private var viewModel: SettingsViewModel public init(viewModel: SettingsViewModel) { self.viewModel = viewModel diff --git a/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift index a454a877..6f290e9c 100644 --- a/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift @@ -7,7 +7,6 @@ import XCTest import SwiftUI -import Combine import SnapshotTesting @testable import FunUI @testable import FunViewModel diff --git a/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift index 77f7add1..7cb02a0b 100644 --- a/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift @@ -7,7 +7,6 @@ import XCTest import SwiftUI -import Combine import SnapshotTesting @testable import FunUI @testable import FunViewModel diff --git a/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift index d4f41d05..75fdbe91 100644 --- a/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift @@ -7,7 +7,6 @@ import XCTest import SwiftUI -import Combine import SnapshotTesting @testable import FunUI @testable import FunViewModel diff --git a/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift index ff59ba3f..026239a4 100644 --- a/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift @@ -7,7 +7,6 @@ import XCTest import SwiftUI -import Combine import SnapshotTesting @testable import FunUI @testable import FunViewModel diff --git a/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift index e2722b43..6d5d194c 100644 --- a/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift @@ -7,7 +7,6 @@ import XCTest import SwiftUI -import Combine import SnapshotTesting @testable import FunUI @testable import FunViewModel diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_darkMode.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_darkMode.1.png index 6a954c5a..2a51b03e 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_darkMode.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_darkMode.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_defaultState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_defaultState.1.png index 0acff49d..e8b79504 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_defaultState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_defaultState.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_favorited.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_favorited.1.png index 0acff49d..e8b79504 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_favorited.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/DetailViewSnapshotTests/testDetailView_favorited.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_landscape.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_landscape.1.png index 9a2210d4..11e6e77b 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_landscape.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_landscape.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_portrait.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_portrait.1.png index 9a2210d4..11e6e77b 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_portrait.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_iPad_portrait.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselDisabled.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselDisabled.1.png index 5899a9ee..608cbdf5 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselDisabled.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselDisabled.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselEnabled.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselEnabled.1.png index 5899a9ee..608cbdf5 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselEnabled.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/HomeViewSnapshotTests/testHomeView_withCarouselEnabled.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_darkMode.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_darkMode.1.png index 8fa1694e..6e94b0fd 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_darkMode.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_darkMode.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_defaultState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_defaultState.1.png index 529fcb87..6e94b0fd 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_defaultState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_defaultState.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_withSearchText.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_withSearchText.1.png index 24ebd180..eac4924f 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_withSearchText.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ItemsViewSnapshotTests/testItemsView_withSearchText.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_defaultState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_defaultState.1.png index 3a9e7742..adf79c67 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_defaultState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_defaultState.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_loggingInState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_loggingInState.1.png index 640e5b02..5254c405 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_loggingInState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/LoginViewSnapshotTests/testLoginView_loggingInState.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ProfileViewSnapshotTests/testProfileView_defaultState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ProfileViewSnapshotTests/testProfileView_defaultState.1.png index ab16062d..8451c204 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/ProfileViewSnapshotTests/testProfileView_defaultState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/ProfileViewSnapshotTests/testProfileView_defaultState.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselDisabled.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselDisabled.1.png index 3dd1ef08..08918558 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselDisabled.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselDisabled.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselEnabled.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselEnabled.1.png index 88c15b6d..18d95894 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselEnabled.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_carouselEnabled.1.png differ diff --git a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_defaultState.1.png b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_defaultState.1.png index 88c15b6d..18d95894 100644 Binary files a/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_defaultState.1.png and b/UI/Tests/UITests/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/testSettingsView_defaultState.1.png differ diff --git a/ViewModel/Package.swift b/ViewModel/Package.swift index bae15600..3e393eee 100644 --- a/ViewModel/Package.swift +++ b/ViewModel/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "ViewModel", platforms: [ - .iOS(.v16), - .macCatalyst(.v16), + .iOS(.v17), + .macCatalyst(.v17), ], products: [ .library(name: "FunViewModel", targets: ["FunViewModel"]), diff --git a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift index b417032a..40915c76 100644 --- a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift +++ b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift @@ -5,31 +5,32 @@ // ViewModel for Detail screen // -import Combine import Foundation +import Observation import FunCore import FunModel @MainActor -public class DetailViewModel: ObservableObject { +@Observable +public class DetailViewModel { // MARK: - Services - @Service(.logger) private var logger: LoggerService - @Service(.favorites) private var favoritesService: FavoritesServiceProtocol - @Service(.ai) private var aiService: AIServiceProtocol - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.favorites) private var favoritesService: FavoritesServiceProtocol + @ObservationIgnored @Service(.ai) private var aiService: AIServiceProtocol + @ObservationIgnored @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - // MARK: - Published State + // MARK: - State - @Published public var itemTitle: String - @Published public var category: String - @Published public var itemDescription: String - @Published public var isFavorited: Bool = false - @Published public var summary: String = "" - @Published public var isSummarizing: Bool = false - @Published public var summaryError: String = "" + public var itemTitle: String + public var category: String + public var itemDescription: String + public var isFavorited: Bool = false + public var summary: String = "" + public var isSummarizing: Bool = false + public var summaryError: String = "" public var showAISummary: Bool { featureToggleService.aiSummary && aiService.isAvailable @@ -42,8 +43,8 @@ public class DetailViewModel: ObservableObject { // MARK: - Private Properties - private var cancellables = Set() - private var itemId: String + @ObservationIgnored private var favoritesObservation: Task? + @ObservationIgnored private var itemId: String // MARK: - Initialization @@ -56,15 +57,21 @@ public class DetailViewModel: ObservableObject { observeFavoritesChanges() } + deinit { + favoritesObservation?.cancel() + } + // MARK: - Setup private func observeFavoritesChanges() { - favoritesService.favoritesDidChange - .sink { [weak self] favorites in - guard let self else { return } - self.isFavorited = favorites.contains(self.itemId) + let stream = favoritesService.favoritesStream + let itemId = self.itemId + favoritesObservation = Task { [weak self] in + for await favorites in stream { + guard let self else { break } + self.isFavorited = favorites.contains(itemId) } - .store(in: &cancellables) + } } // MARK: - Actions diff --git a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift index 30a727ae..443914d4 100644 --- a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift +++ b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift @@ -5,14 +5,15 @@ // ViewModel for Home screen // -import Combine import Foundation +import Observation import FunCore import FunModel @MainActor -public class HomeViewModel: ObservableObject { +@Observable +public class HomeViewModel { // MARK: - Navigation Closures @@ -21,27 +22,27 @@ public class HomeViewModel: ObservableObject { // MARK: - Services - @Service(.logger) private var logger: LoggerService - @Service(.network) private var networkService: NetworkService - @Service(.favorites) private var favoritesService: FavoritesServiceProtocol - @Service(.toast) private var toastService: ToastServiceProtocol + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.network) private var networkService: NetworkService + @ObservationIgnored @Service(.favorites) private var favoritesService: FavoritesServiceProtocol + @ObservationIgnored @Service(.toast) private var toastService: ToastServiceProtocol + @ObservationIgnored @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol + // MARK: - State - // MARK: - Published State - - @Published public var featuredItems: [[FeaturedItem]] = [] - @Published public var currentCarouselIndex: Int = 0 - @Published public var isLoading: Bool = false - @Published public var isCarouselEnabled: Bool = true - @Published public private(set) var favoriteIds: Set = [] - @Published public var hasError: Bool = false + public var featuredItems: [[FeaturedItem]] = [] + public var currentCarouselIndex: Int = 0 + public var isLoading: Bool = false + public var isCarouselEnabled: Bool = true + public var favoriteIds: Set = [] + public var hasError: Bool = false // MARK: - Private Properties - private var cancellables = Set() - private var loadTask: Task? - private var hasLoadedInitialData: Bool = false + @ObservationIgnored private var loadTask: Task? + @ObservationIgnored private var carouselObservation: Task? + @ObservationIgnored private var favoritesObservation: Task? + @ObservationIgnored private var hasLoadedInitialData: Bool = false // MARK: - Initialization @@ -52,6 +53,9 @@ public class HomeViewModel: ObservableObject { self.onShowDetail = onShowDetail self.onShowProfile = onShowProfile + // Initialize from current service values (AsyncStream only emits future changes) + isCarouselEnabled = featureToggleService.featuredCarousel + observeFeatureToggleChanges() observeFavoritesChanges() @@ -63,28 +67,37 @@ public class HomeViewModel: ObservableObject { deinit { loadTask?.cancel() + carouselObservation?.cancel() + favoritesObservation?.cancel() } - // MARK: - Feature Toggle Observation (Combine) + // MARK: - Feature Toggle Observation private func observeFeatureToggleChanges() { - featureToggleService.featuredCarouselPublisher - .sink { [weak self] newValue in - self?.isCarouselEnabled = newValue - self?.logger.log("Carousel visibility changed to: \(newValue)") + let stream = featureToggleService.featuredCarouselStream + carouselObservation = Task { [weak self] in + for await newValue in stream { + guard let self else { break } + self.isCarouselEnabled = newValue + self.logger.log("Carousel visibility changed to: \(newValue)") } - .store(in: &cancellables) + } } // MARK: - Favorites Observation private func observeFavoritesChanges() { - // CurrentValueSubject replays current value on subscribe — no manual init needed - favoritesService.favoritesDidChange - .sink { [weak self] newFavorites in - self?.favoriteIds = newFavorites + // Initialize with current favorites + favoriteIds = favoritesService.favorites + + // Observe future changes + let stream = favoritesService.favoritesStream + favoritesObservation = Task { [weak self] in + for await newFavorites in stream { + guard let self else { break } + self.favoriteIds = newFavorites } - .store(in: &cancellables) + } } // MARK: - Favorites @@ -118,7 +131,8 @@ public class HomeViewModel: ObservableObject { } catch { hasError = true featuredItems = [] - toastService.showToast(message: AppError.networkError.errorDescription ?? L10n.Error.unknownError, type: .error) + let errorMessage = AppError.networkError.errorDescription ?? L10n.Error.unknownError + toastService.showToast(message: errorMessage, type: .error) } isLoading = false diff --git a/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift b/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift index f704b283..bbffe19d 100644 --- a/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift +++ b/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift @@ -5,14 +5,15 @@ // ViewModel for Items screen - combines search, filter, and items list // -import Combine import Foundation +import Observation import FunCore import FunModel @MainActor -public class ItemsViewModel: ObservableObject { +@Observable +public class ItemsViewModel { // MARK: - Navigation Closures @@ -20,24 +21,29 @@ public class ItemsViewModel: ObservableObject { // MARK: - Services - @Service(.logger) private var logger: LoggerService - @Service(.network) private var networkService: NetworkService - @Service(.favorites) private var favoritesService: FavoritesServiceProtocol - @Service(.toast) private var toastService: ToastServiceProtocol - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.network) private var networkService: NetworkService + @ObservationIgnored @Service(.favorites) private var favoritesService: FavoritesServiceProtocol + @ObservationIgnored @Service(.toast) private var toastService: ToastServiceProtocol + @ObservationIgnored @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - // MARK: - Published State + // MARK: - State - @Published public var items: [FeaturedItem] = [] - @Published public private(set) var favoriteIds: Set = [] + public var items: [FeaturedItem] = [] + public var favoriteIds: Set = [] // Search & Filter State - @Published public var searchText: String = "" - @Published public var selectedCategory: String = L10n.Items.categoryAll - @Published public var isSearching: Bool = false - @Published public var needsMoreCharacters: Bool = false - @Published public var hasError: Bool = false - @Published public private(set) var isLoading: Bool = false + public var searchText: String = "" { + didSet { + guard searchText != oldValue else { return } + handleSearchTextChanged() + } + } + public var selectedCategory: String = L10n.Items.categoryAll + public var isSearching: Bool = false + public var needsMoreCharacters: Bool = false + public var hasError: Bool = false + public private(set) var isLoading: Bool = false // MARK: - Configuration @@ -46,17 +52,17 @@ public class ItemsViewModel: ObservableObject { // MARK: - Private Properties - private var cancellables = Set() - private var allItems: [FeaturedItem] = [] - private var loadTask: Task? - private var searchTask: Task? + @ObservationIgnored private var allItems: [FeaturedItem] = [] + @ObservationIgnored private var loadTask: Task? + @ObservationIgnored private var searchTask: Task? + @ObservationIgnored private var debounceTask: Task? + @ObservationIgnored private var favoritesObservation: Task? // MARK: - Initialization public init(onShowDetail: ((FeaturedItem) -> Void)? = nil) { self.onShowDetail = onShowDetail observeFavoritesChanges() - setupSearchBinding() loadTask = Task { [weak self] in await self?.loadItems() @@ -66,47 +72,51 @@ public class ItemsViewModel: ObservableObject { deinit { loadTask?.cancel() searchTask?.cancel() + debounceTask?.cancel() + favoritesObservation?.cancel() } // MARK: - Setup - private func setupSearchBinding() { - // Debounce search text with minimum character requirement - $searchText - .dropFirst() // Skip initial value - .debounce(for: .milliseconds(600), scheduler: RunLoop.main) - .removeDuplicates() - .sink { [weak self] text in - guard let self else { return } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - // Empty search - show all items - self.needsMoreCharacters = false - self.performSearch() - } else if trimmed.count < self.minimumSearchCharacters { - // Below minimum - show "keep typing" unless in error state - if !self.hasError { - self.needsMoreCharacters = true - self.items = [] - } - self.isSearching = false - } else { - // Meets minimum - perform search - self.needsMoreCharacters = false - self.performSearch() - } + private func handleSearchTextChanged() { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled, let self else { return } + self.processSearchText() + } + } + + private func processSearchText() { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + needsMoreCharacters = false + performSearch() + } else if trimmed.count < minimumSearchCharacters { + if !hasError { + needsMoreCharacters = true + items = [] } - .store(in: &cancellables) + isSearching = false + } else { + needsMoreCharacters = false + performSearch() + } } private func observeFavoritesChanges() { - // CurrentValueSubject replays current value on subscribe — no manual init needed - favoritesService.favoritesDidChange - .sink { [weak self] newFavorites in - self?.favoriteIds = newFavorites + // Initialize with current favorites + favoriteIds = favoritesService.favorites + + // Observe future changes + let stream = favoritesService.favoritesStream + favoritesObservation = Task { [weak self] in + for await newFavorites in stream { + guard let self else { break } + self.favoriteIds = newFavorites } - .store(in: &cancellables) + } } // MARK: - Data Loading @@ -194,6 +204,7 @@ public class ItemsViewModel: ObservableObject { public func clearSearch() { searchText = "" + debounceTask?.cancel() searchTask?.cancel() isSearching = false hasError = false diff --git a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift index 06718536..768fd012 100644 --- a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift +++ b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift @@ -5,14 +5,15 @@ // ViewModel for Login screen // -import Combine import Foundation +import Observation import FunCore import FunModel @MainActor -public class LoginViewModel: ObservableObject { +@Observable +public class LoginViewModel { // MARK: - Navigation Closures @@ -20,17 +21,17 @@ public class LoginViewModel: ObservableObject { // MARK: - Services - @Service(.logger) private var logger: LoggerService - @Service(.network) private var networkService: NetworkService - @Service(.toast) private var toastService: ToastServiceProtocol + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.network) private var networkService: NetworkService + @ObservationIgnored @Service(.toast) private var toastService: ToastServiceProtocol - // MARK: - Published State + // MARK: - State - @Published public var isLoggingIn: Bool = false + public var isLoggingIn: Bool = false // MARK: - Private Properties - private var loginTask: Task? + @ObservationIgnored private var loginTask: Task? // MARK: - Initialization diff --git a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift index 6e89b4c4..ee85fdec 100644 --- a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift +++ b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift @@ -6,12 +6,14 @@ // import Foundation +import Observation import FunCore import FunModel @MainActor -public class ProfileViewModel: ObservableObject { +@Observable +public class ProfileViewModel { // MARK: - Navigation Closures @@ -21,16 +23,16 @@ public class ProfileViewModel: ObservableObject { // MARK: - Services - @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.logger) private var logger: LoggerService - // MARK: - Published State + // MARK: - State - @Published public var userName: String - @Published public var userEmail: String - @Published public var userBio: String - @Published public var viewCount: Int - @Published public var favoritesCount: Int - @Published public var daysCount: Int + public var userName: String + public var userEmail: String + public var userBio: String + public var viewCount: Int + public var favoritesCount: Int + public var daysCount: Int // MARK: - Initialization diff --git a/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift b/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift index 0c8ee299..f6beb3b1 100644 --- a/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift +++ b/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift @@ -5,45 +5,47 @@ // ViewModel for Settings screen // -import Combine import Foundation +import Observation import FunCore import FunModel @MainActor -public class SettingsViewModel: ObservableObject { +@Observable +public class SettingsViewModel { // MARK: - Services - @Service(.logger) private var logger: LoggerService - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - // MARK: - Published State + // MARK: - State - @Published public var appearanceMode: AppearanceMode = .system { + public var appearanceMode: AppearanceMode = .system { didSet { featureToggleService.appearanceMode = appearanceMode } } - @Published public var featuredCarouselEnabled: Bool = true { + public var featuredCarouselEnabled: Bool = true { didSet { featureToggleService.featuredCarousel = featuredCarouselEnabled } } - @Published public var simulateErrorsEnabled: Bool = false { + public var simulateErrorsEnabled: Bool = false { didSet { featureToggleService.simulateErrors = simulateErrorsEnabled } } - @Published public var aiSummaryEnabled: Bool = true { + public var aiSummaryEnabled: Bool = true { didSet { featureToggleService.aiSummary = aiSummaryEnabled } } // MARK: - Initialization public init() { - _appearanceMode = Published(initialValue: featureToggleService.appearanceMode) - _featuredCarouselEnabled = Published(initialValue: featureToggleService.featuredCarousel) - _simulateErrorsEnabled = Published(initialValue: featureToggleService.simulateErrors) - _aiSummaryEnabled = Published(initialValue: featureToggleService.aiSummary) + // Override defaults with actual service values (didSet won't fire during init) + appearanceMode = featureToggleService.appearanceMode + featuredCarouselEnabled = featureToggleService.featuredCarousel + simulateErrorsEnabled = featureToggleService.simulateErrors + aiSummaryEnabled = featureToggleService.aiSummary } // MARK: - Actions diff --git a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift index 4c62d86e..b5e22e1d 100644 --- a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift @@ -20,18 +20,21 @@ struct DetailViewModelTests { // MARK: - Setup + @discardableResult private func setupServices( initialFavorites: Set = [], aiService: MockAIService = MockAIService(), featureToggleService: MockFeatureToggleService = MockFeatureToggleService() - ) { + ) -> MockFavoritesService { + let favoritesService = MockFavoritesService(initialFavorites: initialFavorites) ServiceLocator.shared.reset() ServiceLocator.shared.register(MockLoggerService(), for: .logger) ServiceLocator.shared.register(MockNetworkService(), for: .network) - ServiceLocator.shared.register(MockFavoritesService(initialFavorites: initialFavorites), for: .favorites) + ServiceLocator.shared.register(favoritesService, for: .favorites) ServiceLocator.shared.register(featureToggleService, for: .featureToggles) ServiceLocator.shared.register(MockToastService(), for: .toast) ServiceLocator.shared.register(aiService, for: .ai) + return favoritesService } private var testItem: FeaturedItem { @@ -78,9 +81,7 @@ struct DetailViewModelTests { #expect(viewModel.isFavorited == false) viewModel.didTapToggleFavorite() - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.isFavorited } #expect(viewModel.isFavorited == true) } @@ -94,9 +95,7 @@ struct DetailViewModelTests { #expect(viewModel.isFavorited == true) viewModel.didTapToggleFavorite() - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.isFavorited } #expect(viewModel.isFavorited == false) } @@ -105,20 +104,15 @@ struct DetailViewModelTests { @Test("ViewModel updates when favorites service changes externally") func testExternalFavoritesChange() async { - setupServices(initialFavorites: []) - let mockFavorites = MockFavoritesService(initialFavorites: []) - ServiceLocator.shared.register(mockFavorites, for: .favorites) + let mockFavorites = setupServices(initialFavorites: []) let item = testItem let viewModel = DetailViewModel(item: item) #expect(viewModel.isFavorited == false) - // Change favorites externally mockFavorites.addFavorite(item.id) - - // Wait for publisher - await Task.yield() + await awaitObservation { _ = viewModel.isFavorited } #expect(viewModel.isFavorited == true) } @@ -139,7 +133,7 @@ struct DetailViewModelTests { func testDifferentItems() async { setupServices() - let items: [FeaturedItem] = [.swiftUI, .combine, .mvvm, .coordinator] + let items: [FeaturedItem] = [.swiftUI, .asyncSequence, .mvvm, .coordinator] for item in items { let viewModel = DetailViewModel(item: item) #expect(viewModel.itemTitle == item.title) diff --git a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift index ef6d7c93..eb8d1a86 100644 --- a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift @@ -113,10 +113,9 @@ struct HomeViewModelTests { // Explicitly call loadFeaturedItems and wait for it await viewModel.loadFeaturedItems() - // Verify the toast was called - let resolvedToast: MockToastService = ServiceLocator.shared.resolve(for: .toast) - #expect(resolvedToast.showToastCalled == true) - #expect(resolvedToast.lastType == .error) + // Verify the toast was called (use local ref, not ServiceLocator which may be reset by parallel tests) + #expect(mockToast.showToastCalled == true) + #expect(mockToast.lastType == .error) } // MARK: - Coordinator Tests @@ -178,9 +177,7 @@ struct HomeViewModelTests { #expect(viewModel.isFavorited("test_item") == false) viewModel.toggleFavorite(for: "test_item") - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.favoriteIds } #expect(viewModel.isFavorited("test_item") == true) } @@ -193,9 +190,7 @@ struct HomeViewModelTests { #expect(viewModel.isFavorited("test_item") == true) viewModel.toggleFavorite(for: "test_item") - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.favoriteIds } #expect(viewModel.isFavorited("test_item") == false) } diff --git a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift index 7b771b64..c079943c 100644 --- a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift @@ -20,13 +20,19 @@ struct ItemsViewModelTests { // MARK: - Setup - private func setupServices(initialFavorites: Set = [], simulateErrors: Bool = false) { + @discardableResult + private func setupServices( + initialFavorites: Set = [], + simulateErrors: Bool = false + ) -> MockFavoritesService { + let favoritesService = MockFavoritesService(initialFavorites: initialFavorites) ServiceLocator.shared.reset() ServiceLocator.shared.register(MockLoggerService(), for: .logger) ServiceLocator.shared.register(MockNetworkService(shouldThrowError: simulateErrors), for: .network) - ServiceLocator.shared.register(MockFavoritesService(initialFavorites: initialFavorites), for: .favorites) + ServiceLocator.shared.register(favoritesService, for: .favorites) ServiceLocator.shared.register(MockFeatureToggleService(simulateErrors: simulateErrors), for: .featureToggles) ServiceLocator.shared.register(MockToastService(), for: .toast) + return favoritesService } // MARK: - Initialization Tests @@ -166,9 +172,7 @@ struct ItemsViewModelTests { #expect(viewModel.isFavorited("test_item") == false) viewModel.toggleFavorite(for: "test_item") - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.favoriteIds } #expect(viewModel.isFavorited("test_item") == true) } @@ -181,9 +185,7 @@ struct ItemsViewModelTests { #expect(viewModel.isFavorited("test_item") == true) viewModel.toggleFavorite(for: "test_item") - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.favoriteIds } #expect(viewModel.isFavorited("test_item") == false) } @@ -192,19 +194,14 @@ struct ItemsViewModelTests { @Test("ViewModel updates favoriteIds when service changes") func testViewModelObservesFavoritesChanges() async { - setupServices(initialFavorites: []) - let mockFavorites = MockFavoritesService(initialFavorites: []) - ServiceLocator.shared.register(mockFavorites, for: .favorites) + let mockFavorites = setupServices(initialFavorites: []) let viewModel = ItemsViewModel() #expect(viewModel.favoriteIds.isEmpty) - // Add favorite directly on the service mockFavorites.addFavorite("new_item") - - // Wait for publisher to propagate - await Task.yield() + await awaitObservation { _ = viewModel.favoriteIds } #expect(viewModel.favoriteIds.contains("new_item")) } @@ -272,7 +269,7 @@ struct ItemsViewModelTests { // MARK: - Network Search Tests @Test("Search calls networkService.searchItems with query and category") - func testSearchCallsNetworkService() async throws { + func testSearchCallsNetworkService() async { setupServices() let mockNetwork = MockNetworkService( stubbedSearchItems: [.swiftUI] @@ -284,9 +281,7 @@ struct ItemsViewModelTests { viewModel.searchText = "swift" // Trigger search directly by calling the debounced path viewModel.didSelectCategory(viewModel.selectedCategory) - - // Wait for the search task to complete - try await Task.sleep(for: .milliseconds(100)) + await awaitObservation { _ = viewModel.isSearching } #expect(mockNetwork.searchItemsCallCount == 1) #expect(mockNetwork.lastSearchQuery == "swift") @@ -296,28 +291,28 @@ struct ItemsViewModelTests { } @Test("Search error sets hasError and shows toast") - func testSearchErrorSetsHasError() async throws { + func testSearchErrorSetsHasError() async { setupServices() let mockNetwork = MockNetworkService(shouldThrowError: true) + let mockToast = MockToastService() ServiceLocator.shared.register(mockNetwork, for: .network) + ServiceLocator.shared.register(mockToast, for: .toast) let viewModel = ItemsViewModel() viewModel.searchText = "swift" viewModel.didSelectCategory(viewModel.selectedCategory) - - try await Task.sleep(for: .milliseconds(100)) + await awaitObservation { _ = viewModel.isSearching } #expect(viewModel.hasError == true) #expect(viewModel.items.isEmpty) #expect(viewModel.isSearching == false) - let mockToast: ToastServiceProtocol = ServiceLocator.shared.resolve(for: .toast) - let toast = mockToast as! MockToastService - #expect(toast.showToastCalled == true) + // Use local ref, not ServiceLocator which may be reset by parallel tests + #expect(mockToast.showToastCalled == true) } @Test("Clear search resets to filtered allItems") - func testClearSearchResetsToAllItems() async throws { + func testClearSearchResetsToAllItems() async { setupServices() let mockNetwork = MockNetworkService( stubbedSearchItems: [.swiftUI] @@ -331,7 +326,7 @@ struct ItemsViewModelTests { // Perform a search viewModel.searchText = "swift" viewModel.didSelectCategory(viewModel.selectedCategory) - try await Task.sleep(for: .milliseconds(100)) + await awaitObservation { _ = viewModel.isSearching } #expect(viewModel.items.count == 1) // Clear search diff --git a/ViewModel/Tests/ViewModelTests/ViewModelTestSuite.swift b/ViewModel/Tests/ViewModelTests/ViewModelTestSuite.swift index df448a4f..961c9061 100644 --- a/ViewModel/Tests/ViewModelTests/ViewModelTestSuite.swift +++ b/ViewModel/Tests/ViewModelTests/ViewModelTestSuite.swift @@ -7,7 +7,20 @@ // import Testing +import Observation @Suite("ViewModel Tests", .serialized) @MainActor struct ViewModelTestSuite {} + +/// Yields the MainActor until an observed @Observable property changes. +/// Call AFTER triggering the action — @MainActor serialization ensures +/// the observation task can't run until we suspend at withCheckedContinuation. +@MainActor +func awaitObservation(_ apply: () -> Void) async { + await withCheckedContinuation { continuation in + withObservationTracking(apply) { + continuation.resume() + } + } +} diff --git a/ai-rules/general.md b/ai-rules/general.md index 75f92f30..b52ca164 100644 --- a/ai-rules/general.md +++ b/ai-rules/general.md @@ -1,4 +1,4 @@ -# Fun-iOS Architecture Reference (feature/navigation-stack) +# Fun-iOS Architecture Reference (feature/observation) ## SPM Package Structure @@ -8,10 +8,10 @@ FunApp/FunApp.xcodeproj → iOS app target (FunApp.swift @main, AppSessionFactory) Coordinator/ → FunCoordinator (single AppCoordinator + views) UI/ → FunUI (SwiftUI views) -ViewModel/ → FunViewModel (business logic, @Published state) +ViewModel/ → FunViewModel (business logic, @Observable-compatible state) Model/ → FunModel + FunModelTestSupport (domain types, protocols, mocks) Services/ → FunServices (concrete service implementations) -Core/ → FunCore (DI container, Session protocol, utilities) +Core/ → FunCore (DI container, Session protocol, StreamBroadcaster, utilities) ``` ### Dependency Graph @@ -35,28 +35,64 @@ FunServices Services is a sibling to the UI stack — it depends on Model and Core but NOT on ViewModel, UI, or Coordinator. -## MVVM-C Architecture (NavigationStack Variant) +## MVVM-C Architecture (AsyncSequence Variant) -### Single AppCoordinator -Unlike the main branch (6 UIKit coordinators), this branch uses a **single `AppCoordinator: ObservableObject`** that manages all navigation state: +### Single @Observable AppCoordinator +This branch uses `@Observable` (not ObservableObject) with `@ObservationIgnored` for non-observed state: ```swift @MainActor -public final class AppCoordinator: ObservableObject { - @Published public var currentFlow: AppFlow = .login - @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 - @Published public var activeToast: ToastEvent? - @Published public var appearanceMode: AppearanceMode = .system +@Observable +public final class AppCoordinator { + // Observed by SwiftUI + public var currentFlow: AppFlow = .login + public var selectedTab: TabIndex = .home + public var homePath = NavigationPath() + public var itemsPath = NavigationPath() + public var settingsPath = NavigationPath() + public var isProfilePresented = false + public var activeToast: ToastEvent? + public var appearanceMode: AppearanceMode = .system + + // Not observed + @ObservationIgnored @Service(.logger) private var logger: LoggerService + @ObservationIgnored private let sessionFactory: SessionFactory + @ObservationIgnored private var currentSession: Session? + @ObservationIgnored private var pendingDeepLink: DeepLink? + @ObservationIgnored private var toastObservation: Task? +} +``` + +### StreamBroadcaster (in FunCore) +Replaces Combine's Subject pattern. One-to-many AsyncStream broadcaster: +```swift +@MainActor +public final class StreamBroadcaster { + func makeStream() -> AsyncStream // Each consumer gets independent stream + func yield(_ value: Element) // Broadcast to all consumers + func finish() // Complete all streams +} +``` + +Services use `StreamBroadcaster` to emit events. Consumers iterate with `for await`: +```swift +// In service +private let broadcaster = StreamBroadcaster() +func makeStream() -> AsyncStream { broadcaster.makeStream() } + +// In coordinator/viewmodel +Task { [weak self] in + let stream = toastService.makeStream() + for await event in stream { + guard let self else { return } + self.activeToast = event + } } ``` ### Navigation Architecture ``` -FunApp (@main) +FunApp (@main, uses @State not @StateObject) └─ AppRootView ├─ LoginTabContent (when currentFlow == .login) └─ MainTabView (when currentFlow == .main) @@ -71,11 +107,10 @@ FunApp (@main) ``` ### View Wiring Pattern (this branch) -Tab content wrappers create ViewModels and wire closures: ```swift struct HomeTabContent: View { let coordinator: AppCoordinator - @StateObject private var viewModel = HomeViewModel() + @State private var viewModel = HomeViewModel() // @State, not @StateObject var body: some View { HomeView(viewModel: viewModel) @@ -93,7 +128,8 @@ struct HomeTabContent: View { ## ServiceLocator & @Service -Same as main branch — `ServiceLocator.shared` with `@Service` property wrapper. +Same as other branches — `ServiceLocator.shared` with `@Service` property wrapper. +Service events use `StreamBroadcaster` instead of Combine publishers. ### Service Keys `ServiceKey` enum in Core: `.network`, `.logger`, `.favorites`, `.toast`, `.featureToggles`, `.ai` @@ -106,13 +142,13 @@ Same as main branch — `ServiceLocator.shared` with `@Service` property wrapper | `AuthenticatedSession` | logger, network, favorites, toast, featureToggles, ai | Main app | - `AppSessionFactory` creates the right session for each `AppFlow` case -- App entry (`FunApp.swift`) creates coordinator with `@StateObject` and calls `.start()` in `.task` +- App entry (`FunApp.swift`) creates coordinator with `@State` and calls `.start()` in `.task` ## Protocol Placement | Package | What goes here | Example | |---|---|---| -| Core | Reusable abstractions not tied to domain | `Session`, `ServiceLocator`, `@Service` | +| Core | Reusable abstractions not tied to domain | `Session`, `ServiceLocator`, `@Service`, `StreamBroadcaster` | | Model | Domain-specific protocols and types | `LoggerService`, `FavoritesServiceProtocol`, `NetworkService`, `SessionFactory`, `DeepLink`, `AppFlow`, `TabIndex` | | Services | Concrete implementations only | `DefaultLoggerService`, `LoginSession`, `AuthenticatedSession` | @@ -137,8 +173,14 @@ App entry uses `.onOpenURL { url in coordinator.handleDeepLink(DeepLink(url: url - **Test support import**: `@testable import FunModelTestSupport` - **Snapshots**: swift-snapshot-testing in UI package tests -## Key Difference from Main Branch +## Key Differences from Other Branches - No UIKit, no UIViewControllers, no UIHostingController, no BaseCoordinator - Single coordinator (not 6 separate ones) - Navigation via NavigationPath (declarative) instead of safePush/safePop (imperative) - App entry via SwiftUI @main, not SceneDelegate +- `@Observable` instead of `ObservableObject` — no `@Published`, SwiftUI tracks property access automatically +- `@ObservationIgnored` for services and private state that shouldn't trigger view updates +- `@State` instead of `@StateObject` for coordinator and viewmodel ownership +- `StreamBroadcaster` replaces Combine publishers — `for await` instead of `.sink` +- `Task` for observation instead of `AnyCancellable` — cancel via `task?.cancel()` +- Zero `import Combine` in the entire codebase diff --git a/ai-rules/swift-style.md b/ai-rules/swift-style.md index c9bbfbe0..c86da447 100644 --- a/ai-rules/swift-style.md +++ b/ai-rules/swift-style.md @@ -1,74 +1,114 @@ -# Swift Style Guide — Fun-iOS (feature/navigation-stack) +# Swift Style Guide — Fun-iOS (feature/observation) ## Swift 6 Strict Concurrency ### Actor Isolation -- `@MainActor` on AppCoordinator, all ViewModels, ServiceLocator, Session implementations +- `@MainActor` on AppCoordinator, all ViewModels, ServiceLocator, Session implementations, StreamBroadcaster - `Sendable` conformance on value types crossing isolation boundaries - `nonisolated` only when a method genuinely doesn't touch actor-isolated state ### Self-Capture in Async Closures - **Default to `self?.`** for ViewModel async work — if self is nil, the view is gone anyway - `guard let self` creates a strong local ref that keeps self alive across `await` suspension points -- For `AsyncSequence` loops, `guard let self` must go INSIDE the loop body +- For `for await` loops, `guard let self` must go INSIDE the loop body, not before the `for await` - Zero `[unowned self]` in this codebase — never introduce it - **`[weak coordinator]`** in tab content wrappers when wiring closures +- **`[weak self]`** in Task closures that observe streams ### Sendable Types - All enums in Model are `Sendable` (`AppFlow`, `TabIndex`, `DeepLink`, `AppearanceMode`, etc.) - Protocols that cross isolation boundaries include `Sendable` conformance +- `StreamBroadcaster` — element type must be Sendable -## MVVM-C Patterns +## @Observable Patterns (this branch) + +### @Observable vs ObservableObject +This branch uses Swift Observation framework, NOT Combine's ObservableObject: +- **Use `@Observable`** on coordinator and viewmodel classes (not `ObservableObject`) +- **No `@Published`** — just regular `var` properties, SwiftUI tracks access automatically +- **Use `@ObservationIgnored`** for services, private state, and anything that shouldn't trigger view updates +- **Use `@State`** (not `@StateObject`) to own @Observable objects in views +- **No `@ObservedObject`** — just pass @Observable objects directly ### ViewModels -- `@MainActor` class conforming to `ObservableObject` -- `@Published` properties for view state +- `@MainActor @Observable` class +- Regular `var` properties for view state (SwiftUI auto-tracks) +- `@ObservationIgnored` for services (`@Service` properties) and non-UI state - Optional closures for navigation: `var onShowDetail: ((FeaturedItem) -> Void)?` -- Services accessed via `@Service` property wrapper -- Private `Set` for Combine subscriptions +- `Task` for stream observation, stored as `@ObservationIgnored private var observationTask: Task?` - No UIKit imports. No coordinator references. -### Views (Pure SwiftUI) -- Observe ViewModel via `@ObservedObject` or `@StateObject` +### Views — Passing @Observable Objects +- **`let` / `var`** — when you only **read** properties (e.g. `coordinator.currentFlow`) +- **`@Bindable`** — when you need **`$` bindings** (e.g. `$coordinator.selectedTab`, `$coordinator.isProfilePresented`) +- **`@State`** — when the view **owns** the object (e.g. tab content wrappers owning their ViewModel) +- Rule of thumb: if you see a `$` in the body, you need `@Bindable`. Otherwise plain property. - Never make navigation decisions — call ViewModel methods which invoke closures - No `import UIKit` anywhere in this branch ### AppCoordinator -- Single `ObservableObject` managing all navigation state -- `@Published` NavigationPath per tab + selectedTab + modal flags -- Created with `@StateObject` in the app entry point +- `@MainActor @Observable` with per-tab NavigationPath +- `@ObservationIgnored` for sessionFactory, currentSession, pendingDeepLink, observation Tasks +- Created with `@State` in the app entry point - Tab content wrappers wire ViewModel closures using `[weak coordinator]` -## Combine Patterns (this branch) - -### Publishers -- `@Published` in AppCoordinator for navigation state -- `@Published` in ViewModels for UI-bound state -- `CurrentValueSubject` / `PassthroughSubject` in services for events -- `serviceDidRegisterPublisher` on ServiceLocator for dynamic registration - -### Schedulers -- `RunLoop.main` for `debounce`/`throttle` — cooperates with `Task.sleep` in async tests -- `DispatchQueue.main` for `receive(on:)` — not affected by scroll tracking mode -- If subscriber is already `@MainActor`, `receive(on:)` is redundant — skip it - -### Subscriptions -- Store in `private var cancellables = Set()` -- Use `[weak self]` in `.sink` closures +## AsyncSequence Patterns (this branch — zero Combine) + +### StreamBroadcaster +Central pattern replacing Combine publishers. Located in `Core/Sources/Core/StreamBroadcaster.swift`: +```swift +// Service exposes a stream factory +func makeStream() -> AsyncStream { + broadcaster.makeStream() +} + +// Consumer iterates +Task { [weak self] in + for await event in service.makeStream() { + guard let self else { return } + self.handleEvent(event) + } +} +``` + +### Stream Observation Lifecycle +- Start observation in `start()` or initialization methods +- Store the `Task` for cancellation: `observationTask = Task { ... }` +- Cancel in cleanup: `observationTask?.cancel()` +- Always use `[weak self]` in Task closures +- Always `guard let self` INSIDE `for await` loops + +### ServiceLocator Registration Events +`serviceRegistrations` property returns `AsyncStream` (replaces Combine's `serviceDidRegisterPublisher`): +```swift +Task { [weak self] in + for await key in ServiceLocator.shared.serviceRegistrations { + if key == .featureToggles { + self?.observeFeatureToggles() + } + } +} +``` ## Naming Conventions ### Types -- AppCoordinator (single, not per-tab) +- AppCoordinator (single, @Observable) - Tab content wrappers: `HomeTabContent`, `ItemsTabContent`, `SettingsTabContent`, `ProfileTabContent`, `LoginTabContent` -- ViewModels: `HomeViewModel`, `ItemsViewModel`, `DetailViewModel` -- Views: `HomeView`, `ItemsView`, `DetailView` +- ViewModels: `HomeViewModel`, `ItemsViewModel`, `DetailViewModel` (@Observable) +- Views: `HomeView`, `ItemsView`, `DetailView` (pure SwiftUI) - Services: protocol `FavoritesServiceProtocol`, impl `DefaultFavoritesService` ### Navigation Closures - `onShowDetail`, `onShowProfile`, `onLoginSuccess`, `onLogout`, `onDismiss`, `onGoToItems` - Always optional, wired in tab content wrapper `.task` blocks +### Task Properties +- `private var toastObservation: Task?` +- `private var darkModeObservation: Task?` +- `private var registrationObservation: Task?` +- `private var loadTask: Task?` + ## SwiftLint Rules Zero-tolerance — CI fails on any violation. @@ -100,3 +140,12 @@ Zero-tolerance — CI fails on any violation. | Is only needed by one implementation | Probably don't need a protocol | Never define protocols in `Services`, `ViewModel`, `UI`, or `Coordinator`. + +## Things That Don't Belong in This Branch +- `import Combine` — use AsyncSequence/StreamBroadcaster instead +- `@Published` — use regular `var` with `@Observable` +- `ObservableObject` — use `@Observable` +- `@StateObject` — use `@State` +- `@ObservedObject` — pass @Observable objects directly +- `AnyCancellable` / `Set` — use Task cancellation +- `CurrentValueSubject` / `PassthroughSubject` — use StreamBroadcaster