Skip to content

Commit b3cdc17

Browse files
authored
Implement search feature (#685)
Implement search feature - Add a feature module named "search" - Add a SearchScreen that is navigated by tapping the search icon at the top left corner - Add a data layer that takes care of populating the *Fts tables and querying them by a search query - Add a SearchViewModel that wires up the data layer of the Fts tables with the SearchScreen The SearchScreen has following features: - The user is able to type the search query in the TextField - The search result is displayed as the user types - When the search result is clicked, it navigates to: - The InterestsScreen when a topic is clicked - Chrome custom tab with the URL of the clicked news resource - When the search result is clicked or the IME is explicitly closed by the user, the current search query in the TextField is saved as recent searches - Latest recent searches are displayed in the SearchScreen
1 parent 73a3872 commit b3cdc17

File tree

53 files changed

+2905
-101
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2905
-101
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ dependencies {
8383
implementation(project(":feature:foryou"))
8484
implementation(project(":feature:bookmarks"))
8585
implementation(project(":feature:topic"))
86+
implementation(project(":feature:search"))
8687
implementation(project(":feature:settings"))
8788

8889
implementation(project(":core:common"))

app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ package com.google.samples.apps.nowinandroid.navigation
1818

1919
import androidx.compose.runtime.Composable
2020
import androidx.compose.ui.Modifier
21-
import androidx.navigation.NavHostController
2221
import androidx.navigation.compose.NavHost
2322
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
2423
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
2524
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
2625
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
26+
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
2727
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
2828
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
29+
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
30+
import com.google.samples.apps.nowinandroid.ui.NiaAppState
2931

3032
/**
3133
* Top-level navigation graph. Navigation is organized as explained at
@@ -36,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
3638
*/
3739
@Composable
3840
fun NiaNavHost(
39-
navController: NavHostController,
41+
appState: NiaAppState,
4042
modifier: Modifier = Modifier,
4143
startDestination: String = forYouNavigationRoute,
4244
) {
45+
val navController = appState.navController
4346
NavHost(
4447
navController = navController,
4548
startDestination = startDestination,
@@ -48,6 +51,11 @@ fun NiaNavHost(
4851
// TODO: handle topic clicks from each top level destination
4952
forYouScreen(onTopicClick = {})
5053
bookmarksScreen(onTopicClick = {})
54+
searchScreen(
55+
onBackClick = navController::popBackStack,
56+
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
57+
onTopicClick = navController::navigateToTopic,
58+
)
5159
interestsGraph(
5260
onTopicClick = { topicId ->
5361
navController.navigateToTopic(topicId)

app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ fun NiaApp(
173173
if (destination != null) {
174174
NiaTopAppBar(
175175
titleRes = destination.titleTextId,
176+
navigationIcon = NiaIcons.Search,
177+
navigationIconContentDescription = stringResource(
178+
id = settingsR.string.top_app_bar_navigation_icon_description,
179+
),
176180
actionIcon = NiaIcons.Settings,
177181
actionIconContentDescription = stringResource(
178182
id = settingsR.string.top_app_bar_action_icon_description,
@@ -181,10 +185,11 @@ fun NiaApp(
181185
containerColor = Color.Transparent,
182186
),
183187
onActionClick = { appState.setShowSettingsDialog(true) },
188+
onNavigationClick = { appState.navigateToSearch() },
184189
)
185190
}
186191

187-
NiaNavHost(appState.navController)
192+
NiaNavHost(appState)
188193
}
189194

190195
// TODO: We may want to add padding or spacer when the snackbar is shown so that

app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavi
4242
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
4343
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute
4444
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
45+
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
4546
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
4647
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
4748
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
@@ -172,6 +173,10 @@ class NiaAppState(
172173
fun setShowSettingsDialog(shouldShow: Boolean) {
173174
shouldShowSettingsDialog = shouldShow
174175
}
176+
177+
fun navigateToSearch() {
178+
navController.navigateToSearch()
179+
}
175180
}
176181

177182
/**

core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ package com.google.samples.apps.nowinandroid.core.data.test
1818

1919
import com.google.samples.apps.nowinandroid.core.data.di.DataModule
2020
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
21+
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
22+
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
2123
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
2224
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
2325
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
26+
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository
27+
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
2428
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
2529
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
2630
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
@@ -50,6 +54,16 @@ interface TestDataModule {
5054
userDataRepository: FakeUserDataRepository,
5155
): UserDataRepository
5256

57+
@Binds
58+
fun bindsRecentSearchRepository(
59+
recentSearchRepository: FakeRecentSearchRepository,
60+
): RecentSearchRepository
61+
62+
@Binds
63+
fun bindsSearchContentsRepository(
64+
searchContentsRepository: FakeSearchContentsRepository,
65+
): SearchContentsRepository
66+
5367
@Binds
5468
fun bindsNetworkMonitor(
5569
networkMonitor: AlwaysOnlineNetworkMonitor,

core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616

1717
package com.google.samples.apps.nowinandroid.core.data.di
1818

19+
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository
20+
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository
1921
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
2022
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
2123
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
2224
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
25+
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
26+
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
2327
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
2428
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
2529
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
@@ -48,6 +52,16 @@ interface DataModule {
4852
userDataRepository: OfflineFirstUserDataRepository,
4953
): UserDataRepository
5054

55+
@Binds
56+
fun bindsRecentSearchRepository(
57+
recentSearchRepository: DefaultRecentSearchRepository,
58+
): RecentSearchRepository
59+
60+
@Binds
61+
fun bindsSearchContentsRepository(
62+
searchContentsRepository: DefaultSearchContentsRepository,
63+
): SearchContentsRepository
64+
5165
@Binds
5266
fun bindsNetworkMonitor(
5367
networkMonitor: ConnectivityManagerNetworkMonitor,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.data.model
18+
19+
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
20+
import kotlinx.datetime.Clock
21+
import kotlinx.datetime.Instant
22+
23+
data class RecentSearchQuery(
24+
val query: String,
25+
val queriedDate: Instant = Clock.System.now(),
26+
)
27+
28+
fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery(
29+
query = query,
30+
queriedDate = queriedDate,
31+
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.data.repository
18+
19+
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
20+
import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel
21+
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
22+
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
23+
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
24+
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
25+
import kotlinx.coroutines.CoroutineDispatcher
26+
import kotlinx.coroutines.flow.Flow
27+
import kotlinx.coroutines.flow.map
28+
import kotlinx.coroutines.withContext
29+
import kotlinx.datetime.Clock
30+
import javax.inject.Inject
31+
32+
class DefaultRecentSearchRepository @Inject constructor(
33+
private val recentSearchQueryDao: RecentSearchQueryDao,
34+
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
35+
) : RecentSearchRepository {
36+
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {
37+
withContext(ioDispatcher) {
38+
recentSearchQueryDao.insertOrReplaceRecentSearchQuery(
39+
RecentSearchQueryEntity(
40+
query = searchQuery,
41+
queriedDate = Clock.System.now(),
42+
),
43+
)
44+
}
45+
}
46+
47+
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
48+
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
49+
searchQueries.map {
50+
it.asExternalModel()
51+
}
52+
}
53+
54+
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()
55+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.data.repository
18+
19+
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
20+
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
21+
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
22+
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
23+
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
24+
import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity
25+
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
26+
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
27+
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
28+
import kotlinx.coroutines.CoroutineDispatcher
29+
import kotlinx.coroutines.flow.Flow
30+
import kotlinx.coroutines.flow.combine
31+
import kotlinx.coroutines.flow.distinctUntilChanged
32+
import kotlinx.coroutines.flow.flatMapLatest
33+
import kotlinx.coroutines.flow.mapLatest
34+
import kotlinx.coroutines.withContext
35+
import javax.inject.Inject
36+
37+
class DefaultSearchContentsRepository @Inject constructor(
38+
private val newsResourceDao: NewsResourceDao,
39+
private val newsResourceFtsDao: NewsResourceFtsDao,
40+
private val topicDao: TopicDao,
41+
private val topicFtsDao: TopicFtsDao,
42+
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
43+
) : SearchContentsRepository {
44+
45+
override suspend fun populateFtsData() {
46+
withContext(ioDispatcher) {
47+
newsResourceFtsDao.insertAll(
48+
newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() },
49+
)
50+
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
51+
}
52+
}
53+
54+
override fun searchContents(searchQuery: String): Flow<SearchResult> {
55+
// Surround the query by asterisks to match the query when it's in the middle of
56+
// a word
57+
val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*")
58+
val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*")
59+
60+
val newsResourcesFlow = newsResourceIds
61+
.mapLatest { it.toSet() }
62+
.distinctUntilChanged()
63+
.flatMapLatest {
64+
newsResourceDao.getNewsResources(useFilterNewsIds = true, filterNewsIds = it)
65+
}
66+
val topicsFlow = topicIds
67+
.mapLatest { it.toSet() }
68+
.distinctUntilChanged()
69+
.flatMapLatest(topicDao::getTopicEntities)
70+
return combine(newsResourcesFlow, topicsFlow) { newsResources, topics ->
71+
SearchResult(
72+
topics = topics.map { it.asExternalModel() },
73+
newsResources = newsResources.map { it.asExternalModel() },
74+
)
75+
}
76+
}
77+
78+
override fun getSearchContentsCount(): Flow<Int> =
79+
combine(
80+
newsResourceFtsDao.getCount(),
81+
topicFtsDao.getCount(),
82+
) { newsResourceCount, topicsCount ->
83+
newsResourceCount + topicsCount
84+
}
85+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.data.repository
18+
19+
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
20+
import kotlinx.coroutines.flow.Flow
21+
22+
/**
23+
* Data layer interface for the recent searches.
24+
*/
25+
interface RecentSearchRepository {
26+
27+
/**
28+
* Get the recent search queries up to the number of queries specified as [limit].
29+
*/
30+
fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>>
31+
32+
/**
33+
* Insert or replace the [searchQuery] as part of the recent searches.
34+
*/
35+
suspend fun insertOrReplaceRecentSearch(searchQuery: String)
36+
37+
/**
38+
* Clear the recent searches.
39+
*/
40+
suspend fun clearRecentSearches()
41+
}

0 commit comments

Comments
 (0)