diff --git a/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt b/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt index 6314cd37..051b0d69 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt @@ -97,7 +97,7 @@ public data class Table constructor( * * Name of the table that stores the underlying data. */ - internal val internalName: String + val internalName: String get() = if (localOnly) "ps_data_local__$name" else "ps_data__$name" public operator fun get(columnName: String): Column = columns.first { it.name == columnName } diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj index 57e2c8e9..519ba6b0 100644 --- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj @@ -118,7 +118,6 @@ 7555FF79242A565900829871 /* Resources */, F85CB1118929364A9C6EFABC /* Frameworks */, 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */, - 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -192,23 +191,6 @@ runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; }; - 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -248,23 +230,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - F72245E8E98E97BEF8C32493 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -406,10 +371,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = 2Y7WW5ND4N; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; + JAVA_HOME = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -431,10 +397,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = 2Y7WW5ND4N; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; + JAVA_HOME = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt index c1a1cb95..130f5125 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt @@ -11,20 +11,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import co.touchlab.kermit.Logger import com.powersync.DatabaseDriverFactory import com.powersync.PowerSyncDatabase import com.powersync.bucket.BucketPriority import com.powersync.connector.supabase.SupabaseConnector import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.demos.components.EditDialog +import com.powersync.demos.fts.configureFts import com.powersync.demos.powersync.ListContent import com.powersync.demos.powersync.ListItem +import com.powersync.demos.powersync.SearchResult +import com.powersync.demos.powersync.SearchResult.ListResult +import com.powersync.demos.powersync.SearchResult.TodoResult import com.powersync.demos.powersync.Todo import com.powersync.demos.powersync.schema import com.powersync.demos.screens.HomeScreen import com.powersync.demos.screens.SignInScreen import com.powersync.demos.screens.SignUpScreen import com.powersync.demos.screens.TodosScreen +import com.powersync.demos.screens.SearchScreen +import com.powersync.demos.powersync.SearchViewModel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.runBlocking import org.koin.compose.KoinApplication @@ -50,6 +57,7 @@ val sharedAppModule = module { single { NavController(Screen.Home) } viewModelOf(::AuthViewModel) + viewModelOf(::SearchViewModel) } @Composable @@ -71,6 +79,12 @@ fun AppContent( db: PowerSyncDatabase = koinInject(), modifier: Modifier = Modifier, ) { + LaunchedEffect(Unit) { + // Ensure db and appSchema are valid before calling + Logger.i { "AppContent LaunchedEffect: Triggering FTS configuration." } + configureFts(db, schema) + } + // Debouncing the status flow prevents flicker val status by db.currentStatus .asFlow() @@ -86,6 +100,7 @@ fun AppContent( } val authViewModel = koinViewModel() + val searchViewModel = koinViewModel() val navController = koinInject() val authState by authViewModel.authState.collectAsState() val currentScreen by navController.currentScreen.collectAsState() @@ -108,6 +123,8 @@ fun AppContent( val editingItem by todos.value.editingItem.collectAsState() val todosInputText by todos.value.inputText.collectAsState() + val selectedSearchResult = searchViewModel.selectedSearchResult.collectAsState() + fun handleSignOut() { runBlocking { authViewModel.signOut() @@ -139,8 +156,15 @@ fun AppContent( } is Screen.Todos -> { + + val listId = when (selectedSearchResult) { + is ListResult -> selectedSearchResult.item.id + is TodoResult -> selectedSearchResult.item.listId + else -> selectedListId + } + val handleOnAddItemClicked = { - todos.value.onAddItemClicked(userId, selectedListId) + todos.value.onAddItemClicked(userId, listId) } TodosScreen( @@ -166,6 +190,14 @@ fun AppContent( } } + is Screen.Search -> { + + SearchScreen( + navController, + searchViewModel , + ) + } + is Screen.SignIn -> { if (authState == AuthState.SignedIn) { navController.navigate(Screen.Home) diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/NavController.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/NavController.kt index 871269d7..67f1c5f9 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/NavController.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/NavController.kt @@ -9,13 +9,47 @@ sealed class Screen { data object SignIn : Screen() data object SignUp : Screen() data object Todos : Screen() + data object Search : Screen() } -internal class NavController(initialScreen: Screen) { +class NavController(initialScreen: Screen) { + private val backStack = mutableListOf() + private val _currentScreen = MutableStateFlow(initialScreen) val currentScreen: StateFlow = _currentScreen.asStateFlow() + init { + backStack.add(initialScreen) + } + + /** + * Navigates to a new screen, adding it to the top of the back stack. + * Avoids adding the same screen consecutively. + */ fun navigate(screen: Screen) { - _currentScreen.value = screen + if (screen != backStack.lastOrNull()) { + backStack.add(screen) + _currentScreen.value = screen + } + } + + /** + * Navigates back to the previous screen in the stack, if available. + * Returns true if navigation occurred, false otherwise. + */ + fun navigateBack(): Boolean { + if (backStack.size > 1) { + backStack.removeLast() + _currentScreen.value = backStack.last() + return true + } + return false + } + + /** + * Checks if back navigation is possible. + */ + fun canNavigateBack(): Boolean { + return backStack.size > 1 } } \ No newline at end of file diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/SearchResultItem.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/SearchResultItem.kt new file mode 100644 index 00000000..ca1c586b --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/SearchResultItem.kt @@ -0,0 +1,71 @@ +package com.powersync.demos.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.powersync.demos.powersync.SearchResult + +@Composable +fun SearchResultItem( + result: SearchResult, + onClick: (SearchResult) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clickable { onClick(result) }, + elevation = 2.dp + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + verticalArrangement = Arrangement.Center + ) { + when (result) { + is SearchResult.ListResult -> { + Text( + text = result.item.name, + style = MaterialTheme.typography.h6, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "List", + style = MaterialTheme.typography.caption + ) + } + is SearchResult.TodoResult -> { + Text( + text = result.item.description, + style = MaterialTheme.typography.body1, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Todo Item", + style = MaterialTheme.typography.caption + ) + } + } + } + } + } +} diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt new file mode 100644 index 00000000..47804d69 --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt @@ -0,0 +1,231 @@ +/** + * This file provides utility functions for setting up Full-Text Search (FTS) + * using the FTS5 extension with PowerSync in a Kotlin Multiplatform project. + * It mirrors the functionality of the fts_setup.dart file from the PowerSync + * Flutter examples. + * + * Note: FTS5 support depends on the underlying SQLite engine used by the + * PowerSync KMP SDK on each target platform. Ensure FTS5 is enabled/available. + */ +@file:JvmName("FtsSetupKt") + +package com.powersync.demos.fts + +import com.powersync.PowerSyncDatabase +import com.powersync.db.schema.Schema +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import co.touchlab.kermit.Logger +import com.powersync.db.internal.PowerSyncTransaction +import kotlin.jvm.JvmName + +/** + * Defines the type of JSON extract operation needed, affecting the generated SQL. + */ +enum class ExtractType { + /** Generates just the json_extract(...) expression. */ + COLUMN_ONLY, + + /** Generates 'column_name = json_extract(...)' for use in SET clauses. */ + COLUMN_IN_OPERATION +} + +/** + * Generates SQL JSON extract expressions for FTS triggers based on the ExtractType. + * Matches the logic from the Dart helpers.dart example. + * + * @param type The type of extraction needed (COLUMN_ONLY or COLUMN_IN_OPERATION). + * @param sourceColumn The JSON source column (e.g., 'data', 'NEW.data'). + * @param columns The list of column names to extract. + * @return A comma-separated string of SQL expressions. + */ +internal fun generateJsonExtracts( + type: ExtractType, + sourceColumn: String, + columns: List +): String { + // Helper function to generate the core json_extract part + fun createExtract(jsonSource: String, columnName: String): String { + // Quote the column name within the JSON path selector '$."columnName"' + return "json_extract($jsonSource, '\$.\"$columnName\"')" + } + + // Generate the SQL fragment for a single column based on the type + fun generateSingleColumnSql(columnName: String): String { + return when (type) { + ExtractType.COLUMN_ONLY -> + createExtract(sourceColumn, columnName) + + ExtractType.COLUMN_IN_OPERATION -> + // Quote the target column name in the SET clause for safety: "columnName" = ... + "\"$columnName\" = ${createExtract(sourceColumn, columnName)}" + } + } + + // Map each column to its corresponding SQL fragment and join them + return columns.joinToString(", ") { columnName -> + generateSingleColumnSql(columnName) + } +} + +/** + * Generates the SQL statements required to set up an FTS5 virtual table + * and corresponding triggers for a given PowerSync table. This function + * mirrors the logic within the Dart `createFtsMigration` function. + * + * @param tableName The public name of the table to index (e.g., "lists", "todos"). + * @param columns The list of column names within the table to include in the FTS index. + * @param schema The PowerSync Schema object to find the internal table name. + * @param tokenizationMethod The FTS5 tokenization method (e.g., 'porter unicode61', 'unicode61'). + * @return A list of SQL statements to be executed, or null if the table is not found in the schema. + */ +internal fun getFtsSetupSqlStatements( + tableName: String, + columns: List, + schema: Schema, + tokenizationMethod: String = "unicode61" +): List? { + // Find the internal name (PowerSync uses prefixed names internally) + val internalName = schema.tables.find { it.name == tableName }?.internalName + ?: run { + Logger.w { "Table '$tableName' not found in schema. Skipping FTS setup for this table." } + return null + } + + val ftsTableName = "fts_$tableName" + + // Quote column names for use in CREATE VIRTUAL TABLE definition (e.g., "name", "description") + val stringColumnsForCreate = columns.joinToString(", ") { "\"$it\"" } + // Quote column names for use in INSERT INTO statement's column list + val stringColumnsForInsertList = columns.joinToString(", ") { "\"$it\"" } + + val sqlStatements = mutableListOf() + + // --- SQL Statement Generation (Matches Dart logic) --- + + // 1. Create the FTS5 Virtual Table + // Example: CREATE VIRTUAL TABLE IF NOT EXISTS fts_lists USING fts5(id UNINDEXED, "name", tokenize='porter unicode61'); + sqlStatements.add( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS $ftsTableName + USING fts5(id UNINDEXED, $stringColumnsForCreate, tokenize='$tokenizationMethod'); + """.trimIndent() + ) + + // 2. Copy existing data from the main table to the FTS table + // Example: INSERT INTO fts_lists(rowid, id, "name") SELECT rowid, id, json_extract(data, '$."name"') FROM ps_data_lists; + sqlStatements.add( + """ + INSERT INTO $ftsTableName(rowid, id, $stringColumnsForInsertList) + SELECT rowid, id, ${generateJsonExtracts(ExtractType.COLUMN_ONLY, "data", columns)} + FROM $internalName; + """.trimIndent() + ) + + // 3. Create INSERT Trigger: Keep FTS table updated when new rows are inserted into the main table + // Example: CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_lists AFTER INSERT ON ps_data_lists BEGIN INSERT INTO fts_lists(rowid, id, "name") VALUES ( NEW.rowid, NEW.id, json_extract(NEW.data, '$."name"') ); END; + sqlStatements.add( + """ + 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(ExtractType.COLUMN_ONLY, "NEW.data", columns)} + ); + END; + """.trimIndent() + ) + + // 4. Create UPDATE Trigger: Keep FTS table updated when rows are updated in the main table + // Example: CREATE TRIGGER IF NOT EXISTS fts_update_trigger_lists AFTER UPDATE ON ps_data_lists BEGIN UPDATE fts_lists SET "name" = json_extract(NEW.data, '$."name"') WHERE rowid = NEW.rowid; END; + sqlStatements.add( + """ + CREATE TRIGGER IF NOT EXISTS fts_update_trigger_$tableName AFTER UPDATE ON $internalName + BEGIN + UPDATE $ftsTableName + SET ${generateJsonExtracts(ExtractType.COLUMN_IN_OPERATION, "NEW.data", columns)} + WHERE rowid = NEW.rowid; + END; + """.trimIndent() + ) + + // 5. Create DELETE Trigger: Keep FTS table updated when rows are deleted from the main table + // Example: CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_lists AFTER DELETE ON ps_data_lists BEGIN DELETE FROM fts_lists WHERE rowid = OLD.rowid; END; + sqlStatements.add( + """ + CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_$tableName AFTER DELETE ON $internalName + BEGIN + DELETE FROM $ftsTableName WHERE rowid = OLD.rowid; + END; + """.trimIndent() + ) + + return sqlStatements +} + + +/** + * Configures Full-Text Search (FTS) tables and triggers for specified tables + * within the PowerSync database. It generates the necessary SQL and executes it + * within a single transaction. Call this function during your database initialization. + * This function mirrors the intent of the Dart `configureFts` function. + * + * @param db The initialized PowerSyncDatabase instance. + * @param schema The PowerSync Schema instance matching the database. + */ +suspend fun configureFts(db: PowerSyncDatabase, schema: Schema) { + Logger.i { "[FTS] Starting FTS configuration..." } + val allSqlStatements = mutableListOf() + + // --- Define FTS configurations for each table --- + + // Configure FTS for the 'lists' table + getFtsSetupSqlStatements( + tableName = "lists", + columns = listOf("name"), + schema = schema, + tokenizationMethod = "porter unicode61" + )?.let { + Logger.d { "[FTS] Generated ${it.size} SQL statements for 'lists' table." } + allSqlStatements.addAll(it) + } + + // Configure FTS for the 'todos' table + getFtsSetupSqlStatements( + tableName = "todos", + columns = listOf("description", "list_id"), // Index multiple columns + schema = schema + // Uses default tokenizationMethod = "unicode61" + )?.let { + Logger.d { "[FTS] Generated ${it.size} SQL statements for 'todos' table." } + allSqlStatements.addAll(it) + } + + // --- Execute all generated SQL statements --- + + if (allSqlStatements.isNotEmpty()) { + try { + // Execute all setup statements within a single database transaction + // Using Dispatchers.Default as DB operations might be CPU-bound or offloaded by the driver + withContext(Dispatchers.Default) { // Adjust dispatcher if needed (e.g., Dispatchers.IO) + Logger.i { "[FTS] Executing ${allSqlStatements.size} SQL statements in a transaction..." } + db.writeTransaction { tx: PowerSyncTransaction -> + allSqlStatements.forEach { sql -> + // Log SQL execution - consider reducing verbosity in production + Logger.v { "[FTS] Executing SQL:\n$sql" } + tx.execute(sql) // Execute each statement + } + } + } + Logger.i { "[FTS] Configuration completed successfully." } + } catch (e: Exception) { + // Log detailed error information + Logger.e("[FTS] Error during FTS setup SQL execution: ${e.message}", throwable = e) + // Depending on requirements, you might want to re-throw, clear FTS tables, or handle differently + } + } else { + Logger.w { "[FTS] No FTS SQL statements were generated. Check table names and schema definition." } + } +} diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Schema.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Schema.kt index 9ab757e4..db3d0029 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Schema.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Schema.kt @@ -1,5 +1,8 @@ package com.powersync.demos.powersync +import com.powersync.db.SqlCursor +import com.powersync.db.getBoolean +import com.powersync.db.getStringOptional import com.powersync.db.schema.Column import com.powersync.db.schema.Index import com.powersync.db.schema.IndexedColumn @@ -51,7 +54,26 @@ data class ListItem( val name: String, val createdAt: String, val ownerId: String -) +) { + companion object { + /** + * Creates a ListItem instance from a database row represented as a SqlCursor. + * Handles necessary type casting. Assumes non-null fields based on schema. + * + * @param row A SqlCursor representing a row, typically from PowerSync's getAll. + * @return A ListItem instance. + * @throws ClassCastException if expected fields are missing or have wrong types. + */ + fun fromRow(cursor: SqlCursor): ListItem { + return ListItem( + id = cursor.getStringOptional("id") as String, + name = cursor.getStringOptional("name") as String, + createdAt = cursor.getStringOptional("created_at") as String, + ownerId = cursor.getStringOptional("owner_id") as String + ) + } + } +} data class TodoItem( val id: String, @@ -63,4 +85,34 @@ data class TodoItem( val createdBy: String?, val completedBy: String?, val completed: Boolean = false -) +) { + companion object { + /** + * Creates a TodoItem instance from a database row represented as a SqlCursor. + * Handles necessary type casting. Assumes non-null fields based on schema. + * + * @param row A SqlCursor representing a row, typically from PowerSync's getAll. + * @return A TodoItem instance. + * @throws ClassCastException if expected fields are missing or have wrong types. + */ + fun fromRow(cursor: SqlCursor): TodoItem { + return TodoItem( + id = cursor.getStringOptional("id") as String, + listId = cursor.getStringOptional("list_id") as String, + description = cursor.getStringOptional("description") as String, + completed = cursor.getBoolean("completed"), + photoId = cursor.getStringOptional("photo_id"), + createdAt = cursor.getStringOptional("created_at"), + completedAt = cursor.getStringOptional("completed_at"), + createdBy = cursor.getStringOptional("created_by"), + completedBy = cursor.getStringOptional("completed_by") + ) + } + } +} + +// Represents a unified search result item +sealed class SearchResult { + data class ListResult(val item: ListItem) : SearchResult() + data class TodoResult(val item: TodoItem) : SearchResult() +} diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt new file mode 100644 index 00000000..f8293ad9 --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt @@ -0,0 +1,130 @@ +package com.powersync.demos.powersync + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.powersync.PowerSyncDatabase +import com.powersync.db.internal.PowerSyncTransaction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(FlowPreview::class) +class SearchViewModel( + private val db: PowerSyncDatabase +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults.asStateFlow() + + private val _selectedSearchResult = MutableStateFlow(null) + val selectedSearchResult: StateFlow = _selectedSearchResult.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + viewModelScope.launch { + searchQuery + .debounce(300) + .collectLatest { query -> + executeSearch(query) + } + } + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + } + + private fun executeSearch(query: String) { + if (query.isBlank()) { + _searchResults.value = emptyList() + _isLoading.value = false + _error.value = null + return + } + + viewModelScope.launch { + _isLoading.value = true + _error.value = null + Logger.Companion.d { "[SearchViewModel] Executing FTS search for: '$query'" } + + try { + val results = withContext(Dispatchers.Default) { + searchFtsTables(query) + } + _searchResults.value = results + Logger.Companion.d { "[SearchViewModel] Found ${results.size} results." } + } catch (e: Exception) { + Logger.Companion.e("Error during FTS search: ${e.message}", throwable = e) + _error.value = "Search failed: ${e.message}" + _searchResults.value = emptyList() // Clear results on error + } finally { + _isLoading.value = false + } + } + } + + fun onSearchResultClicked(result: SearchResult) { + _selectedSearchResult.value = result + } + + fun clearState() { + Logger.d { "[SearchViewModel] Clearing state." } + _searchQuery.value = "" + _searchResults.value = emptyList() + _selectedSearchResult.value = null + _isLoading.value = false + _error.value = null + } + + private suspend fun searchFtsTables(searchTerm: String): List { + val combinedResults = mutableListOf() + val ftsSearchTerm = "$searchTerm*" + + db.readTransaction { tx: PowerSyncTransaction -> + // 1. Search FTS tables to get IDs + val listIds = tx.getAll( + "SELECT id FROM fts_$LISTS_TABLE WHERE fts_$LISTS_TABLE MATCH ?", + listOf(ftsSearchTerm), + ) { cursor -> cursor.getString(0) as String } + Logger.Companion.d { "[SearchViewModel] Found ${listIds.size} listIds." } + val todoIds = tx.getAll( + "SELECT id FROM fts_$TODOS_TABLE WHERE fts_$TODOS_TABLE MATCH ?", + listOf(ftsSearchTerm), + ) { cursor -> cursor.getString(0) as String } + Logger.Companion.d { "[SearchViewModel] Found ${todoIds.size} todoIds." } + // 2. Fetch full objects from main tables using the IDs (Handle empty ID lists) + if (listIds.isNotEmpty()) { + val placeholders = listIds.joinToString(",") { "?" } + val listItems = tx.getAll( + "SELECT * FROM $TODOS_TABLE WHERE id IN ($placeholders)", listIds + ) { cursor -> ListItem.fromRow(cursor) } + combinedResults.addAll(listItems.map { SearchResult.ListResult(it) }) + } + if (todoIds.isNotEmpty()) { + val placeholders = todoIds.joinToString(",") { "?" } + val todoItems = tx.getAll( + "SELECT * FROM $TODOS_TABLE WHERE id IN ($placeholders)", + todoIds + ) { cursor -> TodoItem.fromRow(cursor) } + combinedResults.addAll(todoItems.map { SearchResult.TodoResult(it) }) + } + } + + return combinedResults + } +} \ No newline at end of file diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Todo.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Todo.kt index 06cbf4ff..315dd14e 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Todo.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Todo.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.powersync.PowerSyncDatabase import com.powersync.db.getBoolean +import com.powersync.db.getBooleanOptional import com.powersync.db.getString import com.powersync.db.getStringOptional import kotlinx.coroutines.flow.Flow @@ -40,9 +41,9 @@ internal class Todo( description = cursor.getString("description"), createdBy = cursor.getStringOptional("created_by"), completedBy = cursor.getStringOptional("completed_by"), - completed = cursor.getBoolean( "completed"), - listId = cursor.getString("list_id"), - photoId = cursor.getStringOptional("photo_id"), + completed = cursor.getBooleanOptional( "completed") == true, + listId = cursor.getStringOptional("list_id") as String, + photoId = cursor.getStringOptional("photo_id") ?: "", ) } } diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt index c543be58..abbb0c6e 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt @@ -8,14 +8,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.powersync.demos.NavController import com.powersync.demos.Screen import com.powersync.demos.components.Input import com.powersync.demos.components.ListContent @@ -23,12 +28,14 @@ import com.powersync.demos.components.Menu import com.powersync.demos.components.WifiIcon import com.powersync.demos.powersync.ListItem import com.powersync.sync.SyncStatusData +import org.koin.compose.koinInject @Composable internal fun HomeScreen( modifier: Modifier = Modifier, items: List, inputText: String, + navController: NavController = koinInject(), syncStatus: SyncStatusData, onSignOutSelected: () -> Unit, onItemClicked: (item: ListItem) -> Unit, @@ -54,6 +61,9 @@ internal fun HomeScreen( actions = { WifiIcon(syncStatus) Spacer(modifier = Modifier.width(16.dp)) + IconButton(onClick = { navController.navigate(Screen.Search) }) { + Icon(Icons.Filled.Search, contentDescription = "Search Lists and Todos") + } }, ) diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/SearchScreen.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/SearchScreen.kt new file mode 100644 index 00000000..860bffff --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/SearchScreen.kt @@ -0,0 +1,116 @@ +package com.powersync.demos.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import com.powersync.demos.NavController +import com.powersync.demos.Screen +import com.powersync.demos.components.SearchResultItem +import com.powersync.demos.powersync.SearchResult +import com.powersync.demos.powersync.SearchViewModel +import org.koin.compose.koinInject + +@Composable +fun SearchScreen( + navController: NavController, + viewModel: SearchViewModel = koinInject(), +) { + val searchQuery by viewModel.searchQuery.collectAsState() + val searchResults by viewModel.searchResults.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val error by viewModel.error.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Search Lists & Todos") }, + navigationIcon = { + IconButton(onClick = { + viewModel.clearState() + navController.navigateBack() + }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.onSearchQueryChanged(it) }, + label = { Text("Search...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.onSearchQueryChanged("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box(modifier = Modifier.fillMaxSize()) { + when { + isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + error != null -> { + Text( + text = "Error: $error", + color = MaterialTheme.colors.error, + modifier = Modifier.align(Alignment.Center) + ) + } + searchResults.isEmpty() && searchQuery.isNotEmpty() && !isLoading -> { + Text( + text = "No results found for \"$searchQuery\"", + modifier = Modifier.align(Alignment.Center) + ) + } + searchResults.isNotEmpty() -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(searchResults, key = { result -> + when (result) { + is SearchResult.ListResult -> "list_${result.item.id}" + is SearchResult.TodoResult -> "todo_${result.item.id}" + } + }) { result -> + SearchResultItem(result) { clickedResult -> + + val listId = when (clickedResult) { + is SearchResult.ListResult -> clickedResult.item.id + is SearchResult.TodoResult -> clickedResult.item.listId + } + Logger.i { "Search item clicked, listId: $listId" } + viewModel.onSearchResultClicked(clickedResult) + navController.navigate(Screen.Todos) + } + } + } + } + } + } + } + } +} diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt index 7e383c97..472c336a 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt @@ -47,7 +47,7 @@ internal fun TodosScreen( ) }, navigationIcon = { - IconButton(onClick = { navController.navigate(Screen.Home) }) { + IconButton(onClick = { navController.navigateBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go back") } },