From 4ee08c9f65dc867bc262c4472a2a31e890a3342b Mon Sep 17 00:00:00 2001 From: Wade Date: Tue, 8 Apr 2025 20:45:14 +0200 Subject: [PATCH 1/3] Add Full-Text Search (FTS) setup and search functionality - Introduced FtsSetup.kt for configuring FTS5 with PowerSync, including SQL generation for virtual tables and triggers. - Added SearchScreen.kt for user interface to perform searches on lists and todos. - Implemented SearchViewModel.kt to manage search logic and state, utilizing FTS for efficient querying. - Created SearchResult.kt to represent unified search results for lists and todos. --- .../com/powersync/demos/fts/FtsSetup.kt | 21 +++ .../powersync/demos/screens/SearchScreen.kt | 149 ++++++++++++++++++ .../powersync/demos/search/SearchResult.kt | 0 .../powersync/demos/search/SearchViewModel.kt | 0 4 files changed, 170 insertions(+) create mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt create mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/SearchScreen.kt create mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt create mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt 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..d2ce9ba7 --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt @@ -0,0 +1,21 @@ +/** + * 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.WriteTransaction +import com.powersync.db.schema.Schema +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class `FtsSetup.kt` { +} \ No newline at end of file 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..0ce990e8 --- /dev/null +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/SearchScreen.kt @@ -0,0 +1,149 @@ +package com.powersync.demos.search + +import androidx.compose.foundation.clickable +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 org.koin.compose.koinInject // Use koinInject or specific platform injection + +@Composable +fun SearchScreen( + navController: NavController, // Inject or pass NavController + viewModel: SearchViewModel = koinInject() // Inject the ViewModel +) { + 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 = { navController.navigateBack() }) { // Or specific back navigation + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + // --- Search Input Field --- + 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)) + + // --- Results Area --- + 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 -> // Provide stable keys + when (result) { + is SearchResult.ListResult -> "list_${result.item.id}" + is SearchResult.TodoResult -> "todo_${result.item.id}" + } + }) { result -> + SearchResultItem(result) { clickedResult -> + // --- Handle Click --- + // Example: Navigate to the list containing the todo or the list itself + val listId = when (clickedResult) { + is SearchResult.ListResult -> clickedResult.item.id + is SearchResult.TodoResult -> clickedResult.item.listId + } + Logger.i { "Search item clicked, listId: $listId" } + // navController.navigateTo(Screen.TodoList(listId)) // Adapt to your navigation + } + } + } + } + // Implicit else: Initial state (empty query, no results, not loading) - show nothing or a prompt + } + } + } + } +} + +// --- Composable for a single search result item --- +@Composable +fun SearchResultItem( + result: SearchResult, + onClick: (SearchResult) -> Unit +) { + // Basic card implementation - customize as needed + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(result) }, + elevation = 2.dp + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Example Icon - could be different per type + // Icon(...) + // Spacer(modifier = Modifier.width(8.dp)) + Column { + when (result) { + is SearchResult.ListResult -> { + Text(result.item.name, style = MaterialTheme.typography.h6) + Text("List", style = MaterialTheme.typography.caption) + } + is SearchResult.TodoResult -> { + Text(result.item.description, style = MaterialTheme.typography.body1) + Text("Todo Item", style = MaterialTheme.typography.caption) + } + } + } + } + } +} \ No newline at end of file diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt new file mode 100644 index 00000000..e69de29b diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt new file mode 100644 index 00000000..e69de29b From a1dbe9cfb7e1e547f3bc3c24a6acf654c0b0d5e8 Mon Sep 17 00:00:00 2001 From: Wade Date: Tue, 8 Apr 2025 20:46:11 +0200 Subject: [PATCH 2/3] Refactor navigation and enhance search functionality - Changed internalName property in Table.kt to public for better access. - Updated NavController to manage back navigation and prevent duplicate screen entries. - Integrated SearchViewModel to handle search queries and results. - Added SearchScreen for user interaction with search functionality. - Enhanced HomeScreen with a search button to navigate to the SearchScreen. - Implemented FTS setup for lists and todos, allowing efficient search capabilities. --- .../kotlin/com/powersync/db/schema/Table.kt | 2 +- .../iosApp/iosApp.xcodeproj/project.pbxproj | 41 +--- .../kotlin/com/powersync/demos/App.kt | 20 ++ .../com/powersync/demos/NavController.kt | 38 ++- .../com/powersync/demos/fts/FtsSetup.kt | 216 +++++++++++++++++- .../com/powersync/demos/powersync/Schema.kt | 52 ++++- .../com/powersync/demos/powersync/Todo.kt | 7 +- .../com/powersync/demos/screens/HomeScreen.kt | 10 + .../powersync/demos/screens/SearchScreen.kt | 6 +- .../powersync/demos/search/SearchResult.kt | 10 + .../powersync/demos/search/SearchViewModel.kt | 116 ++++++++++ 11 files changed, 467 insertions(+), 51 deletions(-) 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..607a9a41 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,12 +11,14 @@ 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.Todo @@ -25,6 +27,8 @@ 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.search.SearchViewModel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.runBlocking import org.koin.compose.KoinApplication @@ -50,6 +54,7 @@ val sharedAppModule = module { single { NavController(Screen.Home) } viewModelOf(::AuthViewModel) + viewModelOf(::SearchViewModel) } @Composable @@ -71,6 +76,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 +97,7 @@ fun AppContent( } val authViewModel = koinViewModel() + val searchViewModel = koinViewModel() val navController = koinInject() val authState by authViewModel.authState.collectAsState() val currentScreen by navController.currentScreen.collectAsState() @@ -166,6 +178,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/fts/FtsSetup.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/fts/FtsSetup.kt index d2ce9ba7..47804d69 100644 --- 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 @@ -12,10 +12,220 @@ package com.powersync.demos.fts import com.powersync.PowerSyncDatabase -import com.powersync.db.WriteTransaction 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 -class `FtsSetup.kt` { -} \ No newline at end of file +/** + * 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..0f372aca 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,30 @@ 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") + ) + } + } +} + + 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 index 0ce990e8..eed17551 100644 --- 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 @@ -1,4 +1,4 @@ -package com.powersync.demos.search +package com.powersync.demos.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -14,6 +14,8 @@ 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.search.SearchResult +import com.powersync.demos.search.SearchViewModel import org.koin.compose.koinInject // Use koinInject or specific platform injection @Composable @@ -44,7 +46,6 @@ fun SearchScreen( .padding(paddingValues) .padding(16.dp) ) { - // --- Search Input Field --- OutlinedTextField( value = searchQuery, onValueChange = { viewModel.onSearchQueryChanged(it) }, @@ -62,7 +63,6 @@ fun SearchScreen( Spacer(modifier = Modifier.height(16.dp)) - // --- Results Area --- Box(modifier = Modifier.fillMaxSize()) { when { isLoading -> { diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt index e69de29b..d7289dff 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt @@ -0,0 +1,10 @@ +package com.powersync.demos.search + +import com.powersync.demos.powersync.ListItem +import com.powersync.demos.powersync.TodoItem + +// Represents a unified search result item +sealed class SearchResult { + data class ListResult(val item: ListItem) : SearchResult() + data class TodoResult(val item: TodoItem) : SearchResult() +} \ No newline at end of file diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt index e69de29b..032089da 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt @@ -0,0 +1,116 @@ +package com.powersync.demos.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.powersync.PowerSyncDatabase +import com.powersync.db.internal.PowerSyncTransaction +import com.powersync.demos.powersync.LISTS_TABLE +import com.powersync.demos.powersync.ListItem +import com.powersync.demos.powersync.TODOS_TABLE +import com.powersync.demos.powersync.TodoItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +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 _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + viewModelScope.launch { + searchQuery + .debounce(300) // Wait for 300ms of silence before triggering search + .collectLatest { query -> // Use collectLatest to cancel previous searches if query changes quickly + 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.d { "[SearchViewModel] Executing FTS search for: '$query'" } + + try { + val results = withContext(Dispatchers.Default) { + searchFtsTables(query) + } + _searchResults.value = results + Logger.d { "[SearchViewModel] Found ${results.size} results." } + } catch (e: Exception) { + Logger.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 + } + } + } + + // --- FTS Query Logic --- + private suspend fun searchFtsTables(searchTerm: String): List { + val combinedResults = mutableListOf() + val ftsSearchTerm = "$searchTerm*" // Add wildcard for prefix matching + + 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 } + + val todoIds = tx.getAll( + "SELECT id FROM fts_$TODOS_TABLE WHERE fts_$TODOS_TABLE MATCH ?", + listOf(ftsSearchTerm), + ) { cursor -> cursor.getString(0) as String } + + // 2. Fetch full objects from main tables using the IDs (Handle empty ID lists) + if (listIds.isNotEmpty()) { + // Construct query like: SELECT * FROM lists WHERE id IN (?, ?, ...) + 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 From 4b8cc26cfa73c1d21e8a4249281f5ccbfbe5a0a2 Mon Sep 17 00:00:00 2001 From: Wade Date: Wed, 9 Apr 2025 10:42:45 +0200 Subject: [PATCH 3/3] Implement search result handling and UI components - Added SearchResultItem composable for displaying search results. - Introduced SearchViewModel to manage search state and results. - Updated App.kt to integrate search results into the TodosScreen. - Refactored SearchScreen to utilize new SearchResultItem and handle search result clicks. - Removed deprecated SearchResult.kt file and consolidated search result logic. --- .../kotlin/com/powersync/demos/App.kt | 16 ++++- .../demos/components/SearchResultItem.kt | 71 +++++++++++++++++++ .../com/powersync/demos/powersync/Schema.kt | 6 +- .../{search => powersync}/SearchViewModel.kt | 46 +++++++----- .../powersync/demos/screens/SearchScreen.kt | 63 ++++------------ .../powersync/demos/screens/TodosScreen.kt | 2 +- .../powersync/demos/search/SearchResult.kt | 10 --- 7 files changed, 136 insertions(+), 78 deletions(-) create mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/SearchResultItem.kt rename demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/{search => powersync}/SearchViewModel.kt (72%) delete mode 100644 demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt 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 607a9a41..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 @@ -21,6 +21,9 @@ 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 @@ -28,7 +31,7 @@ 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.search.SearchViewModel +import com.powersync.demos.powersync.SearchViewModel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.runBlocking import org.koin.compose.KoinApplication @@ -120,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() @@ -151,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( 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/powersync/Schema.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/Schema.kt index 0f372aca..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 @@ -111,4 +111,8 @@ data class TodoItem( } } - +// 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/search/SearchViewModel.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt similarity index 72% rename from demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt rename to demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt index 032089da..f8293ad9 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchViewModel.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/powersync/SearchViewModel.kt @@ -1,17 +1,17 @@ -package com.powersync.demos.search +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 com.powersync.demos.powersync.LISTS_TABLE -import com.powersync.demos.powersync.ListItem -import com.powersync.demos.powersync.TODOS_TABLE -import com.powersync.demos.powersync.TodoItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.* +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 @@ -26,6 +26,9 @@ class SearchViewModel( 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() @@ -35,8 +38,8 @@ class SearchViewModel( init { viewModelScope.launch { searchQuery - .debounce(300) // Wait for 300ms of silence before triggering search - .collectLatest { query -> // Use collectLatest to cancel previous searches if query changes quickly + .debounce(300) + .collectLatest { query -> executeSearch(query) } } @@ -57,16 +60,16 @@ class SearchViewModel( viewModelScope.launch { _isLoading.value = true _error.value = null - Logger.d { "[SearchViewModel] Executing FTS search for: '$query'" } + Logger.Companion.d { "[SearchViewModel] Executing FTS search for: '$query'" } try { val results = withContext(Dispatchers.Default) { searchFtsTables(query) } _searchResults.value = results - Logger.d { "[SearchViewModel] Found ${results.size} results." } + Logger.Companion.d { "[SearchViewModel] Found ${results.size} results." } } catch (e: Exception) { - Logger.e("Error during FTS search: ${e.message}", throwable = e) + 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 { @@ -75,10 +78,22 @@ class SearchViewModel( } } - // --- FTS Query Logic --- + 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*" // Add wildcard for prefix matching + val ftsSearchTerm = "$searchTerm*" db.readTransaction { tx: PowerSyncTransaction -> // 1. Search FTS tables to get IDs @@ -86,15 +101,14 @@ class SearchViewModel( "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()) { - // Construct query like: SELECT * FROM lists WHERE id IN (?, ?, ...) val placeholders = listIds.joinToString(",") { "?" } val listItems = tx.getAll( "SELECT * FROM $TODOS_TABLE WHERE id IN ($placeholders)", listIds 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 index eed17551..860bffff 100644 --- 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 @@ -1,6 +1,5 @@ package com.powersync.demos.screens -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -14,14 +13,16 @@ 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.search.SearchResult -import com.powersync.demos.search.SearchViewModel -import org.koin.compose.koinInject // Use koinInject or specific platform injection +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, // Inject or pass NavController - viewModel: SearchViewModel = koinInject() // Inject the ViewModel + navController: NavController, + viewModel: SearchViewModel = koinInject(), ) { val searchQuery by viewModel.searchQuery.collectAsState() val searchResults by viewModel.searchResults.collectAsState() @@ -33,7 +34,10 @@ fun SearchScreen( TopAppBar( title = { Text("Search Lists & Todos") }, navigationIcon = { - IconButton(onClick = { navController.navigateBack() }) { // Or specific back navigation + IconButton(onClick = { + viewModel.clearState() + navController.navigateBack() + }) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } } @@ -86,64 +90,27 @@ fun SearchScreen( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(searchResults, key = { result -> // Provide stable keys + items(searchResults, key = { result -> when (result) { is SearchResult.ListResult -> "list_${result.item.id}" is SearchResult.TodoResult -> "todo_${result.item.id}" } }) { result -> SearchResultItem(result) { clickedResult -> - // --- Handle Click --- - // Example: Navigate to the list containing the todo or the list itself + val listId = when (clickedResult) { is SearchResult.ListResult -> clickedResult.item.id is SearchResult.TodoResult -> clickedResult.item.listId } Logger.i { "Search item clicked, listId: $listId" } - // navController.navigateTo(Screen.TodoList(listId)) // Adapt to your navigation + viewModel.onSearchResultClicked(clickedResult) + navController.navigate(Screen.Todos) } } } } - // Implicit else: Initial state (empty query, no results, not loading) - show nothing or a prompt } } } } } - -// --- Composable for a single search result item --- -@Composable -fun SearchResultItem( - result: SearchResult, - onClick: (SearchResult) -> Unit -) { - // Basic card implementation - customize as needed - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick(result) }, - elevation = 2.dp - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Example Icon - could be different per type - // Icon(...) - // Spacer(modifier = Modifier.width(8.dp)) - Column { - when (result) { - is SearchResult.ListResult -> { - Text(result.item.name, style = MaterialTheme.typography.h6) - Text("List", style = MaterialTheme.typography.caption) - } - is SearchResult.TodoResult -> { - Text(result.item.description, style = MaterialTheme.typography.body1) - Text("Todo Item", style = MaterialTheme.typography.caption) - } - } - } - } - } -} \ No newline at end of file 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") } }, diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt deleted file mode 100644 index d7289dff..00000000 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/search/SearchResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.powersync.demos.search - -import com.powersync.demos.powersync.ListItem -import com.powersync.demos.powersync.TodoItem - -// Represents a unified search result item -sealed class SearchResult { - data class ListResult(val item: ListItem) : SearchResult() - data class TodoResult(val item: TodoItem) : SearchResult() -} \ No newline at end of file