diff --git a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index d616fc691..2ec2bcf9c 100644 --- a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -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, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index 7c6674bcb..26f0bbc51 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -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, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt new file mode 100644 index 000000000..76dd08811 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt @@ -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, +) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt new file mode 100644 index 000000000..779442c2e --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt @@ -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> { + return recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> + searchQueries.map { + it.asExternalModel() + } + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..87a2ce9dc --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt @@ -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> + + /** + * Insert or replace the [searchQuery] as part of the recent searches. + */ + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + + /** + * Clear the recent searches. + */ + suspend fun clearRecentSearches() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt new file mode 100644 index 000000000..fc649f3ec --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt @@ -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> = + flowOf(emptyList()) + + override suspend fun clearRecentSearches() { /* no-op */ } +} diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json new file mode 100644 index 000000000..aa90a9723 --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt index a18d7bd00..34840a733 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt @@ -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() } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt index ce71b57cf..96714f9a9 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt @@ -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 } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt new file mode 100644 index 000000000..826575828 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt @@ -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> + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt new file mode 100644 index 000000000..9c7439233 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt @@ -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, +) diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt new file mode 100644 index 000000000..51f87d6fd --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt @@ -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> = + recentSearchRepository.getRecentSearchQueries(limit) +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt new file mode 100644 index 000000000..fec9ef438 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt @@ -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 = mutableListOf() + + override fun getRecentSearchQueries(limit: Int): Flow> { + 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() + } +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index df9b5dab8..b4d5822f5 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -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, diff --git a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt index 9834418d9..718c52869 100644 --- a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -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() + } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt new file mode 100644 index 000000000..8628d2e54 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt @@ -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 = emptyList(), + ) : RecentSearchQueriesUiState +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index 4b428491b..82a455cb7 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -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, ) } } @@ -207,7 +242,9 @@ private fun SearchResultBody( newsResources: List, onFollowButtonClick: (String, Boolean) -> 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, +) { + 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, 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() @@ -340,6 +480,7 @@ private fun SearchToolbarPreview() { SearchToolbar( onBackClick = {}, onSearchQueryChanged = {}, + onSearchTriggered = {}, ) } } @@ -355,6 +496,18 @@ private fun EmptySearchResultColumnPreview() { } } +@Preview +@Composable +private fun RecentSearchesBodyPreview() { + NiaTheme { + RecentSearchesBody( + onClearRecentSearches = {}, + onRecentSearchClicked = {}, + recentSearchQueries = listOf("kotlin", "jetpack compose", "testing"), + ) + } +} + @DevicePreviews @Composable private fun SearchScreenPreview( @@ -362,6 +515,6 @@ private fun SearchScreenPreview( searchResultUiState: SearchResultUiState, ) { NiaTheme { - SearchScreen(uiState = searchResultUiState) + SearchScreen(searchResultUiState = searchResultUiState) } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt index 3d44df6e0..121fa5da4 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -19,6 +19,8 @@ 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.core.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult @@ -29,12 +31,15 @@ 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() { @@ -70,9 +75,37 @@ class SearchViewModel @Inject constructor( initialValue = SearchResultUiState.Loading, ) + val recentSearchQueriesUiState: StateFlow = + recentSearchQueriesUseCase().map { + RecentSearchQueriesUiState.Success(it) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RecentSearchQueriesUiState.Loading, + ) + fun onSearchQueryChanged(query: String) { 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] */ diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 374481d86..69f93f4b8 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -23,4 +23,6 @@ to browse topics Topics Updates + Recent searches + Clear searches \ No newline at end of file diff --git a/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt index bcea9aefd..06fa5d8f2 100644 --- a/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt +++ b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt @@ -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, ) } @@ -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(result) + + collectJob.cancel() + } }