├── .editorconfig ├── .gitignore ├── .spi.yml ├── .swift-format ├── Examples ├── CaseStudies │ ├── App.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── DynamicQuery.swift │ ├── Info.plist │ ├── Internal │ │ ├── CaseStudy.swift │ │ └── Text+Template.swift │ ├── ObservableModelDemo.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── README.md │ ├── SwiftUIQueryDemo.swift │ └── SwiftUISyncDemo.swift ├── Examples.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── CaseStudies.xcscheme ├── FirebaseEmulator │ ├── .firebaserc │ ├── .gitignore │ ├── README.md │ ├── firebase.json │ ├── firestore.indexes.json │ └── firestore.rules ├── Package.swift └── README.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── SharingFirestore.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── Sources └── SharingFirestore │ ├── DefaultFirestore.swift │ ├── DocumentIdentifiable.swift │ ├── Documentation.docc │ ├── Articles │ │ ├── DynamicQueries.md │ │ ├── Fetching.md │ │ ├── Observing.md │ │ ├── PreparingDatabase.md │ │ └── Syncing.md │ └── SharingFirestore.md │ ├── Internal │ ├── Exports.swift │ └── Preconcurrency.swift │ ├── QueryKey.swift │ ├── SharingFirestoreQuery.swift │ ├── SharingFirestoreSync.swift │ ├── SharingQueryPredicates.swift │ ├── SyncKey.swift │ └── UniqueRequestKeyID.swift ├── Tests └── SharingFirestoreTests │ └── SharingFirestoreTests.swift └── format.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | trim_trailing_whitespace = true 7 | 8 | [Makefile] 9 | indent_size = 4 10 | indent_style = tab 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/ 3 | /.build 4 | /Packages 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm 8 | .netrc 9 | .index-build/ 10 | .build/ 11 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: 5 | - SharingFirestore 6 | swift_version: 6.0 7 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentConditionalCompilationBlocks" : true, 6 | "indentSwitchCaseLabels" : false, 7 | "indentation" : { 8 | "spaces" : 2 9 | }, 10 | "lineBreakAroundMultilineExpressionChainComponents" : false, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : false, 13 | "lineBreakBeforeEachGenericRequirement" : false, 14 | "lineLength" : 100, 15 | "maximumBlankLines" : 1, 16 | "multiElementCollectionTrailingCommas" : true, 17 | "noAssignmentInExpressions" : { 18 | "allowedFunctions" : [ 19 | "XCTAssertNoThrow" 20 | ] 21 | }, 22 | "prioritizeKeepingFunctionOutputTogether" : false, 23 | "respectsExistingLineBreaks" : true, 24 | "rules" : { 25 | "AllPublicDeclarationsHaveDocumentation" : false, 26 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 27 | "AlwaysUseLowerCamelCase" : true, 28 | "AmbiguousTrailingClosureOverload" : true, 29 | "BeginDocumentationCommentWithOneLineSummary" : false, 30 | "DoNotUseSemicolons" : true, 31 | "DontRepeatTypeInStaticProperties" : true, 32 | "FileScopedDeclarationPrivacy" : true, 33 | "FullyIndirectEnum" : true, 34 | "GroupNumericLiterals" : true, 35 | "IdentifiersMustBeASCII" : true, 36 | "NeverForceUnwrap" : false, 37 | "NeverUseForceTry" : false, 38 | "NeverUseImplicitlyUnwrappedOptionals" : false, 39 | "NoAccessLevelOnExtensionDeclaration" : true, 40 | "NoAssignmentInExpressions" : true, 41 | "NoBlockComments" : true, 42 | "NoCasesWithOnlyFallthrough" : true, 43 | "NoEmptyTrailingClosureParentheses" : true, 44 | "NoLabelsInCasePatterns" : true, 45 | "NoLeadingUnderscores" : false, 46 | "NoParensAroundConditions" : true, 47 | "NoPlaygroundLiterals" : true, 48 | "NoVoidReturnOnFunctionSignature" : true, 49 | "OmitExplicitReturns" : false, 50 | "OneCasePerLine" : true, 51 | "OneVariableDeclarationPerLine" : true, 52 | "OnlyOneTrailingClosureArgument" : true, 53 | "OrderedImports" : true, 54 | "ReplaceForEachWithForLoop" : true, 55 | "ReturnVoidInsteadOfEmptyTuple" : true, 56 | "TypeNamesShouldBeCapitalized" : true, 57 | "UseEarlyExits" : false, 58 | "UseExplicitNilCheckInConditions" : true, 59 | "UseLetInEveryBoundCaseVariable" : true, 60 | "UseShorthandTypeNames" : true, 61 | "UseSingleLinePropertyGetter" : true, 62 | "UseSynthesizedInitializer" : true, 63 | "UseTripleSlashForDocumentationComments" : true, 64 | "UseWhereClausesInForLoops" : false, 65 | "ValidateDocumentationComments" : false 66 | }, 67 | "spacesAroundRangeFormationOperators" : false, 68 | "tabWidth" : 8, 69 | "version" : 1 70 | } 71 | -------------------------------------------------------------------------------- /Examples/CaseStudies/App.swift: -------------------------------------------------------------------------------- 1 | import SharingFirestore 2 | import SwiftUI 3 | import Dependencies 4 | 5 | func prepareFirestore(_ values: inout DependencyValues) { 6 | let options = FirebaseOptions(googleAppID: "1:123:ios:123abc", gcmSenderID: "123") 7 | options.projectID = "demo-project" 8 | FirebaseApp.configure(options: options) 9 | let settings = Firestore.firestore().settings 10 | settings.cacheSettings = MemoryCacheSettings() 11 | settings.host = "127.0.0.1:8080" 12 | settings.isSSLEnabled = false 13 | Firestore.firestore().settings = settings 14 | values.defaultFirestore = Firestore.firestore() 15 | } 16 | 17 | @main 18 | struct CaseStudiesApp: App { 19 | 20 | init() { 21 | prepareDependencies(prepareFirestore(_:)) 22 | } 23 | 24 | var body: some Scene { 25 | WindowGroup { 26 | NavigationStack { 27 | Form { 28 | NavigationLink("Query Demo") { 29 | CaseStudyView { 30 | SwiftUIQueryDemo() 31 | } 32 | } 33 | NavigationLink("Sync Demo") { 34 | CaseStudyView { 35 | SwiftUISyncDemo() 36 | } 37 | } 38 | NavigationLink("Observable Demo") { 39 | CaseStudyView { 40 | ObservableModelDemo() 41 | } 42 | } 43 | NavigationLink("Dynamic Query Demo") { 44 | CaseStudyView { 45 | DynamicQueryDemo() 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/CaseStudies/DynamicQuery.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | @preconcurrency import SharingFirestore 3 | import SwiftUI 4 | 5 | private let collectionPath = "dynamic-facts" 6 | 7 | struct DynamicQueryDemo: SwiftUICaseStudy { 8 | let readMe = """ 9 | This demo shows how to perform a dynamic query using the tools provided by the library. Every 3 seconds, \ 10 | a fact about a number is loaded from the network and saved to Firestore. You can search the \ 11 | facts for specific text, and the list will stay in sync so that if a new fact is added to the database \ 12 | that satisfies your search criteria, it will immediately appear. 13 | 14 | To achieve this, you can call the `load` method defined on the `@SharedReader` projected \ 15 | value to set a new query with dynamic parameters. You can also swipe to delete items. 16 | """ 17 | let caseStudyTitle = "Dynamic Query" 18 | 19 | @State.SharedReader(value: []) private var facts: [Fact] 20 | @State var query = "" 21 | @State var totalCount = 0 22 | @State var searchCount = 0 23 | 24 | @Dependency(\.defaultFirestore) var database 25 | 26 | var body: some View { 27 | List { 28 | Section { 29 | if query.isEmpty { 30 | Text("Facts: \(totalCount)") 31 | .contentTransition(.numericText(value: Double(totalCount))) 32 | .font(.largeTitle) 33 | .bold() 34 | } else { 35 | Text("Search: \(facts.count)") 36 | .contentTransition(.numericText(value: Double(facts.count))) 37 | Text("Facts: \(totalCount)") 38 | .contentTransition(.numericText(value: Double(totalCount))) 39 | } 40 | } 41 | Section { 42 | ForEach(facts) { fact in 43 | Text(fact.body) 44 | } 45 | .onDelete { indexSet in 46 | Task { 47 | do { 48 | let factsToDelete = indexSet.map { facts[$0] } 49 | let batch = database.batch() 50 | for fact in factsToDelete { 51 | if let documentId = fact.id { 52 | let ref = database.collection(collectionPath).document(documentId) 53 | batch.deleteDocument(ref) 54 | } 55 | } 56 | try await batch.commit() 57 | } catch { 58 | print("Error deleting facts: \(error)") 59 | } 60 | } 61 | } 62 | } 63 | } 64 | .searchable(text: $query) 65 | .task(id: query) { 66 | await withErrorReporting { 67 | try await $facts.load(.query(FactsQuery(searchText: query))) 68 | } 69 | } 70 | .task { 71 | let listener: LockIsolated<(any ListenerRegistration)?> = .init(nil) 72 | do { 73 | try await withTaskCancellationHandler { 74 | // Monitor the total count of facts 75 | let reg = database 76 | .collection(collectionPath) 77 | .addSnapshotListener { snapshot, error in 78 | if let error = error { 79 | print("Error fetching total count: \(error)") 80 | return 81 | } 82 | self.totalCount = snapshot?.documents.count ?? 0 83 | } 84 | listener.setValue(reg) 85 | 86 | // Add facts every second 87 | var number = 0 88 | while true { 89 | try await Task.sleep(for: .seconds(3)) 90 | number += 1 91 | let fact = try await String( 92 | decoding: URLSession.shared 93 | .data(from: URL(string: "http://numberapi.com/\(number)")!).0, 94 | as: UTF8.self 95 | ) 96 | 97 | let newFact = Fact( 98 | body: fact, 99 | createdAt: Date() 100 | ) 101 | 102 | try database.collection(collectionPath).addDocument(from: newFact) 103 | } 104 | } onCancel: { 105 | listener.withValue { 106 | $0?.remove() 107 | $0 = nil 108 | } 109 | } 110 | } catch {} 111 | } 112 | } 113 | 114 | private struct FactsQuery: SharingFirestoreQuery.KeyRequest { 115 | var searchText: String 116 | 117 | var configuration: SharingFirestoreQuery.Configuration { 118 | .init( 119 | path: collectionPath, 120 | predicates: [.order(by: "createdAt", descending: true)], 121 | animation: .default 122 | ) 123 | } 124 | 125 | func query(_ db: Firestore) throws -> Query { 126 | let query = db.collection(configuration.path) 127 | 128 | // Apply base predicates 129 | var resultQuery = applingPredicated(query) 130 | 131 | // Apply search filter if needed 132 | if !searchText.isEmpty { 133 | // In a real app, you might use a specialized solution for text search 134 | // Here we're doing a simple filter for demonstration 135 | resultQuery = resultQuery.whereField("body", isGreaterThanOrEqualTo: searchText) 136 | .whereField("body", isLessThanOrEqualTo: searchText + "\u{f8ff}") 137 | } 138 | 139 | return resultQuery 140 | } 141 | } 142 | } 143 | 144 | private struct Fact: Codable, Sendable, Identifiable { 145 | @DocumentID var id: String? 146 | var body: String 147 | var createdAt: Date 148 | } 149 | 150 | #Preview { 151 | let _ = prepareDependencies(prepareFirestore(_:)) 152 | NavigationStack { 153 | CaseStudyView { 154 | DynamicQueryDemo() 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Internal/CaseStudy.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKitNavigation 3 | 4 | @MainActor 5 | protocol CaseStudy { 6 | var readMe: String { get } 7 | var caseStudyTitle: String { get } 8 | var caseStudyNavigationTitle: String { get } 9 | var isPresentedInSheet: Bool { get } 10 | } 11 | protocol SwiftUICaseStudy: CaseStudy, View {} 12 | protocol UIKitCaseStudy: CaseStudy, UIViewController {} 13 | 14 | extension CaseStudy { 15 | var caseStudyNavigationTitle: String { caseStudyTitle } 16 | var isPresentedInSheet: Bool { false } 17 | } 18 | 19 | @resultBuilder 20 | @MainActor 21 | enum CaseStudyViewBuilder { 22 | @ViewBuilder 23 | static func buildBlock() -> some View {} 24 | @ViewBuilder 25 | static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { 26 | SwiftUICaseStudyButton(caseStudy: caseStudy) 27 | } 28 | @ViewBuilder 29 | static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { 30 | UIKitCaseStudyButton(caseStudy: caseStudy) 31 | } 32 | static func buildPartialBlock(first: some View) -> some View { 33 | first 34 | } 35 | @ViewBuilder 36 | static func buildPartialBlock(accumulated: some View, next: some View) -> some View { 37 | accumulated 38 | next 39 | } 40 | } 41 | 42 | struct SwiftUICaseStudyButton: View { 43 | let caseStudy: C 44 | @State var isPresented = false 45 | var body: some View { 46 | if caseStudy.isPresentedInSheet { 47 | Button(caseStudy.caseStudyTitle) { 48 | isPresented = true 49 | } 50 | .sheet(isPresented: $isPresented) { 51 | CaseStudyView { 52 | caseStudy 53 | } 54 | } 55 | } else { 56 | NavigationLink(caseStudy.caseStudyTitle) { 57 | CaseStudyView { 58 | caseStudy 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | struct UIKitCaseStudyButton: View { 66 | let caseStudy: C 67 | @State var isPresented = false 68 | var body: some View { 69 | if caseStudy.isPresentedInSheet { 70 | Button(caseStudy.caseStudyTitle) { 71 | isPresented = true 72 | } 73 | .sheet(isPresented: $isPresented) { 74 | UIViewControllerRepresenting { 75 | ((caseStudy as? UINavigationController) 76 | ?? UINavigationController(rootViewController: caseStudy)) 77 | .setUp(caseStudy: caseStudy) 78 | } 79 | .modifier(CaseStudyModifier(caseStudy: caseStudy)) 80 | } 81 | } else { 82 | NavigationLink(caseStudy.caseStudyTitle) { 83 | UIViewControllerRepresenting { 84 | caseStudy 85 | } 86 | .modifier(CaseStudyModifier(caseStudy: caseStudy)) 87 | } 88 | } 89 | } 90 | } 91 | 92 | extension UINavigationController { 93 | convenience init(caseStudy: UIViewController & UIKitCaseStudy) { 94 | self.init(rootViewController: caseStudy) 95 | _ = setUp(caseStudy: caseStudy) 96 | } 97 | } 98 | 99 | extension UINavigationController { 100 | func setUp(caseStudy: some CaseStudy) -> Self { 101 | self.viewControllers[0].title = caseStudy.caseStudyNavigationTitle 102 | self.viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( 103 | title: "About", 104 | primaryAction: UIAction { [weak self] _ in 105 | self?.present( 106 | UIHostingController( 107 | rootView: Form { 108 | Text(template: caseStudy.readMe) 109 | } 110 | .presentationDetents([.medium]) 111 | ), 112 | animated: true 113 | ) 114 | }) 115 | return self 116 | } 117 | } 118 | 119 | struct CaseStudyModifier: ViewModifier { 120 | let caseStudy: C 121 | @State var isAboutPresented = false 122 | func body(content: Content) -> some View { 123 | content 124 | .navigationTitle(caseStudy.caseStudyNavigationTitle) 125 | .toolbar { 126 | ToolbarItem { 127 | Button("About") { isAboutPresented = true } 128 | } 129 | } 130 | .sheet(isPresented: $isAboutPresented) { 131 | Form { 132 | Text(template: caseStudy.readMe) 133 | } 134 | .presentationDetents([.medium]) 135 | } 136 | } 137 | } 138 | 139 | struct CaseStudyView: View { 140 | @ViewBuilder let caseStudy: C 141 | @State var isAboutPresented = false 142 | var body: some View { 143 | VStack { 144 | caseStudy 145 | } 146 | .modifier(CaseStudyModifier(caseStudy: caseStudy)) 147 | } 148 | } 149 | 150 | struct CaseStudyGroupView: View { 151 | @CaseStudyViewBuilder let content: Content 152 | @ViewBuilder let title: Title 153 | 154 | var body: some View { 155 | Section { 156 | content 157 | } header: { 158 | title 159 | } 160 | } 161 | } 162 | 163 | extension CaseStudyGroupView where Title == Text { 164 | init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { 165 | self.init(content: content) { Text(title) } 166 | } 167 | } 168 | 169 | #Preview("SwiftUI case study") { 170 | NavigationStack { 171 | CaseStudyView { 172 | DemoCaseStudy() 173 | } 174 | } 175 | } 176 | 177 | #Preview("SwiftUI case study group") { 178 | NavigationStack { 179 | Form { 180 | CaseStudyGroupView("Group") { 181 | DemoCaseStudy() 182 | } 183 | } 184 | } 185 | } 186 | 187 | private struct DemoCaseStudy: SwiftUICaseStudy { 188 | let caseStudyTitle = "Demo Case Study" 189 | let readMe = """ 190 | Hello! This is a demo case study. 191 | 192 | Enjoy! 193 | """ 194 | var body: some View { 195 | Text("Hello!") 196 | } 197 | } 198 | 199 | private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { 200 | let caseStudyTitle = "Demo Case Study" 201 | let readMe = """ 202 | Hello! This is a demo case study. 203 | 204 | Enjoy! 205 | """ 206 | } 207 | -------------------------------------------------------------------------------- /Examples/CaseStudies/Internal/Text+Template.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Text { 4 | init(template: String, _ style: Font.TextStyle = .body) { 5 | enum Style: Hashable { 6 | case code 7 | case emphasis 8 | case strong 9 | } 10 | 11 | var segments: [Text] = [] 12 | var currentValue = "" 13 | var currentStyles: Set