From 18cee0dbc63164a3706c18c4e14a4216123b5858 Mon Sep 17 00:00:00 2001
From: Thomas Grapperon <35562418+tgrapperon@users.noreply.github.com>
Date: Tue, 1 Feb 2022 01:30:26 -0300
Subject: [PATCH] Initial commit
---
.gitattributes | 2 +
.github/workflows/documentation.yml | 26 +
.github/workflows/format.yml | 27 +
.github/workflows/swift.yml | 19 +
.gitignore | 7 +
.../xcshareddata/IDEWorkspaceChecks.plist | 8 +
.../contents.xcworkspacedata | 17 +
.../xcshareddata/IDEWorkspaceChecks.plist | 8 +
.../xcshareddata/swiftpm/Package.resolved | 70 ++
Examples/LonelyTimer/.gitignore | 9 +
Examples/LonelyTimer/Package.swift | 35 +
.../Sources/LonelyTimer/LonelyTimer.swift | 226 +++++
.../AccentColor.colorset/Contents.json | 11 +
.../AppIcon.appiconset/Contents.json | 98 +++
.../Assets.xcassets/Contents.json | 6 +
.../DocumentFeature.swift | 59 ++
.../ManyTimers Documents/Info.plist | 39 +
.../ManyTimersDocument.swift | 44 +
.../ManyTimers_Documents.entitlements | 10 +
.../ManyTimers_DocumentsApp.swift | 63 ++
.../Preview Assets.xcassets/Contents.json | 6 +
.../ManyTimers/ManyTimers--iOS--Info.plist | 5 +
.../ManyTimers.xcodeproj/project.pbxproj | 776 ++++++++++++++++++
.../contents.xcworkspacedata | 7 +
.../xcshareddata/IDEWorkspaceChecks.plist | 8 +
.../xcschemes/ManyTimers (iOS).xcscheme | 78 ++
.../AccentColor.colorset/Contents.json | 11 +
.../AppIcon.appiconset/Contents.json | 58 ++
.../ManyTimers/Assets.xcassets/Contents.json | 6 +
.../ManyTimers/ManyTimers.entitlements | 10 +
.../ManyTimers/ManyTimers/ManyTimersApp.swift | 28 +
.../Preview Assets.xcassets/Contents.json | 6 +
.../ManyTimers/ManyTimers/TimersView.swift | 80 ++
Examples/Package.swift | 7 +
LICENSE | 21 +
Makefile | 7 +
Package.swift | 34 +
README.md | 3 +
.../ComposableEffectIdentifier/EffectID.swift | 283 +++++++
.../Identified+Namespace.swift | 110 +++
.../Internal/Debug.swift | 30 +
.../Internal/Locking.swift | 10 +
.../Internal/RuntimeWarnings.swift | 27 +
.../Reducer+Namespace.swift | 291 +++++++
.../EffectIDTests.swift | 37 +
.../Reducer+NamespaceTests.swift | 176 ++++
46 files changed, 2899 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .github/workflows/documentation.yml
create mode 100644 .github/workflows/format.yml
create mode 100644 .github/workflows/swift.yml
create mode 100644 .gitignore
create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
create mode 100644 ComposableEffectIdentifier.xcworkspace/contents.xcworkspacedata
create mode 100644 ComposableEffectIdentifier.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
create mode 100644 ComposableEffectIdentifier.xcworkspace/xcshareddata/swiftpm/Package.resolved
create mode 100644 Examples/LonelyTimer/.gitignore
create mode 100644 Examples/LonelyTimer/Package.swift
create mode 100644 Examples/LonelyTimer/Sources/LonelyTimer/LonelyTimer.swift
create mode 100644 Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AccentColor.colorset/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/AppIcon.appiconset/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers Documents/Assets.xcassets/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers Documents/DocumentFeature.swift
create mode 100644 Examples/ManyTimers/ManyTimers Documents/Info.plist
create mode 100644 Examples/ManyTimers/ManyTimers Documents/ManyTimersDocument.swift
create mode 100644 Examples/ManyTimers/ManyTimers Documents/ManyTimers_Documents.entitlements
create mode 100644 Examples/ManyTimers/ManyTimers Documents/ManyTimers_DocumentsApp.swift
create mode 100644 Examples/ManyTimers/ManyTimers Documents/Preview Content/Preview Assets.xcassets/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers--iOS--Info.plist
create mode 100644 Examples/ManyTimers/ManyTimers.xcodeproj/project.pbxproj
create mode 100644 Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/contents.xcworkspacedata
create mode 100644 Examples/ManyTimers/ManyTimers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
create mode 100644 Examples/ManyTimers/ManyTimers.xcodeproj/xcshareddata/xcschemes/ManyTimers (iOS).xcscheme
create mode 100644 Examples/ManyTimers/ManyTimers/Assets.xcassets/AccentColor.colorset/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers/Assets.xcassets/AppIcon.appiconset/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers/Assets.xcassets/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers/ManyTimers.entitlements
create mode 100644 Examples/ManyTimers/ManyTimers/ManyTimersApp.swift
create mode 100644 Examples/ManyTimers/ManyTimers/Preview Content/Preview Assets.xcassets/Contents.json
create mode 100644 Examples/ManyTimers/ManyTimers/TimersView.swift
create mode 100644 Examples/Package.swift
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 Package.swift
create mode 100644 README.md
create mode 100644 Sources/ComposableEffectIdentifier/EffectID.swift
create mode 100644 Sources/ComposableEffectIdentifier/Identified+Namespace.swift
create mode 100644 Sources/ComposableEffectIdentifier/Internal/Debug.swift
create mode 100644 Sources/ComposableEffectIdentifier/Internal/Locking.swift
create mode 100644 Sources/ComposableEffectIdentifier/Internal/RuntimeWarnings.swift
create mode 100644 Sources/ComposableEffectIdentifier/Reducer+Namespace.swift
create mode 100644 Tests/ComposableEffectIdentifierTests/EffectIDTests.swift
create mode 100644 Tests/ComposableEffectIdentifierTests/Reducer+NamespaceTests.swift
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)
+ }
+}