diff --git a/.swiftformat b/.swiftformat index fc594a2..6a3d003 100644 --- a/.swiftformat +++ b/.swiftformat @@ -10,3 +10,4 @@ --wrapparameters before-first --extensionacl on-declarations --maxwidth 100 +--header \n {file}\n (c) {created.year} Binary Scraping Co.\n LICENSE: MIT\n diff --git a/App/SupaSlack.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj similarity index 56% rename from App/SupaSlack.xcodeproj/project.pbxproj rename to App/App.xcodeproj/project.pbxproj index 72852ff..0ebbeec 100644 --- a/App/SupaSlack.xcodeproj/project.pbxproj +++ b/App/App.xcodeproj/project.pbxproj @@ -7,59 +7,32 @@ objects = { /* Begin PBXBuildFile section */ - 791497CA2956FC0200525E6A /* ConcurrencyHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 791497C92956FC0200525E6A /* ConcurrencyHelpers */; }; - 791497CC2956FC0200525E6A /* DebuggingHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 791497CB2956FC0200525E6A /* DebuggingHelpers */; }; - 791497CE2956FC0200525E6A /* Prelude in Frameworks */ = {isa = PBXBuildFile; productRef = 791497CD2956FC0200525E6A /* Prelude */; }; - 791497D02956FC0200525E6A /* SwiftUIHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 791497CF2956FC0200525E6A /* SwiftUIHelpers */; }; - 791497D32956FC5900525E6A /* ToastUI in Frameworks */ = {isa = PBXBuildFile; productRef = 791497D22956FC5900525E6A /* ToastUI */; }; - 791497D52956FCA200525E6A /* ToastState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791497D42956FCA200525E6A /* ToastState.swift */; }; 791497D72956FF5F00525E6A /* ChannelListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791497D62956FF5F00525E6A /* ChannelListView.swift */; }; 791497D92956FF9600525E6A /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791497D82956FF9600525E6A /* Models.swift */; }; 791497DB295700B900525E6A /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791497DA295700B900525E6A /* APIClient.swift */; }; 791497DD2957058000525E6A /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791497DC2957058000525E6A /* Store.swift */; }; - 793700DC29563D7A00D13E50 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 793700DB29563D7A00D13E50 /* Dependencies */; }; 7967ACAC2956200300C30D93 /* SupaSlackApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACAB2956200300C30D93 /* SupaSlackApp.swift */; }; - 7967ACAE2956200300C30D93 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACAD2956200300C30D93 /* AppView.swift */; }; 7967ACB02956200400C30D93 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7967ACAF2956200400C30D93 /* Assets.xcassets */; }; 7967ACB42956200400C30D93 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7967ACB32956200400C30D93 /* Preview Assets.xcassets */; }; - 7967ACBC2956206700C30D93 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACBB2956206700C30D93 /* Supabase */; }; - 7967ACBF2956208100C30D93 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACBE2956208100C30D93 /* IdentifiedCollections */; }; - 7967ACC2295620CD00C30D93 /* XCTestDynamicOverlay in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACC1295620CD00C30D93 /* XCTestDynamicOverlay */; }; - 7967ACC5295620E100C30D93 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACC4295620E100C30D93 /* SwiftUINavigation */; }; - 7967ACC7295620E100C30D93 /* _SwiftUINavigationState in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACC6295620E100C30D93 /* _SwiftUINavigationState */; }; - 7967ACC92956212C00C30D93 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACC82956212C00C30D93 /* AuthClient.swift */; }; - 7967ACCC2956216100C30D93 /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = 7967ACCB2956216100C30D93 /* Tagged */; }; - 7967ACD02956225100C30D93 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACCF2956225100C30D93 /* AuthView.swift */; }; - 7967ACD2295624CB00C30D93 /* SupaTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACD1295624CB00C30D93 /* SupaTextFieldStyle.swift */; }; - 7967ACD42956255200C30D93 /* SupaButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACD32956255200C30D93 /* SupaButtonStyle.swift */; }; - 7967ACD6295638FD00C30D93 /* Supabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7967ACD5295638FD00C30D93 /* Supabase.swift */; }; - 79B2F755297936E20091B3D9 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 79B2F754297936E20091B3D9 /* GRDB */; }; - 79B2F758297936F50091B3D9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2F757297936F50091B3D9 /* AppDatabase.swift */; }; - 79B2F75A297937150091B3D9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2F759297937150091B3D9 /* Persistence.swift */; }; + 79915201297C158A00F2A6DD /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 79915200297C158A00F2A6DD /* AppFeature */; }; + 79915203297C160800F2A6DD /* APIClientLive in Frameworks */ = {isa = PBXBuildFile; productRef = 79915202297C160800F2A6DD /* APIClientLive */; }; + 79915205297C160800F2A6DD /* AuthClientLive in Frameworks */ = {isa = PBXBuildFile; productRef = 79915204297C160800F2A6DD /* AuthClientLive */; }; + 79915207297C160800F2A6DD /* DatabaseClientLive in Frameworks */ = {isa = PBXBuildFile; productRef = 79915206297C160800F2A6DD /* DatabaseClientLive */; }; 79D6A0CA2959D58D00186187 /* ComposeMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D6A0C92959D58D00186187 /* ComposeMessageView.swift */; }; 79D6A0CC2959D5CA00186187 /* MessageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D6A0CB2959D5CA00186187 /* MessageRowView.swift */; }; 79DB4CCB2959AB4200AE167A /* MessagesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DB4CCA2959AB4200AE167A /* MessagesListView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 791497D42956FCA200525E6A /* ToastState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastState.swift; sourceTree = ""; }; 791497D62956FF5F00525E6A /* ChannelListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListView.swift; sourceTree = ""; }; 791497D82956FF9600525E6A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 791497DA295700B900525E6A /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 791497DC2957058000525E6A /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 7967ACA82956200300C30D93 /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7967ACAB2956200300C30D93 /* SupaSlackApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupaSlackApp.swift; sourceTree = ""; }; - 7967ACAD2956200300C30D93 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; 7967ACAF2956200400C30D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7967ACB12956200400C30D93 /* SupaSlack.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SupaSlack.entitlements; sourceTree = ""; }; 7967ACB32956200400C30D93 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 7967ACC82956212C00C30D93 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = ""; }; - 7967ACCF2956225100C30D93 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; - 7967ACD1295624CB00C30D93 /* SupaTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupaTextFieldStyle.swift; sourceTree = ""; }; - 7967ACD32956255200C30D93 /* SupaButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupaButtonStyle.swift; sourceTree = ""; }; - 7967ACD5295638FD00C30D93 /* Supabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Supabase.swift; sourceTree = ""; }; - 79B2F757297936F50091B3D9 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; - 79B2F759297937150091B3D9 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 79D6A0C92959D58D00186187 /* ComposeMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeMessageView.swift; sourceTree = ""; }; 79D6A0CB2959D5CA00186187 /* MessageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRowView.swift; sourceTree = ""; }; 79DB4CCA2959AB4200AE167A /* MessagesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesListView.swift; sourceTree = ""; }; @@ -70,30 +43,29 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 791497D32956FC5900525E6A /* ToastUI in Frameworks */, - 791497CA2956FC0200525E6A /* ConcurrencyHelpers in Frameworks */, - 7967ACCC2956216100C30D93 /* Tagged in Frameworks */, - 7967ACC5295620E100C30D93 /* SwiftUINavigation in Frameworks */, - 791497CC2956FC0200525E6A /* DebuggingHelpers in Frameworks */, - 79B2F755297936E20091B3D9 /* GRDB in Frameworks */, - 7967ACBC2956206700C30D93 /* Supabase in Frameworks */, - 793700DC29563D7A00D13E50 /* Dependencies in Frameworks */, - 7967ACC2295620CD00C30D93 /* XCTestDynamicOverlay in Frameworks */, - 7967ACC7295620E100C30D93 /* _SwiftUINavigationState in Frameworks */, - 791497CE2956FC0200525E6A /* Prelude in Frameworks */, - 791497D02956FC0200525E6A /* SwiftUIHelpers in Frameworks */, - 7967ACBF2956208100C30D93 /* IdentifiedCollections in Frameworks */, + 79915203297C160800F2A6DD /* APIClientLive in Frameworks */, + 79915201297C158A00F2A6DD /* AppFeature in Frameworks */, + 79915207297C160800F2A6DD /* DatabaseClientLive in Frameworks */, + 79915205297C160800F2A6DD /* AuthClientLive in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 79331DBE2979650400141AA3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 7967AC9F2956200300C30D93 = { isa = PBXGroup; children = ( 7967ACAA2956200300C30D93 /* Sources */, 7967ACA92956200300C30D93 /* Products */, + 79331DBE2979650400141AA3 /* Frameworks */, ); sourceTree = ""; }; @@ -108,18 +80,10 @@ 7967ACAA2956200300C30D93 /* Sources */ = { isa = PBXGroup; children = ( - 79B2F756297936E70091B3D9 /* Database */, 7967ACAB2956200300C30D93 /* SupaSlackApp.swift */, - 7967ACAD2956200300C30D93 /* AppView.swift */, 7967ACAF2956200400C30D93 /* Assets.xcassets */, 7967ACB12956200400C30D93 /* SupaSlack.entitlements */, 7967ACB22956200400C30D93 /* Preview Content */, - 7967ACC82956212C00C30D93 /* AuthClient.swift */, - 7967ACCF2956225100C30D93 /* AuthView.swift */, - 7967ACD1295624CB00C30D93 /* SupaTextFieldStyle.swift */, - 7967ACD32956255200C30D93 /* SupaButtonStyle.swift */, - 7967ACD5295638FD00C30D93 /* Supabase.swift */, - 791497D42956FCA200525E6A /* ToastState.swift */, 791497D62956FF5F00525E6A /* ChannelListView.swift */, 791497D82956FF9600525E6A /* Models.swift */, 791497DA295700B900525E6A /* APIClient.swift */, @@ -139,15 +103,6 @@ path = "Preview Content"; sourceTree = ""; }; - 79B2F756297936E70091B3D9 /* Database */ = { - isa = PBXGroup; - children = ( - 79B2F757297936F50091B3D9 /* AppDatabase.swift */, - 79B2F759297937150091B3D9 /* Persistence.swift */, - ); - path = Database; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -165,19 +120,10 @@ ); name = App; packageProductDependencies = ( - 7967ACBB2956206700C30D93 /* Supabase */, - 7967ACBE2956208100C30D93 /* IdentifiedCollections */, - 7967ACC1295620CD00C30D93 /* XCTestDynamicOverlay */, - 7967ACC4295620E100C30D93 /* SwiftUINavigation */, - 7967ACC6295620E100C30D93 /* _SwiftUINavigationState */, - 7967ACCB2956216100C30D93 /* Tagged */, - 793700DB29563D7A00D13E50 /* Dependencies */, - 791497C92956FC0200525E6A /* ConcurrencyHelpers */, - 791497CB2956FC0200525E6A /* DebuggingHelpers */, - 791497CD2956FC0200525E6A /* Prelude */, - 791497CF2956FC0200525E6A /* SwiftUIHelpers */, - 791497D22956FC5900525E6A /* ToastUI */, - 79B2F754297936E20091B3D9 /* GRDB */, + 79915200297C158A00F2A6DD /* AppFeature */, + 79915202297C160800F2A6DD /* APIClientLive */, + 79915204297C160800F2A6DD /* AuthClientLive */, + 79915206297C160800F2A6DD /* DatabaseClientLive */, ); productName = SupaSlack; productReference = 7967ACA82956200300C30D93 /* App.app */; @@ -198,7 +144,7 @@ }; }; }; - buildConfigurationList = 7967ACA32956200300C30D93 /* Build configuration list for PBXProject "SupaSlack" */; + buildConfigurationList = 7967ACA32956200300C30D93 /* Build configuration list for PBXProject "App" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -208,15 +154,6 @@ ); mainGroup = 7967AC9F2956200300C30D93; packageReferences = ( - 7967ACBA2956206700C30D93 /* XCRemoteSwiftPackageReference "supabase-swift" */, - 7967ACBD2956208100C30D93 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, - 7967ACC0295620CD00C30D93 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */, - 7967ACC3295620E100C30D93 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, - 7967ACCA2956216100C30D93 /* XCRemoteSwiftPackageReference "swift-tagged" */, - 793700DA29563D7A00D13E50 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, - 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */, - 791497D12956FC5900525E6A /* XCRemoteSwiftPackageReference "swiftui-toast" */, - 79B2F753297936E20091B3D9 /* XCRemoteSwiftPackageReference "GRDB" */, ); productRefGroup = 7967ACA92956200300C30D93 /* Products */; projectDirPath = ""; @@ -244,21 +181,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7967ACAE2956200300C30D93 /* AppView.swift in Sources */, - 7967ACD02956225100C30D93 /* AuthView.swift in Sources */, - 79B2F75A297937150091B3D9 /* Persistence.swift in Sources */, - 79B2F758297936F50091B3D9 /* AppDatabase.swift in Sources */, - 7967ACD42956255200C30D93 /* SupaButtonStyle.swift in Sources */, 791497DD2957058000525E6A /* Store.swift in Sources */, - 7967ACD2295624CB00C30D93 /* SupaTextFieldStyle.swift in Sources */, 79D6A0CA2959D58D00186187 /* ComposeMessageView.swift in Sources */, 79D6A0CC2959D5CA00186187 /* MessageRowView.swift in Sources */, - 7967ACD6295638FD00C30D93 /* Supabase.swift in Sources */, 791497DB295700B900525E6A /* APIClient.swift in Sources */, 791497D72956FF5F00525E6A /* ChannelListView.swift in Sources */, - 7967ACC92956212C00C30D93 /* AuthClient.swift in Sources */, 7967ACAC2956200300C30D93 /* SupaSlackApp.swift in Sources */, - 791497D52956FCA200525E6A /* ToastState.swift in Sources */, 79DB4CCB2959AB4200AE167A /* MessagesListView.swift in Sources */, 791497D92956FF9600525E6A /* Models.swift in Sources */, ); @@ -463,7 +391,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 7967ACA32956200300C30D93 /* Build configuration list for PBXProject "SupaSlack" */ = { + 7967ACA32956200300C30D93 /* Build configuration list for PBXProject "App" */ = { isa = XCConfigurationList; buildConfigurations = ( 7967ACB52956200400C30D93 /* Debug */, @@ -483,146 +411,22 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/binaryscraping/bs-apple-kit"; - requirement = { - branch = main; - kind = branch; - }; - }; - 791497D12956FC5900525E6A /* XCRemoteSwiftPackageReference "swiftui-toast" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/binaryscraping/swiftui-toast"; - requirement = { - branch = main; - kind = branch; - }; - }; - 793700DA29563D7A00D13E50 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.47.2; - }; - }; - 7967ACBA2956206700C30D93 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/supabase-community/supabase-swift"; - requirement = { - branch = "release-candidate"; - kind = branch; - }; - }; - 7967ACBD2956208100C30D93 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-identified-collections.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.5.0; - }; - }; - 7967ACC0295620CD00C30D93 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/xctest-dynamic-overlay.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.7.0; - }; - }; - 7967ACC3295620E100C30D93 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.4.5; - }; - }; - 7967ACCA2956216100C30D93 /* XCRemoteSwiftPackageReference "swift-tagged" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-tagged.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.0; - }; - }; - 79B2F753297936E20091B3D9 /* XCRemoteSwiftPackageReference "GRDB" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/groue/GRDB.swift"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 6.6.1; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - 791497C92956FC0200525E6A /* ConcurrencyHelpers */ = { - isa = XCSwiftPackageProductDependency; - package = 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */; - productName = ConcurrencyHelpers; - }; - 791497CB2956FC0200525E6A /* DebuggingHelpers */ = { - isa = XCSwiftPackageProductDependency; - package = 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */; - productName = DebuggingHelpers; - }; - 791497CD2956FC0200525E6A /* Prelude */ = { - isa = XCSwiftPackageProductDependency; - package = 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */; - productName = Prelude; - }; - 791497CF2956FC0200525E6A /* SwiftUIHelpers */ = { - isa = XCSwiftPackageProductDependency; - package = 791497C82956FC0200525E6A /* XCRemoteSwiftPackageReference "bs-apple-kit" */; - productName = SwiftUIHelpers; - }; - 791497D22956FC5900525E6A /* ToastUI */ = { - isa = XCSwiftPackageProductDependency; - package = 791497D12956FC5900525E6A /* XCRemoteSwiftPackageReference "swiftui-toast" */; - productName = ToastUI; - }; - 793700DB29563D7A00D13E50 /* Dependencies */ = { - isa = XCSwiftPackageProductDependency; - package = 793700DA29563D7A00D13E50 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; - productName = Dependencies; - }; - 7967ACBB2956206700C30D93 /* Supabase */ = { - isa = XCSwiftPackageProductDependency; - package = 7967ACBA2956206700C30D93 /* XCRemoteSwiftPackageReference "supabase-swift" */; - productName = Supabase; - }; - 7967ACBE2956208100C30D93 /* IdentifiedCollections */ = { - isa = XCSwiftPackageProductDependency; - package = 7967ACBD2956208100C30D93 /* XCRemoteSwiftPackageReference "swift-identified-collections" */; - productName = IdentifiedCollections; - }; - 7967ACC1295620CD00C30D93 /* XCTestDynamicOverlay */ = { - isa = XCSwiftPackageProductDependency; - package = 7967ACC0295620CD00C30D93 /* XCRemoteSwiftPackageReference "xctest-dynamic-overlay" */; - productName = XCTestDynamicOverlay; - }; - 7967ACC4295620E100C30D93 /* SwiftUINavigation */ = { + 79915200297C158A00F2A6DD /* AppFeature */ = { isa = XCSwiftPackageProductDependency; - package = 7967ACC3295620E100C30D93 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; - productName = SwiftUINavigation; + productName = AppFeature; }; - 7967ACC6295620E100C30D93 /* _SwiftUINavigationState */ = { + 79915202297C160800F2A6DD /* APIClientLive */ = { isa = XCSwiftPackageProductDependency; - package = 7967ACC3295620E100C30D93 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; - productName = _SwiftUINavigationState; + productName = APIClientLive; }; - 7967ACCB2956216100C30D93 /* Tagged */ = { + 79915204297C160800F2A6DD /* AuthClientLive */ = { isa = XCSwiftPackageProductDependency; - package = 7967ACCA2956216100C30D93 /* XCRemoteSwiftPackageReference "swift-tagged" */; - productName = Tagged; + productName = AuthClientLive; }; - 79B2F754297936E20091B3D9 /* GRDB */ = { + 79915206297C160800F2A6DD /* DatabaseClientLive */ = { isa = XCSwiftPackageProductDependency; - package = 79B2F753297936E20091B3D9 /* XCRemoteSwiftPackageReference "GRDB" */; - productName = GRDB; + productName = DatabaseClientLive; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/App/SupaSlack.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from App/SupaSlack.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/App/SupaSlack.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from App/SupaSlack.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/App/SupaSlack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from App/SupaSlack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/App/SupaSlack.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme similarity index 92% rename from App/SupaSlack.xcodeproj/xcshareddata/xcschemes/App.xcscheme rename to App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme index a81df43..dc04dd9 100644 --- a/App/SupaSlack.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -17,7 +17,7 @@ BlueprintIdentifier = "7967ACA72956200300C30D93" BuildableName = "App.app" BlueprintName = "App" - ReferencedContainer = "container:SupaSlack.xcodeproj"> + ReferencedContainer = "container:App.xcodeproj"> @@ -47,7 +47,7 @@ BlueprintIdentifier = "7967ACA72956200300C30D93" BuildableName = "App.app" BlueprintName = "App" - ReferencedContainer = "container:SupaSlack.xcodeproj"> + ReferencedContainer = "container:App.xcodeproj"> @@ -64,7 +64,7 @@ BlueprintIdentifier = "7967ACA72956200300C30D93" BuildableName = "App.app" BlueprintName = "App" - ReferencedContainer = "container:SupaSlack.xcodeproj"> + ReferencedContainer = "container:App.xcodeproj"> diff --git a/App/Package.swift b/App/Package.swift new file mode 100644 index 0000000..dcbb098 --- /dev/null +++ b/App/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 5.7 + +import PackageDescription + +let package = Package( + name: "App", + products: [], + dependencies: [], + targets: [] +) diff --git a/App/Sources/APIClient.swift b/App/Sources/APIClient.swift index 1158264..dd9f78b 100644 --- a/App/Sources/APIClient.swift +++ b/App/Sources/APIClient.swift @@ -1,78 +1,84 @@ -import Dependencies -import Foundation -@preconcurrency import Supabase -import XCTestDynamicOverlay +// +// APIClient.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT +// -struct APIClient { - var fetchChannels: @Sendable () async throws -> [Channel] - var fetchUser: @Sendable (User.ID) async throws -> User - var fetchMessages: @Sendable (Channel.ID) async throws -> [MessageResponse] - var addMessage: @Sendable (String, Channel.ID, User.ID) async throws -> MessageResponse -} - -struct AddMessagePayload: Encodable { - let message: String - let channelId: Channel.ID - let userId: User.ID - - enum CodingKeys: String, CodingKey { - case message - case channelId = "channel_id" - case userId = "user_id" - } -} - -extension APIClient: DependencyKey { - static var liveValue: Self { - @Dependency(\.supabase) var supabase - - return Self( - fetchChannels: { - try await supabase.database.from("channels").select().execute().value - }, - fetchUser: { id in - try await supabase.database - .from("users") - .select() - .eq(column: "id", value: id.rawValue) - .single() - .execute() - .value - }, - fetchMessages: { channelId in - try await supabase.database - .from("messages") - .select(columns: "*, author:user_id(*),channel:channel_id(*)") - .eq(column: "channel_id", value: channelId.rawValue) - .order(column: "inserted_at", ascending: false) - .execute() - .value - }, - addMessage: { message, channelId, userId in - try await supabase.database - .from("messages") - .insert( - values: AddMessagePayload(message: message, channelId: channelId, userId: userId), - returning: .representation - ) - .single() - .execute() - .value - } - ) - } - - static let testValue = Self( - fetchChannels: XCTUnimplemented("APIClient.fetchChannels"), - fetchUser: XCTUnimplemented("APIClient.fetchUser"), - fetchMessages: XCTUnimplemented("APIClient.fetchMessages"), - addMessage: XCTUnimplemented("APIClient.addMessage") - ) -} - -extension DependencyValues { - var api: APIClient { - get { self[APIClient.self] } - set { self[APIClient.self] = newValue } - } -} +// import Dependencies +// import Foundation +// @preconcurrency import Supabase +// import XCTestDynamicOverlay +// +// struct APIClient { +// var fetchChannels: @Sendable () async throws -> [Channel] +// var fetchUser: @Sendable (User.ID) async throws -> User +// var fetchMessages: @Sendable (Channel.ID) async throws -> [MessageResponse] +// var addMessage: @Sendable (String, Channel.ID, User.ID) async throws -> MessageResponse +// } +// +// struct AddMessagePayload: Encodable { +// let message: String +// let channelId: Channel.ID +// let userId: User.ID +// +// enum CodingKeys: String, CodingKey { +// case message +// case channelId = "channel_id" +// case userId = "user_id" +// } +// } +// +// extension APIClient: DependencyKey { +// static var liveValue: Self { +// @Dependency(\.supabase) var supabase +// +// return Self( +// fetchChannels: { +// try await supabase.database.from("channels").select().execute().value +// }, +// fetchUser: { id in +// try await supabase.database +// .from("users") +// .select() +// .eq(column: "id", value: id.rawValue) +// .single() +// .execute() +// .value +// }, +// fetchMessages: { channelId in +// try await supabase.database +// .from("messages") +// .select(columns: "*, author:user_id(*),channel:channel_id(*)") +// .eq(column: "channel_id", value: channelId.rawValue) +// .order(column: "inserted_at", ascending: false) +// .execute() +// .value +// }, +// addMessage: { message, channelId, userId in +// try await supabase.database +// .from("messages") +// .insert( +// values: AddMessagePayload(message: message, channelId: channelId, userId: userId), +// returning: .representation +// ) +// .single() +// .execute() +// .value +// } +// ) +// } +// +// static let testValue = Self( +// fetchChannels: XCTUnimplemented("APIClient.fetchChannels"), +// fetchUser: XCTUnimplemented("APIClient.fetchUser"), +// fetchMessages: XCTUnimplemented("APIClient.fetchMessages"), +// addMessage: XCTUnimplemented("APIClient.addMessage") +// ) +// } +// +// extension DependencyValues { +// var api: APIClient { +// get { self[APIClient.self] } +// set { self[APIClient.self] = newValue } +// } +// } diff --git a/App/Sources/AuthClient.swift b/App/Sources/AuthClient.swift deleted file mode 100644 index 4480805..0000000 --- a/App/Sources/AuthClient.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// AuthClient.swift -// SupaSlack -// -// Created by Guilherme Souza on 23/12/22. -// - -import ConcurrencyHelpers -import Dependencies -import Foundation -@preconcurrency import GoTrue -@preconcurrency import Supabase -import Tagged -import XCTestDynamicOverlay - -enum EmailAddressTag {} -typealias EmailAddress = Tagged - -enum PasswordTag {} -typealias Password = Tagged - -enum AuthEvent { - case signedIn, signedOut -} - -enum SignUpResult { - case signedIn - case requiresConfirmation -} - -struct AuthClient { - var initialize: @Sendable () async -> Void - var authEvent: @Sendable () -> AsyncStream - var session: @Sendable () async throws -> Session - var signUp: @Sendable (EmailAddress, Password) async throws -> SignUpResult - var signIn: @Sendable (EmailAddress, Password) async throws -> Session -} - -extension AuthClient: DependencyKey { - static var liveValue: Self { - @Dependency(\.supabase) var supabase - return Self( - initialize: { - await supabase.auth.initialize() - }, - authEvent: { - AsyncStream( - supabase.auth.authEventChange.map { event in - switch event { - case .signedIn: - return .signedIn - default: - return .signedOut - } - } - ) - }, - session: { - try await supabase.auth.session - }, - signUp: { email, password in - let result = try await supabase.auth.signUp( - email: email.rawValue, - password: password.rawValue - ) - - switch result { - case let .session(session): - if session.user.confirmedAt == nil { - return .requiresConfirmation - } - return .signedIn - case .user: - return .requiresConfirmation - } - }, - signIn: { email, password in - try await supabase.auth.signIn( - email: email.rawValue, - password: password.rawValue - ) - } - ) - } - - static let testValue = Self( - initialize: XCTUnimplemented("AuthClient.initialize"), - authEvent: XCTUnimplemented("AuthClient.authEvent"), - session: XCTUnimplemented("AuthClient.session"), - signUp: XCTUnimplemented("AuthClient.signUp"), - signIn: XCTUnimplemented("AuthClient.signIn") - ) -} - -extension DependencyValues { - var auth: AuthClient { - get { self[AuthClient.self] } - set { self[AuthClient.self] = newValue } - } -} diff --git a/App/Sources/ChannelListView.swift b/App/Sources/ChannelListView.swift index bc06b32..46a31a7 100644 --- a/App/Sources/ChannelListView.swift +++ b/App/Sources/ChannelListView.swift @@ -1,54 +1,60 @@ -import Combine -import Dependencies -import IdentifiedCollections -import Supabase -import SwiftUI -import ToastUI - -@MainActor -final class ChannelListViewModel: ObservableObject { - @Dependency(\.store) private var store - - var channels: IdentifiedArrayOf { - store.channels - } - - private var cancellable: AnyCancellable? - init() { - cancellable = store.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } - } - - func loadChannels() async { - await store.fetchChannels() - } - - func makeMessagesListViewModel(for channel: Channel) -> MessagesListViewModel { - MessagesListViewModel(channel: channel) - } -} - -struct ChannelListView: View { - @ObservedObject var model: ChannelListViewModel - - var body: some View { - List { - ForEach(model.channels) { channel in - NavigationLink(channel.slug, value: model.makeMessagesListViewModel(for: channel)) - } - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Channels") - .animation(.default, value: model.channels) - .task { await model.loadChannels() } - .refreshable { await model.loadChannels() } - .navigationDestination(for: MessagesListViewModel.self) { model in - MessagesListView(model: model) - } - } -} - -struct ChannelListView_Previews: PreviewProvider { - static var previews: some View { - ChannelListView(model: ChannelListViewModel()) - } -} +// +// ChannelListView.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT +// + +// import Combine +// import Dependencies +// import IdentifiedCollections +// import Supabase +// import SwiftUI +// import ToastUI +// +// @MainActor +// final class ChannelListViewModel: ObservableObject { +// @Dependency(\.store) private var store +// +// var channels: IdentifiedArrayOf { +// store.channels +// } +// +// private var cancellable: AnyCancellable? +// init() { +// cancellable = store.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } +// } +// +// func loadChannels() async { +// await store.fetchChannels() +// } +// +// func makeMessagesListViewModel(for channel: Channel) -> MessagesListViewModel { +// MessagesListViewModel(channel: channel) +// } +// } +// +// struct ChannelListView: View { +// @ObservedObject var model: ChannelListViewModel +// +// var body: some View { +// List { +// ForEach(model.channels) { channel in +// NavigationLink(channel.slug, value: model.makeMessagesListViewModel(for: channel)) +// } +// } +// .navigationBarTitleDisplayMode(.inline) +// .navigationTitle("Channels") +// .animation(.default, value: model.channels) +// .task { await model.loadChannels() } +// .refreshable { await model.loadChannels() } +// .navigationDestination(for: MessagesListViewModel.self) { model in +// MessagesListView(model: model) +// } +// } +// } +// +// struct ChannelListView_Previews: PreviewProvider { +// static var previews: some View { +// ChannelListView(model: ChannelListViewModel()) +// } +// } diff --git a/App/Sources/ComposeMessageView.swift b/App/Sources/ComposeMessageView.swift index 97f4bf1..f8e9818 100644 --- a/App/Sources/ComposeMessageView.swift +++ b/App/Sources/ComposeMessageView.swift @@ -1,8 +1,7 @@ // // ComposeMessageView.swift -// SupaSlack -// -// Created by Guilherme Souza on 26/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import SwiftUI diff --git a/App/Sources/Database/AppDatabase.swift b/App/Sources/Database/AppDatabase.swift deleted file mode 100644 index 8765197..0000000 --- a/App/Sources/Database/AppDatabase.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// AppDatabase.swift -// SupaSlack -// -// Created by Guilherme Souza on 19/01/23. -// - -import Foundation -import GRDB - -struct AppDatabase { - init(_ dbWriter: any DatabaseWriter) throws { - self.dbWriter = dbWriter - try migrator.migrate(dbWriter) - } - - private let dbWriter: any DatabaseWriter - - private var migrator: DatabaseMigrator { - var migrator = DatabaseMigrator() - - #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true - #endif - - migrator.registerMigration("createChannel") { db in - try db.create(table: "channel") { t in - t.column("id", .integer).primaryKey() - t.column("insertedAt", .datetime).notNull() - t.column("slug", .text).notNull() - t.column("createdBy", .text).notNull().references("user", column: "id") - } - } - - migrator.registerMigration("createUser") { db in - try db.create(table: "user") { t in - t.column("id", .text).primaryKey() - t.column("username", .text).notNull() - t.column("status", .text).notNull() - } - } - - migrator.registerMigration("createMessage") { db in - try db.create(table: "message") { t in - t.column("id", .text).primaryKey() - t.column("remoteID", .integer).unique() - t.column("insertedAt", .date).notNull() - t.column("message", .text).notNull().check { length($0) > 0 } - t.column("channelId", .integer).notNull().references("channel", column: "id") - t.column("authorId", .text).notNull().references("user", column: "id") - t.column("status", .integer).notNull() - } - } - - return migrator - } -} - -extension Message: FetchableRecord, PersistableRecord {} - -extension AppDatabase { - func save(_ message: MessageResponse) throws -> Message { - try dbWriter.write { db in - if var storedMessage = try Message.filter(Column("remoteID") == message.id).fetchOne(db) { - storedMessage.apply(message) - return try storedMessage.saved(db) - } - - let newMessage = Message( - id: UUID(), - remoteID: message.id, - insertedAt: message.insertedAt, - message: message.message, - channel: message.channel, - author: message.author, - status: .remote - ) - - return try newMessage.inserted(db) - } - } -} diff --git a/App/Sources/Database/Persistence.swift b/App/Sources/Database/Persistence.swift deleted file mode 100644 index 5496771..0000000 --- a/App/Sources/Database/Persistence.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// Persistence.swift -// SupaSlack -// -// Created by Guilherme Souza on 19/01/23. -// - -import Foundation -import GRDB - -extension AppDatabase { - /// The database for the application - static let shared = makeShared() - - private static func makeShared() -> AppDatabase { - do { - // Pick a folder for storing the SQLite database, as well as - // the various temporary files created during normal database - // operations (https://sqlite.org/tempfiles.html). - let fileManager = FileManager() - let folderURL = try fileManager - .url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("database", isDirectory: true) - - // Support for tests: delete the database if requested - if CommandLine.arguments.contains("-reset") { - try? fileManager.removeItem(at: folderURL) - } - - // Create the database folder if needed - try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true) - - // Connect to a database on disk - // See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections - let dbURL = folderURL.appendingPathComponent("db.sqlite") - let dbPool = try DatabasePool(path: dbURL.path) - - // Create the AppDatabase - let appDatabase = try AppDatabase(dbPool) - - // Prepare the database with test fixtures if requested - if CommandLine.arguments.contains("-fixedTestData") { -// try appDatabase.createPlayersForUITests() - } else { - // Otherwise, populate the database if it is empty, for better - // demo purpose. -// try appDatabase.createRandomPlayersIfEmpty() - } - - return appDatabase - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. - // - // Typical reasons for an error here include: - // * The parent directory cannot be created, or disallows writing. - // * The database is not accessible, due to permissions or data protection when the device is - // locked. - // * The device is out of space. - // * The database could not be migrated to its latest schema version. - // Check the error message to determine what the actual problem was. - fatalError("Unresolved error \(error)") - } - } - - /// Creates an empty database for SwiftUI previews - static func empty() -> AppDatabase { - // Connect to an in-memory database - // See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections - let dbQueue = try! DatabaseQueue() - return try! AppDatabase(dbQueue) - } - - /// Creates a database full of random players for SwiftUI previews - static func random() -> AppDatabase { - let appDatabase = empty() -// try! appDatabase.createRandomPlayersIfEmpty() - return appDatabase - } -} diff --git a/App/Sources/MessageRowView.swift b/App/Sources/MessageRowView.swift index d75ef9a..4aa100d 100644 --- a/App/Sources/MessageRowView.swift +++ b/App/Sources/MessageRowView.swift @@ -1,8 +1,7 @@ // // MessageRowView.swift -// SupaSlack -// -// Created by Guilherme Souza on 26/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import SwiftUI diff --git a/App/Sources/MessagesListView.swift b/App/Sources/MessagesListView.swift index 79a5446..c6afe8f 100644 --- a/App/Sources/MessagesListView.swift +++ b/App/Sources/MessagesListView.swift @@ -1,108 +1,114 @@ // // MessagesListView.swift -// SupaSlack +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // -// Created by Guilherme Souza on 26/12/22. -// - -import Combine -import Dependencies -import SwiftUI - -@MainActor -final class MessagesListViewModel: ObservableObject, Equatable, Hashable { - @Dependency(\.store) private var store - - let channel: Channel - - var messages: [Message] { - store.messages[channel.id, default: []] - .sorted(by: { $0.insertedAt > $1.insertedAt }) - } - - @Published var newMessage: String = "" - @Published var scrollToMessageId: Message.ID? - - private var cancellable: AnyCancellable? - init(channel: Channel) { - self.channel = channel - - cancellable = store.objectWillChange - .sink { [weak self] _ in self?.objectWillChange.send() } - } - - func fetchMessages() async { - await store.fetchMessages(channel.id) - } - - func submitNewMessageButtonTapped() { - Task { - do { - let message = try await store.submitNewMessage(newMessage, channel.id) - self.newMessage = "" - self.scrollToMessageId = message.id - } catch { - dump(error) - } - } - } - - nonisolated static func == (lhs: MessagesListViewModel, rhs: MessagesListViewModel) -> Bool { - lhs === rhs - } - - nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} - -struct MessagesListView: View { - @ObservedObject var model: MessagesListViewModel - - var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(model.messages) { message in - MessageRowView(message: message) - .padding() - .flippedUpsideDown() - .id(message.id) - } - } - .animation(.default, value: model.messages) - } - .flippedUpsideDown() - .frame(maxWidth: .infinity) - .clipped() - .safeAreaInset(edge: .bottom) { - ComposeMessageView(message: $model.newMessage) { - model.submitNewMessageButtonTapped() - } - } - .onChange(of: model.scrollToMessageId) { messageId in - if let messageId { - model.scrollToMessageId = nil - withAnimation { - proxy.scrollTo(messageId, anchor: .bottom) - } - } - } - } - .task { await model.fetchMessages() } - .navigationTitle(model.channel.slug) - } -} - -extension View { - func flippedUpsideDown() -> some View { - rotationEffect(.radians(Double.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } -} -struct MessagesListView_Previews: PreviewProvider { - static var previews: some View { - MessagesListView(model: MessagesListViewModel(channel: .mock)) - } -} +//// +//// MessagesListView.swift +//// SupaSlack +//// +//// Created by Guilherme Souza on 26/12/22. +//// +// +// import Combine +// import Dependencies +// import SwiftUI +// +// @MainActor +// final class MessagesListViewModel: ObservableObject, Equatable, Hashable { +// @Dependency(\.store) private var store +// +// let channel: Channel +// +// var messages: [Message] { +// store.messages[channel.id, default: []] +// .sorted(by: { $0.insertedAt > $1.insertedAt }) +// } +// +// @Published var newMessage: String = "" +// @Published var scrollToMessageId: Message.ID? +// +// private var cancellable: AnyCancellable? +// init(channel: Channel) { +// self.channel = channel +// +// cancellable = store.objectWillChange +// .sink { [weak self] _ in self?.objectWillChange.send() } +// } +// +// func fetchMessages() async { +// await store.fetchMessages(channel.id) +// } +// +// func submitNewMessageButtonTapped() { +// Task { +// do { +// let message = try await store.submitNewMessage(newMessage, channel.id) +// self.newMessage = "" +// self.scrollToMessageId = message.id +// } catch { +// dump(error) +// } +// } +// } +// +// nonisolated static func == (lhs: MessagesListViewModel, rhs: MessagesListViewModel) -> Bool { +// lhs === rhs +// } +// +// nonisolated func hash(into hasher: inout Hasher) { +// hasher.combine(ObjectIdentifier(self)) +// } +// } +// +// struct MessagesListView: View { +// @ObservedObject var model: MessagesListViewModel +// +// var body: some View { +// ScrollViewReader { proxy in +// ScrollView { +// LazyVStack(spacing: 0) { +// ForEach(model.messages) { message in +// MessageRowView(message: message) +// .padding() +// .flippedUpsideDown() +// .id(message.id) +// } +// } +// .animation(.default, value: model.messages) +// } +// .flippedUpsideDown() +// .frame(maxWidth: .infinity) +// .clipped() +// .safeAreaInset(edge: .bottom) { +// ComposeMessageView(message: $model.newMessage) { +// model.submitNewMessageButtonTapped() +// } +// } +// .onChange(of: model.scrollToMessageId) { messageId in +// if let messageId { +// model.scrollToMessageId = nil +// withAnimation { +// proxy.scrollTo(messageId, anchor: .bottom) +// } +// } +// } +// } +// .task { await model.fetchMessages() } +// .navigationTitle(model.channel.slug) +// } +// } +// +// extension View { +// func flippedUpsideDown() -> some View { +// rotationEffect(.radians(Double.pi)) +// .scaleEffect(x: -1, y: 1, anchor: .center) +// } +// } +// +// struct MessagesListView_Previews: PreviewProvider { +// static var previews: some View { +// MessagesListView(model: MessagesListViewModel(channel: .mock)) +// } +// } diff --git a/App/Sources/Models.swift b/App/Sources/Models.swift index 106b934..3eb8a7d 100644 --- a/App/Sources/Models.swift +++ b/App/Sources/Models.swift @@ -1,8 +1,7 @@ // // Models.swift -// SupaSlack -// -// Created by Guilherme Souza on 24/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import Foundation diff --git a/App/Sources/Store.swift b/App/Sources/Store.swift index 2705551..9f0f685 100644 --- a/App/Sources/Store.swift +++ b/App/Sources/Store.swift @@ -1,130 +1,129 @@ // // Store.swift -// SupaSlack +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // -// Created by Guilherme Souza on 24/12/22. -// - -import Dependencies -import Foundation -import IdentifiedCollections - -extension Store: DependencyKey { - static let liveValue = Store() -} - -extension DependencyValues { - var store: Store { - get { self[Store.self] } - set { self[Store.self] = newValue } - } -} - -@MainActor -final class Store: ObservableObject { - @Dependency(\.api) private var api - @Dependency(\.auth) private var auth - - @Published private(set) var users: IdentifiedArrayOf = [] - @Published private(set) var channels: IdentifiedArrayOf = [] - @Published private(set) var messages: [Channel.ID: IdentifiedArrayOf] = [:] - - private var remoteIdToLocalIdMap: [Int: UUID] = [:] - - private func makeMessage(from response: MessageResponse) -> Message { - if remoteIdToLocalIdMap[response.id] == nil { - remoteIdToLocalIdMap[response.id] = UUID() - } - - let message = Message( - id: remoteIdToLocalIdMap[response.id]!, - remoteID: response.id, - insertedAt: response.insertedAt, - message: response.message, - channel: response.channel, - author: response.author, - status: .remote - ) - return message - } - - func fetchChannels() async { - do { - channels = try await IdentifiedArrayOf(uniqueElements: api.fetchChannels()) - } catch { - dump(error) - } - } - - func fetchMessages(_ channelId: Channel.ID) async { - do { - messages[channelId] = IdentifiedArrayOf( - uniqueElements: try await api - .fetchMessages(channelId).map { makeMessage(from: $0) } - ) - } catch { - dump(error) - } - } - - func submitNewMessage(_ message: String, _ channelId: Channel.ID) async throws -> Message { - let session = try await auth.session() - let userID = User.ID(session.user.id) - - // Create a local message object without a remote id. - var localMessage = try await Message( - id: UUID(), - remoteID: nil, - insertedAt: Date(), - message: message, - channel: channel(for: channelId), - author: author(for: userID), - status: .local - ) - - // Insert local message to update UI. - messages[channelId, default: []].append(localMessage) - - Task { - do { - // Submit message to api. - let response = try await api.addMessage(message, channelId, User.ID(session.user.id)) - localMessage.apply(response) - remoteIdToLocalIdMap[response.id] = localMessage.id - - } catch { - localMessage.status = .failure - } - - messages[channelId]?.updateOrAppend(localMessage) - } - - return localMessage - } - - private func handleMessageDeleted(_ message: Message) { - messages[message.channel.id]?.remove(id: message.id) - } - - private func channel(for id: Channel.ID) throws -> Channel { - guard let channel = channels[id: id] else { - throw ChannelNotFoundError(id: id) - } - - return channel - } - - private func author(for id: User.ID) async throws -> User { - if let user = users[id: id] { - return user - } - - let user = try await api.fetchUser(id) - users.updateOrAppend(user) - return user - } -} -struct ChannelNotFoundError: Error { - let id: Channel.ID -} +// import Dependencies +// import Foundation +// import IdentifiedCollections +// +// extension Store: DependencyKey { +// static let liveValue = Store() +// } +// +// extension DependencyValues { +// var store: Store { +// get { self[Store.self] } +// set { self[Store.self] = newValue } +// } +// } +// +// @MainActor +// final class Store: ObservableObject { +// @Dependency(\.api) private var api +// @Dependency(\.auth) private var auth +// +// @Published private(set) var users: IdentifiedArrayOf = [] +// @Published private(set) var channels: IdentifiedArrayOf = [] +// @Published private(set) var messages: [Channel.ID: IdentifiedArrayOf] = [:] +// +// private var remoteIdToLocalIdMap: [Int: UUID] = [:] +// +// private func makeMessage(from response: MessageResponse) -> Message { +// if remoteIdToLocalIdMap[response.id] == nil { +// remoteIdToLocalIdMap[response.id] = UUID() +// } +// +// let message = Message( +// id: remoteIdToLocalIdMap[response.id]!, +// remoteID: response.id, +// insertedAt: response.insertedAt, +// message: response.message, +// channel: response.channel, +// author: response.author, +// status: .remote +// ) +// return message +// } +// +// func fetchChannels() async { +// do { +// channels = try await IdentifiedArrayOf(uniqueElements: api.fetchChannels()) +// } catch { +// dump(error) +// } +// } +// +// func fetchMessages(_ channelId: Channel.ID) async { +// do { +// messages[channelId] = IdentifiedArrayOf( +// uniqueElements: try await api +// .fetchMessages(channelId).map { makeMessage(from: $0) } +// ) +// } catch { +// dump(error) +// } +// } +// +// func submitNewMessage(_ message: String, _ channelId: Channel.ID) async throws -> Message { +// let session = try await auth.session() +// let userID = User.ID(session.user.id) +// +// // Create a local message object without a remote id. +// var localMessage = try await Message( +// id: UUID(), +// remoteID: nil, +// insertedAt: Date(), +// message: message, +// channel: channel(for: channelId), +// author: author(for: userID), +// status: .local +// ) +// +// // Insert local message to update UI. +// messages[channelId, default: []].append(localMessage) +// +// Task { +// do { +// // Submit message to api. +// let response = try await api.addMessage(message, channelId, User.ID(session.user.id)) +// localMessage.apply(response) +// remoteIdToLocalIdMap[response.id] = localMessage.id +// +// } catch { +// localMessage.status = .failure +// } +// +// messages[channelId]?.updateOrAppend(localMessage) +// } +// +// return localMessage +// } +// +// private func handleMessageDeleted(_ message: Message) { +// messages[message.channel.id]?.remove(id: message.id) +// } +// +// private func channel(for id: Channel.ID) throws -> Channel { +// guard let channel = channels[id: id] else { +// throw ChannelNotFoundError(id: id) +// } +// +// return channel +// } +// +// private func author(for id: User.ID) async throws -> User { +// if let user = users[id: id] { +// return user +// } +// +// let user = try await api.fetchUser(id) +// users.updateOrAppend(user) +// return user +// } +// } +// +// struct ChannelNotFoundError: Error { +// let id: Channel.ID +// } diff --git a/App/Sources/SupaSlackApp.swift b/App/Sources/SupaSlackApp.swift index b7a87be..e8f3ba3 100644 --- a/App/Sources/SupaSlackApp.swift +++ b/App/Sources/SupaSlackApp.swift @@ -1,10 +1,13 @@ // // SupaSlackApp.swift -// SupaSlack -// -// Created by Guilherme Souza on 23/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // +import APIClientLive +import AppFeature +import AuthClientLive +import DatabaseClientLive import SwiftUI @main diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b3f5b4e --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY: format +format: + @swiftformat . diff --git a/Package.swift b/Package.swift index 542aeff..b6bc5f5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,25 +4,118 @@ import PackageDescription let package = Package( - name: "SupaSlack", - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "SupaSlack", - targets: ["SupaSlack"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "SupaSlack", - dependencies: []), - .testTarget( - name: "SupaSlackTests", - dependencies: ["SupaSlack"]), - ] + name: "SupaSlack", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .library(name: "APIClientLive", targets: ["APIClientLive"]), + .library(name: "AppFeature", targets: ["AppFeature"]), + .library(name: "AuthClientLive", targets: ["AuthClientLive"]), + .library(name: "AuthFeature", targets: ["AuthFeature"]), + .library(name: "ChannelsFeature", targets: ["ChannelsFeature"]), + .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]), + ], + dependencies: [ + .package(url: "https://github.com/binaryscraping/swiftui-toast", branch: "main"), + .package(url: "https://github.com/binaryscraping/bs-apple-kit", branch: "main"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.9.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "6.6.1"), + .package( + url: "https://github.com/supabase-community/supabase-swift", + branch: "release-candidate" + ), + ], + targets: [ + .target( + name: "APIClient", + dependencies: [ + "Models", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "APIClientLive", + dependencies: [ + "APIClient", + "SupabaseDependency", + ] + ), + .target( + name: "AppFeature", + dependencies: [ + "AuthFeature", + "ChannelsFeature", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "AuthClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Tagged", package: "swift-tagged"), + ] + ), + .target( + name: "AuthClientLive", + dependencies: [ + "AuthClient", + "SupabaseDependency", + ] + ), + .target( + name: "AuthFeature", + dependencies: [ + "AuthClient", + "Helpers", + .product(name: "SwiftUIHelpers", package: "bs-apple-kit"), + .product(name: "ToastUI", package: "swiftui-toast"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "ChannelsFeature", + dependencies: [ + "APIClient", + "DatabaseClient", + ] + ), + .target( + name: "DatabaseClient", + dependencies: [ + "Models", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "DatabaseClientLive", + dependencies: [ + "DatabaseClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), + .testTarget( + name: "DatabaseClientLiveTests", + dependencies: ["DatabaseClientLive"] + ), + .target( + name: "Helpers", + dependencies: [ + .product(name: "ToastUI", package: "swiftui-toast"), + ] + ), + .target( + name: "Models", + dependencies: [ + .product(name: "Tagged", package: "swift-tagged"), + ] + ), + .target( + name: "SupabaseDependency", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Supabase", package: "supabase-swift"), + ] + ), + ] ) diff --git a/Sources/APIClient/APIClient.swift b/Sources/APIClient/APIClient.swift new file mode 100644 index 0000000..3699d6d --- /dev/null +++ b/Sources/APIClient/APIClient.swift @@ -0,0 +1,50 @@ +// +// APIClient.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import Dependencies +import Foundation +import Models + +public struct ChannelResponse: Decodable { + public let id: Int + public let insertedAt: Date + public let slug: String + public let createdBy: UUID + + enum CodingKeys: String, CodingKey { + case id + case insertedAt = "inserted_at" + case slug + case createdBy = "created_by" + } +} + +public struct APIClient: Sendable { + public var fetchChannels: @Sendable () async throws -> [ChannelResponse] + public var fetchUsers: @Sendable ([User.ID]) async throws -> [User] + + public init( + fetchChannels: @escaping @Sendable () async throws -> [ChannelResponse], + fetchUsers: @escaping @Sendable ([User.ID]) async throws -> [User] + ) { + self.fetchChannels = fetchChannels + self.fetchUsers = fetchUsers + } +} + +extension APIClient: TestDependencyKey { + public static let testValue = APIClient( + fetchChannels: unimplemented("APIClient.fetchChannels"), + fetchUsers: unimplemented("APIClient.fetchUsers") + ) +} + +extension DependencyValues { + public var api: APIClient { + get { self[APIClient.self] } + set { self[APIClient.self] = newValue } + } +} diff --git a/Sources/APIClientLive/APIClientLive.swift b/Sources/APIClientLive/APIClientLive.swift new file mode 100644 index 0000000..1c99629 --- /dev/null +++ b/Sources/APIClientLive/APIClientLive.swift @@ -0,0 +1,30 @@ +// +// APIClientLive.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import APIClient +import Dependencies +import Foundation +import Supabase + +extension APIClient: DependencyKey { + public static let liveValue: APIClient = { + @Dependency(\.supabase) var supabase + + return APIClient( + fetchChannels: { + try await supabase.database.from("channels").select().execute().value + }, + fetchUsers: { ids in + try await supabase.database + .from("users") + .select() + .in(column: "id", value: ids.map(\.rawValue)) + .execute() + .value + } + ) + }() +} diff --git a/App/Sources/Supabase.swift b/Sources/APIClientLive/Supabase.swift similarity index 91% rename from App/Sources/Supabase.swift rename to Sources/APIClientLive/Supabase.swift index 3d24d59..e703a95 100644 --- a/App/Sources/Supabase.swift +++ b/Sources/APIClientLive/Supabase.swift @@ -1,8 +1,7 @@ // // Supabase.swift -// SupaSlack -// -// Created by Guilherme Souza on 23/12/22. +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT // import Dependencies diff --git a/App/Sources/AppView.swift b/Sources/AppFeature/AppView.swift similarity index 78% rename from App/Sources/AppView.swift rename to Sources/AppFeature/AppView.swift index 445b73c..061f58d 100644 --- a/App/Sources/AppView.swift +++ b/Sources/AppFeature/AppView.swift @@ -1,10 +1,17 @@ +// +// AppView.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT +// + +import AuthClient +import AuthFeature +import ChannelsFeature import Dependencies -import GoTrue -import Supabase import SwiftUI @MainActor -final class AppViewModel: ObservableObject { +public final class AppViewModel: ObservableObject { @Dependency(\.auth) private var auth @Published private(set) var authInitialized = false @@ -14,7 +21,7 @@ final class AppViewModel: ObservableObject { let authViewModel = AuthViewModel() let channelListViewModel = ChannelListViewModel() - init() { + public init() { authEventTask = Task { for await _ in auth.authEvent() { let session = try? await auth.session() @@ -36,11 +43,15 @@ final class AppViewModel: ObservableObject { } } -struct AppView: View { +public struct AppView: View { @ObservedObject var viewModel: AppViewModel + public init(viewModel: AppViewModel) { + self.viewModel = viewModel + } + @ViewBuilder - var body: some View { + public var body: some View { if viewModel.authInitialized { if viewModel.session == nil { AuthView(viewModel: viewModel.authViewModel) diff --git a/Sources/AuthClient/AuthClient.swift b/Sources/AuthClient/AuthClient.swift new file mode 100644 index 0000000..3b9367c --- /dev/null +++ b/Sources/AuthClient/AuthClient.swift @@ -0,0 +1,68 @@ +// +// AuthClient.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT +// + +import Dependencies +import Foundation +import Tagged +import XCTestDynamicOverlay + +public enum EmailAddressTag {} +public typealias EmailAddress = Tagged + +public enum PasswordTag {} +public typealias Password = Tagged + +public enum AuthEvent { + case signedIn, signedOut +} + +public enum AuthResult { + case signedIn + case requiresConfirmation +} + +public struct Session { + public init() {} +} + +public struct AuthClient { + public var initialize: @Sendable () async -> Void + public var authEvent: @Sendable () -> AsyncStream + public var session: @Sendable () async throws -> Session + public var signUp: @Sendable (EmailAddress, Password) async throws -> AuthResult + public var signIn: @Sendable (EmailAddress, Password) async throws -> AuthResult + + public init( + initialize: @escaping @Sendable () async -> Void, + authEvent: @escaping @Sendable () -> AsyncStream, + session: @escaping @Sendable () async throws -> Session, + signUp: @escaping @Sendable (EmailAddress, Password) async throws -> AuthResult, + signIn: @escaping @Sendable (EmailAddress, Password) async throws -> AuthResult + ) { + self.initialize = initialize + self.authEvent = authEvent + self.session = session + self.signUp = signUp + self.signIn = signIn + } +} + +extension AuthClient: TestDependencyKey { + public static let testValue = Self( + initialize: XCTUnimplemented("AuthClient.initialize"), + authEvent: XCTUnimplemented("AuthClient.authEvent"), + session: XCTUnimplemented("AuthClient.session"), + signUp: XCTUnimplemented("AuthClient.signUp"), + signIn: XCTUnimplemented("AuthClient.signIn") + ) +} + +extension DependencyValues { + public var auth: AuthClient { + get { self[AuthClient.self] } + set { self[AuthClient.self] = newValue } + } +} diff --git a/Sources/AuthClientLive/AuthClientLive.swift b/Sources/AuthClientLive/AuthClientLive.swift new file mode 100644 index 0000000..e49b36b --- /dev/null +++ b/Sources/AuthClientLive/AuthClientLive.swift @@ -0,0 +1,64 @@ +// +// AuthClientLive.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import AuthClient +import Dependencies +@preconcurrency import GoTrue +@preconcurrency import Supabase +import SupabaseDependency + +extension AuthClient: DependencyKey { + public static var liveValue: Self { + @Dependency(\.supabase) var supabase + return Self( + initialize: { + await supabase.auth.initialize() + }, + authEvent: { + AsyncStream( + supabase.auth.authEventChange.map { event in + switch event { + case .signedIn: + return .signedIn + default: + return .signedOut + } + } + ) + }, + session: { + _ = try await supabase.auth.session + return .init() + }, + signUp: { email, password in + let result = try await supabase.auth.signUp( + email: email.rawValue, + password: password.rawValue + ) + + switch result { + case let .session(session): + if session.user.confirmedAt == nil { + return .requiresConfirmation + } + return .signedIn + case .user: + return .requiresConfirmation + } + }, + signIn: { email, password in + let session = try await supabase.auth.signIn( + email: email.rawValue, + password: password.rawValue + ) + if session.user.confirmedAt == nil { + return .requiresConfirmation + } + return .signedIn + } + ) + } +} diff --git a/App/Sources/AuthView.swift b/Sources/AuthFeature/AuthView.swift similarity index 82% rename from App/Sources/AuthView.swift rename to Sources/AuthFeature/AuthView.swift index e9cd811..d0e60de 100644 --- a/App/Sources/AuthView.swift +++ b/Sources/AuthFeature/AuthView.swift @@ -1,11 +1,19 @@ +// +// AuthView.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT +// + +import AuthClient import Dependencies +import Helpers import SwiftUI import SwiftUIHelpers import ToastUI @MainActor -final class AuthViewModel: ObservableObject { - enum Mode { +public final class AuthViewModel: ObservableObject { + public enum Mode { case signIn, signUp } @@ -16,7 +24,7 @@ final class AuthViewModel: ObservableObject { @Published var password: Password @Published var toast: ToastState? - init( + public init( mode: Mode = .signIn, email: EmailAddress = EmailAddress(""), password: Password = Password("") @@ -48,26 +56,27 @@ final class AuthViewModel: ObservableObject { private func signUp() async throws { let result = try await auth.signUp(email, password) - switch result { - case .requiresConfirmation: + if result == .requiresConfirmation { toast = ToastState(style: .info, title: "A confirmation email was sent to \(email.rawValue).") - case .signedIn: - break } } private func signIn() async throws { - let session = try await auth.signIn(email, password) - if session.user.confirmedAt == nil { + let result = try await auth.signIn(email, password) + if result == .requiresConfirmation { toast = ToastState(style: .info, title: "A confirmation email was sent to \(email.rawValue).") } } } -struct AuthView: View { +public struct AuthView: View { @ObservedObject var viewModel: AuthViewModel - var body: some View { + public init(viewModel: AuthViewModel) { + self.viewModel = viewModel + } + + public var body: some View { VStack(spacing: 12) { TextField("Email", text: $viewModel.email.rawValue) .keyboardType(.emailAddress) diff --git a/Sources/ChannelsFeature/ChannelListView.swift b/Sources/ChannelsFeature/ChannelListView.swift new file mode 100644 index 0000000..15b057b --- /dev/null +++ b/Sources/ChannelsFeature/ChannelListView.swift @@ -0,0 +1,150 @@ +// +// ChannelListView.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import APIClient +import DatabaseClient +import Dependencies +import Models +import SwiftUI + +@MainActor +public final class ChannelListViewModel: ObservableObject { + @Published private(set) var channels: [Channel] = [] + @Published private(set) var error: Error? { + didSet { + dump(error) + } + } + + @Dependency(\.db) var db + @Dependency(\.api) var api + + public init() { + Task { + do { + for try await channels in db.observeChannels() { + self.channels = channels + } + } catch { + self.error = error + } + } + } + + func load() async { + do { + let channels = try await api.fetchChannels().map { + SaveChannelPayload( + id: $0.id, + insertedAt: $0.insertedAt, + slug: $0.slug, + createdBy: $0.createdBy + ) + } + try await fetchAndSaveUsers(ids: channels.map(\.createdBy)) + try db.saveChannels(channels) + } catch { + self.error = error + } + } + + func reload() { + Task { await load() } + } + + private func fetchAndSaveUsers(ids: [UUID]) async throws { + let users = try await api.fetchUsers(ids.map { User.ID($0) }) + try db.saveUsers(users) + } +} + +struct ErrorRow: View { + let error: Error + let retry: (() -> Void)? + + init(_ error: Error, retry: (() -> Void)? = nil) { + self.error = error + self.retry = retry + } + + var body: some View { + HStack(spacing: 12) { + Text(error.localizedDescription) + .frame(maxWidth: .infinity, alignment: .leading) + + if let retry { + Button("Retry") { + retry() + } + .buttonStyle(.bordered) + } + } + .font(.footnote) + .foregroundColor(Color.white) + .listRowBackground(Color.error) + } +} + +extension Color { + static let error = Color.red.opacity(0.8) +} + +public struct ChannelListView: View { + @ObservedObject var model: ChannelListViewModel + + public init(model: ChannelListViewModel) { + self.model = model + } + + public var body: some View { + List { + if let error = model.error { + Section { + ErrorRow(error, retry: { model.reload() }) + } + } + + Section("Channels") { + ForEach(model.channels) { channel in + HStack { + Text(channel.slug).frame(maxWidth: .infinity, alignment: .leading) + if channel.unreadMessagesCount > 0 { + Text("\(channel.unreadMessagesCount)") + } + } + .bold(channel.unreadMessagesCount > 0) + } + } + } + .animation(.default, value: model.channels) + .task { await model.load() } + .refreshable { await model.load() } + .navigationTitle("Channels") + } +} + +struct ChannelListView_Previews: PreviewProvider { + static var previews: some View { + ChannelListView( + model: withDependencies { + $0.db.observeChannels = { + let (stream, continuation) = AsyncThrowingStream<[Channel], Error> + .streamWithContinuation() + continuation.yield([.withUnreadMessages, .withoutUnreadMessages]) + return stream + } + + $0.api.fetchChannels = { + struct Error: Swift.Error {} + throw Error() + } + + } operation: { + ChannelListViewModel() + } + ) + } +} diff --git a/Sources/DatabaseClient/DatabaseClient.swift b/Sources/DatabaseClient/DatabaseClient.swift new file mode 100644 index 0000000..66e5c7b --- /dev/null +++ b/Sources/DatabaseClient/DatabaseClient.swift @@ -0,0 +1,60 @@ +// +// DatabaseClient.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import Dependencies +import Foundation +import Models + +public struct SaveChannelPayload { + public let id: Int + public let insertedAt: Date + public let slug: String + public let createdBy: UUID + + public init(id: Int, insertedAt: Date, slug: String, createdBy: UUID) { + self.id = id + self.insertedAt = insertedAt + self.slug = slug + self.createdBy = createdBy + } +} + +public struct DatabaseClient { + public var saveUsers: ([User]) throws -> Void + public var observeChannels: () -> AsyncThrowingStream<[Channel], Error> + public var saveChannels: ([SaveChannelPayload]) throws -> Void + public var insertMessage: (Message) throws -> Void + + public init( + saveUsers: @escaping ([User]) throws -> Void, + observeChannels: @escaping () -> AsyncThrowingStream<[Channel], Error>, + saveChannels: @escaping ([SaveChannelPayload]) throws -> Void, + insertMessage: @escaping (Message) throws -> Void + ) { + self.saveUsers = saveUsers + self.observeChannels = observeChannels + self.saveChannels = saveChannels + self.insertMessage = insertMessage + } +} + +extension DatabaseClient: TestDependencyKey { + public static var testValue: DatabaseClient { + DatabaseClient( + saveUsers: XCTUnimplemented("DatabaseClient.saveUsers"), + observeChannels: XCTUnimplemented("DatabaseClient.fetchChannels"), + saveChannels: XCTUnimplemented("DatabaseClient.saveChannels"), + insertMessage: XCTUnimplemented("DatabaseClient.insertMessage") + ) + } +} + +extension DependencyValues { + public var db: DatabaseClient { + get { self[DatabaseClient.self] } + set { self[DatabaseClient.self] = newValue } + } +} diff --git a/Sources/DatabaseClientLive/DatabaseClientLive.swift b/Sources/DatabaseClientLive/DatabaseClientLive.swift new file mode 100644 index 0000000..0207ff0 --- /dev/null +++ b/Sources/DatabaseClientLive/DatabaseClientLive.swift @@ -0,0 +1,271 @@ +// +// DatabaseClientLive.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import DatabaseClient +import Dependencies +import Foundation +import GRDB +import Models + +extension Channel: FetchableRecord {} + +struct UserTable: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "user" + + let id: UUID + let username: String + + enum Columns { + static let id = Column(CodingKeys.id) + } +} + +struct ChannelTable: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "channel" + + let id: Int + let insertedAt: Date + let slug: String + let createdBy: UUID + + enum Columns { + static let id = Column(CodingKeys.id) + } + + static let messages = hasMany(MessageTable.self) + var messages: QueryInterfaceRequest { + request(for: Self.messages) + } +} + +struct MessageTable: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "message" + + let id: UUID + let remoteID: Int? + let insertedAt: Date + let message: String + let channelID: Int + let authorID: UUID + let status: Status + let readAt: Date? + + enum Status: Int, Codable { + case local, remote, failure + } + + enum Columns { + static let insertedAt = Column(CodingKeys.insertedAt) + static let readAt = Column(CodingKeys.readAt) + } + + static let channel = belongsTo(ChannelTable.self) + var channel: QueryInterfaceRequest { + request(for: Self.channel) + } +} + +extension DatabaseClient: DependencyKey { + public static let liveValue = DatabaseClient(.shared) + + public static var memory: DatabaseClient { + DatabaseClient(.memory) + } +} + +struct DatabaseClientLive { + static let shared: DatabaseClientLive = { + do { + // Pick a folder for storing the SQLite database, as well as + // the various temporary files created during normal database + // operations (https://sqlite.org/tempfiles.html). + let folderURL = URL.applicationSupportDirectory + .appendingPathComponent("database", isDirectory: true) + + // Support for tests: delete the database if requested + if CommandLine.arguments.contains("-reset") { + try? FileManager.default.removeItem(at: folderURL) + } + + // Create the database folder if needed + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + // Connect to a database on disk + // See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections + let dbURL = folderURL.appendingPathComponent("db.sqlite") + let dbPool = try DatabasePool(path: dbURL.path) + + // Create the AppDatabase + let appDatabase = try DatabaseClientLive(dbPool) + + // Prepare the database with test fixtures if requested + if CommandLine.arguments.contains("-fixedTestData") { +// try appDatabase.createPlayersForUITests() + } else { + // Otherwise, populate the database if it is empty, for better + // demo purpose. +// try appDatabase.createRandomPlayersIfEmpty() + } + + return appDatabase + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. + // + // Typical reasons for an error here include: + // * The parent directory cannot be created, or disallows writing. + // * The database is not accessible, due to permissions or data protection when the device is + // locked. + // * The device is out of space. + // * The database could not be migrated to its latest schema version. + // Check the error message to determine what the actual problem was. + fatalError("Unresolved error \(error)") + } + }() + + static var memory: DatabaseClientLive { + var configuration = Configuration() + configuration.prepareDatabase { db in + db.trace { + print($0.expandedDescription) + } + } + return try! DatabaseClientLive(DatabaseQueue(configuration: configuration)) + } + + let dbWriter: any DatabaseWriter + + private init(_ dbWriter: any DatabaseWriter) throws { + self.dbWriter = dbWriter + try migrator.migrate(dbWriter) + } + + private var migrator: DatabaseMigrator { + var migrator = DatabaseMigrator() + + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + + migrator.registerMigration("createChannel") { db in + try db.create(table: "channel") { t in + t.column("id", .integer).primaryKey() + t.column("insertedAt", .datetime).notNull() + t.column("slug", .text).notNull() + t.column("createdBy", .text).notNull().references("user", column: "id") + } + } + + migrator.registerMigration("createUser") { db in + try db.create(table: "user") { t in + t.column("id", .text).primaryKey() + t.column("username", .text).notNull() +// t.column("status", .text).notNull() + } + } + + migrator.registerMigration("createMessage") { db in + try db.create(table: "message") { t in + t.column("id", .text).primaryKey() + t.column("remoteID", .integer).unique() + t.column("insertedAt", .date).notNull() + t.column("message", .text).notNull().check { length($0) > 0 } + t.column("channelId", .integer).notNull().references("channel", column: "id") + t.column("authorId", .text).notNull().references("user", column: "id") + t.column("status", .integer).notNull() + t.column("readAt", .date) + } + } + + return migrator + } +} + +extension DatabaseClient { + init(_ client: DatabaseClientLive) { + self.init( + saveUsers: { users in + try client.dbWriter.write { db in + try UserTable + .filter(!users.map(\.id.rawValue).contains(UserTable.Columns.id)) + .deleteAll(db) + + try users + .map { UserTable(id: $0.id.rawValue, username: $0.username) } + .forEach { try $0.save(db) } + } + }, + observeChannels: { + AsyncThrowingStream( + ValueObservation.tracking { db in + // TODO: improve following code by using a single Query. + let allChannels = try ChannelTable.fetchAll(db) + return try allChannels.map { channel in + let lastMessageAt = try channel.messages.select([MessageTable.Columns.insertedAt]) + .order(MessageTable.Columns.insertedAt.desc).asRequest(of: Date.self).fetchOne(db) + let unreadMessagesCount = try channel.messages + .filter(MessageTable.Columns.readAt == nil).fetchCount(db) + return Channel( + id: .init(channel.id), + slug: channel.slug, + lastMessageAt: lastMessageAt, + unreadMessagesCount: unreadMessagesCount + ) + } + } + .values(in: client.dbWriter) + ) + }, + saveChannels: { channels in + try client.dbWriter.write { db in + try ChannelTable + .filter(!channels.map(\.id).contains(ChannelTable.Columns.id)) + .deleteAll(db) + + try channels.map { + ChannelTable( + id: $0.id, + insertedAt: $0.insertedAt, + slug: $0.slug, + createdBy: $0.createdBy + ) + } + .forEach { channel in + try channel.save(db) + } + } + }, + insertMessage: { message in + try client.dbWriter.write { db in + try MessageTable( + id: message.id.rawValue, + remoteID: message.remoteID?.rawValue, + insertedAt: message.insertedAt, + message: message.message, + channelID: message.channelID.rawValue, + authorID: message.authorID.rawValue, + status: .init(from: message.status), + readAt: nil + ) + .save(db) + } + } + ) + } +} + +extension MessageTable.Status { + init(from status: Message.Status) { + switch status { + case .local: + self = .local + case .remote: + self = .remote + case .failure: + self = .failure + } + } +} diff --git a/App/Sources/SupaButtonStyle.swift b/Sources/Helpers/SupaButtonStyle.swift similarity index 75% rename from App/Sources/SupaButtonStyle.swift rename to Sources/Helpers/SupaButtonStyle.swift index b01ecb6..9bbb047 100644 --- a/App/Sources/SupaButtonStyle.swift +++ b/Sources/Helpers/SupaButtonStyle.swift @@ -1,14 +1,13 @@ // // SupaButtonStyle.swift -// SupaSlack -// -// Created by Guilherme Souza on 23/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import SwiftUI -struct SupaButtonStyle: ButtonStyle { - func makeBody(configuration: Self.Configuration) -> some View { +public struct SupaButtonStyle: ButtonStyle { + public func makeBody(configuration: Self.Configuration) -> some View { configuration.label .frame(maxWidth: .infinity) .padding() @@ -23,7 +22,7 @@ struct SupaButtonStyle: ButtonStyle { } extension ButtonStyle where Self == SupaButtonStyle { - static var supa: SupaButtonStyle { + public static var supa: SupaButtonStyle { SupaButtonStyle() } } diff --git a/App/Sources/SupaTextFieldStyle.swift b/Sources/Helpers/SupaTextFieldStyle.swift similarity index 73% rename from App/Sources/SupaTextFieldStyle.swift rename to Sources/Helpers/SupaTextFieldStyle.swift index 5a6688a..1e23947 100644 --- a/App/Sources/SupaTextFieldStyle.swift +++ b/Sources/Helpers/SupaTextFieldStyle.swift @@ -1,14 +1,13 @@ // -// SwiftUIView.swift -// SupaSlack -// -// Created by Guilherme Souza on 23/12/22. +// SupaTextFieldStyle.swift +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import SwiftUI -struct SupaTextFieldStyle: TextFieldStyle { - func _body(configuration: TextField) -> some View { +public struct SupaTextFieldStyle: TextFieldStyle { + public func _body(configuration: TextField) -> some View { configuration .padding() .background(.regularMaterial) @@ -23,7 +22,7 @@ struct SupaTextFieldStyle: TextFieldStyle { } extension TextFieldStyle where Self == SupaTextFieldStyle { - static var supa: SupaTextFieldStyle { + public static var supa: SupaTextFieldStyle { SupaTextFieldStyle() } } diff --git a/App/Sources/ToastState.swift b/Sources/Helpers/ToastState.swift similarity index 68% rename from App/Sources/ToastState.swift rename to Sources/Helpers/ToastState.swift index 03982a9..b44027c 100644 --- a/App/Sources/ToastState.swift +++ b/Sources/Helpers/ToastState.swift @@ -1,15 +1,14 @@ // // ToastState.swift -// SupaSlack -// -// Created by Guilherme Souza on 24/12/22. +// (c) 2022 Binary Scraping Co. +// LICENSE: MIT // import Foundation import ToastUI extension ToastState { - init(_ error: Error) { + public init(_ error: Error) { self.init(style: .failure, icon: nil, title: error.localizedDescription, subtitle: nil) } } diff --git a/Sources/Models/Models.swift b/Sources/Models/Models.swift new file mode 100644 index 0000000..6fab9c6 --- /dev/null +++ b/Sources/Models/Models.swift @@ -0,0 +1,82 @@ +// +// Models.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import Foundation +import Tagged + +public struct User: Identifiable, Codable, Hashable { + public let id: Tagged + public let username: String + + public init(id: Tagged, username: String) { + self.id = id + self.username = username + } +} + +public struct Channel: Identifiable, Codable, Hashable { + public let id: Tagged + public let slug: String + public let lastMessageAt: Date? + public let unreadMessagesCount: Int + + public init(id: Tagged, slug: String, lastMessageAt: Date?, unreadMessagesCount: Int) { + self.id = id + self.slug = slug + self.lastMessageAt = lastMessageAt + self.unreadMessagesCount = unreadMessagesCount + } + + public static let withUnreadMessages = Channel( + id: 1, + slug: "public", + lastMessageAt: Date(), + unreadMessagesCount: 3 + ) + + public static let withoutUnreadMessages = Channel( + id: 2, + slug: "random", + lastMessageAt: nil, + unreadMessagesCount: 0 + ) +} + +public struct Message: Identifiable, Codable, Hashable { + public typealias RemoteID = Tagged + + public let id: Tagged + public let remoteID: RemoteID? + public let insertedAt: Date + public let message: String + public let channelID: Channel.ID + public let authorID: User.ID + public let status: Status + + public enum Status: Int, Codable { + case local + case remote + case failure + } + + public init( + id: Tagged, + remoteID: RemoteID?, + insertedAt: Date, + message: String, + channelID: Channel.ID, + authorID: User.ID, + status: Status + ) { + self.id = id + self.remoteID = remoteID + self.insertedAt = insertedAt + self.message = message + self.channelID = channelID + self.authorID = authorID + self.status = status + } +} diff --git a/Sources/SupaSlack/SupaSlack.swift b/Sources/SupaSlack/SupaSlack.swift deleted file mode 100644 index b67311c..0000000 --- a/Sources/SupaSlack/SupaSlack.swift +++ /dev/null @@ -1,6 +0,0 @@ -public struct SupaSlack { - public private(set) var text = "Hello, World!" - - public init() { - } -} diff --git a/Sources/SupabaseDependency/SupabaseClient.swift b/Sources/SupabaseDependency/SupabaseClient.swift new file mode 100644 index 0000000..58a1902 --- /dev/null +++ b/Sources/SupabaseDependency/SupabaseClient.swift @@ -0,0 +1,23 @@ +// +// SupabaseClient.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import Dependencies +import Foundation +@preconcurrency import Supabase + +extension SupabaseClient: DependencyKey { + public static var liveValue = SupabaseClient( + supabaseURL: URL(string: "https://fxotbpyitfbhzzfzgalj.supabase.co")!, + supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ4b3RicHlpdGZiaHp6ZnpnYWxqIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NzE4MTY5MDAsImV4cCI6MTk4NzM5MjkwMH0.W9ws8MlcxKlVk6pyRAkny8Vf-TGmLGqQQzeNiwza-ik" + ) +} + +extension DependencyValues { + public var supabase: SupabaseClient { + get { self[SupabaseClient.self] } + set { self[SupabaseClient.self] = newValue } + } +} diff --git a/SupaSlack.xcworkspace/contents.xcworkspacedata b/SupaSlack.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..b3324c4 --- /dev/null +++ b/SupaSlack.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/SupaSlack.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SupaSlack.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/SupaSlack.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/SupaSlack.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SupaSlack.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..7639e25 --- /dev/null +++ b/SupaSlack.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,203 @@ +{ + "pins" : [ + { + "identity" : "bs-apple-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/binaryscraping/bs-apple-kit", + "state" : { + "branch" : "main", + "revision" : "aa70f68a9899e4ee0155aa290d6f3d95db53ed5c" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version" : "0.9.1" + } + }, + { + "identity" : "concurrencyplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ConcurrencyPlus", + "state" : { + "revision" : "b5ba8d5ea6bfe9e43ccc44aa63f9b458057fa0f4", + "version" : "0.4.1" + } + }, + { + "identity" : "functions-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/functions-swift", + "state" : { + "revision" : "7d5dfce3673bb30bbb7e1fbb5e0afe01bbd92624", + "version" : "0.2.0" + } + }, + { + "identity" : "get", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Get", + "state" : { + "revision" : "7209fdb015686fd90918b2037cb2039206405bd3", + "version" : "2.1.5" + } + }, + { + "identity" : "getextensions", + "kind" : "remoteSourceControl", + "location" : "https://github.com/binaryscraping/GetExtensions", + "state" : { + "revision" : "aa20f38721142eb6592b2c8f11179d32d7d70ae3", + "version" : "1.0.0" + } + }, + { + "identity" : "gotrue-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/gotrue-swift", + "state" : { + "branch" : "main", + "revision" : "7989dcbc5ee55451c4a4d43d0dce2c333389d3b4" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "ba68e3b02d9ed953a0c9ff43183f856f20c9b7ce", + "version" : "6.6.1" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "postgrest-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/postgrest-swift", + "state" : { + "revision" : "a3c2d6a2ede94d2529edf063b056b1a66fd9cdc2", + "version" : "1.0.0" + } + }, + { + "identity" : "realtime-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/realtime-swift.git", + "state" : { + "revision" : "0b985c687fe963f6bd818ff77a35c27247b98bb4", + "version" : "0.0.2" + } + }, + { + "identity" : "storage-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/storage-swift.git", + "state" : { + "branch" : "main", + "revision" : "04703e499ca258899d7ad45717efe75b8ddc09ab" + } + }, + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/supabase-swift", + "state" : { + "branch" : "release-candidate", + "revision" : "9f3b3d32ccbd4775b7072f38d44f3ed9bce30f9c" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "bb436421f57269fbcfe7360735985321585a86e5", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", + "version" : "0.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "7a094fcc6a4fcb34fbe625ecd3acd81b881a6dfb", + "version" : "0.1.3" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "af06825aaa6adffd636c10a2570b2010c7c07e6a", + "version" : "0.9.0" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation.git", + "state" : { + "revision" : "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", + "version" : "0.4.5" + } + }, + { + "identity" : "swiftui-toast", + "kind" : "remoteSourceControl", + "location" : "https://github.com/binaryscraping/swiftui-toast", + "state" : { + "branch" : "main", + "revision" : "1a2a394125cb2a7692009b0d208fc16720332018" + } + }, + { + "identity" : "urlqueryencoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/URLQueryEncoder", + "state" : { + "revision" : "4ce950479707ea109f229d7230ec074a133b15d7", + "version" : "0.2.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "16b23a295fa322eb957af98037f86791449de60f", + "version" : "0.8.1" + } + } + ], + "version" : 2 +} diff --git a/Tests/DatabaseClientLiveTests/DatabaseClientLiveTests.swift b/Tests/DatabaseClientLiveTests/DatabaseClientLiveTests.swift new file mode 100644 index 0000000..b871bc2 --- /dev/null +++ b/Tests/DatabaseClientLiveTests/DatabaseClientLiveTests.swift @@ -0,0 +1,67 @@ +// +// DatabaseClientLiveTests.swift +// (c) 2023 Binary Scraping Co. +// LICENSE: MIT +// + +import DatabaseClient +@testable import DatabaseClientLive +import Models +import XCTest + +final class DatabaseClientLiveTests: XCTestCase { + let sut = DatabaseClient.memory + let now = Date() + + func testIntegration() async throws { + var channelsIterator = sut.observeChannels().makeAsyncIterator() + + let user = User(id: .init(), username: "guilherme") + try sut.saveUsers([user]) + + let `public` = SaveChannelPayload( + id: 1, + insertedAt: now, + slug: "Public", + createdBy: user.id.rawValue + ) + let random = SaveChannelPayload( + id: 2, + insertedAt: now, + slug: "Random", + createdBy: user.id.rawValue + ) + let swiftUI = SaveChannelPayload( + id: 3, + insertedAt: now, + slug: "SwiftUI", + createdBy: user.id.rawValue + ) + + var channels = try await channelsIterator.next() + XCTAssertEqual(channels, []) + + try sut.saveChannels([`public`, random, swiftUI]) + channels = try await channelsIterator.next() + XCTAssertEqual(channels?.map(\.slug), ["Public", "Random", "SwiftUI"]) + XCTAssertNil(channels?[0].lastMessageAt) + + let message = Message( + id: .init(), + remoteID: nil, + insertedAt: now, + message: "hello world", + channelID: .init(`public`.id), + authorID: user.id, + status: .local + ) + try sut.insertMessage(message) + channels = try await channelsIterator.next() + XCTAssertEqual(channels?[0].lastMessageAt?.formatted(), message.insertedAt.formatted()) + XCTAssertEqual(channels?[0].unreadMessagesCount, 1) + + try sut.saveChannels([`public`, random]) + channels = try await channelsIterator.next() + XCTAssertEqual(channels?.map(\.slug), ["Public", "Random"]) + } +} diff --git a/Tests/SupaSlackTests/SupaSlackTests.swift b/Tests/SupaSlackTests/SupaSlackTests.swift deleted file mode 100644 index d7cfe5f..0000000 --- a/Tests/SupaSlackTests/SupaSlackTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import SupaSlack - -final class SupaSlackTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(SupaSlack().text, "Hello, World!") - } -}