mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-11 16:15:24 -04:00
feat: add FTS5 full-text message search
Leverage Room 3.0.0-alpha04's FTS5 support to enable searching message history. Uses external content table backed by Packet entity. Schema changes (migration 38→39): - Add message_text column to Packet entity - Create PacketFts virtual table with FTS5 index on message_text - Content triggers auto-sync FTS index on INSERT/UPDATE/DELETE Search stack: - PacketDao: searchMessages/searchMessagesInConversation queries - DatabaseManager: backfillSearchIndexIfNeeded on DB switch - PacketRepository: searchMessages with FTS query sanitization - MessageViewModel: debounced search (300ms, min 2 chars) - MessageSearchBar: M3 TopAppBar replacement pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2
.skills/compose-ui/strings-index.txt
generated
2
.skills/compose-ui/strings-index.txt
generated
@@ -1018,6 +1018,8 @@ scanning_network
|
||||
screen_on_for
|
||||
scroll_to_bottom
|
||||
search_emoji
|
||||
search_messages
|
||||
search_result_count
|
||||
secondary
|
||||
secondary_channel_position_feature
|
||||
secondary_no_telemetry
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -139,6 +140,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||
rssi = packet.rssi,
|
||||
hopsAway = packet.hopsAway,
|
||||
filtered = filtered,
|
||||
messageText = packet.text.orEmpty(),
|
||||
)
|
||||
insertRoomPacket(packetToSave)
|
||||
}
|
||||
@@ -278,6 +280,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||
rssi = packet.rssi,
|
||||
hopsAway = packet.hopsAway,
|
||||
filtered = filtered,
|
||||
messageText = packet.text.orEmpty(),
|
||||
)
|
||||
insertRoomPacket(packetToSave)
|
||||
}
|
||||
@@ -510,6 +513,54 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
|
||||
sfpp_hash = sfppHash,
|
||||
)
|
||||
|
||||
override fun searchMessages(query: String, contactKey: String?, getNode: (String?) -> Node): Flow<List<Message>> {
|
||||
val sanitized = sanitizeFtsQuery(query)
|
||||
if (sanitized.isBlank()) return flowOf(emptyList())
|
||||
return dbManager.currentDb.flatMapLatest { db ->
|
||||
kotlinx.coroutines.flow.flow {
|
||||
val dao = db.packetDao()
|
||||
val packets =
|
||||
if (contactKey != null) {
|
||||
dao.searchMessagesInConversation(sanitized, contactKey)
|
||||
} else {
|
||||
dao.searchMessages(sanitized)
|
||||
}
|
||||
emit(
|
||||
packets.map { packet ->
|
||||
val node = getNode(packet.data.from)
|
||||
val isFromLocal =
|
||||
node.user.id == DataPacket.ID_LOCAL ||
|
||||
(packet.myNodeNum != 0 && node.num == packet.myNodeNum)
|
||||
Message(
|
||||
uuid = packet.uuid,
|
||||
receivedTime = packet.received_time,
|
||||
node = node,
|
||||
text = packet.data.text.orEmpty(),
|
||||
fromLocal = isFromLocal,
|
||||
time = org.meshtastic.core.model.util.getShortDateTime(packet.data.time),
|
||||
snr = packet.snr,
|
||||
rssi = packet.rssi,
|
||||
hopsAway = packet.hopsAway,
|
||||
read = packet.read,
|
||||
status = packet.data.status,
|
||||
routingError = packet.routingError,
|
||||
packetId = packet.packetId,
|
||||
emojis = emptyList(),
|
||||
replyId = packet.data.replyId,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a user query for FTS5 by wrapping each token in double quotes. This escapes FTS5 special characters (*,
|
||||
* -, NEAR, etc.) while still allowing multi-word searches as implicit AND queries.
|
||||
*/
|
||||
private fun sanitizeFtsQuery(query: String): String =
|
||||
query.split("\\s+".toRegex()).filter { it.isNotBlank() }.joinToString(" ") { "\"${it.replace("\"", "")}\"" }
|
||||
|
||||
companion object {
|
||||
private const val CONTACTS_PAGE_SIZE = 30
|
||||
private const val MESSAGES_PAGE_SIZE = 50
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -149,6 +149,9 @@ open class DatabaseManager(
|
||||
// One-time cleanup: remove legacy DB if present and not active
|
||||
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
|
||||
|
||||
// Backfill FTS search index for any text messages missing messageText
|
||||
managerScope.launch(dispatchers.io) { backfillSearchIndexIfNeeded(db) }
|
||||
|
||||
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
|
||||
}
|
||||
|
||||
@@ -290,6 +293,26 @@ open class DatabaseManager(
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema. Iterates packets
|
||||
* with empty messageText, extracts text from [DataPacket], and updates. Finally rebuilds the FTS index so search
|
||||
* covers historical messages.
|
||||
*/
|
||||
private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) {
|
||||
val packetDao = db.packetDao()
|
||||
val packets = packetDao.getAllUserPacketsForMigration()
|
||||
val toUpdate = packets.filter { it.messageText.isEmpty() && it.data.text != null }
|
||||
if (toUpdate.isEmpty()) return
|
||||
|
||||
Logger.i { "Backfilling FTS search index for ${toUpdate.size} messages" }
|
||||
for (packet in toUpdate) {
|
||||
val text = packet.data.text ?: continue
|
||||
packetDao.updateMessageText(packet.uuid, text)
|
||||
}
|
||||
packetDao.rebuildFtsIndex()
|
||||
Logger.i { "FTS search index backfill complete" }
|
||||
}
|
||||
|
||||
/** Closes all open databases and cancels background work. */
|
||||
fun close() {
|
||||
managerScope.cancel()
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.entity.PacketFts
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.database.entity.ReactionEntity
|
||||
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
@@ -49,6 +50,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
MyNodeEntity::class,
|
||||
NodeEntity::class,
|
||||
Packet::class,
|
||||
PacketFts::class,
|
||||
ContactSettings::class,
|
||||
MeshLog::class,
|
||||
QuickChatAction::class,
|
||||
@@ -95,8 +97,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
AutoMigration(from = 35, to = 36),
|
||||
AutoMigration(from = 36, to = 37),
|
||||
AutoMigration(from = 37, to = 38),
|
||||
AutoMigration(from = 38, to = 39),
|
||||
],
|
||||
version = 38,
|
||||
version = 39,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@@ -120,7 +123,9 @@ abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
|
||||
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
|
||||
this.fallbackToDestructiveMigration(dropAllTables = false).setQueryCoroutineContext(ioDispatcher)
|
||||
this.fallbackToDestructiveMigration(dropAllTables = false)
|
||||
.setMultipleConnectionPool(maxNumOfReaders = 4, maxNumOfWriters = 1)
|
||||
.setQueryCoroutineContext(ioDispatcher)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -547,4 +547,29 @@ interface PacketDao {
|
||||
"UPDATE packet SET filtered = :filtered WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) AND data LIKE :senderIdPattern",
|
||||
)
|
||||
suspend fun updateFilteredBySender(senderIdPattern: String, filtered: Boolean)
|
||||
|
||||
// region ── FTS5 Search ──
|
||||
|
||||
@Query(
|
||||
"SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
|
||||
"WHERE packet_fts MATCH :query AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
|
||||
"ORDER BY packet.received_time DESC LIMIT 100",
|
||||
)
|
||||
suspend fun searchMessages(query: String): List<Packet>
|
||||
|
||||
@Query(
|
||||
"SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
|
||||
"WHERE packet_fts MATCH :query AND packet.contact_key = :contactKey " +
|
||||
"AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
|
||||
"ORDER BY packet.received_time DESC LIMIT 100",
|
||||
)
|
||||
suspend fun searchMessagesInConversation(query: String, contactKey: String): List<Packet>
|
||||
|
||||
@Query("UPDATE packet SET message_text = :text WHERE uuid = :uuid")
|
||||
suspend fun updateMessageText(uuid: Long, text: String)
|
||||
|
||||
@Query("INSERT INTO packet_fts(packet_fts) VALUES('rebuild')")
|
||||
suspend fun rebuildFtsIndex()
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ data class Packet(
|
||||
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
|
||||
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
|
||||
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
|
||||
@ColumnInfo(name = "message_text", defaultValue = "") val messageText: String = "",
|
||||
) {
|
||||
companion object {
|
||||
const val RELAY_NODE_SUFFIX_MASK = 0xFF
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Fts5
|
||||
|
||||
/**
|
||||
* FTS5 virtual table that mirrors [Packet.messageText] for full-text search. Room auto-generates INSERT/UPDATE/DELETE
|
||||
* triggers to keep this table in sync with the content entity ([Packet]).
|
||||
*/
|
||||
@Fts5(contentEntity = Packet::class)
|
||||
@Entity(tableName = "packet_fts")
|
||||
data class PacketFts(@ColumnInfo(name = "message_text") val messageText: String)
|
||||
@@ -216,4 +216,14 @@ interface PacketRepository {
|
||||
|
||||
/** Updates the SFPP status of packets matching the given commit hash. */
|
||||
suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long)
|
||||
|
||||
/**
|
||||
* Searches message history using full-text search.
|
||||
*
|
||||
* @param query The search text (will be sanitized for FTS5).
|
||||
* @param contactKey Optional contact key to scope search to a single conversation.
|
||||
* @param getNode Function to resolve node info by userId.
|
||||
* @return Flow emitting matching messages.
|
||||
*/
|
||||
fun searchMessages(query: String, contactKey: String? = null, getNode: (String?) -> Node): Flow<List<Message>>
|
||||
}
|
||||
|
||||
@@ -1060,6 +1060,11 @@
|
||||
<string name="screen_on_for">Screen on for</string>
|
||||
<string name="scroll_to_bottom">Scroll to bottom</string>
|
||||
<string name="search_emoji">Search emoji...</string>
|
||||
<string name="search_messages">Search messages…</string>
|
||||
<plurals name="search_result_count">
|
||||
<item quantity="one">%1$d result</item>
|
||||
<item quantity="other">%1$d results</item>
|
||||
</plurals>
|
||||
<string name="secondary">Secondary</string>
|
||||
<string name="secondary_channel_position_feature">Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required.</string>
|
||||
<string name="secondary_no_telemetry">No periodic telemetry broadcast</string>
|
||||
|
||||
@@ -90,6 +90,7 @@ import org.meshtastic.feature.messaging.component.ActionModeTopBar
|
||||
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
|
||||
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
|
||||
import org.meshtastic.feature.messaging.component.MessageMenuAction
|
||||
import org.meshtastic.feature.messaging.component.MessageSearchBar
|
||||
import org.meshtastic.feature.messaging.component.MessageTopBar
|
||||
import org.meshtastic.feature.messaging.component.QuickChatRow
|
||||
import org.meshtastic.feature.messaging.component.ReplySnippet
|
||||
@@ -143,6 +144,9 @@ fun MessageScreen(
|
||||
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
|
||||
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
|
||||
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
|
||||
val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||
|
||||
// Prevent the message TextField from stealing focus when the screen opens
|
||||
LaunchedEffect(contactKey) { focusManager.clearFocus() }
|
||||
@@ -317,6 +321,13 @@ fun MessageScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
} else if (isSearchActive) {
|
||||
MessageSearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::setSearchQuery,
|
||||
onClose = viewModel::closeSearch,
|
||||
resultCount = searchResults.size,
|
||||
)
|
||||
} else {
|
||||
MessageTopBar(
|
||||
title = title,
|
||||
@@ -336,6 +347,7 @@ fun MessageScreen(
|
||||
showFiltered = showFiltered,
|
||||
onToggleShowFiltered = viewModel::toggleShowFiltered,
|
||||
onNavigateToFilterSettings = navigateToFilterSettings,
|
||||
onSearchClick = viewModel::toggleSearch,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
@@ -143,6 +145,43 @@ class MessageViewModel(
|
||||
.flatMapLatest { packetRepository.getFilteredCountFlow(it) }
|
||||
.stateInWhileSubscribed(0)
|
||||
|
||||
// region ── Search ──
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _isSearchActive = MutableStateFlow(false)
|
||||
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
|
||||
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
val searchResults: StateFlow<List<Message>> =
|
||||
combine(_searchQuery, contactKeyForPagedMessages) { query, contactKey -> query to contactKey }
|
||||
.debounce(SEARCH_DEBOUNCE_MS)
|
||||
.flatMapLatest { (query, contactKey) ->
|
||||
if (query.length < MIN_SEARCH_LENGTH) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
packetRepository.searchMessages(query, contactKey, ::getNode)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(emptyList())
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun toggleSearch() {
|
||||
_isSearchActive.value = !_isSearchActive.value
|
||||
if (!_isSearchActive.value) _searchQuery.value = ""
|
||||
}
|
||||
|
||||
fun closeSearch() {
|
||||
_isSearchActive.value = false
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
init {
|
||||
val contactKey = savedStateHandle.get<String>("contactKey")
|
||||
if (contactKey != null) {
|
||||
@@ -234,4 +273,9 @@ class MessageViewModel(
|
||||
val unreadCount = packetRepository.getUnreadCount(contact)
|
||||
if (unreadCount == 0) notificationManager.cancel(contact.hashCode())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SEARCH_DEBOUNCE_MS = 300L
|
||||
private const val MIN_SEARCH_LENGTH = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.alert_bell_text
|
||||
import org.meshtastic.core.resources.cancel_reply
|
||||
import org.meshtastic.core.resources.clear
|
||||
import org.meshtastic.core.resources.clear_selection
|
||||
import org.meshtastic.core.resources.copy
|
||||
import org.meshtastic.core.resources.delete
|
||||
@@ -85,6 +86,8 @@ import org.meshtastic.core.resources.quick_chat_show
|
||||
import org.meshtastic.core.resources.reply
|
||||
import org.meshtastic.core.resources.replying_to
|
||||
import org.meshtastic.core.resources.scroll_to_bottom
|
||||
import org.meshtastic.core.resources.search_messages
|
||||
import org.meshtastic.core.resources.search_result_count
|
||||
import org.meshtastic.core.resources.select_all
|
||||
import org.meshtastic.core.resources.unknown
|
||||
import org.meshtastic.core.ui.component.MeshtasticTextDialog
|
||||
@@ -102,6 +105,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.More
|
||||
import org.meshtastic.core.ui.icon.Muted
|
||||
import org.meshtastic.core.ui.icon.Reply
|
||||
import org.meshtastic.core.ui.icon.Search
|
||||
import org.meshtastic.core.ui.icon.SelectAll
|
||||
import org.meshtastic.core.ui.icon.Settings
|
||||
import org.meshtastic.core.ui.icon.Unmuted
|
||||
@@ -299,6 +303,7 @@ fun MessageTopBar(
|
||||
showFiltered: Boolean = false,
|
||||
onToggleShowFiltered: () -> Unit = {},
|
||||
onNavigateToFilterSettings: () -> Unit = {},
|
||||
onSearchClick: () -> Unit = {},
|
||||
) = TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -319,6 +324,12 @@ fun MessageTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSearchClick) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Search,
|
||||
contentDescription = stringResource(Res.string.search_messages),
|
||||
)
|
||||
}
|
||||
MessageTopBarActions(
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = onToggleQuickChat,
|
||||
@@ -642,3 +653,68 @@ fun String.limitBytes(maxBytes: Int): String {
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ── MessageSearchBar ──
|
||||
|
||||
/**
|
||||
* M3 contextual search bar that replaces the standard MessageTopBar when search is active. Follows the M3 "find in
|
||||
* page" pattern: back arrow + text field + result count + clear.
|
||||
*
|
||||
* This uses [TopAppBar] rather than [SearchBar] because we're filtering within an existing conversation (contextual
|
||||
* search), not performing primary app-level navigation search.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
resultCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.navigate_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
androidx.compose.material3.TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(text = stringResource(Res.string.search_messages), style = MaterialTheme.typography.bodyLarge)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
colors =
|
||||
androidx.compose.material3.TextFieldDefaults.colors(
|
||||
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
if (query.isNotEmpty()) {
|
||||
Text(
|
||||
text = pluralStringResource(Res.plurals.search_result_count, resultCount, resultCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
Reference in New Issue
Block a user