Implement recent searches

- Add Repository and Dao for recent searches
- Add recent searches body in the Search UI
- Add relevant tests
recent_search
Takeshi Hagikura 1 year ago
parent 55483e82df
commit 63cd8901fb

@ -18,10 +18,12 @@ package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
@ -52,6 +54,11 @@ interface TestDataModule {
userDataRepository: FakeUserDataRepository,
): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: FakeRecentSearchRepository,
): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: FakeSearchContentsRepository,

@ -16,11 +16,13 @@
package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
@ -50,6 +52,11 @@ interface DataModule {
userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: DefaultRecentSearchRepository,
): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository,

@ -0,0 +1,31 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class RecentSearchQuery(
val query: String,
val queriedDate: Instant = Clock.System.now(),
)
fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery(
query = query,
queriedDate = queriedDate,
)

@ -0,0 +1,26 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
/** */
data class SearchResult(
val topics: List<Topic> = emptyList(),
val newsResources: List<NewsResource> = emptyList(),
)

@ -0,0 +1,56 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import javax.inject.Inject
class DefaultRecentSearchRepository @Inject constructor(
private val recentSearchQueryDao: RecentSearchQueryDao,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {
withContext(ioDispatcher) {
recentSearchQueryDao.insertOrReplaceRecentSearchQuery(
RecentSearchQueryEntity(
query = searchQuery,
queriedDate = Clock.System.now(),
),
)
}
}
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> {
return recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map {
it.asExternalModel()
}
}
}
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()
}

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
@ -29,7 +30,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import javax.inject.Inject

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import kotlinx.coroutines.flow.Flow
/**
* Data layer interface for the recent searches.
*/
interface RecentSearchRepository {
/**
* Get the recent search queries up to the number of queries specified as [limit].
*/
fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>>
/**
* Insert or replace the [searchQuery] as part of the recent searches.
*/
suspend fun insertOrReplaceRecentSearch(searchQuery: String)
/**
* Clear the recent searches.
*/
suspend fun clearRecentSearches()
}

@ -16,8 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import kotlinx.coroutines.flow.Flow
/**
@ -35,8 +34,3 @@ interface SearchContentsRepository {
*/
fun searchContents(searchQuery: String): Flow<SearchResult>
}
data class SearchResult(
val topics: List<Topic> = emptyList(),
val newsResources: List<NewsResource> = emptyList(),
)

@ -0,0 +1,35 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* Fake implementation of the [RecentSearchRepository]
*/
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ }
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
flowOf(emptyList())
override suspend fun clearRecentSearches() { /* no-op */ }
}

@ -16,9 +16,10 @@
package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
@ -26,8 +27,6 @@ import javax.inject.Inject
*/
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() {}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
TODO("Not yet implemented")
}
override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
}

@ -0,0 +1,308 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "51271b81bde7c7997d67fb23c8f31780",
"entities": [
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "headerImageUrl",
"columnName": "header_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"news_resource_id",
"topic_id"
]
},
"indices": [
{
"name": "index_news_resources_topics_news_resource_id",
"unique": false,
"columnNames": [
"news_resource_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
},
{
"name": "index_news_resources_topics_topic_id",
"unique": false,
"columnNames": [
"topic_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)"
}
],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "topics",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"topic_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "newsResourcesFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "newsResourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "imageUrl",
"columnName": "imageUrl",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "topicsFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recentSearchQueries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "queriedDate",
"columnName": "queriedDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '51271b81bde7c7997d67fb23c8f31780')"
]
}
}

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.database
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import dagger.Module
@ -47,4 +48,9 @@ object DaosModule {
fun providesNewsResourceFtsDao(
database: NiaDatabase,
): NewsResourceFtsDao = database.newsResourceFtsDao()
@Provides
fun providesRecentSearchQueryDao(
database: NiaDatabase,
): RecentSearchQueryDao = database.recentSearchQueryDao()
}

@ -22,11 +22,13 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
@ -39,8 +41,9 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
NewsResourceFtsEntity::class,
TopicEntity::class,
TopicFtsEntity::class,
RecentSearchQueryEntity::class,
],
version = 13,
version = 14,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
@ -54,6 +57,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14),
],
exportSchema = true,
)
@ -66,4 +70,5 @@ abstract class NiaDatabase : RoomDatabase() {
abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao
abstract fun newsResourceFtsDao(): NewsResourceFtsDao
abstract fun recentSearchQueryDao(): RecentSearchQueryDao
}

