diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index e1035ae..0589ceb 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 4276C4302DA67C2D0029D27D /* FtsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C42F2DA67C2D0029D27D /* FtsSetup.swift */; }; + 4276C4322DA6800A0029D27D /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4312DA6800A0029D27D /* SearchResultItem.swift */; }; + 4276C4342DA6805F0029D27D /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4332DA6805F0029D27D /* SearchResultRow.swift */; }; + 4276C4362DA680D90029D27D /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4276C4352DA680D90029D27D /* SearchScreen.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 */; }; @@ -57,6 +61,10 @@ /* Begin PBXFileReference section */ 18CC627A2CC7A8B5009F7CDE /* powersync-kotlin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "powersync-kotlin"; path = "../powersync-kotlin"; sourceTree = SOURCE_ROOT; }; + 4276C42F2DA67C2D0029D27D /* FtsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FtsSetup.swift; sourceTree = ""; }; + 4276C4312DA6800A0029D27D /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; + 4276C4332DA6805F0029D27D /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = ""; }; + 4276C4352DA680D90029D27D /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseConnector.swift; sourceTree = ""; }; 6A4AD3882B9EEB21005CBFD4 /* _Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _Secrets.swift; sourceTree = ""; }; 6A4AD38F2B9EF775005CBFD4 /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; @@ -197,6 +205,7 @@ B65C4D6C2C60D38B00176007 /* HomeScreen.swift */, B65C4D702C60D7D800176007 /* SignUpScreen.swift */, B65C4D722C60D7EB00176007 /* TodosScreen.swift */, + 4276C4352DA680D90029D27D /* SearchScreen.swift */, ); path = Screens; sourceTree = ""; @@ -211,6 +220,7 @@ B666585E2C62115300159A81 /* ListRow.swift */, B66658602C62179E00159A81 /* ListView.swift */, 6ABD787F2B9F2F1300558A41 /* AddListView.swift */, + 4276C4332DA6805F0029D27D /* SearchResultRow.swift */, ); path = Components; sourceTree = ""; @@ -223,6 +233,8 @@ 6ABD78772B9F2D2800558A41 /* Schema.swift */, B66658642C62314B00159A81 /* Lists.swift */, B66658662C62315400159A81 /* Todos.swift */, + 4276C42F2DA67C2D0029D27D /* FtsSetup.swift */, + 4276C4312DA6800A0029D27D /* SearchResultItem.swift */, ); path = PowerSync; sourceTree = ""; @@ -552,12 +564,16 @@ B65C4D712C60D7D800176007 /* SignUpScreen.swift in Sources */, B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */, 6ABD786B2B9F2C1500558A41 /* TodoListView.swift in Sources */, + 4276C4302DA67C2D0029D27D /* FtsSetup.swift in Sources */, + 4276C4322DA6800A0029D27D /* SearchResultItem.swift in Sources */, 6A4AD3852B9EE763005CBFD4 /* SupabaseConnector.swift in Sources */, B65C4D732C60D7EB00176007 /* TodosScreen.swift in Sources */, 6ABD787A2B9F2D8300558A41 /* TodoListRow.swift in Sources */, B66658652C62314B00159A81 /* Lists.swift in Sources */, 6A4AD3902B9EF775005CBFD4 /* ErrorText.swift in Sources */, + 4276C4362DA680D90029D27D /* SearchScreen.swift in Sources */, B66658672C62315400159A81 /* Todos.swift in Sources */, + 4276C4342DA6805F0029D27D /* SearchResultRow.swift in Sources */, 6ABD78672B9F2B4800558A41 /* RootView.swift in Sources */, B66658612C62179E00159A81 /* ListView.swift in Sources */, 6ABD78782B9F2D2800558A41 /* Schema.swift in Sources */, @@ -705,7 +721,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = Y9N25KJUCF; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -742,7 +758,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = Y9N25KJUCF; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/Demo/PowerSyncExample/Components/SearchResultRow.swift b/Demo/PowerSyncExample/Components/SearchResultRow.swift new file mode 100644 index 0000000..f1ea3c6 --- /dev/null +++ b/Demo/PowerSyncExample/Components/SearchResultRow.swift @@ -0,0 +1,57 @@ +// +// SearchResultRow.swift +// PowerSyncExample +// +// Created by Wade Morris on 4/9/25. +// + +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 a8e0802..370b3d4 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..7facb25 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift @@ -0,0 +1,184 @@ +// +// FtsSetup.swift +// PowerSyncExample +// +// Created by Wade Morris on 4/9/25. +// + +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 internalName = schema.tables.first(where: { $0.name == tableName })?.internalName else { + print("Table '\(tableName)' not found in schema. Skipping FTS setup for this table.") + return nil + } + + 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..8662b72 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/SearchResultItem.swift @@ -0,0 +1,36 @@ +// +// SearchResultItem.swift +// PowerSyncExample +// +// Created by Wade Morris on 4/9/25. +// + +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 cd0aa0b..8ad802f 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -1,6 +1,7 @@ import Foundation import PowerSync + @Observable class SystemManager { let connector = SupabaseConnector() @@ -8,8 +9,14 @@ class SystemManager { var db: PowerSyncDatabaseProtocol! // openDb must be called before connect - func openDb() { + func openDb() async throws { db = PowerSyncDatabase(schema: schema, dbFilename: "powersync-swift.sqlite") + do { + try await configureFts(db: db, schema: schema) + } catch { + print("Failed to configure FTS: \(error.localizedDescription)") + + } } func connect() async { @@ -132,4 +139,94 @@ class SystemManager { ) }) } + + /// 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)*" + } + + /// 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 + } } diff --git a/Demo/PowerSyncExample/RootView.swift b/Demo/PowerSyncExample/RootView.swift index 9450aa1..bf69257 100644 --- a/Demo/PowerSyncExample/RootView.swift +++ b/Demo/PowerSyncExample/RootView.swift @@ -3,7 +3,6 @@ import SwiftUI struct RootView: View { @Environment(SystemManager.self) var system - @State private var authModel = AuthModel() @State private var navigationModel = NavigationModel() @@ -24,12 +23,19 @@ struct RootView: View { SignInScreen() case .signUp: SignUpScreen() + case .search: + SearchScreen() } } } .task { if(system.db == nil) { - system.openDb() + do { + try await system.openDb() + await system.connect() + } catch { + print("Failed to open db: \(error.localizedDescription)") + } } } .environment(authModel) diff --git a/Demo/PowerSyncExample/Screens/HomeScreen.swift b/Demo/PowerSyncExample/Screens/HomeScreen.swift index 6a4da2f..a7e1a6a 100644 --- a/Demo/PowerSyncExample/Screens/HomeScreen.swift +++ b/Demo/PowerSyncExample/Screens/HomeScreen.swift @@ -6,34 +6,43 @@ struct HomeScreen: View { @Environment(SystemManager.self) private var system @Environment(AuthModel.self) private var authModel @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() - authModel.isAuthenticated = false - navigationModel.path = NavigationPath() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sign out") { + Task { + try await system.signOut() + authModel.isAuthenticated = false + 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 != nil && !system.db.currentStatus.connected { + await system.connect() + } + } + .navigationBarBackButtonHidden(true) + } } #Preview { NavigationStack{ HomeScreen() .environment(SystemManager()) + .environment(AuthModel()) + .environment(NavigationModel()) } } diff --git a/Demo/PowerSyncExample/Screens/SearchScreen.swift b/Demo/PowerSyncExample/Screens/SearchScreen.swift new file mode 100644 index 0000000..4f27ff1 --- /dev/null +++ b/Demo/PowerSyncExample/Screens/SearchScreen.swift @@ -0,0 +1,108 @@ +// +// SearchScreen.swift +// PowerSyncExample +// +// Created by Wade Morris on 4/9/25. +// + +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 { + List { + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = searchError { + Text("Error: \(error)") + } else if searchText.isEmpty { + ContentUnavailableView("Search Lists & Todos", systemImage: "magnifyingglass") + } else if searchResults.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + ForEach(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()) + } +} diff --git a/Demo/PowerSyncExample/_Secrets.swift b/Demo/PowerSyncExample/_Secrets.swift index 1e1b04e..2edaa90 100644 --- a/Demo/PowerSyncExample/_Secrets.swift +++ b/Demo/PowerSyncExample/_Secrets.swift @@ -2,7 +2,7 @@ import Foundation // Enter your Supabase and PowerSync project details. enum Secrets { - static let powerSyncEndpoint = "https://your-id.powersync.journeyapps.com" - static let supabaseURL = URL(string: "https://your-id.supabase.co")! - static let supabaseAnonKey = "anon-key" + static let powerSyncEndpoint = "https://67f10d71984c6f4cb078337b.powersync.journeyapps.com" + static let supabaseURL = URL(string: "https://yyazvtnigznetffvlksk.supabase.co")! + static let supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl5YXp2dG5pZ3puZXRmZnZsa3NrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDM4NTA1NDUsImV4cCI6MjA1OTQyNjU0NX0.AGeVxr_qlSk4vviCnmPRrn8fLOvHU6bk_c2KoZd8cm4" } \ No newline at end of file diff --git a/Demo/README.md b/Demo/README.md index e3369d1..62b2847 100644 --- a/Demo/README.md +++ b/Demo/README.md @@ -40,3 +40,6 @@ rm -rf ~/Library/org.swift.swiftpm ## Run project Build the project, launch the app and sign in or register a new user. + +based on following guide link where would i configure the full test search(fts) +@https://docs.powersync.com/usage/use-case-examples/full-text-search#full-text-search \ No newline at end of file diff --git a/Sources/PowerSync/Schema/Table.swift b/Sources/PowerSync/Schema/Table.swift index 1d6ac9e..2d51ad8 100644 --- a/Sources/PowerSync/Schema/Table.swift +++ b/Sources/PowerSync/Schema/Table.swift @@ -45,7 +45,7 @@ public struct Table: TableProtocol { viewNameOverride ?? name } - internal var internalName: String { + public var internalName: String { localOnly ? "ps_data_local__\(name)" : "ps_data__\(name)" }