From 5cb94074edacca122489d2036a5005bf24262842 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 1 Nov 2024 15:08:48 -0400 Subject: [PATCH 01/25] Update project settings. --- SyncUps/SyncUps.xcodeproj/project.pbxproj | 24 +++++++++---------- .../xcshareddata/xcschemes/SyncUps.xcscheme | 2 +- SyncUps/SyncUps/App.swift | 8 +++---- .../SyncUpsUITests/SyncUpsListUITests.swift | 17 +++++++------ 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index ef76561..5b37574 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -274,7 +274,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 1610; TargetAttributes = { CA350C702984506E00434F8A = { CreatedOnToolsVersion = 14.2; @@ -407,6 +407,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -461,6 +462,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -468,6 +470,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -515,6 +518,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -542,7 +546,6 @@ PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUps; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -570,7 +573,6 @@ PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUps; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -593,7 +595,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SyncUps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SyncUps"; }; @@ -616,7 +617,6 @@ PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpsTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SyncUps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SyncUps"; }; @@ -640,7 +640,6 @@ PROVISIONING_PROFILE = ""; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = SyncUps; }; @@ -663,7 +662,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = SyncUps; }; @@ -713,7 +711,7 @@ /* Begin XCRemoteSwiftPackageReference section */ CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-custom-dump.git"; + repositoryURL = "https://github.com/pointfreeco/swift-custom-dump"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; @@ -729,7 +727,7 @@ }; CAAF4DC72C3586AD00774888 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/xctest-dynamic-overlay.git"; + repositoryURL = "https://github.com/pointfreeco/xctest-dynamic-overlay"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; @@ -737,7 +735,7 @@ }; CAAF4DCA2C3586C900774888 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-identified-collections.git"; + repositoryURL = "https://github.com/pointfreeco/swift-identified-collections"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.1.0; @@ -745,7 +743,7 @@ }; CAAF4DCD2C3586E200774888 /* XCRemoteSwiftPackageReference "swift-tagged" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-tagged.git"; + repositoryURL = "https://github.com/pointfreeco/swift-tagged"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.10.0; @@ -761,7 +759,7 @@ }; CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-dependencies.git"; + repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; @@ -769,7 +767,7 @@ }; CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-concurrency-extras.git"; + repositoryURL = "https://github.com/pointfreeco/swift-concurrency-extras"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.1.0; diff --git a/SyncUps/SyncUps.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme b/SyncUps/SyncUps.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme index 859f8f8..f61e18d 100644 --- a/SyncUps/SyncUps.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme +++ b/SyncUps/SyncUps.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 2 Nov 2024 11:48:07 -0400 Subject: [PATCH 02/25] Use @Shared in SyncUps. --- .../xcshareddata/swiftpm/Package.resolved | 21 +- SyncUps/SyncUps.xcodeproj/project.pbxproj | 25 +- .../xcshareddata/swiftpm/Package.resolved | 86 +++--- SyncUps/SyncUps/App.swift | 84 +++--- .../SyncUps/Dependencies/DataManager.swift | 58 ---- SyncUps/SyncUps/Helpers.swift | 22 +- SyncUps/SyncUps/SyncUpsApp.swift | 26 +- SyncUps/SyncUps/SyncUpsList.swift | 140 ++-------- SyncUps/SyncUpsTests/AppTests.swift | 32 +-- SyncUps/SyncUpsTests/SyncUpsListTests.swift | 129 +++++---- .../SyncUpsUITests/SyncUpsListUITests.swift | 259 +++++++++--------- 11 files changed, 371 insertions(+), 511 deletions(-) delete mode 100644 SyncUps/SyncUps/Dependencies/DataManager.swift diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0d8ebc4..96b364f 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4330f83645f40a08674938f5dee887a1bbc67b200cdcfdc80b3b2c258d0336b8", + "originHash" : "89d10cf0feeac2589697abac8fb10999ed3f9eceea5a9a60fd2d480b62a26ccf", "pins" : [ { "identity" : "combine-schedulers", @@ -67,10 +67,10 @@ { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", - "version" : "1.1.0" + "branch" : "index-constraint", + "revision" : "b2a160f7c435c4de037677fc1c5cfb9099e0e088" } }, { @@ -87,8 +87,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "branch" : "overload-resolution-fixes", + "revision" : "0389427284f43369eeac55d7111ea013a1bea4c0" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-sharing", + "state" : { + "branch" : "main", + "revision" : "c4e3d6dc494535c35b70bc8df620125333f3eaff" } }, { diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index 5b37574..5b8676a 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -10,11 +10,11 @@ 2A368893298ADD2500E8C33A /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A368892298ADD2500E8C33A /* App.swift */; }; CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA1C303E2C358FEB001EE466 /* CustomDump */; }; CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1D22E92991BC7000B529DE /* AppTests.swift */; }; + CA315CFD2CD5602D002467C9 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CA315CFC2CD5602D002467C9 /* Sharing */; }; CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350C742984506E00434F8A /* SyncUpsApp.swift */; }; CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA1298450BF00434F8A /* SyncUpDetail.swift */; }; CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA2298450BF00434F8A /* RecordMeeting.swift */; }; CA350CB1298450BF00434F8A /* SyncUpForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA3298450BF00434F8A /* SyncUpForm.swift */; }; - CA350CB2298450BF00434F8A /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA5298450BF00434F8A /* DataManager.swift */; }; CA350CB3298450BF00434F8A /* SpeechClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA6298450BF00434F8A /* SpeechClient.swift */; }; CA350CB4298450BF00434F8A /* OpenSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA7298450BF00434F8A /* OpenSettings.swift */; }; CA350CB5298450BF00434F8A /* SoundEffectClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA8298450BF00434F8A /* SoundEffectClient.swift */; }; @@ -64,7 +64,6 @@ CA350CA1298450BF00434F8A /* SyncUpDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpDetail.swift; sourceTree = ""; }; CA350CA2298450BF00434F8A /* RecordMeeting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordMeeting.swift; sourceTree = ""; }; CA350CA3298450BF00434F8A /* SyncUpForm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpForm.swift; sourceTree = ""; }; - CA350CA5298450BF00434F8A /* DataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; CA350CA6298450BF00434F8A /* SpeechClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeechClient.swift; sourceTree = ""; }; CA350CA7298450BF00434F8A /* OpenSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSettings.swift; sourceTree = ""; }; CA350CA8298450BF00434F8A /* SoundEffectClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoundEffectClient.swift; sourceTree = ""; }; @@ -85,6 +84,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA315CFD2CD5602D002467C9 /* Sharing in Frameworks */, CAAF4DD72C3589A700774888 /* ConcurrencyExtras in Frameworks */, CAAF4DCF2C3586E200774888 /* Tagged in Frameworks */, CAAF4DD92C3589D400774888 /* DependenciesMacros in Frameworks */, @@ -175,7 +175,6 @@ CA350CA4298450BF00434F8A /* Dependencies */ = { isa = PBXGroup; children = ( - CA350CA5298450BF00434F8A /* DataManager.swift */, CA350CA6298450BF00434F8A /* SpeechClient.swift */, CA350CA7298450BF00434F8A /* OpenSettings.swift */, CA350CA8298450BF00434F8A /* SoundEffectClient.swift */, @@ -223,6 +222,7 @@ CAAF4DD82C3589D400774888 /* DependenciesMacros */, CA1C303E2C358FEB001EE466 /* CustomDump */, CA8586B72C61210E00A53451 /* SwiftUINavigation */, + CA315CFC2CD5602D002467C9 /* Sharing */, ); productName = SyncUps; productReference = CA350C712984506E00434F8A /* SyncUps.app */; @@ -309,6 +309,7 @@ CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */, CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */, CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */, + CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */, ); productRefGroup = CA350C722984506E00434F8A /* Products */; projectDirPath = ""; @@ -357,7 +358,6 @@ CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */, CA350CB3298450BF00434F8A /* SpeechClient.swift in Sources */, CA350CB5298450BF00434F8A /* SoundEffectClient.swift in Sources */, - CA350CB2298450BF00434F8A /* DataManager.swift in Sources */, CA350CB6298450BF00434F8A /* SyncUpsList.swift in Sources */, CA350CB8298450BF00434F8A /* Helpers.swift in Sources */, CA350CB1298450BF00434F8A /* SyncUpForm.swift in Sources */, @@ -455,7 +455,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -512,7 +512,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -717,6 +717,14 @@ minimumVersion = 1.0.0; }; }; + CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://github.com/pointfreeco/swift-sharing"; + requirement = { + branch = main; + kind = branch; + }; + }; CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-navigation"; @@ -781,6 +789,11 @@ package = CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */; productName = CustomDump; }; + CA315CFC2CD5602D002467C9 /* Sharing */ = { + isa = XCSwiftPackageProductDependency; + package = CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */; + productName = Sharing; + }; CA8586B72C61210E00A53451 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e06b57..e4180dd 100644 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "affaf3fbc11c211e04669aadae9dce0a64a9a162c456851a6d147fad27056c48", + "originHash" : "89d10cf0feeac2589697abac8fb10999ed3f9eceea5a9a60fd2d480b62a26ccf", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", - "version" : "1.3.3" + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version" : "1.1.0" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", + "version" : "1.2.0" } }, { @@ -51,62 +51,80 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "350e1e119babe8525f9bd155b76640a5de270184", - "version" : "1.3.0" + "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", + "version" : "1.4.1" } }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", - "version" : "1.0.2" + "branch" : "index-constraint", + "revision" : "b2a160f7c435c4de037677fc1c5cfb9099e0e088" } }, { - "identity" : "swift-syntax", + "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", + "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", + "version" : "2.2.2" } }, { - "identity" : "swift-tagged", + "identity" : "swift-perception", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged.git", + "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" + "branch" : "overload-resolution-fixes", + "revision" : "0389427284f43369eeac55d7111ea013a1bea4c0" } }, { - "identity" : "swiftui-navigation", + "identity" : "swift-sharing", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation.git", + "location" : "http://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "2ec6c3a15293efff6083966b38439a4004f25565", - "version" : "1.3.0" + "branch" : "main", + "revision" : "3fed67000a229a47ef2c905f5bcf45ffb70f1292" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" } }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ], diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 04f4fb9..fb77ceb 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -1,5 +1,7 @@ import CasePaths import Dependencies +import IdentifiedCollections +import Sharing import SwiftUI @MainActor @@ -72,7 +74,7 @@ class AppModel { model.onConfirmDeletion = { [weak model, weak self] in guard let model, let self else { return } - syncUpsList.syncUps.remove(id: model.syncUp.id) + _ = syncUpsList.$syncUps.withLock { $0.remove(id: model.syncUp.id) } path.removeLast() } @@ -83,7 +85,7 @@ class AppModel { model.onSyncUpUpdated = { [weak self] syncUp in guard let self else { return } - syncUpsList.syncUps[id: syncUp.id] = syncUp + syncUpsList.$syncUps.withLock { $0[id: syncUp.id] = syncUp } } } @@ -131,51 +133,41 @@ struct AppView: View { } } -struct App_Previews: PreviewProvider { - static var previews: some View { +#Preview("Happy path") { + @Shared(.syncUps) var syncUps: IdentifiedArray = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + + AppView( + model: AppModel(syncUpsList: SyncUpsListModel()) + ) +} + +#Preview("Deep link record flow") { + @Shared(.syncUps) var syncUps: IdentifiedArray = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + + Preview( + message: """ + The preview demonstrates how you can start the application navigated to a very specific \ + screen just by constructing a piece of state. In particular we will start the app drilled \ + down to the detail screen of a sync-up, and then further drilled down to the record screen \ + for a new meeting. + """ + ) { AppView( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - ) - .previewDisplayName("Happy path") - - Preview( - message: """ - The preview demonstrates how you can start the application navigated to a very specific \ - screen just by constructing a piece of state. In particular we will start the app drilled \ - down to the detail screen of a sync-up, and then further drilled down to the record screen \ - for a new meeting. - """ - ) { - AppView( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - AppModel( - path: [ - .detail(SyncUpDetailModel(syncUp: .mock)), - .record(RecordMeetingModel(syncUp: .mock)), - ], - syncUpsList: SyncUpsListModel() - ) - } + model: AppModel( + path: [ + .detail(SyncUpDetailModel(syncUp: .mock)), + .record(RecordMeetingModel(syncUp: .mock)), + ], + syncUpsList: SyncUpsListModel() ) - } - .previewDisplayName("Deep link record flow") + ) } } diff --git a/SyncUps/SyncUps/Dependencies/DataManager.swift b/SyncUps/SyncUps/Dependencies/DataManager.swift deleted file mode 100644 index f57ba19..0000000 --- a/SyncUps/SyncUps/Dependencies/DataManager.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Dependencies -import DependenciesMacros -import Foundation - -@DependencyClient -struct DataManager: Sendable { - var load: @Sendable (_ from: URL) throws -> Data - var save: @Sendable (Data, _ to: URL) throws -> Void -} - -extension DataManager: DependencyKey { - static let liveValue = DataManager( - load: { url in try Data(contentsOf: url) }, - save: { data, url in try data.write(to: url) } - ) - - static let testValue = DataManager() -} - -extension DependencyValues { - var dataManager: DataManager { - get { self[DataManager.self] } - set { self[DataManager.self] = newValue } - } -} - -extension DataManager { - static func mock(initialData: Data? = nil) -> DataManager { - let data = LockIsolated(initialData) - return DataManager( - load: { _ in - guard let data = data.value - else { - struct FileNotFound: Error {} - throw FileNotFound() - } - return data - }, - save: { newData, _ in data.setValue(newData) } - ) - } - - static let failToWrite = DataManager( - load: { url in Data() }, - save: { data, url in - struct SaveError: Error {} - throw SaveError() - } - ) - - static let failToLoad = DataManager( - load: { _ in - struct LoadError: Error {} - throw LoadError() - }, - save: { newData, url in } - ) -} diff --git a/SyncUps/SyncUps/Helpers.swift b/SyncUps/SyncUps/Helpers.swift index 0acd1aa..0446cd2 100644 --- a/SyncUps/SyncUps/Helpers.swift +++ b/SyncUps/SyncUps/Helpers.swift @@ -31,17 +31,15 @@ struct Preview: View { } } -struct Preview_Previews: PreviewProvider { - static var previews: some View { - Preview( - message: - """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ - ullamco laboris nisi ut aliquip ex ea commodo consequat. - """ - ) { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) - } +#Preview { + Preview( + message: + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ + ullamco laboris nisi ut aliquip ex ea commodo consequat. + """ + ) { + SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) } } diff --git a/SyncUps/SyncUps/SyncUpsApp.swift b/SyncUps/SyncUps/SyncUpsApp.swift index d9b8e1c..be8dee4 100644 --- a/SyncUps/SyncUps/SyncUpsApp.swift +++ b/SyncUps/SyncUps/SyncUpsApp.swift @@ -1,4 +1,6 @@ import Dependencies +import IdentifiedCollections +import Sharing import SwiftUI @main @@ -29,11 +31,10 @@ struct UITestingView: View { $0.date = DateGenerator { Date() } $0.soundEffectClient = .noop $0.uuid = UUIDGenerator { UUID() } + $0.defaultFileStorage = .inMemory switch testName { - case "testAdd": - $0.dataManager = .mock() - case "testDelete", "testEdit": - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([SyncUp.mock])) + case "testAdd", "testDelete", "testEdit": + break case "testRecord", "testRecord_Discard": $0.date = DateGenerator { Date(timeIntervalSince1970: 1_234_567_890) } $0.speechClient.authorizationStatus = { .authorized } @@ -48,20 +49,17 @@ struct UITestingView: View { $0.finish() } } - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([SyncUp.mock])) - case "testPersistence": - let id = ProcessInfo.processInfo.environment["TEST_UUID"]! - let url = URL.documentsDirectory.appending(component: "\(id).json") - $0.dataManager = .init( - load: { _ in try Data(contentsOf: url) }, - save: { data, _ in try data.write(to: url) } - ) - default: fatalError() } } operation: { - AppView(model: AppModel(syncUpsList: SyncUpsListModel())) + switch testName { + case "testDelete", "testEdit", "testRecord", "testRecord_Discard": + @Shared(.syncUps) var syncUps: IdentifiedArray = [SyncUp.mock] + default: + break + } + return AppView(model: AppModel(syncUpsList: SyncUpsListModel())) } } } diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index f41e749..c548c9d 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -1,29 +1,20 @@ import Dependencies import IdentifiedCollections import IssueReporting +import Sharing import SwiftUI import SwiftUINavigation @MainActor @Observable -final class SyncUpsListModel: ObservableObject { +final class SyncUpsListModel { var destination: Destination? - var syncUps: IdentifiedArrayOf { - didSet { - saveDebouncedTask?.cancel() - saveDebouncedTask = Task { - try await clock.sleep(for: .seconds(1)) - try dataManager.save(JSONEncoder().encode(syncUps), to: .syncUps) - } - } - } - private var saveDebouncedTask: Task? + @ObservationIgnored + @Shared(.syncUps) var syncUps @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored - @Dependency(\.dataManager) var dataManager - @ObservationIgnored @Dependency(\.uuid) var uuid var onSyncUpTapped: (SyncUp) -> Void = unimplemented("onSyncUpTapped") @@ -32,27 +23,12 @@ final class SyncUpsListModel: ObservableObject { @dynamicMemberLookup enum Destination { case add(SyncUpFormModel) - case alert(AlertState) - } - enum AlertAction { - case confirmLoadMockData } init( destination: Destination? = nil ) { self.destination = destination - self.syncUps = [] - - do { - self.syncUps = try JSONDecoder().decode( - IdentifiedArray.self, - from: self.dataManager.load(from: .syncUps) - ) - } catch is DecodingError { - self.destination = .alert(.dataFailedToLoad) - } catch { - } } func addSyncUpButtonTapped() { @@ -80,51 +56,16 @@ final class SyncUpsListModel: ObservableObject { if syncUp.attendees.isEmpty { syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) } - syncUps.append(syncUp) + _ = $syncUps.withLock { $0.append(syncUp) } } func syncUpTapped(syncUp: SyncUp) { onSyncUpTapped(syncUp) } - - func alertButtonTapped(_ action: AlertAction?) { - switch action { - case .confirmLoadMockData?: - withAnimation { - syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - } - case nil: - break - } - } -} - -extension AlertState where Action == SyncUpsListModel.AlertAction { - static let dataFailedToLoad = Self { - TextState("Data failed to load") - } actions: { - ButtonState(action: .confirmLoadMockData) { - TextState("Yes") - } - ButtonState(role: .cancel) { - TextState("No") - } - } message: { - TextState( - """ - Unfortunately your past data failed to load. Would you like to load some mock data to play \ - around with? - """ - ) - } } struct SyncUpsList: View { - @ObservedObject var model: SyncUpsListModel + @Bindable var model: SyncUpsListModel var body: some View { List { @@ -163,9 +104,6 @@ struct SyncUpsList: View { } } } - .alert(self.$model.destination.alert) { - self.model.alertButtonTapped($0) - } } } @@ -203,8 +141,10 @@ extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } -extension URL { - fileprivate static let syncUps = Self.documentsDirectory.appending(component: "sync-ups.json") +extension PersistenceReaderKey where Self == FileStorageKey>.Default { + static var syncUps: Self { + Self[.fileStorage(URL.documentsDirectory.appending(component: "sync-ups.json")), default: []] + } } struct SyncUpsList_Previews: PreviewProvider { @@ -217,41 +157,15 @@ struct SyncUpsList_Previews: PreviewProvider { when its load endpoint is called it will load whatever data we want. """ ) { - SyncUpsList( - model: withDependencies { - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([ - SyncUp.mock, - .engineeringMock, - .designMock, - ]) - ) - } operation: { - SyncUpsListModel() - } - ) + @Shared(.syncUps) var syncUps: IdentifiedArray = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + SyncUpsList(model: SyncUpsListModel()) } .previewDisplayName("Mocking initial sync-ups") - Preview( - message: """ - This preview demonstrates how to test the flow of loading bad data from disk, in which \ - case an alert should be shown. This can be done by overridding the DataManager dependency \ - so that its initial data does not properly decode into a collection of sync-ups. - """ - ) { - SyncUpsList( - model: withDependencies { - $0.dataManager = .mock( - initialData: Data("!@#$% bad data ^&*()".utf8) - ) - } operation: { - SyncUpsListModel() - } - ) - } - .previewDisplayName("Load data failure") - Preview( message: """ The preview demonstrates how you can start the application navigated to a very specific \ @@ -259,22 +173,18 @@ struct SyncUpsList_Previews: PreviewProvider { "Add sync-up" screen opened and with the last attendee text field focused. """ ) { + var syncUp = SyncUp.mock + let lastAttendee = Attendee(id: Attendee.ID()) + let _ = syncUp.attendees.append(lastAttendee) SyncUpsList( - model: withDependencies { - $0.dataManager = .mock() - } operation: { - var syncUp = SyncUp.mock - let lastAttendee = Attendee(id: Attendee.ID()) - let _ = syncUp.attendees.append(lastAttendee) - return SyncUpsListModel( - destination: .add( - SyncUpFormModel( - focus: .attendee(lastAttendee.id), - syncUp: syncUp - ) + model: SyncUpsListModel( + destination: .add( + SyncUpFormModel( + focus: .attendee(lastAttendee.id), + syncUp: syncUp ) ) - } + ) ) } .previewDisplayName("Deep link add flow") diff --git a/SyncUps/SyncUpsTests/AppTests.swift b/SyncUps/SyncUpsTests/AppTests.swift index 661aeb9..22ec90a 100644 --- a/SyncUps/SyncUpsTests/AppTests.swift +++ b/SyncUps/SyncUpsTests/AppTests.swift @@ -2,6 +2,8 @@ import CasePaths import CustomDump import Dependencies import Foundation +import IdentifiedCollections +import Sharing import Testing @testable import SyncUps @@ -20,11 +22,11 @@ struct AppTests { duration: .seconds(10), title: "Engineering" ) + @Shared(.syncUps) var syncUps: IdentifiedArray = [syncUp] let model = withDependencies { $0.continuousClock = ImmediateClock() $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - $0.dataManager = .mock(initialData: try? JSONEncoder().encode([syncUp])) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -66,11 +68,10 @@ struct AppTests { @Test func delete() async throws { - let model = try withDependencies { + @Shared(.syncUps) var syncUps: IdentifiedArray = [SyncUp.mock] + + let model = withDependencies { $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try JSONEncoder().encode([SyncUp.mock]) - ) } operation: { AppModel(syncUpsList: SyncUpsListModel()) } @@ -93,18 +94,17 @@ struct AppTests { @Test func detailEdit() async throws { - let model = try withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try JSONEncoder().encode([ - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ] - ) - ]) + @Shared(.syncUps) var syncUps = [ + SyncUp( + id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, + attendees: [ + Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) + ] ) + ] + + let model = withDependencies { + $0.continuousClock = ImmediateClock() } operation: { AppModel(syncUpsList: SyncUpsListModel()) } diff --git a/SyncUps/SyncUpsTests/SyncUpsListTests.swift b/SyncUps/SyncUpsTests/SyncUpsListTests.swift index 1baa620..3a30f63 100644 --- a/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -12,12 +12,8 @@ import Testing struct SyncUpsListTests { @Test func add() async throws { - let savedData = LockIsolated(Data?.none) - let model = withDependencies { $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() - $0.dataManager.save = { @Sendable data, _ in savedData.setValue(data) } $0.uuid = .incrementing } operation: { SyncUpsListModel() @@ -60,7 +56,6 @@ struct SyncUpsListTests { func addValidatedAttendees() async throws { let model = withDependencies { $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() $0.uuid = .incrementing } operation: { SyncUpsListModel( @@ -99,66 +94,66 @@ struct SyncUpsListTests { ) } - @Test - func loadingDataDecodingFailed() async throws { - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: Data("!@#$ BAD DATA %^&*()".utf8) - ) - } operation: { - SyncUpsListModel() - } - - let alert = try #require(model.destination?.alert) - - expectNoDifference(alert, .dataFailedToLoad) - - model.alertButtonTapped(.confirmLoadMockData) - - expectNoDifference(model.syncUps, [.mock, .designMock, .engineeringMock]) - } - - @Test - func loadingDataFileNotFound() async throws { - let model = withDependencies { - $0.dataManager.load = { @Sendable _ in - struct FileNotFound: Error {} - throw FileNotFound() - } - } operation: { - SyncUpsListModel() - } - - #expect(model.destination == nil) - } - - @Test - func save() async throws { - let clock = TestClock() - - let savedData = LockIsolated(Data()) - await confirmation { confirmation in - let model = withDependencies { - $0.dataManager.load = { @Sendable _ in try JSONEncoder().encode([SyncUp]()) } - $0.dataManager.save = { @Sendable data, _ in - savedData.setValue(data) - confirmation() - } - $0.continuousClock = clock - } operation: { - SyncUpsListModel( - destination: .add(SyncUpFormModel(syncUp: .mock)) - ) - } - - model.confirmAddSyncUpButtonTapped() - await clock.advance(by: .seconds(1)) - } - - expectNoDifference( - try JSONDecoder().decode([SyncUp].self, from: savedData.value), - [.mock] - ) - } +// @Test +// func loadingDataDecodingFailed() async throws { +// let model = withDependencies { +// $0.continuousClock = ImmediateClock() +// $0.dataManager = .mock( +// initialData: Data("!@#$ BAD DATA %^&*()".utf8) +// ) +// } operation: { +// SyncUpsListModel() +// } +// +// let alert = try #require(model.destination?.alert) +// +// expectNoDifference(alert, .dataFailedToLoad) +// +// model.alertButtonTapped(.confirmLoadMockData) +// +// expectNoDifference(model.syncUps, [.mock, .designMock, .engineeringMock]) +// } + +// @Test +// func loadingDataFileNotFound() async throws { +// let model = withDependencies { +// $0.dataManager.load = { @Sendable _ in +// struct FileNotFound: Error {} +// throw FileNotFound() +// } +// } operation: { +// SyncUpsListModel() +// } +// +// #expect(model.destination == nil) +// } +// +// @Test +// func save() async throws { +// let clock = TestClock() +// +// let savedData = LockIsolated(Data()) +// await confirmation { confirmation in +// let model = withDependencies { +// $0.dataManager.load = { @Sendable _ in try JSONEncoder().encode([SyncUp]()) } +// $0.dataManager.save = { @Sendable data, _ in +// savedData.setValue(data) +// confirmation() +// } +// $0.continuousClock = clock +// } operation: { +// SyncUpsListModel( +// destination: .add(SyncUpFormModel(syncUp: .mock)) +// ) +// } +// +// model.confirmAddSyncUpButtonTapped() +// await clock.advance(by: .seconds(1)) +// } +// +// expectNoDifference( +// try JSONDecoder().decode([SyncUp].self, from: savedData.value), +// [.mock] +// ) +// } } diff --git a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift index f7342f2..1bb2685 100644 --- a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift +++ b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift @@ -1,137 +1,122 @@ -import XCTest - -// This test case demonstrates how one can write UI tests using the swift-dependencies library. We -// do not really recommend writing UI tests in general as they are slow and flakey, but if you must -// then this shows how. -// -// The key to doing this is to set a launch environment variable on your XCUIApplication instance, -// and then check for that value in the entry point of the application. If the environment value -// exists, you can use 'withDependencies' to override dependencies to be used in the UI test. -final class SyncUpsListUITests: XCTestCase { - var app: XCUIApplication! - - override func setUpWithError() throws { - nonisolated(unsafe) let test = self - MainActor.assumeIsolated { - test.app = XCUIApplication() - test.app.launchEnvironment["SWIFT_DEPENDENCIES_CONTEXT"] = "test" - test.app.launchEnvironment["UI_TEST_NAME"] = String( - test.name.split(separator: " ").last?.dropLast() ?? "" - ) - test.app.launchEnvironment["TEST_UUID"] = UUID().uuidString - test.app.launch() - } - } - - // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in - // the form, and then adding the sync-up to the list. It's a very simple test, but it takes - // approximately 10 seconds to run, and it depends on a lot of internal implementation details to - // get right, such as tapping a button with the literal label "Add". - // - // This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where - // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when - // the sync-up is added to the list its data will be persisted to disk so that it will be - // available on next launch. - @MainActor - func testAdd() throws { - self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - let titleTextField = self.app.collectionViews.textFields["Title"] - let nameTextField = self.app.collectionViews.textFields["Name"] - - titleTextField.typeText("Engineering") - - nameTextField.tap() - nameTextField.typeText("Blob") - - self.app.buttons["New attendee"].tap() - self.app.typeText("Blob Jr.") - - self.app.navigationBars["New sync-up"].buttons["Add"].tap() - - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) - } - - @MainActor - func testDelete() async throws { - self.app.staticTexts["Design"].tap() - - self.app.buttons["Delete"].tap() - XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) - - self.app.buttons["Yes"].tap() - try await Task.sleep(for: .seconds(0.3)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, false) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) - } - - @MainActor - func testEdit() async throws { - self.app.staticTexts["Design"].tap() - - self.app.buttons["Edit"].tap() - let titleTextField = self.app.textFields["Title"] - titleTextField.typeText(" & Product") - - self.app.buttons["Done"].tap() - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) - - self.app.buttons["Daily Sync-ups"].tap() - try await Task.sleep(for: .seconds(0.3)) - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) - } - - @MainActor - func testRecord() async throws { - self.app.staticTexts["Design"].tap() - - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() - - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Save and end"].tap() - - try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) - - self.app.buttons["Daily Sync-ups"].tap() - self.app.staticTexts["Design"].tap() - - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) - - self.app.staticTexts["February 13, 2009"].tap() - self.app.staticTexts["Hello world!"].tap() - } - - @MainActor - func testRecord_Discard() async throws { - self.app.staticTexts["Design"].tap() - - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() - - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Discard"].tap() - - try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) - } - - @MainActor - func testPersistence() async throws { - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, false) - - self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - let titleTextField = self.app.collectionViews.textFields["Title"] - titleTextField.typeText("Engineering") - self.app.navigationBars["New sync-up"].buttons["Add"].tap() - try await Task.sleep(for: .seconds(1)) - - XCUIDevice.shared.press(.home) - self.app.launch() - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) - } -} +//import XCTest +// +//// This test case demonstrates how one can write UI tests using the swift-dependencies library. We +//// do not really recommend writing UI tests in general as they are slow and flakey, but if you must +//// then this shows how. +//// +//// The key to doing this is to set a launch environment variable on your XCUIApplication instance, +//// and then check for that value in the entry point of the application. If the environment value +//// exists, you can use 'withDependencies' to override dependencies to be used in the UI test. +//final class SyncUpsListUITests: XCTestCase { +// var app: XCUIApplication! +// +// override func setUpWithError() throws { +// nonisolated(unsafe) let test = self +// MainActor.assumeIsolated { +// test.app = XCUIApplication() +// test.app.launchEnvironment["SWIFT_DEPENDENCIES_CONTEXT"] = "test" +// test.app.launchEnvironment["UI_TEST_NAME"] = String( +// test.name.split(separator: " ").last?.dropLast() ?? "" +// ) +// test.app.launchEnvironment["TEST_UUID"] = UUID().uuidString +// test.app.launch() +// } +// } +// +// // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in +// // the form, and then adding the sync-up to the list. It's a very simple test, but it takes +// // approximately 10 seconds to run, and it depends on a lot of internal implementation details to +// // get right, such as tapping a button with the literal label "Add". +// // +// // This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where +// // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when +// // the sync-up is added to the list its data will be persisted to disk so that it will be +// // available on next launch. +// @MainActor +// func testAdd() throws { +// self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() +// let titleTextField = self.app.collectionViews.textFields["Title"] +// let nameTextField = self.app.collectionViews.textFields["Name"] +// +// titleTextField.typeText("Engineering") +// +// nameTextField.tap() +// nameTextField.typeText("Blob") +// +// self.app.buttons["New attendee"].tap() +// self.app.typeText("Blob Jr.") +// +// self.app.navigationBars["New sync-up"].buttons["Add"].tap() +// +// XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) +// } +// +// @MainActor +// func testDelete() async throws { +// self.app.staticTexts["Design"].tap() +// +// self.app.buttons["Delete"].tap() +// XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) +// +// self.app.buttons["Yes"].tap() +// try await Task.sleep(for: .seconds(0.3)) +// XCTAssertEqual(self.app.staticTexts["Design"].exists, false) +// XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) +// } +// +// @MainActor +// func testEdit() async throws { +// self.app.staticTexts["Design"].tap() +// +// self.app.buttons["Edit"].tap() +// let titleTextField = self.app.textFields["Title"] +// titleTextField.typeText(" & Product") +// +// self.app.buttons["Done"].tap() +// XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) +// +// self.app.buttons["Daily Sync-ups"].tap() +// try await Task.sleep(for: .seconds(0.3)) +// XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) +// XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) +// } +// +// @MainActor +// func testRecord() async throws { +// self.app.staticTexts["Design"].tap() +// +// self.app.buttons["Start Meeting"].tap() +// self.app.buttons["End meeting"].tap() +// +// XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) +// self.app.buttons["Save and end"].tap() +// +// try await Task.sleep(for: .seconds(0.5)) +// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) +// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) +// +// self.app.buttons["Daily Sync-ups"].tap() +// self.app.staticTexts["Design"].tap() +// +// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) +// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) +// +// self.app.staticTexts["February 13, 2009"].tap() +// self.app.staticTexts["Hello world!"].tap() +// } +// +// @MainActor +// func testRecord_Discard() async throws { +// self.app.staticTexts["Design"].tap() +// +// self.app.buttons["Start Meeting"].tap() +// self.app.buttons["End meeting"].tap() +// +// XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) +// self.app.buttons["Discard"].tap() +// +// try await Task.sleep(for: .seconds(0.5)) +// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) +// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) +// } +//} From 738d91db0085a93f5f70473699f9177b58078542 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 2 Nov 2024 11:48:50 -0400 Subject: [PATCH 03/25] clean up --- .../xcshareddata/swiftpm/Package.resolved | 61 +++++++++++-------- SyncUps/SyncUps/SyncUpsList.swift | 4 +- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e06b57..7fc86c0 100644 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "affaf3fbc11c211e04669aadae9dce0a64a9a162c456851a6d147fad27056c48", + "originHash" : "49bd7cf9c8f940c70a8cb0844438a7b6d778ba8149aa2ae4a2e8853fd33f7996", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", - "version" : "1.3.3" + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", + "version" : "1.2.0" } }, { @@ -51,14 +51,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { "revision" : "350e1e119babe8525f9bd155b76640a5de270184", "version" : "1.3.0" @@ -67,46 +67,55 @@ { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", - "version" : "1.0.2" + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" } }, { - "identity" : "swift-syntax", + "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", + "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", + "version" : "2.2.2" } }, { - "identity" : "swift-tagged", + "identity" : "swift-perception", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged.git", + "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" + "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", + "version" : "1.3.5" } }, { - "identity" : "swiftui-navigation", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation.git", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2ec6c3a15293efff6083966b38439a4004f25565", - "version" : "1.3.0" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" } }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ], diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index f41e749..0ecd5d6 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -6,7 +6,7 @@ import SwiftUINavigation @MainActor @Observable -final class SyncUpsListModel: ObservableObject { +final class SyncUpsListModel { var destination: Destination? var syncUps: IdentifiedArrayOf { didSet { @@ -124,7 +124,7 @@ extension AlertState where Action == SyncUpsListModel.AlertAction { } struct SyncUpsList: View { - @ObservedObject var model: SyncUpsListModel + @Bindable var model: SyncUpsListModel var body: some View { List { From 0b9a733e193943e3314043a4ff63e54cde7e1d83 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 3 Nov 2024 15:22:16 -0500 Subject: [PATCH 04/25] wip --- SyncUps/SyncUps/App.swift | 58 +++++++---------------------- SyncUps/SyncUps/Helpers.swift | 3 +- SyncUps/SyncUps/RecordMeeting.swift | 30 +++++++++++---- SyncUps/SyncUps/SyncUpDetail.swift | 43 +++++++++++++-------- 4 files changed, 65 insertions(+), 69 deletions(-) diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index fb77ceb..4638116 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -40,9 +40,12 @@ class AppModel { private func bind() { syncUpsList.onSyncUpTapped = { [weak self] syncUp in - guard let self else { return } + guard + let self, + let sharedSyncUp = Shared(syncUpsList.$syncUps[id: syncUp.id]) + else { return } withDependencies(from: self) { - self.path.append(.detail(SyncUpDetailModel(syncUp: syncUp))) + self.path.append(.detail(SyncUpDetailModel(syncUp: sharedSyncUp))) } } @@ -51,65 +54,32 @@ class AppModel { case let .detail(detailModel): bindDetail(model: detailModel) - case .meeting: + case .meeting, .record: break - - case let .record(recordModel): - bindRecord(model: recordModel) } } } private func bindDetail(model: SyncUpDetailModel) { model.onMeetingStarted = { [weak self] syncUp in - guard let self else { return } + guard + let self, + let sharedSyncUp = Shared(syncUpsList.$syncUps[id: syncUp.id]) + else { return } + withDependencies(from: self) { self.path.append( .record( - RecordMeetingModel(syncUp: syncUp) + RecordMeetingModel(syncUp: sharedSyncUp) ) ) } } - model.onConfirmDeletion = { [weak model, weak self] in - guard let model, let self else { return } - _ = syncUpsList.$syncUps.withLock { $0.remove(id: model.syncUp.id) } - path.removeLast() - } - model.onMeetingTapped = { [weak model, weak self] meeting in guard let model, let self else { return } path.append(.meeting(meeting, syncUp: model.syncUp)) } - - model.onSyncUpUpdated = { [weak self] syncUp in - guard let self else { return } - syncUpsList.$syncUps.withLock { $0[id: syncUp.id] = syncUp } - } - } - - private func bindRecord(model: RecordMeetingModel) { - model.onMeetingFinished = { [weak self] transcript in - guard let self else { return } - - guard - case let .some(.detail(detailModel)) = path.dropLast().last - else { - return - } - - let meeting = Meeting( - id: Meeting.ID(self.uuid()), - date: self.now, - transcript: transcript - ) - - let didCancel = (try? await clock.sleep(for: .milliseconds(400))) == nil - _ = withAnimation(didCancel ? nil : .default) { - detailModel.syncUp.meetings.insert(meeting, at: 0) - } - } } } @@ -163,8 +133,8 @@ struct AppView: View { AppView( model: AppModel( path: [ - .detail(SyncUpDetailModel(syncUp: .mock)), - .record(RecordMeetingModel(syncUp: .mock)), + .detail(SyncUpDetailModel(syncUp: Shared(.mock))), + .record(RecordMeetingModel(syncUp: Shared(.mock))), ], syncUpsList: SyncUpsListModel() ) diff --git a/SyncUps/SyncUps/Helpers.swift b/SyncUps/SyncUps/Helpers.swift index 0446cd2..15df5e4 100644 --- a/SyncUps/SyncUps/Helpers.swift +++ b/SyncUps/SyncUps/Helpers.swift @@ -1,3 +1,4 @@ +import Sharing import SwiftUI // NB: This is only used for previews. @@ -40,6 +41,6 @@ struct Preview: View { ullamco laboris nisi ut aliquip ex ea commodo consequat. """ ) { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) + SyncUpDetailView(model: SyncUpDetailModel(syncUp: Shared(.mock))) } } diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index b663e4d..2243c9d 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -1,6 +1,7 @@ import Clocks import Dependencies import IssueReporting +import Sharing import Speech import SwiftUI import SwiftUINavigation @@ -12,17 +13,20 @@ final class RecordMeetingModel { var isDismissed = false var secondsElapsed = 0 var speakerIndex = 0 - let syncUp: SyncUp + @ObservationIgnored + @Shared var syncUp: SyncUp private var transcript = "" @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored + @Dependency(\.date.now) var now + @ObservationIgnored @Dependency(\.soundEffectClient) var soundEffectClient @ObservationIgnored @Dependency(\.speechClient) var speechClient - - var onMeetingFinished: (_ transcript: String) async -> Void = unimplemented("onMeetingFinished") + @ObservationIgnored + @Dependency(\.uuid) var uuid @CasePathable @dynamicMemberLookup @@ -37,10 +41,10 @@ final class RecordMeetingModel { init( destination: Destination? = nil, - syncUp: SyncUp + syncUp: Shared ) { self.destination = destination - self.syncUp = syncUp + self._syncUp = syncUp } var durationRemaining: Duration { @@ -137,7 +141,17 @@ final class RecordMeetingModel { private func finishMeeting() async { isDismissed = true - await onMeetingFinished(transcript) + + let meeting = Meeting( + id: Meeting.ID(self.uuid()), + date: self.now, + transcript: transcript + ) + + try? await Task.sleep(for: .milliseconds(400)) + _ = withAnimation { + $syncUp.withLock { $0.meetings.insert(meeting, at: 0) } + } } } @@ -379,7 +393,7 @@ struct RecordMeeting_Previews: PreviewProvider { static var previews: some View { NavigationStack { RecordMeetingView( - model: RecordMeetingModel(syncUp: .mock) + model: RecordMeetingModel(syncUp: Shared(.mock)) ) } .previewDisplayName("Happy path") @@ -395,7 +409,7 @@ struct RecordMeeting_Previews: PreviewProvider { model: withDependencies { $0.speechClient = .fail(after: .seconds(2)) } operation: { - RecordMeetingModel(syncUp: .mock) + RecordMeetingModel(syncUp: Shared(.mock)) } ) } diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 9419da0..c845b6e 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -2,6 +2,7 @@ import Clocks import CustomDump import Dependencies import IssueReporting +import Sharing import SwiftUI import SwiftUINavigation @@ -10,11 +11,8 @@ import SwiftUINavigation final class SyncUpDetailModel { var destination: Destination? var isDismissed = false - var syncUp: SyncUp { - didSet { - onSyncUpUpdated(syncUp) - } - } + @ObservationIgnored + @Shared var syncUp: SyncUp @ObservationIgnored @Dependency(\.continuousClock) var clock @@ -27,10 +25,8 @@ final class SyncUpDetailModel { @ObservationIgnored @Dependency(\.uuid) var uuid - var onConfirmDeletion: () -> Void = unimplemented("onConfirmDeletion") var onMeetingTapped: (Meeting) -> Void = unimplemented("onMeetingTapped") var onMeetingStarted: (SyncUp) -> Void = unimplemented("onMeetingStarted") - var onSyncUpUpdated: (SyncUp) -> Void = unimplemented("onSyncUpUpdated") @CasePathable @dynamicMemberLookup @@ -46,14 +42,14 @@ final class SyncUpDetailModel { init( destination: Destination? = nil, - syncUp: SyncUp + syncUp: Shared ) { self.destination = destination - self.syncUp = syncUp + self._syncUp = syncUp } func deleteMeetings(atOffsets indices: IndexSet) { - syncUp.meetings.remove(atOffsets: indices) + $syncUp.withLock { $0.meetings.remove(atOffsets: indices) } } func meetingTapped(_ meeting: Meeting) { @@ -67,7 +63,8 @@ final class SyncUpDetailModel { func alertButtonTapped(_ action: AlertAction?) async { switch action { case .confirmDeletion?: - onConfirmDeletion() + @Shared(.syncUps) var syncUps + _ = $syncUps.withLock { $0.remove(id: syncUp.id) } isDismissed = true case .continueWithoutRecording?: @@ -97,7 +94,7 @@ final class SyncUpDetailModel { guard case let .edit(model) = destination else { return } - syncUp = model.syncUp + $syncUp.withLock { $0 = model.syncUp } destination = nil } @@ -121,6 +118,7 @@ final class SyncUpDetailModel { extension SyncUpDetailModel: HashableObject {} struct SyncUpDetailView: View { + @Environment(\.dismiss) var dismiss @State var model: SyncUpDetailModel var body: some View { @@ -216,6 +214,11 @@ struct SyncUpDetailView: View { } } } + .onChange(of: model.isDismissed) { + if model.isDismissed { + dismiss() + } + } } } @@ -309,7 +312,9 @@ struct SyncUpDetail_Previews: PreviewProvider { """ ) { NavigationStack { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: .mock)) + SyncUpDetailView( + model: SyncUpDetailModel(syncUp: Shared(.mock)) + ) } } .previewDisplayName("Happy path") @@ -327,7 +332,9 @@ struct SyncUpDetail_Previews: PreviewProvider { model: withDependencies { $0.speechClient = .fail(after: .seconds(2)) } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel( + syncUp: Shared(.mock) + ) } ) } @@ -346,7 +353,9 @@ struct SyncUpDetail_Previews: PreviewProvider { model: withDependencies { $0.speechClient.authorizationStatus = { .denied } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel( + syncUp: Shared(.mock) + ) } ) } @@ -365,7 +374,9 @@ struct SyncUpDetail_Previews: PreviewProvider { model: withDependencies { $0.speechClient.authorizationStatus = { .restricted } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel( + syncUp: Shared(.mock) + ) } ) } From 9018fa22e07987a29e3595f50f99a3e3ee68b809 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 3 Nov 2024 15:23:01 -0500 Subject: [PATCH 05/25] wip; --- .../swiftpm/Package.resolved.orig | 146 ------------------ 1 file changed, 146 deletions(-) delete mode 100644 SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig deleted file mode 100644 index a30d1bf..0000000 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig +++ /dev/null @@ -1,146 +0,0 @@ -{ -<<<<<<< HEAD - "originHash" : "89d10cf0feeac2589697abac8fb10999ed3f9eceea5a9a60fd2d480b62a26ccf", -======= - "originHash" : "49bd7cf9c8f940c70a8cb0844438a7b6d778ba8149aa2ae4a2e8853fd33f7996", ->>>>>>> origin/update - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", - "version" : "1.5.6" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { -<<<<<<< HEAD - "branch" : "index-constraint", - "revision" : "b2a160f7c435c4de037677fc1c5cfb9099e0e088" -======= - "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", - "version" : "1.1.0" ->>>>>>> origin/update - } - }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", - "version" : "2.2.2" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { -<<<<<<< HEAD - "branch" : "overload-resolution-fixes", - "revision" : "0389427284f43369eeac55d7111ea013a1bea4c0" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "http://github.com/pointfreeco/swift-sharing", - "state" : { - "branch" : "main", - "revision" : "3fed67000a229a47ef2c905f5bcf45ffb70f1292" -======= - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" ->>>>>>> origin/update - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" - } - }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" - } - } - ], - "version" : 3 -} From 8a4c7e7108008c56f37272dfefca1e2ff9448dcf Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 3 Nov 2024 15:46:56 -0500 Subject: [PATCH 06/25] fix tests --- SyncUps/SyncUpsTests/AppTests.swift | 6 +- SyncUps/SyncUpsTests/RecordMeetingTests.swift | 270 +++++++++--------- SyncUps/SyncUpsTests/SyncUpDetailTests.swift | 77 +++-- SyncUps/SyncUpsTests/SyncUpsListTests.swift | 63 ---- 4 files changed, 185 insertions(+), 231 deletions(-) diff --git a/SyncUps/SyncUpsTests/AppTests.swift b/SyncUps/SyncUpsTests/AppTests.swift index 22ec90a..0ee95ea 100644 --- a/SyncUps/SyncUpsTests/AppTests.swift +++ b/SyncUps/SyncUpsTests/AppTests.swift @@ -44,8 +44,8 @@ struct AppTests { } operation: { AppModel( path: [ - .detail(SyncUpDetailModel(syncUp: syncUp)), - .record(RecordMeetingModel(syncUp: syncUp)), + .detail(SyncUpDetailModel(syncUp: $syncUps[0])), + .record(RecordMeetingModel(syncUp: $syncUps[0])), ], syncUpsList: SyncUpsListModel() ) @@ -88,7 +88,7 @@ struct AppTests { await detailModel.alertButtonTapped(.confirmDeletion) - expectNoDifference(model.path, []) + #expect(detailModel.isDismissed == true) expectNoDifference(model.syncUpsList.syncUps, []) } diff --git a/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/SyncUps/SyncUpsTests/RecordMeetingTests.swift index 4a0d2fc..c8ede2e 100644 --- a/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -1,6 +1,8 @@ import CasePaths import CustomDump import Dependencies +import Foundation +import Sharing import Testing @testable import SyncUps @@ -15,67 +17,65 @@ struct RecordMeetingTests { let model = withDependencies { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - // NB: This should not be necessary, but it doesn't seem like there is a better way to - // guarantee that the timer has started up. See this forum discussion for more information - // on the difficulties of testing async code in Swift: - // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 - try await Task.sleep(for: .milliseconds(300)) + // NB: This should not be necessary, but it doesn't seem like there is a better way to + // guarantee that the timer has started up. See this forum discussion for more information + // on the difficulties of testing async code in Swift: + // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + try await Task.sleep(for: .milliseconds(300)) - #expect(model.speakerIndex == 0) - #expect(model.durationRemaining == .seconds(3)) + #expect(model.speakerIndex == 0) + #expect(model.durationRemaining == .seconds(3)) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 1) - #expect(model.durationRemaining == .seconds(2)) - #expect(soundEffectPlayCount.value == 1) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 1) + #expect(model.durationRemaining == .seconds(2)) + #expect(soundEffectPlayCount.value == 1) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - await clock.advance(by: .seconds(1)) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(0)) - #expect(soundEffectPlayCount.value == 2) + await clock.advance(by: .seconds(1)) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(0)) + #expect(soundEffectPlayCount.value == 2) - await task.value + await task.value - #expect(soundEffectPlayCount.value == 2) - } + #expect(soundEffectPlayCount.value == 2) } @Test func recordTranscript() async throws { let model = withDependencies { $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -89,24 +89,31 @@ struct RecordMeetingTests { continuation.finish() } } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) ) ) } - await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "I completed the project") - confirmation() - } + await model.task() - await model.task() - } + expectNoDifference( + model.syncUp.meetings, + [ + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1234567890), + transcript: "I completed the project" + ) + ] + ) } @Test @@ -115,38 +122,33 @@ struct RecordMeetingTests { let model = withDependencies { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } operation: { - RecordMeetingModel(syncUp: .mock) + RecordMeetingModel(syncUp: Shared(SyncUp.mock)) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - model.endMeetingButtonTapped() + model.endMeetingButtonTapped() - let alert = try #require(model.destination?.alert) + let alert = try #require(model.destination?.alert) - expectNoDifference(alert, .endMeeting(isDiscardable: true)) + expectNoDifference(alert, .endMeeting(isDiscardable: true)) - await clock.advance(by: .seconds(5)) + await clock.advance(by: .seconds(5)) - #expect(model.speakerIndex == 0) - #expect(model.durationRemaining == .seconds(60)) + #expect(model.speakerIndex == 0) + #expect(model.durationRemaining == .seconds(60)) - await model.alertButtonTapped(.confirmSave) + await model.alertButtonTapped(.confirmSave) - task.cancel() - await task.value - } + task.cancel() + await task.value } @Test @@ -158,7 +160,7 @@ struct RecordMeetingTests { $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .denied } } operation: { - RecordMeetingModel(syncUp: .mock) + RecordMeetingModel(syncUp: Shared(SyncUp.mock)) } let task = Task { @@ -185,71 +187,69 @@ struct RecordMeetingTests { let model = withDependencies { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { - #expect($0 == "") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - model.nextButtonTapped() + model.nextButtonTapped() - #expect(model.speakerIndex == 1) - #expect(model.durationRemaining == .seconds(2)) - #expect(soundEffectPlayCount.value == 1) + #expect(model.speakerIndex == 1) + #expect(model.durationRemaining == .seconds(2)) + #expect(soundEffectPlayCount.value == 1) - model.nextButtonTapped() + model.nextButtonTapped() - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - model.nextButtonTapped() + model.nextButtonTapped() - let alert = try #require(model.destination?.alert) + let alert = try #require(model.destination?.alert) - expectNoDifference(alert, .endMeeting(isDiscardable: false)) + expectNoDifference(alert, .endMeeting(isDiscardable: false)) - await clock.advance(by: .seconds(5)) + await clock.advance(by: .seconds(5)) - #expect(model.speakerIndex == 2) - #expect(model.durationRemaining == .seconds(1)) - #expect(soundEffectPlayCount.value == 2) + #expect(model.speakerIndex == 2) + #expect(model.durationRemaining == .seconds(1)) + #expect(soundEffectPlayCount.value == 2) - await model.alertButtonTapped(.confirmSave) + await model.alertButtonTapped(.confirmSave) - #expect(soundEffectPlayCount.value == 2) + #expect(soundEffectPlayCount.value == 2) - task.cancel() - await task.value - } + task.cancel() + await task.value } @Test func speechRecognitionFailure_Continue() async throws { let model = withDependencies { $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -264,41 +264,37 @@ struct RecordMeetingTests { $0.finish(throwing: SpeechRecognitionFailure()) } } + $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) ) ) } - try await confirmation { confirmation in - model.onMeetingFinished = { transcript in - #expect(transcript == "I completed the project ❌") - confirmation() - } - - let task = Task { - await model.task() - } + let task = Task { + await model.task() + } - // NB: This should not be necessary, but it doesn't seem like there is a better way to - // guarantee that the timer has started up. See this forum discussion for more information - // on the difficulties of testing async code in Swift: - // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 - try await Task.sleep(for: .milliseconds(100)) + // NB: This should not be necessary, but it doesn't seem like there is a better way to + // guarantee that the timer has started up. See this forum discussion for more information + // on the difficulties of testing async code in Swift: + // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + try await Task.sleep(for: .milliseconds(100)) - let alert = try #require(model.destination?.alert) - #expect(alert == .speechRecognizerFailed) + let alert = try #require(model.destination?.alert) + #expect(alert == .speechRecognizerFailed) - model.destination = nil // NB: Simulate SwiftUI closing alert. + model.destination = nil // NB: Simulate SwiftUI closing alert. - await task.value + await task.value - #expect(model.secondsElapsed == 3) - } + #expect(model.secondsElapsed == 3) } @Test @@ -315,10 +311,12 @@ struct RecordMeetingTests { } } operation: { RecordMeetingModel( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) ) ) } diff --git a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index c34853c..9b95638 100644 --- a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -1,6 +1,7 @@ import CasePaths import CustomDump import Dependencies +import Sharing import Testing @testable import SyncUps @@ -13,7 +14,7 @@ struct SyncUpDetailTests { let model = withDependencies { $0.speechClient.authorizationStatus = { .restricted } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(.mock)) } model.startMeetingButtonTapped() @@ -28,7 +29,7 @@ struct SyncUpDetailTests { let model = withDependencies { $0.speechClient.authorizationStatus = { .denied } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(.mock)) } model.startMeetingButtonTapped() @@ -46,7 +47,7 @@ struct SyncUpDetailTests { } operation: { SyncUpDetailModel( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(.mock) ) } @@ -59,7 +60,7 @@ struct SyncUpDetailTests { func continueWithoutRecording() async throws { let model = SyncUpDetailModel( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(.mock) ) await confirmation { confirmation in @@ -77,7 +78,7 @@ struct SyncUpDetailTests { let model = withDependencies { $0.speechClient.authorizationStatus = { .authorized } } operation: { - SyncUpDetailModel(syncUp: .mock) + SyncUpDetailModel(syncUp: Shared(.mock)) } await confirmation { confirmation in @@ -98,35 +99,53 @@ struct SyncUpDetailTests { @Dependency(\.uuid) var uuid return SyncUpDetailModel( - syncUp: SyncUp( - id: SyncUp.ID(uuid()), - title: "Engineering" + syncUp: Shared( + SyncUp( + id: SyncUp.ID(uuid()), + title: "Engineering" + ) ) ) } - try await confirmation { confirmation in - model.onSyncUpUpdated = { _ in confirmation() } - - model.editButtonTapped() - - let editModel = try #require(model.destination?.edit) - editModel.syncUp.title = "Engineering" - editModel.syncUp.theme = .lavender - model.doneEditingButtonTapped() - - #expect(model.destination == nil) - expectNoDifference( - model.syncUp, - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ], - theme: .lavender, - title: "Engineering" - ) + model.editButtonTapped() + + let editModel = try #require(model.destination?.edit) + editModel.syncUp.title = "Engineering" + editModel.syncUp.theme = .lavender + model.doneEditingButtonTapped() + + #expect(model.destination == nil) + expectNoDifference( + model.syncUp, + SyncUp( + id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, + attendees: [ + Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) + ], + theme: .lavender, + title: "Engineering" ) + ) + } + + @Test + func delete() async { + @Shared(.syncUps) var syncUps = [SyncUp.mock] + + let settingsOpened = LockIsolated(false) + let model = withDependencies { + $0.openSettings = { settingsOpened.setValue(true) } + } operation: { + SyncUpDetailModel(syncUp: $syncUps[0]) } + + model.deleteButtonTapped() + + #expect(model.destination?.alert == .deleteSyncUp) + + await model.alertButtonTapped(.confirmDeletion) + + #expect(syncUps == []) } } diff --git a/SyncUps/SyncUpsTests/SyncUpsListTests.swift b/SyncUps/SyncUpsTests/SyncUpsListTests.swift index 3a30f63..812e9c5 100644 --- a/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -93,67 +93,4 @@ struct SyncUpsListTests { ] ) } - -// @Test -// func loadingDataDecodingFailed() async throws { -// let model = withDependencies { -// $0.continuousClock = ImmediateClock() -// $0.dataManager = .mock( -// initialData: Data("!@#$ BAD DATA %^&*()".utf8) -// ) -// } operation: { -// SyncUpsListModel() -// } -// -// let alert = try #require(model.destination?.alert) -// -// expectNoDifference(alert, .dataFailedToLoad) -// -// model.alertButtonTapped(.confirmLoadMockData) -// -// expectNoDifference(model.syncUps, [.mock, .designMock, .engineeringMock]) -// } - -// @Test -// func loadingDataFileNotFound() async throws { -// let model = withDependencies { -// $0.dataManager.load = { @Sendable _ in -// struct FileNotFound: Error {} -// throw FileNotFound() -// } -// } operation: { -// SyncUpsListModel() -// } -// -// #expect(model.destination == nil) -// } -// -// @Test -// func save() async throws { -// let clock = TestClock() -// -// let savedData = LockIsolated(Data()) -// await confirmation { confirmation in -// let model = withDependencies { -// $0.dataManager.load = { @Sendable _ in try JSONEncoder().encode([SyncUp]()) } -// $0.dataManager.save = { @Sendable data, _ in -// savedData.setValue(data) -// confirmation() -// } -// $0.continuousClock = clock -// } operation: { -// SyncUpsListModel( -// destination: .add(SyncUpFormModel(syncUp: .mock)) -// ) -// } -// -// model.confirmAddSyncUpButtonTapped() -// await clock.advance(by: .seconds(1)) -// } -// -// expectNoDifference( -// try JSONDecoder().decode([SyncUp].self, from: savedData.value), -// [.mock] -// ) -// } } From 655e94cba9c753b82ecfbea08c5fb207832c8870 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 4 Nov 2024 16:17:26 -0500 Subject: [PATCH 07/25] wip --- SyncUps/SyncUps.xcodeproj/project.pbxproj | 45 ++-- .../xcshareddata/swiftpm/Package.resolved | 10 +- SyncUps/SyncUps/App.swift | 182 +++++-------- SyncUps/SyncUps/Helpers.swift | 24 +- SyncUps/SyncUps/RecordMeeting.swift | 72 +++--- SyncUps/SyncUps/SyncUpDetail.swift | 213 +++++++-------- SyncUps/SyncUps/SyncUpsApp.swift | 85 +++--- SyncUps/SyncUps/SyncUpsList.swift | 12 +- .../SyncUpsUITests/SyncUpsListUITests.swift | 244 +++++++++--------- 9 files changed, 413 insertions(+), 474 deletions(-) diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index 5b8676a..37cdb6b 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 2A368893298ADD2500E8C33A /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A368892298ADD2500E8C33A /* App.swift */; }; CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA1C303E2C358FEB001EE466 /* CustomDump */; }; CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1D22E92991BC7000B529DE /* AppTests.swift */; }; - CA315CFD2CD5602D002467C9 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CA315CFC2CD5602D002467C9 /* Sharing */; }; CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350C742984506E00434F8A /* SyncUpsApp.swift */; }; CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA1298450BF00434F8A /* SyncUpDetail.swift */; }; CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA2298450BF00434F8A /* RecordMeeting.swift */; }; @@ -29,12 +28,14 @@ CA350CCF2984518A00434F8A /* SyncUpsListUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CCE2984518A00434F8A /* SyncUpsListUITests.swift */; }; CA350CD2298452F300434F8A /* ding.wav in Resources */ = {isa = PBXBuildFile; fileRef = CA350CD1298452F300434F8A /* ding.wav */; }; CA8586B82C61210E00A53451 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA8586B72C61210E00A53451 /* SwiftUINavigation */; }; + CA99A59C2CD9172000E6F7B0 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CA99A59B2CD9172000E6F7B0 /* Sharing */; }; CAAF4DC92C3586AD00774888 /* IssueReporting in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DC82C3586AD00774888 /* IssueReporting */; }; CAAF4DCC2C3586C900774888 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DCB2C3586C900774888 /* IdentifiedCollections */; }; CAAF4DCF2C3586E200774888 /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DCE2C3586E200774888 /* Tagged */; }; CAAF4DD42C35899500774888 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD32C35899500774888 /* Dependencies */; }; CAAF4DD72C3589A700774888 /* ConcurrencyExtras in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD62C3589A700774888 /* ConcurrencyExtras */; }; CAAF4DD92C3589D400774888 /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DD82C3589D400774888 /* DependenciesMacros */; }; + CAF8AAB12CD91A95007192A6 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CAF8AAB02CD91A95007192A6 /* Sharing */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -84,9 +85,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CA315CFD2CD5602D002467C9 /* Sharing in Frameworks */, + CA99A59C2CD9172000E6F7B0 /* Sharing in Frameworks */, CAAF4DD72C3589A700774888 /* ConcurrencyExtras in Frameworks */, CAAF4DCF2C3586E200774888 /* Tagged in Frameworks */, + CAF8AAB12CD91A95007192A6 /* Sharing in Frameworks */, CAAF4DD92C3589D400774888 /* DependenciesMacros in Frameworks */, CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */, CA8586B82C61210E00A53451 /* SwiftUINavigation in Frameworks */, @@ -222,7 +224,8 @@ CAAF4DD82C3589D400774888 /* DependenciesMacros */, CA1C303E2C358FEB001EE466 /* CustomDump */, CA8586B72C61210E00A53451 /* SwiftUINavigation */, - CA315CFC2CD5602D002467C9 /* Sharing */, + CA99A59B2CD9172000E6F7B0 /* Sharing */, + CAF8AAB02CD91A95007192A6 /* Sharing */, ); productName = SyncUps; productReference = CA350C712984506E00434F8A /* SyncUps.app */; @@ -309,7 +312,7 @@ CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */, CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */, CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */, - CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */, + CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */, ); productRefGroup = CA350C722984506E00434F8A /* Products */; projectDirPath = ""; @@ -717,14 +720,6 @@ minimumVersion = 1.0.0; }; }; - CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "http://github.com/pointfreeco/swift-sharing"; - requirement = { - branch = main; - kind = branch; - }; - }; CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-navigation"; @@ -769,8 +764,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + branch = "prepare-dependencies"; + kind = branch; }; }; CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { @@ -781,6 +776,14 @@ minimumVersion = 1.1.0; }; }; + CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://github.com/pointfreeco/swift-sharing/"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -789,16 +792,15 @@ package = CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */; productName = CustomDump; }; - CA315CFC2CD5602D002467C9 /* Sharing */ = { - isa = XCSwiftPackageProductDependency; - package = CA315CDD2CD55FD8002467C9 /* XCRemoteSwiftPackageReference "swift-sharing" */; - productName = Sharing; - }; CA8586B72C61210E00A53451 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; + CA99A59B2CD9172000E6F7B0 /* Sharing */ = { + isa = XCSwiftPackageProductDependency; + productName = Sharing; + }; CAAF4DC82C3586AD00774888 /* IssueReporting */ = { isa = XCSwiftPackageProductDependency; package = CAAF4DC72C3586AD00774888 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */; @@ -829,6 +831,11 @@ package = CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; + CAF8AAB02CD91A95007192A6 /* Sharing */ = { + isa = XCSwiftPackageProductDependency; + package = CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */; + productName = Sharing; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CA350C692984506E00434F8A /* Project object */; diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4180dd..a7a5de2 100644 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "89d10cf0feeac2589697abac8fb10999ed3f9eceea5a9a60fd2d480b62a26ccf", + "originHash" : "e7bb4b44ac5a5de18fedd878c5439952961453a72ebef27657d6a0acba6855f4", "pins" : [ { "identity" : "combine-schedulers", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" + "branch" : "prepare-dependencies", + "revision" : "ec337305d175a009308659396077e209d2477822" } }, { @@ -94,10 +94,10 @@ { "identity" : "swift-sharing", "kind" : "remoteSourceControl", - "location" : "http://github.com/pointfreeco/swift-sharing", + "location" : "http://github.com/pointfreeco/swift-sharing/", "state" : { "branch" : "main", - "revision" : "3fed67000a229a47ef2c905f5bcf45ffb70f1292" + "revision" : "f69457fa7b2e5aae6bbf4ba107670314f8c899ec" } }, { diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 4638116..1c292e9 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -4,140 +4,74 @@ import IdentifiedCollections import Sharing import SwiftUI -@MainActor -@Observable -class AppModel { - var path: [Path] { - didSet { bind() } - } - var syncUpsList: SyncUpsListModel { - didSet { bind() } - } - - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.date.now) var now - @ObservationIgnored - @Dependency(\.uuid) var uuid - - @CasePathable - @dynamicMemberLookup - enum Path: Hashable { - case detail(SyncUpDetailModel) - case meeting(Meeting, syncUp: SyncUp) - case record(RecordMeetingModel) - } - - init( - path: [Path] = [], - syncUpsList: SyncUpsListModel - ) { - self.path = path - self.syncUpsList = syncUpsList - self.bind() - } - - private func bind() { - syncUpsList.onSyncUpTapped = { [weak self] syncUp in - guard - let self, - let sharedSyncUp = Shared(syncUpsList.$syncUps[id: syncUp.id]) - else { return } - withDependencies(from: self) { - self.path.append(.detail(SyncUpDetailModel(syncUp: sharedSyncUp))) - } - } - - for destination in path { - switch destination { - case let .detail(detailModel): - bindDetail(model: detailModel) - - case .meeting, .record: - break - } - } - } - - private func bindDetail(model: SyncUpDetailModel) { - model.onMeetingStarted = { [weak self] syncUp in - guard - let self, - let sharedSyncUp = Shared(syncUpsList.$syncUps[id: syncUp.id]) - else { return } - - withDependencies(from: self) { - self.path.append( - .record( - RecordMeetingModel(syncUp: sharedSyncUp) - ) - ) - } - } +enum AppPath: Hashable { + case detail(id: SyncUp.ID) + case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) + case record(id: SyncUp.ID) +} - model.onMeetingTapped = { [weak model, weak self] meeting in - guard let model, let self else { return } - path.append(.meeting(meeting, syncUp: model.syncUp)) - } +extension PersistenceReaderKey where Self == InMemoryKey<[AppPath]>.Default { + static var path: Self { + Self[.inMemory("appPath"), default: []] } } struct AppView: View { - @State var model: AppModel + @Shared(.path) var path var body: some View { - NavigationStack(path: $model.path) { - SyncUpsList(model: model.syncUpsList) - .navigationDestination(for: AppModel.Path.self) { destination in - switch destination { - case let .detail(detailModel): - SyncUpDetailView(model: detailModel) - case let .meeting(meeting, syncUp: syncUp): - MeetingView(meeting: meeting, syncUp: syncUp) - case let .record(recordModel): - RecordMeetingView(model: recordModel) + NavigationStack(path: Binding($path)) { + SyncUpsList() + .navigationDestination(for: AppPath.self) { path in + switch path { + case let .detail(id: syncUpID): + SyncUpDetailView(id: syncUpID) + case let .meeting(id: meetingID, syncUpID: syncUpID): + MeetingView(id: meetingID, syncUpID: syncUpID) + EmptyView() + case let .record(id: syncUpID): + RecordMeetingView(id: syncUpID) } } } } } -#Preview("Happy path") { - @Shared(.syncUps) var syncUps: IdentifiedArray = [ - SyncUp.mock, - .engineeringMock, - .designMock, - ] - - AppView( - model: AppModel(syncUpsList: SyncUpsListModel()) - ) -} - -#Preview("Deep link record flow") { - @Shared(.syncUps) var syncUps: IdentifiedArray = [ - SyncUp.mock, - .engineeringMock, - .designMock, - ] - - Preview( - message: """ - The preview demonstrates how you can start the application navigated to a very specific \ - screen just by constructing a piece of state. In particular we will start the app drilled \ - down to the detail screen of a sync-up, and then further drilled down to the record screen \ - for a new meeting. - """ - ) { - AppView( - model: AppModel( - path: [ - .detail(SyncUpDetailModel(syncUp: Shared(.mock))), - .record(RecordMeetingModel(syncUp: Shared(.mock))), - ], - syncUpsList: SyncUpsListModel() - ) - ) - } -} +//#Preview("Happy path") { +// @Shared(.syncUps) var syncUps: IdentifiedArray = [ +// SyncUp.mock, +// .engineeringMock, +// .designMock, +// ] +// +// AppView( +// model: AppModel(syncUpsList: SyncUpsListModel()) +// ) +//} +// +//#Preview("Deep link record flow") { +// @Shared(.syncUps) var syncUps: IdentifiedArray = [ +// SyncUp.mock, +// .engineeringMock, +// .designMock, +// ] +// +// Preview( +// message: """ +// The preview demonstrates how you can start the application navigated to a very specific \ +// screen just by constructing a piece of state. In particular we will start the app drilled \ +// down to the detail screen of a sync-up, and then further drilled down to the record screen \ +// for a new meeting. +// """ +// ) { +// AppView( +// model: AppModel( +// path: [ +// .detail(SyncUpDetailModel(syncUp: Shared(.mock))), +// .record(RecordMeetingModel(syncUp: Shared(.mock))), +// ], +// syncUpsList: SyncUpsListModel() +// ) +// ) +// } +//} diff --git a/SyncUps/SyncUps/Helpers.swift b/SyncUps/SyncUps/Helpers.swift index 15df5e4..c79aa7b 100644 --- a/SyncUps/SyncUps/Helpers.swift +++ b/SyncUps/SyncUps/Helpers.swift @@ -32,15 +32,15 @@ struct Preview: View { } } -#Preview { - Preview( - message: - """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ - ullamco laboris nisi ut aliquip ex ea commodo consequat. - """ - ) { - SyncUpDetailView(model: SyncUpDetailModel(syncUp: Shared(.mock))) - } -} +//#Preview { +// Preview( +// message: +// """ +// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ +// ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ +// ullamco laboris nisi ut aliquip ex ea commodo consequat. +// """ +// ) { +// SyncUpDetailView(model: SyncUpDetailModel(syncUp: Shared(.mock))) +// } +//} diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index 2243c9d..ce0bba2 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -10,10 +10,11 @@ import SwiftUINavigation @Observable final class RecordMeetingModel { var destination: Destination? - var isDismissed = false var secondsElapsed = 0 var speakerIndex = 0 @ObservationIgnored + @Shared(.path) var path + @ObservationIgnored @Shared var syncUp: SyncUp private var transcript = "" @@ -81,7 +82,7 @@ final class RecordMeetingModel { case .confirmSave?: await finishMeeting() case .confirmDiscard?: - isDismissed = true + _ = $path.withLock { $0.removeLast() } case nil: break } @@ -140,7 +141,7 @@ final class RecordMeetingModel { } private func finishMeeting() async { - isDismissed = true + _ = $path.withLock { $0.removeLast() } let meeting = Meeting( id: Meeting.ID(self.uuid()), @@ -148,7 +149,7 @@ final class RecordMeetingModel { transcript: transcript ) - try? await Task.sleep(for: .milliseconds(400)) + try? await clock.sleep(for: .seconds(0.4)) _ = withAnimation { $syncUp.withLock { $0.meetings.insert(meeting, at: 0) } } @@ -198,7 +199,11 @@ extension AlertState where Action == RecordMeetingModel.AlertAction { struct RecordMeetingView: View { @State var model: RecordMeetingModel - @Environment(\.dismiss) var dismiss + + init(id: SyncUp.ID) { + @Shared(.syncUps) var syncUps + _model = State(wrappedValue: RecordMeetingModel(syncUp: Shared($syncUps[id: id])!)) + } var body: some View { ZStack { @@ -237,7 +242,6 @@ struct RecordMeetingView: View { await self.model.alertButtonTapped(action) } .task { await self.model.task() } - .onChange(of: self.model.isDismissed) { _, _ in self.dismiss() } } } @@ -389,31 +393,31 @@ struct MeetingFooterView: View { } } -struct RecordMeeting_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - RecordMeetingView( - model: RecordMeetingModel(syncUp: Shared(.mock)) - ) - } - .previewDisplayName("Happy path") - - Preview( - message: """ - This preview demonstrates how the feature behaves when the speech recognizer emits a \ - failure after 2 seconds of transcribing. - """ - ) { - NavigationStack { - RecordMeetingView( - model: withDependencies { - $0.speechClient = .fail(after: .seconds(2)) - } operation: { - RecordMeetingModel(syncUp: Shared(.mock)) - } - ) - } - } - .previewDisplayName("Speech failure after 2 secs") - } -} +//struct RecordMeeting_Previews: PreviewProvider { +// static var previews: some View { +// NavigationStack { +// RecordMeetingView( +// model: RecordMeetingModel(syncUp: Shared(.mock)) +// ) +// } +// .previewDisplayName("Happy path") +// +// Preview( +// message: """ +// This preview demonstrates how the feature behaves when the speech recognizer emits a \ +// failure after 2 seconds of transcribing. +// """ +// ) { +// NavigationStack { +// RecordMeetingView( +// model: withDependencies { +// $0.speechClient = .fail(after: .seconds(2)) +// } operation: { +// RecordMeetingModel(syncUp: Shared(.mock)) +// } +// ) +// } +// } +// .previewDisplayName("Speech failure after 2 secs") +// } +//} diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index c845b6e..ed4ca26 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -10,9 +10,10 @@ import SwiftUINavigation @Observable final class SyncUpDetailModel { var destination: Destination? - var isDismissed = false @ObservationIgnored @Shared var syncUp: SyncUp + @ObservationIgnored + @Shared(.path) var path @ObservationIgnored @Dependency(\.continuousClock) var clock @@ -25,9 +26,6 @@ final class SyncUpDetailModel { @ObservationIgnored @Dependency(\.uuid) var uuid - var onMeetingTapped: (Meeting) -> Void = unimplemented("onMeetingTapped") - var onMeetingStarted: (SyncUp) -> Void = unimplemented("onMeetingStarted") - @CasePathable @dynamicMemberLookup enum Destination { @@ -52,10 +50,6 @@ final class SyncUpDetailModel { $syncUp.withLock { $0.meetings.remove(atOffsets: indices) } } - func meetingTapped(_ meeting: Meeting) { - onMeetingTapped(meeting) - } - func deleteButtonTapped() { destination = .alert(.deleteSyncUp) } @@ -63,12 +57,16 @@ final class SyncUpDetailModel { func alertButtonTapped(_ action: AlertAction?) async { switch action { case .confirmDeletion?: + _ = $path.withLock { $0.removeLast() } + try? await self.clock.sleep(for: .seconds(0.4)) @Shared(.syncUps) var syncUps - _ = $syncUps.withLock { $0.remove(id: syncUp.id) } - isDismissed = true + withAnimation { + _ = $syncUps.withLock { $0.remove(id: self.syncUp.id) } + } case .continueWithoutRecording?: - onMeetingStarted(syncUp) + $path.withLock { $0.append(.record(id: syncUp.id)) } + //onMeetingStarted(syncUp) case .openSettings?: await openSettings() @@ -101,7 +99,7 @@ final class SyncUpDetailModel { func startMeetingButtonTapped() { switch authorizationStatus() { case .notDetermined, .authorized: - onMeetingStarted(syncUp) + $path.withLock { $0.append(.record(id: syncUp.id)) } case .denied: destination = .alert(.speechRecognitionDenied) @@ -118,9 +116,17 @@ final class SyncUpDetailModel { extension SyncUpDetailModel: HashableObject {} struct SyncUpDetailView: View { - @Environment(\.dismiss) var dismiss @State var model: SyncUpDetailModel + init?(id: SyncUp.ID) { + @Shared(.syncUps) var syncUps + guard let syncUp = Shared($syncUps[id: id]) + else { + return nil + } + _model = State(wrappedValue: SyncUpDetailModel(syncUp: syncUp)) + } + var body: some View { List { Section { @@ -153,9 +159,7 @@ struct SyncUpDetailView: View { if !model.syncUp.meetings.isEmpty { Section { ForEach(model.syncUp.meetings) { meeting in - Button { - model.meetingTapped(meeting) - } label: { + NavigationLink(value: AppPath.meeting(id: meeting.id, syncUpID: model.syncUp.id)) { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) @@ -214,11 +218,6 @@ struct SyncUpDetailView: View { } } } - .onChange(of: model.isDismissed) { - if model.isDismissed { - dismiss() - } - } } } @@ -280,6 +279,12 @@ struct MeetingView: View { let meeting: Meeting let syncUp: SyncUp + init(id: Meeting.ID, syncUpID: SyncUp.ID) { + @Shared(.syncUps) var syncUps + syncUp = syncUps[id: syncUpID]! + meeting = syncUp.meetings[id: id]! + } + var body: some View { ScrollView { VStack(alignment: .leading) { @@ -301,86 +306,86 @@ struct MeetingView: View { } } -struct SyncUpDetail_Previews: PreviewProvider { - static var previews: some View { - Preview( - message: """ - This preview demonstrates the "happy path" of the application where everything works \ - perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ - see that a new transcription was added to the past meetings. The transcript will consist \ - of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: SyncUpDetailModel(syncUp: Shared(.mock)) - ) - } - } - .previewDisplayName("Happy path") - - Preview( - message: """ - This preview demonstrates an "unhappy path" of the application where the speech \ - recognizer mysteriously fails after 2 seconds of recording. This gives us an opportunity \ - to see how the application deals with this rare occurrence. To see the behavior, run the \ - preview, tap the "Start Meeting" button and wait 2 seconds. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient = .fail(after: .seconds(2)) - } operation: { - SyncUpDetailModel( - syncUp: Shared(.mock) - ) - } - ) - } - } - .previewDisplayName("Speech recognition failed") - - Preview( - message: """ - This preview demonstrates how the feature behaves when access to speech recognition has \ - been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ - that situation. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient.authorizationStatus = { .denied } - } operation: { - SyncUpDetailModel( - syncUp: Shared(.mock) - ) - } - ) - } - } - .previewDisplayName("Speech recognition denied") - - Preview( - message: """ - This preview demonstrates how the feature behaves when the device restricts access to \ - speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ - situation. - """ - ) { - NavigationStack { - SyncUpDetailView( - model: withDependencies { - $0.speechClient.authorizationStatus = { .restricted } - } operation: { - SyncUpDetailModel( - syncUp: Shared(.mock) - ) - } - ) - } - } - .previewDisplayName("Speech recognition restricted") - } -} +//struct SyncUpDetail_Previews: PreviewProvider { +// static var previews: some View { +// Preview( +// message: """ +// This preview demonstrates the "happy path" of the application where everything works \ +// perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ +// see that a new transcription was added to the past meetings. The transcript will consist \ +// of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. +// """ +// ) { +// NavigationStack { +// SyncUpDetailView( +// model: SyncUpDetailModel(syncUp: Shared(.mock)) +// ) +// } +// } +// .previewDisplayName("Happy path") +// +// Preview( +// message: """ +// This preview demonstrates an "unhappy path" of the application where the speech \ +// recognizer mysteriously fails after 2 seconds of recording. This gives us an opportunity \ +// to see how the application deals with this rare occurrence. To see the behavior, run the \ +// preview, tap the "Start Meeting" button and wait 2 seconds. +// """ +// ) { +// NavigationStack { +// SyncUpDetailView( +// model: withDependencies { +// $0.speechClient = .fail(after: .seconds(2)) +// } operation: { +// SyncUpDetailModel( +// syncUp: Shared(.mock) +// ) +// } +// ) +// } +// } +// .previewDisplayName("Speech recognition failed") +// +// Preview( +// message: """ +// This preview demonstrates how the feature behaves when access to speech recognition has \ +// been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ +// that situation. +// """ +// ) { +// NavigationStack { +// SyncUpDetailView( +// model: withDependencies { +// $0.speechClient.authorizationStatus = { .denied } +// } operation: { +// SyncUpDetailModel( +// syncUp: Shared(.mock) +// ) +// } +// ) +// } +// } +// .previewDisplayName("Speech recognition denied") +// +// Preview( +// message: """ +// This preview demonstrates how the feature behaves when the device restricts access to \ +// speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ +// situation. +// """ +// ) { +// NavigationStack { +// SyncUpDetailView( +// model: withDependencies { +// $0.speechClient.authorizationStatus = { .restricted } +// } operation: { +// SyncUpDetailModel( +// syncUp: Shared(.mock) +// ) +// } +// ) +// } +// } +// .previewDisplayName("Speech recognition restricted") +// } +//} diff --git a/SyncUps/SyncUps/SyncUpsApp.swift b/SyncUps/SyncUps/SyncUpsApp.swift index be8dee4..e78a600 100644 --- a/SyncUps/SyncUps/SyncUpsApp.swift +++ b/SyncUps/SyncUps/SyncUpsApp.swift @@ -5,61 +5,58 @@ import SwiftUI @main struct SyncUpsApp: App { - static let model = AppModel(syncUpsList: SyncUpsListModel()) + init() { + setUpForUITest() + } var body: some Scene { WindowGroup { - // NB: This conditional is here only to facilitate UI testing so that we can mock out certain - // dependencies for the duration of the test (e.g. the data manager). We do not really - // recommend performing UI tests in general, but we do want to demonstrate how it can be - // done. - if let testName = ProcessInfo.processInfo.environment["UI_TEST_NAME"] { - UITestingView(testName: testName) - } else { - AppView(model: Self.model) - } + AppView() } } } -struct UITestingView: View { - let testName: String +//// NB: During UI tests we override certain dependencies for the app and seed initial state. +private func setUpForUITest() { + guard let testName = ProcessInfo.processInfo.environment["UI_TEST_NAME"] + else { + return + } - var body: some View { - withDependencies { - $0.continuousClock = ContinuousClock() - $0.date = DateGenerator { Date() } - $0.soundEffectClient = .noop - $0.uuid = UUIDGenerator { UUID() } - $0.defaultFileStorage = .inMemory - switch testName { - case "testAdd", "testDelete", "testEdit": - break - case "testRecord", "testRecord_Discard": - $0.date = DateGenerator { Date(timeIntervalSince1970: 1_234_567_890) } - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { - $0.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "Hello world!"), - isFinal: true - ) + // Set up dependencies for UI testing. + prepareDependencies { + $0.continuousClock = ContinuousClock() + $0.defaultFileStorage = .inMemory + $0.soundEffectClient = .noop + $0.uuid = UUIDGenerator { UUID() } + switch testName { + case "testAdd", "testDelete", "testEdit": + break + case "testRecord", "testRecord_Discard": + $0.date = DateGenerator { Date(timeIntervalSince1970: 1_234_567_890) } + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { + $0.yield( + SpeechRecognitionResult( + bestTranscription: Transcription(formattedString: "Hello world!"), + isFinal: true ) - $0.finish() - } + ) + $0.finish() } - default: - fatalError() } - } operation: { - switch testName { - case "testDelete", "testEdit", "testRecord", "testRecord_Discard": - @Shared(.syncUps) var syncUps: IdentifiedArray = [SyncUp.mock] - default: - break - } - return AppView(model: AppModel(syncUpsList: SyncUpsListModel())) + default: + reportIssue("Unrecognized test: \(testName)") } } + + // Seed certain test cases with specific state. + switch testName { + case "testDelete", "testEdit", "testRecord", "testRecord_Discard": + @Shared(.syncUps) var syncUps + $syncUps.withLock { $0 = [SyncUp.mock] } + default: + break + } } diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index c548c9d..ec14b91 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -17,8 +17,6 @@ final class SyncUpsListModel { @ObservationIgnored @Dependency(\.uuid) var uuid - var onSyncUpTapped: (SyncUp) -> Void = unimplemented("onSyncUpTapped") - @CasePathable @dynamicMemberLookup enum Destination { @@ -58,21 +56,15 @@ final class SyncUpsListModel { } _ = $syncUps.withLock { $0.append(syncUp) } } - - func syncUpTapped(syncUp: SyncUp) { - onSyncUpTapped(syncUp) - } } struct SyncUpsList: View { - @Bindable var model: SyncUpsListModel + @State var model = SyncUpsListModel() var body: some View { List { ForEach(self.model.syncUps) { syncUp in - Button { - self.model.syncUpTapped(syncUp: syncUp) - } label: { + NavigationLink(value: AppPath.detail(id: syncUp.id)) { CardView(syncUp: syncUp) } .listRowBackground(syncUp.theme.mainColor) diff --git a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift index 1bb2685..0c5acec 100644 --- a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift +++ b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift @@ -1,122 +1,122 @@ -//import XCTest -// -//// This test case demonstrates how one can write UI tests using the swift-dependencies library. We -//// do not really recommend writing UI tests in general as they are slow and flakey, but if you must -//// then this shows how. -//// -//// The key to doing this is to set a launch environment variable on your XCUIApplication instance, -//// and then check for that value in the entry point of the application. If the environment value -//// exists, you can use 'withDependencies' to override dependencies to be used in the UI test. -//final class SyncUpsListUITests: XCTestCase { -// var app: XCUIApplication! -// -// override func setUpWithError() throws { -// nonisolated(unsafe) let test = self -// MainActor.assumeIsolated { -// test.app = XCUIApplication() -// test.app.launchEnvironment["SWIFT_DEPENDENCIES_CONTEXT"] = "test" -// test.app.launchEnvironment["UI_TEST_NAME"] = String( -// test.name.split(separator: " ").last?.dropLast() ?? "" -// ) -// test.app.launchEnvironment["TEST_UUID"] = UUID().uuidString -// test.app.launch() -// } -// } -// -// // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in -// // the form, and then adding the sync-up to the list. It's a very simple test, but it takes -// // approximately 10 seconds to run, and it depends on a lot of internal implementation details to -// // get right, such as tapping a button with the literal label "Add". -// // -// // This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where -// // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when -// // the sync-up is added to the list its data will be persisted to disk so that it will be -// // available on next launch. -// @MainActor -// func testAdd() throws { -// self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() -// let titleTextField = self.app.collectionViews.textFields["Title"] -// let nameTextField = self.app.collectionViews.textFields["Name"] -// -// titleTextField.typeText("Engineering") -// -// nameTextField.tap() -// nameTextField.typeText("Blob") -// -// self.app.buttons["New attendee"].tap() -// self.app.typeText("Blob Jr.") -// -// self.app.navigationBars["New sync-up"].buttons["Add"].tap() -// -// XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) -// } -// -// @MainActor -// func testDelete() async throws { -// self.app.staticTexts["Design"].tap() -// -// self.app.buttons["Delete"].tap() -// XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) -// -// self.app.buttons["Yes"].tap() -// try await Task.sleep(for: .seconds(0.3)) -// XCTAssertEqual(self.app.staticTexts["Design"].exists, false) -// XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) -// } -// -// @MainActor -// func testEdit() async throws { -// self.app.staticTexts["Design"].tap() -// -// self.app.buttons["Edit"].tap() -// let titleTextField = self.app.textFields["Title"] -// titleTextField.typeText(" & Product") -// -// self.app.buttons["Done"].tap() -// XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) -// -// self.app.buttons["Daily Sync-ups"].tap() -// try await Task.sleep(for: .seconds(0.3)) -// XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) -// XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) -// } -// -// @MainActor -// func testRecord() async throws { -// self.app.staticTexts["Design"].tap() -// -// self.app.buttons["Start Meeting"].tap() -// self.app.buttons["End meeting"].tap() -// -// XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) -// self.app.buttons["Save and end"].tap() -// -// try await Task.sleep(for: .seconds(0.5)) -// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) -// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) -// -// self.app.buttons["Daily Sync-ups"].tap() -// self.app.staticTexts["Design"].tap() -// -// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) -// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) -// -// self.app.staticTexts["February 13, 2009"].tap() -// self.app.staticTexts["Hello world!"].tap() -// } -// -// @MainActor -// func testRecord_Discard() async throws { -// self.app.staticTexts["Design"].tap() -// -// self.app.buttons["Start Meeting"].tap() -// self.app.buttons["End meeting"].tap() -// -// XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) -// self.app.buttons["Discard"].tap() -// -// try await Task.sleep(for: .seconds(0.5)) -// XCTAssertEqual(self.app.staticTexts["Design"].exists, true) -// XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) -// } -//} +import XCTest + +// This test case demonstrates how one can write UI tests using the swift-dependencies library. We +// do not really recommend writing UI tests in general as they are slow and flakey, but if you must +// then this shows how. +// +// The key to doing this is to set a launch environment variable on your XCUIApplication instance, +// and then check for that value in the entry point of the application. If the environment value +// exists, you can use 'withDependencies' to override dependencies to be used in the UI test. +final class SyncUpsListUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + nonisolated(unsafe) let test = self + MainActor.assumeIsolated { + test.app = XCUIApplication() + test.app.launchEnvironment["SWIFT_DEPENDENCIES_CONTEXT"] = "test" + test.app.launchEnvironment["UI_TEST_NAME"] = String( + test.name.split(separator: " ").last?.dropLast() ?? "" + ) + test.app.launchEnvironment["TEST_UUID"] = UUID().uuidString + test.app.launch() + } + } + + // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in + // the form, and then adding the sync-up to the list. It's a very simple test, but it takes + // approximately 10 seconds to run, and it depends on a lot of internal implementation details to + // get right, such as tapping a button with the literal label "Add". + // + // This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where + // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when + // the sync-up is added to the list its data will be persisted to disk so that it will be + // available on next launch. + @MainActor + func testAdd() throws { + self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() + let titleTextField = self.app.collectionViews.textFields["Title"] + let nameTextField = self.app.collectionViews.textFields["Name"] + + titleTextField.typeText("Engineering") + + nameTextField.tap() + nameTextField.typeText("Blob") + + self.app.buttons["New attendee"].tap() + self.app.typeText("Blob Jr.") + + self.app.navigationBars["New sync-up"].buttons["Add"].tap() + + XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) + } + + @MainActor + func testDelete() async throws { + self.app.staticTexts["Design"].tap() + + self.app.buttons["Delete"].tap() + XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) + + self.app.buttons["Yes"].tap() + try await Task.sleep(for: .seconds(0.3)) + XCTAssertEqual(self.app.staticTexts["Design"].exists, false) + XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + } + + @MainActor + func testEdit() async throws { + self.app.staticTexts["Design"].tap() + + self.app.buttons["Edit"].tap() + let titleTextField = self.app.textFields["Title"] + titleTextField.typeText(" & Product") + + self.app.buttons["Done"].tap() + XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) + + self.app.buttons["Daily Sync-ups"].tap() + try await Task.sleep(for: .seconds(0.3)) + XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) + XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + } + + @MainActor + func testRecord() async throws { + self.app.staticTexts["Design"].tap() + + self.app.buttons["Start Meeting"].tap() + self.app.buttons["End meeting"].tap() + + XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) + self.app.buttons["Save and end"].tap() + + try await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(self.app.staticTexts["Design"].exists, true) + XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + + self.app.buttons["Daily Sync-ups"].tap() + self.app.staticTexts["Design"].tap() + + XCTAssertEqual(self.app.staticTexts["Design"].exists, true) + XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + + self.app.staticTexts["February 13, 2009"].tap() + self.app.staticTexts["Hello world!"].tap() + } + + @MainActor + func testRecord_Discard() async throws { + self.app.staticTexts["Design"].tap() + + self.app.buttons["Start Meeting"].tap() + self.app.buttons["End meeting"].tap() + + XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) + self.app.buttons["Discard"].tap() + + try await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(self.app.staticTexts["Design"].exists, true) + XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) + } +} From f6df804b4e839013c616579bda3d86d6018d4428 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 4 Nov 2024 16:29:29 -0500 Subject: [PATCH 08/25] wip --- SyncUps/SyncUps/App.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 1c292e9..44c45c5 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -28,7 +28,6 @@ struct AppView: View { SyncUpDetailView(id: syncUpID) case let .meeting(id: meetingID, syncUpID: syncUpID): MeetingView(id: meetingID, syncUpID: syncUpID) - EmptyView() case let .record(id: syncUpID): RecordMeetingView(id: syncUpID) } From 83dd0dc0b9d5129658491aaacbe076f7adaaecc5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 4 Nov 2024 17:46:34 -0500 Subject: [PATCH 09/25] wip --- SyncUps/SyncUps.xcodeproj/project.pbxproj | 4 - SyncUps/SyncUps/App.swift | 30 +++- SyncUps/SyncUps/SyncUpDetail.swift | 1 - SyncUps/SyncUpsTests/AppTests.swift | 137 ------------------ SyncUps/SyncUpsTests/RecordMeetingTests.swift | 44 +++--- SyncUps/SyncUpsTests/SyncUpDetailTests.swift | 34 ++--- .../SyncUpsUITests/SyncUpsListUITests.swift | 2 +- 7 files changed, 70 insertions(+), 182 deletions(-) delete mode 100644 SyncUps/SyncUpsTests/AppTests.swift diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index 37cdb6b..dff307a 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 2A368893298ADD2500E8C33A /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A368892298ADD2500E8C33A /* App.swift */; }; CA1C303F2C358FEB001EE466 /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA1C303E2C358FEB001EE466 /* CustomDump */; }; - CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1D22E92991BC7000B529DE /* AppTests.swift */; }; CA350C752984506E00434F8A /* SyncUpsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350C742984506E00434F8A /* SyncUpsApp.swift */; }; CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA1298450BF00434F8A /* SyncUpDetail.swift */; }; CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CA2298450BF00434F8A /* RecordMeeting.swift */; }; @@ -57,7 +56,6 @@ /* Begin PBXFileReference section */ 2A368892298ADD2500E8C33A /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; - CA1D22E92991BC7000B529DE /* AppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTests.swift; sourceTree = ""; }; CA350C712984506E00434F8A /* SyncUps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SyncUps.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA350C742984506E00434F8A /* SyncUpsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpsApp.swift; sourceTree = ""; }; CA350C812984506F00434F8A /* SyncUpsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -157,7 +155,6 @@ CA350C842984506F00434F8A /* SyncUpsTests */ = { isa = PBXGroup; children = ( - CA1D22E92991BC7000B529DE /* AppTests.swift */, CA350CC62984518200434F8A /* RecordMeetingTests.swift */, CA350CC92984518200434F8A /* SyncUpDetailTests.swift */, CA350CC82984518200434F8A /* SyncUpFormTests.swift */, @@ -377,7 +374,6 @@ CA350CCD2984518200434F8A /* SyncUpDetailTests.swift in Sources */, CA350CCA2984518200434F8A /* RecordMeetingTests.swift in Sources */, CA350CCB2984518200434F8A /* SyncUpsListTests.swift in Sources */, - CA1D22EA2991BC7000B529DE /* AppTests.swift in Sources */, CA350CCC2984518200434F8A /* SyncUpFormTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 44c45c5..75e605b 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -4,15 +4,39 @@ import IdentifiedCollections import Sharing import SwiftUI -enum AppPath: Hashable { +@CasePathable +enum AppPath: Codable, Hashable { case detail(id: SyncUp.ID) case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) case record(id: SyncUp.ID) } -extension PersistenceReaderKey where Self == InMemoryKey<[AppPath]>.Default { +extension PersistenceReaderKey where Self == FileStorageKey<[AppPath]>.Default { static var path: Self { - Self[.inMemory("appPath"), default: []] + Self[ + .fileStorage( + .documentsDirectory.appending(path: "path.json"), + decode: { data in + try JSONDecoder().decode([AppPath].self, from: data) + }, + // TODO: write unit tests for encode logic + encode: { path in + try JSONEncoder().encode( + path.filter { + switch $0 { + case .detail: + true + case .meeting: + true + case .record: + false + } + } + ) + } + ), + default: [] + ] } } diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index ed4ca26..0ab660f 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -66,7 +66,6 @@ final class SyncUpDetailModel { case .continueWithoutRecording?: $path.withLock { $0.append(.record(id: syncUp.id)) } - //onMeetingStarted(syncUp) case .openSettings?: await openSettings() diff --git a/SyncUps/SyncUpsTests/AppTests.swift b/SyncUps/SyncUpsTests/AppTests.swift deleted file mode 100644 index 0ee95ea..0000000 --- a/SyncUps/SyncUpsTests/AppTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import CasePaths -import CustomDump -import Dependencies -import Foundation -import IdentifiedCollections -import Sharing -import Testing - -@testable import SyncUps - -@MainActor -@Suite -struct AppTests { - @Test - func recordingWithTranscript() async throws { - let syncUp = SyncUp( - id: SyncUp.ID(), - attendees: [ - .init(id: Attendee.ID()), - .init(id: Attendee.ID()), - ], - duration: .seconds(10), - title: "Engineering" - ) - @Shared(.syncUps) var syncUps: IdentifiedArray = [syncUp] - - let model = withDependencies { - $0.continuousClock = ImmediateClock() - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - $0.soundEffectClient = .noop - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { continuation in - continuation.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "I completed the project"), - isFinal: true - ) - ) - continuation.finish() - } - } - $0.uuid = .incrementing - } operation: { - AppModel( - path: [ - .detail(SyncUpDetailModel(syncUp: $syncUps[0])), - .record(RecordMeetingModel(syncUp: $syncUps[0])), - ], - syncUpsList: SyncUpsListModel() - ) - } - - let recordModel = try #require(model.path[1].record) - await recordModel.task() - - expectNoDifference( - model.syncUpsList.syncUps[0].meetings, - [ - Meeting( - id: Meeting.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - date: Date(timeIntervalSince1970: 1_234_567_890), - transcript: "I completed the project" - ) - ] - ) - } - - @Test - func delete() async throws { - @Shared(.syncUps) var syncUps: IdentifiedArray = [SyncUp.mock] - - let model = withDependencies { - $0.continuousClock = ImmediateClock() - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - - model.syncUpsList.syncUpTapped(syncUp: model.syncUpsList.syncUps[0]) - - let detailModel = try #require(model.path[0].detail) - - detailModel.deleteButtonTapped() - - let alert = try #require(detailModel.destination?.alert) - - expectNoDifference(alert, .deleteSyncUp) - - await detailModel.alertButtonTapped(.confirmDeletion) - - #expect(detailModel.isDismissed == true) - expectNoDifference(model.syncUpsList.syncUps, []) - } - - @Test - func detailEdit() async throws { - @Shared(.syncUps) var syncUps = [ - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ] - ) - ] - - let model = withDependencies { - $0.continuousClock = ImmediateClock() - } operation: { - AppModel(syncUpsList: SyncUpsListModel()) - } - - model.syncUpsList.syncUpTapped(syncUp: model.syncUpsList.syncUps[0]) - - let detailModel = try #require(model.path[0].detail) - - detailModel.editButtonTapped() - - let editModel = try #require(detailModel.destination?.edit) - - editModel.syncUp.title = "Design" - detailModel.doneEditingButtonTapped() - - #expect(detailModel.destination == nil) - expectNoDifference( - model.syncUpsList.syncUps, - [ - SyncUp( - id: SyncUp.ID(uuidString: "00000000-0000-0000-0000-000000000000")!, - attendees: [ - Attendee(id: Attendee.ID(uuidString: "00000000-0000-0000-0000-000000000001")!) - ], - title: "Design" - ) - ] - ) - } -} diff --git a/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/SyncUps/SyncUpsTests/RecordMeetingTests.swift index c8ede2e..9a68a20 100644 --- a/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -10,9 +10,11 @@ import Testing @MainActor @Suite struct RecordMeetingTests { + let clock = TestClock() + @Shared(.path) var path + @Test func timer() async throws { - let clock = TestClock() let soundEffectPlayCount = LockIsolated(0) let model = withDependencies { @@ -118,7 +120,8 @@ struct RecordMeetingTests { @Test func endMeetingSave() async throws { - let clock = TestClock() + let syncUp = SyncUp.mock + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock @@ -127,7 +130,7 @@ struct RecordMeetingTests { $0.speechClient.authorizationStatus = { .denied } $0.uuid = .incrementing } operation: { - RecordMeetingModel(syncUp: Shared(SyncUp.mock)) + RecordMeetingModel(syncUp: Shared(syncUp)) } let task = Task { @@ -145,7 +148,13 @@ struct RecordMeetingTests { #expect(model.speakerIndex == 0) #expect(model.durationRemaining == .seconds(60)) - await model.alertButtonTapped(.confirmSave) + let saveTask = Task { + await model.alertButtonTapped(.confirmSave) + } + try await Task.sleep(for: .seconds(0.1)) + await clock.advance(by: .seconds(0.4)) + await saveTask.value + #expect(path == [.detail(id: syncUp.id)]) task.cancel() await task.value @@ -153,14 +162,15 @@ struct RecordMeetingTests { @Test func endMeetingDiscard() async throws { - let clock = TestClock() + let syncUp = SyncUp.mock + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock $0.soundEffectClient = .noop $0.speechClient.authorizationStatus = { .denied } } operation: { - RecordMeetingModel(syncUp: Shared(SyncUp.mock)) + RecordMeetingModel(syncUp: Shared(syncUp)) } let task = Task { @@ -177,12 +187,11 @@ struct RecordMeetingTests { task.cancel() await task.value - #expect(model.isDismissed == true) + #expect(path == [.detail(id: syncUp.id)]) } @Test func nextSpeaker() async throws { - let clock = TestClock() let soundEffectPlayCount = LockIsolated(0) let model = withDependencies { @@ -299,7 +308,12 @@ struct RecordMeetingTests { @Test func speechRecognitionFailure_Discard() async throws { - let clock = TestClock() + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock @@ -310,15 +324,7 @@ struct RecordMeetingTests { return AsyncThrowingStream.finished(throwing: SpeechRecognitionFailure()) } } operation: { - RecordMeetingModel( - syncUp: Shared( - SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) - ) - ) - ) + RecordMeetingModel(syncUp: Shared(syncUp)) } Task { @@ -337,6 +343,6 @@ struct RecordMeetingTests { await model.alertButtonTapped(.confirmDiscard) model.destination = nil // NB: Simulate SwiftUI closing alert. - #expect(model.isDismissed == true) + #expect(path == [.detail(id: syncUp.id)]) } } diff --git a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index 9b95638..b38ec00 100644 --- a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -9,6 +9,8 @@ import Testing @MainActor @Suite struct SyncUpDetailTests { + @Shared(.path) var path + @Test func speechRestricted() async throws { let model = withDependencies { @@ -58,37 +60,31 @@ struct SyncUpDetailTests { @Test func continueWithoutRecording() async throws { + let syncUp = SyncUp.mock + let model = SyncUpDetailModel( destination: .alert(.speechRecognitionDenied), - syncUp: Shared(.mock) + syncUp: Shared(syncUp) ) - await confirmation { confirmation in - model.onMeetingStarted = { syncUp in - #expect(syncUp == .mock) - confirmation() - } + await model.alertButtonTapped(.continueWithoutRecording) - await model.alertButtonTapped(.continueWithoutRecording) - } + #expect(path == [.record(id: syncUp.id)]) } @Test func speechAuthorized() async throws { + let syncUp = SyncUp.mock + let model = withDependencies { $0.speechClient.authorizationStatus = { .authorized } } operation: { - SyncUpDetailModel(syncUp: Shared(.mock)) + SyncUpDetailModel(syncUp: Shared(syncUp)) } - await confirmation { confirmation in - model.onMeetingStarted = { syncUp in - #expect(syncUp == .mock) - confirmation() - } + model.startMeetingButtonTapped() - model.startMeetingButtonTapped() - } + #expect(path == [.record(id: syncUp.id)]) } @Test @@ -131,10 +127,13 @@ struct SyncUpDetailTests { @Test func delete() async { - @Shared(.syncUps) var syncUps = [SyncUp.mock] + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + $path.withLock { $0 = [.detail(id: syncUp.id)]} let settingsOpened = LockIsolated(false) let model = withDependencies { + $0.continuousClock = ContinuousClock() $0.openSettings = { settingsOpened.setValue(true) } } operation: { SyncUpDetailModel(syncUp: $syncUps[0]) @@ -147,5 +146,6 @@ struct SyncUpDetailTests { await model.alertButtonTapped(.confirmDeletion) #expect(syncUps == []) + #expect(path == []) } } diff --git a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift index 0c5acec..778a9da 100644 --- a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift +++ b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift @@ -59,7 +59,7 @@ final class SyncUpsListUITests: XCTestCase { XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) self.app.buttons["Yes"].tap() - try await Task.sleep(for: .seconds(0.3)) + try await Task.sleep(for: .seconds(0.5)) XCTAssertEqual(self.app.staticTexts["Design"].exists, false) XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) } From 6aefa3070b3cc2b94b3b5c7a18727b81142af9c0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 4 Nov 2024 18:23:53 -0500 Subject: [PATCH 10/25] wip --- SyncUps/SyncUps/App.swift | 78 +++++++------- SyncUps/SyncUps/RecordMeeting.swift | 8 +- SyncUps/SyncUps/SyncUpDetail.swift | 157 +++++++++++++--------------- SyncUps/SyncUps/SyncUpsList.swift | 74 +++++++------ 4 files changed, 153 insertions(+), 164 deletions(-) diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 75e605b..46b78f1 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -60,41 +60,43 @@ struct AppView: View { } } -//#Preview("Happy path") { -// @Shared(.syncUps) var syncUps: IdentifiedArray = [ -// SyncUp.mock, -// .engineeringMock, -// .designMock, -// ] -// -// AppView( -// model: AppModel(syncUpsList: SyncUpsListModel()) -// ) -//} -// -//#Preview("Deep link record flow") { -// @Shared(.syncUps) var syncUps: IdentifiedArray = [ -// SyncUp.mock, -// .engineeringMock, -// .designMock, -// ] -// -// Preview( -// message: """ -// The preview demonstrates how you can start the application navigated to a very specific \ -// screen just by constructing a piece of state. In particular we will start the app drilled \ -// down to the detail screen of a sync-up, and then further drilled down to the record screen \ -// for a new meeting. -// """ -// ) { -// AppView( -// model: AppModel( -// path: [ -// .detail(SyncUpDetailModel(syncUp: Shared(.mock))), -// .record(RecordMeetingModel(syncUp: Shared(.mock))), -// ], -// syncUpsList: SyncUpsListModel() -// ) -// ) -// } -//} +#Preview("Happy path") { + @Shared(.syncUps) var syncUps + let _ = $syncUps.withLock { + $0 = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + } + + AppView() +} + + +#Preview("Deep link record flow") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps + let _ = $syncUps.withLock { + $0 = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] + } + @Shared(.path) var path = [ + .detail(id: syncUp.id), + .record(id: syncUp.id) + ] + + Preview( + message: """ + The preview demonstrates how you can start the application navigated to a very specific \ + screen just by constructing a piece of state. In particular we will start the app drilled \ + down to the detail screen of a sync-up, and then further drilled down to the record screen \ + for a new meeting. + """ + ) { + AppView() + } +} diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index ce0bba2..a73e009 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -200,9 +200,13 @@ extension AlertState where Action == RecordMeetingModel.AlertAction { struct RecordMeetingView: View { @State var model: RecordMeetingModel - init(id: SyncUp.ID) { + init?(id: SyncUp.ID) { @Shared(.syncUps) var syncUps - _model = State(wrappedValue: RecordMeetingModel(syncUp: Shared($syncUps[id: id])!)) + guard let syncUp = Shared($syncUps[id: id]) + else { + return nil + } + _model = State(wrappedValue: RecordMeetingModel(syncUp: syncUp)) } var body: some View { diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 0ab660f..d322fa1 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -1,6 +1,7 @@ import Clocks import CustomDump import Dependencies +import IdentifiedCollections import IssueReporting import Sharing import SwiftUI @@ -278,10 +279,16 @@ struct MeetingView: View { let meeting: Meeting let syncUp: SyncUp - init(id: Meeting.ID, syncUpID: SyncUp.ID) { + init?(id: Meeting.ID, syncUpID: SyncUp.ID) { @Shared(.syncUps) var syncUps - syncUp = syncUps[id: syncUpID]! - meeting = syncUp.meetings[id: id]! + guard + let syncUp = syncUps[id: syncUpID], + let meeting = syncUp.meetings[id: id] + else { + return nil + } + self.syncUp = syncUp + self.meeting = meeting } var body: some View { @@ -305,86 +312,64 @@ struct MeetingView: View { } } -//struct SyncUpDetail_Previews: PreviewProvider { -// static var previews: some View { -// Preview( -// message: """ -// This preview demonstrates the "happy path" of the application where everything works \ -// perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ -// see that a new transcription was added to the past meetings. The transcript will consist \ -// of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. -// """ -// ) { -// NavigationStack { -// SyncUpDetailView( -// model: SyncUpDetailModel(syncUp: Shared(.mock)) -// ) -// } -// } -// .previewDisplayName("Happy path") -// -// Preview( -// message: """ -// This preview demonstrates an "unhappy path" of the application where the speech \ -// recognizer mysteriously fails after 2 seconds of recording. This gives us an opportunity \ -// to see how the application deals with this rare occurrence. To see the behavior, run the \ -// preview, tap the "Start Meeting" button and wait 2 seconds. -// """ -// ) { -// NavigationStack { -// SyncUpDetailView( -// model: withDependencies { -// $0.speechClient = .fail(after: .seconds(2)) -// } operation: { -// SyncUpDetailModel( -// syncUp: Shared(.mock) -// ) -// } -// ) -// } -// } -// .previewDisplayName("Speech recognition failed") -// -// Preview( -// message: """ -// This preview demonstrates how the feature behaves when access to speech recognition has \ -// been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ -// that situation. -// """ -// ) { -// NavigationStack { -// SyncUpDetailView( -// model: withDependencies { -// $0.speechClient.authorizationStatus = { .denied } -// } operation: { -// SyncUpDetailModel( -// syncUp: Shared(.mock) -// ) -// } -// ) -// } -// } -// .previewDisplayName("Speech recognition denied") -// -// Preview( -// message: """ -// This preview demonstrates how the feature behaves when the device restricts access to \ -// speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ -// situation. -// """ -// ) { -// NavigationStack { -// SyncUpDetailView( -// model: withDependencies { -// $0.speechClient.authorizationStatus = { .restricted } -// } operation: { -// SyncUpDetailModel( -// syncUp: Shared(.mock) -// ) -// } -// ) -// } -// } -// .previewDisplayName("Speech recognition restricted") -// } -//} +#Preview("Happy path") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates the "happy path" of the application where everything works \ + perfectly. You can start a meeting, wait a few moments, end the meeting, and you will \ + see that a new transcription was added to the past meetings. The transcript will consist \ + of some "lorem ipsum" text because a mock speech recongizer is used for Xcode previews. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) + } + } +} + +#Preview( + "Speech recognition denied", + traits: .dependencies { + $0.speechClient.authorizationStatus = { .denied } + } +) { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates how the feature behaves when access to speech recognition has \ + been previously denied by the user. Tap the "Start Meeting" button to see how we handle \ + that situation. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) + } + } +} + +#Preview( + "Speech recognition restricted", + traits: .dependencies { + $0.speechClient.authorizationStatus = { .restricted } + } +) { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + Preview( + message: """ + This preview demonstrates how the feature behaves when the device restricts access to \ + speech recognition APIs. Tap the "Start Meeting" button to see how we handle that \ + situation. + """ + ) { + NavigationStack { + SyncUpDetailView(id: syncUp.id) + } + } +} diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index ec14b91..7da4b32 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -139,46 +139,44 @@ extension PersistenceReaderKey where Self == FileStorageKey Date: Mon, 4 Nov 2024 18:30:27 -0500 Subject: [PATCH 11/25] clean up --- SyncUps/SyncUps/App.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 46b78f1..d19bb99 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -24,12 +24,8 @@ extension PersistenceReaderKey where Self == FileStorageKey<[AppPath]>.Default { try JSONEncoder().encode( path.filter { switch $0 { - case .detail: - true - case .meeting: - true - case .record: - false + case .detail, .meeting: true + case .record: false } } ) @@ -73,7 +69,6 @@ struct AppView: View { AppView() } - #Preview("Deep link record flow") { let syncUp = SyncUp.mock @Shared(.syncUps) var syncUps From 1d68d9585692632da1d152f4b5248a6b1772a5b4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 4 Nov 2024 18:59:36 -0500 Subject: [PATCH 12/25] wip --- SyncUps/SyncUps/RecordMeeting.swift | 55 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index a73e009..c3cca23 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -397,31 +397,30 @@ struct MeetingFooterView: View { } } -//struct RecordMeeting_Previews: PreviewProvider { -// static var previews: some View { -// NavigationStack { -// RecordMeetingView( -// model: RecordMeetingModel(syncUp: Shared(.mock)) -// ) -// } -// .previewDisplayName("Happy path") -// -// Preview( -// message: """ -// This preview demonstrates how the feature behaves when the speech recognizer emits a \ -// failure after 2 seconds of transcribing. -// """ -// ) { -// NavigationStack { -// RecordMeetingView( -// model: withDependencies { -// $0.speechClient = .fail(after: .seconds(2)) -// } operation: { -// RecordMeetingModel(syncUp: Shared(.mock)) -// } -// ) -// } -// } -// .previewDisplayName("Speech failure after 2 secs") -// } -//} +#Preview("Happy path") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + + NavigationStack { + RecordMeetingView(id: syncUp.id) + } +} + +#Preview("Speech failure after 2 secs") { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + let _ = prepareDependencies { + $0.speechClient = .fail(after: .seconds(2)) + } + + Preview( + message: """ + This preview demonstrates how the feature behaves when the speech recognizer emits a \ + failure after 2 seconds of transcribing. + """ + ) { + NavigationStack { + RecordMeetingView(id: syncUp.id) + } + } +} From 529b4ef510a694527258b684bc5bc53f9e35fd90 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 21 Nov 2024 10:13:04 -0500 Subject: [PATCH 13/25] wip --- .../xcshareddata/swiftpm/Package.resolved | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 96b364f..c55df19 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "89d10cf0feeac2589697abac8fb10999ed3f9eceea5a9a60fd2d480b62a26ccf", + "originHash" : "4cbcd698af3a100db1d3f385bf998a75f4644b3e46af1043935571cd1e43e3a5", "pins" : [ { "identity" : "combine-schedulers", @@ -58,10 +58,10 @@ { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" + "branch" : "prepare-dependencies", + "revision" : "d1b7935c20ef25ea72d28659120f997a1c4e857b" } }, { @@ -94,7 +94,7 @@ { "identity" : "swift-sharing", "kind" : "remoteSourceControl", - "location" : "http://github.com/pointfreeco/swift-sharing", + "location" : "http://github.com/pointfreeco/swift-sharing/", "state" : { "branch" : "main", "revision" : "c4e3d6dc494535c35b70bc8df620125333f3eaff" From 45b6a790fbee8e26038ec84542c9d7d0dc8d26af Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 21 Nov 2024 12:11:58 -0500 Subject: [PATCH 14/25] fixes --- README.md | 34 +++-- .../xcshareddata/swiftpm/Package.resolved | 36 ++--- SyncUps/SyncUps.xcodeproj/project.pbxproj | 25 ++-- .../xcshareddata/swiftpm/Package.resolved | 132 ------------------ SyncUps/SyncUps/App.swift | 37 +---- SyncUps/SyncUps/AppPath.swift | 36 +++++ SyncUps/SyncUps/Helpers.swift | 24 ++-- SyncUps/SyncUps/RecordMeeting.swift | 67 ++++----- SyncUps/SyncUps/SyncUpDetail.swift | 27 ++-- SyncUps/SyncUps/SyncUpForm.swift | 5 +- SyncUps/SyncUps/SyncUpsList.swift | 55 +++----- SyncUps/SyncUpsTests/RecordMeetingTests.swift | 28 ++-- SyncUps/SyncUpsTests/SyncUpDetailTests.swift | 42 +++--- SyncUps/SyncUpsTests/SyncUpFormTests.swift | 63 ++++----- SyncUps/SyncUpsTests/SyncUpsListTests.swift | 51 +++---- 15 files changed, 244 insertions(+), 418 deletions(-) delete mode 100644 SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 SyncUps/SyncUps/AppPath.swift diff --git a/README.md b/README.md index e0959aa..092e252 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ of navigation (_e.g._, sheets, drill-downs, alerts), many side effects (timers, data persistence), and do so in a way that is testable and modular. This application was built over the course of [many episodes][modern-swiftui-collection] on -Point-Free, a video series exploring functional programming and the Swift language, hosted by +Point-Free, a video series exploring advanced programming topics in the Swift language, hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis). @@ -66,25 +66,28 @@ Our SyncUps application is a rebuild of Apple's Scrumdinger application, but wit modern, best practices for SwiftUI development. We faithfully recreate the Scrumdinger, but with some key additions: - 1. Identifiers are made type safe using our [Tagged library][tagged-gh]. This prevents us from - writing non-sensical code, such as comparing a `SyncUp.ID` to a `Attendee.ID`. - 2. Instead of using bare arrays in feature logic we use an "identified" array from our - [IdentifiedCollections][identified-collections-gh] library. This allows you to read and modify - elements of the collection via their ID rather than positional index, which can be error prone - and lead to bugs or crashes. - 3. _All_ navigation is driven off of state, including sheets, drill-downs and alerts. This makes + 1. _All_ navigation is driven off of state, including sheets, drill-downs and alerts. This makes it possible to deep link into any screen of the app by just constructing a piece of state and handing it off to SwiftUI. - 4. Further, each view represents its navigation destinations as a single enum, which gives us - compile time proof that two destinations cannot be active at the same time. This cannot be - accomplished with default SwiftUI tools, but can be done with our [SwiftUINavigation - library][swiftui-nav-gh]. - 5. All side effects are controlled. This includes access to the file system for persistence, access + 1. Further, when a feature can navigate to multiple destinations, an enum is used to model the + destinations, which gives us compile time proof that two destinations cannot be active at the + same time. This cannot be accomplished with default SwiftUI tools, but can be done with our + [SwiftNavigation library][swift-nav-gh]. + 1. Persistence is handled by our [Sharing][sharing-gh] library, which allows one to hold onto + shared state in an observable model or view, and under the hood any changes to the state will be + persisted to external storage, such as the file system. + 1. All side effects are controlled. This includes access to the file system for persistence, access to time-based asynchrony for timers, access to speech recognition APIs, and even the creation of dates and UUIDs. This allows us to run our application in specific execution contexts, which is very useful in tests and Xcode previews. We accomplish this using our [Dependencies][dependencies-gh] library. - 6. The project includes a full test suite. Since all of navigation is driven off of state, and + 1. Identifiers are made type safe using our [Tagged library][tagged-gh]. This prevents us from + writing non-sensical code, such as comparing a `SyncUp.ID` to a `Attendee.ID`. + 1. Instead of using bare arrays in feature logic we use an "identified" array from our + [IdentifiedCollections][identified-collections-gh] library. This allows you to read and modify + elements of the collection via their ID rather than positional index, which can be error prone + and lead to bugs or crashes. + 1. The project includes a full test suite. Since all of navigation is driven off of state, and because we controlled all dependencies, we can write very comprehensive and nuanced tests. For example, we can write a unit test that proves that when a sync-up meeting's timer runs out the screen pops off the stack and a new transcript is added to the sync-up. Such a test would be @@ -103,5 +106,6 @@ Here is a list of ports of the app: [scrumdinger-dl]: https://docs-assets.developer.apple.com/published/1ea2eec121b90031e354288912a76357/TranscribingSpeechToText.zip [tagged-gh]: http://github.com/pointfreeco/swift-tagged [identified-collections-gh]: http://github.com/pointfreeco/swift-identified-collections -[swiftui-nav-gh]: http://github.com/pointfreeco/swiftui-navigation +[swift-nav-gh]: http://github.com/pointfreeco/swift-navigation [dependencies-gh]: http://github.com/pointfreeco/swift-dependencies +[sharing-gh]: https://github.com/pointfreeco/swift-sharing diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index c55df19..533fead 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4cbcd698af3a100db1d3f385bf998a75f4644b3e46af1043935571cd1e43e3a5", + "originHash" : "a41d8ea0a7e275eaf005ddc4bfd786d637276802746d02247b43cc5cfb463378", "pins" : [ { "identity" : "combine-schedulers", @@ -40,16 +40,16 @@ { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", + "version" : "1.3.0" } }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump.git", + "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", "version" : "1.3.3" @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "branch" : "prepare-dependencies", - "revision" : "d1b7935c20ef25ea72d28659120f997a1c4e857b" + "revision" : "e2b06090b0b7738fcd8c762131e705d711fc7e8e", + "version" : "1.6.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "branch" : "index-constraint", - "revision" : "b2a160f7c435c4de037677fc1c5cfb9099e0e088" + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "branch" : "overload-resolution-fixes", - "revision" : "0389427284f43369eeac55d7111ea013a1bea4c0" + "revision" : "dccdf5aed1db969afa11d6fbd36b96a4932ebe8c", + "version" : "1.4.0" } }, { @@ -97,7 +97,7 @@ "location" : "http://github.com/pointfreeco/swift-sharing/", "state" : { "branch" : "main", - "revision" : "c4e3d6dc494535c35b70bc8df620125333f3eaff" + "revision" : "80be2aa6fe7f877127539cb127d35c24f71b94ec" } }, { @@ -105,14 +105,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { "identity" : "swift-tagged", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged.git", + "location" : "https://github.com/pointfreeco/swift-tagged", "state" : { "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", "version" : "0.10.0" @@ -121,10 +121,10 @@ { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" + "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", + "version" : "1.4.3" } } ], diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index dff307a..f605906 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ CA350CCD2984518200434F8A /* SyncUpDetailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CC92984518200434F8A /* SyncUpDetailTests.swift */; }; CA350CCF2984518A00434F8A /* SyncUpsListUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA350CCE2984518A00434F8A /* SyncUpsListUITests.swift */; }; CA350CD2298452F300434F8A /* ding.wav in Resources */ = {isa = PBXBuildFile; fileRef = CA350CD1298452F300434F8A /* ding.wav */; }; + CA763F4E2CEF88FC00C1CE55 /* AppPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */; }; + CA763F5C2CEF9B6700C1CE55 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */; }; CA8586B82C61210E00A53451 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA8586B72C61210E00A53451 /* SwiftUINavigation */; }; CA99A59C2CD9172000E6F7B0 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = CA99A59B2CD9172000E6F7B0 /* Sharing */; }; CAAF4DC92C3586AD00774888 /* IssueReporting in Frameworks */ = {isa = PBXBuildFile; productRef = CAAF4DC82C3586AD00774888 /* IssueReporting */; }; @@ -76,6 +78,7 @@ CA350CC92984518200434F8A /* SyncUpDetailTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpDetailTests.swift; sourceTree = ""; }; CA350CCE2984518A00434F8A /* SyncUpsListUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncUpsListUITests.swift; sourceTree = ""; }; CA350CD1298452F300434F8A /* ding.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ding.wav; sourceTree = ""; }; + CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPath.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -100,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA763F5C2CEF9B6700C1CE55 /* DependenciesTestSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,6 +142,7 @@ isa = PBXGroup; children = ( 2A368892298ADD2500E8C33A /* App.swift */, + CA763F4D2CEF88FC00C1CE55 /* AppPath.swift */, CA350CAB298450BF00434F8A /* Helpers.swift */, CA350CAA298450BF00434F8A /* Models.swift */, CA350CA2298450BF00434F8A /* RecordMeeting.swift */, @@ -243,6 +248,7 @@ ); name = SyncUpsTests; packageProductDependencies = ( + CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */, ); productName = SyncUpsTests; productReference = CA350C812984506F00434F8A /* SyncUpsTests.xctest */; @@ -304,7 +310,6 @@ CAAF4DC72C3586AD00774888 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */, CAAF4DCA2C3586C900774888 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, CAAF4DCD2C3586E200774888 /* XCRemoteSwiftPackageReference "swift-tagged" */, - CAAF4DD12C35874200774888 /* XCRemoteSwiftPackageReference "swift-syntax" */, CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */, CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */, CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */, @@ -363,6 +368,7 @@ CA350CB1298450BF00434F8A /* SyncUpForm.swift in Sources */, 2A368893298ADD2500E8C33A /* App.swift in Sources */, CA350CB0298450BF00434F8A /* RecordMeeting.swift in Sources */, + CA763F4E2CEF88FC00C1CE55 /* AppPath.swift in Sources */, CA350CAF298450BF00434F8A /* SyncUpDetail.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -748,20 +754,12 @@ minimumVersion = 0.10.0; }; }; - CAAF4DD12C35874200774888 /* XCRemoteSwiftPackageReference "swift-syntax" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/swiftlang/swift-syntax"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 510.0.2; - }; - }; CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { - branch = "prepare-dependencies"; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { @@ -788,6 +786,11 @@ package = CA1C303D2C358FEB001EE466 /* XCRemoteSwiftPackageReference "swift-custom-dump" */; productName = CustomDump; }; + CA763F5B2CEF9B6700C1CE55 /* DependenciesTestSupport */ = { + isa = XCSwiftPackageProductDependency; + package = CAAF4DD22C35899500774888 /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesTestSupport; + }; CA8586B72C61210E00A53451 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = CA8586B62C61210E00A53451 /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index a7a5de2..0000000 --- a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,132 +0,0 @@ -{ - "originHash" : "e7bb4b44ac5a5de18fedd878c5439952961453a72ebef27657d6a0acba6855f4", - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", - "version" : "1.5.6" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "branch" : "prepare-dependencies", - "revision" : "ec337305d175a009308659396077e209d2477822" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "branch" : "index-constraint", - "revision" : "b2a160f7c435c4de037677fc1c5cfb9099e0e088" - } - }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", - "version" : "2.2.2" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "branch" : "overload-resolution-fixes", - "revision" : "0389427284f43369eeac55d7111ea013a1bea4c0" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "http://github.com/pointfreeco/swift-sharing/", - "state" : { - "branch" : "main", - "revision" : "f69457fa7b2e5aae6bbf4ba107670314f8c899ec" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" - } - }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" - } - } - ], - "version" : 3 -} diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index d19bb99..512e27e 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -4,38 +4,6 @@ import IdentifiedCollections import Sharing import SwiftUI -@CasePathable -enum AppPath: Codable, Hashable { - case detail(id: SyncUp.ID) - case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) - case record(id: SyncUp.ID) -} - -extension PersistenceReaderKey where Self == FileStorageKey<[AppPath]>.Default { - static var path: Self { - Self[ - .fileStorage( - .documentsDirectory.appending(path: "path.json"), - decode: { data in - try JSONDecoder().decode([AppPath].self, from: data) - }, - // TODO: write unit tests for encode logic - encode: { path in - try JSONEncoder().encode( - path.filter { - switch $0 { - case .detail, .meeting: true - case .record: false - } - } - ) - } - ), - default: [] - ] - } -} - struct AppView: View { @Shared(.path) var path @@ -65,7 +33,6 @@ struct AppView: View { .designMock, ] } - AppView() } @@ -74,14 +41,14 @@ struct AppView: View { @Shared(.syncUps) var syncUps let _ = $syncUps.withLock { $0 = [ - SyncUp.mock, + syncUp, .engineeringMock, .designMock, ] } @Shared(.path) var path = [ .detail(id: syncUp.id), - .record(id: syncUp.id) + .record(id: syncUp.id), ] Preview( diff --git a/SyncUps/SyncUps/AppPath.swift b/SyncUps/SyncUps/AppPath.swift new file mode 100644 index 0000000..b382b85 --- /dev/null +++ b/SyncUps/SyncUps/AppPath.swift @@ -0,0 +1,36 @@ +import CasePaths +import Foundation +import Sharing + +@CasePathable +enum AppPath: Codable, Hashable { + case detail(id: SyncUp.ID) + case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) + case record(id: SyncUp.ID) +} + +extension SharedReaderKey where Self == FileStorageKey<[AppPath]>.Default { + static var path: Self { + Self[ + .fileStorage( + .documentsDirectory.appending(path: "path.json"), + decode: { data in + try JSONDecoder().decode([AppPath].self, from: data) + }, + // TODO: write unit tests for encode logic + encode: { path in + try JSONEncoder().encode( + // NB: Encode only certain paths for state restoration. + path.filter { + switch $0 { + case .detail, .meeting: true + case .record: false + } + } + ) + } + ), + default: [] + ] + } +} diff --git a/SyncUps/SyncUps/Helpers.swift b/SyncUps/SyncUps/Helpers.swift index c79aa7b..af5feff 100644 --- a/SyncUps/SyncUps/Helpers.swift +++ b/SyncUps/SyncUps/Helpers.swift @@ -32,15 +32,15 @@ struct Preview: View { } } -//#Preview { -// Preview( -// message: -// """ -// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ -// ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ -// ullamco laboris nisi ut aliquip ex ea commodo consequat. -// """ -// ) { -// SyncUpDetailView(model: SyncUpDetailModel(syncUp: Shared(.mock))) -// } -//} +#Preview { + Preview( + message: + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt \ + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation \ + ullamco laboris nisi ut aliquip ex ea commodo consequat. + """ + ) { + NavigationStack {} + } +} diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index c3cca23..e4b3692 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -9,31 +9,18 @@ import SwiftUINavigation @MainActor @Observable final class RecordMeetingModel { - var destination: Destination? + var alert: AlertState? var secondsElapsed = 0 var speakerIndex = 0 - @ObservationIgnored - @Shared(.path) var path - @ObservationIgnored - @Shared var syncUp: SyncUp + @ObservationIgnored @Shared(.path) var path + @ObservationIgnored @Shared var syncUp: SyncUp private var transcript = "" - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.date.now) var now - @ObservationIgnored - @Dependency(\.soundEffectClient) var soundEffectClient - @ObservationIgnored - @Dependency(\.speechClient) var speechClient - @ObservationIgnored - @Dependency(\.uuid) var uuid - - @CasePathable - @dynamicMemberLookup - enum Destination { - case alert(AlertState) - } + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.date.now) var now + @ObservationIgnored @Dependency(\.soundEffectClient) var soundEffectClient + @ObservationIgnored @Dependency(\.speechClient) var speechClient + @ObservationIgnored @Dependency(\.uuid) var uuid enum AlertAction { case confirmSave @@ -41,10 +28,8 @@ final class RecordMeetingModel { } init( - destination: Destination? = nil, syncUp: Shared ) { - self.destination = destination self._syncUp = syncUp } @@ -53,18 +38,13 @@ final class RecordMeetingModel { } var isAlertOpen: Bool { - switch destination { - case .alert: - return true - case .none: - return false - } + alert != nil } func nextButtonTapped() { guard speakerIndex < syncUp.attendees.count - 1 else { - destination = .alert(.endMeeting(isDiscardable: false)) + alert = .endMeeting(isDiscardable: false) return } @@ -74,7 +54,7 @@ final class RecordMeetingModel { } func endMeetingButtonTapped() { - destination = .alert(.endMeeting(isDiscardable: true)) + alert = .endMeeting(isDiscardable: true) } func alertButtonTapped(_ action: AlertAction?) async { @@ -120,7 +100,7 @@ final class RecordMeetingModel { if !transcript.isEmpty { transcript += " ❌" } - destination = .alert(.speechRecognizerFailed) + alert = .speechRecognizerFailed } } @@ -143,15 +123,18 @@ final class RecordMeetingModel { private func finishMeeting() async { _ = $path.withLock { $0.removeLast() } - let meeting = Meeting( - id: Meeting.ID(self.uuid()), - date: self.now, - transcript: transcript - ) - try? await clock.sleep(for: .seconds(0.4)) _ = withAnimation { - $syncUp.withLock { $0.meetings.insert(meeting, at: 0) } + $syncUp.withLock { + $0.meetings.insert( + Meeting( + id: Meeting.ID(self.uuid()), + date: self.now, + transcript: transcript + ), + at: 0 + ) + } } } } @@ -203,9 +186,7 @@ struct RecordMeetingView: View { init?(id: SyncUp.ID) { @Shared(.syncUps) var syncUps guard let syncUp = Shared($syncUps[id: id]) - else { - return nil - } + else { return nil } _model = State(wrappedValue: RecordMeetingModel(syncUp: syncUp)) } @@ -242,7 +223,7 @@ struct RecordMeetingView: View { } } .navigationBarBackButtonHidden(true) - .alert(self.$model.destination.alert) { action in + .alert(self.$model.alert) { action in await self.model.alertButtonTapped(action) } .task { await self.model.task() } diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index d322fa1..69b99f2 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -11,21 +11,14 @@ import SwiftUINavigation @Observable final class SyncUpDetailModel { var destination: Destination? - @ObservationIgnored - @Shared var syncUp: SyncUp - @ObservationIgnored - @Shared(.path) var path - - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.date.now) var now - @ObservationIgnored - @Dependency(\.openSettings) var openSettings - @ObservationIgnored - @Dependency(\.speechClient.authorizationStatus) var authorizationStatus - @ObservationIgnored - @Dependency(\.uuid) var uuid + @ObservationIgnored @Shared var syncUp: SyncUp + @ObservationIgnored @Shared(.path) var path + + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.date.now) var now + @ObservationIgnored @Dependency(\.openSettings) var openSettings + @ObservationIgnored @Dependency(\.speechClient.authorizationStatus) var authorizationStatus + @ObservationIgnored @Dependency(\.uuid) var uuid @CasePathable @dynamicMemberLookup @@ -66,7 +59,9 @@ final class SyncUpDetailModel { } case .continueWithoutRecording?: - $path.withLock { $0.append(.record(id: syncUp.id)) } + $path.withLock { + $0.append(.record(id: syncUp.id)) + } case .openSettings?: await openSettings() diff --git a/SyncUps/SyncUps/SyncUpForm.swift b/SyncUps/SyncUps/SyncUpForm.swift index 0afb3d0..d79b807 100644 --- a/SyncUps/SyncUps/SyncUpForm.swift +++ b/SyncUps/SyncUps/SyncUpForm.swift @@ -7,8 +7,7 @@ final class SyncUpFormModel: Identifiable { var focus: Field? var syncUp: SyncUp - @ObservationIgnored - @Dependency(\.uuid) var uuid + @ObservationIgnored @Dependency(\.uuid) var uuid enum Field: Hashable { case attendee(Attendee.ID) @@ -46,7 +45,7 @@ final class SyncUpFormModel: Identifiable { struct SyncUpFormView: View { @FocusState var focus: SyncUpFormModel.Field? - @State var model: SyncUpFormModel + @Bindable var model: SyncUpFormModel var body: some View { Form { diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index 7da4b32..8e0558d 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -8,43 +8,32 @@ import SwiftUINavigation @MainActor @Observable final class SyncUpsListModel { - var destination: Destination? - @ObservationIgnored - @Shared(.syncUps) var syncUps - - @ObservationIgnored - @Dependency(\.continuousClock) var clock - @ObservationIgnored - @Dependency(\.uuid) var uuid - - @CasePathable - @dynamicMemberLookup - enum Destination { - case add(SyncUpFormModel) - } + var addSyncUp: SyncUpFormModel? + @ObservationIgnored @Shared(.syncUps) var syncUps + + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.uuid) var uuid init( - destination: Destination? = nil + addSyncUp: SyncUpFormModel? = nil ) { - self.destination = destination + self.addSyncUp = addSyncUp } func addSyncUpButtonTapped() { - destination = .add( - withDependencies(from: self) { - SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(self.uuid()))) - } - ) + addSyncUp = withDependencies(from: self) { + SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(self.uuid()))) + } } func dismissAddSyncUpButtonTapped() { - destination = nil + addSyncUp = nil } func confirmAddSyncUpButtonTapped() { - defer { destination = nil } + defer { addSyncUp = nil } - guard case let .add(syncUpFormModel) = destination + guard let syncUpFormModel = addSyncUp else { return } var syncUp = syncUpFormModel.syncUp @@ -78,7 +67,7 @@ struct SyncUpsList: View { } } .navigationTitle("Daily Sync-ups") - .sheet(item: self.$model.destination.add) { model in + .sheet(item: self.$model.addSyncUp) { model in NavigationStack { SyncUpFormView(model: model) .navigationTitle("New sync-up") @@ -133,7 +122,7 @@ extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } -extension PersistenceReaderKey where Self == FileStorageKey>.Default { +extension SharedReaderKey where Self == FileStorageKey>.Default { static var syncUps: Self { Self[.fileStorage(URL.documentsDirectory.appending(component: "sync-ups.json")), default: []] } @@ -153,7 +142,9 @@ extension PersistenceReaderKey where Self == FileStorageKey Date: Mon, 25 Nov 2024 11:18:15 -0600 Subject: [PATCH 15/25] wip --- .../xcshareddata/swiftpm/Package.resolved | 6 +- SyncUps/SyncUps.xcodeproj/project.pbxproj | 4 +- SyncUps/SyncUps/AppPath.swift | 1 - SyncUps/SyncUpsTests/RecordMeetingTests.swift | 97 ++++++++++--------- SyncUps/SyncUpsTests/SyncUpDetailTests.swift | 26 ++--- SyncUps/SyncUpsTests/SyncUpFormTests.swift | 9 +- 6 files changed, 65 insertions(+), 78 deletions(-) diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 533fead..f92c50a 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "e2b06090b0b7738fcd8c762131e705d711fc7e8e", - "version" : "1.6.0" + "branch" : "dependency-trait-fix", + "revision" : "9dcf4357cd12ef91a1c9c35a58c4a063150a50dc" } }, { @@ -97,7 +97,7 @@ "location" : "http://github.com/pointfreeco/swift-sharing/", "state" : { "branch" : "main", - "revision" : "80be2aa6fe7f877127539cb127d35c24f71b94ec" + "revision" : "aefeae51b6c246fce58e33f788b2d235e92532c2" } }, { diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index f605906..a677e27 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -758,8 +758,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + branch = "dependency-trait-fix"; + kind = branch; }; }; CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { diff --git a/SyncUps/SyncUps/AppPath.swift b/SyncUps/SyncUps/AppPath.swift index b382b85..b8c4f99 100644 --- a/SyncUps/SyncUps/AppPath.swift +++ b/SyncUps/SyncUps/AppPath.swift @@ -17,7 +17,6 @@ extension SharedReaderKey where Self == FileStorageKey<[AppPath]>.Default { decode: { data in try JSONDecoder().decode([AppPath].self, from: data) }, - // TODO: write unit tests for encode logic encode: { path in try JSONEncoder().encode( // NB: Encode only certain paths for state restoration. diff --git a/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/SyncUps/SyncUpsTests/RecordMeetingTests.swift index 7729657..793c8ad 100644 --- a/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -8,14 +8,22 @@ import Testing @testable import SyncUps @MainActor -@Suite -struct RecordMeetingTests { +@Suite struct RecordMeetingTests { let clock = TestClock() @Shared(.path) var path - @Test - func timer() async throws { + @Test func timer() async throws { let soundEffectPlayCount = LockIsolated(0) + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } let model = withDependencies { $0.continuousClock = clock @@ -27,15 +35,7 @@ struct RecordMeetingTests { } operation: { RecordMeetingModel( syncUp: Shared( - value: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) - ) + value: syncUp ) ) } @@ -68,13 +68,20 @@ struct RecordMeetingTests { #expect(model.durationRemaining == .seconds(0)) #expect(soundEffectPlayCount.value == 2) + await clock.run() await task.value #expect(soundEffectPlayCount.value == 2) } - @Test - func recordTranscript() async throws { + @Test func recordTranscript() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } + let model = withDependencies { $0.continuousClock = ImmediateClock() $0.date.now = Date(timeIntervalSince1970: 1234567890) @@ -94,13 +101,7 @@ struct RecordMeetingTests { $0.uuid = .incrementing } operation: { RecordMeetingModel( - syncUp: Shared( - value: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) - ) - ) + syncUp: Shared(value: syncUp) ) } @@ -118,8 +119,7 @@ struct RecordMeetingTests { ) } - @Test - func endMeetingSave() async throws { + @Test func endMeetingSave() async throws { let syncUp = SyncUp.mock $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } @@ -160,8 +160,7 @@ struct RecordMeetingTests { await task.value } - @Test - func endMeetingDiscard() async throws { + @Test func endMeetingDiscard() async throws { let syncUp = SyncUp.mock $path.withLock { $0 = [.detail(id: syncUp.id), .record(id: syncUp.id)] } @@ -190,30 +189,31 @@ struct RecordMeetingTests { #expect(path == [.detail(id: syncUp.id)]) } - @Test - func nextSpeaker() async throws { + @Test func nextSpeaker() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(3) + ) let soundEffectPlayCount = LockIsolated(0) + $path.withLock { $0 = [.record(id: syncUp.id)] } + print(path) let model = withDependencies { - $0.continuousClock = clock + $0.continuousClock = ImmediateClock() $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.soundEffectClient = .noop $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } } $0.speechClient.authorizationStatus = { .denied } $0.uuid = .incrementing - } operation: { RecordMeetingModel( syncUp: Shared( - value: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(3) - ) + value: syncUp ) ) } @@ -254,8 +254,14 @@ struct RecordMeetingTests { await task.value } - @Test - func speechRecognitionFailure_Continue() async throws { + @Test func speechRecognitionFailure_Continue() async throws { + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [Attendee(id: Attendee.ID())], + duration: .seconds(3) + ) + $path.withLock { $0 = [.record(id: syncUp.id)] } + let model = withDependencies { $0.continuousClock = ImmediateClock() $0.date.now = Date(timeIntervalSince1970: 1234567890) @@ -277,11 +283,7 @@ struct RecordMeetingTests { } operation: { RecordMeetingModel( syncUp: Shared( - value: SyncUp( - id: SyncUp.ID(), - attendees: [Attendee(id: Attendee.ID())], - duration: .seconds(3) - ) + value: syncUp ) ) } @@ -306,8 +308,7 @@ struct RecordMeetingTests { #expect(model.secondsElapsed == 3) } - @Test - func speechRecognitionFailure_Discard() async throws { + @Test func speechRecognitionFailure_Discard() async throws { let syncUp = SyncUp( id: SyncUp.ID(), attendees: [Attendee(id: Attendee.ID())], diff --git a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index 0ec18ad..cc385e4 100644 --- a/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -12,8 +12,7 @@ import Testing struct SyncUpDetailTests { @Shared(.path) var path - @Test - func speechRestricted() async throws { + @Test func speechRestricted() async throws { let model = withDependencies { $0.speechClient.authorizationStatus = { .restricted } } operation: { @@ -27,8 +26,7 @@ struct SyncUpDetailTests { expectNoDifference(alert, .speechRecognitionRestricted) } - @Test - func speechDenied() async throws { + @Test func speechDenied() async throws { let model = withDependencies { $0.speechClient.authorizationStatus = { .denied } } operation: { @@ -42,8 +40,7 @@ struct SyncUpDetailTests { expectNoDifference(alert, .speechRecognitionDenied) } - @Test - func openSettings() async { + @Test func openSettings() async { let settingsOpened = LockIsolated(false) let model = withDependencies { $0.openSettings = { settingsOpened.setValue(true) } @@ -59,8 +56,7 @@ struct SyncUpDetailTests { #expect(settingsOpened.value == true) } - @Test - func continueWithoutRecording() async throws { + @Test func continueWithoutRecording() async throws { let syncUp = SyncUp.mock let model = SyncUpDetailModel( @@ -70,13 +66,10 @@ struct SyncUpDetailTests { await model.alertButtonTapped(.continueWithoutRecording) - withKnownIssue("This should pass") { - #expect(path == [.record(id: syncUp.id)]) - } + #expect(path == [.record(id: syncUp.id)]) } - @Test - func speechAuthorized() async throws { + @Test func speechAuthorized() async throws { let syncUp = SyncUp.mock let model = withDependencies { @@ -87,9 +80,7 @@ struct SyncUpDetailTests { model.startMeetingButtonTapped() - withKnownIssue("This should pass") { - #expect(path == [.record(id: syncUp.id)]) - } + #expect(path == [.record(id: syncUp.id)]) } @Test(.dependency(\.uuid, .incrementing)) @@ -125,8 +116,7 @@ struct SyncUpDetailTests { ) } - @Test - func delete() async { + @Test func delete() async { let syncUp = SyncUp.mock @Shared(.syncUps) var syncUps = [syncUp] $path.withLock { $0 = [.detail(id: syncUp.id)] } diff --git a/SyncUps/SyncUpsTests/SyncUpFormTests.swift b/SyncUps/SyncUpsTests/SyncUpFormTests.swift index 9b5f90f..7fbe95b 100644 --- a/SyncUps/SyncUpsTests/SyncUpFormTests.swift +++ b/SyncUps/SyncUpsTests/SyncUpFormTests.swift @@ -7,8 +7,7 @@ import Testing @MainActor @Suite(.dependency(\.uuid, .incrementing)) struct SyncUpFormTests { - @Test - func addAttendee() async { + @Test func addAttendee() async { let model = SyncUpFormModel( syncUp: SyncUp( id: SyncUp.ID(), @@ -35,8 +34,7 @@ struct SyncUpFormTests { ) } - @Test - func focusAddAttendee() async { + @Test func focusAddAttendee() async { let model = SyncUpFormModel( syncUp: SyncUp( id: SyncUp.ID(), @@ -54,8 +52,7 @@ struct SyncUpFormTests { ) } - @Test - func focusRemoveAttendee() async { + @Test func focusRemoveAttendee() async { @Dependency(\.uuid) var uuid let model = SyncUpFormModel( syncUp: SyncUp( From ae23e2302827b0d6d7f9f58dadf3829dcafbdd12 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 25 Nov 2024 11:26:46 -0600 Subject: [PATCH 16/25] wip --- README.md | 3 ++- SyncUps/SyncUps/AppPath.swift | 20 +++++++++----------- SyncUps/SyncUps/SyncUpDetail.swift | 4 +--- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 092e252..02d76b7 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ some key additions: [SwiftNavigation library][swift-nav-gh]. 1. Persistence is handled by our [Sharing][sharing-gh] library, which allows one to hold onto shared state in an observable model or view, and under the hood any changes to the state will be - persisted to external storage, such as the file system. + persisted to external storage, such as the file system. Even the global navigation is persisted + using the Sharing library. 1. All side effects are controlled. This includes access to the file system for persistence, access to time-based asynchrony for timers, access to speech recognition APIs, and even the creation of dates and UUIDs. This allows us to run our application in specific execution contexts, which diff --git a/SyncUps/SyncUps/AppPath.swift b/SyncUps/SyncUps/AppPath.swift index b8c4f99..e587b09 100644 --- a/SyncUps/SyncUps/AppPath.swift +++ b/SyncUps/SyncUps/AppPath.swift @@ -1,12 +1,18 @@ -import CasePaths import Foundation import Sharing -@CasePathable enum AppPath: Codable, Hashable { case detail(id: SyncUp.ID) case meeting(id: Meeting.ID, syncUpID: SyncUp.ID) case record(id: SyncUp.ID) + + // NB: Encode only certain paths for state restoration. + var isRestorable: Bool { + switch self { + case .detail, .meeting: true + case .record: false + } + } } extension SharedReaderKey where Self == FileStorageKey<[AppPath]>.Default { @@ -18,15 +24,7 @@ extension SharedReaderKey where Self == FileStorageKey<[AppPath]>.Default { try JSONDecoder().decode([AppPath].self, from: data) }, encode: { path in - try JSONEncoder().encode( - // NB: Encode only certain paths for state restoration. - path.filter { - switch $0 { - case .detail, .meeting: true - case .record: false - } - } - ) + try JSONEncoder().encode(path.filter(\.isRestorable)) } ), default: [] diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 69b99f2..3b86121 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -116,9 +116,7 @@ struct SyncUpDetailView: View { init?(id: SyncUp.ID) { @Shared(.syncUps) var syncUps guard let syncUp = Shared($syncUps[id: id]) - else { - return nil - } + else { return nil } _model = State(wrappedValue: SyncUpDetailModel(syncUp: syncUp)) } From 38ee9b4bafa4490e0151da3b45201d0594aee58e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 26 Nov 2024 19:30:08 -0600 Subject: [PATCH 17/25] wip --- SyncUps/SyncUps/RecordMeeting.swift | 4 ++-- SyncUps/SyncUps/SyncUpDetail.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index e4b3692..041361c 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -59,9 +59,9 @@ final class RecordMeetingModel { func alertButtonTapped(_ action: AlertAction?) async { switch action { - case .confirmSave?: + case .confirmSave: await finishMeeting() - case .confirmDiscard?: + case .confirmDiscard: _ = $path.withLock { $0.removeLast() } case nil: break diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 3b86121..8a6d2f2 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -50,7 +50,7 @@ final class SyncUpDetailModel { func alertButtonTapped(_ action: AlertAction?) async { switch action { - case .confirmDeletion?: + case .confirmDeletion: _ = $path.withLock { $0.removeLast() } try? await self.clock.sleep(for: .seconds(0.4)) @Shared(.syncUps) var syncUps @@ -58,12 +58,12 @@ final class SyncUpDetailModel { _ = $syncUps.withLock { $0.remove(id: self.syncUp.id) } } - case .continueWithoutRecording?: + case .continueWithoutRecording: $path.withLock { $0.append(.record(id: syncUp.id)) } - case .openSettings?: + case .openSettings: await openSettings() case nil: From d70113b2f7cbe86b5f1439b3c96722ddce349452 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Dec 2024 07:46:34 -0600 Subject: [PATCH 18/25] pin to releases --- SyncUps/SyncUps.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index a677e27..d961fcd 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -758,8 +758,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { - branch = "dependency-trait-fix"; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; CAAF4DD52C3589A700774888 /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { @@ -774,8 +774,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "http://github.com/pointfreeco/swift-sharing/"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f57f873 --- /dev/null +++ b/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "a41d8ea0a7e275eaf005ddc4bfd786d637276802746d02247b43cc5cfb463378", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "7d2eb4ad20efb2838269645410d26b64ca48d8aa", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", + "version" : "2.2.2" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "http://github.com/pointfreeco/swift-sharing/", + "state" : { + "revision" : "0afc5170fc2d2a5f6d2ef1dd284cf4f698740603", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", + "version" : "1.4.3" + } + } + ], + "version" : 3 +} From d4166654b3294b60c535668349ee226109cf4115 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Dec 2024 07:51:17 -0600 Subject: [PATCH 19/25] clean up --- SyncUps/SyncUps/App.swift | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/SyncUps/SyncUps/App.swift b/SyncUps/SyncUps/App.swift index 512e27e..dd2f9f0 100644 --- a/SyncUps/SyncUps/App.swift +++ b/SyncUps/SyncUps/App.swift @@ -25,27 +25,21 @@ struct AppView: View { } #Preview("Happy path") { - @Shared(.syncUps) var syncUps - let _ = $syncUps.withLock { - $0 = [ - SyncUp.mock, - .engineeringMock, - .designMock, - ] - } + @Shared(.syncUps) var syncUps = [ + SyncUp.mock, + .engineeringMock, + .designMock, + ] AppView() } #Preview("Deep link record flow") { let syncUp = SyncUp.mock - @Shared(.syncUps) var syncUps - let _ = $syncUps.withLock { - $0 = [ - syncUp, - .engineeringMock, - .designMock, - ] - } + @Shared(.syncUps) var syncUps = [ + syncUp, + .engineeringMock, + .designMock, + ] @Shared(.path) var path = [ .detail(id: syncUp.id), .record(id: syncUp.id), From c2f04a5f6490dbe07e0cd7885c1e122744565a66 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Dec 2024 07:56:21 -0600 Subject: [PATCH 20/25] clean up --- SyncUps/SyncUps/RecordMeeting.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index 041361c..7130d66 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -387,12 +387,12 @@ struct MeetingFooterView: View { } } -#Preview("Speech failure after 2 secs") { +#Preview( + "Speech failure after 2 secs", + traits: .dependency(\.speechClient, .fail(after: .seconds(2))) +) { let syncUp = SyncUp.mock @Shared(.syncUps) var syncUps = [syncUp] - let _ = prepareDependencies { - $0.speechClient = .fail(after: .seconds(2)) - } Preview( message: """ From 6a4a9340daeddec30d064d2b2be23db7830d8cc0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Dec 2024 08:05:21 -0600 Subject: [PATCH 21/25] wip --- SyncUps/SyncUps/SyncUpsApp.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SyncUps/SyncUps/SyncUpsApp.swift b/SyncUps/SyncUps/SyncUpsApp.swift index e78a600..df2b3f8 100644 --- a/SyncUps/SyncUps/SyncUpsApp.swift +++ b/SyncUps/SyncUps/SyncUpsApp.swift @@ -54,8 +54,7 @@ private func setUpForUITest() { // Seed certain test cases with specific state. switch testName { case "testDelete", "testEdit", "testRecord", "testRecord_Discard": - @Shared(.syncUps) var syncUps - $syncUps.withLock { $0 = [SyncUp.mock] } + @Shared(.syncUps) var syncUps = [.mock] default: break } From 62a2b4d5ceea03bf5afd6d0dfca0dc4c69beb878 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Dec 2024 09:50:11 -0600 Subject: [PATCH 22/25] wip --- .../xcshareddata/swiftpm/Package.resolved | 12 +++--- SyncUps/SyncUps.xcodeproj/project.pbxproj | 2 +- SyncUps/SyncUps/RecordMeeting.swift | 42 ++++++++----------- SyncUps/SyncUps/SyncUpDetail.swift | 2 - 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved index f92c50a..f57f873 100644 --- a/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SyncUps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "branch" : "dependency-trait-fix", - "revision" : "9dcf4357cd12ef91a1c9c35a58c4a063150a50dc" + "revision" : "7d2eb4ad20efb2838269645410d26b64ca48d8aa", + "version" : "1.6.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "dccdf5aed1db969afa11d6fbd36b96a4932ebe8c", - "version" : "1.4.0" + "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", + "version" : "1.4.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-sharing/", "state" : { - "branch" : "main", - "revision" : "aefeae51b6c246fce58e33f788b2d235e92532c2" + "revision" : "0afc5170fc2d2a5f6d2ef1dd284cf4f698740603", + "version" : "1.0.1" } }, { diff --git a/SyncUps/SyncUps.xcodeproj/project.pbxproj b/SyncUps/SyncUps.xcodeproj/project.pbxproj index d961fcd..94b464c 100644 --- a/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -772,7 +772,7 @@ }; CAF8AAAF2CD91A95007192A6 /* XCRemoteSwiftPackageReference "swift-sharing" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "http://github.com/pointfreeco/swift-sharing/"; + repositoryURL = "http://github.com/pointfreeco/swift-sharing"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index 7130d66..c9385d1 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -10,9 +10,9 @@ import SwiftUINavigation @Observable final class RecordMeetingModel { var alert: AlertState? + @ObservationIgnored @Shared(.path) var path var secondsElapsed = 0 var speakerIndex = 0 - @ObservationIgnored @Shared(.path) var path @ObservationIgnored @Shared var syncUp: SyncUp private var transcript = "" @@ -27,9 +27,7 @@ final class RecordMeetingModel { case confirmDiscard } - init( - syncUp: Shared - ) { + init(syncUp: Shared) { self._syncUp = syncUp } @@ -37,10 +35,6 @@ final class RecordMeetingModel { syncUp.duration - .seconds(secondsElapsed) } - var isAlertOpen: Bool { - alert != nil - } - func nextButtonTapped() { guard speakerIndex < syncUp.attendees.count - 1 else { @@ -105,7 +99,7 @@ final class RecordMeetingModel { } private func startTimer() async { - for await _ in clock.timer(interval: .seconds(1)) where !isAlertOpen { + for await _ in clock.timer(interval: .seconds(1)) where alert == nil { secondsElapsed += 1 let secondsPerAttendee = Int(syncUp.durationPerAttendee.components.seconds) @@ -139,8 +133,6 @@ final class RecordMeetingModel { } } -extension RecordMeetingModel: HashableObject {} - extension AlertState where Action == RecordMeetingModel.AlertAction { static func endMeeting(isDiscardable: Bool) -> Self { Self { @@ -193,40 +185,40 @@ struct RecordMeetingView: View { var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) - .fill(self.model.syncUp.theme.mainColor) + .fill(model.syncUp.theme.mainColor) VStack { MeetingHeaderView( - secondsElapsed: self.model.secondsElapsed, - durationRemaining: self.model.durationRemaining, - theme: self.model.syncUp.theme + secondsElapsed: model.secondsElapsed, + durationRemaining: model.durationRemaining, + theme: model.syncUp.theme ) MeetingTimerView( - syncUp: self.model.syncUp, - speakerIndex: self.model.speakerIndex + syncUp: model.syncUp, + speakerIndex: model.speakerIndex ) MeetingFooterView( - syncUp: self.model.syncUp, - nextButtonTapped: { self.model.nextButtonTapped() }, - speakerIndex: self.model.speakerIndex + syncUp: model.syncUp, + nextButtonTapped: { model.nextButtonTapped() }, + speakerIndex: model.speakerIndex ) } } .padding() - .foregroundColor(self.model.syncUp.theme.accentColor) + .foregroundColor(model.syncUp.theme.accentColor) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("End meeting") { - self.model.endMeetingButtonTapped() + model.endMeetingButtonTapped() } } } .navigationBarBackButtonHidden(true) - .alert(self.$model.alert) { action in - await self.model.alertButtonTapped(action) + .alert($model.alert) { action in + await model.alertButtonTapped(action) } - .task { await self.model.task() } + .task { await model.task() } } } diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 8a6d2f2..2ac1a7e 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -108,8 +108,6 @@ final class SyncUpDetailModel { } } -extension SyncUpDetailModel: HashableObject {} - struct SyncUpDetailView: View { @State var model: SyncUpDetailModel From 3afbe8012ce60a8d9951edc8baee6bfabdd643e2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Dec 2024 09:53:20 -0600 Subject: [PATCH 23/25] wip --- .../SyncUps/Dependencies/SpeechClient.swift | 14 ++-- SyncUps/SyncUps/RecordMeeting.swift | 8 +- SyncUps/SyncUps/SyncUpDetail.swift | 14 ++-- SyncUps/SyncUps/SyncUpForm.swift | 4 +- SyncUps/SyncUps/SyncUpsList.swift | 20 ++--- .../SyncUpsUITests/SyncUpsListUITests.swift | 82 +++++++++---------- 6 files changed, 71 insertions(+), 71 deletions(-) diff --git a/SyncUps/SyncUps/Dependencies/SpeechClient.swift b/SyncUps/SyncUps/Dependencies/SpeechClient.swift index ca2e468..3e1d761 100644 --- a/SyncUps/SyncUps/Dependencies/SpeechClient.swift +++ b/SyncUps/SyncUps/Dependencies/SpeechClient.swift @@ -143,7 +143,7 @@ private actor Speech { request: SFSpeechAudioBufferRecognitionRequest ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - self.recognitionContinuation = continuation + recognitionContinuation = continuation let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) @@ -153,9 +153,9 @@ private actor Speech { return } - self.audioEngine = AVAudioEngine() + audioEngine = AVAudioEngine() let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! - self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in + recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in switch (result, error) { case let (.some(result), _): continuation.yield(SpeechRecognitionResult(result)) @@ -173,17 +173,17 @@ private actor Speech { recognitionTask?.finish() } - self.audioEngine?.inputNode.installTap( + audioEngine?.inputNode.installTap( onBus: 0, bufferSize: 1024, - format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + format: audioEngine?.inputNode.outputFormat(forBus: 0) ) { buffer, when in request.append(buffer) } - self.audioEngine?.prepare() + audioEngine?.prepare() do { - try self.audioEngine?.start() + try audioEngine?.start() } catch { continuation.finish(throwing: error) return diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index c9385d1..6d9289b 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -73,11 +73,11 @@ final class RecordMeetingModel { await withTaskGroup(of: Void.self) { group in if authorization == .authorized { group.addTask { - await self.startSpeechRecognition() + await startSpeechRecognition() } } group.addTask { - await self.startTimer() + await startTimer() } } } @@ -122,8 +122,8 @@ final class RecordMeetingModel { $syncUp.withLock { $0.meetings.insert( Meeting( - id: Meeting.ID(self.uuid()), - date: self.now, + id: Meeting.ID(uuid()), + date: now, transcript: transcript ), at: 0 diff --git a/SyncUps/SyncUps/SyncUpDetail.swift b/SyncUps/SyncUps/SyncUpDetail.swift index 2ac1a7e..e52d309 100644 --- a/SyncUps/SyncUps/SyncUpDetail.swift +++ b/SyncUps/SyncUps/SyncUpDetail.swift @@ -11,8 +11,8 @@ import SwiftUINavigation @Observable final class SyncUpDetailModel { var destination: Destination? - @ObservationIgnored @Shared var syncUp: SyncUp @ObservationIgnored @Shared(.path) var path + @ObservationIgnored @Shared var syncUp: SyncUp @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.date.now) var now @@ -52,10 +52,10 @@ final class SyncUpDetailModel { switch action { case .confirmDeletion: _ = $path.withLock { $0.removeLast() } - try? await self.clock.sleep(for: .seconds(0.4)) + try? await clock.sleep(for: .seconds(0.4)) @Shared(.syncUps) var syncUps withAnimation { - _ = $syncUps.withLock { $0.remove(id: self.syncUp.id) } + _ = $syncUps.withLock { $0.remove(id: syncUp.id) } } case .continueWithoutRecording: @@ -74,7 +74,7 @@ final class SyncUpDetailModel { func editButtonTapped() { destination = .edit( withDependencies(from: self) { - SyncUpFormModel(syncUp: self.syncUp) + SyncUpFormModel(syncUp: syncUp) } ) } @@ -289,16 +289,16 @@ struct MeetingView: View { .padding(.bottom) Text("Attendees") .font(.headline) - ForEach(self.syncUp.attendees) { attendee in + ForEach(syncUp.attendees) { attendee in Text(attendee.name) } Text("Transcript") .font(.headline) .padding(.top) - Text(self.meeting.transcript) + Text(meeting.transcript) } } - .navigationTitle(Text(self.meeting.date, style: .date)) + .navigationTitle(Text(meeting.date, style: .date)) .padding() } } diff --git a/SyncUps/SyncUps/SyncUpForm.swift b/SyncUps/SyncUps/SyncUpForm.swift index d79b807..61adf9f 100644 --- a/SyncUps/SyncUps/SyncUpForm.swift +++ b/SyncUps/SyncUps/SyncUpForm.swift @@ -20,8 +20,8 @@ final class SyncUpFormModel: Identifiable { ) { self.focus = focus self.syncUp = syncUp - if self.syncUp.attendees.isEmpty { - self.syncUp.attendees.append(Attendee(id: Attendee.ID(self.uuid()))) + if syncUp.attendees.isEmpty { + self.syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) } } diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index 8e0558d..5c3cfb9 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -22,7 +22,7 @@ final class SyncUpsListModel { func addSyncUpButtonTapped() { addSyncUp = withDependencies(from: self) { - SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(self.uuid()))) + SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(uuid()))) } } @@ -52,7 +52,7 @@ struct SyncUpsList: View { var body: some View { List { - ForEach(self.model.syncUps) { syncUp in + ForEach(model.syncUps) { syncUp in NavigationLink(value: AppPath.detail(id: syncUp.id)) { CardView(syncUp: syncUp) } @@ -61,25 +61,25 @@ struct SyncUpsList: View { } .toolbar { Button { - self.model.addSyncUpButtonTapped() + model.addSyncUpButtonTapped() } label: { Image(systemName: "plus") } } .navigationTitle("Daily Sync-ups") - .sheet(item: self.$model.addSyncUp) { model in + .sheet(item: $model.addSyncUp) { model in NavigationStack { SyncUpFormView(model: model) .navigationTitle("New sync-up") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Dismiss") { - self.model.dismissAddSyncUpButtonTapped() + model.dismissAddSyncUpButtonTapped() } } ToolbarItem(placement: .confirmationAction) { Button("Add") { - self.model.confirmAddSyncUpButtonTapped() + model.confirmAddSyncUpButtonTapped() } } } @@ -93,19 +93,19 @@ struct CardView: View { var body: some View { VStack(alignment: .leading) { - Text(self.syncUp.title) + Text(syncUp.title) .font(.headline) Spacer() HStack { - Label("\(self.syncUp.attendees.count)", systemImage: "person.3") + Label("\(syncUp.attendees.count)", systemImage: "person.3") Spacer() - Label(self.syncUp.duration.formatted(.units()), systemImage: "clock") + Label(syncUp.duration.formatted(.units()), systemImage: "clock") .labelStyle(.trailingIcon) } .font(.caption) } .padding() - .foregroundColor(self.syncUp.theme.accentColor) + .foregroundColor(syncUp.theme.accentColor) } } diff --git a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift index 778a9da..cc2181f 100644 --- a/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift +++ b/SyncUps/SyncUpsUITests/SyncUpsListUITests.swift @@ -34,89 +34,89 @@ final class SyncUpsListUITests: XCTestCase { // available on next launch. @MainActor func testAdd() throws { - self.app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - let titleTextField = self.app.collectionViews.textFields["Title"] - let nameTextField = self.app.collectionViews.textFields["Name"] + app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() + let titleTextField = app.collectionViews.textFields["Title"] + let nameTextField = app.collectionViews.textFields["Name"] titleTextField.typeText("Engineering") nameTextField.tap() nameTextField.typeText("Blob") - self.app.buttons["New attendee"].tap() - self.app.typeText("Blob Jr.") + app.buttons["New attendee"].tap() + app.typeText("Blob Jr.") - self.app.navigationBars["New sync-up"].buttons["Add"].tap() + app.navigationBars["New sync-up"].buttons["Add"].tap() - XCTAssertEqual(self.app.staticTexts["Engineering"].exists, true) + XCTAssertEqual(app.staticTexts["Engineering"].exists, true) } @MainActor func testDelete() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Delete"].tap() - XCTAssertEqual(self.app.staticTexts["Delete?"].exists, true) + app.buttons["Delete"].tap() + XCTAssertEqual(app.staticTexts["Delete?"].exists, true) - self.app.buttons["Yes"].tap() + app.buttons["Yes"].tap() try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, false) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, false) + XCTAssertEqual(app.staticTexts["Daily Sync-ups"].exists, true) } @MainActor func testEdit() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Edit"].tap() - let titleTextField = self.app.textFields["Title"] + app.buttons["Edit"].tap() + let titleTextField = app.textFields["Title"] titleTextField.typeText(" & Product") - self.app.buttons["Done"].tap() - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) + app.buttons["Done"].tap() + XCTAssertEqual(app.staticTexts["Design & Product"].exists, true) - self.app.buttons["Daily Sync-ups"].tap() + app.buttons["Daily Sync-ups"].tap() try await Task.sleep(for: .seconds(0.3)) - XCTAssertEqual(self.app.staticTexts["Design & Product"].exists, true) - XCTAssertEqual(self.app.staticTexts["Daily Sync-ups"].exists, true) + XCTAssertEqual(app.staticTexts["Design & Product"].exists, true) + XCTAssertEqual(app.staticTexts["Daily Sync-ups"].exists, true) } @MainActor func testRecord() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() + app.buttons["Start Meeting"].tap() + app.buttons["End meeting"].tap() - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Save and end"].tap() + XCTAssertEqual(app.staticTexts["End meeting?"].exists, true) + app.buttons["Save and end"].tap() try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, true) - self.app.buttons["Daily Sync-ups"].tap() - self.app.staticTexts["Design"].tap() + app.buttons["Daily Sync-ups"].tap() + app.staticTexts["Design"].tap() - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, true) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, true) - self.app.staticTexts["February 13, 2009"].tap() - self.app.staticTexts["Hello world!"].tap() + app.staticTexts["February 13, 2009"].tap() + app.staticTexts["Hello world!"].tap() } @MainActor func testRecord_Discard() async throws { - self.app.staticTexts["Design"].tap() + app.staticTexts["Design"].tap() - self.app.buttons["Start Meeting"].tap() - self.app.buttons["End meeting"].tap() + app.buttons["Start Meeting"].tap() + app.buttons["End meeting"].tap() - XCTAssertEqual(self.app.staticTexts["End meeting?"].exists, true) - self.app.buttons["Discard"].tap() + XCTAssertEqual(app.staticTexts["End meeting?"].exists, true) + app.buttons["Discard"].tap() try await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(self.app.staticTexts["Design"].exists, true) - XCTAssertEqual(self.app.staticTexts["February 13, 2009"].exists, false) + XCTAssertEqual(app.staticTexts["Design"].exists, true) + XCTAssertEqual(app.staticTexts["February 13, 2009"].exists, false) } } From dd01bcc81448c78108b6f350d77513b445161931 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Dec 2024 09:54:16 -0600 Subject: [PATCH 24/25] wip --- SyncUps/SyncUps/RecordMeeting.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SyncUps/SyncUps/RecordMeeting.swift b/SyncUps/SyncUps/RecordMeeting.swift index 6d9289b..1862611 100644 --- a/SyncUps/SyncUps/RecordMeeting.swift +++ b/SyncUps/SyncUps/RecordMeeting.swift @@ -73,11 +73,11 @@ final class RecordMeetingModel { await withTaskGroup(of: Void.self) { group in if authorization == .authorized { group.addTask { - await startSpeechRecognition() + await self.startSpeechRecognition() } } group.addTask { - await startTimer() + await self.startTimer() } } } From f93bed4f356f602f733cc9a931e04794c20f253f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Dec 2024 09:54:51 -0600 Subject: [PATCH 25/25] wip --- SyncUps/SyncUps/SyncUpsList.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SyncUps/SyncUps/SyncUpsList.swift b/SyncUps/SyncUps/SyncUpsList.swift index 5c3cfb9..5eba6d6 100644 --- a/SyncUps/SyncUps/SyncUpsList.swift +++ b/SyncUps/SyncUps/SyncUpsList.swift @@ -67,9 +67,9 @@ struct SyncUpsList: View { } } .navigationTitle("Daily Sync-ups") - .sheet(item: $model.addSyncUp) { model in + .sheet(item: $model.addSyncUp) { syncUpFormModel in NavigationStack { - SyncUpFormView(model: model) + SyncUpFormView(model: syncUpFormModel) .navigationTitle("New sync-up") .toolbar { ToolbarItem(placement: .cancellationAction) {