@ -0,0 +1,38 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [RecentSearchQueryEntity] access
*/
@Dao
interface RecentSearchQueryDao {
@Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit")
fun getRecentSearchQueryEntities(limit: Int): Flow<List<RecentSearchQueryEntity>>
@Upsert
suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity)
@Query(value = "DELETE FROM recentSearchQueries")
suspend fun clearRecentSearchQueries()
}

@ -0,0 +1,35 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Defines an database entity that stored recent search queries.
*/
@Entity(
tableName = "recentSearchQueries",
)
data class RecentSearchQueryEntity(
@PrimaryKey
val query: String,
@ColumnInfo
val queriedDate: Instant,
)

@ -0,0 +1,32 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* A use case which returns the recent search queries.
*/
class GetRecentSearchQueriesUseCase @Inject constructor(
private val recentSearchRepository: RecentSearchRepository,
) {
operator fun invoke(limit: Int = 10): Flow<List<RecentSearchQuery>> =
recentSearchRepository.getRecentSearchQueries(limit)
}

@ -16,11 +16,12 @@
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserSearchResult
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -41,7 +42,7 @@ class GetSearchContentsUseCase @Inject constructor(
.mapToUserSearchResult(userDataRepository.userData)
}
fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =
private fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =
combine(userDataStream) { searchResult, userData ->
UserSearchResult(
topics = searchResult.topics.map { topic ->
@ -58,8 +59,3 @@ fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Fl
},
)
}
data class UserSearchResult(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
)

@ -0,0 +1,28 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
/**
* An entity of [SearchResult] with additional user information such as whether the user is
* following a topic.
*/
data class UserSearchResult(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
)

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class TestRecentSearchRepository : RecentSearchRepository {
private val cachedRecentSearches: MutableList<RecentSearchQuery> = mutableListOf()
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> {
return flow {
emit(cachedRecentSearches.sortedByDescending { it.queriedDate }.take(limit))
}
}
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {
cachedRecentSearches.add(RecentSearchQuery(searchQuery))
}
override suspend fun clearRecentSearches() {
cachedRecentSearches.clear()
}
}

