How the Search Feature Implements History and Suggestions in GSYGitHubAppCompose

The GSYGitHubAppCompose search feature persists the last 10 queries in a Room database, exposes them as reactive state through a ViewModel, and renders them as clickable suggestions when the search field is focused and empty.

The open-source GSYGitHubAppCompose project demonstrates a modern Android search implementation using Jetpack Compose and Room. This article breaks down exactly how the search feature implements history and suggestions across the data, domain, and UI layers, referencing the actual Kotlin source code from the repository.

Data Layer: Room Database for Persistent Storage

The persistence layer uses Room to store search terms locally, enforcing a maximum of 10 entries to keep storage bounded.

Entity Definition

The SearchHistoryEntity in core/database/src/main/java/com/shuyu/gsygithubappcompose/core/database/entity/SearchHistoryEntity.kt defines a simple table structure where the query string serves as the primary key and a timestamp tracks recency.

@Entity(tableName = "search_history")
data class SearchHistoryEntity(
    @PrimaryKey val query: String,
    val timestamp: Long
)

Data Access Object

The SearchHistoryDao in core/database/src/main/java/com/shuyu/gsygithubappcompose/core/database/dao/SearchHistoryDao.kt provides three critical operations: insert with conflict replacement, retrieval of the latest 10 entries, and cleanup of older records.

@Dao
interface SearchHistoryDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertSearchHistory(searchHistoryEntity: SearchHistoryEntity)

    @Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT 10")
    fun getSearchHistory(): Flow<List<SearchHistoryEntity>>

    @Query("DELETE FROM search_history WHERE query NOT IN (SELECT query FROM search_history ORDER BY timestamp DESC LIMIT 10)")
    suspend fun deleteOldSearchHistory()
}

Repository Wrapper

The SearchHistoryRepository in data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/SearchHistoryRepository.kt exposes a clean API for the ViewModel, handling the transaction of inserting a new query and immediately pruning excess entries.

fun getSearchHistory(): Flow<List<SearchHistoryEntity>> = searchHistoryDao.getSearchHistory()

suspend fun saveSearchQuery(query: String) {
    val entity = SearchHistoryEntity(query = query, timestamp = System.currentTimeMillis())
    searchHistoryDao.insertSearchHistory(entity)
    searchHistoryDao.deleteOldSearchHistory()
}

Domain Layer: ViewModel State Management

The SearchViewModel in feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchViewModel.kt bridges the data layer and UI, exposing search history as a StateFlow that survives configuration changes.

Collecting History on Initialization

The ViewModel subscribes to the repository's Flow in its init block, converting the database stream into observable state.

init {
    searchHistoryRepository.getSearchHistory()
        .onEach { _searchHistory.value = it }
        .launchIn(viewModelScope)
}

Saving Queries After Successful Searches

To avoid polluting history with incomplete or failed attempts, the ViewModel only persists queries after a first-page search succeeds. The saveSearchQuery method launches a coroutine to handle the database write asynchronously.

if (page == 1) {
    saveSearchQuery(_searchQuery.value)
}

private fun saveSearchQuery(query: String) {
    viewModelScope.launch {
        searchHistoryRepository.saveSearchQuery(query)
    }
}

UI Layer: Jetpack Compose Implementation

The SearchScreen in feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchScreen.kt conditionally renders the history list based on user focus state and input content.

Conditional Suggestion Display

The UI shows a LazyColumn of suggestions only when three conditions are met: the search field has focus, the query text is blank, and history items exist.

if (isSearchFieldFocused && searchQuery.isBlank() && searchHistory.isNotEmpty()) {
    LazyColumn {
        items(searchHistory) { historyItem ->
            Text(
                text = historyItem.query,
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        searchViewModel.onSearchQueryChanged(historyItem.query)
                        searchViewModel.performSearch()
                        focusManager.clearFocus()
                    }
                    .padding(8.dp)
            )
            HorizontalDivider()
        }
    }
}

Handling Suggestion Clicks

When a user taps a history item, the app populates the search field, executes the search, and clears focus to dismiss the suggestion list and reveal results.

Summary

  • Room database stores queries with timestamps, using query as the primary key to automatically update existing entries via OnConflictStrategy.REPLACE.
  • DAO limits history to 10 items through SQL LIMIT 10 clauses in both the select query and the cleanup delete statement.
  • ViewModel exposes history as a StateFlow collected in the init block, ensuring the UI always reflects current data.
  • Persistence timing occurs only after successful first-page searches, preventing storage of typo-ridden or abandoned queries.
  • Compose UI displays suggestions only when the input is focused and empty, optimizing screen real estate and user flow.

Frequently Asked Questions

How does the app limit search history to 10 items?

The SearchHistoryDao enforces this limit at the database level. The getSearchHistory() method uses LIMIT 10 in its SQL query, while deleteOldSearchHistory() removes any rows not present in the top 10 most recent entries. This ensures the database never stores more than 10 records regardless of UI state.

When exactly does a search query get saved to history?

The SearchViewModel saves a query only after the first page of search results returns successfully. Inside the search execution logic, the code checks if (page == 1) before calling saveSearchQuery(), ensuring that pagination requests or failed initial searches do not create history entries.

What triggers the display of search suggestions on screen?

Three conditions must be satisfied simultaneously: the search text field must have focus (isSearchFieldFocused), the query string must be blank (searchQuery.isBlank()), and the history list must contain at least one item (searchHistory.isNotEmpty()). When these align, the LazyColumn renders the suggestion list.

Why does the DAO use OnConflictStrategy.REPLACE for inserts?

This strategy ensures that searching for an existing term updates its timestamp rather than creating a duplicate entry. Since query is the primary key, attempting to insert an existing query triggers the replace operation, effectively moving that term to the top of the history list without manual deletion logic.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →