From fb738ac9b44d2c01f29b94fca163499b49a296f5 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:18:42 +0100 Subject: [PATCH 01/17] Update action --- .github/workflows/test.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d326b26..d8939a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,15 +8,10 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Lint run: | swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint - name: Test run: | - xcodebuild -scheme FuntastyKit-Package -destination 'platform=iOS Simulator,name=iPhone 8' -sdk iphonesimulator test + xcodebuild -scheme FuntastyKit-Package -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator test From 6486fb38c2ee7bed0f2c9b37178ba887e89aff4c Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:19:11 +0100 Subject: [PATCH 02/17] Remove cocoapods support --- .swiftlint.yml | 2 - FuntastyKit.podspec | 29 -------------- Gemfile | 5 --- Gemfile.lock | 93 --------------------------------------------- 4 files changed, 129 deletions(-) delete mode 100644 FuntastyKit.podspec delete mode 100644 Gemfile delete mode 100644 Gemfile.lock diff --git a/.swiftlint.yml b/.swiftlint.yml index 5703ab3..5d7b40f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,10 +1,8 @@ disabled_rules: - line_length excluded: - - Pods - vendor opt_in_rules: - - anyobject_protocol - array_init - attributes - closure_body_length diff --git a/FuntastyKit.podspec b/FuntastyKit.podspec deleted file mode 100644 index e67f591..0000000 --- a/FuntastyKit.podspec +++ /dev/null @@ -1,29 +0,0 @@ -Pod::Spec.new do |s| - s.name = "FuntastyKit" - s.version = "2.3.0" - s.summary = "A collection of Swift utilities and protocols used in our projects." - s.description = <<-DESC - MVVM-C architecture, service holder for code injection of services - some regularly used UIKit extensions, protocols for simple initialization from XIB files, - storyboards and for handling keyboard, hairline and keyboard height constraints - DESC - s.homepage = "https://github.com/futuredapp/FuntastyKit" - s.license = { type: "MIT", file: "LICENSE" } - s.author = { "Matěj K. Jirásek" => "matej.jirasek@futured.app" } - s.social_media_url = "https://twitter.com/Futuredapps" - s.platform = :ios, "9.0" - s.swift_version = "5.0" - s.source = { :git => "https://github.com/futuredapp/FuntastyKit.git", :tag => s.version.to_s } - s.frameworks = ["Foundation", "UIKit"] - s.default_subspec = "Core" - - s.subspec "Core" do |ss| - ss.source_files = Dir["Sources/FuntastyKit/*"] - end - - s.subspec "IBInspectable" do |ss| - ss.dependency "FuntastyKit/Core" - ss.source_files = Dir["Sources/FuntastyKitIBInspectable/*"] - end -end - diff --git a/Gemfile b/Gemfile deleted file mode 100644 index abc1a45..0000000 --- a/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "cocoapods", "~> 1.10" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index cdb09b9..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,93 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.2) - activesupport (5.2.4.4) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - claide (1.0.3) - cocoapods (1.10.0) - addressable (~> 2.6) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.10.0) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.6.6) - nap (~> 1.0) - ruby-macho (~> 1.4) - xcodeproj (>= 1.19.0, < 2.0) - cocoapods-core (1.10.0) - activesupport (> 5.0, < 6) - addressable (~> 2.6) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.4.0) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.0) - cocoapods-trunk (1.5.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.1.7) - escape (0.0.4) - ethon (0.12.0) - ffi (>= 1.3.0) - ffi (1.13.1) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.8.5) - concurrent-ruby (~> 1.0) - json (2.3.1) - minitest (5.14.2) - molinillo (0.6.6) - nanaimo (0.3.0) - nap (1.1.0) - netrc (0.11.0) - public_suffix (4.0.6) - ruby-macho (1.4.0) - thread_safe (0.3.6) - typhoeus (1.4.0) - ethon (>= 0.9.0) - tzinfo (1.2.7) - thread_safe (~> 0.1) - xcodeproj (1.19.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - -PLATFORMS - ruby - -DEPENDENCIES - cocoapods (~> 1.10) - -BUNDLED WITH - 2.1.4 From 7467f6dd9a638d13a0e9414521132d416e0004fe Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:19:22 +0100 Subject: [PATCH 03/17] Bump to swift 6.2 --- Package.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/Package.swift b/Package.swift index 81c0f75..aaef49a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,26 +1,21 @@ -// swift-tools-version:5.1 +// swift-tools-version:6.2 import PackageDescription let package = Package( name: "FuntastyKit", - platforms: [.iOS(.v9)], + platforms: [.iOS(.v16)], products: [ .library( name: "FuntastyKit", targets: ["FuntastyKit"]), - .library( - name: "FuntastyKitIBInspectable", - targets: ["FuntastyKitIBInspectable"]) ], targets: [ .target( name: "FuntastyKit", - dependencies: []), - .target( - name: "FuntastyKitIBInspectable", - dependencies: []), + swiftSettings: [.defaultIsolation(MainActor.self)]), .testTarget( name: "FuntastyKitTests", - dependencies: ["FuntastyKit"]) + dependencies: ["FuntastyKit"], + swiftSettings: [.defaultIsolation(MainActor.self)]), ] ) From 8949a8bee25f96a394d0be97f1a3fd0ad3044a2f Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:20:44 +0100 Subject: [PATCH 04/17] Update readme --- README.md | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1ad5eee..cf0c2f2 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,21 @@ # FuntastyKit -![Cocoapods](https://img.shields.io/cocoapods/v/FuntastyKit.svg) -![Cocoapods platforms](https://img.shields.io/cocoapods/p/FuntastyKit.svg) -![License](https://img.shields.io/cocoapods/l/FuntastyKit.svg) +![License](https://img.shields.io/github/license/futuredapp/FuntastyKit.svg) The FuntastyKit for iOS contains: - MVVM-C architecture used at [Futured](https://www.futured.app/en/), the template for this architecture can be found at https://github.com/futuredapp/MVVM-C-Templates. +- Coordinator pattern protocols for UIKit navigation (Push, Modal, Show, TabBarItem). +- Error handling with `ResolvableError` and `AlertCoordinator`. - Some regularly used UIKit extensions. -- Protocols for simple initialization from XIB files, storyboards and for handling keyboard. -- Hairline constraint for one-pixel designs. ## Installation -If you want to use CocoaPods for dependency management, add following line to your `Podfile`: - -```ruby -pod 'FuntastyKit', '~> 2.3' -``` - -If you also want to use IBInspectable extensions also add: - -```ruby -pod 'FuntastyKit', '~> 2.3', subspecs: ['IBInspectable'] -``` - When using Swift package manager add following line to your `Package.swift` file or add the repository using Xcode: ```swift -.package(url: "https://github.com/futuredapp/FuntastyKit.git", from: "2.3.0") +.package(url: "https://github.com/futuredapp/FuntastyKit.git", from: "3.0.0") ``` ## Note on the name @@ -39,10 +25,6 @@ The name of the framework comes from the name of our company before rebranding, ## Contributors -If you have any questions or issues, please contact the current maintainer: - -- Matěj K. Jirásek, matej.jirasek@futured.app - Over the years many inside and outside contributors made improvements to this library, namely: - [Petr Zvoníček](https://github.com/zvonicek) From 9a5e023d8631b54aca8cebad013d010046972fcb Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:21:45 +0100 Subject: [PATCH 05/17] Remove unnecessary uikit and ib helpers --- .../AutoLayout/HairlineLayoutConstraint.swift | 10 --- .../AutoLayout/KeyboardHeightConstraint.swift | 57 -------------- Sources/FuntastyKit/Protocols/Nibable.swift | 34 --------- .../Protocols/StoryboardType.swift | 33 --------- .../UIView+Extensions.swift | 74 ------------------- 5 files changed, 208 deletions(-) delete mode 100644 Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift delete mode 100644 Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift delete mode 100644 Sources/FuntastyKit/Protocols/Nibable.swift delete mode 100644 Sources/FuntastyKit/Protocols/StoryboardType.swift delete mode 100644 Sources/FuntastyKitIBInspectable/UIView+Extensions.swift diff --git a/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift b/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift deleted file mode 100644 index 351a5c3..0000000 --- a/Sources/FuntastyKit/AutoLayout/HairlineLayoutConstraint.swift +++ /dev/null @@ -1,10 +0,0 @@ -import UIKit - -/// Useful to ensure 1px size on retina displays -public class HairlineLayoutConstraint: NSLayoutConstraint { - - override public func awakeFromNib() { - super.awakeFromNib() - self.constant /= UIScreen.main.scale - } -} diff --git a/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift b/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift deleted file mode 100644 index 2dee47b..0000000 --- a/Sources/FuntastyKit/AutoLayout/KeyboardHeightConstraint.swift +++ /dev/null @@ -1,57 +0,0 @@ -import UIKit - -public final class KeyboardHeightConstraint: NSLayoutConstraint { - - override public func awakeFromNib() { - super.awakeFromNib() - - let center: NotificationCenter = .default - center.addObserver(self, selector: #selector(keyboardWillChange), name: UIResponder.keyboardWillHideNotification, object: nil) - center.addObserver(self, selector: #selector(keyboardWillChange), name: UIResponder.keyboardWillShowNotification, object: nil) - } - - private var superview: UIView? { - (secondItem as? UIView)?.superview - } - - @objc - private func keyboardWillChange(_ notification: Notification) { - guard let userInfo = notification.userInfo else { - return - } - - let insetHeight = (notification.name == UIResponder.keyboardWillHideNotification) ? 0.0 : height(for: userInfo) - inset - - superview?.layoutIfNeeded() - UIView.animate(withDuration: duration(from: userInfo), delay: 0, options: options(from: userInfo), animations: { - self.constant = insetHeight - self.superview?.layoutIfNeeded() - }, completion: nil) - } - - private var inset: CGFloat { - if #available(iOS 11.0, *) { - return superview?.safeAreaInsets.bottom ?? 0.0 - } - return 0.0 - } - - private func height(for userInfo: [AnyHashable: Any]) -> CGFloat { - userInfo[UIResponder.keyboardFrameEndUserInfoKey] - .flatMap { $0 as? NSValue } - .map { $0.cgRectValue.height } ?? 0.0 - } - - private func duration(from userInfo: [AnyHashable: Any]) -> Double { - userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] - .flatMap { $0 as? NSNumber } - .map { $0.doubleValue } ?? 0.0 - } - - private func options(from userInfo: [AnyHashable: Any]) -> UIView.AnimationOptions { - userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] - .flatMap { $0 as? NSNumber } - .map { $0.uintValue << 16 } - .map(UIView.AnimationOptions.init) ?? UIView.AnimationOptions() - } -} diff --git a/Sources/FuntastyKit/Protocols/Nibable.swift b/Sources/FuntastyKit/Protocols/Nibable.swift deleted file mode 100644 index fe71c3d..0000000 --- a/Sources/FuntastyKit/Protocols/Nibable.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Nibable.swift -// FuntastyKit -// -// Created by Matěj Jirásek on 07/10/2016. -// Copyright © 2016 FUNTASTY Digital s.r.o. All rights reserved. -// - -import UIKit - -public extension UIView { - - static var nibName: String { - String(describing: self) - } -} - -public protocol Nibable { - static var nib: UINib { get } -} - -public extension Nibable where Self: UIView { - - static var nib: UINib { - UINib(nibName: Self.nibName, bundle: nil) - } - - init(owner: AnyObject? = nil) { - let views = Self.nib.instantiate(withOwner: owner, options: [:]) - self = views.lazy.compactMap { $0 as? Self }.first ?? { () -> Self in - fatalError("Nib for class \(Self.nibName) could not be loaded!") - }() - } -} diff --git a/Sources/FuntastyKit/Protocols/StoryboardType.swift b/Sources/FuntastyKit/Protocols/StoryboardType.swift deleted file mode 100644 index dcb754d..0000000 --- a/Sources/FuntastyKit/Protocols/StoryboardType.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// StoryboardType.swift -// FuntastyKit -// -// Created by Aleš Kocur on 21.05.16. -// Copyright © 2016 The Funtasty. All rights reserved. -// - -import Foundation -import UIKit - -public protocol StoryboardType { - static var name: String { get } -} - -public struct StoryboardReference { - - private let id: String - private let bundle: Bundle? - - public init(id: String, bundle: Bundle? = nil) { - self.bundle = bundle - self.id = id - } - - public func instantiate() -> T { - if let controller = UIStoryboard(name: S.name, bundle: bundle).instantiateViewController(withIdentifier: id) as? T { - return controller - } else { - fatalError("Instantiated controller with \(id) has different type than expected!") - } - } -} diff --git a/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift b/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift deleted file mode 100644 index d64f005..0000000 --- a/Sources/FuntastyKitIBInspectable/UIView+Extensions.swift +++ /dev/null @@ -1,74 +0,0 @@ -import UIKit - -@IBDesignable -extension UIView { - - // MARK: - Corner radius - - @IBInspectable open var cornerRadius: CGFloat { - get { - layer.cornerRadius - } - set { - layer.cornerRadius = newValue - } - } - - // MARK: - Shadow - - @IBInspectable open var shadowColor: UIColor? { - get { - layer.shadowColor.flatMap(UIColor.init) - } - set { - layer.shadowColor = newValue?.cgColor - } - } - - @IBInspectable open var shadowOffset: CGSize { - get { - layer.shadowOffset - } - set { - layer.shadowOffset = newValue - } - } - - @IBInspectable open var shadowRadius: CGFloat { - get { - layer.shadowRadius - } - set { - layer.shadowRadius = newValue - } - } - - @IBInspectable open var shadowOpacity: Float { - get { - layer.shadowOpacity - } - set { - layer.shadowOpacity = newValue - } - } - - // MARK: - Border - - @IBInspectable open var borderWidth: CGFloat { - get { - layer.borderWidth - } - set { - layer.borderWidth = newValue - } - } - - @IBInspectable open var borderColor: UIColor? { - get { - layer.borderColor.flatMap(UIColor.init) - } - set { - layer.borderColor = newValue?.cgColor - } - } -} From 358a99c2d832fab32f67e9c8593ac273a54abeaf Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:25:39 +0100 Subject: [PATCH 06/17] Update gitignore --- .gitignore | 74 +++--------------------------------------------------- 1 file changed, 4 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 3bf50c9..95c4320 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,5 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## Build generated -build/ -DerivedData/ - -## Various settings -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 +.DS_Store +/.build +/Packages +/*.xcodeproj xcuserdata/ - -## Other -*.moved-aside -*.xcuserstate - -## Obj-C/Swift specific -*.hmap -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -.build/ -.swiftpm -Package.resolved - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/README.md - -# Project, because it can be generated using SPM -*.xcodeproj From 183a761bf06ef143e9cea27e4656d14e580f811b Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 20:28:08 +0100 Subject: [PATCH 07/17] Update swiftlint --- .swiftlint.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 5d7b40f..2268b8b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,7 +1,11 @@ disabled_rules: - line_length excluded: - - vendor + - .build + - .claude +analyzer_rules: + - unused_declaration + - unused_import opt_in_rules: - array_init - attributes @@ -9,7 +13,6 @@ opt_in_rules: - closure_end_indentation - closure_spacing - collection_alignment - - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil @@ -32,24 +35,29 @@ opt_in_rules: - function_default_parameter_at_end - identical_operands - implicit_return + - implicitly_unwrapped_optional - joined_default_parameter - last_where - legacy_multiple - legacy_random + - let_var_whitespace - literal_expression_end_indentation - lower_acl_than_parent - modifier_order + - multiline_arguments - multiline_function_chains - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - nimble_operator + - no_extension_access_modifier - number_separator - object_literal - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension + - pattern_matching_keywords - prefer_self_type_over_type_of_self - private_action - private_outlet @@ -67,8 +75,6 @@ opt_in_rules: - trailing_closure - unneeded_parentheses_in_closure_argument - untyped_error_in_catch - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - yoda_condition @@ -80,6 +86,9 @@ identifier_name: - x - y - z + - pr + +type_body_length: 400 # Disable errors, allow only warnings cyclomatic_complexity: From 2edeaaecdca4b04779c272a1baae4e24fc84152a Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 21:27:27 +0100 Subject: [PATCH 08/17] Update tests --- .github/workflows/test.yml | 2 +- Package.swift | 5 ++--- Tests/FuntastyKitTests/ExampleScene.swift | 14 ++++++++++---- Tests/FuntastyKitTests/FuntastyKitTests.swift | 16 +++++++++++----- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8939a9..144c14a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,4 +14,4 @@ jobs: swiftlint --strict - name: Test run: | - xcodebuild -scheme FuntastyKit-Package -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator test + xcodebuild -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator test diff --git a/Package.swift b/Package.swift index aaef49a..0ec30dd 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( products: [ .library( name: "FuntastyKit", - targets: ["FuntastyKit"]), + targets: ["FuntastyKit"]) ], targets: [ .target( @@ -15,7 +15,6 @@ let package = Package( swiftSettings: [.defaultIsolation(MainActor.self)]), .testTarget( name: "FuntastyKitTests", - dependencies: ["FuntastyKit"], - swiftSettings: [.defaultIsolation(MainActor.self)]), + dependencies: ["FuntastyKit"]) ] ) diff --git a/Tests/FuntastyKitTests/ExampleScene.swift b/Tests/FuntastyKitTests/ExampleScene.swift index 7601278..1f0989f 100644 --- a/Tests/FuntastyKitTests/ExampleScene.swift +++ b/Tests/FuntastyKitTests/ExampleScene.swift @@ -6,12 +6,15 @@ struct Model { // MARK: - Controller +@MainActor protocol ExampleViewControllerInput: AnyObject { // TO-DO: Place your presenting methods here } +@MainActor final class ExampleViewController: UIViewController { - var viewModel: ExampleViewModel! + + var viewModel: ExampleViewModel? } extension ExampleViewController: ExampleViewControllerInput { @@ -20,13 +23,14 @@ extension ExampleViewController: ExampleViewControllerInput { // MARK: - View model +@MainActor final class ExampleViewModel { - private weak var viewController: ExampleViewControllerInput? - private let coordinator: ExampleCoordinatorInput + private weak var viewController: (any ExampleViewControllerInput)? + private let coordinator: any ExampleCoordinatorInput private let model: Model - init(model: Model, coordinator: ExampleCoordinatorInput, viewController: ExampleViewControllerInput) { + init(model: Model, coordinator: any ExampleCoordinatorInput, viewController: any ExampleViewControllerInput) { self.model = model self.coordinator = coordinator self.viewController = viewController @@ -35,10 +39,12 @@ final class ExampleViewModel { // MARK: - Coordinator +@MainActor protocol ExampleCoordinatorInput: Coordinator { // TO-DO: Place your navigation methods here } +@MainActor final class ExampleCoordinator: ModalCoordinator { var sourceViewController: UIViewController diff --git a/Tests/FuntastyKitTests/FuntastyKitTests.swift b/Tests/FuntastyKitTests/FuntastyKitTests.swift index 108ffc4..dddf328 100644 --- a/Tests/FuntastyKitTests/FuntastyKitTests.swift +++ b/Tests/FuntastyKitTests/FuntastyKitTests.swift @@ -1,14 +1,20 @@ -import Foundation import FuntastyKit -import XCTest +import Testing +import UIKit -final class FuntastyKitTests: XCTestCase { +@Suite +@MainActor +struct FuntastyKitTests { - func testArchitecture() { + @Test + func architecture() { let model = Model() + let window = UIWindow() + let viewController = UIViewController() - UIApplication.shared.keyWindow?.rootViewController = viewController + window.rootViewController = viewController + window.makeKeyAndVisible() let coordinator = ExampleCoordinator(from: viewController, model: model) coordinator.start() From 481eb5ea94dcc5526bfe816821bff1c84846b50d Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 21:27:45 +0100 Subject: [PATCH 09/17] Update claude.md and readme --- CLAUDE.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 60 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3da1a4d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md — FuntastyKit + +## Build & Test + +This is an iOS framework — use the Xcode toolchain (`xcrun`), not the local Swift toolchain: + +```bash +# Build (requires iOS SDK via Xcode) +xcodebuild build -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator + +# Run tests (matches CI) +xcodebuild test -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator + +# Lint +swiftlint --strict +``` + +Note: `swift build` / `swift test` do not work because UIKit is not available on macOS host. Always use `xcodebuild` with an iOS simulator destination. + +## Project Structure + +Single SPM target: + +- **FuntastyKit** (`Sources/FuntastyKit/`) — Core: Coordinator pattern, error handling, UIKit extensions + +Tests are in `FuntastyKitTests`. + +## Architecture + +Swift 6.2 with `defaultIsolation: MainActor.self` on the FuntastyKit target — all code in the module is MainActor-isolated by default. No manual `@MainActor` annotations needed. Use `nonisolated` to opt out where required. + +Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). The test target does **not** have `defaultIsolation` — test suites/types that interact with MainActor-isolated library code need explicit `@MainActor` annotations. + +Coordinator protocol hierarchy for UIKit navigation: +`Coordinator` → `DefaultCoordinator` → `ConfiguringCoordinator` → `PushCoordinator` / `ModalCoordinator` / `ShowCoordinator` / `TabBarItemCoordinator` + +Each coordinator type provides a default `start()` implementation for its navigation style. `CoordinatorDelegate` notifies on `willStop`/`didStop`. + +## SwiftLint + +Strict mode enabled. Key style rules to follow: +- `no_extension_access_modifier`: Use `extension Foo { public func ... }`, not `public extension Foo { func ... }` +- `let_var_whitespace`: Blank line before variable declarations +- `pattern_matching_keywords`: Move `let`/`var` outside tuples in pattern matching +- `implicitly_unwrapped_optional`: Avoid `!` optionals +- `multiline_arguments`: Multi-line function calls should have each argument on its own line + +Key thresholds: +- Cyclomatic complexity: warning at 10 +- Function parameters: max 5 +- Large tuple: warning 3, error 4 +- Type name/body max length: 50 / 400 +- `line_length` is disabled +- `force_cast` and `force_try` are warnings (not errors) + +## CI (GitHub Actions) + +Runs on PRs (`test.yml`): SwiftLint --strict → xcodebuild test. diff --git a/README.md b/README.md index cf0c2f2..c9665b7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ![License](https://img.shields.io/github/license/futuredapp/FuntastyKit.svg) +> Requires **Swift 6.2** / **iOS 16+** + The FuntastyKit for iOS contains: - MVVM-C architecture used at [Futured](https://www.futured.app/en/), the template for this architecture can be found at https://github.com/futuredapp/MVVM-C-Templates. From f4e678a702356465ae5fb0aea0869aeec172cf0a Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 21:35:47 +0100 Subject: [PATCH 10/17] Architecture update --- .../contents.xcworkspacedata | 7 +++ README.md | 2 +- .../Architecture/Coordinator.swift | 60 +++++++++---------- .../FuntastyKit/Errors/AlertCoordinator.swift | 24 ++++---- Sources/FuntastyKit/Errors/Errors.swift | 12 ++-- .../Errors/UIAlertController+Error.swift | 22 +++---- .../Extensions/UITableView+Extensions.swift | 14 +++-- .../UIViewController+Deselection.swift | 16 +++-- 8 files changed, 87 insertions(+), 70 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/README.md b/README.md index c9665b7..3586bde 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -FuntastyKit logo +FuntastyKit logo # FuntastyKit diff --git a/Sources/FuntastyKit/Architecture/Coordinator.swift b/Sources/FuntastyKit/Architecture/Coordinator.swift index 1e773ae..6a5ba53 100644 --- a/Sources/FuntastyKit/Architecture/Coordinator.swift +++ b/Sources/FuntastyKit/Architecture/Coordinator.swift @@ -10,17 +10,17 @@ public protocol Coordinator { func stop() } -public extension Coordinator { - func stop() { +extension Coordinator { + public func stop() { } } public protocol DefaultCoordinator: Coordinator { associatedtype ViewController: UIViewController - var viewController: ViewController? { get } + var viewController: ViewController? { get } var animated: Bool { get } - var delegate: CoordinatorDelegate? { get set } + var delegate: (any CoordinatorDelegate)? { get set } } public protocol ConfiguringCoordinator: DefaultCoordinator { @@ -47,14 +47,14 @@ public protocol TabBarItemCoordinator: ConfiguringCoordinator { var destinationNavigationController: UINavigationController? { get } } -public extension DefaultCoordinator { +extension DefaultCoordinator { // default implementation if not overridden - var animated: Bool { + public var animated: Bool { true } // default implementation of nil delegate, should be overridden when needed - var delegate: CoordinatorDelegate? { + public var delegate: (any CoordinatorDelegate)? { get { nil } @@ -63,19 +63,19 @@ public extension DefaultCoordinator { } } - func stop() { + public func stop() { delegate?.willStop(in: self) delegate?.didStop(in: self) } } -public extension ShowCoordinator { - var isDetail: Bool { +extension ShowCoordinator { + public var isDetail: Bool { false } - func start() { - guard let viewController = viewController else { + public func start() { + guard let viewController else { return } configure(viewController: viewController) @@ -87,9 +87,9 @@ public extension ShowCoordinator { } } -public extension PushCoordinator { - func start() { - guard let viewController = viewController else { +extension PushCoordinator { + public func start() { + guard let viewController else { return } @@ -97,24 +97,24 @@ public extension PushCoordinator { navigationController?.pushViewController(viewController, animated: animated) } - func stop() { + public func stop() { delegate?.willStop(in: self) navigationController?.popViewController(animated: animated) delegate?.didStop(in: self) } } -public extension ModalCoordinator { - var destinationNavigationController: UINavigationController? { nil } +extension ModalCoordinator { + public var destinationNavigationController: UINavigationController? { nil } - func start() { - guard let viewController = viewController else { + public func start() { + guard let viewController else { return } configure(viewController: viewController) - if let destinationNavigationController = destinationNavigationController { + if let destinationNavigationController { // wrapper navigation controller given, present it sourceViewController.present(destinationNavigationController, animated: animated, completion: nil) } else { @@ -123,7 +123,7 @@ public extension ModalCoordinator { } } - func stop() { + public func stop() { delegate?.willStop(in: self) viewController?.dismiss(animated: animated) { self.delegate?.didStop(in: self) @@ -131,11 +131,11 @@ public extension ModalCoordinator { } } -public extension TabBarItemCoordinator { - var destinationNavigationController: UINavigationController? { nil } +extension TabBarItemCoordinator { + public var destinationNavigationController: UINavigationController? { nil } - func start() { - guard let viewController = viewController else { + public func start() { + guard let viewController else { return } configure(viewController: viewController) @@ -146,9 +146,9 @@ public extension TabBarItemCoordinator { tabBarController?.setViewControllers(viewControllers, animated: animated) } - func stop() { + public func stop() { delegate?.willStop(in: self) - guard let viewController = viewController, let viewControllers = tabBarController?.viewControllers else { + guard let viewController, let viewControllers = tabBarController?.viewControllers else { return } @@ -163,6 +163,6 @@ public extension TabBarItemCoordinator { } public protocol CoordinatorDelegate: AnyObject { - func willStop(in coordinator: Coordinator) - func didStop(in coordinator: Coordinator) + func willStop(in coordinator: any Coordinator) + func didStop(in coordinator: any Coordinator) } diff --git a/Sources/FuntastyKit/Errors/AlertCoordinator.swift b/Sources/FuntastyKit/Errors/AlertCoordinator.swift index d1f7df1..6988137 100644 --- a/Sources/FuntastyKit/Errors/AlertCoordinator.swift +++ b/Sources/FuntastyKit/Errors/AlertCoordinator.swift @@ -3,12 +3,12 @@ import UIKit public class AlertCoordinator: DefaultCoordinator { public typealias ViewController = UIAlertController - public enum Source { + public enum Source: Sendable { case button(UIBarButtonItem) case view(UIView) } - public enum Style { + public enum Style: Sendable { case alert case actionSheet(source: Source?) @@ -23,14 +23,14 @@ public class AlertCoordinator: DefaultCoordinator { } enum InputType { - case error(Error) + case error(any Error) case custom(title: String?, message: String?, actions: [ErrorAction]?) func alertController(preferredStyle: Style = .alert) -> UIAlertController { switch self { case .error(let error): return UIAlertController(error: error, preferredStyle: preferredStyle.controllerStyle) - case .custom(let title, let message, let actions): + case let .custom(title, message, actions): let alert = UIAlertController(title: title, message: message, preferredStyle: preferredStyle.controllerStyle) (actions ?? [ErrorAction(title: NSLocalizedString("OK", comment: "OK"))]) .forEach { action in @@ -47,14 +47,14 @@ public class AlertCoordinator: DefaultCoordinator { let parentViewController: UIViewController public weak var viewController: UIAlertController? - public weak var delegate: CoordinatorDelegate? + public weak var delegate: (any CoordinatorDelegate)? private var type: InputType private var preferredStyle: Style // MARK: - Inits - public init(parent: UIViewController, error: Error, preferredStyle: Style = .alert) { + public init(parent: UIViewController, error: any Error, preferredStyle: Style = .alert) { self.parentViewController = parent self.type = .error(error) self.preferredStyle = preferredStyle @@ -88,8 +88,8 @@ public class AlertCoordinator: DefaultCoordinator { } } -public extension ErrorAction { - func alertStyle() -> UIAlertAction.Style { +extension ErrorAction { + public func alertStyle() -> UIAlertAction.Style { switch self.style { case .cancel: return .cancel @@ -100,15 +100,15 @@ public extension ErrorAction { } } - func alertAction() -> UIAlertAction { + public func alertAction() -> UIAlertAction { UIAlertAction(title: self.title, style: self.alertStyle()) { _ in self.action?() } } } -public extension DefaultCoordinator { - func showAlert(for error: Error, preferredStyle: AlertCoordinator.Style = .alert) { +extension DefaultCoordinator { + public func showAlert(for error: any Error, preferredStyle: AlertCoordinator.Style = .alert) { guard let viewController = self.viewController else { return } @@ -117,7 +117,7 @@ public extension DefaultCoordinator { alertCoordinator.start() } - func showAlert(title: String?, message: String?, actions: [ErrorAction]? = nil, preferredStyle: AlertCoordinator.Style = .alert) { + public func showAlert(title: String?, message: String?, actions: [ErrorAction]? = nil, preferredStyle: AlertCoordinator.Style = .alert) { guard let viewController = self.viewController else { return } diff --git a/Sources/FuntastyKit/Errors/Errors.swift b/Sources/FuntastyKit/Errors/Errors.swift index f97734c..1389416 100644 --- a/Sources/FuntastyKit/Errors/Errors.swift +++ b/Sources/FuntastyKit/Errors/Errors.swift @@ -4,19 +4,19 @@ public protocol ResolvableError: LocalizedError { var actions: [ErrorAction] { get } } -public struct ErrorAction { - public enum Style: Int { +public struct ErrorAction: Sendable { + public enum Style: Int, Sendable { case `default` case cancel case destructive case preferred } - public typealias ErrorHandler = () -> Void + public typealias ErrorHandler = @Sendable () -> Void - let title: String - var action: ErrorHandler? - var style: Style + public let title: String + public var action: ErrorHandler? + public var style: Style public init(title: String, style: Style = .default, action: ErrorHandler? = nil) { self.title = title diff --git a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift index 588233b..fdefa8f 100644 --- a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift +++ b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift @@ -1,11 +1,13 @@ import UIKit -public extension UIAlertController { - convenience init(error: Error, preferredStyle: UIAlertController.Style = .alert) { - self.init(title: UIAlertController.alertTitle(error: error), - message: UIAlertController.alertMessage(error: error), - preferredStyle: preferredStyle) - if let error = error as? ResolvableError { +extension UIAlertController { + public convenience init(error: any Error, preferredStyle: UIAlertController.Style = .alert) { + self.init( + title: UIAlertController.alertTitle(error: error), + message: UIAlertController.alertMessage(error: error), + preferredStyle: preferredStyle + ) + if let error = error as? any ResolvableError { error.actions.map { $0.alertAction() }.forEach(self.addAction) if !error.actions.isEmpty { return @@ -14,18 +16,18 @@ public extension UIAlertController { self.addAction(UIAlertAction(title: UIAlertController.okButtonText, style: .default)) } - private static func alertTitle(error: Error) -> String { + private static func alertTitle(error: any Error) -> String { switch error { - case let error as LocalizedError: + case let error as any LocalizedError: return error.errorDescription ?? defaultErrorTitle default: return defaultErrorTitle } } - private static func alertMessage(error: Error) -> String { + private static func alertMessage(error: any Error) -> String { switch error { - case let error as LocalizedError: + case let error as any LocalizedError: return error.failureReason ?? error.localizedDescription default: return error.localizedDescription diff --git a/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift b/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift index f113465..923aafd 100644 --- a/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift +++ b/Sources/FuntastyKit/Extensions/UITableView+Extensions.swift @@ -1,18 +1,22 @@ -import Foundation import UIKit -public extension UITableView { +extension UIView { + public static var nibName: String { + String(describing: self) + } +} - func registerNib(for cellClass: T.Type) { +extension UITableView { + public func registerNib(for cellClass: T.Type) { let nib = UINib(nibName: cellClass.nibName, bundle: nil) register(nib, forCellReuseIdentifier: cellClass.nibName) } - func registerCellClass(_ type: T.Type) { + public func registerCellClass(_ type: T.Type) { register(type, forCellReuseIdentifier: type.nibName) } - func dequeueReusableCell(withType type: T.Type) -> T { + public func dequeueReusableCell(withType type: T.Type) -> T { if let cell = dequeueReusableCell(withIdentifier: type.nibName) as? T { return cell } else { diff --git a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift index 65263fa..f9afc35 100644 --- a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift +++ b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift @@ -2,6 +2,7 @@ import UIKit public protocol Deselectable { var indexPathsForSelectedItems: [IndexPath]? { get } + func selectItem(at indexPath: IndexPath?, animated: Bool) func deselectItem(at indexPath: IndexPath, animated: Bool) } @@ -29,7 +30,7 @@ extension UICollectionView: Deselectable { } } -public extension UIViewController { +extension UIViewController { /// Smoothly deselect selected rows in a table view during an animated /// transition, and intelligently reselect those rows if the interactive @@ -38,7 +39,7 @@ public extension UIViewController { /// /// - parameter deselectable: The (de)selectable view in which to perform deselection/reselection. @nonobjc - func smoothlyDeselectItems(on deselectable: Deselectable?) { + public func smoothlyDeselectItems(on deselectable: (any Deselectable)?) { let selectedIndexPaths = deselectable?.indexPathsForSelectedItems ?? [] if let coordinator = transitionCoordinator { @@ -78,17 +79,20 @@ public extension UIViewController { } private func switchSelectedItemsState( - on deselectable: Deselectable?, + on deselectable: (any Deselectable)?, selectedIndexPaths: [IndexPath], shouldBeSelected: Bool, animated: Bool ) { - guard let deselectable = deselectable else { + guard let deselectable else { return } selectedIndexPaths.forEach { - let switchSelectionFunc = shouldBeSelected ? deselectable.selectItem : deselectable.deselectItem - switchSelectionFunc($0, animated) + if shouldBeSelected { + deselectable.selectItem(at: $0, animated: animated) + } else { + deselectable.deselectItem(at: $0, animated: animated) + } } } } From 0b37b99adb62d262426fb5cda2de2ff9712b026a Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 21:41:09 +0100 Subject: [PATCH 11/17] Update setRootViewController to async --- .../FuntastyKit/Architecture/Coordinator.swift | 3 +-- .../Extensions/UIWindow+RootViewController.swift | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/FuntastyKit/Architecture/Coordinator.swift b/Sources/FuntastyKit/Architecture/Coordinator.swift index 6a5ba53..71b7511 100644 --- a/Sources/FuntastyKit/Architecture/Coordinator.swift +++ b/Sources/FuntastyKit/Architecture/Coordinator.swift @@ -11,8 +11,7 @@ public protocol Coordinator { } extension Coordinator { - public func stop() { - } + public func stop() {} } public protocol DefaultCoordinator: Coordinator { diff --git a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift index f108a31..5c1f366 100644 --- a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift +++ b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift @@ -3,20 +3,28 @@ import UIKit /** * UIWindow extension for setting the rootViewController on a UIWindow instance in a safe and animatable way. */ -public extension UIWindow { +extension UIWindow { /** Set the rootViewController on this UIWindow instance. - parameter viewController: The view controller to set - parameter animated: Whether or not to animate the transition, animation is a cross-fade - - parameter completion: Completion block to be invoked after the transition finishes */ @nonobjc - func setRootViewController(_ viewController: UIViewController, animated: Bool, duration: TimeInterval = 0.3, options: UIView.AnimationOptions = .transitionCrossDissolve, completion: ((Bool) -> Void)? = nil) { + public func setRootViewController( + _ viewController: UIViewController, + animated: Bool, + duration: TimeInterval = 0.3, + options: UIView.AnimationOptions = .transitionCrossDissolve + ) async { rootViewController = viewController if animated { - UIView.transition(with: self, duration: duration, options: options, animations: nil, completion: completion) + await withCheckedContinuation { continuation in + UIView.transition(with: self, duration: duration, options: options, animations: nil) { _ in + continuation.resume() + } + } } } } From 0a90deffad19e0daddef6dd3897e01ce90476a9f Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 21:59:48 +0100 Subject: [PATCH 12/17] CR updates --- .github/workflows/test.yml | 2 +- .swiftlint.yml | 1 - CLAUDE.md | 2 +- Sources/FuntastyKit/Errors/Errors.swift | 2 +- Tests/FuntastyKitTests/FuntastyKitTests.swift | 2 ++ 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 144c14a..737fc43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: jobs: test: - runs-on: macos-latest + runs-on: macos-26 steps: - uses: actions/checkout@v6 diff --git a/.swiftlint.yml b/.swiftlint.yml index 2268b8b..6e73a8d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -23,7 +23,6 @@ opt_in_rules: - empty_collection_literal - empty_count - empty_string - - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough diff --git a/CLAUDE.md b/CLAUDE.md index 3da1a4d..05b5a59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Tests are in `FuntastyKitTests`. Swift 6.2 with `defaultIsolation: MainActor.self` on the FuntastyKit target — all code in the module is MainActor-isolated by default. No manual `@MainActor` annotations needed. Use `nonisolated` to opt out where required. -Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). The test target does **not** have `defaultIsolation` — test suites/types that interact with MainActor-isolated library code need explicit `@MainActor` annotations. +Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). The test target does **not** have `defaultIsolation` — adding it causes "crosses into main actor-isolated code" errors on cross-module protocol conformances. Types conforming to library protocols need explicit `@MainActor` annotations. Coordinator protocol hierarchy for UIKit navigation: `Coordinator` → `DefaultCoordinator` → `ConfiguringCoordinator` → `PushCoordinator` / `ModalCoordinator` / `ShowCoordinator` / `TabBarItemCoordinator` diff --git a/Sources/FuntastyKit/Errors/Errors.swift b/Sources/FuntastyKit/Errors/Errors.swift index 1389416..b43c9d3 100644 --- a/Sources/FuntastyKit/Errors/Errors.swift +++ b/Sources/FuntastyKit/Errors/Errors.swift @@ -12,7 +12,7 @@ public struct ErrorAction: Sendable { case preferred } - public typealias ErrorHandler = @Sendable () -> Void + public typealias ErrorHandler = @MainActor @Sendable () -> Void public let title: String public var action: ErrorHandler? diff --git a/Tests/FuntastyKitTests/FuntastyKitTests.swift b/Tests/FuntastyKitTests/FuntastyKitTests.swift index dddf328..9c16994 100644 --- a/Tests/FuntastyKitTests/FuntastyKitTests.swift +++ b/Tests/FuntastyKitTests/FuntastyKitTests.swift @@ -18,5 +18,7 @@ struct FuntastyKitTests { let coordinator = ExampleCoordinator(from: viewController, model: model) coordinator.start() + + #expect(coordinator.viewController?.viewModel != nil) } } From d88f8953d7336392ce59ba26480a46168b255073 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 22:29:03 +0100 Subject: [PATCH 13/17] CR updates --- .github/workflows/test.yml | 3 +++ .../Architecture/Coordinator.swift | 27 +++++++------------ .../FuntastyKit/Errors/AlertCoordinator.swift | 22 +++++++-------- .../Errors/UIAlertController+Error.swift | 8 +++--- .../UIViewController+Deselection.swift | 5 ++-- 5 files changed, 28 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 737fc43..7c2766a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install SwiftLint + run: | + brew install swiftlint - name: Lint run: | swiftlint --strict diff --git a/Sources/FuntastyKit/Architecture/Coordinator.swift b/Sources/FuntastyKit/Architecture/Coordinator.swift index 71b7511..d7b7293 100644 --- a/Sources/FuntastyKit/Architecture/Coordinator.swift +++ b/Sources/FuntastyKit/Architecture/Coordinator.swift @@ -74,9 +74,8 @@ extension ShowCoordinator { } public func start() { - guard let viewController else { - return - } + guard let viewController else { return } + configure(viewController: viewController) if isDetail { sourceViewController?.showDetailViewController(viewController, sender: nil) @@ -88,9 +87,7 @@ extension ShowCoordinator { extension PushCoordinator { public func start() { - guard let viewController else { - return - } + guard let viewController else { return } configure(viewController: viewController) navigationController?.pushViewController(viewController, animated: animated) @@ -107,18 +104,16 @@ extension ModalCoordinator { public var destinationNavigationController: UINavigationController? { nil } public func start() { - guard let viewController else { - return - } + guard let viewController else { return } configure(viewController: viewController) if let destinationNavigationController { // wrapper navigation controller given, present it - sourceViewController.present(destinationNavigationController, animated: animated, completion: nil) + sourceViewController.present(destinationNavigationController, animated: animated) } else { // no wrapper navigation controller given, present actual controller - sourceViewController.present(viewController, animated: animated, completion: nil) + sourceViewController.present(viewController, animated: animated) } } @@ -134,9 +129,8 @@ extension TabBarItemCoordinator { public var destinationNavigationController: UINavigationController? { nil } public func start() { - guard let viewController else { - return - } + guard let viewController else { return } + configure(viewController: viewController) var viewControllers = tabBarController?.viewControllers ?? [] @@ -147,9 +141,8 @@ extension TabBarItemCoordinator { public func stop() { delegate?.willStop(in: self) - guard let viewController, let viewControllers = tabBarController?.viewControllers else { - return - } + + guard let viewController, let viewControllers = tabBarController?.viewControllers else { return } var mutableViewControllers = viewControllers if let index = mutableViewControllers.firstIndex(of: destinationNavigationController ?? viewController) { diff --git a/Sources/FuntastyKit/Errors/AlertCoordinator.swift b/Sources/FuntastyKit/Errors/AlertCoordinator.swift index 6988137..f1996c1 100644 --- a/Sources/FuntastyKit/Errors/AlertCoordinator.swift +++ b/Sources/FuntastyKit/Errors/AlertCoordinator.swift @@ -15,9 +15,9 @@ public class AlertCoordinator: DefaultCoordinator { var controllerStyle: UIAlertController.Style { switch self { case .alert: - return .alert + .alert case .actionSheet: - return .actionSheet + .actionSheet } } } @@ -76,7 +76,7 @@ public class AlertCoordinator: DefaultCoordinator { alert.popoverPresentationController?.sourceView = view } } - parentViewController.present(alert, animated: animated, completion: nil) + parentViewController.present(alert, animated: animated) viewController = alert } @@ -90,13 +90,13 @@ public class AlertCoordinator: DefaultCoordinator { extension ErrorAction { public func alertStyle() -> UIAlertAction.Style { - switch self.style { + switch style { case .cancel: - return .cancel + .cancel case .destructive: - return .destructive + .destructive default: - return .default + .default } } @@ -109,18 +109,14 @@ extension ErrorAction { extension DefaultCoordinator { public func showAlert(for error: any Error, preferredStyle: AlertCoordinator.Style = .alert) { - guard let viewController = self.viewController else { - return - } + guard let viewController else { return } let alertCoordinator = AlertCoordinator(parent: viewController, error: error, preferredStyle: preferredStyle) alertCoordinator.start() } public func showAlert(title: String?, message: String?, actions: [ErrorAction]? = nil, preferredStyle: AlertCoordinator.Style = .alert) { - guard let viewController = self.viewController else { - return - } + guard let viewController else { return } let alertCoordinator = AlertCoordinator(parent: viewController, title: title, message: message, actions: actions, preferredStyle: preferredStyle) alertCoordinator.start() diff --git a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift index fdefa8f..77a929d 100644 --- a/Sources/FuntastyKit/Errors/UIAlertController+Error.swift +++ b/Sources/FuntastyKit/Errors/UIAlertController+Error.swift @@ -19,18 +19,18 @@ extension UIAlertController { private static func alertTitle(error: any Error) -> String { switch error { case let error as any LocalizedError: - return error.errorDescription ?? defaultErrorTitle + error.errorDescription ?? defaultErrorTitle default: - return defaultErrorTitle + defaultErrorTitle } } private static func alertMessage(error: any Error) -> String { switch error { case let error as any LocalizedError: - return error.failureReason ?? error.localizedDescription + error.failureReason ?? error.localizedDescription default: - return error.localizedDescription + error.localizedDescription } } diff --git a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift index f9afc35..ef7a54f 100644 --- a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift +++ b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift @@ -84,9 +84,8 @@ extension UIViewController { shouldBeSelected: Bool, animated: Bool ) { - guard let deselectable else { - return - } + guard let deselectable else { return } + selectedIndexPaths.forEach { if shouldBeSelected { deselectable.selectItem(at: $0, animated: animated) From 00ff5fce338dbf426e4ffa88fd2404c38c770707 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 22:33:27 +0100 Subject: [PATCH 14/17] Update device selection --- .github/workflows/test.yml | 2 +- CLAUDE.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c2766a..36c7387 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,4 +17,4 @@ jobs: swiftlint --strict - name: Test run: | - xcodebuild -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator test + xcodebuild -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator test diff --git a/CLAUDE.md b/CLAUDE.md index 05b5a59..e620956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,10 +6,10 @@ This is an iOS framework — use the Xcode toolchain (`xcrun`), not the local Sw ```bash # Build (requires iOS SDK via Xcode) -xcodebuild build -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator +xcodebuild build -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator # Run tests (matches CI) -xcodebuild test -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 16' -sdk iphonesimulator +xcodebuild test -scheme FuntastyKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -sdk iphonesimulator # Lint swiftlint --strict From 733eddbd215f2f18a6fc539931ad0980c8795cf4 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 22:39:43 +0100 Subject: [PATCH 15/17] Minor improvement --- .../Extensions/UIWindow+RootViewController.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift index 5c1f366..7aeb872 100644 --- a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift +++ b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift @@ -10,20 +10,23 @@ extension UIWindow { - parameter viewController: The view controller to set - parameter animated: Whether or not to animate the transition, animation is a cross-fade + - returns: `true` if the animation finished, `false` if it was interrupted. Always `true` when not animated. */ @nonobjc + @discardableResult public func setRootViewController( _ viewController: UIViewController, animated: Bool, duration: TimeInterval = 0.3, options: UIView.AnimationOptions = .transitionCrossDissolve - ) async { + ) async -> Bool { rootViewController = viewController - if animated { - await withCheckedContinuation { continuation in - UIView.transition(with: self, duration: duration, options: options, animations: nil) { _ in - continuation.resume() - } + guard animated else { + return true + } + return await withCheckedContinuation { continuation in + UIView.transition(with: self, duration: duration, options: options, animations: nil) { finished in + continuation.resume(returning: finished) } } } From f70aabe75594df0bb4492dfe0bff0aa71f411ba9 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Mon, 16 Mar 2026 23:00:08 +0100 Subject: [PATCH 16/17] Update public protocols --- CLAUDE.md | 4 +++- Sources/FuntastyKit/Architecture/Coordinator.swift | 8 ++++++++ .../Extensions/UIViewController+Deselection.swift | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e620956..c2e0060 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,9 @@ Tests are in `FuntastyKitTests`. ## Architecture -Swift 6.2 with `defaultIsolation: MainActor.self` on the FuntastyKit target — all code in the module is MainActor-isolated by default. No manual `@MainActor` annotations needed. Use `nonisolated` to opt out where required. +Swift 6.2 with `defaultIsolation: MainActor.self` on the FuntastyKit target — all code in the module is MainActor-isolated by default. Use `nonisolated` to opt out where required. + +Public protocols (`Coordinator`, `DefaultCoordinator`, `CoordinatorDelegate`, `Deselectable`, etc.) also carry explicit `@MainActor` annotations. This is redundant within the module but ensures consumers see the correct isolation in the module interface regardless of their Swift version. Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). The test target does **not** have `defaultIsolation` — adding it causes "crosses into main actor-isolated code" errors on cross-module protocol conformances. Types conforming to library protocols need explicit `@MainActor` annotations. diff --git a/Sources/FuntastyKit/Architecture/Coordinator.swift b/Sources/FuntastyKit/Architecture/Coordinator.swift index d7b7293..ecc81ea 100644 --- a/Sources/FuntastyKit/Architecture/Coordinator.swift +++ b/Sources/FuntastyKit/Architecture/Coordinator.swift @@ -1,5 +1,6 @@ import UIKit +@MainActor public protocol Coordinator { /// Triggers navigation to the corresponding controller func start() @@ -14,6 +15,7 @@ extension Coordinator { public func stop() {} } +@MainActor public protocol DefaultCoordinator: Coordinator { associatedtype ViewController: UIViewController @@ -22,25 +24,30 @@ public protocol DefaultCoordinator: Coordinator { var delegate: (any CoordinatorDelegate)? { get set } } +@MainActor public protocol ConfiguringCoordinator: DefaultCoordinator { func configure(viewController: ViewController) } +@MainActor public protocol ShowCoordinator: ConfiguringCoordinator { /// When used on Split View Controller as Detail View Controller, sourceViewController should be 'weak', otherwise memory leak will emerge var sourceViewController: UIViewController? { get } var isDetail: Bool { get } } +@MainActor public protocol PushCoordinator: ConfiguringCoordinator { var navigationController: UINavigationController? { get } } +@MainActor public protocol ModalCoordinator: ConfiguringCoordinator { var sourceViewController: UIViewController { get } var destinationNavigationController: UINavigationController? { get } } +@MainActor public protocol TabBarItemCoordinator: ConfiguringCoordinator { var tabBarController: UITabBarController? { get } var destinationNavigationController: UINavigationController? { get } @@ -154,6 +161,7 @@ extension TabBarItemCoordinator { } } +@MainActor public protocol CoordinatorDelegate: AnyObject { func willStop(in coordinator: any Coordinator) func didStop(in coordinator: any Coordinator) diff --git a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift index ef7a54f..f14930b 100644 --- a/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift +++ b/Sources/FuntastyKit/Extensions/UIViewController+Deselection.swift @@ -1,5 +1,6 @@ import UIKit +@MainActor public protocol Deselectable { var indexPathsForSelectedItems: [IndexPath]? { get } From 27427c86e56be820c3c25fb3aaa191de359dcf94 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 13:08:09 +0100 Subject: [PATCH 17/17] CR adjustments --- CLAUDE.md | 6 ++---- Package.swift | 14 ++++---------- .../FuntastyKit/Errors/AlertCoordinator.swift | 2 ++ .../UIWindow+RootViewController.swift | 17 ++++++----------- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2e0060..276c170 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,9 @@ Tests are in `FuntastyKitTests`. ## Architecture -Swift 6.2 with `defaultIsolation: MainActor.self` on the FuntastyKit target — all code in the module is MainActor-isolated by default. Use `nonisolated` to opt out where required. +Swift 6.2 with explicit `@MainActor` annotations on all public protocols and UIKit-facing types. No `defaultIsolation` — all isolation is spelled out in source. Extensions on `@MainActor` protocols and UIKit classes inherit isolation automatically; only standalone types/extensions that create UIKit objects need their own `@MainActor`. -Public protocols (`Coordinator`, `DefaultCoordinator`, `CoordinatorDelegate`, `Deselectable`, etc.) also carry explicit `@MainActor` annotations. This is redundant within the module but ensures consumers see the correct isolation in the module interface regardless of their Swift version. - -Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). The test target does **not** have `defaultIsolation` — adding it causes "crosses into main actor-isolated code" errors on cross-module protocol conformances. Types conforming to library protocols need explicit `@MainActor` annotations. +Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`). Types in the test target that conform to library protocols need explicit `@MainActor` annotations. Coordinator protocol hierarchy for UIKit navigation: `Coordinator` → `DefaultCoordinator` → `ConfiguringCoordinator` → `PushCoordinator` / `ModalCoordinator` / `ShowCoordinator` / `TabBarItemCoordinator` diff --git a/Package.swift b/Package.swift index 0ec30dd..6ca6cca 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,14 @@ -// swift-tools-version:6.2 +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "FuntastyKit", platforms: [.iOS(.v16)], products: [ - .library( - name: "FuntastyKit", - targets: ["FuntastyKit"]) + .library(name: "FuntastyKit", targets: ["FuntastyKit"]) ], targets: [ - .target( - name: "FuntastyKit", - swiftSettings: [.defaultIsolation(MainActor.self)]), - .testTarget( - name: "FuntastyKitTests", - dependencies: ["FuntastyKit"]) + .target(name: "FuntastyKit"), + .testTarget(name: "FuntastyKitTests", dependencies: ["FuntastyKit"]) ] ) diff --git a/Sources/FuntastyKit/Errors/AlertCoordinator.swift b/Sources/FuntastyKit/Errors/AlertCoordinator.swift index f1996c1..0e91459 100644 --- a/Sources/FuntastyKit/Errors/AlertCoordinator.swift +++ b/Sources/FuntastyKit/Errors/AlertCoordinator.swift @@ -22,6 +22,7 @@ public class AlertCoordinator: DefaultCoordinator { } } + @MainActor enum InputType { case error(any Error) case custom(title: String?, message: String?, actions: [ErrorAction]?) @@ -88,6 +89,7 @@ public class AlertCoordinator: DefaultCoordinator { } } +@MainActor extension ErrorAction { public func alertStyle() -> UIAlertAction.Style { switch style { diff --git a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift index 7aeb872..5f7e4d3 100644 --- a/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift +++ b/Sources/FuntastyKit/Extensions/UIWindow+RootViewController.swift @@ -10,24 +10,19 @@ extension UIWindow { - parameter viewController: The view controller to set - parameter animated: Whether or not to animate the transition, animation is a cross-fade - - returns: `true` if the animation finished, `false` if it was interrupted. Always `true` when not animated. + - parameter completion: Completion block to be invoked after the transition finishes */ @nonobjc - @discardableResult public func setRootViewController( _ viewController: UIViewController, animated: Bool, duration: TimeInterval = 0.3, - options: UIView.AnimationOptions = .transitionCrossDissolve - ) async -> Bool { + options: UIView.AnimationOptions = .transitionCrossDissolve, + completion: ((Bool) -> Void)? = nil + ) { rootViewController = viewController - guard animated else { - return true - } - return await withCheckedContinuation { continuation in - UIView.transition(with: self, duration: duration, options: options, animations: nil) { finished in - continuation.resume(returning: finished) - } + if animated { + UIView.transition(with: self, duration: duration, options: options, animations: nil, completion: completion) } } }