@ -16,8 +16,8 @@
package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow
@ -28,23 +28,21 @@ class TestSearchContentsRepository : SearchContentsRepository {
private val cachedTopics: MutableList<Topic> = mutableListOf()
private val cachedNewsResources: MutableList<NewsResource> = mutableListOf()
override suspend fun populateFtsData() {}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
return flowOf(
SearchResult(
topics = cachedTopics.filter {
it.name.contains(searchQuery) ||
it.shortDescription.contains(searchQuery) ||
it.longDescription.contains(searchQuery)
},
newsResources = cachedNewsResources.filter {
it.content.contains(searchQuery) ||
it.title.contains(searchQuery)
},
),
)
}
override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf(
SearchResult(
topics = cachedTopics.filter {
it.name.contains(searchQuery) ||
it.shortDescription.contains(searchQuery) ||
it.longDescription.contains(searchQuery)
},
newsResources = cachedNewsResources.filter {
it.content.contains(searchQuery) ||
it.title.contains(searchQuery)
},
),
)
/**
* Test only method to add the topics to the stored list in memory

@ -51,6 +51,7 @@ fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
onExpandedCardClick: () -> Unit = {},
) {
when (feedState) {
NewsFeedUiState.Loading -> Unit
@ -67,6 +68,7 @@ fun LazyGridScope.newsFeed(
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onClick = {
onExpandedCardClick()
analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title,

@ -23,8 +23,9 @@ import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onParent
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
@ -47,6 +48,7 @@ class SearchScreenTest {
private lateinit var clearSearchContentDesc: String
private lateinit var followButtonContentDesc: String
private lateinit var unfollowButtonContentDesc: String
private lateinit var clearRecentSearchesContentDesc: String
private lateinit var topicsString: String
private lateinit var updatesString: String
private lateinit var tryAnotherSearchString: String
@ -64,6 +66,7 @@ class SearchScreenTest {
fun setup() {
composeTestRule.activity.apply {
clearSearchContentDesc = getString(R.string.clear_search_text_content_desc)
clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc)
followButtonContentDesc =
getString(interestsR.string.card_follow_button_content_desc)
unfollowButtonContentDesc =
@ -82,10 +85,7 @@ class SearchScreenTest {
}
composeTestRule
.onNodeWithContentDescription(clearSearchContentDesc)
// The parent of the IconButton whose contentDescription matches the clearSearchText
// should be the TextField for search
.onParent()
.onNodeWithTag("searchTextField")
.assertIsFocused()
}
@ -93,7 +93,7 @@ class SearchScreenTest {
fun emptySearchResult_emptyScreenIsDisplayed() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(),
searchResultUiState = SearchResultUiState.Success(),
)
}
@ -106,7 +106,7 @@ class SearchScreenTest {
fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(topics = followableTopicTestData),
searchResultUiState = SearchResultUiState.Success(topics = followableTopicTestData),
)
}
@ -135,7 +135,7 @@ class SearchScreenTest {
fun searchResultWithNewsResources_firstNewsResourcesIsVisible() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(
searchResultUiState = SearchResultUiState.Success(
newsResources = newsResourcesTestData.map {
UserNewsResource(
newsResource = it,
@ -153,4 +153,29 @@ class SearchScreenTest {
.onNodeWithText(newsResourcesTestData[0].title)
.assertIsDisplayed()
}
@Test
fun emptyQuery_notEmptyRecentSearches_verifyClearSearchesButton_displayed() {
val recentSearches = listOf("kotlin", "testing")
composeTestRule.setContent {
SearchScreen(
searchResultUiState = SearchResultUiState.EmptyQuery,
recentSearchesUiState = RecentSearchQueriesUiState.Success(
recentQueries = recentSearches.map {
RecentSearchQuery(it)
},
),
)
}
composeTestRule
.onNodeWithContentDescription(clearRecentSearchesContentDesc)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("kotlin")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("testing")
.assertIsDisplayed()
}
}

@ -0,0 +1,27 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.search
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
sealed interface RecentSearchQueriesUiState {
object Loading : RecentSearchQueriesUiState
data class Success(
val recentQueries: List<RecentSearchQuery> = emptyList(),
) : RecentSearchQueriesUiState
}

@ -29,11 +29,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -46,21 +50,29 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -88,18 +100,22 @@ internal fun SearchRoute(
searchViewModel: SearchViewModel = hiltViewModel(),
forYouViewModel: ForYouViewModel = hiltViewModel(),
) {
val uiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle()
val recentSearchQueriesUiState by searchViewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle()
val searchResultUiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
SearchScreen(
modifier = modifier,
onBackClick = onBackClick,
onClearRecentSearches = searchViewModel::clearRecentSearches,
onFollowButtonClick = interestsViewModel::followTopic,
onInterestsClick = onInterestsClick,
onSearchQueryChanged = searchViewModel::onSearchQueryChanged,
onSearchTriggered = searchViewModel::onSearchTriggered,
onTopicClick = onTopicClick,
onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved,
recentSearchesUiState = recentSearchQueriesUiState,
searchQuery = searchQuery,
uiState = uiState,
searchResultUiState = searchResultUiState,
)
}
@ -107,13 +123,16 @@ internal fun SearchRoute(
internal fun SearchScreen(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onClearRecentSearches: () -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },
onInterestsClick: () -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },
onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {},
onTopicClick: (String) -> Unit = {},
searchQuery: String = "",
uiState: SearchResultUiState = SearchResultUiState.Loading,
recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
) {
TrackScreenViewEvent(screenName = "Search")
Column(modifier = modifier) {
@ -121,26 +140,42 @@ internal fun SearchScreen(
SearchToolbar(
onBackClick = onBackClick,
onSearchQueryChanged = onSearchQueryChanged,
onSearchTriggered = onSearchTriggered,
searchQuery = searchQuery,
)
when (uiState) {
when (searchResultUiState) {
SearchResultUiState.Loading,
SearchResultUiState.LoadFailed,
SearchResultUiState.EmptyQuery,
-> Unit
SearchResultUiState.EmptyQuery,
-> {
if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {
RecentSearchesBody(
onClearRecentSearches = onClearRecentSearches,
onRecentSearchClicked = {
onSearchQueryChanged(it)
onSearchTriggered(it)
},
recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query },
)
}
}
is SearchResultUiState.Success -> {
if (uiState.isEmpty()) {
if (searchResultUiState.isEmpty()) {
EmptySearchResultBody(
onInterestsClick = onInterestsClick,
searchQuery = searchQuery,
)
} else {
SearchResultBody(
topics = uiState.topics,
topics = searchResultUiState.topics,
onFollowButtonClick = onFollowButtonClick,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onSearchTriggered = onSearchTriggered,
onTopicClick = onTopicClick,
newsResources = uiState.newsResources,
newsResources = searchResultUiState.newsResources,
searchQuery = searchQuery,
)
}
}
@ -151,7 +186,7 @@ internal fun SearchScreen(
@Composable
fun EmptySearchResultBody(
onInterestsClick: () -> Unit = {},
onInterestsClick: () -> Unit,
searchQuery: String,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -206,8 +241,10 @@ private fun SearchResultBody(
topics: List<FollowableTopic>,
newsResources: List<UserNewsResource>,
onFollowButtonClick: (String, Boolean) -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },
onTopicClick: (String) -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onSearchTriggered: (String) -> Unit,
onTopicClick: (String) -> Unit,
searchQuery: String = "",
) {
if (topics.isNotEmpty()) {
Text(
@ -220,7 +257,11 @@ private fun SearchResultBody(
)
TopicsTabContent(
topics = topics,
onTopicClick = onTopicClick,
onTopicClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(it)
},
onFollowButtonClick = onFollowButtonClick,
withBottomSpacer = false,
)
@ -252,17 +293,83 @@ private fun SearchResultBody(
feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onTopicClick = onTopicClick,
onExpandedCardClick = {
onSearchTriggered(searchQuery)
},
)
}
}
}
@Composable
private fun RecentSearchesBody(
onClearRecentSearches: () -> Unit,
onRecentSearchClicked: (String) -> Unit,
recentSearchQueries: List<String>,
) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.recent_searches))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (recentSearchQueries.isNotEmpty()) {
IconButton(
onClick = {
onClearRecentSearches()
},
modifier = Modifier.padding(horizontal = 16.dp),
) {
Icon(
imageVector = NiaIcons.Close,
contentDescription = stringResource(
id = searchR.string.clear_recent_searches_content_desc,
),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {
items(recentSearchQueries) { recentSearch ->
ClickableText(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(
fontSize = 28.sp,
fontFamily = FontFamily.SansSerif,
),
) {
append(recentSearch)
}
},
onClick = {
onRecentSearchClicked(recentSearch)
},
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxWidth(),
)
}
}
}
}
@Composable
private fun SearchToolbar(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onSearchQueryChanged: (String) -> Unit = {},
onBackClick: () -> Unit,
onSearchQueryChanged: (String) -> Unit,
searchQuery: String = "",
onSearchTriggered: (String) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -278,18 +385,27 @@ private fun SearchToolbar(
}
SearchTextField(
onSearchQueryChanged = onSearchQueryChanged,
onSearchTriggered = onSearchTriggered,
searchQuery = searchQuery,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
private fun SearchTextField(
onSearchQueryChanged: (String) -> Unit,
searchQuery: String,
onSearchTriggered: (String) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onSearchExplicitlyTriggered = {
keyboardController?.hide()
onSearchTriggered(searchQuery)
}
TextField(
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
@ -306,27 +422,51 @@ private fun SearchTextField(
)
},
trailingIcon = {
IconButton(onClick = {
onSearchQueryChanged("")
}) {
Icon(
imageVector = NiaIcons.Close,
contentDescription = stringResource(
id = searchR.string.clear_search_text_content_desc,
),
tint = MaterialTheme.colorScheme.onSurface,
)
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = {
onSearchQueryChanged("")
},
) {
Icon(
imageVector = NiaIcons.Close,
contentDescription = stringResource(
id = searchR.string.clear_search_text_content_desc,
),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
},
onValueChange = {
onSearchQueryChanged(it)
if (!it.contains("\n")) {
onSearchQueryChanged(it)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.focusRequester(focusRequester),
.focusRequester(focusRequester)
.onKeyEvent {
if (it.key == Key.Enter) {
onSearchExplicitlyTriggered()
true
} else {
false
}
}.testTag("searchTextField"),
shape = RoundedCornerShape(32.dp),
value = searchQuery,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
onSearchExplicitlyTriggered()
},
),
maxLines = 1,
singleLine = true,
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@ -337,7 +477,11 @@ private fun SearchTextField(
@Composable
private fun SearchToolbarPreview() {
NiaTheme {
SearchToolbar()
SearchToolbar(
onBackClick = {},
onSearchQueryChanged = {},
onSearchTriggered = {},
)
}
}
@ -345,7 +489,22 @@ private fun SearchToolbarPreview() {
@Composable
private fun EmptySearchResultColumnPreview() {
NiaTheme {
EmptySearchResultBody(searchQuery = "C++")
EmptySearchResultBody(
onInterestsClick = {},
searchQuery = "C++",
)
}
}
@Preview
@Composable
private fun RecentSearchesBodyPreview() {
NiaTheme {
RecentSearchesBody(
onClearRecentSearches = {},
onRecentSearchClicked = {},
recentSearchQueries = listOf("kotlin", "jetpack compose", "testing"),
)
}
}
@ -356,6 +515,6 @@ private fun SearchScreenPreview(
searchResultUiState: SearchResultUiState,
) {
NiaTheme {
SearchScreen(uiState = searchResultUiState)
SearchScreen(searchResultUiState = searchResultUiState)
}
}

@ -19,8 +19,11 @@ package com.google.samples.apps.nowinandroid.feature.search
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.LoadFailed
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -28,31 +31,42 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
// TODO: Add GetSearchContentsCountUseCase to check if the fts tables are populated
getSearchContentsUseCase: GetSearchContentsUseCase,
recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase,
private val recentSearchRepository: RecentSearchRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val searchQuery = savedStateHandle.getStateFlow("searchQuery", "")
val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "")
val searchResultUiState: StateFlow<SearchResultUiState> =
searchQuery.flatMapLatest { query ->
if (query.length < 2) {
if (query.length < SEARCH_QUERY_MIN_LENGTH) {
flowOf(SearchResultUiState.EmptyQuery)
} else {
try {
getSearchContentsUseCase(query).map {
SearchResultUiState.Success(
topics = it.topics,
newsResources = it.newsResources,
)
getSearchContentsUseCase(query).asResult().map {
when (it) {
is Result.Success -> {
SearchResultUiState.Success(
topics = it.data.topics,
newsResources = it.data.newsResources,
)
}
is Result.Loading -> {
SearchResultUiState.Loading
}
is Result.Error -> {
SearchResultUiState.LoadFailed
}
}
} catch (exception: Exception) {
flowOf(LoadFailed)
}
}
}.stateIn(
@ -61,7 +75,39 @@ class SearchViewModel @Inject constructor(
initialValue = SearchResultUiState.Loading,
)
val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =
recentSearchQueriesUseCase().map {
RecentSearchQueriesUiState.Success(it)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = RecentSearchQueriesUiState.Loading,
)
fun onSearchQueryChanged(query: String) {
savedStateHandle["searchQuery"] = query
savedStateHandle[SEARCH_QUERY] = query
}
/**
* Called when the search action is explicitly triggered by the user. For example, when the
* search icon is tapped in the IME or when the enter key is pressed in the search text field.
*
* The search results are displayed on the fly as the user types, but to explicitly save the
* search query in the search text field, defining this method.
*/
fun onSearchTriggered(query: String) {
viewModelScope.launch {
recentSearchRepository.insertOrReplaceRecentSearch(query)
}
}
fun clearRecentSearches() {
viewModelScope.launch {
recentSearchRepository.clearRecentSearches()
}
}
}
/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */
const val SEARCH_QUERY_MIN_LENGTH = 2
const val SEARCH_QUERY = "searchQuery"

