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.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository 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.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository 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.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository 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.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
@ -52,6 +54,11 @@ interface TestDataModule {
userDataRepository: FakeUserDataRepository, userDataRepository: FakeUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: FakeRecentSearchRepository,
): RecentSearchRepository
@Binds @Binds
fun bindsSearchContentsRepository( fun bindsSearchContentsRepository(
searchContentsRepository: FakeSearchContentsRepository, searchContentsRepository: FakeSearchContentsRepository,

@ -16,11 +16,13 @@
package com.google.samples.apps.nowinandroid.core.data.di 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.DefaultSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository 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.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository 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.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.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository 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.UserDataRepository
@ -50,6 +52,11 @@ interface DataModule {
userDataRepository: OfflineFirstUserDataRepository, userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: DefaultRecentSearchRepository,
): RecentSearchRepository
@Binds @Binds
fun bindsSearchContentsRepository( fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository, 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 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.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject 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 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.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
@ -35,8 +34,3 @@ interface SearchContentsRepository {
*/ */
fun searchContents(searchQuery: String): Flow<SearchResult> 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 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.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -26,8 +27,6 @@ import javax.inject.Inject
*/ */
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() {} override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> { override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
TODO("Not yet implemented")
}
} }

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

@ -22,11 +22,13 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao 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.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.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao 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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity 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.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.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter 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, NewsResourceFtsEntity::class,
TopicEntity::class, TopicEntity::class,
TopicFtsEntity::class, TopicFtsEntity::class,
RecentSearchQueryEntity::class,
], ],
version = 13, version = 14,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), 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 = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class), AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
AutoMigration(from = 12, to = 13), AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14),
], ],
exportSchema = true, exportSchema = true,
) )
@ -66,4 +70,5 @@ abstract class NiaDatabase : RoomDatabase() {
abstract fun newsResourceDao(): NewsResourceDao abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao abstract fun topicFtsDao(): TopicFtsDao
abstract fun newsResourceFtsDao(): NewsResourceFtsDao 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 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.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.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic 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.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserSearchResult
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -41,7 +42,7 @@ class GetSearchContentsUseCase @Inject constructor(
.mapToUserSearchResult(userDataRepository.userData) .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 -> combine(userDataStream) { searchResult, userData ->
UserSearchResult( UserSearchResult(
topics = searchResult.topics.map { topic -> 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 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.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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -28,10 +28,9 @@ class TestSearchContentsRepository : SearchContentsRepository {
private val cachedTopics: MutableList<Topic> = mutableListOf() private val cachedTopics: MutableList<Topic> = mutableListOf()
private val cachedNewsResources: MutableList<NewsResource> = mutableListOf() private val cachedNewsResources: MutableList<NewsResource> = mutableListOf()
override suspend fun populateFtsData() {} override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> { override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf(
return flowOf(
SearchResult( SearchResult(
topics = cachedTopics.filter { topics = cachedTopics.filter {
it.name.contains(searchQuery) || it.name.contains(searchQuery) ||
@ -44,7 +43,6 @@ class TestSearchContentsRepository : SearchContentsRepository {
}, },
), ),
) )
}
/** /**
* Test only method to add the topics to the stored list in memory * Test only method to add the topics to the stored list in memory

@ -51,6 +51,7 @@ fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onExpandedCardClick: () -> Unit = {},
) { ) {
when (feedState) { when (feedState) {
NewsFeedUiState.Loading -> Unit NewsFeedUiState.Loading -> Unit
@ -67,6 +68,7 @@ fun LazyGridScope.newsFeed(
userNewsResource = userNewsResource, userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved, isBookmarked = userNewsResource.isSaved,
onClick = { onClick = {
onExpandedCardClick()
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id, newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title, 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID 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 clearSearchContentDesc: String
private lateinit var followButtonContentDesc: String private lateinit var followButtonContentDesc: String
private lateinit var unfollowButtonContentDesc: String private lateinit var unfollowButtonContentDesc: String
private lateinit var clearRecentSearchesContentDesc: String
private lateinit var topicsString: String private lateinit var topicsString: String
private lateinit var updatesString: String private lateinit var updatesString: String
private lateinit var tryAnotherSearchString: String private lateinit var tryAnotherSearchString: String
@ -64,6 +66,7 @@ class SearchScreenTest {
fun setup() { fun setup() {
composeTestRule.activity.apply { composeTestRule.activity.apply {
clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) clearSearchContentDesc = getString(R.string.clear_search_text_content_desc)
clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc)
followButtonContentDesc = followButtonContentDesc =
getString(interestsR.string.card_follow_button_content_desc) getString(interestsR.string.card_follow_button_content_desc)
unfollowButtonContentDesc = unfollowButtonContentDesc =
@ -82,10 +85,7 @@ class SearchScreenTest {
} }
composeTestRule composeTestRule
.onNodeWithContentDescription(clearSearchContentDesc) .onNodeWithTag("searchTextField")
// The parent of the IconButton whose contentDescription matches the clearSearchText
// should be the TextField for search
.onParent()
.assertIsFocused() .assertIsFocused()
} }
@ -93,7 +93,7 @@ class SearchScreenTest {
fun emptySearchResult_emptyScreenIsDisplayed() { fun emptySearchResult_emptyScreenIsDisplayed() {
composeTestRule.setContent { composeTestRule.setContent {
SearchScreen( SearchScreen(
uiState = SearchResultUiState.Success(), searchResultUiState = SearchResultUiState.Success(),
) )
} }
@ -106,7 +106,7 @@ class SearchScreenTest {
fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() { fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() {
composeTestRule.setContent { composeTestRule.setContent {
SearchScreen( SearchScreen(
uiState = SearchResultUiState.Success(topics = followableTopicTestData), searchResultUiState = SearchResultUiState.Success(topics = followableTopicTestData),
) )
} }
@ -135,7 +135,7 @@ class SearchScreenTest {
fun searchResultWithNewsResources_firstNewsResourcesIsVisible() { fun searchResultWithNewsResources_firstNewsResourcesIsVisible() {
composeTestRule.setContent { composeTestRule.setContent {
SearchScreen( SearchScreen(
uiState = SearchResultUiState.Success( searchResultUiState = SearchResultUiState.Success(
newsResources = newsResourcesTestData.map { newsResources = newsResourcesTestData.map {
UserNewsResource( UserNewsResource(
newsResource = it, newsResource = it,
@ -153,4 +153,29 @@ class SearchScreenTest {
.onNodeWithText(newsResourcesTestData[0].title) .onNodeWithText(newsResourcesTestData[0].title)
.assertIsDisplayed() .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.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight 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.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -46,21 +50,29 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color 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.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -88,18 +100,22 @@ internal fun SearchRoute(
searchViewModel: SearchViewModel = hiltViewModel(), searchViewModel: SearchViewModel = hiltViewModel(),
forYouViewModel: ForYouViewModel = 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() val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
SearchScreen( SearchScreen(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
onClearRecentSearches = searchViewModel::clearRecentSearches,
onFollowButtonClick = interestsViewModel::followTopic, onFollowButtonClick = interestsViewModel::followTopic,
onInterestsClick = onInterestsClick, onInterestsClick = onInterestsClick,
onSearchQueryChanged = searchViewModel::onSearchQueryChanged, onSearchQueryChanged = searchViewModel::onSearchQueryChanged,
onSearchTriggered = searchViewModel::onSearchTriggered,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved,
recentSearchesUiState = recentSearchQueriesUiState,
searchQuery = searchQuery, searchQuery = searchQuery,
uiState = uiState, searchResultUiState = searchResultUiState,
) )
} }
@ -107,13 +123,16 @@ internal fun SearchRoute(
internal fun SearchScreen( internal fun SearchScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit = {}, onBackClick: () -> Unit = {},
onClearRecentSearches: () -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },
onInterestsClick: () -> Unit = {}, onInterestsClick: () -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },
onSearchQueryChanged: (String) -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {},
onTopicClick: (String) -> Unit = {}, onTopicClick: (String) -> Unit = {},
searchQuery: String = "", searchQuery: String = "",
uiState: SearchResultUiState = SearchResultUiState.Loading, recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
) { ) {
TrackScreenViewEvent(screenName = "Search") TrackScreenViewEvent(screenName = "Search")
Column(modifier = modifier) { Column(modifier = modifier) {
@ -121,26 +140,42 @@ internal fun SearchScreen(
SearchToolbar( SearchToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onSearchQueryChanged = onSearchQueryChanged, onSearchQueryChanged = onSearchQueryChanged,
onSearchTriggered = onSearchTriggered,
searchQuery = searchQuery, searchQuery = searchQuery,
) )
when (uiState) { when (searchResultUiState) {
SearchResultUiState.Loading, SearchResultUiState.Loading,
SearchResultUiState.LoadFailed, SearchResultUiState.LoadFailed,
SearchResultUiState.EmptyQuery,
-> Unit -> 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 -> { is SearchResultUiState.Success -> {
if (uiState.isEmpty()) { if (searchResultUiState.isEmpty()) {
EmptySearchResultBody( EmptySearchResultBody(
onInterestsClick = onInterestsClick, onInterestsClick = onInterestsClick,
searchQuery = searchQuery, searchQuery = searchQuery,
) )
} else { } else {
SearchResultBody( SearchResultBody(
topics = uiState.topics, topics = searchResultUiState.topics,
onFollowButtonClick = onFollowButtonClick, onFollowButtonClick = onFollowButtonClick,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onSearchTriggered = onSearchTriggered,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
newsResources = uiState.newsResources, newsResources = searchResultUiState.newsResources,
searchQuery = searchQuery,
) )
} }
} }
@ -151,7 +186,7 @@ internal fun SearchScreen(
@Composable @Composable
fun EmptySearchResultBody( fun EmptySearchResultBody(
onInterestsClick: () -> Unit = {}, onInterestsClick: () -> Unit,
searchQuery: String, searchQuery: String,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -206,8 +241,10 @@ private fun SearchResultBody(
topics: List<FollowableTopic>, topics: List<FollowableTopic>,
newsResources: List<UserNewsResource>, newsResources: List<UserNewsResource>,
onFollowButtonClick: (String, Boolean) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit = {}, onSearchTriggered: (String) -> Unit,
onTopicClick: (String) -> Unit,
searchQuery: String = "",
) { ) {
if (topics.isNotEmpty()) { if (topics.isNotEmpty()) {
Text( Text(
@ -220,7 +257,11 @@ private fun SearchResultBody(
) )
TopicsTabContent( TopicsTabContent(
topics = topics, topics = topics,
onTopicClick = onTopicClick, onTopicClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(it)
},
onFollowButtonClick = onFollowButtonClick, onFollowButtonClick = onFollowButtonClick,
withBottomSpacer = false, withBottomSpacer = false,
) )
@ -252,17 +293,83 @@ private fun SearchResultBody(
feedState = NewsFeedUiState.Success(feed = newsResources), feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onTopicClick = onTopicClick, 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 @Composable
private fun SearchToolbar( private fun SearchToolbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit = {}, onBackClick: () -> Unit,
onSearchQueryChanged: (String) -> Unit = {}, onSearchQueryChanged: (String) -> Unit,
searchQuery: String = "", searchQuery: String = "",
onSearchTriggered: (String) -> Unit,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -278,18 +385,27 @@ private fun SearchToolbar(
} }
SearchTextField( SearchTextField(
onSearchQueryChanged = onSearchQueryChanged, onSearchQueryChanged = onSearchQueryChanged,
onSearchTriggered = onSearchTriggered,
searchQuery = searchQuery, searchQuery = searchQuery,
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
private fun SearchTextField( private fun SearchTextField(
onSearchQueryChanged: (String) -> Unit, onSearchQueryChanged: (String) -> Unit,
searchQuery: String, searchQuery: String,
onSearchTriggered: (String) -> Unit,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onSearchExplicitlyTriggered = {
keyboardController?.hide()
onSearchTriggered(searchQuery)
}
TextField( TextField(
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
@ -306,9 +422,12 @@ private fun SearchTextField(
) )
}, },
trailingIcon = { trailingIcon = {
IconButton(onClick = { if (searchQuery.isNotEmpty()) {
IconButton(
onClick = {
onSearchQueryChanged("") onSearchQueryChanged("")
}) { },
) {
Icon( Icon(
imageVector = NiaIcons.Close, imageVector = NiaIcons.Close,
contentDescription = stringResource( contentDescription = stringResource(
@ -317,16 +436,37 @@ private fun SearchTextField(
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
}
}, },
onValueChange = { onValueChange = {
if (!it.contains("\n")) {
onSearchQueryChanged(it) onSearchQueryChanged(it)
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester)
.onKeyEvent {
if (it.key == Key.Enter) {
onSearchExplicitlyTriggered()
true
} else {
false
}
}.testTag("searchTextField"),
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
value = searchQuery, value = searchQuery,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
onSearchExplicitlyTriggered()
},
),
maxLines = 1,
singleLine = true,
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@ -337,7 +477,11 @@ private fun SearchTextField(
@Composable @Composable
private fun SearchToolbarPreview() { private fun SearchToolbarPreview() {
NiaTheme { NiaTheme {
SearchToolbar() SearchToolbar(
onBackClick = {},
onSearchQueryChanged = {},
onSearchTriggered = {},
)
} }
} }
@ -345,7 +489,22 @@ private fun SearchToolbarPreview() {
@Composable @Composable
private fun EmptySearchResultColumnPreview() { private fun EmptySearchResultColumnPreview() {
NiaTheme { 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, searchResultUiState: SearchResultUiState,
) { ) {
NiaTheme { 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.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -28,31 +31,42 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
// TODO: Add GetSearchContentsCountUseCase to check if the fts tables are populated // TODO: Add GetSearchContentsCountUseCase to check if the fts tables are populated
getSearchContentsUseCase: GetSearchContentsUseCase, getSearchContentsUseCase: GetSearchContentsUseCase,
recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase,
private val recentSearchRepository: RecentSearchRepository,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val searchQuery = savedStateHandle.getStateFlow("searchQuery", "") val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "")
val searchResultUiState: StateFlow<SearchResultUiState> = val searchResultUiState: StateFlow<SearchResultUiState> =
searchQuery.flatMapLatest { query -> searchQuery.flatMapLatest { query ->
if (query.length < 2) { if (query.length < SEARCH_QUERY_MIN_LENGTH) {
flowOf(SearchResultUiState.EmptyQuery) flowOf(SearchResultUiState.EmptyQuery)
} else { } else {
try { getSearchContentsUseCase(query).asResult().map {
getSearchContentsUseCase(query).map { when (it) {
is Result.Success -> {
SearchResultUiState.Success( SearchResultUiState.Success(
topics = it.topics, topics = it.data.topics,
newsResources = it.newsResources, newsResources = it.data.newsResources,
) )
} }
} catch (exception: Exception) {
flowOf(LoadFailed) is Result.Loading -> {
SearchResultUiState.Loading
}
is Result.Error -> {
SearchResultUiState.LoadFailed
}
}
} }
} }
}.stateIn( }.stateIn(
@ -61,7 +75,39 @@ class SearchViewModel @Inject constructor(
initialValue = SearchResultUiState.Loading, 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) { 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( fun NavGraphBuilder.searchScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onInterestsClick: () -> Unit, onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit = {}, onTopicClick: (String) -> Unit,
) { ) {
// TODO: Handle back stack for each top-level destination. At the moment each top-level // TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack. // destination may have own search screen's back stack.

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

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