diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..1f13548 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,26 @@ +# .github/workflows/documentation.yml +name: Documentation + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Generate Documentation + uses: SwiftDocOrg/swift-doc@master + with: + inputs: "Sources" + module-name: ComposableEnvironment + output: "Documentation" + - name: Upload Documentation to Wiki + uses: SwiftDocOrg/github-wiki-publish-action@v1 + with: + path: "Documentation" + env: + GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..b01b1b5 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,27 @@ +name: Format + +on: + push: + branches: + - main + +jobs: + swift_format: + name: swift-format + runs-on: macOS-11 + steps: + - uses: actions/checkout@v2 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Tap + run: brew tap tgrapperon/formulae + - name: Install + run: brew install Formulae/swift-format@5.5 + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..c68584b --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,19 @@ +name: Swift + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb460e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ComposableEffectIdentifier.xcworkspace/contents.xcworkspacedata b/ComposableEffectIdentifier.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..51fe558 --- /dev/null +++ b/ComposableEffectIdentifier.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/ComposableEffectIdentifier.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ComposableEffectIdentifier.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ComposableEffectIdentifier.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ComposableEffectIdentifier.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableEffectIdentifier.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..35b6749 --- /dev/null +++ b/ComposableEffectIdentifier.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,70 @@ +{ + "object": { + "pins": [ + { + "package": "combine-schedulers", + "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", + "state": { + "branch": null, + "revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b", + "version": "0.5.3" + } + }, + { + "package": "swift-case-paths", + "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", + "state": { + "branch": null, + "revision": "241301b67d8551c26d8f09bd2c0e52cc49f18007", + "version": "0.8.0" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections", + "state": { + "branch": null, + "revision": "48254824bb4248676bf7ce56014ff57b142b77eb", + "version": "1.0.2" + } + }, + { + "package": "swift-composable-architecture", + "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", + "state": { + "branch": null, + "revision": "ba9c626ab1b2b6af8cf684eebb2ab472fa5b6753", + "version": "0.33.1" + } + }, + { + "package": "swift-custom-dump", + "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", + "state": { + "branch": null, + "revision": "51698ece74ecf31959d3fa81733f0a5363ef1b4e", + "version": "0.3.0" + } + }, + { + "package": "swift-identified-collections", + "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", + "state": { + "branch": null, + "revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9", + "version": "0.3.2" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", + "version": "0.2.1" + } + } + ] + }, + "version": 1 +} diff --git a/Examples/LonelyTimer/.gitignore b/Examples/LonelyTimer/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Examples/LonelyTimer/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/LonelyTimer/Package.swift b/Examples/LonelyTimer/Package.swift new file mode 100644 index 0000000..5208b43 --- /dev/null +++ b/Examples/LonelyTimer/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LonelyTimer", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8), + ], + products: [ + .library( + name: "LonelyTimer", + targets: ["LonelyTimer"]) + ], + dependencies: [ + .package(path: "../../"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.33.1"), + ], + targets: [ + .target( + name: "LonelyTimer", + dependencies: [ + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture"), + .product( + name: "ComposableEffectIdentifier", + package: "composable-effect-identifier"), + ]) + ] +) diff --git a/Examples/LonelyTimer/Sources/LonelyTimer/LonelyTimer.swift b/Examples/LonelyTimer/Sources/LonelyTimer/LonelyTimer.swift new file mode 100644 index 0000000..7bed166 --- /dev/null +++ b/Examples/LonelyTimer/Sources/LonelyTimer/LonelyTimer.swift @@ -0,0 +1,226 @@ +import ComposableArchitecture +import ComposableEffectIdentifier +import SwiftUI + +public struct TimerState: Hashable, Codable { + public init( + name: String, + duration: TimeInterval, + position: TimeInterval? = nil, + state: TimerState.State = .ready + ) { + self.name = name + self.duration = duration + self.position = position ?? duration + self.state = state + } + + public enum State: Hashable, Codable { + case ready + case counting + case finished + } + + var name: String + var duration: TimeInterval + var position: TimeInterval + var state: State + + var elapsed: TimeInterval { + duration - position + } +} + +public enum TimerAction { + case start + case stop + case toggle + case reset + case tick + case onAppear + case onDisappear +} + +public struct TimerEnvironment { + public init( + mainQueue: AnySchedulerOf + ) { + self.mainQueue = mainQueue + } + + let mainQueue: AnySchedulerOf +} + +public let timerReducer = Reducer { + state, action, environment in + + @EffectID var timerID + + switch action { + case .onAppear: + if state.state == .counting { + return Effect(value: .start) + } + return .none + case .onDisappear: + return .cancel(id: timerID) + case .reset: + state.position = state.duration + return Effect(value: .stop) + + case .start: + state.state = .counting + return Effect.timer( + id: timerID, + every: .seconds(1), + on: environment.mainQueue + ) + .map { _ in .tick } + + case .stop: + state.state = .ready + return .cancel(id: timerID) + + case .tick: + state.position -= 1 + + if state.position < 1 { + state.position = 0 + state.state = .finished + return .cancel(id: timerID) + } + return .none + + case .toggle: + switch state.state { + case .finished: + return Effect(value: .reset) + case .ready: + return Effect(value: .start) + case .counting: + return Effect(value: .stop) + } + } +} + +public struct TimerView: View { + let store: Store + public init( + store: Store + ) { + self.store = store + } + public var body: some View { + WithViewStore(store) { viewStore in + HStack { + + VStack(alignment: .leading) { + Text(viewStore.name) + .bold() + Text( + Measurement(value: viewStore.duration, unit: UnitDuration.seconds) + .formatted(.measurement(width: .wide)) + ) + .monospacedDigit() + } + + Spacer() + + if viewStore.position != viewStore.duration { + Button { + viewStore.send(.reset) + } label: { + Image(systemName: "arrow.counterclockwise") + + #if os(macOS) + .frame(minWidth: 22) + #else + .frame(minWidth: 44) + #endif + } + .buttonStyle(.borderless) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + Button { + viewStore.send(.toggle) + } label: { + Image(systemName: viewStore.state.state == .counting ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderedProminent) + + } + .overlay { + if viewStore.state.state == .finished { + Button { + viewStore.send(.reset) + } label: { + Text("Done!") + .bold() + .blendMode(.destinationOut) + + #if os(macOS) + .padding(4) + #else + .padding(9) + #endif + .background { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(Color.accentColor) + } + .compositingGroup() + .rotationEffect(.degrees(-10)) + .transition(.scale) + } + .buttonStyle(.plain) + } + } + .background { + if viewStore.state.state != .finished, viewStore.position != viewStore.duration { + Circle() + .trim(from: 0, to: viewStore.elapsed / viewStore.duration) + .rotation(.degrees(-90)) + .stroke(Color.accentColor, style: .init(lineWidth: 3, lineCap: .round)) + + .overlay( + Text( + Measurement(value: viewStore.elapsed, unit: UnitDuration.seconds) + .formatted(.measurement(width: .narrow)) + ) + .font(Font.system(.callout, design: .rounded).bold()) + .foregroundColor(Color.accentColor) + .minimumScaleFactor(0.25) + .lineLimit(1) + .padding(3) + ) + .frame(width: 33, height: 33) + .transition(.scale) + } + } + .animation(.spring(), value: viewStore.position) + .frame(minWidth: 200) + .onAppear { viewStore.send(.onAppear) } + .onDisappear { viewStore.send(.onDisappear) } + + } + } +} + +struct TimerView_Previews: PreviewProvider { + static var previews: some View { + TimerView( + store: .init( + initialState: .init( + name: "Timer 1", + duration: 10, + position: 4, + state: .counting + ), + reducer: timerReducer, + environment: .init( + mainQueue: .main) + ) + ) + .padding(.horizontal) + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9221b9b --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/Contents.json b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/DocumentFeature.swift b/Examples/ManyTimers/ManyTimers Documents/DocumentFeature.swift new file mode 100644 index 0000000..9bc9481 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/DocumentFeature.swift @@ -0,0 +1,59 @@ +import ComposableArchitecture +import LonelyTimer +import SwiftUI + +enum DocumentAction { + case action(Action) + case stateDidChange(State) +} + +extension Reducer { + func document( + file: FileDocumentConfiguration + ) -> Reducer< + State, + DocumentAction, + Environment + > where DocumentType: FileDocument { + Reducer, Environment> + .combine( + pullback(state: \.self, action: /DocumentAction.action, environment: { $0 }), + Reducer< + State, DocumentAction, Environment + > { state, action, environment in + switch action { + case .action: + return .none + case let .stateDidChange(newValue): + state = newValue + return .none + } + } + ) + } +} + +struct DocumentView: View { + let file: FileDocumentConfiguration + let store: Store> + init( + file: FileDocumentConfiguration, + store: Store> + ) { + self.file = file + self.store = store + } + + var body: some View { + TimerView(store: store.scope(state: \.value, action: DocumentAction.action)) + .background( + WithViewStore(store) { viewStore in + Color.clear + .onChange(of: file.document.identifiedTimer) { viewStore.send(.stateDidChange($0)) } + .onChange(of: viewStore.state) { file.document.identifiedTimer = $0 } + } + ) + .padding() + .frame(minWidth: 250, minHeight: 100) + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/Info.plist b/Examples/ManyTimers/ManyTimers Documents/Info.plist new file mode 100644 index 0000000..503e09d --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDocumentTypes + + + CFBundleTypeRole + Viewer + LSItemContentTypes + + composable.effect.identifier-many.timers.timer + + NSUbiquitousDocumentUserActivityType + $(PRODUCT_BUNDLE_IDENTIFIER).example-document + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.json + + UTTypeDescription + Many Timers timer + UTTypeIdentifier + composable.effect.identifier-many.timers.timer + UTTypeTagSpecification + + public.filename-extension + + timer + + + + + + diff --git a/Examples/ManyTimers/ManyTimers Documents/ManyTimersDocument.swift b/Examples/ManyTimers/ManyTimers Documents/ManyTimersDocument.swift new file mode 100644 index 0000000..96ef71f --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/ManyTimersDocument.swift @@ -0,0 +1,44 @@ +import LonelyTimer +import SwiftUI +import UniformTypeIdentifiers +import ComposableArchitecture + +extension UTType { + static var timer: UTType { + UTType(exportedAs: "composable.effect.identifier-many.timers.timer", conformingTo: .json) + } +} + +typealias IdentifiedTimer = Identified + +// WARNING: It is bad practice to rely on `Codable` to serialize your documents. It is especially +// bad when versions from the past are editing new versions of the document, as it may lead to data +// losses. You should at least store a version identifier to prevent writes "from the past" if you +// still opt to use `Codable`. It is used directly here for presentation purposes. +struct ManyTimersDocument: FileDocument { + var identifiedTimer: IdentifiedTimer + + init( + timer: TimerState, id: UUID + ) { + self.identifiedTimer = .init(timer, id: id) + } + + static var readableContentTypes: [UTType] { [.timer] } + + init( + configuration: ReadConfiguration + ) throws { + guard let data = configuration.file.regularFileContents, + let identifiedTimer = try? JSONDecoder().decode(IdentifiedTimer.self, from: data) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.identifiedTimer = identifiedTimer + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let data = try JSONEncoder().encode(identifiedTimer) + return .init(regularFileWithContents: data) + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/ManyTimers_Documents.entitlements b/Examples/ManyTimers/ManyTimers Documents/ManyTimers_Documents.entitlements new file mode 100644 index 0000000..6d968ed --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/ManyTimers_Documents.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/Examples/ManyTimers/ManyTimers Documents/ManyTimers_DocumentsApp.swift b/Examples/ManyTimers/ManyTimers Documents/ManyTimers_DocumentsApp.swift new file mode 100644 index 0000000..4eb2579 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/ManyTimers_DocumentsApp.swift @@ -0,0 +1,63 @@ +import ComposableArchitecture +import ComposableEffectIdentifier +import LonelyTimer +import SwiftUI + +var stores: [AnyHashable: Store>] = + [:] +func _store( + for file: FileDocumentConfiguration +) + -> Store> +{ + if let store = stores[file.documentID] { + return store + } + let documentID = file.documentID + let store = Store( + initialState: file.document.identifiedTimer, + reducer: + identifiedTimerReducer // A `timerReducer` that can work transparently on `Identified` states + .document(file: file) // This synchronizes `file.document.identifiedTimer` with `State`'s value + .namespace(documentID), // This defines a namespace for the whole document. + // ^ Namespacing is mandatory for this document-based app. If you comment the line above and + // open several documents, you can see that if starting the timer in one document cancels any + // ongoing timer in other documents. With namespacing, all document are behaving correctly in + // isolation, an multiple timers can run at the same time and be cancelled independently. + environment: + .init( + mainQueue: .main + ) + ) + stores[documentID] = store + return store +} + +// Makes `timerReducer` work transparently on `IdentifierTimer`'s state. +let identifiedTimerReducer = Reducer { + timerReducer.run(&$0.value, $1, $2) +} + +@main +struct ManyTimers_DocumentApp: App { + var body: some Scene { + DocumentGroup( + newDocument: + ManyTimersDocument( + timer: .init( + name: "A Timer", + duration: TimeInterval(Int.random(in: 4...60)) + ), + id: UUID()) + ) { file in + DocumentView(file: file, store: _store(for: file)) + } + } +} + +extension FileDocumentConfiguration where Document == ManyTimersDocument { + var documentID: AnyHashable { + if let url = fileURL { return url } + return document.identifiedTimer.id + } +} diff --git a/Examples/ManyTimers/ManyTimers Documents/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/ManyTimers/ManyTimers Documents/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers Documents/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers--iOS--Info.plist b/Examples/ManyTimers/ManyTimers--iOS--Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers--iOS--Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Examples/ManyTimers/ManyTimers.xcodeproj/project.pbxproj b/Examples/ManyTimers/ManyTimers.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f9b7ed5 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers.xcodeproj/project.pbxproj @@ -0,0 +1,776 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + E91AF42E27A8E1FC00E4A4C4 /* ManyTimersApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF0927A8920400ADE1D0 /* ManyTimersApp.swift */; }; + E91AF42F27A8E20200E4A4C4 /* TimersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF0B27A8920400ADE1D0 /* TimersView.swift */; }; + E91AF43027A8E24500E4A4C4 /* ManyTimers_DocumentsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF1C27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift */; }; + E91AF43127A8E24500E4A4C4 /* ManyTimersDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF1E27A8929600ADE1D0 /* ManyTimersDocument.swift */; }; + E91AF43327A8E24D00E4A4C4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9C3DF0D27A8920500ADE1D0 /* Assets.xcassets */; }; + E91AF43527A8E75900E4A4C4 /* DocumentFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91AF43427A8E75900E4A4C4 /* DocumentFeature.swift */; }; + E91AF43627A8E75900E4A4C4 /* DocumentFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91AF43427A8E75900E4A4C4 /* DocumentFeature.swift */; }; + E9947BC027A8B33B00A8FF43 /* LonelyTimer in Frameworks */ = {isa = PBXBuildFile; productRef = E9947BBF27A8B33B00A8FF43 /* LonelyTimer */; }; + E9947BC227A8B34300A8FF43 /* LonelyTimer in Frameworks */ = {isa = PBXBuildFile; productRef = E9947BC127A8B34300A8FF43 /* LonelyTimer */; }; + E9947BC427A8B34A00A8FF43 /* LonelyTimer in Frameworks */ = {isa = PBXBuildFile; productRef = E9947BC327A8B34A00A8FF43 /* LonelyTimer */; }; + E9947BC627A8B35200A8FF43 /* LonelyTimer in Frameworks */ = {isa = PBXBuildFile; productRef = E9947BC527A8B35200A8FF43 /* LonelyTimer */; }; + E9C3DF0A27A8920400ADE1D0 /* ManyTimersApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF0927A8920400ADE1D0 /* ManyTimersApp.swift */; }; + E9C3DF0C27A8920400ADE1D0 /* TimersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF0B27A8920400ADE1D0 /* TimersView.swift */; }; + E9C3DF0E27A8920500ADE1D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9C3DF0D27A8920500ADE1D0 /* Assets.xcassets */; }; + E9C3DF1127A8920500ADE1D0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9C3DF1027A8920500ADE1D0 /* Preview Assets.xcassets */; }; + E9C3DF1D27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF1C27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift */; }; + E9C3DF1F27A8929600ADE1D0 /* ManyTimersDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3DF1E27A8929600ADE1D0 /* ManyTimersDocument.swift */; }; + E9C3DF2327A8929800ADE1D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9C3DF2227A8929800ADE1D0 /* Assets.xcassets */; }; + E9C3DF2627A8929800ADE1D0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E9C3DF2527A8929800ADE1D0 /* Preview Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + E91AF43427A8E75900E4A4C4 /* DocumentFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentFeature.swift; sourceTree = ""; }; + E9C3DEF227A891D300ADE1D0 /* ManyTimers.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ManyTimers.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E9C3DF0727A8920400ADE1D0 /* ManyTimers (macOS).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ManyTimers (macOS).app"; sourceTree = BUILT_PRODUCTS_DIR; }; + E9C3DF0927A8920400ADE1D0 /* ManyTimersApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManyTimersApp.swift; sourceTree = ""; }; + E9C3DF0B27A8920400ADE1D0 /* TimersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimersView.swift; sourceTree = ""; }; + E9C3DF0D27A8920500ADE1D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E9C3DF1027A8920500ADE1D0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + E9C3DF1227A8920500ADE1D0 /* ManyTimers.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ManyTimers.entitlements; sourceTree = ""; }; + E9C3DF1A27A8929600ADE1D0 /* ManyTimers Documents (macOS).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ManyTimers Documents (macOS).app"; sourceTree = BUILT_PRODUCTS_DIR; }; + E9C3DF1C27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManyTimers_DocumentsApp.swift; sourceTree = ""; }; + E9C3DF1E27A8929600ADE1D0 /* ManyTimersDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManyTimersDocument.swift; sourceTree = ""; }; + E9C3DF2227A8929800ADE1D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E9C3DF2527A8929800ADE1D0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + E9C3DF2727A8929800ADE1D0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E9C3DF2827A8929800ADE1D0 /* ManyTimers_Documents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ManyTimers_Documents.entitlements; sourceTree = ""; }; + E9C3DF3027A892BF00ADE1D0 /* ManyTimers Documents (iOS).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ManyTimers Documents (iOS).app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E9C3DEEF27A891D300ADE1D0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E9947BC027A8B33B00A8FF43 /* LonelyTimer in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF0427A8920400ADE1D0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E9947BC227A8B34300A8FF43 /* LonelyTimer in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF1727A8929600ADE1D0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E9947BC627A8B35200A8FF43 /* LonelyTimer in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF2D27A892BF00ADE1D0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E9947BC427A8B34A00A8FF43 /* LonelyTimer in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E9947BBE27A8B33B00A8FF43 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + E9C3DEE927A891D300ADE1D0 = { + isa = PBXGroup; + children = ( + E9C3DF0827A8920400ADE1D0 /* ManyTimers */, + E9C3DF1B27A8929600ADE1D0 /* ManyTimers Documents */, + E9C3DEF327A891D300ADE1D0 /* Products */, + E9947BBE27A8B33B00A8FF43 /* Frameworks */, + ); + sourceTree = ""; + }; + E9C3DEF327A891D300ADE1D0 /* Products */ = { + isa = PBXGroup; + children = ( + E9C3DEF227A891D300ADE1D0 /* ManyTimers.app */, + E9C3DF0727A8920400ADE1D0 /* ManyTimers (macOS).app */, + E9C3DF1A27A8929600ADE1D0 /* ManyTimers Documents (macOS).app */, + E9C3DF3027A892BF00ADE1D0 /* ManyTimers Documents (iOS).app */, + ); + name = Products; + sourceTree = ""; + }; + E9C3DF0827A8920400ADE1D0 /* ManyTimers */ = { + isa = PBXGroup; + children = ( + E9C3DF0927A8920400ADE1D0 /* ManyTimersApp.swift */, + E9C3DF0B27A8920400ADE1D0 /* TimersView.swift */, + E9C3DF0D27A8920500ADE1D0 /* Assets.xcassets */, + E9C3DF1227A8920500ADE1D0 /* ManyTimers.entitlements */, + E9C3DF0F27A8920500ADE1D0 /* Preview Content */, + ); + path = ManyTimers; + sourceTree = ""; + }; + E9C3DF0F27A8920500ADE1D0 /* Preview Content */ = { + isa = PBXGroup; + children = ( + E9C3DF1027A8920500ADE1D0 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + E9C3DF1B27A8929600ADE1D0 /* ManyTimers Documents */ = { + isa = PBXGroup; + children = ( + E91AF43427A8E75900E4A4C4 /* DocumentFeature.swift */, + E9C3DF1C27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift */, + E9C3DF1E27A8929600ADE1D0 /* ManyTimersDocument.swift */, + E9C3DF2227A8929800ADE1D0 /* Assets.xcassets */, + E9C3DF2727A8929800ADE1D0 /* Info.plist */, + E9C3DF2827A8929800ADE1D0 /* ManyTimers_Documents.entitlements */, + E9C3DF2427A8929800ADE1D0 /* Preview Content */, + ); + path = "ManyTimers Documents"; + sourceTree = ""; + }; + E9C3DF2427A8929800ADE1D0 /* Preview Content */ = { + isa = PBXGroup; + children = ( + E9C3DF2527A8929800ADE1D0 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E9C3DEF127A891D300ADE1D0 /* ManyTimers (iOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9C3DF0027A891D400ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers (iOS)" */; + buildPhases = ( + E9C3DEEE27A891D300ADE1D0 /* Sources */, + E9C3DEEF27A891D300ADE1D0 /* Frameworks */, + E9C3DEF027A891D300ADE1D0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ManyTimers (iOS)"; + packageProductDependencies = ( + E9947BBF27A8B33B00A8FF43 /* LonelyTimer */, + ); + productName = ManyTimers; + productReference = E9C3DEF227A891D300ADE1D0 /* ManyTimers.app */; + productType = "com.apple.product-type.application"; + }; + E9C3DF0627A8920400ADE1D0 /* ManyTimers (macOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9C3DF1327A8920500ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers (macOS)" */; + buildPhases = ( + E9C3DF0327A8920400ADE1D0 /* Sources */, + E9C3DF0427A8920400ADE1D0 /* Frameworks */, + E9C3DF0527A8920400ADE1D0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ManyTimers (macOS)"; + packageProductDependencies = ( + E9947BC127A8B34300A8FF43 /* LonelyTimer */, + ); + productName = ManyTimers; + productReference = E9C3DF0727A8920400ADE1D0 /* ManyTimers (macOS).app */; + productType = "com.apple.product-type.application"; + }; + E9C3DF1927A8929600ADE1D0 /* ManyTimers Documents (macOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9C3DF2B27A8929800ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers Documents (macOS)" */; + buildPhases = ( + E9C3DF1627A8929600ADE1D0 /* Sources */, + E9C3DF1727A8929600ADE1D0 /* Frameworks */, + E9C3DF1827A8929600ADE1D0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ManyTimers Documents (macOS)"; + packageProductDependencies = ( + E9947BC527A8B35200A8FF43 /* LonelyTimer */, + ); + productName = "ManyTimers Documents"; + productReference = E9C3DF1A27A8929600ADE1D0 /* ManyTimers Documents (macOS).app */; + productType = "com.apple.product-type.application"; + }; + E9C3DF2F27A892BF00ADE1D0 /* ManyTimers Documents (iOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9C3DF3E27A892C100ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers Documents (iOS)" */; + buildPhases = ( + E9C3DF2C27A892BF00ADE1D0 /* Sources */, + E9C3DF2D27A892BF00ADE1D0 /* Frameworks */, + E9C3DF2E27A892BF00ADE1D0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ManyTimers Documents (iOS)"; + packageProductDependencies = ( + E9947BC327A8B34A00A8FF43 /* LonelyTimer */, + ); + productName = "ManyTimers Documents"; + productReference = E9C3DF3027A892BF00ADE1D0 /* ManyTimers Documents (iOS).app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E9C3DEEA27A891D300ADE1D0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1330; + LastUpgradeCheck = 1330; + TargetAttributes = { + E9C3DEF127A891D300ADE1D0 = { + CreatedOnToolsVersion = 13.3; + }; + E9C3DF0627A8920400ADE1D0 = { + CreatedOnToolsVersion = 13.3; + }; + E9C3DF1927A8929600ADE1D0 = { + CreatedOnToolsVersion = 13.3; + }; + E9C3DF2F27A892BF00ADE1D0 = { + CreatedOnToolsVersion = 13.3; + }; + }; + }; + buildConfigurationList = E9C3DEED27A891D300ADE1D0 /* Build configuration list for PBXProject "ManyTimers" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E9C3DEE927A891D300ADE1D0; + productRefGroup = E9C3DEF327A891D300ADE1D0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E9C3DEF127A891D300ADE1D0 /* ManyTimers (iOS) */, + E9C3DF0627A8920400ADE1D0 /* ManyTimers (macOS) */, + E9C3DF2F27A892BF00ADE1D0 /* ManyTimers Documents (iOS) */, + E9C3DF1927A8929600ADE1D0 /* ManyTimers Documents (macOS) */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E9C3DEF027A891D300ADE1D0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E91AF43327A8E24D00E4A4C4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF0527A8920400ADE1D0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E9C3DF1127A8920500ADE1D0 /* Preview Assets.xcassets in Resources */, + E9C3DF0E27A8920500ADE1D0 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF1827A8929600ADE1D0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E9C3DF2627A8929800ADE1D0 /* Preview Assets.xcassets in Resources */, + E9C3DF2327A8929800ADE1D0 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF2E27A892BF00ADE1D0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E9C3DEEE27A891D300ADE1D0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E91AF42F27A8E20200E4A4C4 /* TimersView.swift in Sources */, + E91AF42E27A8E1FC00E4A4C4 /* ManyTimersApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF0327A8920400ADE1D0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E9C3DF0C27A8920400ADE1D0 /* TimersView.swift in Sources */, + E9C3DF0A27A8920400ADE1D0 /* ManyTimersApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF1627A8929600ADE1D0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E9C3DF1F27A8929600ADE1D0 /* ManyTimersDocument.swift in Sources */, + E9C3DF1D27A8929600ADE1D0 /* ManyTimers_DocumentsApp.swift in Sources */, + E91AF43627A8E75900E4A4C4 /* DocumentFeature.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9C3DF2C27A892BF00ADE1D0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E91AF43127A8E24500E4A4C4 /* ManyTimersDocument.swift in Sources */, + E91AF43027A8E24500E4A4C4 /* ManyTimers_DocumentsApp.swift in Sources */, + E91AF43527A8E75900E4A4C4 /* DocumentFeature.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + E9C3DEFE27A891D400ADE1D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E9C3DEFF27A891D400ADE1D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E9C3DF0127A891D400ADE1D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers--iOS--Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers"; + PRODUCT_NAME = ManyTimers; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E9C3DF0227A891D400ADE1D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers--iOS--Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers"; + PRODUCT_NAME = ManyTimers; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E9C3DF1427A8920500ADE1D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ManyTimers/ManyTimers.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + E9C3DF1527A8920500ADE1D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ManyTimers/ManyTimers.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + E9C3DF2927A8929800ADE1D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "ManyTimers Documents/ManyTimers_Documents.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers Documents/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers Documents/Info.plist"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers-Documents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + E9C3DF2A27A8929800ADE1D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "ManyTimers Documents/ManyTimers_Documents.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers Documents/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers Documents/Info.plist"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers-Documents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + E9C3DF3F27A892C100ADE1D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers Documents/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers Documents/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportsDocumentBrowser = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers-Documents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E9C3DF4027A892C100ADE1D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ManyTimers Documents/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "ManyTimers Documents/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportsDocumentBrowser = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "effect-identifier.ManyTimers-Documents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E9C3DEED27A891D300ADE1D0 /* Build configuration list for PBXProject "ManyTimers" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9C3DEFE27A891D400ADE1D0 /* Debug */, + E9C3DEFF27A891D400ADE1D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E9C3DF0027A891D400ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers (iOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9C3DF0127A891D400ADE1D0 /* Debug */, + E9C3DF0227A891D400ADE1D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E9C3DF1327A8920500ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers (macOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9C3DF1427A8920500ADE1D0 /* Debug */, + E9C3DF1527A8920500ADE1D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E9C3DF2B27A8929800ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers Documents (macOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9C3DF2927A8929800ADE1D0 /* Debug */, + E9C3DF2A27A8929800ADE1D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E9C3DF3E27A892C100ADE1D0 /* Build configuration list for PBXNativeTarget "ManyTimers Documents (iOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9C3DF3F27A892C100ADE1D0 /* Debug */, + E9C3DF4027A892C100ADE1D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + E9947BBF27A8B33B00A8FF43 /* LonelyTimer */ = { + isa = XCSwiftPackageProductDependency; + productName = LonelyTimer; + }; + E9947BC127A8B34300A8FF43 /* LonelyTimer */ = { + isa = XCSwiftPackageProductDependency; + productName = LonelyTimer; + }; + E9947BC327A8B34A00A8FF43 /* LonelyTimer */ = { + isa = XCSwiftPackageProductDependency; + productName = LonelyTimer; + }; + E9947BC527A8B35200A8FF43 /* LonelyTimer */ = { + isa = XCSwiftPackageProductDependency; + productName = LonelyTimer; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E9C3DEEA27A891D300ADE1D0 /* Project object */; +} diff --git a/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/ManyTimers/ManyTimers.xcodeproj/xcshareddata/xcschemes/ManyTimers (iOS).xcscheme b/Examples/ManyTimers/ManyTimers.xcodeproj/xcshareddata/xcschemes/ManyTimers (iOS).xcscheme new file mode 100644 index 0000000..91fdd89 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers.xcodeproj/xcshareddata/xcschemes/ManyTimers (iOS).xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/ManyTimers/ManyTimers/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/ManyTimers/ManyTimers/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ManyTimers/ManyTimers/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers/Assets.xcassets/Contents.json b/Examples/ManyTimers/ManyTimers/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers/ManyTimers.entitlements b/Examples/ManyTimers/ManyTimers/ManyTimers.entitlements new file mode 100644 index 0000000..f2ef3ae --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/ManyTimers.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Examples/ManyTimers/ManyTimers/ManyTimersApp.swift b/Examples/ManyTimers/ManyTimers/ManyTimersApp.swift new file mode 100644 index 0000000..7476578 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/ManyTimersApp.swift @@ -0,0 +1,28 @@ +import ComposableArchitecture +import LonelyTimer +import SwiftUI + +let timers = IdentifiedArrayOf( + uniqueElements: (0..<10).map { + Identified(TimerState(name: "Timer #\($0)", duration: TimeInterval(3 * $0 + 4)), id: $0) + } +) + +let store = Store( + initialState: .init( + timers: timers + ), + reducer: timersReducers, + environment: .init( + mainQueue: .main + ) +) + +@main +struct ManyTimersApp: App { + var body: some Scene { + WindowGroup { + TimersView(store: store) + } + } +} diff --git a/Examples/ManyTimers/ManyTimers/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/ManyTimers/ManyTimers/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ManyTimers/ManyTimers/TimersView.swift b/Examples/ManyTimers/ManyTimers/TimersView.swift new file mode 100644 index 0000000..93de6b2 --- /dev/null +++ b/Examples/ManyTimers/ManyTimers/TimersView.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import ComposableEffectIdentifier +import LonelyTimer +import SwiftUI + +typealias IdentifiedTimer = Identified + +struct TimersState { + var timers: IdentifiedArrayOf +} + +enum TimersAction { + case timers(Int, TimerAction) +} + +struct TimersEnvironment { + let mainQueue: AnySchedulerOf +} + +let timersReducers = Reducer + .combine( + timerReducer + .forEachNamespaced( + state: \.timers, + action: /TimersAction.timers, + environment: { .init(mainQueue: $0.mainQueue) } + ), + Reducer { + state, action, environment in + return .none + } + ) + +struct TimersView: View { + let store: Store + + var body: some View { + #if os(macOS) + contentView + #else + NavigationView { + contentView + } + #endif + } + + var contentView: some View { + ScrollView { + VStack { + ForEachStore(store.scope(state: \.timers, action: TimersAction.timers)) { store in + TimerView(store: store.scope(state: \.value)) + .padding(.horizontal) + } + } + .padding(.top) + } + .navigationTitle("Many Timers") + } +} + +struct TimersView_Previews: PreviewProvider { + static let timers = IdentifiedArrayOf( + uniqueElements: (0..<10).map { + Identified(TimerState(name: "Timer #\($0)", duration: TimeInterval(3 * $0 + 4)), id: $0) + } + ) + + static var previews: some View { + TimersView( + store: .init( + initialState: + .init(timers: timers), + reducer: timersReducers, + environment: .init( + mainQueue: .main + ) + ) + ) + } +} diff --git a/Examples/Package.swift b/Examples/Package.swift new file mode 100644 index 0000000..6ae7c03 --- /dev/null +++ b/Examples/Package.swift @@ -0,0 +1,7 @@ +// swift-tools-version:5.3 + +import PackageDescription + +let package = Package( + name: "Example", + products: []) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..99acefb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Thomas Grapperon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66ede1a --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +format: + swift format \ + --ignore-unparsable-files \ + --in-place \ + --recursive \ + ./Examples ./Package.swift ./Sources ./Tests + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0e7572f --- /dev/null +++ b/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.4 + +import PackageDescription + +let package = Package( + name: "composable-effect-identifier", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "ComposableEffectIdentifier", + targets: ["ComposableEffectIdentifier"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.0.1") + ], + targets: [ + .target( + name: "ComposableEffectIdentifier", + dependencies: [ + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture") + ]), + .testTarget( + name: "ComposableEffectIdentifierTests", + dependencies: ["ComposableEffectIdentifier"]), + ] + ) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d18101 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ComposableEffectIdentifier + + diff --git a/Sources/ComposableEffectIdentifier/EffectID.swift b/Sources/ComposableEffectIdentifier/EffectID.swift new file mode 100644 index 0000000..a5aea35 --- /dev/null +++ b/Sources/ComposableEffectIdentifier/EffectID.swift @@ -0,0 +1,283 @@ +import Foundation + +#if DEBUG + import os +#endif +/// A property wrapper that generates a hashable value suitable to identify ``Effect``'s. +/// +/// These identifiers can be bound to the root ``Store`` executing the ``Reducer`` that produces +/// the ``Effect``'s. This can be conveniently exploited in document-based apps for example, where +/// you may have multiple documents and by extension, multiple root ``Store``'s coexisting in the +/// same process. +/// +/// In order to bind the identifiers to a store, you need to namespace their root reducer using the +/// ``Reducer.namespace()`` methods. You don't need to declare a namespace if you're using only one +/// instance of a root store in your application. +/// +/// The value returned when accessing a property wrapped with this type is an opaque hashable value +/// that is constant across ``Reducer``'s runs, and which can be used to identify long-running or +/// cancellable effects: +/// ``` swift +/// Reducer { state, action, environment in +/// @EffectID var timerID +/// switch action { +/// case .onAppear: +/// return +/// .timer(id: timerID, every: 1, on: environment.mainQueue) +/// .map { _ in Action.timerTick } +/// case .onDisappear: +/// return .cancel(id: timerID) +/// case .timerTick: +/// state.ticks += 1 +/// return .none +/// } +/// } +/// ``` +/// +/// These property wrappers can be used without arguments, but you can also provide some contextual +/// data to parametrize them: +/// +/// ``` swift +/// Reducer { state, action, environment in +/// @EffectID var timerID = state.timerID +/// … +/// } +/// ``` +/// +/// If you want to share an ``EffectID`` across reducers, you should define it as a property in any +/// shared type. You can even use the ``EffectID`` type itself to declare a shared identifier: +/// ```swift +/// extension EffectID { +/// @EffectID public static var sharedID +/// } +/// // And access it as inside reducers as: +/// EffectID.sharedID +/// ``` +/// +/// - Warning: This property wrapper is context-specific. Two identifiers defined in different +/// locations are always different, even if they share the same user data: +/// ``` swift +/// Reducer { _, _, _ in +/// @EffectID var id1 = "A" +/// @EffectID var id2 = "A" +/// // => id1 != id2 +/// } +/// ``` +/// Two identifiers are equal iff they are defined at the same place, and with the same contextual +/// data (if any). +/// +/// - Warning: When using namespaces, this property wrapper should only be used within some +/// ``Reducer``'s context, that is, when reducing some action. Failing to do so when using +/// namespaces raises a runtime warning when comparing two identifiers. The value can be defined in +/// any spot allowing property wrappers, but it should only be accessed from some ``Reducer`` +/// execution block. +/// When only one, non-namespaced, store is used, these properties can be defined and accessed +/// everywhere. +@propertyWrapper +public struct EffectID: Hashable { + private static var currentNamespace: EffectNamespace { + if Thread.isMainThread { + return mainThreadCurrentEffectIDNamespace + } else { + return currentEffectIDNamespaceLock.sync { + currentEffectIDNamespace + } + } + } + + private let value: Value + + public var wrappedValue: Value { + value.with(namespace: Self.currentNamespace) + } + + /// Initialize an ``EffectID`` carrying some user-defined payload. + /// + /// The ``EffectID.Value`` returned when accessing this property is as unique and stable as the + /// value provided. You can assign a value when you want to parametrize the identifier with some + /// `State`-dependant value for example: + /// ```swift + /// let appReducer = Reducer { state, action, env in + /// @EffectID var timerId = state.timerID + /// + /// switch action { + /// case .startButtonTapped: + /// return Effect.timer(id: timerId, every: 1, on: env.mainQueue) + /// .map { _ in .timerTicked } + /// + /// case .stopButtonTapped: + /// return .cancel(id: timerId) + /// + /// case let .timerTicked: + /// state.count += 1 + /// return .none + /// } + /// ``` + /// - Warning: Two @``EffectID``'s defined at different places will always be different, even if + /// they share the same user-defined value: + /// ```swift + /// @EffectID var id1 = "A" + /// @EffectID var id2 = "A" + /// // => id1 != id2 + /// ``` + public init( + wrappedValue: UserData, + file: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) where UserData: Hashable { + value = .init( + userData: wrappedValue, + file: file, + line: line, + column: column + ) + } + + /// Initialize an ``EffectID`` that returns a unique and stable ``EffectID.Value`` when accessed. + /// + /// You don't need to provide any value: + /// ```swift + /// let appReducer = Reducer { state, action, env in + /// @EffectID var timerId + /// + /// switch action { + /// case .startButtonTapped: + /// return Effect.timer(id: timerId, every: 1, on: env.mainQueue) + /// .map { _ in .timerTicked } + /// + /// case .stopButtonTapped: + /// return .cancel(id: timerId) + /// + /// case let .timerTicked: + /// state.count += 1 + /// return .none + /// } + public init( + file: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) { + value = .init( + file: file, + line: line, + column: column + ) + } +} + +extension EffectID { + public struct Value: Hashable { + var namespace: AnyHashable? + let userData: AnyHashable? + let file: String + let line: UInt + let column: UInt + + internal init( + effectIDNamespace: AnyHashable? = nil, + userData: AnyHashable? = nil, + file: StaticString, + line: UInt, + column: UInt + ) { + self.namespace = effectIDNamespace + self.userData = userData + self.file = "\(file)" + self.line = line + self.column = column + } + + func with(namespace: AnyHashable?) -> Self { + var identifier = self + identifier.namespace = namespace + return identifier + } + + public static func == (lhs: Value, rhs: Value) -> Bool { + #if DEBUG + // Don't generate warning for SwiftUI Previews + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" { + if lhs.namespace == nil || rhs.namespace == nil { + func issueWarningIfNeeded(id: Value) { + guard id.namespace == nil else { return } + + let namespace: AnyHashable? + if Thread.isMainThread { + namespace = mainThreadCurrentEffectIDNamespace + } else { + namespace = currentEffectIDNamespaceLock.sync { + currentEffectIDNamespace + } + } + // To avoid runtime warnings in innocuous single-store cases, we only warn the user + // when some namespace is defined, but the `EffectID.Value` is bearing none, meaning + // the property wrapper was accessed outside of a `Reducer`'s scope (as we are probably + // equating this value when trying to cancel an effect). + // We don't warn if no namespace is defined, as it's only required in specific cases + // like document-based apps. For this reason, most users can use the `EffectID` + // property wrapper directly, without namespacing their `Reducer`s. + guard namespace != nil else { return } + + let warningID = WarningID(file: id.file, line: id.line, column: id.column) + guard + Self.issuedWarningsLock.sync(work: { + guard !issuedWarnings.contains(warningID) else { return false } + issuedWarnings.insert(warningID) + return true + }) + else { return } + os_log( + .fault, dso: rw.dso, log: rw.log, + """ + An `@EffectID` declared at "%@:%d" was accessed outside of a reducer's context. + + `@EffectID` identifiers should only be accessed by `Reducer`'s while they're \ + receiving an action. + """, + "\(id.file)", + id.line + ) + } + issueWarningIfNeeded(id: lhs) + issueWarningIfNeeded(id: rhs) + } + } + #endif + guard + lhs.file == rhs.file, + lhs.line == rhs.line, + lhs.column == rhs.column, + lhs.namespace == rhs.namespace, + lhs.userData == rhs.userData + else { + return false + } + return true + } + + #if DEBUG + static var issuedWarningsLock = NSRecursiveLock() + static var issuedWarnings = Set() + struct WarningID: Hashable { + let file: String + let line: UInt + let column: UInt + } + #endif + } +} + +let currentEffectIDNamespaceLock = NSRecursiveLock() +var currentEffectIDNamespace = EffectNamespace() +var mainThreadCurrentEffectIDNamespace = EffectNamespace() + +struct EffectNamespace: Hashable { + var components: [AnyHashable] = [] + mutating func push(_ component: AnyHashable) { + components.append(component) + } + mutating func pop() { + components.removeLast() + } +} diff --git a/Sources/ComposableEffectIdentifier/Identified+Namespace.swift b/Sources/ComposableEffectIdentifier/Identified+Namespace.swift new file mode 100644 index 0000000..82895d5 --- /dev/null +++ b/Sources/ComposableEffectIdentifier/Identified+Namespace.swift @@ -0,0 +1,110 @@ +import ComposableArchitecture + +#if DEBUG + import os +#endif + +extension Reducer { + /// A version of ``pullback(state:action:environment:)`` that transforms a reducer that works on + /// an element into one namespaced reducer that works on an identified array of elements wrapped + /// in ``Identified``s. + /// + /// The wrapper's ``Identified/id`` is used to namespace the element's reducer. + /// + /// ```swift + /// // Global domain that holds a collection of local domains: + /// struct AppState { var todos: IdentifiedArrayOf } + /// enum AppAction { case todo(id: Todo.ID, action: TodoAction) } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } + /// + /// // A reducer that works on a local domain: + /// let todoReducer = Reducer { ... } + /// + /// // Pullback the local todo reducer so that it works on all of the app domain: + /// let appReducer = Reducer.combine( + /// todoReducer.forEach( + /// state: \.todos, + /// action: /AppAction.todo(id:action:), + /// environment: { _ in TodoEnvironment() } + /// ), + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// ``` + /// + /// Take care when combining ``forEach(state:action:environment:file:line:)-gvte`` reducers into + /// parent domains, as order matters. Always combine + /// ``forEach(state:action:environment:file:line:)-gvte`` reducers _before_ parent reducers that + /// can modify the collection. + /// + /// - Parameters: + /// - toLocalState: A key path that can get/set a collection of `State` elements inside + /// `GlobalState`. + /// - toLocalAction: A case path that can extract/embed `(Collection.Index, Action)` from + /// `GlobalAction`. + /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. + /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. + public func forEachNamespaced( + state toLocalState: WritableKeyPath< + GlobalState, IdentifiedArrayOf> + >, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, + file: StaticString = #fileID, + line: UInt = #line + ) -> Reducer { + .init { globalState, globalAction, globalEnvironment in + guard let (id, localAction) = toLocalAction.extract(from: globalAction) else { return .none } + if globalState[keyPath: toLocalState][id: id] == nil { + #if DEBUG + os_log( + .fault, dso: rw.dso, log: rw.log, + """ + A "forEach" reducer at "%@:%d" received an action when state contained no element with \ + that id. … + + Action: + %@ + ID: + %@ + + This is generally considered an application logic error, and can happen for a few \ + reasons: + + • This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this id when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended id. + + • An in-flight effect emitted this action while state contained no element at this id. \ + It may be perfectly reasonable to ignore this action, but you also may want to cancel \ + the effect it originated from when removing an element from the identified array, \ + especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this id. \ + To fix this make sure that actions for this reducer can only be sent to a view store \ + when its state contains an element at this id. In SwiftUI applications, use \ + "ForEachStore". + """, + "\(file)", + line, + debugCaseOutput(localAction), + "\(id)" + ) + #endif + return .none + } + return + self + .namespace(id) + .run( + &globalState[keyPath: toLocalState][id: id]!.value, + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map { toLocalAction.embed((id, $0)) } + } + } +} diff --git a/Sources/ComposableEffectIdentifier/Internal/Debug.swift b/Sources/ComposableEffectIdentifier/Internal/Debug.swift new file mode 100644 index 0000000..2a65e27 --- /dev/null +++ b/Sources/ComposableEffectIdentifier/Internal/Debug.swift @@ -0,0 +1,30 @@ +// Imported verbatim from https://github.com/pointfreeco/swift-composable-architecture v0.33.1 +func debugCaseOutput(_ value: Any) -> String { + func debugCaseOutputHelp(_ value: Any) -> String { + let mirror = Mirror(reflecting: value) + switch mirror.displayStyle { + case .enum: + guard let child = mirror.children.first else { + let childOutput = "\(value)" + return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" + } + let childOutput = debugCaseOutputHelp(child.value) + return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" + case .tuple: + return mirror.children.map { label, value in + let childOutput = debugCaseOutputHelp(value) + return + "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" + } + .joined(separator: ", ") + default: + return "" + } + } + + return "\(type(of: value))\(debugCaseOutputHelp(value))" +} + +private func isUnlabeledArgument(_ label: String) -> Bool { + label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil +} diff --git a/Sources/ComposableEffectIdentifier/Internal/Locking.swift b/Sources/ComposableEffectIdentifier/Internal/Locking.swift new file mode 100644 index 0000000..b9dae55 --- /dev/null +++ b/Sources/ComposableEffectIdentifier/Internal/Locking.swift @@ -0,0 +1,10 @@ +import Foundation + +extension NSRecursiveLock { + @inlinable @discardableResult + func sync(work: () -> R) -> R { + self.lock() + defer { self.unlock() } + return work() + } +} diff --git a/Sources/ComposableEffectIdentifier/Internal/RuntimeWarnings.swift b/Sources/ComposableEffectIdentifier/Internal/RuntimeWarnings.swift new file mode 100644 index 0000000..920365a --- /dev/null +++ b/Sources/ComposableEffectIdentifier/Internal/RuntimeWarnings.swift @@ -0,0 +1,27 @@ +// File imported from https://github.com/pointfreeco/swift-composable-architecture v0.33.1 +#if DEBUG + import os + + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + let rw = ( + dso: { () -> UnsafeMutableRawPointer in + let count = _dyld_image_count() + for i in 0..(_ namespace: Namespace) -> Self { + Reducer { state, action, environment in + namespacedEffect( + namespace: namespace, + state: &state, + action: action, + environment: environment + ) + } + } + + /// Define an `EffectID` namespace for this reducer and all its descendents using a constant + /// identifier extracted from `State`. + /// + /// Namespaces allow to discern effects emanating from different instances of the same `Store` + /// type running at the same time in the same process. This can happen with document-based apps + /// where each document is supported by distinct instances of `Store`, each one without knowledge + /// of the other ones. Without namespacing, the `Store` for the document "A" may cancel some + /// ongoing `Effect`s from the document "B" `Store`. + /// + /// You ideally define a namespace for the `Reducer` of the root store, using some `Hashable` + /// value that is unique to the `Store` instance, like a document identifier. + /// + /// - Parameter id: some `Hashable` value derived from `State` that is constant and unique to the + /// store using this reducer. You can use any function, or a `KeyPath`. + /// - Warning: The identifier should be constant for the lifetime of the store. Otherwise, + /// `Effect`s may fail to be property cancelled. + /// - Returns: A reducer that defines a namespace for identifiers defined with the @``EffectID`` + /// property wrapper. + public func namespace(_ id: @escaping (State) -> ID) -> Self { + Reducer { state, action, environment in + namespacedEffect( + namespace: id(state), + state: &state, + action: action, + environment: environment + ) + } + } + + /// Define an `EffectID` namespace for this reducer and all its descendents using a constant + /// identifier extracted from the `Environment`. + /// + /// Namespaces allow to discern effects emanating from different instances of the same `Store` + /// type running at the same time in the same process. This can happen with document-based apps + /// where each document is supported by distinct instances of `Store`, each one without knowledge + /// of the other ones. Without namespacing, the `Store` for the document "A" may cancel some + /// ongoing `Effect`s from the document "B" `Store`. + /// + /// You ideally define a namespace for the `Reducer` of the root store, using some `Hashable` + /// value that is unique to the `Store` instance, like a document identifier. + /// + /// - Parameter id: some `Hashable` value derived from `Environment` that is constant and unique + /// to the store using this reducer. You can use any function, or a `KeyPath`. + /// - Warning: The identifier should be constant for the lifetime of the store. Otherwise, + /// `Effect`s may fail to be property cancelled. + /// - Returns: A reducer that defines a namespace for identifiers defined with the @``EffectID`` + /// property wrapper. + public func namespace(_ id: @escaping (Environment) -> ID) -> Self { + Reducer { state, action, environment in + namespacedEffect( + namespace: id(environment), + state: &state, + action: action, + environment: environment + ) + } + } + + func namespacedEffect( + namespace: Namespace, + state: inout State, + action: Action, + environment: Environment + ) -> Effect { + if Thread.isMainThread { + mainThreadCurrentEffectIDNamespace.push(namespace) + defer { mainThreadCurrentEffectIDNamespace.pop() } + return self.run(&state, action, environment) + } else { + return currentEffectIDNamespaceLock.sync { + currentEffectIDNamespace.push(namespace) + defer { currentEffectIDNamespace.pop() } + return self.run(&state, action, environment) + } + } + } +} + +extension Reducer { + /// A version of ``pullback(state:action:environment:)`` that transforms a reducer that works on + /// an element into one that works on an identified array of elements. + /// + /// ```swift + /// // Global domain that holds a collection of local domains: + /// struct AppState { var todos: IdentifiedArrayOf } + /// enum AppAction { case todo(id: Todo.ID, action: TodoAction) } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } + /// + /// // A reducer that works on a local domain: + /// let todoReducer = Reducer { ... } + /// + /// // Pullback the local todo reducer so that it works on all of the app domain: + /// let appReducer = Reducer.combine( + /// todoReducer.forEach( + /// state: \.todos, + /// action: /AppAction.todo(id:action:), + /// environment: { _ in TodoEnvironment() } + /// ), + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// ``` + /// + /// Take care when combining ``forEach(state:action:environment:file:line:)-gvte`` reducers into + /// parent domains, as order matters. Always combine + /// ``forEach(state:action:environment:file:line:)-gvte`` reducers _before_ parent reducers that + /// can modify the collection. + /// + /// - Parameters: + /// - toLocalState: A key path that can get/set a collection of `State` elements inside + /// `GlobalState`. + /// - toLocalAction: A case path that can extract/embed `(Collection.Index, Action)` from + /// `GlobalAction`. + /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. + /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. + public func forEachNamespaced( + state toLocalState: WritableKeyPath>, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, + file: StaticString = #fileID, + line: UInt = #line + ) -> Reducer { + .init { globalState, globalAction, globalEnvironment in + guard let (id, localAction) = toLocalAction.extract(from: globalAction) else { return .none } + if globalState[keyPath: toLocalState][id: id] == nil { + #if DEBUG + os_log( + .fault, dso: rw.dso, log: rw.log, + """ + A "forEach" reducer at "%@:%d" received an action when state contained no element with \ + that id. … + + Action: + %@ + ID: + %@ + + This is generally considered an application logic error, and can happen for a few \ + reasons: + + • This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this id when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended id. + + • An in-flight effect emitted this action while state contained no element at this id. \ + It may be perfectly reasonable to ignore this action, but you also may want to cancel \ + the effect it originated from when removing an element from the identified array, \ + especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this id. \ + To fix this make sure that actions for this reducer can only be sent to a view store \ + when its state contains an element at this id. In SwiftUI applications, use \ + "ForEachStore". + """, + "\(file)", + line, + debugCaseOutput(localAction), + "\(id)" + ) + #endif + return .none + } + return + self + .namespace(id) + .run( + &globalState[keyPath: toLocalState][id: id]!, + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map { toLocalAction.embed((id, $0)) } + } + } + + /// A version of ``pullback(state:action:environment:)`` that transforms a reducer that works on + /// an element into one namespaced reducer that works on a dictionary of element values. + /// + /// The dictionary's key is used to namespace the element's reducer. + /// + /// Take care when combining ``forEachNamespaced(state:action:environment:file:line:)`` + /// reducers into parent domains, as order matters. Always combine + /// ``forEachNamespaced(state:action:environment:file:line:)`` reducers _before_ parent reducers + /// that can modify the dictionary. + /// + /// - Parameters: + /// - toLocalState: A key path that can get/set a dictionary of `State` values inside + /// `GlobalState`. + /// - toLocalAction: A case path that can extract/embed `(Key, Action)` from `GlobalAction`. + /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. + /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. + public func forEachNamespaced( + state toLocalState: WritableKeyPath, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, + file: StaticString = #fileID, + line: UInt = #line + ) -> Reducer { + .init { globalState, globalAction, globalEnvironment in + guard let (key, localAction) = toLocalAction.extract(from: globalAction) else { return .none } + + if globalState[keyPath: toLocalState][key] == nil { + #if DEBUG + os_log( + .fault, dso: rw.dso, log: rw.log, + """ + A "forEach" reducer at "%@:%d" received an action when state contained no value at \ + that key. … + + Action: + %@ + Key: + %@ + + This is generally considered an application logic error, and can happen for a few \ + reasons: + + • This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this key when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended key. + + • An in-flight effect emitted this action while state contained no element at this \ + key. It may be perfectly reasonable to ignore this action, but you also may want to \ + cancel the effect it originated from when removing a value from the dictionary, \ + especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this \ + key. To fix this make sure that actions for this reducer can only be sent to a view \ + store when its state contains an element at this key. + """, + "\(file)", + line, + debugCaseOutput(localAction), + "\(key)" + ) + #endif + return .none + } + return self + .namespace(key) + .run( + &globalState[keyPath: toLocalState][key]!, + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map { toLocalAction.embed((key, $0)) } + } + } +} + + + + diff --git a/Tests/ComposableEffectIdentifierTests/EffectIDTests.swift b/Tests/ComposableEffectIdentifierTests/EffectIDTests.swift new file mode 100644 index 0000000..16b8437 --- /dev/null +++ b/Tests/ComposableEffectIdentifierTests/EffectIDTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import EffectIdentifier + +final class EffectIDTests: XCTestCase { + @EffectID var id1 + @EffectID var id2_1 = 1 + @EffectID var id2_2 = 1 + + func testEffectIdentifierEquality() { + XCTAssertEqual(id1, id1) + XCTAssertEqual(id2_1, id2_1) + XCTAssertEqual(id2_2, id2_2) + + XCTAssertNotEqual(id1, id2_1) + XCTAssertNotEqual(id1, id2_2) + XCTAssertNotEqual(id2_1, id2_2) + } + + #if compiler(>=5.5) + func testLocalEffectIdentifierEquality() { + @EffectID var id1 + @EffectID var id2_1 = 1 + @EffectID var id2_2 = 1 + + XCTAssertEqual(id1, id1) + XCTAssertEqual(id2_1, id2_1) + XCTAssertEqual(id2_2, id2_2) + + XCTAssertNotEqual(id1, id2_1) + XCTAssertNotEqual(id1, id2_2) + XCTAssertNotEqual(id2_1, id2_2) + + XCTAssertNotEqual(id1, self.id1) + } + #endif +} diff --git a/Tests/ComposableEffectIdentifierTests/Reducer+NamespaceTests.swift b/Tests/ComposableEffectIdentifierTests/Reducer+NamespaceTests.swift new file mode 100644 index 0000000..8bc2a21 --- /dev/null +++ b/Tests/ComposableEffectIdentifierTests/Reducer+NamespaceTests.swift @@ -0,0 +1,176 @@ +import ComposableArchitecture +import EffectIdentifier +import XCTest + +final class ReducerNamespaceTests: XCTestCase { + struct State: Equatable { + var id: Int = 0 + var count: Int = 0 + } + enum Action { + case start + case stop + case tick + } + struct Environment { + var main: AnySchedulerOf + var documentID: () -> String = { "" } + } + + let reducer = Reducer { + state, action, environment in + @EffectID var timerID + switch action { + case .start: + return Effect.timer(id: timerID, every: .seconds(1), on: environment.main) + .map { _ in .tick } + case .stop: + return .cancel(id: timerID) + case .tick: + state.count += 1 + return .none + } + } + + // This test proves the cancellation collision which can happen when not using namespaces + func testStoresImproperlyNamespaced() { + let scheduler = DispatchQueue.test + let store1 = TestStore( + initialState: .init(), + reducer: reducer, + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + let store2 = TestStore( + initialState: .init(), + reducer: reducer, + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + + store1.send(.start) + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 1 + } + + // The following should stop the ongoing effect from store1, and the test should pass, + // as no effect will remain unaccounted by the end of the test. + store2.send(.stop) + } + + func testStoreNamespacing() { + let scheduler = DispatchQueue.test + let store1 = TestStore( + initialState: .init(), + reducer: reducer.namespace(1), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + let store2 = TestStore( + initialState: .init(), + reducer: reducer.namespace(2), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + + store1.send(.start) + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 1 + } + + // This should be effect-less (!): + store2.send(.stop) + + // store1 should still be ticking: + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 2 + } + + store1.send(.stop) + } + + func testStoreNamespacingFromState() { + let scheduler = DispatchQueue.test + let store1 = TestStore( + initialState: .init(id: 1), + reducer: reducer.namespace(\.id), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + let store2 = TestStore( + initialState: .init(id: 2), + reducer: reducer.namespace(\.id), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { fatalError() } + ) + ) + + store1.send(.start) + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 1 + } + + // This should be effect-less (!): + store2.send(.stop) + + // store1 should still be ticking: + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 2 + } + + store1.send(.stop) + } + + func testStoreNamespacingFromEnvironment() { + let scheduler = DispatchQueue.test + let store1 = TestStore( + initialState: .init(), + reducer: reducer.namespace({ $0.documentID() }), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { "1" } + ) + ) + let store2 = TestStore( + initialState: .init(), + reducer: reducer.namespace({ $0.documentID() }), + environment: .init( + main: scheduler.eraseToAnyScheduler(), + documentID: { "2" } + ) + ) + + store1.send(.start) + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 1 + } + + // This should be effect-less (!): + store2.send(.stop) + + // store1 should still be ticking: + scheduler.advance(by: 1) + store1.receive(.tick) { + $0.count = 2 + } + + store1.send(.stop) + } +}