From 37b0917002e81475f0283c36464cbe38a2e9d69a Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Thu, 20 Apr 2023 15:45:41 +0900 Subject: [PATCH] Search backend (#668) Adds search UI (same change as #656) - Empty search screen if the search result is empty - Topics contents that reuses the TopicsTabContent for InterestsScreen - Updates contents that reuses the newsFeed for ForYouScreen TODO: Needs to add RecentSearch Adds search backend that wires the backend data with the search UI - Add Fts related tables. - Add SearchContentsRepository that takes care of populating the Fts tables and search the contents from the Fts tables. - Wire the SearchContentsRepository with the SearchViewModel --- .../nowinandroid/navigation/NiaNavHost.kt | 12 +- .../samples/apps/nowinandroid/ui/NiaApp.kt | 2 +- .../core/data/test/TestDataModule.kt | 7 + .../nowinandroid/core/data/di/DataModule.kt | 7 + .../core/data/model/SearchResult.kt.kt | 26 ++ .../DefaultSearchContentsRepository.kt | 77 +++++ .../repository/SearchContentsRepository.kt | 36 +++ .../fake/FakeSearchContentsRepository.kt | 32 ++ .../data/testdoubles/TestNewsResourceDao.kt | 2 + .../core/data/testdoubles/TestTopicDao.kt | 2 + .../13.json | 282 ++++++++++++++++++ .../nowinandroid/core/database/DaosModule.kt | 12 + .../core/database/DatabaseModule.kt | 2 + .../nowinandroid/core/database/NiaDatabase.kt | 11 +- .../core/database/dao/NewsResourceDao.kt | 4 + .../core/database/dao/NewsResourceFtsDao.kt | 39 +++ .../core/database/dao/TopicDao.kt | 3 + .../core/database/dao/TopicFtsDao.kt | 39 +++ .../database/model/NewsResourceFtsEntity.kt | 44 +++ .../database/model/PopulatedNewsResource.kt | 6 + .../core/database/model/TopicFtsEntity.kt | 48 +++ .../core/domain/GetSearchContentsUseCase.kt | 61 ++++ .../core/domain/model/UserSearchResult.kt | 28 ++ .../TestSearchContentsRepository.kt | 60 ++++ ...serNewsResourcePreviewParameterProvider.kt | 189 ++++++------ .../feature/interests/TabContent.kt | 7 +- feature/search/build.gradle.kts | 6 + .../feature/search/SearchScreenTest.kt | 104 ++++++- .../feature/search/SearchResultUiState.kt | 40 +++ .../feature/search/SearchScreen.kt | 236 +++++++++++++-- .../SearchUiStatePreviewParameterProvider.kt | 38 +++ .../feature/search/SearchViewModel.kt | 58 +++- .../search/navigation/SearchNavigation.kt | 12 +- .../search/src/main/res/values/strings.xml | 8 +- .../feature/search/SearchViewModelTest.kt | 91 ++++++ .../nowinandroid/sync/workers/SyncWorker.kt | 3 + 36 files changed, 1503 insertions(+), 131 deletions(-) create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/SearchResult.kt.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt create mode 100644 core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserSearchResult.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt create mode 100644 feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 8e4caabe8..e43dfaba7 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute @@ -27,6 +26,8 @@ import com.google.samples.apps.nowinandroid.feature.interests.navigation.interes import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS +import com.google.samples.apps.nowinandroid.ui.NiaAppState /** * Top-level navigation graph. Navigation is organized as explained at @@ -37,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen */ @Composable fun NiaNavHost( - navController: NavHostController, + appState: NiaAppState, modifier: Modifier = Modifier, startDestination: String = forYouNavigationRoute, ) { + val navController = appState.navController NavHost( navController = navController, startDestination = startDestination, @@ -49,7 +51,11 @@ fun NiaNavHost( // TODO: handle topic clicks from each top level destination forYouScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {}) - searchScreen(onBackClick = navController::popBackStack) + searchScreen( + onBackClick = navController::popBackStack, + onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, + onTopicClick = navController::navigateToTopic, + ) interestsGraph( onTopicClick = { topicId -> navController.navigateToTopic(topicId) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 41563c205..2388131fe 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -181,7 +181,7 @@ fun NiaApp( ) } - NiaNavHost(appState.navController) + NiaNavHost(appState) } // TODO: We may want to add padding or spacer when the snackbar is shown so that 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 f4fc9c7b0..d616fc691 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,9 +18,11 @@ 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.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.FakeSearchContentsRepository 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.util.NetworkMonitor @@ -50,6 +52,11 @@ interface TestDataModule { userDataRepository: FakeUserDataRepository, ): UserDataRepository + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: FakeSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: AlwaysOnlineNetworkMonitor, 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 b4dda701e..7c6674bcb 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,10 +16,12 @@ package com.google.samples.apps.nowinandroid.core.data.di +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.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.util.ConnectivityManagerNetworkMonitor @@ -48,6 +50,11 @@ interface DataModule { userDataRepository: OfflineFirstUserDataRepository, ): UserDataRepository + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: DefaultSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: ConnectivityManagerNetworkMonitor, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/SearchResult.kt.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/SearchResult.kt.kt new file mode 100644 index 000000000..cc6dd2b52 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/SearchResult.kt.kt @@ -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 = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt new file mode 100644 index 000000000..63b20374d --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt @@ -0,0 +1,77 @@ +/* + * 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.SearchResult +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao +import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity +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.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DefaultSearchContentsRepository @Inject constructor( + private val newsResourceDao: NewsResourceDao, + private val newsResourceFtsDao: NewsResourceFtsDao, + private val topicDao: TopicDao, + private val topicFtsDao: TopicFtsDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : SearchContentsRepository { + + override suspend fun populateFtsData() { + withContext(ioDispatcher) { + newsResourceFtsDao.insertAll( + newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() }, + ) + topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() }) + } + } + + override fun searchContents(searchQuery: String): Flow { + // Surround the query by asterisks to match the query when it's in the middle of + // a word + val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*") + val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*") + + val newsResourcesFlow = newsResourceIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest { + newsResourceDao.getNewsResources(filterNewsIds = it) + } + val topicsFlow = topicIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest(topicDao::getTopicEntities) + return combine(newsResourcesFlow, topicsFlow) { newsResources, topics -> + SearchResult( + topics = topics.map { it.asExternalModel() }, + newsResources = newsResources.map { it.asExternalModel() }, + ) + } + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt new file mode 100644 index 000000000..dfcc3129c --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt @@ -0,0 +1,36 @@ +/* + * 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.SearchResult +import kotlinx.coroutines.flow.Flow + +/** + * Data layer interface for the search feature. + */ +interface SearchContentsRepository { + + /** + * Populate the fts tables for the search contents. + */ + suspend fun populateFtsData() + + /** + * Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult] + */ + fun searchContents(searchQuery: String): Flow +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt new file mode 100644 index 000000000..c91eef71a --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.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.data.repository.fake + +import com.google.samples.apps.nowinandroid.core.data.model.SearchResult +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [SearchContentsRepository] + */ +class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { + + override suspend fun populateFtsData() { /* no-op */ } + override fun searchContents(searchQuery: String): Flow = flowOf() +} diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index bb1ac20ab..555bc2938 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -63,6 +63,8 @@ class TestNewsResourceDao : NewsResourceDao { result } + override suspend fun getOneOffNewsResources(): List = emptyList() + override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index c0cef479f..e891dcfdc 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -43,6 +43,8 @@ class TestTopicDao : TopicDao { getTopicEntities() .map { topics -> topics.filter { it.id in ids } } + override suspend fun getOneOffTopicEntities(): List = emptyList() + override suspend fun insertOrIgnoreTopics(topicEntities: List): List { // Keep old values over new values entitiesStateFlow.update { oldValues -> diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json new file mode 100644 index 000000000..387049dea --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json @@ -0,0 +1,282 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "b6b299e53da623b16360975581ebfcfe", + "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": [] + } + ], + "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, 'b6b299e53da623b16360975581ebfcfe')" + ] + } +} \ 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 1cb17f110..a18d7bd00 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 @@ -17,7 +17,9 @@ 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.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,4 +37,14 @@ object DaosModule { fun providesNewsResourceDao( database: NiaDatabase, ): NewsResourceDao = database.newsResourceDao() + + @Provides + fun providesTopicFtsDao( + database: NiaDatabase, + ): TopicFtsDao = database.topicFtsDao() + + @Provides + fun providesNewsResourceFtsDao( + database: NiaDatabase, + ): NewsResourceFtsDao = database.newsResourceFtsDao() } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt index 7d89cd1ac..7a145166b 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DatabaseModule.kt @@ -36,5 +36,7 @@ object DatabaseModule { context, NiaDatabase::class.java, "nia-database", + // TODO: This is a workaround for executing read query in the main thread for search. + // Figure out how other use cases avoid that ).build() } 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 83bd46967..ce71b57cf 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 @@ -21,10 +21,14 @@ import androidx.room.Database 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.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.TopicEntity +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.NewsResourceTypeConverter @@ -32,9 +36,11 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC entities = [ NewsResourceEntity::class, NewsResourceTopicCrossRef::class, + NewsResourceFtsEntity::class, TopicEntity::class, + TopicFtsEntity::class, ], - version = 12, + version = 13, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), @@ -47,6 +53,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC AutoMigration(from = 9, to = 10), AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class), + AutoMigration(from = 12, to = 13), ], exportSchema = true, ) @@ -57,4 +64,6 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC abstract class NiaDatabase : RoomDatabase() { abstract fun topicDao(): TopicDao abstract fun newsResourceDao(): NewsResourceDao + abstract fun topicFtsDao(): TopicFtsDao + abstract fun newsResourceFtsDao(): NewsResourceFtsDao } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 782e5c87a..cc8bba0e6 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -66,6 +66,10 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> + @Transaction + @Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC") + suspend fun getOneOffNewsResources(): List + /** * Inserts [entities] into the db if they don't exist, and ignores those that do */ diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt new file mode 100644 index 000000000..1795cf512 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt @@ -0,0 +1,39 @@ +/* + * 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.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [NewsResourceFtsEntity] access. + */ +@Dao +interface NewsResourceFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(newsResources: List) + + @Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query") + fun searchAllNewsResources(query: String): Flow> + + @Query("SELECT count(*) FROM newsResourcesFts") + suspend fun getCount(): Int +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 37724af69..693a85b77 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -41,6 +41,9 @@ interface TopicDao { @Query(value = "SELECT * FROM topics") fun getTopicEntities(): Flow> + @Query(value = "SELECT * FROM topics") + suspend fun getOneOffTopicEntities(): List + @Query( value = """ SELECT * FROM topics diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt new file mode 100644 index 000000000..e1b622464 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt @@ -0,0 +1,39 @@ +/* + * 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.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [TopicFtsEntity] access. + */ +@Dao +interface TopicFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(topics: List) + + @Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query") + fun searchAllTopics(query: String): Flow> + + @Query("SELECT count(*) FROM topicsFts") + suspend fun getCount(): Int +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt new file mode 100644 index 000000000..0ef9333c1 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt @@ -0,0 +1,44 @@ +/* + * 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.Fts4 + +/** + * Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "newsResourcesFts") +@Fts4 +data class NewsResourceFtsEntity( + + @ColumnInfo(name = "newsResourceId") + val newsResourceId: String, + + @ColumnInfo(name = "title") + val title: String, + + @ColumnInfo(name = "content") + val content: String, +) + +fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = id, + title = title, + content = content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt index ec8acfb3f..a70342401 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt @@ -49,3 +49,9 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource( type = entity.type, topics = topics.map(TopicEntity::asExternalModel), ) + +fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = entity.id, + title = entity.title, + content = entity.content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt new file mode 100644 index 000000000..23d56f2df --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt @@ -0,0 +1,48 @@ +/* + * 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.Fts4 + +/** + * Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "topicsFts") +@Fts4 +data class TopicFtsEntity( + + @ColumnInfo(name = "topicId") + val topicId: String, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "shortDescription") + val shortDescription: String, + + @ColumnInfo(name = "longDescription") + val longDescription: String, +) + +fun TopicEntity.asFtsEntity() = TopicFtsEntity( + topicId = id, + name = name, + shortDescription = shortDescription, + longDescription = longDescription, +) diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt new file mode 100644 index 000000000..b18406739 --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt @@ -0,0 +1,61 @@ +/* + * 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.SearchResult +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.domain.model.UserSearchResult +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** + * A use case which returns the searched contents matched with the search query. + */ +class GetSearchContentsUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository, + private val userDataRepository: UserDataRepository, +) { + + operator fun invoke( + searchQuery: String, + ): Flow = + searchContentsRepository.searchContents(searchQuery) + .mapToUserSearchResult(userDataRepository.userData) +} + +private fun Flow.mapToUserSearchResult(userDataStream: Flow): Flow = + combine(userDataStream) { searchResult, userData -> + UserSearchResult( + topics = searchResult.topics.map { topic -> + FollowableTopic( + topic = topic, + isFollowed = topic.id in userData.followedTopics, + ) + }, + newsResources = searchResult.newsResources.map { news -> + UserNewsResource( + newsResource = news, + userData = userData, + ) + }, + ) + } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserSearchResult.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserSearchResult.kt new file mode 100644 index 000000000..005291348 --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserSearchResult.kt @@ -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 = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt new file mode 100644 index 000000000..03eced9e1 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt @@ -0,0 +1,60 @@ +/* + * 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.SearchResult +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class TestSearchContentsRepository : SearchContentsRepository { + + private val cachedTopics: MutableList = mutableListOf() + private val cachedNewsResources: MutableList = mutableListOf() + + override suspend fun populateFtsData() { /* no-op */ } + + override fun searchContents(searchQuery: String): Flow = flowOf( + SearchResult( + topics = cachedTopics.filter { + it.name.contains(searchQuery) || + it.shortDescription.contains(searchQuery) || + it.longDescription.contains(searchQuery) + }, + newsResources = cachedNewsResources.filter { + it.content.contains(searchQuery) || + it.title.contains(searchQuery) + }, + ), + ) + + /** + * Test only method to add the topics to the stored list in memory + */ + fun addTopics(topics: List) { + cachedTopics.addAll(topics) + } + + /** + * Test only method to add the news resources to the stored list in memory + */ + fun addNewsResources(newsResources: List) { + cachedNewsResources.addAll(newsResources) + } +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index e32aa1a57..cbcbae3f1 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -36,100 +37,100 @@ import kotlinx.datetime.toInstant * provides list of [UserNewsResource] for Composable previews. */ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider> { - override val values: Sequence> - get() { - val userData: UserData = UserData( - bookmarkedNewsResources = setOf("1", "3"), - followedTopics = emptySet(), - themeBrand = ThemeBrand.ANDROID, - darkThemeConfig = DarkThemeConfig.DARK, - shouldHideOnboarding = true, - useDynamicColor = false, - ) + override val values: Sequence> = sequenceOf(newsResources) +} + +object PreviewParameterData { + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + followedTopics = emptySet(), + themeBrand = ThemeBrand.ANDROID, + darkThemeConfig = DarkThemeConfig.DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) - val topics = listOf( - Topic( - id = "2", - name = "Headlines", - shortDescription = "News we want everyone to see", - longDescription = "Stay up to date with the latest events and announcements from Android!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", - url = "", - ), - Topic( - id = "3", - name = "UI", - shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", - longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", - url = "", - ), - Topic( - id = "4", - name = "Testing", - shortDescription = "CI, Espresso, TestLab, etc", - longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", - url = "", - ), - ) + val topics = listOf( + Topic( + id = "2", + name = "Headlines", + shortDescription = "News we want everyone to see", + longDescription = "Stay up to date with the latest events and announcements from Android!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", + url = "", + ), + Topic( + id = "3", + name = "UI", + shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", + longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", + url = "", + ), + Topic( + id = "4", + name = "Testing", + shortDescription = "CI, Espresso, TestLab, etc", + longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", + url = "", + ), + ) - return sequenceOf( - listOf( - UserNewsResource( - newsResource = NewsResource( - id = "1", - title = "Android Basics with Compose", - content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", - url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", - headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", - publishDate = LocalDateTime( - year = 2022, - monthNumber = 5, - dayOfMonth = 4, - hour = 23, - minute = 0, - second = 0, - nanosecond = 0, - ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, - topics = listOf(topics[2]), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "2", - title = "Thanks for helping us reach 1M YouTube Subscribers", - content = "Thank you everyone for following the Now in Android series and everything the " + - "Android Developers YouTube channel has to offer. During the Android Developer " + - "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + - "thank you all.", - url = "https://youtu.be/-fJ6poHQrjM", - headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = Video, - topics = topics.take(2), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "3", - title = "Transformations and customisations in the Paging Library", - content = "A demonstration of different operations that can be performed " + - "with Paging. Transformations like inserting separators, when to " + - "create a new pager, and customisation options for consuming " + - "PagingData.", - url = "https://youtu.be/ZARz0pjm5YM", - headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = Video, - topics = listOf(topics[2]), - ), - userData = userData, - ), - ), - ) - } + val newsResources = listOf( + UserNewsResource( + newsResource = NewsResource( + id = "1", + title = "Android Basics with Compose", + content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", + url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", + headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", + publishDate = LocalDateTime( + year = 2022, + monthNumber = 5, + dayOfMonth = 4, + hour = 23, + minute = 0, + second = 0, + nanosecond = 0, + ).toInstant(TimeZone.UTC), + type = NewsResourceType.Codelab, + topics = listOf(topics[2]), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "2", + title = "Thanks for helping us reach 1M YouTube Subscribers", + content = "Thank you everyone for following the Now in Android series and everything the " + + "Android Developers YouTube channel has to offer. During the Android Developer " + + "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + + "thank you all.", + url = "https://youtu.be/-fJ6poHQrjM", + headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), + type = Video, + topics = topics.take(2), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "3", + title = "Transformations and customisations in the Paging Library", + content = "A demonstration of different operations that can be performed " + + "with Paging. Transformations like inserting separators, when to " + + "create a new pager, and customisation options for consuming " + + "PagingData.", + url = "https://youtu.be/ZARz0pjm5YM", + headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), + type = Video, + topics = listOf(topics[2]), + ), + userData = userData, + ), + ) } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 71667e4dc..55cbc75b3 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -35,6 +35,7 @@ fun TopicsTabContent( onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + withBottomSpacer: Boolean = true, ) { LazyColumn( modifier = modifier @@ -56,8 +57,10 @@ fun TopicsTabContent( } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } } diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 1b6ae0f9c..a630c90a4 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -26,3 +26,9 @@ android { namespace = "com.google.samples.apps.nowinandroid.feature.search" } +dependencies { + implementation(project(":feature:foryou")) + implementation(project(":feature:interests")) + implementation(libs.kotlinx.datetime) +} + 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 37fed8f85..9834418d9 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 @@ -17,13 +17,24 @@ package com.google.samples.apps.nowinandroid.feature.search import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed 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.onNodeWithText import androidx.compose.ui.test.onParent +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 +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import org.junit.Before import org.junit.Rule import org.junit.Test +import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR /** * UI test for checking the correct behaviour of the Search screen. @@ -33,12 +44,34 @@ class SearchScreenTest { @get:Rule val composeTestRule = createAndroidComposeRule() - private lateinit var clearSearchText: String + private lateinit var clearSearchContentDesc: String + private lateinit var followButtonContentDesc: String + private lateinit var unfollowButtonContentDesc: String + private lateinit var topicsString: String + private lateinit var updatesString: String + private lateinit var tryAnotherSearchString: String + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + followedTopics = emptySet(), + themeBrand = ANDROID, + darkThemeConfig = DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) @Before fun setup() { composeTestRule.activity.apply { - clearSearchText = getString(R.string.clear_search_text) + clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) + followButtonContentDesc = + getString(interestsR.string.card_follow_button_content_desc) + unfollowButtonContentDesc = + getString(interestsR.string.card_unfollow_button_content_desc) + topicsString = getString(R.string.topics) + updatesString = getString(R.string.updates) + tryAnotherSearchString = getString(R.string.try_another_search) + + " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) } } @@ -49,10 +82,75 @@ class SearchScreenTest { } composeTestRule - .onNodeWithContentDescription(clearSearchText) + .onNodeWithContentDescription(clearSearchContentDesc) // The parent of the IconButton whose contentDescription matches the clearSearchText // should be the TextField for search .onParent() .assertIsFocused() } + + @Test + fun emptySearchResult_emptyScreenIsDisplayed() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success(), + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + } + + @Test + fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success(topics = followableTopicTestData), + ) + } + + composeTestRule + .onNodeWithText(topicsString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[0].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[1].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[2].topic.name) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription(followButtonContentDesc) + .assertCountEquals(2) + composeTestRule + .onAllNodesWithContentDescription(unfollowButtonContentDesc) + .assertCountEquals(1) + } + + @Test + fun searchResultWithNewsResources_firstNewsResourcesIsVisible() { + composeTestRule.setContent { + SearchScreen( + uiState = SearchResultUiState.Success( + newsResources = newsResourcesTestData.map { + UserNewsResource( + newsResource = it, + userData = userData, + ) + }, + ), + ) + } + + composeTestRule + .onNodeWithText(updatesString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(newsResourcesTestData[0].title) + .assertIsDisplayed() + } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt new file mode 100644 index 000000000..14967fa5e --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt @@ -0,0 +1,40 @@ +/* + * 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.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource + +sealed interface SearchResultUiState { + object Loading : SearchResultUiState + + /** + * The state query is empty or too short. To distinguish the state between the + * (initial state or when the search query is cleared) vs the state where no search + * result is returned, explicitly define the empty query state. + */ + object EmptyQuery : SearchResultUiState + + object LoadFailed : SearchResultUiState + + data class Success( + val topics: List = emptyList(), + val newsResources: List = emptyList(), + ) : SearchResultUiState { + fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty() + } +} 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 206aa609c..4b428491b 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 @@ -16,52 +16,90 @@ package com.google.samples.apps.nowinandroid.feature.search +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.grid.GridCells.Adaptive +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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.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.FontWeight +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +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.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent +import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank +import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel +import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent import com.google.samples.apps.nowinandroid.feature.search.R as searchR @Composable internal fun SearchRoute( modifier: Modifier = Modifier, onBackClick: () -> Unit, - viewModel: SearchViewModel = hiltViewModel(), + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, + interestsViewModel: InterestsViewModel = hiltViewModel(), + searchViewModel: SearchViewModel = hiltViewModel(), + forYouViewModel: ForYouViewModel = hiltViewModel(), ) { + val uiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle() + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() SearchScreen( modifier = modifier, onBackClick = onBackClick, - onSearchQueryChanged = viewModel::onSearchQueryChanged, + onFollowButtonClick = interestsViewModel::followTopic, + onInterestsClick = onInterestsClick, + onSearchQueryChanged = searchViewModel::onSearchQueryChanged, + onTopicClick = onTopicClick, + onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, + searchQuery = searchQuery, + uiState = uiState, ) } @@ -69,27 +107,162 @@ internal fun SearchRoute( internal fun SearchScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, + onInterestsClick: () -> Unit = {}, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, onSearchQueryChanged: (String) -> Unit = {}, + onTopicClick: (String) -> Unit = {}, + searchQuery: String = "", + uiState: SearchResultUiState = SearchResultUiState.Loading, ) { TrackScreenViewEvent(screenName = "Search") - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(modifier = modifier) { Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) SearchToolbar( onBackClick = onBackClick, onSearchQueryChanged = onSearchQueryChanged, + searchQuery = searchQuery, ) + when (uiState) { + SearchResultUiState.Loading, + SearchResultUiState.LoadFailed, + SearchResultUiState.EmptyQuery, + -> Unit + is SearchResultUiState.Success -> { + if (uiState.isEmpty()) { + EmptySearchResultBody( + onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + ) + } else { + SearchResultBody( + topics = uiState.topics, + onFollowButtonClick = onFollowButtonClick, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onTopicClick = onTopicClick, + newsResources = uiState.newsResources, + ) + } + } + } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } +@Composable +fun EmptySearchResultBody( + onInterestsClick: () -> Unit, + searchQuery: String, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val message = stringResource(id = searchR.string.search_result_not_found, searchQuery) + val start = message.indexOf(searchQuery) + Text( + text = AnnotatedString( + text = message, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + searchQuery.length, + ), + ), + ), + modifier = Modifier.padding(horizontal = 36.dp, vertical = 24.dp), + ) + val interests = stringResource(id = searchR.string.interests) + val tryAnotherSearchString = buildAnnotatedString { + append(stringResource(id = searchR.string.try_another_search)) + append(" ") + withStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + ) { + pushStringAnnotation(tag = interests, annotation = interests) + append(interests) + } + append(" ") + append(stringResource(id = searchR.string.to_browse_topics)) + } + ClickableText( + text = tryAnotherSearchString, + modifier = Modifier + .padding(start = 36.dp, end = 36.dp, bottom = 24.dp) + .clickable {}, + ) { offset -> + tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) + .firstOrNull() + ?.let { + onInterestsClick() + } + } + } +} + +@Composable +private fun SearchResultBody( + topics: List, + newsResources: List, + onFollowButtonClick: (String, Boolean) -> Unit, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onTopicClick: (String) -> Unit, +) { + if (topics.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.topics)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + TopicsTabContent( + topics = topics, + onTopicClick = onTopicClick, + onFollowButtonClick = onFollowButtonClick, + withBottomSpacer = false, + ) + } + + if (newsResources.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.updates)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + val state = rememberLazyGridState() + TrackScrollJank(scrollableState = state, stateName = "search:newsResource") + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:newsResources"), + state = state, + ) { + newsFeed( + feedState = NewsFeedUiState.Success(feed = newsResources), + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onTopicClick = onTopicClick, + ) + } + } +} + @Composable private fun SearchToolbar( modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, - onSearchQueryChanged: (String) -> Unit = {}, + onBackClick: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + searchQuery: String = "", ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -103,14 +276,19 @@ private fun SearchToolbar( ), ) } - SearchTextField(onSearchQueryChanged = onSearchQueryChanged) + SearchTextField( + onSearchQueryChanged = onSearchQueryChanged, + searchQuery = searchQuery, + ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { - val textState = remember { mutableStateOf("") } +private fun SearchTextField( + onSearchQueryChanged: (String) -> Unit, + searchQuery: String, +) { val focusRequester = remember { FocusRequester() } TextField( colors = TextFieldDefaults.textFieldColors( @@ -128,26 +306,27 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { ) }, trailingIcon = { - IconButton(onClick = { textState.value = "" }) { + IconButton(onClick = { + onSearchQueryChanged("") + }) { Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_search_text, + id = searchR.string.clear_search_text_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) } }, onValueChange = { - textState.value = it onSearchQueryChanged(it) }, modifier = Modifier .fillMaxWidth() - .padding(12.dp) + .padding(16.dp) .focusRequester(focusRequester), shape = RoundedCornerShape(32.dp), - value = textState.value, + value = searchQuery, ) LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -158,14 +337,31 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) { @Composable private fun SearchToolbarPreview() { NiaTheme { - SearchToolbar() + SearchToolbar( + onBackClick = {}, + onSearchQueryChanged = {}, + ) + } +} + +@Preview +@Composable +private fun EmptySearchResultColumnPreview() { + NiaTheme { + EmptySearchResultBody( + onInterestsClick = {}, + searchQuery = "C++", + ) } } @DevicePreviews @Composable -private fun SearchScreenPreview() { +private fun SearchScreenPreview( + @PreviewParameter(SearchUiStatePreviewParameterProvider::class) + searchResultUiState: SearchResultUiState, +) { NiaTheme { - SearchScreen() + SearchScreen(uiState = searchResultUiState) } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000..06dac9bb2 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.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.feature.search + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics + +/* ktlint-disable max-line-length */ +/** + * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) + * provides list of [SearchResultUiState] for Composable previews. + */ +class SearchUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + SearchResultUiState.Success( + topics = topics.mapIndexed { i, topic -> + FollowableTopic(topic = topic, isFollowed = i % 2 == 0) + }, + newsResources = newsResources, + ), + ) +} 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 00194af0a..3d44df6e0 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 @@ -16,15 +16,65 @@ 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.domain.GetSearchContentsUseCase +import com.google.samples.apps.nowinandroid.core.result.Result +import com.google.samples.apps.nowinandroid.core.result.asResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel -class SearchViewModel @Inject constructor() : ViewModel() { +class SearchViewModel @Inject constructor( + // TODO: Add GetSearchContentsCountUseCase to check if the fts tables are populated + getSearchContentsUseCase: GetSearchContentsUseCase, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { - fun onSearchQueryChanged(searchQuery: String) { - // TODO: Pass it to UseCase - println("New search query is '$searchQuery'") + val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "") + + val searchResultUiState: StateFlow = + searchQuery.flatMapLatest { query -> + if (query.length < SEARCH_QUERY_MIN_LENGTH) { + flowOf(SearchResultUiState.EmptyQuery) + } else { + getSearchContentsUseCase(query).asResult().map { + when (it) { + is Result.Success -> { + SearchResultUiState.Success( + topics = it.data.topics, + newsResources = it.data.newsResources, + ) + } + + is Result.Loading -> { + SearchResultUiState.Loading + } + + is Result.Error -> { + SearchResultUiState.LoadFailed + } + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchResultUiState.Loading, + ) + + fun onSearchQueryChanged(query: String) { + savedStateHandle[SEARCH_QUERY] = query } } + +/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */ +const val SEARCH_QUERY_MIN_LENGTH = 2 +const val SEARCH_QUERY = "searchQuery" diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt index 5122ce0c7..42bf3f475 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -28,10 +28,18 @@ fun NavController.navigateToSearch(navOptions: NavOptions? = null) { this.navigate(searchRoute, navOptions) } -fun NavGraphBuilder.searchScreen(onBackClick: () -> Unit) { +fun NavGraphBuilder.searchScreen( + onBackClick: () -> Unit, + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, +) { // TODO: Handle back stack for each top-level destination. At the moment each top-level // destination may have own search screen's back stack. composable(route = searchRoute) { - SearchRoute(onBackClick = onBackClick) + SearchRoute( + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, + onTopicClick = onTopicClick, + ) } } diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index e97f97089..374481d86 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -16,5 +16,11 @@ --> Search - Clear search text + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Try another search or explorer + Interests + to browse topics + Topics + Updates \ 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 new file mode 100644 index 000000000..bcea9aefd --- /dev/null +++ b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt @@ -0,0 +1,91 @@ +/* + * 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 androidx.lifecycle.SavedStateHandle +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.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.SearchResultUiState.EmptyQuery +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + */ +class SearchViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val userDataRepository = TestUserDataRepository() + private val searchContentsRepository = TestSearchContentsRepository() + private val getSearchContentsUseCase = GetSearchContentsUseCase( + searchContentsRepository = searchContentsRepository, + userDataRepository = userDataRepository, + ) + private lateinit var viewModel: SearchViewModel + + @Before + fun setup() { + viewModel = SearchViewModel( + getSearchContentsUseCase = getSearchContentsUseCase, + savedStateHandle = SavedStateHandle(), + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertEquals(Loading, viewModel.searchResultUiState.value) + } + + @Test + fun stateIsEmptyQuery_withEmptySearchQuery() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("") + assertEquals(EmptyQuery, viewModel.searchResultUiState.value) + + collectJob.cancel() + } + + @Test + fun emptyResultIsReturned_withNotMatchingQuery() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("XXX") + searchContentsRepository.addNewsResources(newsResourcesTestData) + searchContentsRepository.addTopics(topicsTestData) + + val result = viewModel.searchResultUiState.value + // TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery + // assertIs(result) + + collectJob.cancel() + } +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 211940ddb..afa2a9d89 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +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.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource @@ -52,6 +53,7 @@ class SyncWorker @AssistedInject constructor( private val niaPreferences: NiaPreferencesDataSource, private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, + private val searchContentsRepository: SearchContentsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val analyticsHelper: AnalyticsHelper, ) : CoroutineWorker(appContext, workerParams), Synchronizer { @@ -72,6 +74,7 @@ class SyncWorker @AssistedInject constructor( analyticsHelper.logSyncFinished(syncedSuccessfully) if (syncedSuccessfully) { + searchContentsRepository.populateFtsData() Result.success() } else { Result.retry()