diff --git a/.claude/agents/change-reviewer.md b/.claude/agents/change-reviewer.md index 82326688..9f3c8142 100644 --- a/.claude/agents/change-reviewer.md +++ b/.claude/agents/change-reviewer.md @@ -14,9 +14,10 @@ Review all recent code changes thoroughly and provide a structured, actionable a ## Project Context -- **Architecture**: MVVM-C with Combine (main branch), NavigationStack + Combine (navigation-stack branch), AsyncSequence + @Observable (observation branch) +- **Branch**: feature/navigation-stack — Pure SwiftUI, NavigationPath, single AppCoordinator (ObservableObject), 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 - **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) @@ -33,26 +34,23 @@ Review all recent code changes thoroughly and provide a structured, actionable a - **Similar patterns elsewhere**: Search the codebase for code following the same pattern. If the same improvement applies elsewhere, flag each location. - **Consistency**: Do changes follow existing patterns? - **No orphaned references**: Stale imports, unused variables, dead code paths? -- **Edge cases**: Boundary conditions, nil/optional handling, error paths? ### Step 3: Architecture Check - Package dependency direction respected? -- No `import UIKit` in ViewModel or Model +- No `import UIKit` — pure SwiftUI branch - No coordinator references in ViewModels (except weak closures) - No `print()` — use LoggerService - No `UserDefaults.standard` outside Services -- Navigation logic only in Coordinators +- 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 -- Detect which branch you're on and enforce the right reactive pattern: - - `main`: Combine + UIKit coordinators - - `feature/navigation-stack`: Combine + NavigationPath + ObservableObject - - `feature/observation`: AsyncSequence + StreamBroadcaster + @Observable, zero Combine +- Reactive pattern: Combine (`@Published`, `@StateObject`, `@ObservedObject`, `.sink`) ### Step 4: Correctness Check - **Logic errors**: Algorithms, conditions, control flow - **Type safety**: Force unwraps, force casts, unsafe assumptions -- **Concurrency**: `@MainActor` isolation, `Sendable` conformance, thread safety (Swift 6 strict) -- **Memory management**: `[weak self]` in closures, no retain cycles. `self?.` preferred over `guard let self` for async ViewModel work. +- **Concurrency**: `@MainActor` isolation, `Sendable` conformance, Swift 6 strict +- **Memory management**: `[weak self]` and `[weak coordinator]` in closures - **API contracts**: Public interfaces used correctly ### Step 5: Quality Check @@ -91,15 +89,7 @@ Ship it | Minor fixes needed | Needs significant work 1. **Be calibrated**: This is a demo/portfolio app. Don't demand enterprise patterns. 2. **Be specific**: Reference exact files and lines. No vague feedback. 3. **Be actionable**: Every finding must include a concrete recommendation. -4. **Don't over-engineer**: If the codebase uses a pattern (e.g., `fatalError` for service resolution), don't flag it. +4. **Don't over-engineer**: If the codebase uses a pattern, don't flag it. 5. **Focus on the diff**: Review what changed, not pre-existing code. 6. **Verify before flagging**: Read actual code before claiming something is missing. 7. **Count honestly**: Fewer than 3 issues? That's fine. Don't inflate. - -## Self-Verification - -Before delivering your review: -- Re-read each finding: "Is this actually a problem, or am I being overly cautious?" -- "Did I miss any changed files?" -- "Are my recommendations correct and compatible with the codebase?" -- "Would I stand behind each finding in a review discussion?" diff --git a/.claude/skills/cross-platform/SKILL.md b/.claude/skills/cross-platform/SKILL.md index 1ce3cbb1..ba8c5856 100644 --- a/.claude/skills/cross-platform/SKILL.md +++ b/.claude/skills/cross-platform/SKILL.md @@ -10,7 +10,7 @@ args: "" Compare the implementation of a feature across Fun-iOS and Fun-Android to find unintentional divergences. ## Project Paths -- **iOS**: `~/Documents/Source/Fun-iOS/` +- **iOS**: `~/Documents/Source/Fun-iOS-NavigationStack/` - **Android**: `~/Documents/Source/Fun-Android/` ## Steps diff --git a/.claude/skills/pull-request/SKILL.md b/.claude/skills/pull-request/SKILL.md index b9e0098c..cffd7b97 100644 --- a/.claude/skills/pull-request/SKILL.md +++ b/.claude/skills/pull-request/SKILL.md @@ -19,6 +19,7 @@ Create a draft PR following the team's quality standards. - `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) 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 e082b260..826c8c4a 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -20,20 +20,20 @@ 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` in ViewModel or Model + - No `import UIKit` anywhere — this branch is pure SwiftUI - 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 patterns (this branch uses Combine + UIKit coordinators) + - Branch-specific: Combine + NavigationPath + single AppCoordinator (ObservableObject) 4. **Similar pattern search** - Search the codebase for code that follows the same pattern as what changed - If the same improvement should be applied elsewhere, flag each location 5. **Correctness check** - - Logic errors, type safety, concurrency (Swift 6 strict), memory management (`[weak self]`) + - Logic errors, type safety, concurrency (Swift 6 strict), memory management (`[weak self]`, `[weak coordinator]`) - Verify `@MainActor` isolation, `Sendable` conformance where needed 6. **Cross-platform parity** diff --git a/CLAUDE.md b/CLAUDE.md index 9708ea06..ef58e585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,21 +45,27 @@ Coordinator → UI → ViewModel → Model → Core Never import upward. ViewModel must NOT import UI or Coordinator. Model must NOT import Services. ## Anti-Patterns (Red Flags) -- `import UIKit` in ViewModel or Model packages — UIKit belongs in UI and Coordinator only +- `import UIKit` anywhere — this branch is pure SwiftUI, zero UIKit - 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 — navigation decisions belong in Coordinators only +- 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. -## Architecture (this branch: main) -- **Entry point**: UIKit `AppDelegate` + `SceneDelegate` (scene-based lifecycle) -- **Navigation**: 6 UIKit coordinators — `AppCoordinator`, `BaseCoordinator`, `LoginCoordinator`, `HomeCoordinator`, `ItemsCoordinator`, `SettingsCoordinator` -- **Views**: SwiftUI views embedded in UIHostingController via UIViewControllers -- **Reactive**: Combine (`@Published`, `CurrentValueSubject`, `.sink`) -- **ViewModel → Coordinator**: Optional closures (`onShowDetail`, `onShowProfile`, etc.) +## Architecture (this branch: feature/navigation-stack) +- **Entry point**: SwiftUI `@main App` struct (`FunApp.swift`) — no AppDelegate or SceneDelegate +- **Navigation**: Single `AppCoordinator: ObservableObject` with per-tab `NavigationPath` +- **Views**: Pure SwiftUI views, no UIHostingController or UIViewControllers +- **Reactive**: Combine (`@Published`, `@StateObject`, `@ObservedObject`, `.sink`) +- **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. ## Rule Index Consult these files for detailed guidance (not auto-loaded — read on demand): @@ -69,11 +75,12 @@ Consult these files for detailed guidance (not auto-loaded — read on demand): ## Code Style - Swift 6 strict concurrency, iOS 17+ -- SwiftUI + UIKit hybrid, MVVM-C with Combine -- ViewModels use closures for navigation (no coordinator protocols) +- 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 - Navigation logic ONLY in Coordinators, never in Views - Protocol placement: Core = reusable abstractions, Model = domain-specific -- ServiceLocator with @Service property wrapper (assertionFailure, not fatalError) +- ServiceLocator with @Service property wrapper - Combine over NotificationCenter for reactive state ## Testing diff --git a/Coordinator/Package.swift b/Coordinator/Package.swift index 547d305e..a01b355c 100644 --- a/Coordinator/Package.swift +++ b/Coordinator/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Coordinator", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunCoordinator", targets: ["FunCoordinator"]), diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 0b6a5416..7a280dab 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -2,22 +2,23 @@ // AppCoordinator.swift // Coordinator // -// Main coordinator for the application +// SwiftUI-based coordinator managing navigation state and app flow // -import UIKit +import Combine +import SwiftUI import FunCore import FunModel -import FunUI -import FunViewModel -/// Main app coordinator that manages the root navigation and app flow -public final class AppCoordinator: BaseCoordinator { +@MainActor +public final class AppCoordinator: ObservableObject { // MARK: - Services @Service(.logger) private var logger: LoggerService + @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol + @Service(.toast) private var toastService: ToastServiceProtocol // MARK: - Session Management @@ -26,38 +27,42 @@ public final class AppCoordinator: BaseCoordinator { // MARK: - App Flow State - private var currentFlow: AppFlow = .login + @Published public var currentFlow: AppFlow = .login - // MARK: - Child Coordinators + // MARK: - Navigation State - private var loginCoordinator: LoginCoordinator? - private var homeCoordinator: HomeCoordinator? - private var itemsCoordinator: ItemsCoordinator? - private var settingsCoordinator: SettingsCoordinator? + @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 - // Store tab bar view model for tab switching - private var tabBarViewModel: HomeTabBarViewModel? + // MARK: - Deep Link - // Queue deep link if received during login flow private var pendingDeepLink: DeepLink? + // MARK: - Toast + + @Published public var activeToast: ToastEvent? + private var cancellables = Set() + + // MARK: - Dark Mode + + @Published public var appearanceMode: AppearanceMode = .system + private var darkModeCancellable: AnyCancellable? + // MARK: - Init - public init(navigationController: UINavigationController, sessionFactory: SessionFactory) { + public init(sessionFactory: SessionFactory) { self.sessionFactory = sessionFactory - super.init(navigationController: navigationController) } // MARK: - Start - override public func start() { + public func start() { activateSession(for: currentFlow) - switch currentFlow { - case .login: - showLoginFlow() - case .main: - showMainFlow() - } + observeToastEvents() + observeDarkMode() } // MARK: - Session Lifecycle @@ -69,106 +74,55 @@ public final class AppCoordinator: BaseCoordinator { currentSession = session } - // MARK: - Flow Management - - private func showLoginFlow() { - // Clear any existing main flow coordinators - clearMainFlowCoordinators() + // MARK: - Navigation - let loginCoordinator = LoginCoordinator(navigationController: navigationController) - loginCoordinator.onLoginSuccess = { [weak self] in - self?.transitionToMainFlow() - } - self.loginCoordinator = loginCoordinator - loginCoordinator.start() - } - - private func showMainFlow() { - // Clear login coordinator - loginCoordinator = nil - - // Create navigation controllers for each tab - let homeNavController = UINavigationController() - let itemsNavController = UINavigationController() - let settingsNavController = UINavigationController() - - // Large navigation titles for root screens - homeNavController.navigationBar.prefersLargeTitles = true - itemsNavController.navigationBar.prefersLargeTitles = true - settingsNavController.navigationBar.prefersLargeTitles = true - - // Configure tab bar items with icons and titles - homeNavController.tabBarItem = UITabBarItem( - title: L10n.Tabs.home, - image: UIImage(systemName: "house"), - selectedImage: UIImage(systemName: "house.fill") - ) - homeNavController.tabBarItem.accessibilityIdentifier = AccessibilityID.Tabs.home - - itemsNavController.tabBarItem = UITabBarItem( - title: L10n.Tabs.items, - image: UIImage(systemName: "list.bullet"), - selectedImage: UIImage(systemName: "list.bullet") - ) - itemsNavController.tabBarItem.accessibilityIdentifier = AccessibilityID.Tabs.items - - settingsNavController.tabBarItem = UITabBarItem( - title: L10n.Tabs.settings, - image: UIImage(systemName: "gearshape"), - selectedImage: UIImage(systemName: "gearshape.fill") - ) - settingsNavController.tabBarItem.accessibilityIdentifier = AccessibilityID.Tabs.settings - - // Create view model for tab bar - let tabBarViewModel = HomeTabBarViewModel() - self.tabBarViewModel = tabBarViewModel - - // Create and store coordinators for each tab - let homeCoordinator = HomeCoordinator( - navigationController: homeNavController - ) - let itemsCoordinator = ItemsCoordinator( - navigationController: itemsNavController - ) - let settingsCoordinator = SettingsCoordinator( - navigationController: settingsNavController - ) - - // Set up logout callback through home coordinator (Profile modal) - homeCoordinator.onLogout = { [weak self] in - self?.transitionToLoginFlow() + public func showDetail(_ item: FeaturedItem, in tab: TabIndex) { + switch tab { + case .home: homePath.append(item) + case .items: itemsPath.append(item) + default: break } + } - // Store coordinators to prevent deallocation - self.homeCoordinator = homeCoordinator - self.itemsCoordinator = itemsCoordinator - self.settingsCoordinator = settingsCoordinator + public func showProfile() { + isProfilePresented = true + } + + public func dismissProfile() { + isProfilePresented = false + } - // Start each coordinator's flow - homeCoordinator.start() - itemsCoordinator.start() - settingsCoordinator.start() + public func selectTab(_ tab: TabIndex) { + selectedTab = tab + } - // Create tab bar with view model and navigation controllers - let tabBarController = HomeTabBarController( - viewModel: tabBarViewModel, - tabNavigationControllers: [ - homeNavController, - itemsNavController, - settingsNavController - ] - ) + public func popToRoot() { + homePath = NavigationPath() + itemsPath = NavigationPath() + settingsPath = NavigationPath() + } - // Set as root (tab bar doesn't push, it's the container) - navigationController.setViewControllers([tabBarController], animated: false) + // MARK: - Routing + + // Centralised routing table — called from both homeTab and itemsTab + // .navigationDestination closures, so destination logic lives in one place. + // As destination types grow, expand with a switch: + // + // switch item.category { + // case .article: ArticleDetailView(item: item) + // case .video: VideoPlayerView(item: item) + // default: DetailTabContent(item: item) + // } + @ViewBuilder + func destinationView(for item: FeaturedItem) -> some View { + DetailTabContent(item: item) } // MARK: - Flow Transitions - private func transitionToMainFlow() { + public func transitionToMainFlow() { currentFlow = .main activateSession(for: .main) - showMainFlow() if let deepLink = pendingDeepLink { pendingDeepLink = nil @@ -176,52 +130,86 @@ public final class AppCoordinator: BaseCoordinator { } } - private func transitionToLoginFlow() { + public func transitionToLoginFlow() { currentFlow = .login pendingDeepLink = nil activateSession(for: .login) - showLoginFlow() - } - // MARK: - Cleanup - - private func clearMainFlowCoordinators() { - homeCoordinator = nil - itemsCoordinator = nil - settingsCoordinator = nil - tabBarViewModel = nil + // Reset navigation state + popToRoot() + selectTab(.home) + dismissProfile() + activeToast = nil } // MARK: - Deep Link Handling - /// Handle incoming deep link - /// - Parameter deepLink: The deep link to handle public func handleDeepLink(_ deepLink: DeepLink) { - // If on login screen, queue for after login if currentFlow == .login { pendingDeepLink = deepLink return } - executeDeepLink(deepLink) } private func executeDeepLink(_ deepLink: DeepLink) { switch deepLink { case .tab(let tabIndex): - tabBarViewModel?.switchToTab(tabIndex.rawValue) + selectTab(tabIndex) case .item(let id): - tabBarViewModel?.switchToTab(TabIndex.home.rawValue) + selectTab(.home) if let item = FeaturedItem.all.first(where: { $0.id == id }) { - homeCoordinator?.showDetail(for: item) + showDetail(item, in: .home) } else { logger.log("Deep link item not found: \(id)", level: .warning, category: .general) } case .profile: - tabBarViewModel?.switchToTab(TabIndex.home.rawValue) - homeCoordinator?.showProfile() + selectTab(.home) + showProfile() } } + + // MARK: - Toast + + private func observeToastEvents() { + ServiceLocator.shared.serviceDidRegisterPublisher + .filter { $0 == .toast } + .sink { [weak self] _ in + self?.subscribeToToasts() + } + .store(in: &cancellables) + } + + private func subscribeToToasts() { + toastService.toastPublisher + .sink { [weak self] event in + self?.activeToast = event + } + .store(in: &cancellables) + } + + public func dismissToast() { + activeToast = nil + } + + // 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 + } + } } diff --git a/Coordinator/Sources/Coordinator/AppRootView.swift b/Coordinator/Sources/Coordinator/AppRootView.swift new file mode 100644 index 00000000..a15b2113 --- /dev/null +++ b/Coordinator/Sources/Coordinator/AppRootView.swift @@ -0,0 +1,43 @@ +// +// AppRootView.swift +// Coordinator +// +// Root SwiftUI view that switches between login and main tab flow. +// Lives in Coordinator (not FunUI) because it depends on AppCoordinator. +// Moving to FunUI would create a circular dependency: Coordinator → UI → Coordinator. +// + +import SwiftUI + +import FunCore +import FunModel +import FunUI +import FunViewModel + +public struct AppRootView: View { + @ObservedObject var coordinator: AppCoordinator + + public init(coordinator: AppCoordinator) { + self.coordinator = coordinator + } + + public var body: some View { + Group { + switch coordinator.currentFlow { + case .login: + LoginTabContent(coordinator: coordinator) + case .main: + MainTabView(coordinator: coordinator) + } + } + .preferredColorScheme(colorScheme) + } + + private var colorScheme: ColorScheme? { + switch coordinator.appearanceMode { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} diff --git a/Coordinator/Sources/Coordinator/BaseCoordinator.swift b/Coordinator/Sources/Coordinator/BaseCoordinator.swift deleted file mode 100644 index 5c49ef1c..00000000 --- a/Coordinator/Sources/Coordinator/BaseCoordinator.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// BaseCoordinator.swift -// Coordinator -// -// Base coordinator class with safe navigation methods -// - -import UIKit - -// MARK: - Coordinator Protocol - -@MainActor -public protocol Coordinator: AnyObject { - var navigationController: UINavigationController { get } - func start() -} - -// MARK: - Base Coordinator - -@MainActor -open class BaseCoordinator: Coordinator { - - public let navigationController: UINavigationController - - private var isTransitioning: Bool { - navigationController.transitionCoordinator != nil - } - - /// Single pending action retried after the current transition completes. - /// Handles deep links arriving mid-transition without full queue complexity. - private var pendingAction: (@MainActor () -> Void)? - - public init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - open func start() { - // Override in subclasses - } - - // MARK: - Safe Navigation Methods - - public func safePush(_ viewController: UIViewController, animated: Bool = true) { - guard !isTransitioning else { - scheduleRetry { [weak self] in - self?.safePush(viewController, animated: animated) - } - return - } - - navigationController.pushViewController(viewController, animated: animated) - } - - public func safePop(animated: Bool = true, completion: (@MainActor () -> Void)? = nil) { - guard !isTransitioning else { - scheduleRetry { [weak self] in - self?.safePop(animated: animated, completion: completion) - } - return - } - navigationController.popViewController(animated: animated) - // UIKit's popViewController has no completion handler. - // Hook into the transition coordinator created by the pop to - // fire our completion after the animation finishes. - if animated, let transitionCoordinator = navigationController.transitionCoordinator { - transitionCoordinator.animate(alongsideTransition: nil) { _ in - completion?() - } - } else { - completion?() - } - } - - public func safePresent(_ viewController: UIViewController, animated: Bool = true, completion: (@MainActor () -> Void)? = nil) { - // Walk up to the topmost presented VC so we present from the right level - var presenter: UIViewController = navigationController - while let presented = presenter.presentedViewController { - presenter = presented - } - presenter.present(viewController, animated: animated, completion: completion) - } - - public func safeDismiss(animated: Bool = true, completion: (@MainActor () -> Void)? = nil) { - navigationController.dismiss(animated: animated, completion: completion) - } - - // MARK: - Share - - public func share(text: String) { - let activityViewController = UIActivityViewController( - activityItems: [text], - applicationActivities: nil - ) - - // iPad support - if let popoverController = activityViewController.popoverPresentationController { - popoverController.sourceView = navigationController.view - popoverController.sourceRect = CGRect( - x: navigationController.view.bounds.midX, - y: navigationController.view.bounds.midY, - width: 0, - height: 0 - ) - popoverController.permittedArrowDirections = [] - } - - safePresent(activityViewController) - } - - // MARK: - Transition Retry - - /// Schedules a single action to execute after the current transition completes. - /// If a new action arrives before the previous one executes, it replaces it - /// (latest navigation intent wins). - private func scheduleRetry(_ action: @escaping @MainActor () -> Void) { - pendingAction = action - - if let coordinator = navigationController.transitionCoordinator { - coordinator.animate( - alongsideTransition: nil, - completion: { [weak self] _ in - self?.executePendingAction() - } - ) - } else { - executePendingAction() - } - } - - private func executePendingAction() { - let action = pendingAction - pendingAction = nil - action?() - } -} diff --git a/Coordinator/Sources/Coordinator/HomeCoordinator.swift b/Coordinator/Sources/Coordinator/HomeCoordinator.swift deleted file mode 100644 index 61276d91..00000000 --- a/Coordinator/Sources/Coordinator/HomeCoordinator.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// HomeCoordinator.swift -// Coordinator -// -// Coordinator for Home tab -// - -import UIKit - -import FunModel -import FunUI -import FunViewModel - -public final class HomeCoordinator: BaseCoordinator { - - // MARK: - Properties - - /// Callback to notify parent coordinator of logout - public var onLogout: (() -> Void)? - - private var isShowingDetail = false - - override public func start() { - let viewModel = HomeViewModel() - viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } - viewModel.onShowProfile = { [weak self] in self?.showProfile() } - - let viewController = HomeViewController(viewModel: viewModel) - navigationController.setViewControllers([viewController], animated: false) - } - - // MARK: - Navigation - - public func showDetail(for item: FeaturedItem) { - guard !isShowingDetail else { return } - isShowingDetail = true - - let viewModel = DetailViewModel(item: item) - viewModel.onPop = { [weak self] in self?.isShowingDetail = false } - viewModel.onShare = { [weak self] text in self?.share(text: text) } - - let viewController = DetailViewController(viewModel: viewModel) - safePush(viewController) - } - - public func showProfile() { - let profileNavController = UINavigationController() - - let viewModel = ProfileViewModel() - viewModel.onDismiss = { [weak self] in self?.safeDismiss() } - viewModel.onLogout = { [weak self] in self?.safeDismiss { self?.onLogout?() } } - viewModel.onGoToItems = { [weak self] in - self?.safeDismiss() - if let url = URL(string: "funapp://tab/items") { - UIApplication.shared.open(url) - } - } - - let viewController = ProfileViewController(viewModel: viewModel) - profileNavController.setViewControllers([viewController], animated: false) - - safePresent(profileNavController) - } -} diff --git a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift b/Coordinator/Sources/Coordinator/ItemsCoordinator.swift deleted file mode 100644 index d105e8d0..00000000 --- a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ItemsCoordinator.swift -// Coordinator -// -// Coordinator for Items tab -// - -import UIKit - -import FunModel -import FunUI -import FunViewModel - -public final class ItemsCoordinator: BaseCoordinator { - - private var isShowingDetail = false - - override public func start() { - let viewModel = ItemsViewModel() - viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } - - let viewController = ItemsViewController(viewModel: viewModel) - navigationController.setViewControllers([viewController], animated: false) - } - - // MARK: - Navigation - - public func showDetail(for item: FeaturedItem) { - guard !isShowingDetail else { return } - isShowingDetail = true - - let viewModel = DetailViewModel(item: item) - viewModel.onPop = { [weak self] in self?.isShowingDetail = false } - viewModel.onShare = { [weak self] text in self?.share(text: text) } - - let viewController = DetailViewController(viewModel: viewModel) - safePush(viewController) - } -} diff --git a/Coordinator/Sources/Coordinator/LoginCoordinator.swift b/Coordinator/Sources/Coordinator/LoginCoordinator.swift deleted file mode 100644 index 4535c5e2..00000000 --- a/Coordinator/Sources/Coordinator/LoginCoordinator.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// LoginCoordinator.swift -// Coordinator -// -// Coordinator for Login flow -// - -import UIKit - -import FunUI -import FunViewModel - -public final class LoginCoordinator: BaseCoordinator { - - // MARK: - Properties - - /// Callback to notify parent coordinator of successful login - public var onLoginSuccess: (() -> Void)? - - override public func start() { - let viewModel = LoginViewModel() - viewModel.onLogin = { [weak self] in self?.onLoginSuccess?() } - - let viewController = LoginViewController(viewModel: viewModel) - navigationController.setViewControllers([viewController], animated: false) - } -} diff --git a/Coordinator/Sources/Coordinator/MainTabView.swift b/Coordinator/Sources/Coordinator/MainTabView.swift new file mode 100644 index 00000000..8bfde47d --- /dev/null +++ b/Coordinator/Sources/Coordinator/MainTabView.swift @@ -0,0 +1,179 @@ +// +// MainTabView.swift +// Coordinator +// +// Main tab view with NavigationStack per tab, profile sheet, and toast overlay. +// Lives in Coordinator (not FunUI) because it depends on AppCoordinator. +// Moving to FunUI would create a circular dependency: Coordinator → UI → Coordinator. +// + +import SwiftUI + +import FunCore +import FunModel +import FunUI +import FunViewModel + +struct MainTabView: View { + @ObservedObject var coordinator: AppCoordinator + + var body: some View { + TabView(selection: $coordinator.selectedTab) { + homeTab + itemsTab + settingsTab + } + .sheet(isPresented: $coordinator.isProfilePresented) { + NavigationStack { + ProfileTabContent(coordinator: coordinator) + } + } + .overlay(alignment: .top) { + if let toast = coordinator.activeToast { + ToastView( + message: toast.message, + type: toast.type, + onDismiss: { coordinator.dismissToast() } + ) + } + } + } + + // MARK: - Tabs + + private var homeTab: some View { + NavigationStack(path: $coordinator.homePath) { + HomeTabContent(coordinator: coordinator) + .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") + } + .tag(TabIndex.home) + .accessibilityIdentifier(AccessibilityID.Tabs.home) + } + + private var itemsTab: some View { + NavigationStack(path: $coordinator.itemsPath) { + ItemsTabContent(coordinator: coordinator) + .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") + } + .tag(TabIndex.items) + .accessibilityIdentifier(AccessibilityID.Tabs.items) + } + + private var settingsTab: some View { + NavigationStack(path: $coordinator.settingsPath) { + SettingsTabContent() + } + .tabItem { + Label(L10n.Tabs.settings, systemImage: "gearshape") + } + .tag(TabIndex.settings) + .accessibilityIdentifier(AccessibilityID.Tabs.settings) + } +} + +// MARK: - Tab Content Views + +/// Wrapper that creates HomeViewModel with navigation closures wired to coordinator +struct HomeTabContent: View { + let coordinator: AppCoordinator + @StateObject private var viewModel = HomeViewModel() + + var body: some View { + HomeView(viewModel: viewModel) + .task { + viewModel.onShowDetail = { [weak coordinator] item in + coordinator?.showDetail(item, in: .home) + } + viewModel.onShowProfile = { [weak coordinator] in + coordinator?.showProfile() + } + } + } +} + +/// Wrapper that creates ItemsViewModel with navigation closures wired to coordinator +struct ItemsTabContent: View { + let coordinator: AppCoordinator + @StateObject private var viewModel = ItemsViewModel() + + var body: some View { + ItemsView(viewModel: viewModel) + .task { + viewModel.onShowDetail = { [weak coordinator] item in + coordinator?.showDetail(item, in: .items) + } + } + } +} + +/// Wrapper that creates SettingsViewModel +struct SettingsTabContent: View { + @StateObject private var viewModel = SettingsViewModel() + + var body: some View { + SettingsView(viewModel: viewModel) + } +} + +/// Wrapper that creates DetailViewModel for a pushed item +struct DetailTabContent: View { + @StateObject private var viewModel: DetailViewModel + + init(item: FeaturedItem) { + _viewModel = StateObject(wrappedValue: DetailViewModel(item: item)) + } + + var body: some View { + DetailView(viewModel: viewModel) + } +} + +/// Wrapper that creates ProfileViewModel with navigation closures +struct ProfileTabContent: View { + let coordinator: AppCoordinator + @StateObject private var viewModel = ProfileViewModel() + + var body: some View { + ProfileView(viewModel: viewModel) + .task { + viewModel.onDismiss = { [weak coordinator] in + coordinator?.dismissProfile() + } + viewModel.onLogout = { [weak coordinator] in + coordinator?.dismissProfile() + coordinator?.transitionToLoginFlow() + } + viewModel.onGoToItems = { [weak coordinator] in + coordinator?.dismissProfile() + coordinator?.selectTab(.items) + } + } + } +} + +/// Wrapper that creates LoginViewModel with login success closure +struct LoginTabContent: View { + let coordinator: AppCoordinator + @StateObject private var viewModel = LoginViewModel() + + var body: some View { + LoginView(viewModel: viewModel) + .task { + viewModel.onLoginSuccess = { [weak coordinator] in + coordinator?.transitionToMainFlow() + } + } + } +} diff --git a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift b/Coordinator/Sources/Coordinator/SettingsCoordinator.swift deleted file mode 100644 index 2b46440a..00000000 --- a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SettingsCoordinator.swift -// Coordinator -// -// Coordinator for Settings tab -// - -import UIKit - -import FunUI -import FunViewModel - -public final class SettingsCoordinator: BaseCoordinator { - - override public func start() { - let viewModel = SettingsViewModel() - let viewController = SettingsViewController(viewModel: viewModel) - navigationController.setViewControllers([viewController], animated: false) - } -} diff --git a/Core/Package.swift b/Core/Package.swift index 1344aae9..e61a8491 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -5,8 +5,8 @@ let package = Package( name: "Core", defaultLocalization: "en", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunCore", targets: ["FunCore"]), diff --git a/FunApp/FunApp.xcodeproj/project.pbxproj b/FunApp/FunApp.xcodeproj/project.pbxproj index 6ec90b4b..e68fe133 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; + PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.swiftui; 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; + PRODUCT_BUNDLE_IDENTIFIER = com.charles.wang.FunApp.swiftui; 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 = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.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 = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/FunApp/FunApp/AppDelegate.swift b/FunApp/FunApp/AppDelegate.swift deleted file mode 100644 index c3e36204..00000000 --- a/FunApp/FunApp/AppDelegate.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// AppDelegate.swift -// FunApp -// - -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - // Intentionally empty — all app lifecycle is handled by SceneDelegate -} diff --git a/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/FunApp/FunApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 17a6a831..af467781 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 new file mode 100644 index 00000000..7c6c110b --- /dev/null +++ b/FunApp/FunApp/FunApp.swift @@ -0,0 +1,32 @@ +// +// FunApp.swift +// FunApp +// +// SwiftUI App entry point +// + +import SwiftUI + +import FunCoordinator +import FunModel + +@main +struct FunApp: App { + @StateObject private var coordinator = AppCoordinator( + sessionFactory: AppSessionFactory() + ) + + var body: some Scene { + WindowGroup { + AppRootView(coordinator: coordinator) + .onOpenURL { url in + if let deepLink = DeepLink(url: url) { + coordinator.handleDeepLink(deepLink) + } + } + .task { + coordinator.start() + } + } + } +} diff --git a/FunApp/FunApp/Info.plist b/FunApp/FunApp/Info.plist index 7744c929..f825c8cf 100644 --- a/FunApp/FunApp/Info.plist +++ b/FunApp/FunApp/Info.plist @@ -3,7 +3,7 @@ CFBundleDisplayName - Fun App + Fun SwiftUI CFBundleURLTypes @@ -12,25 +12,8 @@ funapp CFBundleURLName - com.charles.wang.FunApp + com.charles.wang.FunApp.swiftui - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift deleted file mode 100644 index ec19752a..00000000 --- a/FunApp/FunApp/SceneDelegate.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// SceneDelegate.swift -// FunApp -// -// Created by Charles Wang on 30/01/2026. -// - -import Combine -import UIKit - -import FunCoordinator -import FunCore -import FunModel - -@MainActor -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - var appCoordinator: AppCoordinator? - private var cancellables = Set() - private var darkModeCancellable: AnyCancellable? - - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let windowScene = (scene as? UIWindowScene) else { return } - - // MARK: - Setup Window - - let window = UIWindow(windowScene: windowScene) - - // Create root navigation controller - let navigationController = UINavigationController() - navigationController.setNavigationBarHidden(true, animated: false) - - window.rootViewController = navigationController - self.window = window - - // Observe before coordinator.start() so serviceDidRegisterPublisher triggers initial subscription - observeDarkMode() - - // Create and start app coordinator with session factory - let coordinator = AppCoordinator( - navigationController: navigationController, - sessionFactory: AppSessionFactory() - ) - coordinator.start() - self.appCoordinator = coordinator - - window.makeKeyAndVisible() - - // Handle deep link from cold start - if let url = connectionOptions.urlContexts.first?.url, - let deepLink = DeepLink(url: url) { - coordinator.handleDeepLink(deepLink) - } - } - - // 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() { - // Cancel previous subscription to avoid duplicates on repeated registrations - darkModeCancellable?.cancel() - darkModeCancellable = featureToggleService.appearanceModePublisher - .sink { [weak self] mode in - let style: UIUserInterfaceStyle = switch mode { - case .system: .unspecified - case .light: .light - case .dark: .dark - } - self?.window?.overrideUserInterfaceStyle = style - } - } - - // MARK: - Deep Link Handling - - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - guard let url = URLContexts.first?.url, - let deepLink = DeepLink(url: url) else { return } - appCoordinator?.handleDeepLink(deepLink) - } -} diff --git a/Model/Package.swift b/Model/Package.swift index 50bc73f4..2ed4baec 100644 --- a/Model/Package.swift +++ b/Model/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Model", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunModel", targets: ["FunModel"]), diff --git a/Model/Sources/Model/FeaturedItem.swift b/Model/Sources/Model/FeaturedItem.swift index 6d0fb811..a214ecc0 100644 --- a/Model/Sources/Model/FeaturedItem.swift +++ b/Model/Sources/Model/FeaturedItem.swift @@ -7,7 +7,7 @@ import Foundation -public struct FeaturedItem: Identifiable, Equatable, Sendable { +public struct FeaturedItem: Identifiable, Hashable, Sendable { public let id: String public let title: String public let subtitle: String @@ -75,7 +75,7 @@ public extension FeaturedItem { static let swiftUI = FeaturedItem( id: TechnologyItem.swiftUI.rawValue, title: "SwiftUI", - subtitle: "Declarative UI", + subtitle: "Pure SwiftUI + NavigationStack", 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: "Navigation pattern", + subtitle: "Single ObservableObject", iconName: "arrow.triangle.branch", iconColor: .purple, category: "Navigation" @@ -197,7 +197,7 @@ public extension FeaturedItem { static let deploymentTarget = FeaturedItem( id: TechnologyItem.deploymentTarget.rawValue, - title: "iOS 15+", + title: "iOS 16+", subtitle: "Minimum deployment target", iconName: "iphone.gen3", iconColor: .yellow, diff --git a/Model/Sources/Model/ItemColor.swift b/Model/Sources/Model/ItemColor.swift index 80da2d68..1c7d5cd4 100644 --- a/Model/Sources/Model/ItemColor.swift +++ b/Model/Sources/Model/ItemColor.swift @@ -7,7 +7,7 @@ import Foundation -public enum ItemColor: String, Sendable, Equatable { +public enum ItemColor: String, Sendable, Hashable { case green case orange case blue diff --git a/Model/Sources/Model/TechnologyDescriptions+Extended.swift b/Model/Sources/Model/TechnologyDescriptions+Extended.swift new file mode 100644 index 00000000..5675c990 --- /dev/null +++ b/Model/Sources/Model/TechnologyDescriptions+Extended.swift @@ -0,0 +1,113 @@ +// +// TechnologyDescriptions+Extended.swift +// Model +// +// Additional technology descriptions split out for type_body_length compliance +// + +extension TechnologyDescriptions { + + static let snapshotDescription = """ + Visual regression testing with swift-snapshot-testing: + + ```swift + @Test func homeViewSnapshot() { + let view = HomeView(viewModel: mockViewModel) + assertSnapshot(of: view, as: .image) + } + ``` + + • Captures UI as images + • Detects unintended visual changes + • Multiple device configurations + • Light/dark mode variants + """ + + static let accessibilityDescription = """ + Full VoiceOver and accessibility support: + + • accessibilityIdentifier for UI testing + • accessibilityLabel for VoiceOver + • accessibilityHint for context + + Example: + ```swift + .accessibilityIdentifier("featured_card_\\(item.id)") + .accessibilityLabel("\\(item.title), \\(item.subtitle)") + .accessibilityHint("Double tap to view details") + ``` + + All interactive elements are accessible. + """ + + static let deploymentTargetDescription = """ + This branch requires iOS 16.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 + + Three branches demonstrate progressive iOS version requirements: + • main: iOS 15+ (UIKit navigation + Combine) + • navigation-stack: iOS 16+ (SwiftUI NavigationStack + Combine) + • observation: iOS 17+ (AsyncStream + @Observable, zero Combine) + + Choose the branch that matches your app's deployment target. + """ + + static let concurrencyPatternsDescription = """ + Three approaches to the same problem: fetch 3 pages of items concurrently \ + and combine results. + + 1. Callbacks (DispatchGroup + concurrent barrier queue): + ```swift + let group = DispatchGroup() + let queue = DispatchQueue(label: "fetch", attributes: .concurrent) + var allItems: [[Item]] = Array(repeating: [], count: 3) + + for page in 0..<3 { + group.enter() + queue.async { + let items = fetchPage(page) + queue.async(flags: .barrier) { + allItems[page] = items + group.leave() + } + } + } + group.notify(queue: .main) { completion(allItems.flatMap { $0 }) } + ``` + 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): + ```swift + let publishers = (0..<3).map { page in + Future<[Item], Never> { promise in + promise(.success(fetchPage(page))) + } + } + Publishers.MergeMany(publishers) + .collect() + .map { $0.flatMap { $0 } } + .sink { items in self.allItems = items } + .store(in: &cancellables) + ``` + + 3. async/await (TaskGroup): + ```swift + let items = await withTaskGroup(of: (Int, [Item]).self) { group in + for page in 0..<3 { + group.addTask { (page, await fetchPage(page)) } + } + var results: [(Int, [Item])] = [] + for await result in group { results.append(result) } + return results.sorted { $0.0 < $1.0 }.flatMap { $0.1 } + } + ``` + + All three produce identical results. async/await is the cleanest syntax. + """ +} diff --git a/Model/Sources/Model/TechnologyDescriptions.swift b/Model/Sources/Model/TechnologyDescriptions.swift index 5eb481d6..1ac7523f 100644 --- a/Model/Sources/Model/TechnologyDescriptions.swift +++ b/Model/Sources/Model/TechnologyDescriptions.swift @@ -70,7 +70,7 @@ public enum TechnologyDescriptions { Example from HomeViewModel: ```swift public func loadFeaturedItems() async { - try? await Task.sleep(nanoseconds: delay) + try? await Task.sleep(for: .milliseconds(delay)) featuredItems = FeaturedItem.allCarouselSets } ``` @@ -95,36 +95,36 @@ public enum TechnologyDescriptions { """ private static let swiftUIDescription = """ - SwiftUI provides the declarative UI layer: + SwiftUI provides the entire UI and navigation layer: - • All tab views built with SwiftUI (HomeView, ItemsView, etc.) - • Embedded in UIKit via UIHostingController + • All views built with SwiftUI (HomeView, ItemsView, etc.) + • NavigationStack + NavigationPath for programmatic navigation • @ObservedObject for ViewModel binding • Modern modifiers: .refreshable, .swipeActions, .searchable - UIKit + SwiftUI Interop: + Navigation: ```swift - func embedSwiftUIView(_ content: Content) { - let hosting = UIHostingController(rootView: content) - addChild(hosting) - view.addSubview(hosting.view) + NavigationStack(path: $coordinator.homePath) { + HomeView(viewModel: viewModel) + .navigationDestination(for: FeaturedItem.self) { item in + DetailView(viewModel: DetailViewModel(item: item)) + } } ``` """ private static let coordinatorDescription = """ - Coordinator pattern manages all navigation flow: - - • BaseCoordinator with safe navigation methods - • Prevents duplicate pushes and handles transitions - • 3 tab coordinators handle all screens in their stack directly - • ViewModels use closures (onPop, onShare, onDismiss) instead of coordinator protocols - - Structure: - AppCoordinator - ├── HomeCoordinator (detail + profile screens) - ├── ItemsCoordinator (detail screens) - └── SettingsCoordinator + A single AppCoordinator manages all navigation: + + • ObservableObject owning NavigationPath per tab + • Programmatic push via path.append() + • Modal presentation via @Published booleans + • ViewModels receive navigation closures, not coordinator refs + + Flow: + View → ViewModel.onShowDetail?(item) + → AppCoordinator.homePath.append(item) + → NavigationStack picks up via .navigationDestination """ private static let mvvmDescription = """ @@ -151,7 +151,7 @@ public enum TechnologyDescriptions { • Model - Data models, protocols • Services - Concrete implementations • ViewModel - Business logic - • UI - SwiftUI views, UIKit controllers + • UI - SwiftUI views • Coordinator - Navigation logic Dependency graph: @@ -170,7 +170,7 @@ public enum TechnologyDescriptions { private static let serviceLocatorDescription = """ Custom dependency injection using ServiceLocator pattern: - Registration (in SceneDelegate): + Registration (in Session.activate): ```swift ServiceLocator.shared.register( NetworkServiceImpl(), @@ -283,113 +283,4 @@ public enum TechnologyDescriptions { Benefits over XCTest: cleaner syntax, better assertions, parallel execution. """ - private static let snapshotDescription = """ - Visual regression testing with swift-snapshot-testing: - - ```swift - @Test func homeViewSnapshot() { - let view = HomeView(viewModel: mockViewModel) - assertSnapshot(of: view, as: .image) - } - ``` - - • Captures UI as images - • Detects unintended visual changes - • Multiple device configurations - • Light/dark mode variants - """ - - private static let accessibilityDescription = """ - Full VoiceOver and accessibility support: - - • accessibilityIdentifier for UI testing - • accessibilityLabel for VoiceOver - • accessibilityHint for context - - Example: - ```swift - .accessibilityIdentifier("featured_card_\\(item.id)") - .accessibilityLabel("\\(item.title), \\(item.subtitle)") - .accessibilityHint("Double tap to view details") - ``` - - All interactive elements are accessible. - """ - - private static let deploymentTargetDescription = """ - This branch requires iOS 15.0 as the minimum deployment target. - - iOS 15 provides: - • async/await and structured concurrency - • AsyncStream for event-driven sequences - • SwiftUI improvements (refreshable, swipeActions, searchable) - • UIKit-based navigation with UINavigationController - - Three branches demonstrate progressive iOS version requirements: - • main: iOS 15+ (UIKit navigation + Combine) - • navigation-stack: iOS 16+ (SwiftUI NavigationStack + Combine) - • observation: iOS 17+ (AsyncStream + @Observable, zero Combine) - - Choose the branch that matches your app's deployment target. - """ - - private static let concurrencyPatternsDescription = """ - Three approaches to the same problem: fetch 3 pages of items concurrently \ - and combine results. - - 1. Callbacks (DispatchGroup + concurrent barrier queue): - ```swift - let queue = DispatchQueue(label: "concurrent", attributes: .concurrent) - let group = DispatchGroup() - var items: [Item] = [] - - for _ in 0..<3 { - group.enter() - dataSource.fetchItems { result in - queue.asyncAndWait(flags: .barrier) { - items.append(contentsOf: result) - group.leave() - } - } - } - group.notify(queue: .main) { completion(items) } - ``` - The concurrent queue runs all 3 fetches in parallel. asyncAndWait with \ - barrier ensures thread-safe array mutations. - - 2. Combine (Publishers.MergeMany): - ```swift - let publishers = (0..<3).map { [weak self] _ in - Future<[Item], Never> { promise in - self?.dataSource.fetchItems { items in - promise(.success(items)) - } - }.eraseToAnyPublisher() - } - return Publishers.MergeMany(publishers) - .flatMap { $0.publisher } - .collect() - .eraseToAnyPublisher() - ``` - Future wraps callback-based fetches. MergeMany runs them concurrently, \ - flatMap flattens each page, collect gathers all items. - - 3. async/await (TaskGroup): - ```swift - var items: [Item] = [] - await withTaskGroup { group in - for _ in 0..<3 { - group.addTask { - await self.dataSource.fetchItems() - } - } - for await result in group { - items.append(contentsOf: result) - } - } - return items - ``` - - All three produce identical results. async/await is the cleanest syntax. - """ } diff --git a/README.md b/README.md index 346d4151..431ba4ce 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The three branches are **visually identical**, but architectural differences pro Fun-iOS/ ├── FunApp/ # iOS app target (Xcode project) ├── Coordinator/ # Navigation coordinators -├── UI/ # SwiftUI views & UIKit controllers +├── UI/ # SwiftUI views ├── ViewModel/ # Business logic (MVVM) ├── Model/ # Data models & protocols ├── Services/ # Concrete service implementations diff --git a/Services/Package.swift b/Services/Package.swift index 25f95015..034e1c00 100644 --- a/Services/Package.swift +++ b/Services/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "Services", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunServices", targets: ["FunServices"]), diff --git a/Services/Sources/Services/CoreServices/LoginSession.swift b/Services/Sources/Services/CoreServices/LoginSession.swift index 572b1701..534bbd37 100644 --- a/Services/Sources/Services/CoreServices/LoginSession.swift +++ b/Services/Sources/Services/CoreServices/LoginSession.swift @@ -17,6 +17,7 @@ public final class LoginSession: Session { locator.register(DefaultLoggerService(), for: .logger) locator.register(NetworkServiceImpl(), for: .network) locator.register(DefaultFeatureToggleService(), for: .featureToggles) + locator.register(DefaultToastService(), for: .toast) } public func teardown() { diff --git a/Services/Sources/Services/CoreServices/NetworkServiceImpl.swift b/Services/Sources/Services/CoreServices/NetworkServiceImpl.swift index 71484258..a39c267f 100644 --- a/Services/Sources/Services/CoreServices/NetworkServiceImpl.swift +++ b/Services/Sources/Services/CoreServices/NetworkServiceImpl.swift @@ -17,27 +17,24 @@ public actor NetworkServiceImpl: NetworkService { } public func login() async throws { - try await Task.sleep(nanoseconds: 500_000_000) + try await Task.sleep(for: .milliseconds(500)) } public func fetchFeaturedItems() async throws -> [[FeaturedItem]] { try await throwIfSimulatingErrors() - let delay = UInt64.random(in: 1_000_000_000...2_000_000_000) - try await Task.sleep(nanoseconds: delay) + try await Task.sleep(for: .milliseconds(.random(in: 1000...2000))) return FeaturedItem.allCarouselSets.shuffled().map { $0.shuffled() } } public func fetchAllItems() async throws -> [FeaturedItem] { try await throwIfSimulatingErrors() - let delay = UInt64.random(in: 500_000_000...1_000_000_000) - try await Task.sleep(nanoseconds: delay) + try await Task.sleep(for: .milliseconds(.random(in: 500...1000))) return FeaturedItem.all } public func searchItems(query: String, category: String) async throws -> [FeaturedItem] { try await throwIfSimulatingErrors() - let delay = UInt64.random(in: 300_000_000...800_000_000) - try await Task.sleep(nanoseconds: delay) + try await Task.sleep(for: .milliseconds(.random(in: 300...800))) var results = FeaturedItem.all @@ -58,8 +55,7 @@ public actor NetworkServiceImpl: NetworkService { private func throwIfSimulatingErrors() async throws { guard await shouldSimulateErrors() else { return } - let delay = UInt64.random(in: 1_000_000_000...2_000_000_000) - try await Task.sleep(nanoseconds: delay) + try await Task.sleep(for: .milliseconds(.random(in: 1000...2000))) throw AppError.networkError } } diff --git a/Services/Tests/ServicesTests/SessionTests.swift b/Services/Tests/ServicesTests/SessionTests.swift index 0ecd84f3..c0cb3a38 100644 --- a/Services/Tests/ServicesTests/SessionTests.swift +++ b/Services/Tests/ServicesTests/SessionTests.swift @@ -39,7 +39,7 @@ struct SessionTests { #expect(ServiceLocator.shared.isRegistered(for: .network)) #expect(ServiceLocator.shared.isRegistered(for: .featureToggles)) #expect(!ServiceLocator.shared.isRegistered(for: .favorites)) - #expect(!ServiceLocator.shared.isRegistered(for: .toast)) + #expect(ServiceLocator.shared.isRegistered(for: .toast)) session.teardown() } @@ -152,7 +152,7 @@ struct SessionTests { #expect(ServiceLocator.shared.isRegistered(for: .network)) #expect(ServiceLocator.shared.isRegistered(for: .featureToggles)) #expect(!ServiceLocator.shared.isRegistered(for: .favorites)) - #expect(!ServiceLocator.shared.isRegistered(for: .toast)) + #expect(ServiceLocator.shared.isRegistered(for: .toast)) login.teardown() } @@ -181,7 +181,7 @@ struct SessionTests { #expect(ServiceLocator.shared.isRegistered(for: .network)) #expect(ServiceLocator.shared.isRegistered(for: .featureToggles)) #expect(!ServiceLocator.shared.isRegistered(for: .favorites)) - #expect(!ServiceLocator.shared.isRegistered(for: .toast)) + #expect(ServiceLocator.shared.isRegistered(for: .toast)) login2.teardown() } diff --git a/UI/Package.swift b/UI/Package.swift index 732ac57f..ac291be4 100644 --- a/UI/Package.swift +++ b/UI/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "UI", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunUI", targets: ["FunUI"]), diff --git a/UI/Sources/UI/Components/ToastView.swift b/UI/Sources/UI/Components/ToastView.swift index d22e1488..17b8e8ba 100644 --- a/UI/Sources/UI/Components/ToastView.swift +++ b/UI/Sources/UI/Components/ToastView.swift @@ -68,7 +68,7 @@ public struct ToastView: View { } // Auto-dismiss after 3 seconds - try? await Task.sleep(nanoseconds: 3_000_000_000) + try? await Task.sleep(for: .seconds(3)) dismiss() } } @@ -88,7 +88,7 @@ public struct ToastView: View { isVisible = false } Task { - try? await Task.sleep(nanoseconds: 250_000_000) + try? await Task.sleep(for: .milliseconds(250)) onDismiss() } } diff --git a/UI/Sources/UI/Detail/DetailView.swift b/UI/Sources/UI/Detail/DetailView.swift index 710ec4a0..1b668ffd 100644 --- a/UI/Sources/UI/Detail/DetailView.swift +++ b/UI/Sources/UI/Detail/DetailView.swift @@ -18,23 +18,40 @@ public struct DetailView: View { } public var body: some View { + detailContent + .toolbar(.hidden, for: .tabBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { viewModel.didTapToggleFavorite() }) { + Image(systemName: viewModel.isFavorited ? "star.fill" : "star") + .foregroundColor(viewModel.isFavorited ? .yellow : .blue) + } + .accessibilityIdentifier(AccessibilityID.Detail.favoriteButton) + } + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: viewModel.shareText) + .accessibilityIdentifier(AccessibilityID.Detail.shareButton) + } + } + } + + private var detailContent: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - // Header - VStack(alignment: .leading, spacing: 8) { - Text(viewModel.itemTitle) - .font(.largeTitle) - .fontWeight(.bold) - - HStack { - Text(viewModel.category) - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue.opacity(0.1)) - .foregroundColor(.blue) - .cornerRadius(8) - } + // Title + Text(viewModel.itemTitle) + .font(.largeTitle) + .fontWeight(.bold) + + // Category badge + HStack { + Text(viewModel.category) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(8) } Divider() diff --git a/UI/Sources/UI/Detail/DetailViewController.swift b/UI/Sources/UI/Detail/DetailViewController.swift deleted file mode 100644 index 3382059b..00000000 --- a/UI/Sources/UI/Detail/DetailViewController.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// DetailViewController.swift -// UI -// -// View controller for Detail screen -// - -import Combine -import SwiftUI -import UIKit - -import FunViewModel - -public final class DetailViewController: UIViewController { - - private let viewModel: DetailViewModel - private var cancellables = Set() - private var favoriteButton: UIBarButtonItem? - - public init(viewModel: DetailViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - hidesBottomBarWhenPushed = true - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // Detect interactive pop gesture (swipe back) or back button tap. - // When parent becomes nil, this VC was removed from the navigation stack. - override public func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) - if parent == nil { - viewModel.handleBackNavigation() - } - } - - override public func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - observeFavoriteState() - embedSwiftUIView(DetailView(viewModel: viewModel)) - } - - private func setupNavigationBar() { - let shareButton = UIBarButtonItem( - image: UIImage(systemName: "square.and.arrow.up"), - style: .plain, - target: self, - action: #selector(shareTapped) - ) - shareButton.accessibilityIdentifier = AccessibilityID.Detail.shareButton - - favoriteButton = UIBarButtonItem( - image: UIImage(systemName: viewModel.isFavorited ? "star.fill" : "star"), - style: .plain, - target: self, - action: #selector(favoriteTapped) - ) - favoriteButton?.tintColor = viewModel.isFavorited ? .systemYellow : .systemBlue - favoriteButton?.accessibilityIdentifier = AccessibilityID.Detail.favoriteButton - - navigationItem.rightBarButtonItems = [shareButton, favoriteButton].compactMap { $0 } - } - - private func observeFavoriteState() { - viewModel.$isFavorited - .sink { [weak self] isFavorited in - self?.updateFavoriteButton(isFavorited: isFavorited) - } - .store(in: &cancellables) - } - - private func updateFavoriteButton(isFavorited: Bool) { - favoriteButton?.image = UIImage(systemName: isFavorited ? "star.fill" : "star") - favoriteButton?.tintColor = isFavorited ? .systemYellow : .systemBlue - } - - @objc private func shareTapped() { - viewModel.didTapShare() - } - - @objc private func favoriteTapped() { - viewModel.didTapToggleFavorite() - } -} diff --git a/UI/Sources/UI/Extensions/UIViewController+SwiftUI.swift b/UI/Sources/UI/Extensions/UIViewController+SwiftUI.swift deleted file mode 100644 index 38a48188..00000000 --- a/UI/Sources/UI/Extensions/UIViewController+SwiftUI.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// UIViewController+SwiftUI.swift -// UI -// -// Extension to embed SwiftUI views in UIViewControllers -// - -import SwiftUI -import UIKit - -public extension UIViewController { - /// Embeds a SwiftUI view as a child view controller, filling the entire view - /// - Parameter content: The SwiftUI view to embed - func embedSwiftUIView(_ content: Content) { - let hostingController = UIHostingController(rootView: content) - addChild(hostingController) - view.addSubview(hostingController.view) - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - hostingController.didMove(toParent: self) - } -} diff --git a/UI/Sources/UI/Home/HomeView.swift b/UI/Sources/UI/Home/HomeView.swift index 4118cef9..cc62ec6c 100644 --- a/UI/Sources/UI/Home/HomeView.swift +++ b/UI/Sources/UI/Home/HomeView.swift @@ -19,36 +19,49 @@ public struct HomeView: View { } public var body: some View { - Group { - if viewModel.isLoading { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(1.5) - Text(L10n.Common.loading) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.hasError { - ErrorStateView(onRetry: viewModel.retry) - } else if viewModel.isCarouselEnabled && !viewModel.featuredItems.isEmpty { - ScrollView { - CarouselView( - featuredItems: viewModel.featuredItems, - currentIndex: $viewModel.currentCarouselIndex, - isFavorited: viewModel.isFavorited, - onItemTap: viewModel.didTapFeaturedItem, - onFavoriteTap: { viewModel.toggleFavorite(for: $0) } - ) - .padding(.vertical) - } - .refreshable { - await viewModel.refresh() + content + .navigationTitle(L10n.Tabs.home) + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { viewModel.didTapProfile() }) { + Image(systemName: "person.circle") + } + .accessibilityIdentifier(AccessibilityID.Home.profileButton) + .accessibilityLabel(L10n.Profile.title) } - } else { - // Empty state when carousel is disabled - CarouselDisabledView() } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.5) + Text(L10n.Common.loading) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.hasError { + ErrorStateView(onRetry: viewModel.retry) + } else if viewModel.isCarouselEnabled && !viewModel.featuredItems.isEmpty { + ScrollView { + CarouselView( + featuredItems: viewModel.featuredItems, + currentIndex: $viewModel.currentCarouselIndex, + isFavorited: viewModel.isFavorited, + onItemTap: viewModel.didTapFeaturedItem, + onFavoriteTap: { viewModel.toggleFavorite(for: $0) } + ) + .padding(.vertical) + } + .refreshable { + await viewModel.refresh() + } + } else { + CarouselDisabledView() } } } diff --git a/UI/Sources/UI/Home/HomeViewController.swift b/UI/Sources/UI/Home/HomeViewController.swift deleted file mode 100644 index 5bccedea..00000000 --- a/UI/Sources/UI/Home/HomeViewController.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// HomeViewController.swift -// UI -// -// View controller for Home screen -// - -import SwiftUI -import UIKit - -import FunCore -import FunViewModel - -public final class HomeViewController: UIViewController { - - private let viewModel: HomeViewModel - - public init(viewModel: HomeViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidLoad() { - super.viewDidLoad() - title = L10n.Tabs.home - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "person.circle"), - style: .plain, - target: self, - action: #selector(profileTapped) - ) - navigationItem.rightBarButtonItem?.accessibilityIdentifier = AccessibilityID.Home.profileButton - navigationItem.rightBarButtonItem?.accessibilityLabel = L10n.Profile.title - embedSwiftUIView(HomeView(viewModel: viewModel)) - } - - @objc private func profileTapped() { - viewModel.didTapProfile() - } -} diff --git a/UI/Sources/UI/HomeTabBarController.swift b/UI/Sources/UI/HomeTabBarController.swift deleted file mode 100644 index d3ddeeb0..00000000 --- a/UI/Sources/UI/HomeTabBarController.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// HomeTabBarController.swift -// UI -// -// Tab bar controller that displays the main tabs of the app -// Coordinators are injected from outside to avoid circular dependencies -// - -import Combine -import SwiftUI -import UIKit - -import FunCore -import FunModel -import FunViewModel - -public class HomeTabBarController: UITabBarController { - - // MARK: - ViewModel - - private let viewModel: HomeTabBarViewModel - - // MARK: - Services - - @Service(.toast) private var toastService: ToastServiceProtocol - - // MARK: - Combine - - private var cancellables = Set() - - // MARK: - Toast UI - - private var toastHostingController: UIHostingController? - private var activeToastId = UUID() - - // MARK: - Tasks - - private var tabObservationTask: Task? - - // MARK: - Initialization - - public init(viewModel: HomeTabBarViewModel, tabNavigationControllers: [UINavigationController]) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - delegate = self - viewControllers = tabNavigationControllers - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - tabObservationTask?.cancel() - } - - // MARK: - Lifecycle - - override public func viewDidLoad() { - super.viewDidLoad() - - // Sync initial tab selection - viewModel.selectedTabIndex = selectedIndex - - // Observe view model for programmatic tab changes using AsyncSequence. - // Combine alternative: viewModel.$selectedTabIndex.sink { ... }.store(in: &cancellables) - // AsyncSequence is used here to demonstrate both observation patterns in the codebase. - // Note: guard let self must be inside the loop body, not outside, - // to avoid a retain cycle across suspension points. - tabObservationTask = Task { @MainActor [weak self] in - guard let viewModel = self?.viewModel else { return } - for await index in viewModel.$selectedTabIndex.values { - guard let self else { break } - guard self.selectedIndex != index else { continue } - self.selectedIndex = index - } - } - - observeToastEvents() - } - - // MARK: - Toast Observation - - private func observeToastEvents() { - toastService.toastPublisher - .sink { [weak self] event in - self?.showToast(message: event.message, type: event.type) - } - .store(in: &cancellables) - } - - private func showToast(message: String, type: ToastType) { - // Remove existing toast if any - dismissToast() - - let toastId = UUID() - activeToastId = toastId - - let toastView = ToastView(message: message, type: type) { [weak self] in - // Only dismiss if this toast is still the active one - guard self?.activeToastId == toastId else { return } - self?.dismissToast() - } - - let hostingController = UIHostingController(rootView: toastView) - hostingController.view.backgroundColor = .clear - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - - // Proper child view controller management - addChild(hostingController) - view.addSubview(hostingController.view) - hostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.heightAnchor.constraint(equalToConstant: 100) - ]) - - toastHostingController = hostingController - } - - private func dismissToast() { - guard let hostingController = toastHostingController else { return } - hostingController.willMove(toParent: nil) - hostingController.view.removeFromSuperview() - hostingController.removeFromParent() - toastHostingController = nil - } - -} - -// MARK: - UITabBarControllerDelegate - -extension HomeTabBarController: UITabBarControllerDelegate { - public func tabBarController( - _ tabBarController: UITabBarController, - didSelect viewController: UIViewController - ) { - viewModel.tabDidChange(to: selectedIndex) - } -} diff --git a/UI/Sources/UI/Items/ItemsView.swift b/UI/Sources/UI/Items/ItemsView.swift index 0a8a1d29..86be00bd 100644 --- a/UI/Sources/UI/Items/ItemsView.swift +++ b/UI/Sources/UI/Items/ItemsView.swift @@ -20,6 +20,8 @@ public struct ItemsView: View { public var body: some View { ItemsMainContent(viewModel: viewModel) + .navigationTitle(L10n.Tabs.items) + .navigationBarTitleDisplayMode(.large) } } diff --git a/UI/Sources/UI/Items/ItemsViewController.swift b/UI/Sources/UI/Items/ItemsViewController.swift deleted file mode 100644 index 65448417..00000000 --- a/UI/Sources/UI/Items/ItemsViewController.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// ItemsViewController.swift -// UI -// -// View controller for Items screen -// - -import SwiftUI -import UIKit - -import FunCore -import FunViewModel - -public final class ItemsViewController: UIViewController { - - private let viewModel: ItemsViewModel - - public init(viewModel: ItemsViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidLoad() { - super.viewDidLoad() - title = L10n.Tabs.items - embedSwiftUIView(ItemsView(viewModel: viewModel)) - } -} diff --git a/UI/Sources/UI/Login/LoginViewController.swift b/UI/Sources/UI/Login/LoginViewController.swift deleted file mode 100644 index 2d1ccec6..00000000 --- a/UI/Sources/UI/Login/LoginViewController.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LoginViewController.swift -// UI -// -// View controller for Login screen -// - -import SwiftUI -import UIKit - -import FunCore -import FunViewModel - -public final class LoginViewController: UIViewController { - - private let viewModel: LoginViewModel - - public init(viewModel: LoginViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidLoad() { - super.viewDidLoad() - embedSwiftUIView(LoginView(viewModel: viewModel)) - } -} diff --git a/UI/Sources/UI/Profile/ProfileView.swift b/UI/Sources/UI/Profile/ProfileView.swift index 7cc96d0e..cfac569e 100644 --- a/UI/Sources/UI/Profile/ProfileView.swift +++ b/UI/Sources/UI/Profile/ProfileView.swift @@ -22,6 +22,22 @@ public struct ProfileView: View { } public var body: some View { + profileContent + .navigationTitle(L10n.Profile.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { viewModel.didTapDismiss() }) { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + } + .accessibilityLabel("Done") + .accessibilityIdentifier(AccessibilityID.Profile.dismissButton) + } + } + } + + private var profileContent: some View { ScrollView { VStack(spacing: 24) { Circle() diff --git a/UI/Sources/UI/Profile/ProfileViewController.swift b/UI/Sources/UI/Profile/ProfileViewController.swift deleted file mode 100644 index 7882c7ea..00000000 --- a/UI/Sources/UI/Profile/ProfileViewController.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ProfileViewController.swift -// UI -// -// View controller for Profile screen (modal) -// - -import SwiftUI -import UIKit - -import FunCore -import FunViewModel - -public final class ProfileViewController: UIViewController { - - private let viewModel: ProfileViewModel - - public init(viewModel: ProfileViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidLoad() { - super.viewDidLoad() - title = L10n.Profile.title - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(dismissTapped) - ) - navigationItem.rightBarButtonItem?.accessibilityIdentifier = AccessibilityID.Profile.dismissButton - embedSwiftUIView(ProfileView(viewModel: viewModel)) - - // Detect interactive dismiss (swipe-down gesture on the modal sheet) - navigationController?.presentationController?.delegate = self - } - - @objc private func dismissTapped() { - viewModel.didTapDismiss() - } -} - -// MARK: - UIAdaptivePresentationControllerDelegate - -extension ProfileViewController: UIAdaptivePresentationControllerDelegate { - - public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - viewModel.didTapDismiss() - } -} diff --git a/UI/Sources/UI/Settings/SettingsView.swift b/UI/Sources/UI/Settings/SettingsView.swift index 58d19f48..e3bc4c93 100644 --- a/UI/Sources/UI/Settings/SettingsView.swift +++ b/UI/Sources/UI/Settings/SettingsView.swift @@ -19,6 +19,12 @@ public struct SettingsView: View { } public var body: some View { + settingsContent + .navigationTitle(L10n.Tabs.settings) + .navigationBarTitleDisplayMode(.large) + } + + private var settingsContent: some View { Form { Section(header: Text(L10n.Settings.appearance)) { Picker(L10n.Settings.appearance, selection: $viewModel.appearanceMode) { diff --git a/UI/Sources/UI/Settings/SettingsViewController.swift b/UI/Sources/UI/Settings/SettingsViewController.swift deleted file mode 100644 index 3d1bd5d0..00000000 --- a/UI/Sources/UI/Settings/SettingsViewController.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SettingsViewController.swift -// UI -// -// View controller for Settings screen -// - -import SwiftUI -import UIKit - -import FunCore -import FunViewModel - -public final class SettingsViewController: UIViewController { - - private let viewModel: SettingsViewModel - - public init(viewModel: SettingsViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidLoad() { - super.viewDidLoad() - title = L10n.Tabs.settings - embedSwiftUIView(SettingsView(viewModel: viewModel)) - } -} diff --git a/ViewModel/Package.swift b/ViewModel/Package.swift index b4b4f2d4..bae15600 100644 --- a/ViewModel/Package.swift +++ b/ViewModel/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "ViewModel", platforms: [ - .iOS(.v15), - .macCatalyst(.v15), + .iOS(.v16), + .macCatalyst(.v16), ], products: [ .library(name: "FunViewModel", targets: ["FunViewModel"]), diff --git a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift index f26e4afd..b417032a 100644 --- a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift +++ b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift @@ -14,11 +14,6 @@ import FunModel @MainActor public class DetailViewModel: ObservableObject { - // MARK: - Navigation Closures - - public var onPop: (() -> Void)? - public var onShare: ((String) -> Void)? - // MARK: - Services @Service(.logger) private var logger: LoggerService @@ -40,6 +35,11 @@ public class DetailViewModel: ObservableObject { featureToggleService.aiSummary && aiService.isAvailable } + /// Text to share via share sheet + public var shareText: String { + L10n.Detail.shareText(itemTitle) + } + // MARK: - Private Properties private var cancellables = Set() @@ -69,16 +69,6 @@ public class DetailViewModel: ObservableObject { // MARK: - Actions - /// Called when the view controller is removed from the navigation stack by the system (back button) - public func handleBackNavigation() { - onPop?() - } - - public func didTapShare() { - let shareText = L10n.Detail.shareText(itemTitle) - onShare?(shareText) - } - public func didTapToggleFavorite() { favoritesService.toggleFavorite(itemId) logger.log("Favorite toggled for \(itemTitle)") diff --git a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift index 6c21f826..30a727ae 100644 --- a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift +++ b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift @@ -11,36 +11,6 @@ import Foundation import FunCore import FunModel -// MARK: - Swift Concurrency Alternative -// -// iOS 17+: Replace ObservableObject + @Published with @Observable macro. -// @Observable tracks per-property (not per-object), so SwiftUI only re-renders -// views that read the specific property that changed. -// -// @MainActor -// @Observable public class HomeViewModel { -// var featuredItems: [[FeaturedItem]] = [] // no @Published needed -// var isLoading: Bool = false -// -// @ObservationIgnored @Service(.network) private var networkService: NetworkService -// @ObservationIgnored private var loadTask: Task? -// // @ObservationIgnored excludes non-UI state from observation tracking -// } -// -// View side: @ObservedObject → @Bindable (two-way) or plain var (read-only) -// @StateObject → @State -// -// Subscription side: .sink { }.store(in: &cancellables) → Task { for await ... } -// let stream = favoritesService.favoritesChanges -// favoritesObservation = Task { [weak self] in -// for await newFavorites in stream { -// guard let self else { break } -// self.favoriteIds = newFavorites -// } -// } -// -// See feature/observation for the full implementation. - @MainActor public class HomeViewModel: ObservableObject { @@ -75,7 +45,13 @@ public class HomeViewModel: ObservableObject { // MARK: - Initialization - public init() { + public init( + onShowDetail: ((FeaturedItem) -> Void)? = nil, + onShowProfile: (() -> Void)? = nil + ) { + self.onShowDetail = onShowDetail + self.onShowProfile = onShowProfile + observeFeatureToggleChanges() observeFavoritesChanges() @@ -137,9 +113,8 @@ public class HomeViewModel: ObservableObject { do { featuredItems = try await networkService.fetchFeaturedItems() } catch is CancellationError { - // SwiftUI's .refreshable cancels the task when user releases drag early — - // swallow cancellation to keep refresh smooth - featuredItems = [] + // Keep existing items when task is cancelled (e.g. by .refreshable) + logger.log("Featured items load cancelled, keeping existing data") } catch { hasError = true featuredItems = [] diff --git a/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift b/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift deleted file mode 100644 index 416cb599..00000000 --- a/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// HomeTabBarViewModel.swift -// ViewModel -// -// ViewModel for tab bar tab switching -// - -import Combine -import Foundation - -import FunCore -import FunModel - -@MainActor -public class HomeTabBarViewModel: ObservableObject { - - // MARK: - Services - - @Service(.logger) private var logger: LoggerService - - // MARK: - Published State - - /// Currently selected tab index - @Published public var selectedTabIndex: Int = 0 - - // MARK: - Initialization - - public init() { - logger.log("HomeTabBarViewModel initialized") - } - - // MARK: - Tab Management - - /// Called when tab selection changes - public func tabDidChange(to index: Int) { - guard (0.. Void)? = nil) { + self.onShowDetail = onShowDetail observeFavoritesChanges() setupSearchBinding() @@ -97,30 +98,6 @@ public class ItemsViewModel: ObservableObject { } } .store(in: &cancellables) - - // MARK: - Swift Concurrency Alternative (iOS 15+) - // - // With @Observable (iOS 17+), use didSet + Task.sleep for debounce: - // - // public 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() - // } - // } - // - // Key differences from Combine .debounce(): - // - No .dropFirst() needed — didSet doesn't fire during init - // - No .removeDuplicates() needed — didSet only fires on actual assignment - // - Task.cancel() handles debounce reset automatically - // - // See feature/observation for the full implementation. } private func observeFavoritesChanges() { diff --git a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift index 4cd0e839..06718536 100644 --- a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift +++ b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift @@ -16,7 +16,7 @@ public class LoginViewModel: ObservableObject { // MARK: - Navigation Closures - public var onLogin: (() -> Void)? + public var onLoginSuccess: (() -> Void)? // MARK: - Services @@ -34,7 +34,9 @@ public class LoginViewModel: ObservableObject { // MARK: - Initialization - public init() {} + public init(onLoginSuccess: (() -> Void)? = nil) { + self.onLoginSuccess = onLoginSuccess + } deinit { loginTask?.cancel() @@ -54,7 +56,7 @@ public class LoginViewModel: ObservableObject { defer { self.isLoggingIn = false } do { try await self.networkService.login() - self.onLogin?() + self.onLoginSuccess?() } catch { self.logger.log("Login failed: \(error)", level: .error, category: .general) self.toastService.showToast(message: L10n.Error.networkError, type: .error) diff --git a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift index e1cb5b3a..6e89b4c4 100644 --- a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift +++ b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift @@ -34,7 +34,15 @@ public class ProfileViewModel: ObservableObject { // MARK: - Initialization - public init(profile: UserProfile = .demo) { + public init( + onDismiss: (() -> Void)? = nil, + onLogout: (() -> Void)? = nil, + onGoToItems: (() -> Void)? = nil, + profile: UserProfile = .demo + ) { + self.onDismiss = onDismiss + self.onLogout = onLogout + self.onGoToItems = onGoToItems self.userName = profile.name self.userEmail = profile.email self.userBio = profile.bio diff --git a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift index a3eddc6c..4c62d86e 100644 --- a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift @@ -123,44 +123,14 @@ struct DetailViewModelTests { #expect(viewModel.isFavorited == true) } - // MARK: - Share Tests + // MARK: - Share Text Tests - @Test("didTapShare calls onShare with item title") - func testDidTapShareCallsOnShare() async { + @Test("shareText contains item title") + func testShareTextContainsItemTitle() async { setupServices() let viewModel = DetailViewModel(item: testItem) - var shareCalled = false - var shareText: String? - viewModel.onShare = { text in shareCalled = true; shareText = text } - - viewModel.didTapShare() - - #expect(shareCalled == true) - #expect(shareText?.contains(testItem.title) == true) - } - - // MARK: - Back Navigation Tests - - @Test("handleBackNavigation calls onPop") - func testHandleBackNavigationCallsOnPop() async { - setupServices() - let viewModel = DetailViewModel(item: testItem) - - var popCalled = false - viewModel.onPop = { popCalled = true } - - viewModel.handleBackNavigation() - - #expect(popCalled == true) - } - - @Test("handleBackNavigation with nil closure does not crash") - func testHandleBackNavigationWithNilClosure() async { - setupServices() - let viewModel = DetailViewModel(item: testItem) - - viewModel.handleBackNavigation() // Should not crash + #expect(viewModel.shareText.contains(testItem.title)) } // MARK: - Different Items Tests diff --git a/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift b/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift deleted file mode 100644 index ee14cdef..00000000 --- a/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// HomeTabBarViewModelTests.swift -// ViewModel -// -// Unit tests for HomeTabBarViewModel -// - -import Testing -import Foundation -@testable import FunViewModel -@testable import FunModel -@testable import FunCore -import FunModelTestSupport - -extension ViewModelTestSuite { - -@Suite("HomeTabBarViewModel Tests") -@MainActor -struct HomeTabBarViewModelTests { - - // MARK: - Setup - - init() { - ServiceLocator.shared.reset() - ServiceLocator.shared.register(MockLoggerService(), for: .logger) - ServiceLocator.shared.register(MockNetworkService(), for: .network) - ServiceLocator.shared.register(MockFavoritesService(), for: .favorites) - ServiceLocator.shared.register(MockFeatureToggleService(), for: .featureToggles) - ServiceLocator.shared.register(MockToastService(), for: .toast) - } - - // MARK: - Initialization Tests - - @Test("Initial selectedTabIndex is 0") - func testInitialTabIndex() async { - let viewModel = HomeTabBarViewModel() - - #expect(viewModel.selectedTabIndex == 0) - } - - // MARK: - Tab Change Tests - - @Test("tabDidChange updates selectedTabIndex") - func testTabDidChangeUpdatesIndex() async { - let viewModel = HomeTabBarViewModel() - - viewModel.tabDidChange(to: 1) - #expect(viewModel.selectedTabIndex == 1) - - viewModel.tabDidChange(to: 2) - #expect(viewModel.selectedTabIndex == 2) - - viewModel.tabDidChange(to: 0) - #expect(viewModel.selectedTabIndex == 0) - } - - @Test("switchToTab updates selectedTabIndex") - func testSwitchToTabUpdatesIndex() async { - let viewModel = HomeTabBarViewModel() - - viewModel.switchToTab(1) - #expect(viewModel.selectedTabIndex == 1) - - viewModel.switchToTab(2) - #expect(viewModel.selectedTabIndex == 2) - } - - @Test("switchToTab and tabDidChange produce same result") - func testSwitchAndDidChangeEquivalent() async { - let vm1 = HomeTabBarViewModel() - let vm2 = HomeTabBarViewModel() - - vm1.switchToTab(2) - vm2.tabDidChange(to: 2) - - #expect(vm1.selectedTabIndex == vm2.selectedTabIndex) - } - - // MARK: - Bounds Checking Tests - - @Test("switchToTab ignores negative index") - func testSwitchToTabIgnoresNegativeIndex() async { - let viewModel = HomeTabBarViewModel() - - viewModel.switchToTab(1) - #expect(viewModel.selectedTabIndex == 1) - - viewModel.switchToTab(-1) - #expect(viewModel.selectedTabIndex == 1) // Unchanged - } - - @Test("switchToTab ignores out-of-bounds index") - func testSwitchToTabIgnoresOutOfBoundsIndex() async { - let viewModel = HomeTabBarViewModel() - - viewModel.switchToTab(1) - #expect(viewModel.selectedTabIndex == 1) - - viewModel.switchToTab(99) - #expect(viewModel.selectedTabIndex == 1) // Unchanged - } - - @Test("switchToTab accepts all valid tab indices") - func testSwitchToTabAcceptsAllValidIndices() async { - let viewModel = HomeTabBarViewModel() - - for tabIndex in TabIndex.allCases { - viewModel.switchToTab(tabIndex.rawValue) - #expect(viewModel.selectedTabIndex == tabIndex.rawValue) - } - } - -} -} diff --git a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift index 022be03f..ef6d7c93 100644 --- a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift @@ -10,7 +10,7 @@ import Foundation @testable import FunViewModel @testable import FunModel @testable import FunCore -import FunModelTestSupport +@testable import FunModelTestSupport // MARK: - Test Scenarios @@ -121,14 +121,13 @@ struct HomeViewModelTests { // MARK: - Coordinator Tests - @Test("didTapFeaturedItem calls onShowDetail") - func testDidTapFeaturedItemCallsOnShowDetail() async throws { + @Test("didTapFeaturedItem calls onShowDetail closure") + func testDidTapFeaturedItemCallsClosure() async throws { setupServices() - let viewModel = HomeViewModel() - - var showDetailCalled = false var showDetailItem: FeaturedItem? - viewModel.onShowDetail = { item in showDetailCalled = true; showDetailItem = item } + let viewModel = HomeViewModel( + onShowDetail: { item in showDetailItem = item } + ) await viewModel.loadFeaturedItems() @@ -137,17 +136,16 @@ struct HomeViewModelTests { viewModel.didTapFeaturedItem(item) - #expect(showDetailCalled == true) #expect(showDetailItem?.id == item.id) } - @Test("didTapProfile calls onShowProfile") - func testDidTapProfileCallsOnShowProfile() async { + @Test("didTapProfile calls onShowProfile closure") + func testDidTapProfileCallsClosure() async { setupServices() - let viewModel = HomeViewModel() - var showProfileCalled = false - viewModel.onShowProfile = { showProfileCalled = true } + let viewModel = HomeViewModel( + onShowProfile: { showProfileCalled = true } + ) viewModel.didTapProfile() diff --git a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift index 11c529a1..7b771b64 100644 --- a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift @@ -211,22 +211,19 @@ struct ItemsViewModelTests { // MARK: - Coordinator Tests - @Test("didSelectItem calls onShowDetail") - func testDidSelectItemCallsOnShowDetail() async throws { + @Test("didSelectItem calls onShowDetail closure") + func testDidSelectItemCallsClosure() async throws { setupServices() - let viewModel = ItemsViewModel() - - var showDetailCalled = false var showDetailItem: FeaturedItem? - viewModel.onShowDetail = { item in showDetailCalled = true; showDetailItem = item } - + let viewModel = ItemsViewModel( + onShowDetail: { item in showDetailItem = item } + ) await viewModel.loadItems() let item = try #require(viewModel.items.first) viewModel.didSelectItem(item) - #expect(showDetailCalled == true) #expect(showDetailItem?.id == item.id) } @@ -289,7 +286,7 @@ struct ItemsViewModelTests { viewModel.didSelectCategory(viewModel.selectedCategory) // Wait for the search task to complete - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(for: .milliseconds(100)) #expect(mockNetwork.searchItemsCallCount == 1) #expect(mockNetwork.lastSearchQuery == "swift") @@ -308,7 +305,7 @@ struct ItemsViewModelTests { viewModel.searchText = "swift" viewModel.didSelectCategory(viewModel.selectedCategory) - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(for: .milliseconds(100)) #expect(viewModel.hasError == true) #expect(viewModel.items.isEmpty) @@ -334,7 +331,7 @@ struct ItemsViewModelTests { // Perform a search viewModel.searchText = "swift" viewModel.didSelectCategory(viewModel.selectedCategory) - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(for: .milliseconds(100)) #expect(viewModel.items.count == 1) // Clear search diff --git a/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift b/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift index 5c9f9c5d..913ffd1d 100644 --- a/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift @@ -49,28 +49,24 @@ struct LoginViewModelTests { #expect(viewModel.isLoggingIn == true) } - @Test("Login calls onLogin after network request") - func testLoginCallsOnLogin() async { - let viewModel = LoginViewModel() - - var loginCalled = false - viewModel.onLogin = { loginCalled = true } + @Test("Login calls onLoginSuccess after network request") + func testLoginCallsOnLoginSuccess() async { + var loginSuccessCalled = false + let viewModel = LoginViewModel(onLoginSuccess: { loginSuccessCalled = true }) viewModel.login() // Mock network service returns instantly, so yield to let the Task complete await Task.yield() - #expect(loginCalled == true) + #expect(loginSuccessCalled == true) #expect(viewModel.isLoggingIn == false) } @Test("Login prevents multiple simultaneous logins") func testLoginPreventsMultipleLogins() async { - let viewModel = LoginViewModel() - - var loginCallCount = 0 - viewModel.onLogin = { loginCallCount += 1 } + var loginSuccessCount = 0 + let viewModel = LoginViewModel(onLoginSuccess: { loginSuccessCount += 1 }) viewModel.login() #expect(viewModel.isLoggingIn == true) @@ -82,11 +78,11 @@ struct LoginViewModelTests { // Yield to let the Task complete await Task.yield() - #expect(loginCallCount == 1) + #expect(loginSuccessCount == 1) } - @Test("Login with nil coordinator completes without crash") - func testLoginWithNilCoordinatorDoesNotCrash() async { + @Test("Login with no onLoginSuccess closure completes without crash") + func testLoginWithNoClosureDoesNotCrash() async { let viewModel = LoginViewModel() viewModel.login() @@ -97,13 +93,13 @@ struct LoginViewModelTests { #expect(viewModel.isLoggingIn == false) } - @Test("Login failure does not call onLogin and shows error toast") - func testLoginFailureDoesNotCallOnLogin() async { + @Test("Login failure does not call onLoginSuccess and shows error toast") + func testLoginFailureDoesNotCallOnLoginSuccess() async { ServiceLocator.shared.register(MockNetworkService(shouldThrowError: true), for: .network) let viewModel = LoginViewModel() var loginCalled = false - viewModel.onLogin = { loginCalled = true } + viewModel.onLoginSuccess = { loginCalled = true } viewModel.login() await Task.yield() diff --git a/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift b/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift index cdac4462..ace0d6df 100644 --- a/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift @@ -10,7 +10,7 @@ import Foundation @testable import FunViewModel @testable import FunModel @testable import FunCore -import FunModelTestSupport +@testable import FunModelTestSupport extension ViewModelTestSuite { @@ -55,12 +55,10 @@ struct ProfileViewModelTests { // MARK: - Dismiss Tests - @Test("Dismiss calls onDismiss") - func testDismissCallsOnDismiss() async { - let viewModel = ProfileViewModel() - + @Test("Dismiss calls onDismiss closure") + func testDismissCallsClosure() async { var dismissCalled = false - viewModel.onDismiss = { dismissCalled = true } + let viewModel = ProfileViewModel(onDismiss: { dismissCalled = true }) viewModel.didTapDismiss() @@ -69,12 +67,10 @@ struct ProfileViewModelTests { // MARK: - Logout Tests - @Test("Logout calls onLogout") - func testLogoutCallsOnLogout() async { - let viewModel = ProfileViewModel() - + @Test("Logout calls onLogout closure") + func testLogoutCallsClosure() async { var logoutCalled = false - viewModel.onLogout = { logoutCalled = true } + let viewModel = ProfileViewModel(onLogout: { logoutCalled = true }) viewModel.logout() @@ -83,12 +79,10 @@ struct ProfileViewModelTests { // MARK: - Go to Items Tests - @Test("didTapGoToItems calls onGoToItems") - func testDidTapGoToItemsCallsOnGoToItems() async { - let viewModel = ProfileViewModel() - + @Test("didTapGoToItems calls onGoToItems closure") + func testDidTapGoToItemsCallsClosure() async { var goToItemsCalled = false - viewModel.onGoToItems = { goToItemsCalled = true } + let viewModel = ProfileViewModel(onGoToItems: { goToItemsCalled = true }) viewModel.didTapGoToItems() diff --git a/ai-rules/general.md b/ai-rules/general.md index 16ba096a..75f92f30 100644 --- a/ai-rules/general.md +++ b/ai-rules/general.md @@ -1,13 +1,13 @@ -# Fun-iOS Architecture Reference +# Fun-iOS Architecture Reference (feature/navigation-stack) ## SPM Package Structure 6 local packages + 1 Xcode app target, unified by `Fun.xcworkspace`: ``` -FunApp/FunApp.xcodeproj → iOS app target (AppDelegate, SceneDelegate, AppSessionFactory) -Coordinator/ → FunCoordinator (navigation logic) -UI/ → FunUI (SwiftUI views + UIViewControllers) +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) Model/ → FunModel + FunModelTestSupport (domain types, protocols, mocks) Services/ → FunServices (concrete service implementations) @@ -35,69 +35,78 @@ 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 +## MVVM-C Architecture (NavigationStack Variant) -### Coordinators (UIKit, in FunCoordinator) -- `AppCoordinator` — Root coordinator. Manages login ↔ main flow transitions, deep links, session lifecycle. -- `BaseCoordinator` — Base class with `safePush()`, `safePop()`, `safePresent()`, `safeDismiss()`, `share(text:)`. Handles transition-during-animation queuing. -- `LoginCoordinator` — Login flow -- `HomeCoordinator` — Home tab (detail + profile push/modal) -- `ItemsCoordinator` — Items tab -- `SettingsCoordinator` — Settings tab +### Single AppCoordinator +Unlike the main branch (6 UIKit coordinators), this branch uses a **single `AppCoordinator: ObservableObject`** that manages all navigation state: -### Navigation Rules -- Navigation decisions ONLY happen in Coordinators -- ViewModels communicate navigation intent via optional closures: `onShowDetail`, `onShowProfile`, `onLoginSuccess`, `onLogout`, `onPop`, `onShare`, `onDismiss`, `onGoToItems` -- Coordinators wire these closures when creating ViewModels -- Views call ViewModel methods, which invoke closures — Views never know about Coordinators - -### View Embedding Pattern (this branch) -SwiftUI views are embedded in UIKit via UIViewControllers: ```swift -// In Coordinator: -let viewModel = HomeViewModel() -viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } -let viewController = HomeViewController(viewModel: viewModel) -safePush(viewController) - -// HomeViewController wraps HomeView(viewModel:) in UIHostingController +@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 +} ``` -## ServiceLocator & @Service - -### Registration -Services are registered in session `activate()` methods: -```swift -// LoginSession.activate() -locator.register(DefaultLoggerService(), for: .logger) -locator.register(NetworkServiceImpl(), for: .network) -locator.register(DefaultFeatureToggleService(), for: .featureToggles) - -// AuthenticatedSession.activate() — adds favorites, toast, ai +### Navigation Architecture +``` +FunApp (@main) + └─ AppRootView + ├─ LoginTabContent (when currentFlow == .login) + └─ MainTabView (when currentFlow == .main) + ├─ homeTab: NavigationStack(path: $coordinator.homePath) + │ └─ HomeTabContent → .navigationDestination(for: FeaturedItem.self) + ├─ itemsTab: NavigationStack(path: $coordinator.itemsPath) + │ └─ ItemsTabContent + └─ settingsTab: NavigationStack(path: $coordinator.settingsPath) + └─ SettingsTabContent + + .sheet(isPresented: $coordinator.isProfilePresented) + └─ ProfileTabContent ``` -### Resolution +### View Wiring Pattern (this branch) +Tab content wrappers create ViewModels and wire closures: ```swift -@Service(.logger) private var logger: LoggerService -@Service(.favorites) private var favoritesService: FavoritesServiceProtocol +struct HomeTabContent: View { + let coordinator: AppCoordinator + @StateObject private var viewModel = HomeViewModel() + + var body: some View { + HomeView(viewModel: viewModel) + .task { + viewModel.onShowDetail = { [weak coordinator] item in + coordinator?.showDetail(item, in: .home) + } + viewModel.onShowProfile = { [weak coordinator] in + coordinator?.showProfile() + } + } + } +} ``` -Resolution crashes with `fatalError` if service isn't registered. This is intentional — a missing service means a programming error. + +## ServiceLocator & @Service + +Same as main branch — `ServiceLocator.shared` with `@Service` property wrapper. ### Service Keys `ServiceKey` enum in Core: `.network`, `.logger`, `.favorites`, `.toast`, `.featureToggles`, `.ai` ## Session-Scoped DI -Two session types control which services are available: - | Session | Services Registered | When Active | |---|---|---| -| `LoginSession` | logger, network, featureToggles | Login screen | +| `LoginSession` | logger, network, featureToggles, toast | Login screen | | `AuthenticatedSession` | logger, network, favorites, toast, featureToggles, ai | Main app | -- `activate()` registers services on `ServiceLocator.shared` -- `teardown()` calls `favoritesService.resetFavorites()` (AuthenticatedSession) then `ServiceLocator.shared.reset()` - `AppSessionFactory` creates the right session for each `AppFlow` case +- App entry (`FunApp.swift`) creates coordinator with `@StateObject` and calls `.start()` in `.task` ## Protocol Placement @@ -118,13 +127,18 @@ URL scheme: `funapp://` Parsed by `DeepLink(url:)` in Model. Handled by `AppCoordinator.handleDeepLink(_:)`. If received during login, stored as `pendingDeepLink` and executed after `transitionToMainFlow()`. +App entry uses `.onOpenURL { url in coordinator.handleDeepLink(DeepLink(url: url)) }`. ## Testing - **Framework**: Swift Testing (`import Testing`, `@Test`, `#expect`, `@Suite`) - **Test command**: `xcodebuild test -workspace Fun.xcworkspace -scheme FunApp -skip-testing UITests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO` -- **Mock location**: `Model/Sources/ModelTestSupport/Mocks/` — MockLoggerService, MockNetworkService, MockFavoritesService, MockToastService, MockFeatureToggleService, MockAIService -- **Test support import**: `import FunModelTestSupport` -- **Setup pattern**: Use `init()` on test structs, not a `setupServices()` function -- **Consolidation**: Merge thin init tests into a single test when testing the same concern +- **Mock location**: `Model/Sources/ModelTestSupport/Mocks/` +- **Test support import**: `@testable import FunModelTestSupport` - **Snapshots**: swift-snapshot-testing in UI package tests + +## Key Difference from Main Branch +- 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 diff --git a/ai-rules/swift-style.md b/ai-rules/swift-style.md index 62941856..c9bbfbe0 100644 --- a/ai-rules/swift-style.md +++ b/ai-rules/swift-style.md @@ -1,22 +1,22 @@ -# Swift Style Guide — Fun-iOS (main branch) +# Swift Style Guide — Fun-iOS (feature/navigation-stack) ## Swift 6 Strict Concurrency ### Actor Isolation -- `@MainActor` on all ViewModels, Coordinators, ServiceLocator, Session implementations, and UI-related code -- `Sendable` conformance on value types crossing isolation boundaries (enums, structs) +- `@MainActor` on AppCoordinator, all ViewModels, ServiceLocator, Session implementations +- `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, not before the `for await` -- Zero `[unowned self]` in this codebase — correct decision, never introduce it +- For `AsyncSequence` loops, `guard let self` must go INSIDE the loop body +- Zero `[unowned self]` in this codebase — never introduce it +- **`[weak coordinator]`** in tab content wrappers when wiring closures ### Sendable Types - All enums in Model are `Sendable` (`AppFlow`, `TabIndex`, `DeepLink`, `AppearanceMode`, etc.) - Protocols that cross isolation boundaries include `Sendable` conformance -- Use `@unchecked Sendable` only as a last resort with documented justification ## MVVM-C Patterns @@ -26,25 +26,26 @@ - Optional closures for navigation: `var onShowDetail: ((FeaturedItem) -> Void)?` - Services accessed via `@Service` property wrapper - Private `Set` for Combine subscriptions -- No direct UIKit imports. No coordinator references (except weak closures). +- No UIKit imports. No coordinator references. -### Views (SwiftUI) +### Views (Pure SwiftUI) - Observe ViewModel via `@ObservedObject` or `@StateObject` - Never make navigation decisions — call ViewModel methods which invoke closures -- Use `AccessibilityID` enum for accessibility identifiers +- No `import UIKit` anywhere in this branch -### Coordinators (UIKit) -- Subclass `BaseCoordinator` for safe navigation (`safePush`, `safePop`, etc.) -- Create ViewModels, wire closures, wrap in ViewControllers -- Strong child coordinator references (parent → child is intentionally strong) -- `[weak self]` in all closures that reference the coordinator +### AppCoordinator +- Single `ObservableObject` managing all navigation state +- `@Published` NavigationPath per tab + selectedTab + modal flags +- Created with `@StateObject` 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 to observe dynamic service registration +- `serviceDidRegisterPublisher` on ServiceLocator for dynamic registration ### Schedulers - `RunLoop.main` for `debounce`/`throttle` — cooperates with `Task.sleep` in async tests @@ -54,43 +55,23 @@ ### Subscriptions - Store in `private var cancellables = Set()` - Use `[weak self]` in `.sink` closures -- Cancel explicitly when needed (e.g., `darkModeCancellable?.cancel()`) - -## ServiceLocator & @Service - -```swift -// Registration (in Session.activate()) -ServiceLocator.shared.register(DefaultLoggerService(), for: .logger) - -// Resolution (in ViewModel/Coordinator) -@Service(.logger) private var logger: LoggerService -``` - -- Registration happens in `LoginSession.activate()` and `AuthenticatedSession.activate()` -- Resolution crashes if service isn't registered — this is intentional -- Never call `ServiceLocator.shared.resolve()` directly in Views ## Naming Conventions ### Types +- AppCoordinator (single, not per-tab) +- Tab content wrappers: `HomeTabContent`, `ItemsTabContent`, `SettingsTabContent`, `ProfileTabContent`, `LoginTabContent` - ViewModels: `HomeViewModel`, `ItemsViewModel`, `DetailViewModel` - Views: `HomeView`, `ItemsView`, `DetailView` -- ViewControllers: `HomeViewController`, `ItemsViewController` -- Coordinators: `HomeCoordinator`, `ItemsCoordinator` - Services: protocol `FavoritesServiceProtocol`, impl `DefaultFavoritesService` -- Sessions: `LoginSession`, `AuthenticatedSession` ### Navigation Closures -- `onShowDetail`, `onShowProfile`, `onLoginSuccess`, `onLogout`, `onPop`, `onShare`, `onDismiss`, `onGoToItems` -- Always optional, always set by the Coordinator - -### Service Protocols -- Suffix with `Protocol` when the name would otherwise collide: `FavoritesServiceProtocol`, `FeatureToggleServiceProtocol`, `ToastServiceProtocol`, `AIServiceProtocol` -- No suffix when unambiguous: `LoggerService`, `NetworkService` +- `onShowDetail`, `onShowProfile`, `onLoginSuccess`, `onLogout`, `onDismiss`, `onGoToItems` +- Always optional, wired in tab content wrapper `.task` blocks ## SwiftLint Rules -Zero-tolerance — CI fails on any violation. Key rules: +Zero-tolerance — CI fails on any violation. ### Custom Rules - **`no_print`**: Use `LoggerService` instead of `print()` @@ -98,25 +79,17 @@ Zero-tolerance — CI fails on any violation. Key rules: - **`weak_delegate`**: Delegate properties must be `weak` - **`no_direct_userdefaults`**: Use `FeatureToggleService`, not `UserDefaults.standard` (exempt: Services/) -### Enforced Opt-In Rules -- `force_unwrapping` — no force unwraps -- `implicitly_unwrapped_optional` — no IUOs -- `fatal_error_message` — fatal errors must have a message -- `modifier_order` — consistent modifier ordering -- `yoda_condition` — no `42 == x` - -### Limits -- Line length: warning 120, error 200 (ignores URLs, function declarations, comments) +### Key Limits +- Line length: warning 120, error 200 - File length: warning 500, error 1000 - Function body: warning 60, error 100 - Cyclomatic complexity: warning 15, error 25 ## Error Handling -- `assertionFailure()` for programmer errors that shouldn't crash in production (e.g., missing service) -- `fatalError()` only in ServiceLocator.resolve() — a missing service IS a programming error -- Never silently swallow errors — log them via LoggerService at minimum -- Use `Result` or `throws` for recoverable errors +- `assertionFailure()` for programmer errors that shouldn't crash in production +- `fatalError()` only in ServiceLocator.resolve() +- Never silently swallow errors — log via LoggerService ## Protocol Placement Rules