diff --git a/.swiftformat b/.swiftformat index 292e863..2040e52 100644 --- a/.swiftformat +++ b/.swiftformat @@ -7,8 +7,8 @@ --disable sortDeclarations --disable blankLinesAtStartOfScope --disable opaqueGenericParameters ---enable redundanttype --header "" +--enable redundanttype --enable organizeDeclarations --organizetypes markcategories --extensionacl on-extension diff --git a/Examples/Search/Search.xcodeproj/project.pbxproj b/Examples/Search/Search.xcodeproj/project.pbxproj index d47d3f3..ea0e793 100644 --- a/Examples/Search/Search.xcodeproj/project.pbxproj +++ b/Examples/Search/Search.xcodeproj/project.pbxproj @@ -7,12 +7,10 @@ objects = { /* Begin PBXBuildFile section */ - 832055D92B94A0F000AEABBB /* SearchActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832055D82B94A0F000AEABBB /* SearchActions.swift */; }; - 832055DB2B94A11300AEABBB /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832055DA2B94A11300AEABBB /* Search.swift */; }; 83E5F5EF2A90165500C772A0 /* VDStore in Frameworks */ = {isa = PBXBuildFile; productRef = 83E5F5EE2A90165500C772A0 /* VDStore */; }; CA66690B242547B000A639B3 /* WeatherClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA66690A242547B000A639B3 /* WeatherClient.swift */; }; CA86E49D24253C2500357AD9 /* SearchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E49C24253C2500357AD9 /* SearchApp.swift */; }; - CA86E49F24253C2500357AD9 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E49E24253C2500357AD9 /* SearchView.swift */; }; + CA86E49F24253C2500357AD9 /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E49E24253C2500357AD9 /* Search.swift */; }; CA86E4A124253C2700357AD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA86E4A024253C2700357AD9 /* Assets.xcassets */; }; CA86E4B224253C2700357AD9 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E4B124253C2700357AD9 /* SearchTests.swift */; }; /* End PBXBuildFile section */ @@ -51,12 +49,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 832055D82B94A0F000AEABBB /* SearchActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchActions.swift; sourceTree = ""; }; - 832055DA2B94A11300AEABBB /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; CA66690A242547B000A639B3 /* WeatherClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherClient.swift; sourceTree = ""; }; CA86E49724253C2500357AD9 /* Search.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Search.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA86E49C24253C2500357AD9 /* SearchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchApp.swift; sourceTree = ""; }; - CA86E49E24253C2500357AD9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + CA86E49E24253C2500357AD9 /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; CA86E4A024253C2700357AD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CA86E4AD24253C2700357AD9 /* SearchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SearchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA86E4B124253C2700357AD9 /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = ""; }; @@ -115,9 +111,7 @@ isa = PBXGroup; children = ( CA86E49C24253C2500357AD9 /* SearchApp.swift */, - CA86E49E24253C2500357AD9 /* SearchView.swift */, - 832055D82B94A0F000AEABBB /* SearchActions.swift */, - 832055DA2B94A11300AEABBB /* Search.swift */, + CA86E49E24253C2500357AD9 /* Search.swift */, CA66690A242547B000A639B3 /* WeatherClient.swift */, CA86E4A024253C2700357AD9 /* Assets.xcassets */, ); @@ -241,10 +235,8 @@ buildActionMask = 2147483647; files = ( CA66690B242547B000A639B3 /* WeatherClient.swift in Sources */, - 832055D92B94A0F000AEABBB /* SearchActions.swift in Sources */, - 832055DB2B94A11300AEABBB /* Search.swift in Sources */, CA86E49D24253C2500357AD9 /* SearchApp.swift in Sources */, - CA86E49F24253C2500357AD9 /* SearchView.swift in Sources */, + CA86E49F24253C2500357AD9 /* Search.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index ae26b94..7dd89b8 100644 Binary files a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/Search/Search/Search.swift b/Examples/Search/Search/Search.swift index eb5af8e..e8921a6 100644 --- a/Examples/Search/Search/Search.swift +++ b/Examples/Search/Search/Search.swift @@ -1,6 +1,13 @@ -import Foundation +import SwiftUI +import VDStore -// MARK: - Search feature domain +private let readMe = """ +This application demonstrates live-searching with the VDStore. As you type the \ +events are debounced for 300ms, and when you stop typing an API request is made to load \ +locations. Then tapping on a location will load weather. +""" + +// MARK: - Search state struct Search: Equatable { @@ -23,3 +30,169 @@ struct Search: Equatable { } } } + +// MARK: - Search actions + +@Actions +extension Store { + + func searchQueryChanged(query: String) { + state.searchQuery = query + cancel(Self.searchQueryChangeDebounced) + guard query.isEmpty else { return } + state.results = [] + state.weather = nil + } + + @CancelInFlight + func searchQueryChangeDebounced() async { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + guard !state.searchQuery.isEmpty, !Task.isCancelled else { + return + } + do { + let response = try await di.weatherClient.search(state.searchQuery) + try Task.checkCancellation() + state.results = response.results + } catch { + guard !Task.isCancelled, !(error is CancellationError) else { return } + state.results = [] + } + } +} + +@Actions +extension Store { + + @CancelInFlight + func searchResultTapped(location: GeocodingSearch.Result) async { + state.resultForecastRequestInFlight = location + defer { state.resultForecastRequestInFlight = nil } + do { + let forecast = try await di.weatherClient.forecast(location) + state.weather = State.Weather( + id: location.id, + days: forecast.daily.time.indices.map { + State.Weather.Day( + date: forecast.daily.time[$0], + temperatureMax: forecast.daily.temperatureMax[$0], + temperatureMaxUnit: forecast.dailyUnits.temperatureMax, + temperatureMin: forecast.daily.temperatureMin[$0], + temperatureMinUnit: forecast.dailyUnits.temperatureMin + ) + } + ) + } catch { + state.weather = nil + } + } +} + +// MARK: - Search feature view + +struct SearchView: View { + + @ViewStore var state = Search() + + var body: some View { + NavigationStack { + VStack(alignment: .leading) { + Text(readMe) + .padding() + + HStack { + Image(systemName: "magnifyingglass") + TextField( + "New York, San Francisco, ...", + text: Binding { + state.searchQuery + } set: { text in + $state.searchQueryChanged(query: text) + } + ) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + .padding(.horizontal, 16) + + List { + ForEach(state.results) { location in + VStack(alignment: .leading) { + Button { + Task { + await $state.searchResultTapped(location: location) + } + } label: { + HStack { + Text(location.name) + + if state.resultForecastRequestInFlight?.id == location.id { + ProgressView() + } + } + } + + if location.id == state.weather?.id { + weatherView(locationWeather: state.weather) + } + } + } + } + + Button("Weather API provided by Open-Meteo") { + UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!) + } + .foregroundColor(.gray) + .padding(.all, 16) + } + .navigationTitle("Search") + } + .task(id: state.searchQuery) { + await $state.searchQueryChangeDebounced() + } + } + + @ViewBuilder + func weatherView(locationWeather: Search.Weather?) -> some View { + if let locationWeather { + let days = locationWeather.days + .enumerated() + .map { idx, weather in formattedWeather(day: weather, isToday: idx == 0) } + + VStack(alignment: .leading) { + ForEach(days, id: \.self) { day in + Text(day) + } + } + .padding(.leading, 16) + } + } +} + +// MARK: - Private helpers + +private func formattedWeather(day: Search.Weather.Day, isToday: Bool) -> String { + let date = + isToday + ? "Today" + : dateFormatter.string(from: day.date).capitalized + let min = "\(day.temperatureMin)\(day.temperatureMinUnit)" + let max = "\(day.temperatureMax)\(day.temperatureMaxUnit)" + + return "\(date), \(min) – \(max)" +} + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE" + return formatter +}() + +// MARK: - SwiftUI previews + +struct SearchView_Previews: PreviewProvider { + static var previews: some View { + SearchView() + } +} diff --git a/Examples/Search/Search/SearchActions.swift b/Examples/Search/Search/SearchActions.swift deleted file mode 100644 index 855f825..0000000 --- a/Examples/Search/Search/SearchActions.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import VDStore - -@Actions -extension Store { - - func searchQueryChanged(query: String) { - state.searchQuery = query - cancel(Self.searchQueryChangeDebounced) - guard query.isEmpty else { return } - state.results = [] - state.weather = nil - } - - func searchQueryChangeDebounced() async { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) - guard !state.searchQuery.isEmpty, !Task.isCancelled else { - return - } - do { - let response = try await di.weatherClient.search(state.searchQuery) - guard !Task.isCancelled else { return } - state.results = response.results - } catch { - guard !Task.isCancelled, !(error is CancellationError) else { return } - state.results = [] - } - } -} - -@Actions -extension Store { - - func searchResultTapped(location: GeocodingSearch.Result) async { - state.resultForecastRequestInFlight = location - defer { state.resultForecastRequestInFlight = nil } - do { - let forecast = try await di.weatherClient.forecast(location) - state.weather = State.Weather( - id: location.id, - days: forecast.daily.time.indices.map { - State.Weather.Day( - date: forecast.daily.time[$0], - temperatureMax: forecast.daily.temperatureMax[$0], - temperatureMaxUnit: forecast.dailyUnits.temperatureMax, - temperatureMin: forecast.daily.temperatureMin[$0], - temperatureMinUnit: forecast.dailyUnits.temperatureMin - ) - } - ) - } catch { - state.weather = nil - } - } -} diff --git a/Examples/Search/Search/SearchView.swift b/Examples/Search/Search/SearchView.swift deleted file mode 100644 index 87bcf2c..0000000 --- a/Examples/Search/Search/SearchView.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SwiftUI -import VDStore - -private let readMe = """ -This application demonstrates live-searching with the VDStore. As you type the \ -events are debounced for 300ms, and when you stop typing an API request is made to load \ -locations. Then tapping on a location will load weather. -""" - -// MARK: - Search feature view - -struct SearchView: View { - - @ViewStore var state = Search() - - var body: some View { - NavigationStack { - VStack(alignment: .leading) { - Text(readMe) - .padding() - - HStack { - Image(systemName: "magnifyingglass") - TextField( - "New York, San Francisco, ...", - text: Binding { - state.searchQuery - } set: { text in - $state.searchQueryChanged(query: text) - } - ) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - } - .padding(.horizontal, 16) - - List { - ForEach(state.results) { location in - VStack(alignment: .leading) { - Button { - Task { - await $state.searchResultTapped(location: location) - } - } label: { - HStack { - Text(location.name) - - if state.resultForecastRequestInFlight?.id == location.id { - ProgressView() - } - } - } - - if location.id == state.weather?.id { - weatherView(locationWeather: state.weather) - } - } - } - } - - Button("Weather API provided by Open-Meteo") { - UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!) - } - .foregroundColor(.gray) - .padding(.all, 16) - } - .navigationTitle("Search") - } - .task(id: state.searchQuery) { - await $state.searchQueryChangeDebounced() - } - } - - @ViewBuilder - func weatherView(locationWeather: Search.Weather?) -> some View { - if let locationWeather { - let days = locationWeather.days - .enumerated() - .map { idx, weather in formattedWeather(day: weather, isToday: idx == 0) } - - VStack(alignment: .leading) { - ForEach(days, id: \.self) { day in - Text(day) - } - } - .padding(.leading, 16) - } - } -} - -// MARK: - Private helpers - -private func formattedWeather(day: Search.Weather.Day, isToday: Bool) -> String { - let date = - isToday - ? "Today" - : dateFormatter.string(from: day.date).capitalized - let min = "\(day.temperatureMin)\(day.temperatureMinUnit)" - let max = "\(day.temperatureMax)\(day.temperatureMaxUnit)" - - return "\(date), \(min) – \(max)" -} - -private let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "EEEE" - return formatter -}() - -// MARK: - SwiftUI previews - -struct SearchView_Previews: PreviewProvider { - static var previews: some View { - SearchView() - } -} diff --git a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index 19fbcb1..eccaccb 100644 Binary files a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift index 38fc5a5..da75787 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift @@ -81,8 +81,7 @@ final actor ActorIsolated { extension StoreDIValues { - @StoreDIValue - var speechClient: SpeechClient = valueFor( + @StoreDIValue var speechClient: SpeechClient = valueFor( live: .liveValue, test: SpeechClient(), preview: .previewValue diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj index 4270a1b..e8c1e07 100644 --- a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -669,7 +669,7 @@ repositoryURL = "https://github.com/dankinsoid/VDFlow"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.12.0; + minimumVersion = 4.26.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40686ec..d742fc5 100644 --- a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/dankinsoid/VDFlow", "state" : { - "revision" : "d53882224e3f88ac489d26231719cdb320213b5c", - "version" : "4.12.0" + "revision" : "c56440956274448d9a7efaa77e3f03b1d3104464", + "version" : "4.26.0" } } ], diff --git a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist similarity index 72% rename from Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist index 18d9810..5dd5da8 100644 --- a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist @@ -1,8 +1,5 @@ - - IDEDidComputeMac32BitWarning - - + diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index 0278141..c1c62a3 100644 Binary files a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/SyncUps/SyncUps/AppFeature.swift b/Examples/SyncUps/SyncUps/AppFeature.swift index 58e48ba..8486fce 100644 --- a/Examples/SyncUps/SyncUps/AppFeature.swift +++ b/Examples/SyncUps/SyncUps/AppFeature.swift @@ -4,14 +4,14 @@ import VDStore struct AppFeature: Equatable { - var path = Path(.list) + var path: Path = .list var syncUpsList = SyncUpsList() @Steps struct Path: Equatable { var list - var detail = SyncUpDetail(syncUp: .engineeringMock) - var meeting = MeetingSyncUp() + var detail: SyncUpDetail = .init(syncUp: .engineeringMock) + var meeting: MeetingSyncUp = .init() var record: RecordMeeting = .mock struct MeetingSyncUp: Equatable { @@ -37,7 +37,7 @@ extension Store: SyncUpDetailDelegate { } func startMeeting(syncUp: SyncUp) { - state.path.record = RecordMeeting(syncUp: syncUp) + state.path.$record.select(with: RecordMeeting(syncUp: syncUp)) } } @@ -49,8 +49,8 @@ extension Store: RecordMeetingDelegate { state.syncUpsList.syncUps[i] = state.path.detail.syncUp } + @CancelInFlight func debounceSave(syncUps: [SyncUp]) async throws { - cancel(Self.debounceSave) try await di.continuousClock.sleep(for: .seconds(1)) try await di.dataManager.save(JSONEncoder().encode(syncUps), .syncUps) } @@ -72,11 +72,11 @@ struct AppView: View { @ViewStore var state: AppFeature init(state: AppFeature) { - self.state = state + _state = ViewStore(wrappedValue: state) } init(store: Store) { - _state = ViewStore(store: store) + _state = ViewStore(store) } var body: some View { @@ -98,7 +98,7 @@ struct AppView: View { private var listView: some View { SyncUpsListView(store: $state.syncUpsList) - .step($state.binding.path, \.$list) + .step($state.binding.path.$list) } private var detailView: some View { @@ -106,7 +106,7 @@ struct AppView: View { store: $state.path.detail .di(\.syncUpDetailDelegate, $state) ) - .step($state.binding.path, \.$detail) + .step($state.binding.path.$detail) } private var meetingView: some View { @@ -114,7 +114,7 @@ struct AppView: View { meeting: state.path.meeting.meeting, syncUp: state.path.meeting.syncUp ) - .step($state.binding.path, \.$meeting) + .step($state.binding.path.$meeting) } private var recordView: some View { @@ -122,7 +122,7 @@ struct AppView: View { store: $state.path.record .di(\.recordMeetingDelegate, $state) ) - .step($state.binding.path, \.$record) + .step($state.binding.path.$record) } } diff --git a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift b/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift index c22c9d9..52b60c6 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift @@ -20,8 +20,7 @@ extension DataManager { extension StoreDIValues { - @StoreDIValue - var dataManager: DataManager = valueFor(live: DataManager.liveValue, test: DataManager.testValue) + @StoreDIValue var dataManager: DataManager = valueFor(live: DataManager.liveValue, test: DataManager.testValue) } extension DataManager { diff --git a/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift b/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift index 93077e2..85123c8 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift @@ -3,8 +3,7 @@ import VDStore extension StoreDIValues { - @StoreDIValue - var openSettings: @Sendable () async -> Void = Self.openSettings + @StoreDIValue var openSettings: @Sendable () async -> Void = Self.openSettings private static let openSettings: @Sendable () async -> Void = { await MainActor.run { diff --git a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift index 8659af6..2bfdd6c 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift @@ -116,8 +116,7 @@ final actor ActorIsolated { extension StoreDIValues { - @StoreDIValue - var speechClient: SpeechClient = valueFor(live: .liveValue, test: .testValue, preview: .previewValue) + @StoreDIValue var speechClient: SpeechClient = valueFor(live: .liveValue, test: .testValue, preview: .previewValue) } struct SpeechRecognitionResult: Equatable { diff --git a/Examples/SyncUps/SyncUps/RecordMeeting.swift b/Examples/SyncUps/SyncUps/RecordMeeting.swift index 8fd0759..4af5544 100644 --- a/Examples/SyncUps/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/SyncUps/RecordMeeting.swift @@ -5,7 +5,7 @@ import VDStore struct RecordMeeting: Equatable { - var alert = Alert() + var alert: Alert = .none var secondsElapsed = 0 var speakerIndex = 0 var syncUp: SyncUp @@ -19,6 +19,7 @@ struct RecordMeeting: Equatable { struct Alert: Equatable { var endMeeting = true var speechRecognizerFailed + var none } static let mock = RecordMeeting(syncUp: .engineeringMock) @@ -57,13 +58,13 @@ extension Store { } func endMeetingButtonTapped() { - state.alert.endMeeting = true + state.alert.$endMeeting.select(with: true) } func nextButtonTapped() { guard state.speakerIndex < state.syncUp.attendees.count - 1 else { - state.alert.endMeeting = false + state.alert.$endMeeting.select(with: false) return } state.speakerIndex += 1 @@ -123,7 +124,7 @@ extension Store { if !state.transcript.isEmpty { state.transcript += " ❌" } - state.alert.speechRecognizerFailed.select() + state.alert.$speechRecognizerFailed.select() } } @@ -132,11 +133,11 @@ struct RecordMeetingView: View { @ViewStore var state: RecordMeeting init(state: RecordMeeting) { - self.state = state + _state = ViewStore(wrappedValue: state) } init(store: Store) { - _state = ViewStore(store: store) + _state = ViewStore(store) } var body: some View { diff --git a/Examples/SyncUps/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUps/SyncUpDetail.swift index b141144..ee99cf6 100644 --- a/Examples/SyncUps/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUps/SyncUpDetail.swift @@ -4,19 +4,21 @@ import VDStore struct SyncUpDetail: Equatable { - var destination = Destination() + var destination: Destination = .none var syncUp: SyncUp @Steps struct Destination: Equatable { - var alert = Alert() - var edit = SyncUpForm(syncUp: SyncUp(id: StoreDIValues.current.uuid())) + var alert: Alert = .none + var edit: SyncUpForm = .init(syncUp: SyncUp(id: StoreDIValues.current.uuid())) + var none @Steps struct Alert: Equatable { var confirmDeletion var speechRecognitionDenied var speechRecognitionRestricted + var none } } } @@ -41,7 +43,7 @@ extension Store { } func deleteButtonTapped() { - state.destination.alert.confirmDeletion.select() + state.destination.alert.$confirmDeletion.select() } func deleteMeetings(atOffsets indices: IndexSet) { @@ -70,7 +72,7 @@ extension Store { } func editButtonTapped() { - state.destination.edit = SyncUpForm(syncUp: state.syncUp) + state.destination.$edit.select(with: SyncUpForm(syncUp: state.syncUp)) } func startMeetingButtonTapped() { @@ -79,10 +81,10 @@ extension Store { di.syncUpDetailDelegate?.startMeeting(syncUp: state.syncUp) case .denied: - state.destination.alert.speechRecognitionDenied.select() + state.destination.alert.$speechRecognitionDenied.select() case .restricted: - state.destination.alert.speechRecognitionRestricted.select() + state.destination.alert.$speechRecognitionRestricted.select() @unknown default: break @@ -93,14 +95,14 @@ extension Store { struct SyncUpDetailView: View { @ViewStore var state: SyncUpDetail - @StateStep var feature = AppFeature.Path() + @StateStep var feature: AppFeature.Path = .list init(state: SyncUpDetail) { _state = ViewStore(wrappedValue: state) } init(store: Store) { - _state = ViewStore(store: store) + _state = ViewStore(store) } var body: some View { diff --git a/Examples/SyncUps/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUps/SyncUpForm.swift index 275a56f..e3c79fc 100644 --- a/Examples/SyncUps/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUps/SyncUpForm.swift @@ -54,7 +54,7 @@ struct SyncUpFormView: View { } init(store: Store, focus: SyncUpForm.Field? = nil) { - _state = ViewStore(store: store) + _state = ViewStore(store) self.focus = focus } diff --git a/Examples/SyncUps/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUps/SyncUpsList.swift index 3f7edad..5214ea2 100644 --- a/Examples/SyncUps/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUps/SyncUpsList.swift @@ -4,14 +4,14 @@ import VDStore struct SyncUpsList: Equatable { - var destination = Destination() + var destination: Destination var syncUps: [SyncUp] = [] init( - destination: Destination.Steps? = nil, + destination: Destination = .none, syncUps: () throws -> [SyncUp] = { [] } ) { - self.destination = Destination(destination) + self.destination = destination do { self.syncUps = try syncUps() } catch is DecodingError { @@ -24,8 +24,9 @@ struct SyncUpsList: Equatable { @Steps struct Destination: Equatable { - var add = SyncUpForm(syncUp: SyncUp(id: .init())) + var add: SyncUpForm = .init(syncUp: SyncUp(id: .init())) var confirmLoadMockData + var none } } @@ -33,7 +34,7 @@ struct SyncUpsList: Equatable { extension Store { func addSyncUpButtonTapped() { - state.destination.add = SyncUpForm(syncUp: SyncUp(id: di.uuid())) + state.destination.$add.select(with: SyncUpForm(syncUp: SyncUp(id: di.uuid()))) } func confirmAddSyncUpButtonTapped() { @@ -51,7 +52,7 @@ extension Store { } func destinationPresented() { - state.destination.confirmLoadMockData.select() + state.destination.$confirmLoadMockData.select() state.syncUps = [ .mock, .designMock, @@ -71,21 +72,21 @@ extension Store { struct SyncUpsListView: View { @ViewStore var state: SyncUpsList - @StateStep var feature = AppFeature.Path() + @StateStep var feature: AppFeature.Path = .list init(state: SyncUpsList) { _state = ViewStore(wrappedValue: state) } init(store: Store) { - _state = ViewStore(store: store) + _state = ViewStore(store) } var body: some View { List { ForEach(state.syncUps) { syncUp in Button { - feature.detail = SyncUpDetail(syncUp: syncUp) + feature.$detail.select(with: SyncUpDetail(syncUp: syncUp)) } label: { CardView(syncUp: syncUp) } diff --git a/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift b/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift index 7f73727..b5745aa 100644 --- a/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift +++ b/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift @@ -1,8 +1,7 @@ import XCTest final class SyncUpsUITests: XCTestCase { - @MainActor - var app: XCUIApplication! + @MainActor var app: XCUIApplication! @MainActor override func setUpWithError() throws { diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aa0e350..3e5e11b 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/dankinsoid/VDFlow", "state" : { - "revision" : "e80a2ebd4231739633e76aae116ab792bbb47bfe", - "version" : "4.21.0" + "revision" : "c56440956274448d9a7efaa77e3f03b1d3104464", + "version" : "4.26.0" } } ], diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index ec5eabd..c306a51 100644 Binary files a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Examples/TicTacToe/TicTacToe.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 600513d..8ae6d12 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,20 +3,4 @@ uuid = "7B2C137B-197C-49F8-8812-740AE1314E0E" type = "1" version = "2.0"> - - - - - - diff --git a/Examples/TicTacToe/tic-tac-toe/Package.swift b/Examples/TicTacToe/tic-tac-toe/Package.swift index 21c2c73..5591f9a 100644 --- a/Examples/TicTacToe/tic-tac-toe/Package.swift +++ b/Examples/TicTacToe/tic-tac-toe/Package.swift @@ -27,7 +27,7 @@ let package = Package( ], dependencies: [ .package(name: "VDStore", path: "../../.."), - .package(url: "https://github.com/dankinsoid/VDFlow.git", from: "4.21.0"), + .package(url: "https://github.com/dankinsoid/VDFlow.git", from: "4.26.0"), ], targets: [ .target( diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift index 3905e4d..3b379d8 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift @@ -7,8 +7,8 @@ import VDStore @Steps public struct TicTacToe: Equatable { - public var login: Login = Login() - public var newGame: NewGame = NewGame() + public var login: Login = .init() + public var newGame: NewGame = .init() } extension Store: LogoutButtonDelegate { diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift index 0b0ae3f..6ea423d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift @@ -1,7 +1,9 @@ import AppCore +import GameSwiftUI import LoginSwiftUI import NewGameSwiftUI import SwiftUI +import VDFlow import VDStore public struct AppView: View { @@ -18,8 +20,10 @@ public struct AppView: View { LoginView(store: $state.login) } case .newGame: - NavigationStack { + NavigationSteps(selection: $state.binding.newGame.flow.selected) { NewGameView(store: $state.newGame) + GameView(store: $state.newGame.flow.game) + .step($state.binding.newGame.flow.$game) } } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift index efa87bf..cbb0bc4 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift @@ -74,6 +74,5 @@ public extension AuthenticationClient { public extension StoreDIValues { - @StoreDIValue - var authenticationClient = valueFor(live: AuthenticationClient.liveValue, test: AuthenticationClient()) + @StoreDIValue var authenticationClient = valueFor(live: AuthenticationClient.liveValue, test: AuthenticationClient()) } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift index 0f92be7..63895f9 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift @@ -12,7 +12,8 @@ public struct GameView: View { } public var body: some View { - GeometryReader { proxy in + Self._printChanges() + return GeometryReader { proxy in VStack(spacing: 0.0) { VStack { Text(state.title) @@ -28,7 +29,7 @@ public struct GameView: View { } .padding(.bottom, 48) - VStack { + VStack(spacing: 0.0) { rowView(row: 0, proxy: proxy) rowView(row: 1, proxy: proxy) rowView(row: 2, proxy: proxy) diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift index 932f4e3..1262737 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift @@ -19,7 +19,7 @@ public struct Login: Sendable, Equatable { @Steps public struct Flow: Equatable, Sendable { - public var twoFactor: TwoFactor = TwoFactor(token: "") + public var twoFactor: TwoFactor = .init(token: "") public var alert = "" public var none } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift index 7850e4d..9e71c93 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift @@ -47,15 +47,15 @@ public struct LoginView: View { } label: { HStack { Text("Log in") - if state.isActivityIndicatorVisible { + if state.isLoginRequestInFlight { Spacer() ProgressView() } } } - .disabled(state.isLoginButtonDisabled) + .disabled(!state.isFormValid) } - .disabled(state.isFormDisabled) + .disabled(state.isLoginRequestInFlight) .alert(state.flow.alert, isPresented: $state.binding.flow.isSelected(.alert)) { Button("Ok") {} } @@ -66,12 +66,6 @@ public struct LoginView: View { } } -private extension Login { - var isActivityIndicatorVisible: Bool { isLoginRequestInFlight } - var isFormDisabled: Bool { isLoginRequestInFlight } - var isLoginButtonDisabled: Bool { !isFormValid } -} - #Preview { NavigationStack { LoginView( diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift index 58b959b..58c1a13 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift @@ -99,13 +99,13 @@ public class LoginViewController: UIViewController { if state.email != emailTextField.text { emailTextField.text = state.email } - emailTextField.isEnabled = state.isEmailTextFieldEnabled + emailTextField.isEnabled = !state.isLoginRequestInFlight if passwordTextField.text != state.password { passwordTextField.text = state.password } - passwordTextField.isEnabled = state.isPasswordTextFieldEnabled + passwordTextField.isEnabled = !state.isLoginRequestInFlight loginButton.isEnabled = state.isLoginButtonEnabled - activityIndicator.isHidden = state.isActivityIndicatorHidden + activityIndicator.isHidden = !state.isLoginRequestInFlight if store.state.flow.selected == .alert, alertController == nil @@ -158,10 +158,7 @@ public class LoginViewController: UIViewController { } private extension Login { - var isActivityIndicatorHidden: Bool { !isLoginRequestInFlight } - var isEmailTextFieldEnabled: Bool { !isLoginRequestInFlight } var isLoginButtonEnabled: Bool { isFormValid && !isLoginRequestInFlight } - var isPasswordTextFieldEnabled: Bool { !isLoginRequestInFlight } } @Actions diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift index 4575532..5bbcab3 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift @@ -10,7 +10,7 @@ public struct NewGame: Equatable { @Steps public struct Flow: Equatable { - public var game: Game = Game(oPlayerName: "", xPlayerName: "") + public var game: Game = .init(oPlayerName: "", xPlayerName: "") public var none } @@ -23,8 +23,7 @@ public protocol LogoutButtonDelegate { } public extension StoreDIValues { - @StoreDIValue - var logoutButtonDelegate: LogoutButtonDelegate? + @StoreDIValue var logoutButtonDelegate: LogoutButtonDelegate? } @Actions diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift index 1a18ad0..5fa880d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift @@ -44,9 +44,6 @@ public struct NewGameView: View { $state.di.logoutButtonDelegate?.logoutButtonTapped() } ) - .navigationDestination(isPresented: $state.binding.flow.isSelected(.game)) { - GameView(store: $state.flow.game) - } } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift index 408db40..68ef20f 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift @@ -51,6 +51,5 @@ public protocol LoginDelegate { public extension StoreDIValues { - @StoreDIValue - var loginDelegate: LoginDelegate? + @StoreDIValue var loginDelegate: LoginDelegate? } diff --git a/Examples/Todos/Todos.xcodeproj/project.pbxproj b/Examples/Todos/Todos.xcodeproj/project.pbxproj index 924e4cf..6abe43a 100644 --- a/Examples/Todos/Todos.xcodeproj/project.pbxproj +++ b/Examples/Todos/Todos.xcodeproj/project.pbxproj @@ -3,12 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 8318EBE92BA76C760018B691 /* VDStore in Frameworks */ = {isa = PBXBuildFile; productRef = 8318EBE82BA76C760018B691 /* VDStore */; }; + 8318EBEF2BA84D320018B691 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 8318EBEE2BA84D320018B691 /* IdentifiedCollections */; }; CA93D060249BF4D000A6F65D /* Todo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA93D05F249BF4D000A6F65D /* Todo.swift */; }; - DC1394322469E57000EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC1394312469E57000EE1157 /* ComposableArchitecture */; }; DCBCB77624290F6C00DE1F59 /* TodosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBCB77524290F6C00DE1F59 /* TodosApp.swift */; }; DCBCB77A24290F6D00DE1F59 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCBCB77924290F6D00DE1F59 /* Assets.xcassets */; }; DCBCB78B24290F6D00DE1F59 /* TodosTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBCB78A24290F6D00DE1F59 /* TodosTests.swift */; }; @@ -51,7 +52,7 @@ /* Begin PBXFileReference section */ 23EDBE6A271CD8C7004F7430 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CA93D05F249BF4D000A6F65D /* Todo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Todo.swift; sourceTree = ""; }; - DC85B441242D0286009784B0 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; + DC85B441242D0286009784B0 /* VDStore */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VDStore; path = ../..; sourceTree = ""; }; DCBCB77024290F6C00DE1F59 /* Todos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Todos.app; sourceTree = BUILT_PRODUCTS_DIR; }; DCBCB77524290F6C00DE1F59 /* TodosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodosApp.swift; sourceTree = ""; }; DCBCB77924290F6D00DE1F59 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -65,7 +66,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DC1394322469E57000EE1157 /* ComposableArchitecture in Frameworks */, + 8318EBE92BA76C760018B691 /* VDStore in Frameworks */, + 8318EBEF2BA84D320018B691 /* IdentifiedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,14 +81,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 8318EBE72BA76C760018B691 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; DCBCB76724290F6C00DE1F59 = { isa = PBXGroup; children = ( - DC85B441242D0286009784B0 /* swift-composable-architecture */, + DC85B441242D0286009784B0 /* VDStore */, 23EDBE6A271CD8C7004F7430 /* README.md */, DCBCB77124290F6C00DE1F59 /* Products */, DCBCB77224290F6C00DE1F59 /* Todos */, DCBCB78924290F6D00DE1F59 /* TodosTests */, + 8318EBE72BA76C760018B691 /* Frameworks */, ); sourceTree = ""; }; @@ -136,7 +146,8 @@ ); name = Todos; packageProductDependencies = ( - DC1394312469E57000EE1157 /* ComposableArchitecture */, + 8318EBE82BA76C760018B691 /* VDStore */, + 8318EBEE2BA84D320018B691 /* IdentifiedCollections */, ); productName = Todos; productReference = DCBCB77024290F6C00DE1F59 /* Todos.app */; @@ -192,6 +203,7 @@ ); mainGroup = DCBCB76724290F6C00DE1F59; packageReferences = ( + 8318EBED2BA84D310018B691 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, ); productRefGroup = DCBCB77124290F6C00DE1F59 /* Products */; projectDirPath = ""; @@ -493,10 +505,26 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 8318EBED2BA84D310018B691 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-identified-collections"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ - DC1394312469E57000EE1157 /* ComposableArchitecture */ = { + 8318EBE82BA76C760018B691 /* VDStore */ = { + isa = XCSwiftPackageProductDependency; + productName = VDStore; + }; + 8318EBEE2BA84D320018B691 /* IdentifiedCollections */ = { isa = XCSwiftPackageProductDependency; - productName = ComposableArchitecture; + package = 8318EBED2BA84D310018B691 /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = IdentifiedCollections; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f86e6bd --- /dev/null +++ b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..f717615 Binary files /dev/null and b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/Todos/Todos.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Examples/Todos/Todos.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..2098071 --- /dev/null +++ b/Examples/Todos/Todos.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Examples/Todos/Todos/Todo.swift b/Examples/Todos/Todos/Todo.swift index 2227fd0..ebcddc9 100644 --- a/Examples/Todos/Todos/Todo.swift +++ b/Examples/Todos/Todos/Todo.swift @@ -1,38 +1,37 @@ -import ComposableArchitecture import SwiftUI +import VDStore -@Reducer -struct Todo { - @ObservableState - struct State: Equatable, Identifiable { - var description = "" - let id: UUID - var isComplete = false - } +struct Todo: Equatable, Identifiable { - enum Action: BindableAction, Sendable { - case binding(BindingAction) - } + var description = "" + let id: UUID + var isComplete = false - var body: some Reducer { - BindingReducer() - } + static let mock = Todo( + description: "Call Mom", + id: UUID(), + isComplete: true + ) } struct TodoView: View { - @Bindable var store: StoreOf + @ViewStore var state: Todo + + init(store: Store) { + _state = ViewStore(store) + } var body: some View { HStack { Button { - store.isComplete.toggle() + state.isComplete.toggle() } label: { - Image(systemName: store.isComplete ? "checkmark.square" : "square") + Image(systemName: state.isComplete ? "checkmark.square" : "square") } .buttonStyle(.plain) - TextField("Untitled Todo", text: $store.description) + TextField("Untitled Todo", text: $state.binding.description) } - .foregroundColor(store.isComplete ? .gray : nil) + .foregroundColor(state.isComplete ? .gray : nil) } } diff --git a/Examples/Todos/Todos/Todos.swift b/Examples/Todos/Todos/Todos.swift index 556b820..4055782 100644 --- a/Examples/Todos/Todos/Todos.swift +++ b/Examples/Todos/Todos/Todos.swift @@ -1,5 +1,6 @@ -import ComposableArchitecture +import IdentifiedCollections import SwiftUI +import VDStore enum Filter: LocalizedStringKey, CaseIterable, Hashable { case all = "All" @@ -7,108 +8,78 @@ enum Filter: LocalizedStringKey, CaseIterable, Hashable { case completed = "Completed" } -@Reducer -struct Todos { - @ObservableState - struct State: Equatable { - var editMode: EditMode = .inactive - var filter: Filter = .all - var todos: IdentifiedArrayOf = [] - - var filteredTodos: IdentifiedArrayOf { - switch filter { - case .active: return todos.filter { !$0.isComplete } - case .all: return todos - case .completed: return todos.filter(\.isComplete) - } +struct Todos: Equatable { + + var editMode: EditMode = .inactive + var filter: Filter = .all + var todos: IdentifiedArrayOf = [] + + var filteredTodos: IdentifiedArrayOf { + switch filter { + case .active: return todos.filter { !$0.isComplete } + case .all: return todos + case .completed: return todos.filter(\.isComplete) } } +} + +@Actions +extension Store { - enum Action: BindableAction, Sendable { - case addTodoButtonTapped - case binding(BindingAction) - case clearCompletedButtonTapped - case delete(IndexSet) - case move(IndexSet, Int) - case sortCompletedTodos - case todos(IdentifiedActionOf) + func addTodoButtonTapped() { + state.todos.insert(Todo(id: di.uuid()), at: 0) } - @Dependency(\.continuousClock) var clock - @Dependency(\.uuid) var uuid - private enum CancelID { case todoCompletion } - - var body: some Reducer { - BindingReducer() - Reduce { state, action in - switch action { - case .addTodoButtonTapped: - state.todos.insert(Todo.State(id: uuid()), at: 0) - return .none - - case .binding: - return .none - - case .clearCompletedButtonTapped: - state.todos.removeAll(where: \.isComplete) - return .none - - case let .delete(indexSet): - let filteredTodos = state.filteredTodos - for index in indexSet { - state.todos.remove(id: filteredTodos[index].id) - } - return .none - - case var .move(source, destination): - if state.filter == .completed { - source = IndexSet( - source - .map { state.filteredTodos[$0] } - .compactMap { state.todos.index(id: $0.id) } - ) - destination = - (destination < state.filteredTodos.endIndex - ? state.todos.index(id: state.filteredTodos[destination].id) - : state.todos.endIndex) - ?? destination - } + func clearCompletedButtonTapped() { + state.todos.removeAll(where: \.isComplete) + } - state.todos.move(fromOffsets: source, toOffset: destination) + func delete(indexSet: IndexSet) { + let filteredTodos = state.filteredTodos + for index in indexSet { + state.todos.remove(id: filteredTodos[index].id) + } + } - return .run { send in - try await clock.sleep(for: .milliseconds(100)) - await send(.sortCompletedTodos) - } + func move(source: IndexSet, destination: Int) { + var source = source + var destination = destination + if state.filter == .completed { + let filtered = state.filteredTodos + source = IndexSet( + source + .map { filtered[$0] } + .compactMap { state.todos.index(id: $0.id) } + ) + destination = + (destination < filtered.endIndex + ? state.todos.index(id: filtered[destination].id) + : state.todos.endIndex) + ?? destination + } - case .sortCompletedTodos: - state.todos.sort { $1.isComplete && !$0.isComplete } - return .none + state.todos.move(fromOffsets: source, toOffset: destination) + } - case .todos(.element(id: _, action: .binding(\.isComplete))): - return .run { send in - try await clock.sleep(for: .seconds(1)) - await send(.sortCompletedTodos, animation: .default) - } - .cancellable(id: CancelID.todoCompletion, cancelInFlight: true) + @CancelInFlight + func todoIsCompletedChanged() async throws { + try await di.continuousClock.sleep(for: .seconds(1)) + sortCompletedTodos() + } - case .todos: - return .none - } - } - .forEach(\.todos, action: \.todos) { - Todo() - } + func sortCompletedTodos() { + state.todos.sort { $1.isComplete && !$0.isComplete } } } struct AppView: View { - @Bindable var store: StoreOf + + @ViewStore var todos: Todos var body: some View { NavigationStack { VStack(alignment: .leading) { - Picker("Filter", selection: $store.filter.animation()) { + Picker("Filter", selection: $todos.binding.filter) { ForEach(Filter.allCases, id: \.self) { filter in Text(filter.rawValue).tag(filter) } @@ -117,11 +88,17 @@ struct AppView: View { .padding(.horizontal) List { - ForEach(store.scope(state: \.filteredTodos, action: \.todos)) { store in - TodoView(store: store) + ForEach(todos.filteredTodos) { todo in + TodoView( + store: $todos.todos[id: todo.id].or(todo).onChange(of: \.isComplete) { _, _, _ in + Task { + try await $todos.todoIsCompletedChanged() + } + } + ) } - .onDelete { store.send(.delete($0)) } - .onMove { store.send(.move($0, $1)) } + .onDelete { $todos.delete(indexSet: $0) } + .onMove { $todos.move(source: $0, destination: $1) } } } .navigationTitle("Todos") @@ -129,30 +106,31 @@ struct AppView: View { trailing: HStack(spacing: 20) { EditButton() Button("Clear Completed") { - store.send(.clearCompletedButtonTapped, animation: .default) + $todos.clearCompletedButtonTapped() } - .disabled(!store.todos.contains(where: \.isComplete)) - Button("Add Todo") { store.send(.addTodoButtonTapped, animation: .default) } + .disabled(!todos.todos.contains(where: \.isComplete)) + Button("Add Todo") { $todos.addTodoButtonTapped() } } ) - .environment(\.editMode, $store.editMode) + .environment(\.editMode, $todos.binding.editMode) } + .animation(.default, value: todos) } } -extension IdentifiedArray where ID == Todo.State.ID, Element == Todo.State { +extension IdentifiedArrayOf { static let mock: Self = [ - Todo.State( + Todo( description: "Check Mail", id: UUID(), isComplete: false ), - Todo.State( + Todo( description: "Buy Milk", id: UUID(), isComplete: false ), - Todo.State( + Todo( description: "Call Mom", id: UUID(), isComplete: true @@ -161,9 +139,5 @@ extension IdentifiedArray where ID == Todo.State.ID, Element == Todo.State { } #Preview { - AppView( - store: Store(initialState: Todos.State(todos: .mock)) { - Todos() - } - ) + AppView(todos: Todos(todos: .mock)) } diff --git a/Examples/Todos/Todos/TodosApp.swift b/Examples/Todos/Todos/TodosApp.swift index dab6ba9..92c8d16 100644 --- a/Examples/Todos/Todos/TodosApp.swift +++ b/Examples/Todos/Todos/TodosApp.swift @@ -1,15 +1,11 @@ -import ComposableArchitecture import SwiftUI +import VDStore @main struct TodosApp: App { var body: some Scene { WindowGroup { - AppView( - store: Store(initialState: Todos.State()) { - Todos() - } - ) + AppView(todos: Todos()) } } } diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index 750dbfa..828b2d8 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import VDStore import XCTest @testable import Todos diff --git a/Examples/VoiceMemos/README.md b/Examples/VoiceMemos/README.md deleted file mode 100644 index 2d90ff6..0000000 --- a/Examples/VoiceMemos/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Voice Memos - -This application demonstrates how to work with multiple dependencies and manage a complex state machine driven off of timers in the Composable Architecture. Some functionality includes: - -* Requesting the user’s permission to record audio. -* Prompting the user if insufficient permission is provided. -* Audio recording and playback. -* Handling errors that may occur during recording or playback. -* Stubbing dependencies to work with SwiftUI previews. diff --git a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj b/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj deleted file mode 100644 index b25cb54..0000000 --- a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj +++ /dev/null @@ -1,543 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 53; - objects = { - -/* Begin PBXBuildFile section */ - CA93D05C249BF42500A6F65D /* VoiceMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA93D05B249BF42500A6F65D /* VoiceMemo.swift */; }; - CA93D05E249BF46E00A6F65D /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA93D05D249BF46E00A6F65D /* Helpers.swift */; }; - CABAB49028A2B5F900122307 /* RecordingMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABAB48F28A2B5F900122307 /* RecordingMemo.swift */; }; - DC1394342469E59600EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC1394332469E59600EE1157 /* ComposableArchitecture */; }; - DC52A010288F01B30092F7DB /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC52A00F288F01B30092F7DB /* Dependencies.swift */; }; - DC5BDCB024589177009C65A3 /* VoiceMemosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDCAF24589177009C65A3 /* VoiceMemosApp.swift */; }; - DC5BDCB224589177009C65A3 /* VoiceMemos.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDCB124589177009C65A3 /* VoiceMemos.swift */; }; - DC5BDCB424589178009C65A3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC5BDCB324589178009C65A3 /* Assets.xcassets */; }; - DC5BDCC524589179009C65A3 /* VoiceMemosTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDCC424589179009C65A3 /* VoiceMemosTests.swift */; }; - DC5BDF362458939C009C65A3 /* AudioRecorderClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF352458939C009C65A3 /* AudioRecorderClient.swift */; }; - DC5BDF3A245893C1009C65A3 /* LiveAudioRecorderClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */; }; - DC5BDF3D245893E6009C65A3 /* AudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */; }; - DC5BDF3F24589406009C65A3 /* LiveAudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - DC5BDCC124589179009C65A3 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DC5BDCA224589177009C65A3 /* Project object */; - proxyType = 1; - remoteGlobalIDString = DC5BDCA924589177009C65A3; - remoteInfo = VoiceMemos; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - DC5BDF2E24589263009C65A3 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - DC5BDF3224589267009C65A3 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 23EDBE6B271CD8DD004F7430 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - CA93D05B249BF42500A6F65D /* VoiceMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemo.swift; sourceTree = ""; }; - CA93D05D249BF46E00A6F65D /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; - CABAB48F28A2B5F900122307 /* RecordingMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMemo.swift; sourceTree = ""; }; - DC52A00F288F01B30092F7DB /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; - DC5BDCAA24589177009C65A3 /* VoiceMemos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceMemos.app; sourceTree = BUILT_PRODUCTS_DIR; }; - DC5BDCAF24589177009C65A3 /* VoiceMemosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemosApp.swift; sourceTree = ""; }; - DC5BDCB124589177009C65A3 /* VoiceMemos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemos.swift; sourceTree = ""; }; - DC5BDCB324589178009C65A3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - DC5BDCBB24589178009C65A3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DC5BDCC024589179009C65A3 /* VoiceMemosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceMemosTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - DC5BDCC424589179009C65A3 /* VoiceMemosTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemosTests.swift; sourceTree = ""; }; - DC5BDF2A245891B7009C65A3 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; - DC5BDF352458939C009C65A3 /* AudioRecorderClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderClient.swift; sourceTree = ""; }; - DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAudioRecorderClient.swift; sourceTree = ""; }; - DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerClient.swift; sourceTree = ""; }; - DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAudioPlayerClient.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - DC5BDCA724589177009C65A3 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DC1394342469E59600EE1157 /* ComposableArchitecture in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DC5BDCBD24589179009C65A3 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - CA71F02129A6A32F007AE0DD /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; - DC5BDCA124589177009C65A3 = { - isa = PBXGroup; - children = ( - DC5BDF2A245891B7009C65A3 /* swift-composable-architecture */, - 23EDBE6B271CD8DD004F7430 /* README.md */, - DC5BDCAB24589177009C65A3 /* Products */, - DC5BDCAC24589177009C65A3 /* VoiceMemos */, - DC5BDCC324589179009C65A3 /* VoiceMemosTests */, - CA71F02129A6A32F007AE0DD /* Frameworks */, - ); - sourceTree = ""; - }; - DC5BDCAB24589177009C65A3 /* Products */ = { - isa = PBXGroup; - children = ( - DC5BDCAA24589177009C65A3 /* VoiceMemos.app */, - DC5BDCC024589179009C65A3 /* VoiceMemosTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - DC5BDCAC24589177009C65A3 /* VoiceMemos */ = { - isa = PBXGroup; - children = ( - DC5BDCBB24589178009C65A3 /* Info.plist */, - DC52A00F288F01B30092F7DB /* Dependencies.swift */, - CA93D05D249BF46E00A6F65D /* Helpers.swift */, - CABAB48F28A2B5F900122307 /* RecordingMemo.swift */, - CA93D05B249BF42500A6F65D /* VoiceMemo.swift */, - DC5BDCB124589177009C65A3 /* VoiceMemos.swift */, - DC5BDCAF24589177009C65A3 /* VoiceMemosApp.swift */, - DC5BDCB324589178009C65A3 /* Assets.xcassets */, - DC5BDF3B245893DB009C65A3 /* AudioPlayerClient */, - DC5BDF3424589389009C65A3 /* AudioRecorderClient */, - ); - path = VoiceMemos; - sourceTree = ""; - }; - DC5BDCC324589179009C65A3 /* VoiceMemosTests */ = { - isa = PBXGroup; - children = ( - DC5BDCC424589179009C65A3 /* VoiceMemosTests.swift */, - ); - path = VoiceMemosTests; - sourceTree = ""; - }; - DC5BDF3424589389009C65A3 /* AudioRecorderClient */ = { - isa = PBXGroup; - children = ( - DC5BDF352458939C009C65A3 /* AudioRecorderClient.swift */, - DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */, - ); - path = AudioRecorderClient; - sourceTree = ""; - }; - DC5BDF3B245893DB009C65A3 /* AudioPlayerClient */ = { - isa = PBXGroup; - children = ( - DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */, - DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */, - ); - path = AudioPlayerClient; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - DC5BDCA924589177009C65A3 /* VoiceMemos */ = { - isa = PBXNativeTarget; - buildConfigurationList = DC5BDCC924589179009C65A3 /* Build configuration list for PBXNativeTarget "VoiceMemos" */; - buildPhases = ( - DC5BDCA624589177009C65A3 /* Sources */, - DC5BDCA724589177009C65A3 /* Frameworks */, - DC5BDCA824589177009C65A3 /* Resources */, - DC5BDF2E24589263009C65A3 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - DCFC51462466F42900A0B8CF /* PBXTargetDependency */, - ); - name = VoiceMemos; - packageProductDependencies = ( - DC1394332469E59600EE1157 /* ComposableArchitecture */, - ); - productName = VoiceMemos; - productReference = DC5BDCAA24589177009C65A3 /* VoiceMemos.app */; - productType = "com.apple.product-type.application"; - }; - DC5BDCBF24589179009C65A3 /* VoiceMemosTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = DC5BDCCC24589179009C65A3 /* Build configuration list for PBXNativeTarget "VoiceMemosTests" */; - buildPhases = ( - DC5BDCBC24589179009C65A3 /* Sources */, - DC5BDCBD24589179009C65A3 /* Frameworks */, - DC5BDCBE24589179009C65A3 /* Resources */, - DC5BDF3224589267009C65A3 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - DC5BDCC224589179009C65A3 /* PBXTargetDependency */, - ); - name = VoiceMemosTests; - packageProductDependencies = ( - ); - productName = VoiceMemosTests; - productReference = DC5BDCC024589179009C65A3 /* VoiceMemosTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - DC5BDCA224589177009C65A3 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1140; - LastUpgradeCheck = 1430; - ORGANIZATIONNAME = "Point-Free"; - TargetAttributes = { - DC5BDCA924589177009C65A3 = { - CreatedOnToolsVersion = 11.4.1; - }; - DC5BDCBF24589179009C65A3 = { - CreatedOnToolsVersion = 11.4.1; - TestTargetID = DC5BDCA924589177009C65A3; - }; - }; - }; - buildConfigurationList = DC5BDCA524589177009C65A3 /* Build configuration list for PBXProject "VoiceMemos" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = DC5BDCA124589177009C65A3; - productRefGroup = DC5BDCAB24589177009C65A3 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - DC5BDCA924589177009C65A3 /* VoiceMemos */, - DC5BDCBF24589179009C65A3 /* VoiceMemosTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - DC5BDCA824589177009C65A3 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DC5BDCB424589178009C65A3 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DC5BDCBE24589179009C65A3 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - DC5BDCA624589177009C65A3 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DC5BDF362458939C009C65A3 /* AudioRecorderClient.swift in Sources */, - DC5BDF3D245893E6009C65A3 /* AudioPlayerClient.swift in Sources */, - CA93D05E249BF46E00A6F65D /* Helpers.swift in Sources */, - DC5BDF3A245893C1009C65A3 /* LiveAudioRecorderClient.swift in Sources */, - DC5BDF3F24589406009C65A3 /* LiveAudioPlayerClient.swift in Sources */, - DC52A010288F01B30092F7DB /* Dependencies.swift in Sources */, - CA93D05C249BF42500A6F65D /* VoiceMemo.swift in Sources */, - DC5BDCB024589177009C65A3 /* VoiceMemosApp.swift in Sources */, - CABAB49028A2B5F900122307 /* RecordingMemo.swift in Sources */, - DC5BDCB224589177009C65A3 /* VoiceMemos.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DC5BDCBC24589179009C65A3 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DC5BDCC524589179009C65A3 /* VoiceMemosTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - DC5BDCC224589179009C65A3 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DC5BDCA924589177009C65A3 /* VoiceMemos */; - targetProxy = DC5BDCC124589179009C65A3 /* PBXContainerItemProxy */; - }; - DCFC51462466F42900A0B8CF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - DC5BDCC724589179009C65A3 /* 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++14"; - CLANG_CXX_LIBRARY = "libc++"; - 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; - 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"; - SWIFT_STRICT_CONCURRENCY = complete; - }; - name = Debug; - }; - DC5BDCC824589179009C65A3 /* 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++14"; - CLANG_CXX_LIBRARY = "libc++"; - 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; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_STRICT_CONCURRENCY = complete; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - DC5BDCCA24589179009C65A3 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = VoiceMemos/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemos; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - DC5BDCCB24589179009C65A3 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = VoiceMemos/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemos; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - DC5BDCCD24589179009C65A3 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = VoiceMemos/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemosTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceMemos.app/VoiceMemos"; - }; - name = Debug; - }; - DC5BDCCE24589179009C65A3 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = VoiceMemos/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemosTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceMemos.app/VoiceMemos"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - DC5BDCA524589177009C65A3 /* Build configuration list for PBXProject "VoiceMemos" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DC5BDCC724589179009C65A3 /* Debug */, - DC5BDCC824589179009C65A3 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DC5BDCC924589179009C65A3 /* Build configuration list for PBXNativeTarget "VoiceMemos" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DC5BDCCA24589179009C65A3 /* Debug */, - DC5BDCCB24589179009C65A3 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DC5BDCCC24589179009C65A3 /* Build configuration list for PBXNativeTarget "VoiceMemosTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DC5BDCCD24589179009C65A3 /* Debug */, - DC5BDCCE24589179009C65A3 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCSwiftPackageProductDependency section */ - DC1394332469E59600EE1157 /* ComposableArchitecture */ = { - isa = XCSwiftPackageProductDependency; - productName = ComposableArchitecture; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = DC5BDCA224589177009C65A3 /* Project object */; -} diff --git a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Examples/VoiceMemos/VoiceMemos.xcodeproj/xcshareddata/xcschemes/VoiceMemos.xcscheme b/Examples/VoiceMemos/VoiceMemos.xcodeproj/xcshareddata/xcschemes/VoiceMemos.xcscheme deleted file mode 100644 index ad4176d..0000000 --- a/Examples/VoiceMemos/VoiceMemos.xcodeproj/xcshareddata/xcschemes/VoiceMemos.xcscheme +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png deleted file mode 100644 index b1516e5..0000000 Binary files a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png and /dev/null differ diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png deleted file mode 100644 index 869cd81..0000000 Binary files a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png and /dev/null differ diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png deleted file mode 100644 index 94a32fd..0000000 Binary files a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png and /dev/null differ diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon.png deleted file mode 100644 index 6f108dd..0000000 Binary files a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon.png and /dev/null differ diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 4f45077..0000000 --- a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "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" - }, - { - "filename" : "AppIcon-60@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "AppIcon.png", - "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" - }, - { - "filename" : "AppIcon-76@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "AppIcon-iPadPro@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "transparent.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/transparent.png b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/transparent.png deleted file mode 100644 index bae1e0d..0000000 Binary files a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/transparent.png and /dev/null differ diff --git a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/Contents.json b/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/Examples/VoiceMemos/VoiceMemos/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift deleted file mode 100644 index 9c94b66..0000000 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift +++ /dev/null @@ -1,25 +0,0 @@ -import ComposableArchitecture -import Foundation - -@DependencyClient -struct AudioPlayerClient { - var play: @Sendable (_ url: URL) async throws -> Bool -} - -extension AudioPlayerClient: TestDependencyKey { - static let previewValue = Self( - play: { _ in - try await Task.sleep(for: .seconds(5)) - return true - } - ) - - static let testValue = Self() -} - -extension DependencyValues { - var audioPlayer: AudioPlayerClient { - get { self[AudioPlayerClient.self] } - set { self[AudioPlayerClient.self] = newValue } - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift deleted file mode 100644 index 0768b55..0000000 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift +++ /dev/null @@ -1,54 +0,0 @@ -@preconcurrency import AVFoundation -import Dependencies - -extension AudioPlayerClient: DependencyKey { - static let liveValue = Self { url in - let stream = AsyncThrowingStream { continuation in - do { - let delegate = try Delegate( - url: url, - didFinishPlaying: { successful in - continuation.yield(successful) - continuation.finish() - }, - decodeErrorDidOccur: { error in - continuation.finish(throwing: error) - } - ) - delegate.player.play() - continuation.onTermination = { _ in - delegate.player.stop() - } - } catch { - continuation.finish(throwing: error) - } - } - return try await stream.first(where: { _ in true }) ?? false - } -} - -private final class Delegate: NSObject, AVAudioPlayerDelegate, Sendable { - let didFinishPlaying: @Sendable (Bool) -> Void - let decodeErrorDidOccur: @Sendable (Error?) -> Void - let player: AVAudioPlayer - - init( - url: URL, - didFinishPlaying: @escaping @Sendable (Bool) -> Void, - decodeErrorDidOccur: @escaping @Sendable (Error?) -> Void - ) throws { - self.didFinishPlaying = didFinishPlaying - self.decodeErrorDidOccur = decodeErrorDidOccur - player = try AVAudioPlayer(contentsOf: url) - super.init() - player.delegate = self - } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - didFinishPlaying(flag) - } - - func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { - decodeErrorDidOccur(error) - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift deleted file mode 100644 index 807c014..0000000 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift +++ /dev/null @@ -1,43 +0,0 @@ -import ComposableArchitecture -import Foundation - -@DependencyClient -struct AudioRecorderClient { - var currentTime: @Sendable () async -> TimeInterval? - var requestRecordPermission: @Sendable () async -> Bool = { false } - var startRecording: @Sendable (_ url: URL) async throws -> Bool - var stopRecording: @Sendable () async -> Void -} - -extension AudioRecorderClient: TestDependencyKey { - static var previewValue: Self { - let isRecording = ActorIsolated(false) - let currentTime = ActorIsolated(0.0) - - return Self( - currentTime: { await currentTime.value }, - requestRecordPermission: { true }, - startRecording: { _ in - await isRecording.setValue(true) - while await isRecording.value { - try await Task.sleep(for: .seconds(1)) - await currentTime.withValue { $0 += 1 } - } - return true - }, - stopRecording: { - await isRecording.setValue(false) - await currentTime.setValue(0) - } - ) - } - - static let testValue = Self() -} - -extension DependencyValues { - var audioRecorder: AudioRecorderClient { - get { self[AudioRecorderClient.self] } - set { self[AudioRecorderClient.self] = newValue } - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift deleted file mode 100644 index 7a381de..0000000 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift +++ /dev/null @@ -1,105 +0,0 @@ -import AVFoundation -import Dependencies - -extension AudioRecorderClient: DependencyKey { - static var liveValue: Self { - let audioRecorder = AudioRecorder() - return Self( - currentTime: { await audioRecorder.currentTime }, - requestRecordPermission: { await AudioRecorder.requestPermission() }, - startRecording: { url in try await audioRecorder.start(url: url) }, - stopRecording: { await audioRecorder.stop() } - ) - } -} - -private actor AudioRecorder { - var delegate: Delegate? - var recorder: AVAudioRecorder? - - var currentTime: TimeInterval? { - guard - let recorder, - recorder.isRecording - else { return nil } - return recorder.currentTime - } - - static func requestPermission() async -> Bool { - await AVAudioApplication.requestRecordPermission() - } - - func stop() { - recorder?.stop() - try? AVAudioSession.sharedInstance().setActive(false) - } - - func start(url: URL) async throws -> Bool { - stop() - - let stream = AsyncThrowingStream { continuation in - do { - self.delegate = Delegate( - didFinishRecording: { flag in - continuation.yield(flag) - continuation.finish() - try? AVAudioSession.sharedInstance().setActive(false) - }, - encodeErrorDidOccur: { error in - continuation.finish(throwing: error) - try? AVAudioSession.sharedInstance().setActive(false) - } - ) - let recorder = try AVAudioRecorder( - url: url, - settings: [ - AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 1, - AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, - ] - ) - self.recorder = recorder - recorder.delegate = self.delegate - - continuation.onTermination = { [recorder = UncheckedSendable(recorder)] _ in - recorder.wrappedValue.stop() - } - - try AVAudioSession.sharedInstance().setCategory( - .playAndRecord, mode: .default, options: .defaultToSpeaker - ) - try AVAudioSession.sharedInstance().setActive(true) - self.recorder?.record() - } catch { - continuation.finish(throwing: error) - } - } - - for try await didFinish in stream { - return didFinish - } - throw CancellationError() - } -} - -private final class Delegate: NSObject, AVAudioRecorderDelegate, Sendable { - let didFinishRecording: @Sendable (Bool) -> Void - let encodeErrorDidOccur: @Sendable (Error?) -> Void - - init( - didFinishRecording: @escaping @Sendable (Bool) -> Void, - encodeErrorDidOccur: @escaping @Sendable (Error?) -> Void - ) { - self.didFinishRecording = didFinishRecording - self.encodeErrorDidOccur = encodeErrorDidOccur - } - - func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { - didFinishRecording(flag) - } - - func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { - encodeErrorDidOccur(error) - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/Dependencies.swift b/Examples/VoiceMemos/VoiceMemos/Dependencies.swift deleted file mode 100644 index 6e6af45..0000000 --- a/Examples/VoiceMemos/VoiceMemos/Dependencies.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Dependencies -import SwiftUI - -extension DependencyValues { - var openSettings: @Sendable () async -> Void { - get { self[OpenSettingsKey.self] } - set { self[OpenSettingsKey.self] = newValue } - } - - private enum OpenSettingsKey: DependencyKey { - typealias Value = @Sendable () async -> Void - - static let liveValue: @Sendable () async -> Void = { - await MainActor.run { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } - } - } - - var temporaryDirectory: @Sendable () -> URL { - get { self[TemporaryDirectoryKey.self] } - set { self[TemporaryDirectoryKey.self] = newValue } - } - - private enum TemporaryDirectoryKey: DependencyKey { - static let liveValue: @Sendable () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) } - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/Helpers.swift b/Examples/VoiceMemos/VoiceMemos/Helpers.swift deleted file mode 100644 index 75e3ff0..0000000 --- a/Examples/VoiceMemos/VoiceMemos/Helpers.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -let dateComponentsFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute, .second] - formatter.zeroFormattingBehavior = .pad - return formatter -}() diff --git a/Examples/VoiceMemos/VoiceMemos/Info.plist b/Examples/VoiceMemos/VoiceMemos/Info.plist deleted file mode 100644 index 23a53a8..0000000 --- a/Examples/VoiceMemos/VoiceMemos/Info.plist +++ /dev/null @@ -1,62 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - NSMicrophoneUsageDescription - Your microphone will be used to record your speech when you press the "Start Recording" button. - - diff --git a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift b/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift deleted file mode 100644 index 38ee80b..0000000 --- a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift +++ /dev/null @@ -1,122 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -@Reducer -struct RecordingMemo { - @ObservableState - struct State: Equatable, Sendable { - var date: Date - var duration: TimeInterval = 0 - var mode: Mode = .recording - var url: URL - - enum Mode { - case recording - case encoding - } - } - - enum Action: Sendable { - case audioRecorderDidFinish(Result) - case delegate(Delegate) - case finalRecordingTime(TimeInterval) - case onTask - case timerUpdated - case stopButtonTapped - - @CasePathable - enum Delegate: Sendable { - case didFinish(Result) - } - } - - struct Failed: Equatable, Error {} - - @Dependency(\.audioRecorder) var audioRecorder - @Dependency(\.continuousClock) var clock - - var body: some Reducer { - Reduce { state, action in - switch action { - case .audioRecorderDidFinish(.success(true)): - return .send(.delegate(.didFinish(.success(state)))) - - case .audioRecorderDidFinish(.success(false)): - return .send(.delegate(.didFinish(.failure(Failed())))) - - case let .audioRecorderDidFinish(.failure(error)): - return .send(.delegate(.didFinish(.failure(error)))) - - case .delegate: - return .none - - case let .finalRecordingTime(duration): - state.duration = duration - return .none - - case .stopButtonTapped: - state.mode = .encoding - return .run { send in - if let currentTime = await audioRecorder.currentTime() { - await send(.finalRecordingTime(currentTime)) - } - await audioRecorder.stopRecording() - } - - case .onTask: - return .run { [url = state.url] send in - async let startRecording: Void = send( - .audioRecorderDidFinish( - Result { try await audioRecorder.startRecording(url: url) } - ) - ) - for await _ in clock.timer(interval: .seconds(1)) { - await send(.timerUpdated) - } - await startRecording - } - - case .timerUpdated: - state.duration += 1 - return .none - } - } - } -} - -struct RecordingMemoView: View { - let store: StoreOf - - var body: some View { - VStack(spacing: 12) { - Text("Recording") - .font(.title) - .colorMultiply(Color(Int(store.duration).isMultiple(of: 2) ? .systemRed : .label)) - .animation(.easeInOut(duration: 0.5), value: store.duration) - - if let formattedDuration = dateComponentsFormatter.string(from: store.duration) { - Text(formattedDuration) - .font(.body.monospacedDigit().bold()) - .foregroundColor(.black) - } - - ZStack { - Circle() - .foregroundColor(Color(.label)) - .frame(width: 74, height: 74) - - Button { - store.send(.stopButtonTapped, animation: .default) - } label: { - RoundedRectangle(cornerRadius: 4) - .foregroundColor(Color(.systemRed)) - .padding(17) - } - .frame(width: 70, height: 70) - } - } - .task { - await store.send(.onTask).finish() - } - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift deleted file mode 100644 index 28fdcb0..0000000 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift +++ /dev/null @@ -1,145 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -@Reducer -struct VoiceMemo { - @ObservableState - struct State: Equatable, Identifiable { - var date: Date - var duration: TimeInterval - var mode = Mode.notPlaying - var title = "" - var url: URL - - var id: URL { url } - - @CasePathable - @dynamicMemberLookup - enum Mode: Equatable { - case notPlaying - case playing(progress: Double) - } - } - - enum Action { - case audioPlayerClient(Result) - case delegate(Delegate) - case playButtonTapped - case timerUpdated(TimeInterval) - case titleTextFieldChanged(String) - - @CasePathable - enum Delegate { - case playbackStarted - case playbackFailed - } - } - - @Dependency(\.audioPlayer) var audioPlayer - @Dependency(\.continuousClock) var clock - private enum CancelID { case play } - - var body: some Reducer { - Reduce { state, action in - switch action { - case .audioPlayerClient(.failure): - state.mode = .notPlaying - return .merge( - .cancel(id: CancelID.play), - .send(.delegate(.playbackFailed)) - ) - - case .audioPlayerClient: - state.mode = .notPlaying - return .cancel(id: CancelID.play) - - case .delegate: - return .none - - case .playButtonTapped: - switch state.mode { - case .notPlaying: - state.mode = .playing(progress: 0) - - return .run { [url = state.url] send in - await send(.delegate(.playbackStarted)) - - async let playAudio: Void = send( - .audioPlayerClient(Result { try await audioPlayer.play(url: url) }) - ) - - var start: TimeInterval = 0 - for await _ in clock.timer(interval: .milliseconds(500)) { - start += 0.5 - await send(.timerUpdated(start)) - } - - await playAudio - } - .cancellable(id: CancelID.play, cancelInFlight: true) - - case .playing: - state.mode = .notPlaying - return .cancel(id: CancelID.play) - } - - case let .timerUpdated(time): - switch state.mode { - case .notPlaying: - break - case .playing: - state.mode = .playing(progress: time / state.duration) - } - return .none - - case let .titleTextFieldChanged(text): - state.title = text - return .none - } - } - } -} - -struct VoiceMemoView: View { - @Bindable var store: StoreOf - - var body: some View { - let currentTime = - store.mode.playing.map { $0 * store.duration } ?? store.duration - HStack { - TextField( - "Untitled, \(store.date.formatted(date: .numeric, time: .shortened))", - text: $store.title.sending(\.titleTextFieldChanged) - ) - - Spacer() - - dateComponentsFormatter.string(from: currentTime).map { - Text($0) - .font(.footnote.monospacedDigit()) - .foregroundColor(Color(.systemGray)) - } - - Button { - store.send(.playButtonTapped) - } label: { - Image(systemName: store.mode.is(\.playing) ? "stop.circle" : "play.circle") - .font(.system(size: 22)) - } - } - .buttonStyle(.borderless) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.horizontal) - .listRowBackground(store.mode.is(\.playing) ? Color(.systemGray6) : .clear) - .listRowInsets(EdgeInsets()) - .background( - Color(.systemGray5) - .frame(maxWidth: store.mode.is(\.playing) ? .infinity : 0) - .animation( - store.mode.is(\.playing) ? .linear(duration: store.duration) : nil, - value: store.mode.is(\.playing) - ), - alignment: .leading - ) - } -} diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift deleted file mode 100644 index 8b08169..0000000 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift +++ /dev/null @@ -1,229 +0,0 @@ -import AVFoundation -import ComposableArchitecture -import SwiftUI - -@Reducer -struct VoiceMemos { - @ObservableState - struct State: Equatable { - @Presents var alert: AlertState? - var audioRecorderPermission = RecorderPermission.undetermined - @Presents var recordingMemo: RecordingMemo.State? - var voiceMemos: IdentifiedArrayOf = [] - - enum RecorderPermission { - case allowed - case denied - case undetermined - } - } - - enum Action: Sendable { - case alert(PresentationAction) - case onDelete(IndexSet) - case openSettingsButtonTapped - case recordButtonTapped - case recordPermissionResponse(Bool) - case recordingMemo(PresentationAction) - case voiceMemos(IdentifiedActionOf) - - enum Alert: Equatable {} - } - - @Dependency(\.audioRecorder.requestRecordPermission) var requestRecordPermission - @Dependency(\.date) var date - @Dependency(\.openSettings) var openSettings - @Dependency(\.temporaryDirectory) var temporaryDirectory - @Dependency(\.uuid) var uuid - - var body: some Reducer { - Reduce { state, action in - switch action { - case .alert: - return .none - - case let .onDelete(indexSet): - state.voiceMemos.remove(atOffsets: indexSet) - return .none - - case .openSettingsButtonTapped: - return .run { _ in - await openSettings() - } - - case .recordButtonTapped: - switch state.audioRecorderPermission { - case .undetermined: - return .run { send in - await send(.recordPermissionResponse(requestRecordPermission())) - } - - case .denied: - state.alert = AlertState { TextState("Permission is required to record voice memos.") } - return .none - - case .allowed: - state.recordingMemo = newRecordingMemo - return .none - } - - case let .recordingMemo(.presented(.delegate(.didFinish(.success(recordingMemo))))): - state.recordingMemo = nil - state.voiceMemos.insert( - VoiceMemo.State( - date: recordingMemo.date, - duration: recordingMemo.duration, - url: recordingMemo.url - ), - at: 0 - ) - return .none - - case .recordingMemo(.presented(.delegate(.didFinish(.failure)))): - state.alert = AlertState { TextState("Voice memo recording failed.") } - state.recordingMemo = nil - return .none - - case .recordingMemo: - return .none - - case let .recordPermissionResponse(permission): - state.audioRecorderPermission = permission ? .allowed : .denied - if permission { - state.recordingMemo = newRecordingMemo - return .none - } else { - state.alert = AlertState { TextState("Permission is required to record voice memos.") } - return .none - } - - case let .voiceMemos(.element(id: id, action: .delegate(delegateAction))): - switch delegateAction { - case .playbackFailed: - state.alert = AlertState { TextState("Voice memo playback failed.") } - return .none - case .playbackStarted: - for memoID in state.voiceMemos.ids where memoID != id { - state.voiceMemos[id: memoID]?.mode = .notPlaying - } - return .none - } - - case .voiceMemos: - return .none - } - } - .ifLet(\.$alert, action: \.alert) - .ifLet(\.$recordingMemo, action: \.recordingMemo) { - RecordingMemo() - } - .forEach(\.voiceMemos, action: \.voiceMemos) { - VoiceMemo() - } - } - - private var newRecordingMemo: RecordingMemo.State { - RecordingMemo.State( - date: date.now, - url: temporaryDirectory() - .appendingPathComponent(uuid().uuidString) - .appendingPathExtension("m4a") - ) - } -} - -struct VoiceMemosView: View { - @Bindable var store: StoreOf - - var body: some View { - NavigationStack { - VStack { - List { - ForEach(store.scope(state: \.voiceMemos, action: \.voiceMemos)) { store in - VoiceMemoView(store: store) - } - .onDelete { store.send(.onDelete($0)) } - } - - Group { - if let store = store.scope( - state: \.recordingMemo, action: \.recordingMemo.presented - ) { - RecordingMemoView(store: store) - } else { - RecordButton(permission: store.audioRecorderPermission) { - store.send(.recordButtonTapped, animation: .spring()) - } settingsAction: { - store.send(.openSettingsButtonTapped) - } - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color(white: 0.95)) - } - .alert($store.scope(state: \.alert, action: \.alert)) - .navigationTitle("Voice memos") - } - } -} - -struct RecordButton: View { - let permission: VoiceMemos.State.RecorderPermission - let action: () -> Void - let settingsAction: () -> Void - - var body: some View { - ZStack { - Group { - Circle() - .foregroundColor(Color(.label)) - .frame(width: 74, height: 74) - - Button(action: action) { - RoundedRectangle(cornerRadius: 35) - .foregroundColor(Color(.systemRed)) - .padding(2) - } - .frame(width: 70, height: 70) - } - .opacity(permission == .denied ? 0.1 : 1) - - if permission == .denied { - VStack(spacing: 10) { - Text("Recording requires microphone access.") - .multilineTextAlignment(.center) - Button("Open Settings", action: settingsAction) - } - .frame(maxWidth: .infinity, maxHeight: 74) - } - } - } -} - -#Preview { - VoiceMemosView( - store: Store( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 5, - mode: .notPlaying, - title: "Functions", - url: URL(string: "https://www.pointfree.co/functions")! - ), - VoiceMemo.State( - date: Date(), - duration: 5, - mode: .notPlaying, - title: "", - url: URL(string: "https://www.pointfree.co/untitled")! - ), - ] - ) - ) { - VoiceMemos() - } - ) -} diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift deleted file mode 100644 index f4a2b92..0000000 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift +++ /dev/null @@ -1,15 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -@main -struct VoiceMemosApp: App { - var body: some Scene { - WindowGroup { - VoiceMemosView( - store: Store(initialState: VoiceMemos.State()) { - VoiceMemos()._printChanges() - } - ) - } - } -} diff --git a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift deleted file mode 100644 index 59bd241..0000000 --- a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift +++ /dev/null @@ -1,457 +0,0 @@ -import ComposableArchitecture -import XCTest - -@testable import VoiceMemos - -let deadbeefID = UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")! -let deadbeefURL = URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") - -final class VoiceMemosTests: XCTestCase { - @MainActor - func testRecordAndPlayback() async throws { - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in - try await clock.sleep(for: .milliseconds(2500)) - return true - } - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.audioRecorder.stopRecording = { - didFinish.continuation.yield(true) - didFinish.continuation.finish() - } - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.continuousClock = clock - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } - - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: deadbeefURL - ) - } - await store.send(\.recordingMemo.onTask) - await store.send(\.recordingMemo.stopButtonTapped) { - $0.recordingMemo?.mode = .encoding - } - await store.receive(\.recordingMemo.finalRecordingTime) { - $0.recordingMemo?.duration = 2.5 - } - await store.receive(\.recordingMemo.audioRecorderDidFinish.success) - await store.receive(\.recordingMemo.delegate.didFinish.success) { - $0.recordingMemo = nil - $0.voiceMemos = [ - VoiceMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - duration: 2.5, - mode: .notPlaying, - title: "", - url: deadbeefURL - ), - ] - } - await store.send(\.voiceMemos[id: deadbeefURL].playButtonTapped) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id: deadbeefURL].delegate.playbackStarted) - await clock.run() - - await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.2) - } - await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.4) - } - await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.6) - } - await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.8) - } - await store.receive(\.voiceMemos[id: deadbeefURL].audioPlayerClient.success) { - $0.voiceMemos[id: deadbeefURL]?.mode = .notPlaying - } - } - - @MainActor - func testRecordMemoHappyPath() async throws { - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.audioRecorder.stopRecording = { - didFinish.continuation.yield(true) - didFinish.continuation.finish() - } - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.continuousClock = clock - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) - } - - await store.send(.recordButtonTapped) - await clock.advance() - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") - ) - } - let recordingMemoTask = await store.send(\.recordingMemo.onTask) - await clock.advance(by: .seconds(1)) - await store.receive(\.recordingMemo.timerUpdated) { - $0.recordingMemo?.duration = 1 - } - await clock.advance(by: .seconds(1)) - await store.receive(\.recordingMemo.timerUpdated) { - $0.recordingMemo?.duration = 2 - } - await clock.advance(by: .milliseconds(500)) - await store.send(\.recordingMemo.stopButtonTapped) { - $0.recordingMemo?.mode = .encoding - } - await store.receive(\.recordingMemo.finalRecordingTime) { - $0.recordingMemo?.duration = 2.5 - } - await store.receive(\.recordingMemo.audioRecorderDidFinish.success) - await store.receive(\.recordingMemo.delegate.didFinish.success) { - $0.recordingMemo = nil - $0.voiceMemos = [ - VoiceMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - duration: 2.5, - mode: .notPlaying, - title: "", - url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") - ), - ] - } - await recordingMemoTask.cancel() - } - - @MainActor - func testPermissionDenied() async { - var didOpenSettings = false - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.requestRecordPermission = { false } - $0.openSettings = { @MainActor in didOpenSettings = true } - } - - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.alert = AlertState { TextState("Permission is required to record voice memos.") } - $0.audioRecorderPermission = .denied - } - await store.send(\.alert.dismiss) { - $0.alert = nil - } - await store.send(.openSettingsButtonTapped).finish() - XCTAssert(didOpenSettings) - } - - @MainActor - func testRecordMemoFailure() async { - struct SomeError: Error, Equatable {} - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.continuousClock = clock - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } - - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: deadbeefURL - ) - } - await store.send(\.recordingMemo.onTask) - - didFinish.continuation.finish(throwing: SomeError()) - await store.receive(\.recordingMemo.audioRecorderDidFinish.failure) - await store.receive(\.recordingMemo.delegate.didFinish.failure) { - $0.alert = AlertState { TextState("Voice memo recording failed.") } - $0.recordingMemo = nil - } - } - - /// Demonstration of how to write a non-exhaustive test for recording a memo and it failing to - /// record. - @MainActor - func testRecordMemoFailure_NonExhaustive() async { - struct SomeError: Error, Equatable {} - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.continuousClock = clock - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } - store.exhaustivity = .off(showSkippedAssertions: true) - - await store.send(.recordButtonTapped) - await store.send(\.recordingMemo.onTask) - didFinish.continuation.finish(throwing: SomeError()) - await store.receive(\.recordingMemo.delegate.didFinish.failure) { - $0.alert = AlertState { TextState("Voice memo recording failed.") } - $0.recordingMemo = nil - } - } - - @MainActor - func testPlayMemoHappyPath() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 1.25, - mode: .notPlaying, - title: "", - url: url - ), - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in - try await clock.sleep(for: .milliseconds(1250)) - return true - } - $0.continuousClock = clock - } - - await store.send(\.voiceMemos[id: url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) - await clock.advance(by: .milliseconds(500)) - await store.receive(\.voiceMemos[id: url].timerUpdated) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0.4) - } - await clock.advance(by: .milliseconds(500)) - await store.receive(\.voiceMemos[id: url].timerUpdated) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0.8) - } - await clock.advance(by: .milliseconds(250)) - await store.receive(\.voiceMemos[id: url].audioPlayerClient.success) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - } - - @MainActor - func testPlayMemoFailure() async { - struct SomeError: Error, Equatable {} - - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .notPlaying, - title: "", - url: url - ), - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in throw SomeError() } - $0.continuousClock = clock - } - - let task = await store.send(\.voiceMemos[id: url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) - await store.receive(\.voiceMemos[id: url].audioPlayerClient.failure) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - await store.receive(\.voiceMemos[id: url].delegate.playbackFailed) { - $0.alert = AlertState { TextState("Voice memo playback failed.") } - } - await task.cancel() - } - - @MainActor - func testStopMemo() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .playing(progress: 0.3), - title: "", - url: url - ), - ] - ) - ) { - VoiceMemos() - } - - await store.send(\.voiceMemos[id: url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - } - - @MainActor - func testDeleteMemo() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .playing(progress: 0.3), - title: "", - url: url - ), - ] - ) - ) { - VoiceMemos() - } - - await store.send(.onDelete([0])) { - $0.voiceMemos = [] - } - } - - @MainActor - func testDeleteMemos() async { - let date = Date() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 1", - url: URL(fileURLWithPath: "pointfreeco/1.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 2", - url: URL(fileURLWithPath: "pointfreeco/2.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 3", - url: URL(fileURLWithPath: "pointfreeco/3.m4a") - ), - ] - ) - ) { - VoiceMemos() - } - - await store.send(.onDelete([1])) { - $0.voiceMemos = [ - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 1", - url: URL(fileURLWithPath: "pointfreeco/1.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 3", - url: URL(fileURLWithPath: "pointfreeco/3.m4a") - ), - ] - } - } - - @MainActor - func testDeleteMemoWhilePlaying() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 10, - mode: .notPlaying, - title: "", - url: url - ), - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in try await Task.never() } - $0.continuousClock = clock - } - - await store.send(\.voiceMemos[id: url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) - await store.send(.onDelete([0])) { - $0.voiceMemos = [] - } - await store.finish() - } -} diff --git a/Package.swift b/Package.swift index 79b3211..846743b 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,11 @@ let package = Package( dependencies: [ ], targets: [ - .target(name: "VDStore", dependencies: []), + .target( + name: "VDStore", + dependencies: [ + ] + ), .testTarget(name: "VDStoreTests", dependencies: ["VDStore"]), ] ) diff --git a/README.md b/README.md index b6810c8..8d52c45 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,8 @@ Also `@Actions` make all your `async` methods cancellable. @Actions extension Store { + @CancelInFlight func updateRates() async { - cancel(Self.updateRates) state.isLoading = true defer { state.isLoading = false } do { @@ -164,7 +164,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.30.0") + .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.31.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDStore"]) diff --git a/Sources/VDStore/Action.swift b/Sources/VDStore/Action.swift index 29e3b44..06671f0 100644 --- a/Sources/VDStore/Action.swift +++ b/Sources/VDStore/Action.swift @@ -60,10 +60,11 @@ public extension Store.Action { init( id: StoreActionID, + cancelInFlight: Bool = false, action: @escaping @Sendable (Store) -> @MainActor (Args) async -> T ) where Res == Task { self.init(id: id) { store, args in - store.task(id: id) { + store.task(id: id, cancelInFlight: cancelInFlight) { await action(store)(args) } } @@ -71,10 +72,11 @@ public extension Store.Action { init( id: StoreActionID, + cancelInFlight: Bool = false, action: @escaping @Sendable (Store) -> @MainActor (Args) async throws -> T ) where Res == Task { self.init(id: id) { store, args in - store.task(id: id) { + store.task(id: id, cancelInFlight: cancelInFlight) { try await action(store)(args) } } @@ -254,11 +256,13 @@ public extension Store { file: String = #fileID, line: UInt = #line, from function: String = #function, + cancelInFlight: Bool = false, action: @MainActor @escaping () async throws -> Res ) async throws -> Res { try await execute( Action>( id: id ?? StoreActionID(name: "anonymous", fileID: file, line: line), + cancelInFlight: cancelInFlight, action: { _ in { _ in try await action() } } @@ -276,11 +280,13 @@ public extension Store { file: String = #fileID, line: UInt = #line, from function: String = #function, + cancelInFlight: Bool = false, action: @MainActor @escaping () async -> Res ) async -> Res { await execute( Action>( id: id ?? StoreActionID(name: "anonymous", fileID: file, line: line), + cancelInFlight: cancelInFlight, action: { _ in { _ in await action() } } diff --git a/Sources/VDStore/Dependencies/CancellableStorage.swift b/Sources/VDStore/Dependencies/CancellableStorage.swift index de0d8b2..3bb8932 100644 --- a/Sources/VDStore/Dependencies/CancellableStorage.swift +++ b/Sources/VDStore/Dependencies/CancellableStorage.swift @@ -8,8 +8,7 @@ extension StoreDIValues { } /// Stores cancellables for Combine subscriptions. - @MainActor - public var cancellableSet: Set { + @MainActor public var cancellableSet: Set { get { cancellableStorage.set } nonmutating set { cancellableStorage.set = newValue } } diff --git a/Sources/VDStore/Dependencies/TasksStorage.swift b/Sources/VDStore/Dependencies/TasksStorage.swift index c811359..479e6ab 100644 --- a/Sources/VDStore/Dependencies/TasksStorage.swift +++ b/Sources/VDStore/Dependencies/TasksStorage.swift @@ -10,13 +10,13 @@ public extension StoreDIValues { } /// The storage of async tasks. Allows to store and cancel tasks. -@MainActor public final class TasksStorage { /// The shared instance of the storage. public static let shared = TasksStorage() + private let lock = NSRecursiveLock() - private var tasks: [AnyHashable: CancellableTask] = [:] + private var tasks: [AnyHashable: [UUID: CancellableTask]] = [:] var count: Int { tasks.count } @@ -24,25 +24,39 @@ public final class TasksStorage { /// Cancel a task by its cancellation id. public func cancel(id: AnyHashable) { - tasks[id]?.cancel() - remove(id: id) + lock.lock() + tasks[id]?.forEach { $0.value.cancel() } + tasks[id] = nil + lock.unlock() } - fileprivate func add(for id: AnyHashable, _ task: Task) { - cancel(id: id) - var isFinished = false - Task { [weak self] in - _ = try? await task.value - self?.remove(id: id) - isFinished = true + fileprivate func add(for id: AnyHashable, _ task: Task, cancelInFlight: Bool) { + if cancelInFlight { + cancel(id: id) } - if !isFinished { - tasks[id] = task + let uuid = UUID() + Task { [self] in + addTask(task, id: id, uuid: uuid) + defer { + remove(id: id, uuid: uuid) + } + _ = try await task.value } } - private func remove(id: AnyHashable) { - tasks[id] = nil + private func addTask(_ task: Task, id: AnyHashable, uuid: UUID) { + lock.lock() + tasks[id, default: [:]][uuid] = task + lock.unlock() + } + + private func remove(id: AnyHashable, uuid: UUID) { + lock.lock() + tasks[id]?[uuid] = nil + if tasks[id]?.isEmpty == true { + tasks[id] = nil + } + lock.unlock() } } @@ -56,24 +70,38 @@ extension Task: CancellableTask {} public extension Store { /// Create a throwing task with cancellation id. + /// - Parameters: + /// - id: The task's identifier. + /// - cancelInFlight: Determines if any in-flight tasks with the same identifier should be + /// canceled before starting this new one. + /// - task: The async throwing task. @discardableResult func task( id: AnyHashable, - _ task: @escaping @Sendable () async throws -> T + cancelInFlight: Bool = false, + _ task: @MainActor @escaping @Sendable () async throws -> T ) -> Task { - Task { - try await withDIValues(operation: task) + withDIValues { + Task(operation: task) + .store(in: di.tasksStorage, id: id, cancelInFlight: cancelInFlight) } - .store(in: di.tasksStorage, id: id) } /// Create a task with cancellation id. + /// - Parameters: + /// - id: The task's identifier. + /// - cancelInFlight: Determines if any in-flight tasks with the same identifier should be canceled before starting this new one. + /// - task: The async task. @discardableResult func task( id: AnyHashable, - _ task: @escaping @Sendable () async -> T + cancelInFlight: Bool = false, + _ task: @MainActor @escaping @Sendable () async -> T ) -> Task { - Task(operation: task).store(in: di.tasksStorage, id: id) + withDIValues { + Task(operation: task) + .store(in: di.tasksStorage, id: id, cancelInFlight: cancelInFlight) + } } /// Cancel an async store action. @@ -97,10 +125,14 @@ public extension Store { public extension Task { /// Store the task in the storage by it cancellation id. + /// - Parameters: + /// - id: The task's identifier. + /// - cancelInFlight: Determines if any in-flight tasks with the same identifier should be + /// canceled before starting this new one. @MainActor @discardableResult - func store(in storage: TasksStorage, id: AnyHashable) -> Task { - storage.add(for: id, self) + func store(in storage: TasksStorage, id: AnyHashable, cancelInFlight: Bool = false) -> Task { + storage.add(for: id, self, cancelInFlight: cancelInFlight) return self } } diff --git a/Sources/VDStore/Macros.swift b/Sources/VDStore/Macros.swift index 7fc75b2..2352fe1 100644 --- a/Sources/VDStore/Macros.swift +++ b/Sources/VDStore/Macros.swift @@ -8,6 +8,11 @@ import Foundation @attached(member, names: arbitrary) public macro Actions() = #externalMacro(module: "VDStoreMacros", type: "ActionsMacro") +/// Determines if any in-flight executions of the function should be canceled before starting this new one. +/// Works within `@Actions` extension only. +@attached(peer, names: arbitrary) +public macro CancelInFlight() = #externalMacro(module: "VDStoreMacros", type: "CancelInFlightMacro") + /// Creates an store DI variable and adds getters and setters. /// The initial value of the variable becomes the default value. @attached(accessor, names: named(get), named(set)) diff --git a/Sources/VDStore/Store.swift b/Sources/VDStore/Store.swift index baefdb6..d242a57 100644 --- a/Sources/VDStore/Store.swift +++ b/Sources/VDStore/Store.swift @@ -1,5 +1,5 @@ import Combine -import Foundation +import SwiftUI /// A store represents the runtime that powers the application. It is the object that you will pass /// around to views that need to interact with the application. @@ -82,7 +82,7 @@ import Foundation /// /// ### Thread safety /// -/// The `Store` class is isolated to main thread by @MainActor attribute. +/// The `Store` class is isolated to main actor by @MainActor attribute. @propertyWrapper @dynamicMemberLookup @MainActor @@ -333,16 +333,21 @@ public struct Store: Sendable { public extension Store where State: MutableCollection { - nonisolated subscript(_ index: State.Index) -> Store { - scope(index) - } - - nonisolated func scope(_ index: State.Index) -> Store { - scope { - $0[index] - } set: { - $0[index] = $1 - } + subscript(index: State.Index, or defaultValue: State.Element) -> Store { + scope( + get: { state in + guard state.indices.contains(index) else { + return defaultValue + } + return state[index] + }, + set: { state, newValue in + guard state.indices.contains(index) else { + return + } + state[index] = newValue + } + ) } } @@ -377,5 +382,7 @@ private extension Store { } } -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -extension Store: Observable {} +extension Store: Identifiable where State: Identifiable { + + public var id: State.ID { state.id } +} diff --git a/Sources/VDStore/StoreDIValues.swift b/Sources/VDStore/StoreDIValues.swift index 9c97ac6..bf2e16a 100644 --- a/Sources/VDStore/StoreDIValues.swift +++ b/Sources/VDStore/StoreDIValues.swift @@ -3,8 +3,7 @@ import Foundation /// The storage of injected dependencies. public struct StoreDIValues { - @TaskLocal - public static var current = StoreDIValues() + @TaskLocal public static var current = StoreDIValues() typealias Key = PartialKeyPath diff --git a/Sources/VDStore/StoreExtensions/ForEach.swift b/Sources/VDStore/StoreExtensions/ForEach.swift deleted file mode 100644 index 0963986..0000000 --- a/Sources/VDStore/StoreExtensions/ForEach.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -public extension Store where State: MutableCollection { - - @MainActor - func forEach(_ operation: (Store) throws -> Void) rethrows { - for index in state.indices { - try operation(self[index]) - } - } - - @MainActor - func forEach(_ operation: (Store) async throws -> Void) async rethrows { - for index in state.indices { - try await operation(self[index]) - } - } -} diff --git a/Sources/VDStore/StoreExtensions/OnChange.swift b/Sources/VDStore/StoreExtensions/OnChange.swift index 372eb0d..a62f92d 100644 --- a/Sources/VDStore/StoreExtensions/OnChange.swift +++ b/Sources/VDStore/StoreExtensions/OnChange.swift @@ -11,8 +11,11 @@ public extension Store { $0 } set: { let oldValue = $0[keyPath: keyPath] + let newValue = $1[keyPath: keyPath] $0 = $1 - operation(oldValue, $1[keyPath: keyPath], &$0) + if !isDuplicate(oldValue, newValue) { + operation(oldValue, newValue, &$0) + } } } diff --git a/Sources/VDStore/Utils/DIPublisher.swift b/Sources/VDStore/Utils/DIPublisher.swift index c6cabf3..b05fb48 100644 --- a/Sources/VDStore/Utils/DIPublisher.swift +++ b/Sources/VDStore/Utils/DIPublisher.swift @@ -48,8 +48,8 @@ struct DISubscriber: Subscriber { } func execute(_ operation: () -> T) -> T { -// StoreDIValues.$current.withValue(values) { - operation() -// } + StoreDIValues.$current.withValue(values) { + operation() + } } } diff --git a/Sources/VDStore/Utils/StoreBox.swift b/Sources/VDStore/Utils/StoreBox.swift index 2ba01d1..e00c92f 100644 --- a/Sources/VDStore/Utils/StoreBox.swift +++ b/Sources/VDStore/Utils/StoreBox.swift @@ -10,23 +10,19 @@ struct StoreBox: Publisher { nonmutating set { setter(newValue) } } - let willSet: AnyPublisher - let startUpdate: () -> Void - let endUpdate: () -> Void - let forceUpdate: () -> Void + var willSet: AnyPublisher { root.willSetPublisher } + + private let root: StoreRootBoxType private let getter: () -> Output private let setter: (Output) -> Void private let valuePublisher: AnyPublisher init(_ value: Output) { let rootBox = StoreRootBox(value) - willSet = rootBox.willSetPublisher + root = rootBox valuePublisher = rootBox.eraseToAnyPublisher() getter = { rootBox.state } setter = { rootBox.state = $0 } - startUpdate = rootBox.startUpdate - endUpdate = rootBox.endUpdate - forceUpdate = rootBox.forceUpdateIfNeeded } init( @@ -34,25 +30,34 @@ struct StoreBox: Publisher { get: @escaping (T) -> Output, set: @escaping (inout T, Output) -> Void ) { + root = parent.root valuePublisher = parent.valuePublisher.map(get).eraseToAnyPublisher() - willSet = parent.willSet getter = { get(parent.getter()) } setter = { var state = parent.getter() set(&state, $0) parent.setter(state) } - startUpdate = parent.startUpdate - endUpdate = parent.endUpdate - forceUpdate = parent.forceUpdate } + func startUpdate() { root.startUpdate() } + func endUpdate() { root.endUpdate() } + func forceUpdate() { root.forceUpdateIfNeeded() } + func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { valuePublisher.receive(subscriber: subscriber) } } -private final class StoreRootBox: Publisher { +private protocol StoreRootBoxType { + + var willSetPublisher: AnyPublisher { get } + func startUpdate() + func endUpdate() + func forceUpdateIfNeeded() +} + +private final class StoreRootBox: StoreRootBoxType, Publisher { typealias Output = State typealias Failure = Never diff --git a/Sources/VDStore/ViewStore.swift b/Sources/VDStore/ViewStore.swift index 66707fc..6f15b6f 100644 --- a/Sources/VDStore/ViewStore.swift +++ b/Sources/VDStore/ViewStore.swift @@ -26,6 +26,8 @@ public struct ViewStore: DynamicProperty { result = observable.wrappedValue.store case let .store(store): result = store + case let .state(state): + result = state.wrappedValue } return result.di(transformDI) } @@ -37,6 +39,10 @@ public struct ViewStore: DynamicProperty { public init(_ store: Store) { if store.di.isViewStore { property = .store(store) + } else if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + property = .state( + SwiftUI.State(wrappedValue: store.di(\.isViewStore, true)) + ) } else { property = .stateObject( StateObject( @@ -54,6 +60,7 @@ public struct ViewStore: DynamicProperty { private enum Property: DynamicProperty { case stateObject(StateObject) + case state(SwiftUI.State>) case store(Store) } diff --git a/Sources/VDStoreMacros/ActionsMacro.swift b/Sources/VDStoreMacros/ActionsMacro.swift index f10acdd..544cbba 100644 --- a/Sources/VDStoreMacros/ActionsMacro.swift +++ b/Sources/VDStoreMacros/ActionsMacro.swift @@ -45,7 +45,7 @@ public struct ActionsMacro: MemberAttributeMacro, MemberMacro { } } -public struct ActionMacro: PeerMacro { +public struct CancelInFlightMacro: PeerMacro { public static func expansion( of node: AttributeSyntax, @@ -53,9 +53,12 @@ public struct ActionMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { - throw CustomError("@Action only works on functions") + throw CustomError("@CancelInFlight only works on functions") } - return try VDStoreMacros.expansion(of: node, funcDecl: funcDecl, in: context) + guard funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil else { + throw CustomError("@CancelInFlight only works on async functions") + } + return [] } } @@ -116,21 +119,24 @@ private func expansion( default: break } + let cancelInFlight = isAsync && funcDecl.containsAttribute("CancelInFlight") let lineNumber = context.location(of: funcDecl)?.line.description ?? "#line" - let staticVarDecl = try VariableDeclSyntax(""" - static var \(raw: funcDecl.name.text): \(raw: varType) { - Action( - id: StoreActionID(name: "\(raw: funcDecl.name.text)", fileID: #fileID, line: \(raw: lineNumber)), - action: \(raw: actionBody) - ) - } - """) + let staticVarDecl = try VariableDeclSyntax( + """ + static var \(raw: funcDecl.name.text): \(raw: varType) { + Action( + id: StoreActionID(name: "\(raw: funcDecl.name.text)", fileID: #fileID, line: \(raw: lineNumber)),\(raw: cancelInFlight ? "\n cancelInFlight: true," : "") + action: \(raw: actionBody) + ) + } + """) var executeDecl = funcDecl executeDecl.remove(attribute: "Action") + executeDecl.remove(attribute: "CancelInFlight") executeDecl.remove(attribute: "_disfavoredOverload") // executeDecl.add(attribute: "MainActor") - // executeDecl.modifiers.remove(at: privateIndex) + // executeDecl.modifiers.remove(at: privateIndex) var parameterList = executeDecl.signature.parameterClause.parameters.map { FunctionParameterSyntax( leadingTrivia: .newline, diff --git a/Sources/VDStoreMacros/Extensions.swift b/Sources/VDStoreMacros/Extensions.swift index 84d80a4..d5e73cf 100644 --- a/Sources/VDStoreMacros/Extensions.swift +++ b/Sources/VDStoreMacros/Extensions.swift @@ -16,6 +16,10 @@ extension SyntaxCollection { extension FunctionDeclSyntax { + func containsAttribute(_ attribute: String) -> Bool { + attributes.contains(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) + } + mutating func remove(attribute: String) { if let i = attributes.firstIndex(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) { attributes.remove(at: i) diff --git a/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift b/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift index 190daa4..c037acc 100644 --- a/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift +++ b/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift @@ -12,6 +12,7 @@ struct VDStoreMacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ ActionsMacro.self, + CancelInFlightMacro.self, StoreDIValueMacro.self, StoreDIValuesMacro.self, ] diff --git a/Tests/VDStoreTests/VDStoreTests.swift b/Tests/VDStoreTests/VDStoreTests.swift index ea21259..55a3f14 100644 --- a/Tests/VDStoreTests/VDStoreTests.swift +++ b/Tests/VDStoreTests/VDStoreTests.swift @@ -125,7 +125,13 @@ final class VDStoreTests: XCTestCase { func testTasksMacroCancel() async { let store = Store(Counter()) - let value = await store.asyncTask() + let value = await store.cancellableTask() + XCTAssertEqual(value, 6) + } + + func testTaskMacroCancelInFlight() async { + let store = Store(Counter()) + let value = await store.cancellableInFlightTask() XCTAssertEqual(value, 6) } @@ -307,11 +313,23 @@ extension Store { @Actions extension Store { - func asyncTask() async -> Int { + func cancellableTask() async -> Int { + for i in 0 ..< 10 { + guard !Task.isCancelled else { return i } + if i == 5 { + cancel(Self.cancellableTask) + } + } + return 10 + } + + @CancelInFlight + func cancellableInFlightTask(ignore: Bool = false) async -> Int { + guard !ignore else { return -1 } for i in 0 ..< 10 { guard !Task.isCancelled else { return i } if i == 5 { - cancel(Self.asyncTask) + _ = await cancellableInFlightTask(ignore: true) } } return 10 @@ -327,7 +345,7 @@ class MockSomeService: SomeService {} extension StoreDIValues { var someService: SomeService { - get { self[\.someService] ?? MockSomeService() } - set { self[\.someService] = newValue } + get { get(\.someService, or: MockSomeService()) } + set { set(\.someService, newValue) } } }