diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..00870b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +## Project + +FuturedKit — SwiftUI state management tools, resources and views by Futured. +Repo: https://github.com/futuredapp/FuturedKit + +## Build & Test + +```bash +# Build (requires Xcode, not standalone swift build due to UIKit/SwiftUI dependencies) +xcodebuild -scheme FuturedHelpers -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -skipMacroValidation build + +# Test (all tests are in FuturedKitTests, run via the package scheme) +xcodebuild -scheme FuturedKit-Package -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -skipMacroValidation test +``` + +`-skipMacroValidation` is needed because the project depends on `futured-macros` which requires macro trust approval. + +## Package Structure + +Swift 6.1, strict concurrency. Platforms: iOS 17+, macOS 14+, watchOS 10+, tvOS 17+. + +Two library targets: +- **FuturedArchitecture** — core architecture types (Coordinator, NavigationStackFlow, TabViewFlow, ComponentModel, DataCache, AlertModel) + - Depends on: `futured-macros` (FuturedMacros) +- **FuturedHelpers** — non-mandatory extensions and views (TextStyle, Font extensions, SceneDelegate, CoordinatorSceneFlowProvider) + - Depends on: FuturedArchitecture + +Test target: **FuturedKitTests** (tests both targets) + +## Architecture Patterns + +- All architectural types use `@MainActor` isolation by default +- Reactive state uses `@Observable` macro (not Combine — zero Combine usage in source) +- **ComponentModel** — `@MainActor` protocol; conforming types must be `@Observable`, retained via `@State` in their Component (View). Child components receive model as a plain property (not wrapped). User interactions forwarded as simple function calls. Events passed to coordinators via `onEvent` closure. +- **Coordinator** — `@MainActor @Observable` class hierarchy: base `Coordinator` → `TabCoordinator` (adds `selectedTab`) / `NavigationStackCoordinator` (adds `path: [Destination]`). Controls navigation in response to ComponentModel events. +- **NavigationStackFlow / TabViewFlow** — generic SwiftUI containers that bind to coordinator state, handle modals via `.sheet()` / `.fullScreenCover()` +- **Flow Providers** — encapsulate reusable sub-flows shared across multiple coordinators +- **DataCache** — `@Observable @MainActor` class, single source of truth for shared data; all reads/writes are synchronous from MainActor context. ComponentModels use computed properties to derive data (observation tracking is automatic). +- **Container** — app-level pattern (not a concrete type in this library); a class holding shared dependencies (DataCache, Services). Created at app root, passed to each Coordinator. Uses `listeningTask` to co-locate side effects reacting to cache changes. +- **Services** — encapsulate business logic (networking, data processing) using async/await; update DataCache with results +- Observation subscriptions use `withObservationTracking` in Task loops (not AsyncStream) + +### Macros (from futured-macros) + +- **`@EnumIdentable`** — auto-conforms enums to `Identifiable`; used on coordinator `Destination` enums marked `nonisolated` +- **`@ProxyMembers`** — generates dynamic member lookup forwarding to reduce boilerplate + +## Code Style + +- Swift 6.1 strict concurrency +- UIKit-dependent code is gated with `#if canImport(UIKit)` +- Public types have doc comments with `/// Overview` sections +- No third-party formatting tools — follow existing style in the file you're editing diff --git a/Package.resolved b/Package.resolved index c7c88df..b91d9bb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "6354240cbef4dceef9719ed1f9a46112183ddb48f1e4101d988a169c2837f1ab", + "originHash" : "21d858265c65e921299db7dc6008ca77cae2d4c783d3ba8ea1c50f9b36d324c8", "pins" : [ - { - "identity" : "bindingkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mkj-is/BindingKit", - "state" : { - "revision" : "fe58851b967301ec5c391a2c34979b676b854f89", - "version" : "1.0.0" - } - }, { "identity" : "futured-macros", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 4474b7e..9f09893 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,6 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/mkj-is/BindingKit", from: "1.0.0"), .package(url: "https://github.com/futuredapp/futured-macros", from: "0.1.0") ], targets: [ @@ -35,8 +34,7 @@ let package = Package( .target( name: "FuturedHelpers", dependencies: [ - "FuturedArchitecture", - .product(name: "BindingKit", package: "BindingKit") + "FuturedArchitecture" ] ), .testTarget( diff --git a/README.md b/README.md index 04b27b7..8ae377d 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,6 @@ This target contains non-mandatory extension to the Architecture and additional are commonly used. - ``TextStyle`` (See source documentation) -- ``CameraImagePicker`` (See source documentation) -- ``GalleryImagePicker`` (See source documentation) ## Installation diff --git a/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/CameraImagePicker.swift b/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/CameraImagePicker.swift deleted file mode 100644 index 13af242..0000000 --- a/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/CameraImagePicker.swift +++ /dev/null @@ -1,125 +0,0 @@ -#if canImport(UIKit) - -import AVFoundation -import FuturedArchitecture -import SwiftUI - -/// A view that displays a camera for picking photo. -/// -/// ## Overview -/// -/// Camera image picker uses ``WrappedUIImagePicker`` and automatically shows the alert if -/// camera permission status is unauthorized. -/// After selection is the view automatically dismissed. -public struct CameraImagePicker: View { - @Environment(\.dismiss) private var dismiss - @Binding private var selection: UIImage? - @State private var permissionAlertModel: AlertModel? - - private let cameraPermissionAlertConfiguration: CameraPermissionAlertConfiguration - - /// A configuration for unauthorized permission alert. - public struct CameraPermissionAlertConfiguration { - let title: String - let message: String - let dismissButtonTitle: String - let settingsButtonTitle: String - - /// Creates an permission alert configuration. - /// - Parameters: - /// - title: The title of the alert. - /// - message: The message to display in the body of the alert. - /// - dismissButtonTitle: The title of the cancel button. - /// - settingsButtonTitle: The title of the button which action goes to settings. - public init( - title: String, - message: String, - dismissButtonTitle: String, - settingsButtonTitle: String - ) { - self.title = title - self.message = message - self.dismissButtonTitle = dismissButtonTitle - self.settingsButtonTitle = settingsButtonTitle - } - } - - /// Creates a picker that selects an item and configures alert in camera permission status is unauthorized. - /// - Parameters: - /// - selection: The selected photo. - /// - cameraPermissionAlertConfiguration: The alert configuration for unauthorized permission status. - public init( - selection: Binding, - cameraPermissionAlertConfiguration: CameraPermissionAlertConfiguration - ) { - self._selection = selection - self.cameraPermissionAlertConfiguration = cameraPermissionAlertConfiguration - } - - public var body: some View { - WrappedUIImagePicker(didFinishPickingMediaWithInfo: handlePickerResult) { viewController in - viewController.sourceType = .camera - } - .background(Color.black.ignoresSafeArea(edges: .bottom)) - .onAppear(perform: checkCameraAuthorizationStatus) - .defaultAlert(model: $permissionAlertModel) - } - - private var cameraPermissionAlert: AlertModel { - AlertModel( - title: cameraPermissionAlertConfiguration.title, - message: cameraPermissionAlertConfiguration.message, - primaryAction: AlertModel.ButtonAction( - title: cameraPermissionAlertConfiguration.dismissButtonTitle, - buttonRole: .cancel, - action: { - dismiss() - } - ), - secondaryAction: AlertModel.ButtonAction( - title: cameraPermissionAlertConfiguration.settingsButtonTitle, - action: { - goToSettings() - dismiss() - } - ) - ) - } - - private func goToSettings() { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - - private func checkCameraAuthorizationStatus() { - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - break - - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { granted in - if !granted { - Task { @MainActor in - permissionAlertModel = cameraPermissionAlert - } - } - } - - case .denied, .restricted: - permissionAlertModel = cameraPermissionAlert - - @unknown default: - assertionFailure("Unknown state") - } - } - - private func handlePickerResult(with info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { - selection = image - dismiss() - } - } -} - -#endif diff --git a/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/WrappedUIImagePicker.swift b/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/WrappedUIImagePicker.swift deleted file mode 100644 index 29c3326..0000000 --- a/Sources/FuturedHelpers/ImagePicker/CameraImagePicker/WrappedUIImagePicker.swift +++ /dev/null @@ -1,59 +0,0 @@ -#if canImport(UIKit) - -import SwiftUI - -/// Wrapped `UIImagePickerController`. -/// -/// ## Overview -/// -/// It allows to process data obtained by `imagePickerController(_:didFinishPickingMediaWithInfo:)` delegate method. -/// An additional view controller setup can by provided by optional `setup` parameter. -public struct WrappedUIImagePicker: UIViewControllerRepresentable { - private let didFinishPickingMediaWithInfo: ([UIImagePickerController.InfoKey: Any]) -> Void - private let setup: ((UIImagePickerController) -> Void)? - - /// Creates a wrapped `UIImagePickerController` with the handler for data processing and additional - /// view controller setup. - /// - Parameters: - /// - didFinishPickingMediaWithInfo: The handler which process data obtained - /// by `imagePickerController(_:didFinishPickingMediaWithInfo:)` delegate method. - /// - setup: The closure for additional view controller setup. - public init( - didFinishPickingMediaWithInfo: @escaping ([UIImagePickerController.InfoKey: Any]) -> Void, - setup: ((UIImagePickerController) -> Void)? = nil - ) { - self.didFinishPickingMediaWithInfo = didFinishPickingMediaWithInfo - self.setup = setup - } - - public func makeUIViewController(context: Context) -> UIImagePickerController { - let imagePicker = UIImagePickerController() - imagePicker.delegate = context.coordinator - return imagePicker - } - - public func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { - setup?(uiViewController) - } - - public func makeCoordinator() -> Coordinator { - Coordinator(didFinishPicking: didFinishPickingMediaWithInfo) - } - - public final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - var didFinishPicking: ([UIImagePickerController.InfoKey: Any]) -> Void - - init(didFinishPicking: @escaping ([UIImagePickerController.InfoKey: Any]) -> Void) { - self.didFinishPicking = didFinishPicking - } - - public func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - didFinishPicking(info) - } - } -} - -#endif diff --git a/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/GalleryImagePicker.swift b/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/GalleryImagePicker.swift deleted file mode 100644 index 6b8a42e..0000000 --- a/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/GalleryImagePicker.swift +++ /dev/null @@ -1,94 +0,0 @@ -#if canImport(UIKit) - -import BindingKit -import PhotosUI -import SwiftUI - -/// A view that displays a Photos picker for choosing assets from the photo library. -/// -/// ## Overview -/// -/// Gallery image picker uses `WrappedPHPicker`. It supports single and multiple selection. -/// After selection is the view automatically dismissed. -public struct GalleryImagePicker: View { - @Environment(\.dismiss) private var dismiss - @Binding private var selection: [UIImage] - - private let selectionLimit: Int - - /// Creates a picker that selects an images which count is specified by `selection` parameter. - /// - Parameters: - /// - selection: The selected images. - /// - selectionLimit: The maximum number of selections the user can make. - public init(selection: Binding<[UIImage]>, selectionLimit: Int = 0) { - self._selection = selection - self.selectionLimit = selectionLimit - } - - /// Creates a picker that selects an image. - /// - Parameters: - /// - selection: The selected images. - /// - selectionLimit: The maximum number of selections the user can make. - public init(selection: Binding) { - self._selection = selection.map { image in - [image].compactMap { $0 } - } back: { images in - images.first - } - self.selectionLimit = 1 - } - - public var body: some View { - WrappedPHPicker(configuration: configuration, didFinishPicking: handlePickerResult) - .ignoresSafeArea() - } - - private var configuration: PHPickerConfiguration { - var configuration = PHPickerConfiguration() - configuration.filter = .images - configuration.selectionLimit = selectionLimit - return configuration - } - - private func handlePickerResult(_ results: [PHPickerResult]) { - dismiss() - - Task { - var images: [UIImage] = [] - images.reserveCapacity(results.count) - - for result in results { - let provider = result.itemProvider - if let image = try? await provider.loadImage() { - images.append(image) - } - } - selection = images - } - } -} - -extension NSItemProvider { - enum LoadImageError: Error { - case castToImageFailed - case cannotLoadUIImage - } - - func loadImage() async throws -> UIImage { - guard canLoadObject(ofClass: UIImage.self) else { - throw LoadImageError.cannotLoadUIImage - } - - return try await withCheckedThrowingContinuation { continuation in - loadObject(ofClass: UIImage.self) { image, error in - if let image = image as? UIImage { - continuation.resume(returning: image) - } else { - continuation.resume(throwing: error ?? LoadImageError.castToImageFailed) - } - } - } - } -} - -#endif diff --git a/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/WrappedPHPPicker.swift b/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/WrappedPHPPicker.swift deleted file mode 100644 index 4a70155..0000000 --- a/Sources/FuturedHelpers/ImagePicker/GalleryImagePicker/WrappedPHPPicker.swift +++ /dev/null @@ -1,56 +0,0 @@ -#if canImport(UIKit) - -import PhotosUI -import SwiftUI - -/// Wrapped `PHPickerViewController`. -public struct WrappedPHPicker: UIViewControllerRepresentable { - private let configuration: PHPickerConfiguration - private let didFinishPicking: ([PHPickerResult]) -> Void - - /// Creates a wrapped `PHPickerViewController` with the configuration and the handler - /// for data processing. - /// - Parameters: - /// - configuration: The configuration with which to initialize the view controller. - /// - didFinishPickingMediaWithInfo: The handler which process data obtained - /// by `imagePickerController(_:didFinishPickingMediaWithInfo:)` delegate method. - /// - setup: The closure for additional view controller setup. - public init( - configuration: PHPickerConfiguration, - didFinishPicking: @escaping ([PHPickerResult]) -> Void - ) { - self.configuration = configuration - self.didFinishPicking = didFinishPicking - } - - public func makeUIViewController(context: Context) -> PHPickerViewController { - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = context.coordinator - return picker - } - - public func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { - } - - public func makeCoordinator() -> Coordinator { - Coordinator(didFinishPicking: handleSelection) - } - - private func handleSelection(_ results: [PHPickerResult]) { - didFinishPicking(results) - } - - public final class Coordinator: NSObject, PHPickerViewControllerDelegate, UINavigationControllerDelegate { - let didFinishPicking: ([PHPickerResult]) -> Void - - init(didFinishPicking: @escaping ([PHPickerResult]) -> Void) { - self.didFinishPicking = didFinishPicking - } - - public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - didFinishPicking(results) - } - } -} - -#endif