Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
disabled_rules:
- line_length
- trailing_closure
excluded:
- Templates
- .build
- .claude
analyzer_rules:
- unused_declaration
- unused_import
opt_in_rules:
- array_init
- closure_end_indentation
- closure_spacing
- collection_alignment
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
- contains_over_range_nil_comparison
- convenience_type
- discouraged_object_literal
- empty_collection_literal
- empty_count
- empty_string
- empty_xctest_method
- enum_case_associated_values_count
- explicit_init
- fallthrough
- fatal_error_message
- file_name_no_space
- first_where
- flatmap_over_map_reduce
- force_unwrapping
- 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
- prohibited_super_call
- reduce_into
- redundant_nil_coalescing
- required_enum_case
- single_test_class
- sorted_first_last
- sorted_imports
- static_operator
- strict_fileprivate
- switch_case_on_newline
- toggle_bool
- unneeded_parentheses_in_closure_argument
- untyped_error_in_catch
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- yoda_condition

# Rule configurations
identifier_name:
excluded:
- id
- x
- y
- z
- pr

type_body_length: 400

# Disable errors, allow only warnings
cyclomatic_complexity:
warning: 13
type_name:
max_length: 50
force_cast: warning
force_try: warning
function_parameter_count: 5
large_tuple:
warning: 3
error: 4
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// swift-tools-version: 6.1

import PackageDescription
import CompilerPluginSupport
import PackageDescription

