diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index 64891f7..a21dbc3 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..28ea2a7 --- /dev/null +++ b/Demo/PowerSyncExample/Components/SearchResultRow.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct SearchResultRow: View { + let item: SearchResultItem + + var body: some View { + HStack { + + 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) + + case .todo(let todo): + Text(todo.description) + .strikethrough(todo.isComplete, color: .secondary) + .foregroundColor(todo.isComplete ? .secondary : .primary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .foregroundColor(.secondary.opacity(0.5)) + } + .contentShape(Rectangle()) + } +} + +#Preview { + List { + 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/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..4d23672 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift @@ -0,0 +1,178 @@ +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..fd6d5f2 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift @@ -0,0 +1,20 @@ +import Foundation + +enum SearchResultContent: Hashable { + case list(ListContent) + case todo(Todo) +} + +struct SearchResultItem: Identifiable, Hashable { + let id: String + let content: SearchResultContent + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(content) + } + + static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { + lhs.id == rhs.id && lhs.content == rhs.content + } +} diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 124a11b..f250997 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -86,6 +86,7 @@ final class SystemManager { ) ) try await attachments?.startSync() + try await configureFts(db: db, schema: AppSchema) } catch { print("Unexpected error: \(error.localizedDescription)") // Catches any other error } @@ -243,4 +244,101 @@ final 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..531c90c --- /dev/null +++ b/Demo/PowerSyncExample/Screens/SearchScreen.swift @@ -0,0 +1,105 @@ +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, content: .list(list)) + } else if let todo = item as? Todo { + return SearchResultItem(id: todo.id, content: .todo(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()) + } +}