@ -31,7 +31,7 @@ fun NavController.navigateToSearch(navOptions: NavOptions? = null) {
fun NavGraphBuilder.searchScreen(
onBackClick: () -> Unit,
onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit = {},
onTopicClick: (String) -> Unit,
) {
// TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack.

@ -23,4 +23,6 @@
<string name="to_browse_topics"> to browse topics</string>
<string name="topics">Topics</string>
<string name="updates">Updates</string>
<string name="recent_searches">Recent searches</string>
<string name="clear_recent_searches_content_desc">Clear searches</string>
</resources>

@ -17,12 +17,15 @@
package com.google.samples.apps.nowinandroid.feature.search
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData
import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading
import kotlinx.coroutines.flow.collect
@ -33,6 +36,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* To learn more about how this test handles Flows created with stateIn, see
@ -49,13 +53,17 @@ class SearchViewModelTest {
searchContentsRepository = searchContentsRepository,
userDataRepository = userDataRepository,
)
private val recentSearchRepository = TestRecentSearchRepository()
private val getRecentQueryUseCase = GetRecentSearchQueriesUseCase(recentSearchRepository)
private lateinit var viewModel: SearchViewModel
@Before
fun setup() {
viewModel = SearchViewModel(
getSearchContentsUseCase = getSearchContentsUseCase,
recentSearchQueriesUseCase = getRecentQueryUseCase,
savedStateHandle = SavedStateHandle(),
recentSearchRepository = recentSearchRepository,
)
}
@ -76,7 +84,7 @@ class SearchViewModelTest {
@Test
fun emptyResultIsReturned_withNotMatchingQuery() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchQuery.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("XXX")
searchContentsRepository.addNewsResources(newsResourcesTestData)
@ -88,4 +96,15 @@ class SearchViewModelTest {
collectJob.cancel()
}
@Test
fun recentSearches() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() }
viewModel.onSearchTriggered("kotlin")
val result = viewModel.recentSearchQueriesUiState.value
assertIs<Success>(result)
collectJob.cancel()
}
}

Loading…
Cancel
Save