let package = Package(
name: "FuturedKit",
Expand Down
3 changes: 3 additions & 0 deletions Sources/FuturedArchitecture/Architecture/ComponentModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import Foundation
/// - Note: Each *component model* should have have *mock* class and *implementation* class.
/// Each *Component* (i.e. View) should have own *component model*. Each instance of component
/// model has to be referenced by no more than 1 *coordinator.*
///
/// - Important: Conforming types must be annotated with `@Observable`. Without it the class will
/// compile but SwiftUI views will not react to state changes.
@MainActor
public protocol ComponentModel: AnyObject {

Expand Down
21 changes: 11 additions & 10 deletions Sources/FuturedArchitecture/Architecture/Coordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ public protocol Coordinator: AnyObject {
func onModalDismiss()
}

public extension Coordinator {
extension Coordinator {
/// Convenience function for presenting a modal over the *container*.
/// - Parameters:
/// - destination: The description of the desired view passed to the ``scene(for:)`` function
/// of the *coordinator*.
/// - type: Kind of modal presentation.
func present(modal destination: Destination, type: ModalCoverModelStyle) {
public func present(modal destination: Destination, type: ModalCoverModelStyle) {
switch type {
case .sheet:
self.modalCover = .init(destination: destination, style: .sheet)
Expand All @@ -58,11 +58,11 @@ public extension Coordinator {
}

/// Convenience method for dismissing a modal.
func dismissModal() {
public func dismissModal() {
self.modalCover = nil
}

func onModalDismiss() {}
public func onModalDismiss() {}
}

/// `TabCoordinator` provides additional requirements for the use with ``SwiftUI.TabView``.
Expand All @@ -73,6 +73,7 @@ public extension Coordinator {
/// which is essentially duplication of `Destination`. Consider, how the API limits the use of tabs.
public protocol TabCoordinator: Coordinator {
associatedtype Tab: Hashable

var selectedTab: Tab { get set }
}

Expand All @@ -85,34 +86,34 @@ public protocol NavigationStackCoordinator: Coordinator {
var path: [Destination] { get set }
}

public extension NavigationStackCoordinator {
extension NavigationStackCoordinator {
/// Convenience function used to add new view to the navigation stack.
func navigate(to destination: Destination) {
public func navigate(to destination: Destination) {
self.path.append(destination)
}

/// Convenience function used to remove topmost view from the navigation stack.
func pop() {
public func pop() {
self.path.removeLast()
}

/// Convenience function used to remove all views from the stack, until the provided destination.
/// - Parameter destination: Destination to be reached. If nil is passed, or such destination
/// is not currently on the stack, all views are removed.
/// - Experiment: This API is in preview and subject to change.
func pop(to destination: Destination) {
public func pop(to destination: Destination) {
guard let index = self.path.lastIndex(of: destination) else {
assertionFailure("Destination not found on the stack")
return
}
self.path = Array(path[path.startIndex...index])
}

func popToRoot() {
public func popToRoot() {
path = []
}

func reset() {
public func reset() {
path = []
modalCover = nil
}
Expand Down
157 changes: 48 additions & 109 deletions Sources/FuturedArchitecture/Architecture/DataCache.swift
Original file line number Diff line number Diff line change
@@ -1,152 +1,91 @@
import Foundation

/// `DataCache` is intended to store state which may be used by
/// more than one *component* and/or fetched from remote.
/// `DataCache` stores shared mutable state that can be read by multiple components
/// and/or fetched from remote.
///
/// An Application should contain one shared application-wide cache, but each
/// coordinator may also create a private data cache.
/// Because `DataCache` is `@MainActor`, all reads and writes are synchronous from any
/// `@MainActor` context (coordinators, component models).
///
/// The data from data cache should be taken as a subscription and modified
/// only via provided `update` methods. As a general rule, value types should
/// be used as a `Model`.
/// Observation is handled by the `@Observable` macro: any `@Observable` or SwiftUI
/// context that reads `dataCache.value` (or a keyPath of it) will automatically
/// re-evaluate when the value changes.
///
/// - Experiment: This API is in preview and subject to change.
/// - ToDo: How the `DataCache` may interact with persistence such as
/// `CoreData` or `SwiftData` is an open question and subject of further
/// research.
public actor DataCache<Model: Equatable & Sendable> {

// MARK: Stored state
/// Mutate the cache only via the provided `update` and `populate` methods.
/// As a general rule, value types should be used as the `Model`.
///
/// An application should contain one shared application-wide cache stored in the
/// `Container`, but each coordinator may also create a private data cache.
@Observable
@MainActor
public final class DataCache<Model: Equatable & Sendable> {

/// The data held by this data cache.
public private(set) var value: Model

private var subscribers: [UUID: AsyncStream<Model>.Continuation] = [:]

// MARK: Init

public init(value: Model) {
self.value = value
}

deinit {
for continuation in subscribers.values {
continuation.finish()
}
subscribers.removeAll()
}

// MARK: Observation (Swift Concurrency)

/// Observe changes of the cache value.
///
/// - Parameter skipInitial: When `true`, the returned stream does not yield the current value
/// immediately. It only yields subsequent changes.
///
/// This stream yields whenever `value` changes via any of the `update`/`populate` methods.
///
/// Each call creates a new stream ("one stream per subscriber").
///
/// The stream uses `bufferingNewest(1)` because this is "state": consumers typically only care
/// about the latest value, and we want to avoid unbounded buffering if updates happen faster
/// than the consumer can process them.
public func values(skipInitial: Bool = false) -> AsyncStream<Model> {
let id = UUID()
return AsyncStream(Model.self, bufferingPolicy: .bufferingNewest(1)) { continuation in
// Register subscriber inside the actor.
subscribers[id] = continuation

// Yield the current value immediately unless the caller asked to skip it.
if !skipInitial {
continuation.yield(value)
}

continuation.onTermination = { [weak self] _ in
Task { // Hop back into the actor to remove subscriber.
await self?.removeSubscriber(id: id)
}
}
}
}

// MARK: Updates

/// Atomically update the whole data cache. Use this method if you need
/// to perform number of changes at once.
/// Replace the whole model. Use this method when you need to update multiple
/// properties at once. No-op if the value is unchanged.
public func update(with value: Model) {
guard value != self.value else { return }
self.value = value
broadcast(self.value)
}

/// Atomically update one variable.
///
/// - ToDo: Investigate whether we can use variadic generics to improve the API.
/// No change is emitted when the value is the same.
/// Replace one property via keyPath. No-op if the value is unchanged.
public func update<T: Equatable>(_ keyPath: WritableKeyPath<Model, T>, with value: T) {
guard value != self.value[keyPath: keyPath] else { return }
self.value[keyPath: keyPath] = value
broadcast(self.value)
}

/// Populate one variable of Collection type.
/// - Description: The method will append new elements to the existing collection. The elements which are already
/// in the collection as well as in the new collection will be updated. No change is emitted when the new collection is empty
/// or when the merged result is the same as the current value.
/// Merge a collection by Identifiable identity.
///
/// - Existing items whose ID appears in `newItems` are updated in place (order preserved).
/// - Items in `newItems` whose ID is absent from the current collection are appended.
/// - Items already in the collection but absent from `newItems` are kept unchanged.
/// - No write occurs when the merged result is equal to the current collection.
public func populate<T>(
_ keyPath: WritableKeyPath<Model, T>,
with newItems: T
) where T: RangeReplaceableCollection, T.Element: Equatable {
) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable {
guard !newItems.isEmpty else { return }
let current = self.value[keyPath: keyPath]
let merged = mergedCollection(current: current, newItems: newItems)
guard !current.elementsEqual(merged) else { return }
let original = self.value[keyPath: keyPath]
let merged = merging(original, with: newItems)
guard !merged.elementsEqual(original) else { return }
self.value[keyPath: keyPath] = merged
broadcast(self.value)
}

/// Populate one optional variable of Collection type.
///
/// - Description: The method will append new elements to the existing collection. The elements which are already
/// in the collection as well as in the new collection will be updated. No change is emitted when the new collection is empty
/// or when the merged result is the same as the current value.
/// Optional-collection variant of `populate(_:with:)`.
public func populate<T>(
_ keyPath: WritableKeyPath<Model, T?>,
with newItems: T
) where T: RangeReplaceableCollection, T.Element: Equatable {
) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable {
guard !newItems.isEmpty else { return }
let current = self.value[keyPath: keyPath] ?? T()
let merged = mergedCollection(current: current, newItems: newItems)
guard !current.elementsEqual(merged) else { return }
let original = self.value[keyPath: keyPath] ?? T()
let merged = merging(original, with: newItems)
guard !merged.elementsEqual(original) else { return }
self.value[keyPath: keyPath] = merged
broadcast(self.value)
}

// MARK: Private Helpers

private func removeSubscriber(id: UUID) {
subscribers[id]?.finish()
subscribers[id] = nil
}

private func broadcast(_ value: Model) {
for continuation in subscribers.values {
continuation.yield(value)
private func merging<T>(
_ current: T,
with newItems: T
) -> T where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable {
var result = current
let newItemsDict = Dictionary(newItems.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
let existingIds = Set(current.map(\.id))

for index in result.indices {
if let updated = newItemsDict[result[index].id] {
result[index] = updated
}
}
}

private func mergedCollection<T>(
current: T,
newItems: T
) -> T where T: RangeReplaceableCollection, T.Element: Equatable {
var result = T()
result.reserveCapacity(current.count + newItems.count)

let filteredExisting = current.filter { existingItem in
!newItems.contains(existingItem)
var appendedIds = existingIds
for item in newItems where appendedIds.insert(item.id).inserted {
result.append(newItemsDict[item.id]!)
}
result.append(contentsOf: filteredExisting)
result.append(contentsOf: newItems)

return result
}
Expand Down
Loading