From 2dfda29509b3935537739d38bf4898fe8fb34558 Mon Sep 17 00:00:00 2001 From: joshua-journey-apps Date: Thu, 4 Sep 2025 11:00:22 +0200 Subject: [PATCH 1/3] initial implementation --- .../project.pbxproj | 18 +- .../Components/SearchResultRow.swift | 57 ++++++ Demo/PowerSyncExample/Navigation.swift | 1 + .../PowerSyncExample/PowerSync/FtsSetup.swift | 184 ++++++++++++++++++ .../PowerSync/SearchResultItem.swift | 36 ++++ .../PowerSync/SystemManager.swift | 95 ++++++++- Demo/PowerSyncExample/RootView.swift | 4 +- .../PowerSyncExample/Screens/HomeScreen.swift | 43 ++-- .../Screens/SearchScreen.swift | 112 +++++++++++ 9 files changed, 529 insertions(+), 21 deletions(-) create mode 100644 Demo/PowerSyncExample/Components/SearchResultRow.swift create mode 100644 Demo/PowerSyncExample/PowerSync/FtsSetup.swift create mode 100644 Demo/PowerSyncExample/PowerSync/SearchResultItem.swift create mode 100644 Demo/PowerSyncExample/Screens/SearchScreen.swift diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index cbe2645..24fd6e7 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -3,10 +3,14 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ + 0B29DBE92E686D6000D60A06 /* FtsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */; }; + 0B29DBEB2E68876500D60A06 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */; }; + 0B29DBED2E68887A00D60A06 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEC2E68887700D60A06 /* SearchScreen.swift */; }; + 0B29DBEF2E68898C00D60A06 /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */; }; 6A4AD3852B9EE763005CBFD4 /* SupabaseConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */; }; 6A4AD3892B9EEB21005CBFD4 /* _Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */; }; 6A4AD3902B9EF775005CBFD4 /* ErrorText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4AD38F2B9EF775005CBFD4 /* ErrorText.swift */; }; @@ -60,6 +64,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FtsSetup.swift; sourceTree = ""; }; + 0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; + 0B29DBEC2E68887700D60A06 /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; + 0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = ""; }; 18CC627A2CC7A8B5009F7CDE /* powersync-kotlin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "powersync-kotlin"; path = "../powersync-kotlin"; sourceTree = SOURCE_ROOT; }; 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseConnector.swift; sourceTree = ""; }; 6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _Secrets.swift; sourceTree = ""; }; @@ -200,6 +208,7 @@ B65C4D6B2C60D36700176007 /* Screens */ = { isa = PBXGroup; children = ( + 0B29DBEC2E68887700D60A06 /* SearchScreen.swift */, 6A9669032B9EE6FA00B05DCF /* SignInScreen.swift */, B65C4D6C2C60D38B00176007 /* HomeScreen.swift */, B65C4D702C60D7D800176007 /* SignUpScreen.swift */, @@ -211,6 +220,7 @@ B65C4D6E2C60D52E00176007 /* Components */ = { isa = PBXGroup; children = ( + 0B29DBEE2E68898800D60A06 /* SearchResultRow.swift */, 6ABD78792B9F2D8300558A41 /* TodoListRow.swift */, 6ABD786A2B9F2C1500558A41 /* TodoListView.swift */, B66658622C621CA700159A81 /* AddTodoListView.swift */, @@ -225,6 +235,8 @@ B65C4D6F2C60D58500176007 /* PowerSync */ = { isa = PBXGroup; children = ( + 0B29DBEA2E68875C00D60A06 /* SearchResultItem.swift */, + 0B29DBE82E686D5A00D60A06 /* FtsSetup.swift */, BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */, 6A7315BA2B98BDD30004CB17 /* SystemManager.swift */, 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */, @@ -556,6 +568,8 @@ 6ABD787C2B9F2E6700558A41 /* Debug.swift in Sources */, B666585B2C620C3900159A81 /* Constants.swift in Sources */, 6ABD78802B9F2F1300558A41 /* AddListView.swift in Sources */, + 0B29DBEF2E68898C00D60A06 /* SearchResultRow.swift in Sources */, + 0B29DBED2E68887A00D60A06 /* SearchScreen.swift in Sources */, 6A4AD3892B9EEB21005CBFD4 /* _Secrets.swift in Sources */, B65C4D712C60D7D800176007 /* SignUpScreen.swift in Sources */, B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */, @@ -570,11 +584,13 @@ B66658612C62179E00159A81 /* ListView.swift in Sources */, 6ABD78782B9F2D2800558A41 /* Schema.swift in Sources */, BEE4708B2E3BBB2500140D11 /* Secrets.swift in Sources */, + 0B29DBE92E686D6000D60A06 /* FtsSetup.swift in Sources */, B65C4D6D2C60D38B00176007 /* HomeScreen.swift in Sources */, 6A7315882B9854220004CB17 /* PowerSyncExampleApp.swift in Sources */, B666585F2C62115300159A81 /* ListRow.swift in Sources */, BE2F26EC2DA54B2F0080F1AE /* SupabaseRemoteStorage.swift in Sources */, B66658632C621CA700159A81 /* AddTodoListView.swift in Sources */, + 0B29DBEB2E68876500D60A06 /* SearchResultItem.swift in Sources */, B666585D2C620E9E00159A81 /* WifiIcon.swift in Sources */, 6A9669042B9EE6FA00B05DCF /* SignInScreen.swift in Sources */, 6A7315BB2B98BDD30004CB17 /* SystemManager.swift in Sources */, diff --git a/Demo/PowerSyncExample/Components/SearchResultRow.swift b/Demo/PowerSyncExample/Components/SearchResultRow.swift new file mode 100644 index 0000000..cfba840 --- /dev/null +++ b/Demo/PowerSyncExample/Components/SearchResultRow.swift @@ -0,0 +1,57 @@ +// +// SearchResultRow.swift +// PowerSyncExample +// +// Created by Joshua Brink on 2025/09/03. +// + +import SwiftUI + +struct SearchResultRow: View { + let item: SearchResultItem + + var body: some View { + HStack { + + Image(systemName: item.type == .list ? "list.bullet" : "checkmark.circle") + .foregroundColor(.secondary) + + if let list = item.listContent { + Text(list.name) + } else if let todo = item.todo { + Text(todo.description) + .strikethrough(todo.isComplete, color: .secondary) + .foregroundColor(todo.isComplete ? .secondary : .primary) + } else { + Text("Unknown item") + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .foregroundColor(.secondary.opacity(0.5)) + } + .contentShape(Rectangle()) + } +} + +#Preview { + List { + SearchResultRow(item: SearchResultItem( + id: UUID().uuidString, + type: .list, + content: ListContent(id: UUID().uuidString, name: "Groceries", createdAt: "now", ownerId: "user1") + )) + SearchResultRow(item: SearchResultItem( + id: UUID().uuidString, + type: .todo, + content: Todo(id: UUID().uuidString, listId: "list1", description: "Buy milk", isComplete: false) + )) + SearchResultRow(item: SearchResultItem( + id: UUID().uuidString, + type: .todo, + content: Todo(id: UUID().uuidString, listId: "list1", description: "Walk the dog", isComplete: true) + )) + } +} diff --git a/Demo/PowerSyncExample/Navigation.swift b/Demo/PowerSyncExample/Navigation.swift index 202809f..7a387d6 100644 --- a/Demo/PowerSyncExample/Navigation.swift +++ b/Demo/PowerSyncExample/Navigation.swift @@ -4,6 +4,7 @@ enum Route: Hashable { case home case signIn case signUp + case search } @Observable diff --git a/Demo/PowerSyncExample/PowerSync/FtsSetup.swift b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift new file mode 100644 index 0000000..f916fb9 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift @@ -0,0 +1,184 @@ +// +// FullTextSearch.swift +// PowerSyncExample +// +// Created by Joshua Brink on 2025/09/03. +// +import Foundation +import PowerSync + +enum ExtractType { + case columnOnly + case columnInOperation +} + +/// Generates SQL JSON extract expressions for FTS triggers. +/// +/// - Parameters: +/// - type: The type of extraction needed (`columnOnly` or `columnInOperation`). +/// - sourceColumn: The JSON source column (e.g., `'data'`, `'NEW.data'`). +/// - columns: The list of column names to extract. +/// - Returns: A comma-separated string of SQL expressions. +func generateJsonExtracts(type: ExtractType, sourceColumn: String, columns: [String]) -> String { + func createExtract(jsonSource: String, columnName: String) -> String { + return "json_extract(\(jsonSource), '$.\"\(columnName)\"')" + } + + func generateSingleColumnSql(columnName: String) -> String { + switch type { + case .columnOnly: + return createExtract(jsonSource: sourceColumn, columnName: columnName) + case .columnInOperation: + return "\"\(columnName)\" = \(createExtract(jsonSource: sourceColumn, columnName: columnName))" + } + } + + return columns.map(generateSingleColumnSql).joined(separator: ", ") +} + +/// Generates the SQL statements required to set up an FTS5 virtual table +/// and corresponding triggers for a given PowerSync table. +/// +/// +/// - Parameters: +/// - tableName: The public name of the table to index (e.g., "lists", "todos"). +/// - columns: The list of column names within the table to include in the FTS index. +/// - schema: The PowerSync `Schema` object to find the internal table name. +/// - tokenizationMethod: The FTS5 tokenization method (e.g., "porter unicode61", "unicode61"). +/// - Returns: An array of SQL statements to be executed, or `nil` if the table is not found in the schema. +func getFtsSetupSqlStatements( + tableName: String, + columns: [String], + schema: Schema, + tokenizationMethod: String = "unicode61" +) -> [String]? { + + guard let table = schema.tables.first(where: { $0.name == tableName }) else { + print("Table '\(tableName)' not found in schema. Skipping FTS setup for this table.") + return nil + } + let internalName = table.localOnly ? "ps_data_local__\(table.name)" : "ps_data__\(table.name)" + + let ftsTableName = "fts_\(tableName)" + + let stringColumnsForCreate = columns.map { "\"\($0)\"" }.joined(separator: ", ") + + let stringColumnsForInsertList = columns.map { "\"\($0)\"" }.joined(separator: ", ") + + var sqlStatements: [String] = [] + + // 1. Create the FTS5 Virtual Table + sqlStatements.append(""" + CREATE VIRTUAL TABLE IF NOT EXISTS \(ftsTableName) + USING fts5(id UNINDEXED, \(stringColumnsForCreate), tokenize='\(tokenizationMethod)'); + """) + + // 2. Copy existing data from the main table to the FTS table + sqlStatements.append(""" + INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList)) + SELECT rowid, id, \(generateJsonExtracts(type: .columnOnly, sourceColumn: "data", columns: columns)) + FROM \(internalName); + """) + + // 3. Create INSERT Trigger + sqlStatements.append(""" + CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_\(tableName) AFTER INSERT ON \(internalName) + BEGIN + INSERT INTO \(ftsTableName)(rowid, id, \(stringColumnsForInsertList)) + VALUES ( + NEW.rowid, + NEW.id, + \(generateJsonExtracts(type: .columnOnly, sourceColumn: "NEW.data", columns: columns)) + ); + END; + """) + + // 4. Create UPDATE Trigger + sqlStatements.append(""" + CREATE TRIGGER IF NOT EXISTS fts_update_trigger_\(tableName) AFTER UPDATE ON \(internalName) + BEGIN + UPDATE \(ftsTableName) + SET \(generateJsonExtracts(type: .columnInOperation, sourceColumn: "NEW.data", columns: columns)) + WHERE rowid = NEW.rowid; + END; + """) + + // 5. Create DELETE Trigger + sqlStatements.append(""" + CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_\(tableName) AFTER DELETE ON \(internalName) + BEGIN + DELETE FROM \(ftsTableName) WHERE rowid = OLD.rowid; + END; + """) + + return sqlStatements +} + + +/// Configures Full-Text Search (FTS) tables and triggers for specified tables +/// within the PowerSync database. Call this function during database initialization. +/// +/// Executes all generated SQL within a single transaction. +/// +/// - Parameters: +/// - db: The initialized `PowerSyncDatabaseProtocol` instance. +/// - schema: The `Schema` instance matching the database. +/// - Throws: An error if the database transaction fails. +func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws { + let ftsCheckTable = "fts_\(LISTS_TABLE)" + let checkSql = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?" + + do { + let existingTable: String? = try await db.getOptional(sql: checkSql, parameters: [ftsCheckTable]) { cursor in + try cursor.getString(name: "name") + } + + if existingTable != nil { + print("[FTS] FTS table '\(ftsCheckTable)' already exists. Skipping setup.") + return + } + } catch { + print("[FTS] Failed to check for existing FTS tables: \(error.localizedDescription). Proceeding with setup attempt.") + } + print("[FTS] Starting FTS configuration...") + var allSqlStatements: [String] = [] + + if let listStatements = getFtsSetupSqlStatements( + tableName: LISTS_TABLE, + columns: ["name"], + schema: schema, + tokenizationMethod: "porter unicode61" + ) { + print("[FTS] Generated \(listStatements.count) SQL statements for '\(LISTS_TABLE)' table.") + allSqlStatements.append(contentsOf: listStatements) + } + + if let todoStatements = getFtsSetupSqlStatements( + tableName: TODOS_TABLE, + columns: ["description"], + schema: schema + ) { + print("[FTS] Generated \(todoStatements.count) SQL statements for '\(TODOS_TABLE)' table.") + allSqlStatements.append(contentsOf: todoStatements) + } + + // --- Execute all generated SQL statements --- + + if !allSqlStatements.isEmpty { + do { + print("[FTS] Executing \(allSqlStatements.count) SQL statements in a transaction...") + _ = try await db.writeTransaction { transaction in + for sql in allSqlStatements { + print("[FTS] Executing SQL:\n\(sql)") + _ = try transaction.execute(sql: sql, parameters: []) + } + } + print("[FTS] Configuration completed successfully.") + } catch { + print("[FTS] Error during FTS setup SQL execution: \(error.localizedDescription)") + throw error + } + } else { + print("[FTS] No FTS SQL statements were generated. Check table names and schema definition.") + } +} diff --git a/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift new file mode 100644 index 0000000..41ef9dd --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift @@ -0,0 +1,36 @@ +// +// SearchResultItem.swift +// PowerSyncExample +// +// Created by Joshua Brink on 2025/09/03. +// + +import Foundation + +enum SearchResultType { + case list + case todo +} + +struct SearchResultItem: Identifiable, Hashable { + let id: String + let type: SearchResultType + let content: AnyHashable + + var listContent: ListContent? { + content as? ListContent + } + + var todo: Todo? { + content as? Todo + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(type) + } + + static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { + lhs.id == rhs.id && lhs.type == rhs.type + } +} diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 89b3641..7dc00bb 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -83,6 +83,7 @@ class SystemManager { ) ) try await attachments?.startSync() + try await configureFts(db: db, schema: AppSchema) } catch { print("Unexpected error: \(error.localizedDescription)") // Catches any other error } @@ -240,11 +241,103 @@ class SystemManager { } } } - + + /// Searches across lists and todos using FTS. + /// + /// - Parameter searchTerm: The text to search for. + /// - Returns: An array of search results, containing either `ListContent` or `Todo` objects. + /// - Throws: An error if the database query fails. + func searchListsAndTodos(searchTerm: String) async throws -> [AnyHashable] { + let preparedSearchTerm = createSearchTermWithOptions(searchTerm) + + guard !preparedSearchTerm.isEmpty else { + print("[FTS] Prepared search term is empty, returning no results.") + return [] + } + + print("[FTS] Searching for term: \(preparedSearchTerm)") + + var results: [AnyHashable] = [] + + // --- Search Lists --- + let listSql = """ + SELECT l.* + FROM \(LISTS_TABLE) l + JOIN fts_\(LISTS_TABLE) fts ON l.id = fts.id + WHERE fts.fts_\(LISTS_TABLE) MATCH ? ORDER BY fts.rank + """ + do { + let listsFound = try await db.getAll( + sql: listSql, + parameters: [preparedSearchTerm], + mapper: { cursor in + try ListContent( + id: cursor.getString(name: "id"), + name: cursor.getString(name: "name"), + createdAt: cursor.getString(name: "created_at"), + ownerId: cursor.getString(name: "owner_id") + ) + } + ) + results.append(contentsOf: listsFound) + print("[FTS] Found \(listsFound.count) lists matching term.") + } catch { + print("[FTS] Error searching lists: \(error.localizedDescription)") + throw error + } + + + // --- Search Todos --- + let todoSql = """ + SELECT t.* + FROM \(TODOS_TABLE) t + JOIN fts_\(TODOS_TABLE) fts ON t.id = fts.id + WHERE fts.fts_\(TODOS_TABLE) MATCH ? ORDER BY fts.rank + """ + do { + let todosFound = try await db.getAll( + sql: todoSql, + parameters: [preparedSearchTerm], + mapper: { cursor in + try Todo( + id: cursor.getString(name: "id"), + listId: cursor.getString(name: "list_id"), + photoId: cursor.getStringOptional(name: "photo_id"), + description: cursor.getString(name: "description"), + isComplete: cursor.getBoolean(name: "completed"), + createdAt: cursor.getString(name: "created_at"), + completedAt: cursor.getStringOptional(name: "completed_at"), + createdBy: cursor.getStringOptional(name: "created_by"), + completedBy: cursor.getStringOptional(name: "completed_by") + ) + } + ) + results.append(contentsOf: todosFound) + print("[FTS] Found \(todosFound.count) todos matching term.") + } catch { + print("[FTS] Error searching todos: \(error.localizedDescription)") + throw error + } + + print("[FTS] Total results found: \(results.count)") + return results + } + private func deleteTodoInTX(id: String, tx: ConnectionContext) throws { _ = try tx.execute( sql: "DELETE FROM \(TODOS_TABLE) WHERE id = ?", parameters: [id] ) } + + /// Helper function to prepare the search term for FTS5 query syntax. + private func createSearchTermWithOptions(_ searchTerm: String) -> String { + let trimmedSearchTerm = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSearchTerm.isEmpty else { + return "" + } + return "\(trimmedSearchTerm)*" + } + + } diff --git a/Demo/PowerSyncExample/RootView.swift b/Demo/PowerSyncExample/RootView.swift index baed264..23fea0b 100644 --- a/Demo/PowerSyncExample/RootView.swift +++ b/Demo/PowerSyncExample/RootView.swift @@ -5,7 +5,7 @@ struct RootView: View { @Environment(SystemManager.self) var system @State private var navigationModel = NavigationModel() - + var body: some View { NavigationStack(path: $navigationModel.path) { Group { @@ -23,6 +23,8 @@ struct RootView: View { SignInScreen() case .signUp: SignUpScreen() + case .search: + SearchScreen() } } } diff --git a/Demo/PowerSyncExample/Screens/HomeScreen.swift b/Demo/PowerSyncExample/Screens/HomeScreen.swift index 640759b..608e046 100644 --- a/Demo/PowerSyncExample/Screens/HomeScreen.swift +++ b/Demo/PowerSyncExample/Screens/HomeScreen.swift @@ -5,28 +5,35 @@ import SwiftUI struct HomeScreen: View { @Environment(SystemManager.self) private var system @Environment(NavigationModel.self) private var navigationModel - - var body: some View { - + + var body: some View { + ListView() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Sign out") { - Task { - try await system.signOut() - navigationModel.path = NavigationPath() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sign out") { + Task { + try await system.signOut() + navigationModel.path = NavigationPath() + } + } + } + ToolbarItem(placement: .primaryAction) { + Button { + navigationModel.path.append(Route.search) + } label: { + Label("Search", systemImage: "magnifyingglass") + } } - } } - } - .task { - if(system.db.currentStatus.connected == false) { - await system.connect() - } - } - .navigationBarBackButtonHidden(true) - } + .task { + if(system.db.currentStatus.connected == false) { + await system.connect() + } + } + .navigationBarBackButtonHidden(true) + } } #Preview { diff --git a/Demo/PowerSyncExample/Screens/SearchScreen.swift b/Demo/PowerSyncExample/Screens/SearchScreen.swift new file mode 100644 index 0000000..ef30690 --- /dev/null +++ b/Demo/PowerSyncExample/Screens/SearchScreen.swift @@ -0,0 +1,112 @@ +// +// SearchScreen.swift +// PowerSyncExample +// +// Created by Joshua Brink on 2025/09/03. +// + +import SwiftUI + +struct SearchScreen: View { + @Environment(SystemManager.self) private var system + @State private var searchText: String = "" + @State private var searchResults: [SearchResultItem] = [] + @State private var isLoading: Bool = false + @State private var searchError: String? = nil + @State private var searchTask: Task? = nil + + var body: some View { + Group { + if isLoading { + VStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = searchError { + VStack { + Spacer() + Text("Error: \(error)") + Spacer() + } + } else if searchText.isEmpty { + ContentUnavailableView("Search Lists & Todos", systemImage: "magnifyingglass") + } else if searchResults.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + List(searchResults) { item in + SearchResultRow(item: item) + } + } + } + .navigationTitle("Search") + .searchable(text: $searchText, + placement: .toolbar, + prompt: "Search Lists & Todos") + .onChange(of: searchText) { _, newValue in + triggerSearch(term: newValue) + } + .onChange(of: searchText) { _, newValue in + if newValue.isEmpty && !isLoading { + searchResults = [] + searchError = nil + } + } + } + + private func triggerSearch(term: String) { + searchTask?.cancel() + + let trimmedTerm = term.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedTerm.isEmpty else { + self.searchResults = [] + self.searchError = nil + self.isLoading = false + return + } + + self.isLoading = false + self.searchError = nil + + searchTask = Task { + do { + try await Task.sleep(for: .milliseconds(300)) + + self.isLoading = true + + print("Performing search for: \(trimmedTerm)") + let results = try await system.searchListsAndTodos(searchTerm: trimmedTerm) + + try Task.checkCancellation() + + self.searchResults = results.compactMap { item in + if let list = item as? ListContent { + return SearchResultItem(id: list.id, type: .list, content: list) + } else if let todo = item as? Todo { + return SearchResultItem(id: todo.id, type: .todo, content: todo) + } + return nil + } + self.searchError = nil + print("Search completed with \(self.searchResults.count) results.") + + } catch is CancellationError { + print("Search task cancelled.") + } catch { + print("Search failed: \(error.localizedDescription)") + self.searchError = error.localizedDescription + self.searchResults = [] + } + + self.isLoading = false + } + } +} + +#Preview { + NavigationStack { + SearchScreen() + .environment(SystemManager()) + } +} From d9c89c2d2d08b830e8e76a0135b9dc6f4e95cbe7 Mon Sep 17 00:00:00 2001 From: joshua-journey-apps Date: Tue, 9 Sep 2025 10:05:22 +0200 Subject: [PATCH 2/3] remove file headers --- Demo/PowerSyncExample/Components/SearchResultRow.swift | 7 ------- Demo/PowerSyncExample/PowerSync/FtsSetup.swift | 6 ------ Demo/PowerSyncExample/PowerSync/SearchResultItem.swift | 7 ------- Demo/PowerSyncExample/Screens/SearchScreen.swift | 7 ------- 4 files changed, 27 deletions(-) diff --git a/Demo/PowerSyncExample/Components/SearchResultRow.swift b/Demo/PowerSyncExample/Components/SearchResultRow.swift index cfba840..e45a932 100644 --- a/Demo/PowerSyncExample/Components/SearchResultRow.swift +++ b/Demo/PowerSyncExample/Components/SearchResultRow.swift @@ -1,10 +1,3 @@ -// -// SearchResultRow.swift -// PowerSyncExample -// -// Created by Joshua Brink on 2025/09/03. -// - import SwiftUI struct SearchResultRow: View { diff --git a/Demo/PowerSyncExample/PowerSync/FtsSetup.swift b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift index f916fb9..4d23672 100644 --- a/Demo/PowerSyncExample/PowerSync/FtsSetup.swift +++ b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift @@ -1,9 +1,3 @@ -// -// FullTextSearch.swift -// PowerSyncExample -// -// Created by Joshua Brink on 2025/09/03. -// import Foundation import PowerSync diff --git a/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift index 41ef9dd..b309621 100644 --- a/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift +++ b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift @@ -1,10 +1,3 @@ -// -// SearchResultItem.swift -// PowerSyncExample -// -// Created by Joshua Brink on 2025/09/03. -// - import Foundation enum SearchResultType { diff --git a/Demo/PowerSyncExample/Screens/SearchScreen.swift b/Demo/PowerSyncExample/Screens/SearchScreen.swift index ef30690..e7ef13c 100644 --- a/Demo/PowerSyncExample/Screens/SearchScreen.swift +++ b/Demo/PowerSyncExample/Screens/SearchScreen.swift @@ -1,10 +1,3 @@ -// -// SearchScreen.swift -// PowerSyncExample -// -// Created by Joshua Brink on 2025/09/03. -// - import SwiftUI struct SearchScreen: View { From 5b5867d7a2fc8ddd1ee1aae9f73b65275285df1d Mon Sep 17 00:00:00 2001 From: joshua-journey-apps Date: Tue, 9 Sep 2025 10:07:29 +0200 Subject: [PATCH 3/3] combine type and content into single enum --- .../Components/SearchResultRow.swift | 83 +++++++++++++------ .../PowerSync/SearchResultItem.swift | 21 ++--- .../Screens/SearchScreen.swift | 4 +- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/Demo/PowerSyncExample/Components/SearchResultRow.swift b/Demo/PowerSyncExample/Components/SearchResultRow.swift index e45a932..28ea2a7 100644 --- a/Demo/PowerSyncExample/Components/SearchResultRow.swift +++ b/Demo/PowerSyncExample/Components/SearchResultRow.swift @@ -6,24 +6,33 @@ struct SearchResultRow: View { var body: some View { HStack { - Image(systemName: item.type == .list ? "list.bullet" : "checkmark.circle") - .foregroundColor(.secondary) + Image( + systemName: { + switch item.content { + case .list: + return "list.bullet" + case .todo: + return "checkmark.circle" + } + }() + ) + .foregroundColor(.secondary) + + switch item.content { + case .list(let listContent): + Text(listContent.name) - if let list = item.listContent { - Text(list.name) - } else if let todo = item.todo { + case .todo(let todo): Text(todo.description) .strikethrough(todo.isComplete, color: .secondary) .foregroundColor(todo.isComplete ? .secondary : .primary) - } else { - Text("Unknown item") } Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.bold)) - .foregroundColor(.secondary.opacity(0.5)) + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .foregroundColor(.secondary.opacity(0.5)) } .contentShape(Rectangle()) } @@ -31,20 +40,44 @@ struct SearchResultRow: View { #Preview { List { - SearchResultRow(item: SearchResultItem( - id: UUID().uuidString, - type: .list, - content: ListContent(id: UUID().uuidString, name: "Groceries", createdAt: "now", ownerId: "user1") - )) - SearchResultRow(item: SearchResultItem( - id: UUID().uuidString, - type: .todo, - content: Todo(id: UUID().uuidString, listId: "list1", description: "Buy milk", isComplete: false) - )) - SearchResultRow(item: SearchResultItem( - id: UUID().uuidString, - type: .todo, - content: Todo(id: UUID().uuidString, listId: "list1", description: "Walk the dog", isComplete: true) - )) + SearchResultRow( + item: SearchResultItem( + id: UUID().uuidString, + content: .list( + ListContent( + id: UUID().uuidString, + name: "Groceries", + createdAt: "now", + ownerId: "user1" + ) + ) + ) + ) + SearchResultRow( + item: SearchResultItem( + id: UUID().uuidString, + content: .todo( + Todo( + id: UUID().uuidString, + listId: "list1", + description: "Buy milk", + isComplete: false + ) + ) + ) + ) + SearchResultRow( + item: SearchResultItem( + id: UUID().uuidString, + content: .todo( + Todo( + id: UUID().uuidString, + listId: "list1", + description: "Walk the dog", + isComplete: true + ) + ) + ) + ) } } diff --git a/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift index b309621..fd6d5f2 100644 --- a/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift +++ b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift @@ -1,29 +1,20 @@ import Foundation -enum SearchResultType { - case list - case todo +enum SearchResultContent: Hashable { + case list(ListContent) + case todo(Todo) } struct SearchResultItem: Identifiable, Hashable { let id: String - let type: SearchResultType - let content: AnyHashable - - var listContent: ListContent? { - content as? ListContent - } - - var todo: Todo? { - content as? Todo - } + let content: SearchResultContent func hash(into hasher: inout Hasher) { hasher.combine(id) - hasher.combine(type) + hasher.combine(content) } static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { - lhs.id == rhs.id && lhs.type == rhs.type + lhs.id == rhs.id && lhs.content == rhs.content } } diff --git a/Demo/PowerSyncExample/Screens/SearchScreen.swift b/Demo/PowerSyncExample/Screens/SearchScreen.swift index e7ef13c..531c90c 100644 --- a/Demo/PowerSyncExample/Screens/SearchScreen.swift +++ b/Demo/PowerSyncExample/Screens/SearchScreen.swift @@ -75,9 +75,9 @@ struct SearchScreen: View { self.searchResults = results.compactMap { item in if let list = item as? ListContent { - return SearchResultItem(id: list.id, type: .list, content: list) + return SearchResultItem(id: list.id, content: .list(list)) } else if let todo = item as? Todo { - return SearchResultItem(id: todo.id, type: .todo, content: todo) + return SearchResultItem(id: todo.id, content: .todo(todo)) } return nil }