diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 33e1cdbc..c25951a0 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.compose) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.realm) alias(libs.plugins.kover) } @@ -30,6 +31,19 @@ android { } } +sonar { + properties { + property( + "sonar.coverage.exclusions", + properties["sonar.coverage.exclusions"].toString() + ",**/SearchEncryptionHelper.*,**/SearchRealmProvider.*" + ) + property( + "sonar.cpd.exclusions", + properties["sonar.cpd.exclusions"].toString() + ",**/SearchEncryptionHelper.*,**/SearchRealmProvider.*" + ) + } +} + dependencies { implementation(projects.design) implementation(projects.analytics) @@ -41,9 +55,11 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.hilt.android) + implementation(libs.androidx.datastore.preferences) implementation(libs.retrofit) implementation(libs.retrofit.gson) + implementation(libs.realm.base) ksp(libs.hilt.compiler) diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/SearchUiState.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/SearchUiState.kt index 3549d9f1..8d448660 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/SearchUiState.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/SearchUiState.kt @@ -1,21 +1,26 @@ package uk.govuk.app.search -import uk.govuk.app.search.data.remote.model.Result +import uk.govuk.app.search.data.remote.model.SearchResult import java.util.UUID -internal sealed class SearchUiState( - val uuid: UUID, - val searchTerm: String -) { +internal sealed class SearchUiState() { + internal class Default( - uuid: UUID, - searchTerm: String, - val searchResults: List - ) : SearchUiState(uuid = uuid, searchTerm = searchTerm) + val previousSearches: List + ): SearchUiState() + + internal class Results( + val searchTerm: String, + val searchResults: List + ) : SearchUiState() - internal class Empty(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm) + internal sealed class Error( + val uuid: UUID + ) : SearchUiState() { + internal class Empty(uuid: UUID, val searchTerm: String) : Error(uuid) - internal class Offline(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm) + internal class Offline(uuid: UUID, val searchTerm: String) : Error(uuid) - internal class ServiceError(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm) + internal class ServiceError(uuid: UUID) : Error(uuid) + } } diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/SearchViewModel.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/SearchViewModel.kt index f3188608..0384f808 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/SearchViewModel.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/SearchViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import uk.govuk.app.analytics.AnalyticsClient import uk.govuk.app.networking.domain.DeviceOfflineException +import uk.govuk.app.search.SearchUiState.* import uk.govuk.app.search.data.SearchRepo import uk.govuk.app.visited.Visited import java.util.UUID @@ -17,50 +18,59 @@ import javax.inject.Inject internal class SearchViewModel @Inject constructor( private val analyticsClient: AnalyticsClient, private val visited: Visited, - private val repository: SearchRepo + private val searchRepo: SearchRepo ): ViewModel() { + companion object { + private const val SCREEN_CLASS = "SearchScreen" + private const val SCREEN_NAME = "Search" + private const val TITLE = "Search" + } + private val _uiState: MutableStateFlow = MutableStateFlow(null) val uiState = _uiState.asStateFlow() + init { + emitPreviousSearches() + } + + fun onPageView() { + analyticsClient.screenView( + screenClass = SCREEN_CLASS, + screenName = SCREEN_NAME, + title = TITLE + ) + } + + private fun emitPreviousSearches() { + viewModelScope.launch { + _uiState.value = Default(searchRepo.fetchPreviousSearches()) + } + } + private fun fetchSearchResults(searchTerm: String) { viewModelScope.launch { val id = UUID.randomUUID() - val searchResult = repository.performSearch(searchTerm) + val searchResult = searchRepo.performSearch(searchTerm.trim()) searchResult.onSuccess { result -> if (result.results.isNotEmpty()) { - _uiState.value = SearchUiState.Default( - uuid = id, + _uiState.value = Results( searchTerm = searchTerm, searchResults = result.results ) } else { - _uiState.value = SearchUiState.Empty(id, searchTerm) + _uiState.value = Error.Empty(id, searchTerm) } } searchResult.onFailure { exception -> _uiState.value = when (exception) { - is DeviceOfflineException -> SearchUiState.Offline(id, searchTerm) - else -> SearchUiState.ServiceError(id, searchTerm) + is DeviceOfflineException -> Error.Offline(id, searchTerm) + else -> Error.ServiceError(id) } } } } - companion object { - private const val SCREEN_CLASS = "SearchScreen" - private const val SCREEN_NAME = "Search" - private const val TITLE = "Search" - } - - fun onPageView() { - analyticsClient.screenView( - screenClass = SCREEN_CLASS, - screenName = SCREEN_NAME, - title = TITLE - ) - } - fun onSearch(searchTerm: String) { fetchSearchResults(searchTerm) analyticsClient.search(searchTerm) @@ -74,6 +84,20 @@ internal class SearchViewModel @Inject constructor( } fun onClear() { - _uiState.value = null + emitPreviousSearches() + } + + fun onRemoveAllPreviousSearches() { + viewModelScope.launch { + searchRepo.removeAllPreviousSearches() + } + emitPreviousSearches() + } + + fun onRemovePreviousSearch(searchTerm: String) { + viewModelScope.launch { + searchRepo.removePreviousSearch(searchTerm) + } + emitPreviousSearches() } } diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/SearchRepo.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/SearchRepo.kt index d03a4237..69fd806f 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/data/SearchRepo.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/SearchRepo.kt @@ -2,6 +2,7 @@ package uk.govuk.app.search.data import uk.govuk.app.networking.domain.DeviceOfflineException import uk.govuk.app.networking.domain.ServiceNotRespondingException +import uk.govuk.app.search.data.local.SearchLocalDataSource import uk.govuk.app.search.data.remote.SearchApi import uk.govuk.app.search.data.remote.model.SearchResponse import uk.govuk.app.search.domain.SearchConfig @@ -9,18 +10,34 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class SearchRepo @Inject constructor( - private val searchApi: SearchApi +internal class SearchRepo @Inject constructor( + private val searchApi: SearchApi, + private val localDataSource: SearchLocalDataSource ) { + + suspend fun fetchPreviousSearches(): List { + return localDataSource.fetchPreviousSearches().map { it.searchTerm } + } + + suspend fun removePreviousSearch(searchTerm: String) { + localDataSource.removePreviousSearch(searchTerm) + } + + suspend fun removeAllPreviousSearches() { + localDataSource.removeAllPreviousSearches() + } + suspend fun performSearch( searchTerm: String, count: Int = SearchConfig.DEFAULT_RESULTS_PER_PAGE ): Result { + localDataSource.insertOrUpdatePreviousSearch(searchTerm) + return try { val response = searchApi.getSearchResults(searchTerm, count) Result.success(response) - } catch (e: java.net.UnknownHostException) { + } catch (_: java.net.UnknownHostException) { Result.failure(DeviceOfflineException()) - } catch (e: retrofit2.HttpException) { + } catch (_: retrofit2.HttpException) { Result.failure(ServiceNotRespondingException()) } catch (e: Exception) { Result.failure(e) diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchDataStore.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchDataStore.kt new file mode 100644 index 00000000..88bb2c13 --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchDataStore.kt @@ -0,0 +1,38 @@ +package uk.govuk.app.search.data.local + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class SearchDataStore @Inject constructor( + private val dataStore: androidx.datastore.core.DataStore +) { + + companion object { + internal const val REALM_SEARCH_KEY = "realm_search_key" + internal const val REALM_SEARCH_IV = "realm_search_iv" + } + + internal suspend fun getRealmSearchKey(): String? { + return dataStore.data.firstOrNull()?.get(stringPreferencesKey(REALM_SEARCH_KEY)) + } + + internal suspend fun saveRealmSearchKey(key: String) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(REALM_SEARCH_KEY)] = key + } + } + + internal suspend fun getRealmSearchIv(): String? { + return dataStore.data.firstOrNull()?.get(stringPreferencesKey(REALM_SEARCH_IV)) + } + + internal suspend fun saveRealmSearchIv(iv: String) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(REALM_SEARCH_IV)] = iv + } + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchEncryptionHelper.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchEncryptionHelper.kt new file mode 100644 index 00000000..28ef79d2 --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchEncryptionHelper.kt @@ -0,0 +1,102 @@ +package uk.govuk.app.search.data.local + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject + +internal class SearchEncryptionHelper @Inject constructor( + private val dataStore: SearchDataStore +) { + + private companion object { + private const val KEYSTORE_KEY_ALIAS = "realm_search_key" + } + + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore") + + init { + keyStore.load(null) + } + + suspend fun getRealmKey(): ByteArray { + val encryptedRealmKey = dataStore.getRealmSearchKey() + val realmIv = dataStore.getRealmSearchIv() + + return if (encryptedRealmKey != null && realmIv != null && + keyStore.containsAlias(KEYSTORE_KEY_ALIAS) + ) { + decryptRealmKey( + encryptedKeyString = encryptedRealmKey, + ivString = realmIv + ).encoded + } else { + val keystoreKey = createKeystoreKey() + val realmEncryptionKey = createRealmEncryptionKey() + encryptAndSaveRealmKey(realmEncryptionKey, keystoreKey) + realmEncryptionKey + } + } + + private fun decryptRealmKey( + encryptedKeyString: String, + ivString: String + ): SecretKey { + val encryptedKey = Base64.decode(encryptedKeyString, Base64.DEFAULT) + val iv = Base64.decode(ivString, Base64.DEFAULT) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, getKeystoreKey(), GCMParameterSpec(128, iv)) // Using the stored IV + + val decryptedKeyBytes = cipher.doFinal(encryptedKey) + return SecretKeySpec(decryptedKeyBytes, "AES") // Convert to SecretKey + } + + private fun getKeystoreKey(): SecretKey { + val secretKeyEntry = keyStore.getEntry(KEYSTORE_KEY_ALIAS, null) as KeyStore.SecretKeyEntry + return secretKeyEntry.secretKey + } + + private fun createKeystoreKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + KEYSTORE_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) // Generate a 256-bit key + .build() + + keyGenerator.init(keyGenParameterSpec) + return keyGenerator.generateKey() + } + + private fun createRealmEncryptionKey(): ByteArray { + val key = ByteArray(64) // 256 bits = 32 bytes + val secureRandom = SecureRandom() + secureRandom.nextBytes(key) // Fill the byte array with random bytes + return key + } + + private suspend fun encryptAndSaveRealmKey(realmKey: ByteArray, keystoreKey: SecretKey) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keystoreKey) + + val iv = cipher.iv + val encryptedKey = cipher.doFinal(realmKey) + + val encodedKey = Base64.encodeToString(encryptedKey, Base64.DEFAULT) + val encodedIv = Base64.encodeToString(iv, Base64.DEFAULT) + + dataStore.saveRealmSearchKey(encodedKey) + dataStore.saveRealmSearchIv(encodedIv) + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSource.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSource.kt new file mode 100644 index 00000000..b577e8ef --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSource.kt @@ -0,0 +1,67 @@ +package uk.govuk.app.search.data.local + +import io.realm.kotlin.ext.query +import io.realm.kotlin.query.Sort +import uk.govuk.app.search.data.local.model.LocalSearchItem +import uk.govuk.app.search.domain.SearchConfig +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class SearchLocalDataSource @Inject constructor( + private val realmProvider: SearchRealmProvider, +) { + + suspend fun fetchPreviousSearches(): List { + return realmProvider.open() + .query() + .sort("timestamp", Sort.DESCENDING) + .find() + } + + suspend fun insertOrUpdatePreviousSearch(searchTerm: String) { + realmProvider.open().writeBlocking { + val localSearch = query("searchTerm = $0", searchTerm).first().find() + val now = Calendar.getInstance().timeInMillis + + localSearch?.apply { + this.timestamp = now + } ?: run { + copyToRealm( + LocalSearchItem().apply { + this.searchTerm = searchTerm + this.timestamp = now + } + ) + + val localSearches = query().sort("timestamp", Sort.DESCENDING).find() + if (localSearches.size > SearchConfig.MAX_PREVIOUS_SEARCH_COUNT) { + localSearches.drop(SearchConfig.MAX_PREVIOUS_SEARCH_COUNT).forEach { + findLatest(it)?.apply { + delete(this) + } + } + } + } + } + } + + suspend fun removePreviousSearch(searchTerm: String) { + realmProvider.open().writeBlocking { + val localSearch = query("searchTerm = $0", searchTerm).first().find() + + localSearch?.let { + findLatest(it)?.apply { + delete(this) + } + } + } + } + + suspend fun removeAllPreviousSearches() { + realmProvider.open().writeBlocking { + deleteAll() + } + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchRealmProvider.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchRealmProvider.kt new file mode 100644 index 00000000..4b69292e --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/SearchRealmProvider.kt @@ -0,0 +1,33 @@ +package uk.govuk.app.search.data.local + +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import uk.govuk.app.search.data.local.model.LocalSearchItem +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class SearchRealmProvider @Inject constructor( + private val encryptionHelper: SearchEncryptionHelper +) { + + private companion object { + private const val REALM_NAME = "search" + } + + private lateinit var realm: Realm + + suspend fun open(): Realm { + if (!::realm.isInitialized) { + val realmKey = encryptionHelper.getRealmKey() + + val config = RealmConfiguration.Builder(schema = setOf(LocalSearchItem::class)) + .name(REALM_NAME) + .encryptionKey(realmKey) + .build() + realm = Realm.open(config) + } + + return realm + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/model/LocalSearchItem.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/model/LocalSearchItem.kt new file mode 100644 index 00000000..5c045364 --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/local/model/LocalSearchItem.kt @@ -0,0 +1,10 @@ +package uk.govuk.app.search.data.local.model + +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PrimaryKey + +internal class LocalSearchItem : RealmObject { + @PrimaryKey + var searchTerm: String = "" + var timestamp: Long = 0 +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResponse.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResponse.kt index 4e446843..f734c2b0 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResponse.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResponse.kt @@ -4,5 +4,5 @@ import com.google.gson.annotations.SerializedName data class SearchResponse( @SerializedName("total") val total: Int, - @SerializedName("results") val results: List + @SerializedName("results") val results: List ) diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/Result.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResult.kt similarity index 93% rename from feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/Result.kt rename to feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResult.kt index 8fcf6b8d..61d1c74f 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/Result.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/data/remote/model/SearchResult.kt @@ -3,7 +3,7 @@ package uk.govuk.app.search.data.remote.model import com.google.gson.annotations.SerializedName import uk.govuk.app.search.domain.SearchConfig.DESCRIPTION_RESPONSE_FIELD -data class Result( +data class SearchResult( @SerializedName("title") val title: String, @SerializedName(DESCRIPTION_RESPONSE_FIELD) val description: String?, @SerializedName("link") val link: String diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/domain/SearchConfig.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/domain/SearchConfig.kt index e8e6c164..5cb6583a 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/domain/SearchConfig.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/domain/SearchConfig.kt @@ -1,7 +1,7 @@ package uk.govuk.app.search.domain object SearchConfig { - val BASE_URL = "https://www.gov.uk" + const val BASE_URL = "https://www.gov.uk" // Search API V1 ===> // val API_BASE_URL = "https://www.gov.uk" @@ -9,10 +9,12 @@ object SearchConfig { // const val DESCRIPTION_RESPONSE_FIELD = "description" // Search API V2 ===> - val API_BASE_URL = "https://search.publishing.service.gov.uk" + const val API_BASE_URL = "https://search.publishing.service.gov.uk" const val SEARCH_PATH = "/v0_1/search.json" // field has both changed name and is now optional! const val DESCRIPTION_RESPONSE_FIELD = "description_with_highlighting" - val DEFAULT_RESULTS_PER_PAGE = 10 + const val DEFAULT_RESULTS_PER_PAGE = 10 + + const val MAX_PREVIOUS_SEARCH_COUNT = 5 } diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchErrors.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchErrors.kt new file mode 100644 index 00000000..80f57dcb --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchErrors.kt @@ -0,0 +1,82 @@ +package uk.govuk.app.search.ui + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import uk.govuk.app.design.ui.component.BodyRegularLabel +import uk.govuk.app.design.ui.theme.GovUkTheme +import uk.govuk.app.networking.ui.component.OfflineMessage +import uk.govuk.app.networking.ui.component.ProblemMessage +import uk.govuk.app.search.R +import uk.govuk.app.search.SearchUiState + +@Composable +internal fun SearchError( + error: SearchUiState.Error, + onRetry: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + + Column(modifier.verticalScroll(rememberScrollState())) { + when (error) { + is SearchUiState.Error.Empty -> + NoResults( + searchTerm = error.searchTerm, + focusRequester = focusRequester + ) + is SearchUiState.Error.Offline -> + OfflineMessage( + onButtonClick = { onRetry(error.searchTerm) }, + focusRequester = focusRequester + ) + is SearchUiState.Error.ServiceError -> + ProblemMessage( + focusRequester = focusRequester + ) + else -> { } // Do nothing + } + } + + LaunchedEffect(error.uuid) { + focusRequester.requestFocus() + } +} + +@Composable +private fun NoResults( + searchTerm: String, + focusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + Row( + modifier.padding( + GovUkTheme.spacing.medium, + GovUkTheme.spacing.large, + GovUkTheme.spacing.medium, + GovUkTheme.spacing.medium + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + BodyRegularLabel( + text = "${stringResource(R.string.search_no_results)} '${searchTerm}'", + modifier = Modifier + .align(Alignment.CenterVertically) + .focusRequester(focusRequester) + .focusable() + ) + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchResults.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchResults.kt new file mode 100644 index 00000000..a17b5ca1 --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchResults.kt @@ -0,0 +1,145 @@ +package uk.govuk.app.search.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import uk.govuk.app.design.ui.component.BodyRegularLabel +import uk.govuk.app.design.ui.component.GovUkCard +import uk.govuk.app.design.ui.component.MediumVerticalSpacer +import uk.govuk.app.design.ui.component.SmallVerticalSpacer +import uk.govuk.app.design.ui.component.Title3BoldLabel +import uk.govuk.app.design.ui.theme.GovUkTheme +import uk.govuk.app.search.R +import uk.govuk.app.search.data.remote.model.SearchResult +import uk.govuk.app.search.domain.StringUtils + +@Composable +internal fun SearchResults( + searchTerm: String, + searchResults: List, + onClick: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + + var previousSearchTerm by rememberSaveable { mutableStateOf("") } + + LazyColumn( + modifier = modifier.fillMaxWidth(), + state = listState + ) { + item { + Header(focusRequester) + } + items(searchResults) { searchResult -> + SearchResult(searchResult, onClick) + } + item { + MediumVerticalSpacer() + } + } + + LaunchedEffect(searchTerm) { + // We only want to trigger scroll and focus if we have a new search (rather than orientation change) + if (searchTerm != previousSearchTerm) { + listState.animateScrollToItem(0) + focusRequester.requestFocus() + previousSearchTerm = searchTerm + } + } +} + +@Composable +private fun Header( + focusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + Title3BoldLabel( + text = stringResource(R.string.search_results_heading), + modifier = modifier + .padding(horizontal = GovUkTheme.spacing.extraLarge,) + .padding(top = GovUkTheme.spacing.medium) + .focusRequester(focusRequester) + .focusable() + .semantics { heading() } + ) +} + +@Composable +private fun SearchResult( + searchResult: SearchResult, + onClick: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + val title = StringUtils.collapseWhitespace(searchResult.title) + val url = StringUtils.buildFullUrl(searchResult.link) + + val context = LocalContext.current + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + + GovUkCard( + modifier = Modifier.padding( + GovUkTheme.spacing.medium, + GovUkTheme.spacing.medium, + GovUkTheme.spacing.medium, + 0.dp + ), + onClick = { + onClick(title, url) + context.startActivity(intent) + } + ) { + Row( + verticalAlignment = Alignment.Top + ) { + BodyRegularLabel( + text = title, + modifier = Modifier.weight(1f), + color = GovUkTheme.colourScheme.textAndIcons.link, + ) + + Icon( + painter = painterResource( + uk.govuk.app.design.R.drawable.ic_external_link + ), + contentDescription = stringResource( + uk.govuk.app.design.R.string.opens_in_web_browser + ), + tint = GovUkTheme.colourScheme.textAndIcons.link, + modifier = Modifier.padding(start = GovUkTheme.spacing.medium) + ) + } + + val description = searchResult.description + if (!description.isNullOrBlank()) { + SmallVerticalSpacer() + BodyRegularLabel(StringUtils.collapseWhitespace(description)) + } + } +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchScreen.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchScreen.kt index a5ad9b4b..e66e5114 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchScreen.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchScreen.kt @@ -1,58 +1,30 @@ package uk.govuk.app.search.ui -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.delay -import uk.govuk.app.design.ui.component.BodyRegularLabel -import uk.govuk.app.design.ui.component.GovUkCard -import uk.govuk.app.design.ui.component.MediumVerticalSpacer -import uk.govuk.app.design.ui.component.SmallVerticalSpacer -import uk.govuk.app.design.ui.component.Title3BoldLabel -import uk.govuk.app.design.ui.theme.GovUkTheme -import uk.govuk.app.networking.ui.component.OfflineMessage -import uk.govuk.app.networking.ui.component.ProblemMessage import uk.govuk.app.search.R import uk.govuk.app.search.SearchUiState +import uk.govuk.app.search.SearchUiState.Error import uk.govuk.app.search.SearchViewModel -import uk.govuk.app.search.data.remote.model.Result -import uk.govuk.app.search.domain.StringUtils import uk.govuk.app.search.ui.component.SearchHeader -import java.util.UUID +import uk.govuk.app.search.ui.component.SearchHeaderActions @Composable internal fun SearchRoute( @@ -66,49 +38,74 @@ internal fun SearchRoute( SearchScreen( uiState = uiState, - onPageView = { viewModel.onPageView() }, - onBack = onBack, - onSearch = { searchTerm -> - keyboardController?.hide() - viewModel.onSearch(searchTerm) - }, - onClear = { - viewModel.onClear() - }, - onResultClick = { title, url -> - viewModel.onSearchResultClicked(title, url) - }, - onRetry = { searchTerm -> - viewModel.onSearch(searchTerm) - }, + actions = SearchScreenActions( + onPageView = { viewModel.onPageView() }, + onBack = onBack, + onSearch = { searchTerm -> + keyboardController?.hide() + viewModel.onSearch(searchTerm) + }, + onClear = { + viewModel.onClear() + }, + onResultClick = { title, url -> + viewModel.onSearchResultClicked(title, url) + }, + onRetry = { searchTerm -> + viewModel.onSearch(searchTerm) + }, + onRemoveAllPreviousSearches = { + viewModel.onRemoveAllPreviousSearches() + }, + onRemovePreviousSearch = { searchTerm -> + viewModel.onRemovePreviousSearch(searchTerm) + } + ), modifier = modifier ) } +private class SearchScreenActions( + val onPageView: () -> Unit, + val onBack: () -> Unit, + val onSearch: (String) -> Unit, + val onClear: () -> Unit, + val onResultClick: (String, String) -> Unit, + val onRetry: (String) -> Unit, + val onRemoveAllPreviousSearches: () -> Unit, + val onRemovePreviousSearch: (String) -> Unit, +) + @Composable private fun SearchScreen( uiState: SearchUiState?, - onPageView: () -> Unit, - onBack: () -> Unit, - onSearch: (String) -> Unit, - onClear: () -> Unit, - onResultClick: (String, String) -> Unit, - onRetry: (String) -> Unit, + actions: SearchScreenActions, modifier: Modifier = Modifier ) { LaunchedEffect(Unit) { - onPageView() + actions.onPageView() } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current + var searchTerm by remember { mutableStateOf("") } + Column(modifier) { SearchHeader( - onBack = onBack, - onSearch = onSearch, - onClear = onClear, + searchTerm = searchTerm, placeholder = stringResource(R.string.search_placeholder), + actions = SearchHeaderActions( + onBack = actions.onBack, + onSearchTermChange = { + searchTerm = it + if (searchTerm.isBlank()) { + actions.onClear() + } + }, + onSearch = { actions.onSearch(searchTerm) }, + onClear = actions.onClear + ), focusRequester = focusRequester ) } @@ -122,171 +119,27 @@ private fun SearchScreen( uiState?.let { when (it) { is SearchUiState.Default -> - ShowResults(it.searchTerm, it.searchResults, onResultClick) - else -> ShowError(uiState.uuid, uiState, onRetry) - } - } ?: ShowNothing() - } - - LaunchedEffect(focusRequester) { - focusRequester.requestFocus() - delay(100) - keyboard?.show() - } -} - -@Composable -private fun ShowResults( - searchTerm: String, - searchResults: List, - onClick: (String, String) -> Unit -) { - val listState = rememberLazyListState() - val focusRequester = remember { FocusRequester() } - - var previousSearchTerm by rememberSaveable { mutableStateOf("") } - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - state = listState - ) { - item { - Title3BoldLabel( - text = stringResource(R.string.search_results_heading), - modifier = Modifier - .padding(horizontal = GovUkTheme.spacing.extraLarge,) - .padding(top = GovUkTheme.spacing.medium) - .focusRequester(focusRequester) - .focusable() - .semantics { heading() } - ) - } - items(searchResults) { searchResult -> - val title = StringUtils.collapseWhitespace(searchResult.title) - val url = StringUtils.buildFullUrl(searchResult.link) - - val context = LocalContext.current - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(url) - - GovUkCard( - modifier = Modifier.padding( - GovUkTheme.spacing.medium, - GovUkTheme.spacing.medium, - GovUkTheme.spacing.medium, - 0.dp - ), - onClick = { - onClick(title, url) - context.startActivity(intent) - } - ) { - Row( - verticalAlignment = Alignment.Top - ) { - BodyRegularLabel( - text = title, - modifier = Modifier.weight(1f), - color = GovUkTheme.colourScheme.textAndIcons.link, + PreviousSearches( + previousSearches = it.previousSearches, + onClick = { + searchTerm = it + actions.onSearch(it) + }, + onRemoveAll = actions.onRemoveAllPreviousSearches, + onRemove = actions.onRemovePreviousSearch ) - Icon( - painter = painterResource( - uk.govuk.app.design.R.drawable.ic_external_link - ), - contentDescription = stringResource( - uk.govuk.app.design.R.string.opens_in_web_browser - ), - tint = GovUkTheme.colourScheme.textAndIcons.link, - modifier = Modifier.padding(start = GovUkTheme.spacing.medium) - ) - } + is SearchUiState.Results -> + SearchResults(it.searchTerm, it.searchResults, actions.onResultClick) - val description = searchResult.description - if (!description.isNullOrBlank()) { - SmallVerticalSpacer() - BodyRegularLabel(StringUtils.collapseWhitespace(description)) - } + else -> SearchError(uiState as Error, actions.onRetry) } } - item { - MediumVerticalSpacer() - } - } - - LaunchedEffect(searchTerm) { - // We only want to trigger scroll and focus if we have a new search (rather than orientation change) - if (searchTerm != previousSearchTerm) { - listState.animateScrollToItem(0) - focusRequester.requestFocus() - previousSearchTerm = searchTerm - } - } -} - -@Composable -private fun NoResultsFound( - searchTerm: String, - focusRequester: FocusRequester -) { - Row( - Modifier.padding( - GovUkTheme.spacing.medium, - GovUkTheme.spacing.large, - GovUkTheme.spacing.medium, - GovUkTheme.spacing.medium - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - BodyRegularLabel( - text = "${stringResource(R.string.search_no_results)} '${searchTerm}'", - modifier = Modifier - .align(Alignment.CenterVertically) - .focusRequester(focusRequester) - .focusable() - ) - } -} - -@Composable -private fun ShowError( - uuid: UUID, - uiState: SearchUiState, - onRetry: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val focusRequester = remember { FocusRequester() } - val searchTerm = uiState.searchTerm - - Column(modifier.verticalScroll(rememberScrollState())) { - when (uiState) { - is SearchUiState.Empty -> - NoResultsFound( - searchTerm = searchTerm, - focusRequester = focusRequester - ) - is SearchUiState.Offline -> - OfflineMessage( - onButtonClick = { onRetry(searchTerm) }, - focusRequester = focusRequester - ) - is SearchUiState.ServiceError -> - ProblemMessage( - focusRequester = focusRequester - ) - else -> { } // Do nothing - } } - LaunchedEffect(uuid) { + LaunchedEffect(focusRequester) { focusRequester.requestFocus() + delay(100) + keyboard?.show() } } - -@Composable -private fun ShowNothing() { - // does nothing on purpose as this is shown before - // the user actually searches or when an unknown - // error occurs. -} diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchSuggestions.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchSuggestions.kt new file mode 100644 index 00000000..6b4fc2f8 --- /dev/null +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/SearchSuggestions.kt @@ -0,0 +1,171 @@ +package uk.govuk.app.search.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import uk.govuk.app.design.ui.component.BodyBoldLabel +import uk.govuk.app.design.ui.component.BodyRegularLabel +import uk.govuk.app.design.ui.component.ExtraSmallHorizontalSpacer +import uk.govuk.app.design.ui.component.ListDivider +import uk.govuk.app.design.ui.component.SmallHorizontalSpacer +import uk.govuk.app.design.ui.theme.GovUkTheme +import uk.govuk.app.search.R + +@Composable +internal fun PreviousSearches( + previousSearches: List, + onClick: (String) -> Unit, + onRemoveAll: () -> Unit, + onRemove: (String) -> Unit, + modifier: Modifier = Modifier +) { + if (previousSearches.isNotEmpty()) { + var showDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + LazyColumn( + modifier + .fillMaxWidth() + .padding(horizontal = GovUkTheme.spacing.medium) + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = GovUkTheme.spacing.small, + ), + verticalAlignment = Alignment.CenterVertically + ) { + BodyBoldLabel( + text = stringResource(R.string.previous_searches_heading), + modifier = Modifier + .weight(1f) + .semantics { heading() } + ) + + SmallHorizontalSpacer() + + TextButton( + onClick = { showDialog = true } + ) { + BodyRegularLabel( + text = stringResource(R.string.remove_all_button), + modifier = Modifier + .semantics { + contentDescription = context.getString(R.string.content_desc_delete_all) + }, + color = GovUkTheme.colourScheme.textAndIcons.link, + ) + } + } + } + items(previousSearches) { searchTerm -> + Column{ + ListDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(searchTerm) } + .semantics { + role = Role.Button + onClick(label = context.getString(R.string.content_desc_search)) { true } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = GovUkTheme.colourScheme.textAndIcons.secondary + ) + ExtraSmallHorizontalSpacer() + BodyRegularLabel( + text = searchTerm, + modifier = Modifier.weight(1f) + ) + ExtraSmallHorizontalSpacer() + TextButton( + onClick = { onRemove(searchTerm) } + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.content_desc_remove), + modifier = Modifier.size(18.dp), + tint = GovUkTheme.colourScheme.textAndIcons.trailingIcon + ) + } + } + } + } + item { + ListDivider() + } + } + + if (showDialog) { + ShowRemoveAllConfirmationDialog( + onConfirm = onRemoveAll, + onDismiss = { showDialog = false } + ) + } + } +} + +@Composable +private fun ShowRemoveAllConfirmationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = onConfirm + ) { + BodyBoldLabel( + text = stringResource(R.string.remove_confirmation_dialog_button), + color = GovUkTheme.colourScheme.textAndIcons.buttonRemove + ) + } + }, + modifier = modifier, + title = { + BodyBoldLabel(stringResource(R.string.remove_confirmation_dialog_title)) + }, + text = { + BodyBoldLabel( + text = stringResource(R.string.remove_confirmation_dialog_message), + color = GovUkTheme.colourScheme.textAndIcons.secondary + ) + } + ) +} \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/Header.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/Header.kt index f31fa2a5..22f7af52 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/Header.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/Header.kt @@ -18,12 +18,18 @@ import uk.govuk.app.design.R import uk.govuk.app.design.ui.component.ListDivider import uk.govuk.app.design.ui.theme.GovUkTheme +class SearchHeaderActions( + val onBack: () -> Unit, + val onSearchTermChange: (String) -> Unit, + val onSearch: () -> Unit, + val onClear: () -> Unit +) + @Composable fun SearchHeader( - onBack: () -> Unit, + searchTerm: String, placeholder: String, - onSearch: (String) -> Unit, - onClear: () -> Unit, + actions: SearchHeaderActions, modifier: Modifier = Modifier, focusRequester: FocusRequester = FocusRequester() ) { @@ -33,7 +39,7 @@ fun SearchHeader( verticalAlignment = Alignment.CenterVertically ) { TextButton( - onClick = onBack + onClick = actions.onBack ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, @@ -42,9 +48,11 @@ fun SearchHeader( ) } SearchField( + searchTerm = searchTerm, placeholder = placeholder, - onSearch = onSearch, - onClear = onClear, + onSearchTermChange = actions.onSearchTermChange, + onSearch = actions.onSearch, + onClear = actions.onClear, modifier = Modifier .weight(1f) .focusRequester(focusRequester) @@ -59,10 +67,14 @@ fun SearchHeader( private fun SearchHeaderPreview() { GovUkTheme { SearchHeader( - onBack = { }, + searchTerm = "", placeholder = "Search", - onSearch = { }, - onClear = { } + actions = SearchHeaderActions( + onBack = { }, + onSearchTermChange = { }, + onSearch = { }, + onClear = { } + ) ) } } \ No newline at end of file diff --git a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/TextField.kt b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/TextField.kt index 3e532916..8bef57f7 100644 --- a/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/TextField.kt +++ b/feature/search/src/main/kotlin/uk/govuk/app/search/ui/component/TextField.kt @@ -9,43 +9,37 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import uk.govuk.app.search.R import uk.govuk.app.design.ui.component.BodyRegularLabel import uk.govuk.app.design.ui.theme.GovUkTheme +import uk.govuk.app.search.R @Composable fun SearchField( + searchTerm: String, placeholder: String, - onSearch: (String) -> Unit, + onSearchTermChange: (String) -> Unit, + onSearch: () -> Unit, onClear: () -> Unit, modifier: Modifier = Modifier ) { - var searchQuery by rememberSaveable { - mutableStateOf("") - } - TextField( - value = searchQuery, + value = searchTerm, onValueChange = { - searchQuery = it + onSearchTermChange(it) }, modifier = modifier, placeholder = { BodyRegularLabel(placeholder) }, trailingIcon = { - if (searchQuery.isNotEmpty()) { + if (searchTerm.isNotEmpty()) { TextButton( onClick = { - searchQuery = "" + onSearchTermChange("") onClear() }, ) { @@ -58,7 +52,7 @@ fun SearchField( } }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch(searchQuery) }), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), singleLine = true, textStyle = GovUkTheme.typography.bodyRegular, colors = TextFieldDefaults.colors() diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 00182563..bcdc83d3 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -7,4 +7,12 @@ No results for Search is not working. Try again later, or search on the GOV.UK website. Search results + Previous searches + Delete all + Delete + Delete previous searches? + Permanently delete all your previous searches + Delete all previous searches + search + Remove from search history diff --git a/feature/search/src/test/kotlin/uk/govuk/app/search/SearchViewModelTest.kt b/feature/search/src/test/kotlin/uk/govuk/app/search/SearchViewModelTest.kt index a7fa551a..918ca395 100644 --- a/feature/search/src/test/kotlin/uk/govuk/app/search/SearchViewModelTest.kt +++ b/feature/search/src/test/kotlin/uk/govuk/app/search/SearchViewModelTest.kt @@ -6,13 +6,13 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.experimental.runners.Enclosed @@ -22,9 +22,10 @@ import uk.govuk.app.networking.domain.ApiException import uk.govuk.app.networking.domain.DeviceOfflineException import uk.govuk.app.networking.domain.ServiceNotRespondingException import uk.govuk.app.search.data.SearchRepo -import uk.govuk.app.search.data.remote.model.Result +import uk.govuk.app.search.data.local.SearchLocalDataSource +import uk.govuk.app.search.data.remote.SearchApi import uk.govuk.app.search.data.remote.model.SearchResponse -import uk.govuk.app.search.di.SearchModule +import uk.govuk.app.search.data.remote.model.SearchResult import uk.govuk.app.visited.Visited @RunWith(Enclosed::class) @@ -33,12 +34,25 @@ class SearchViewModelTest { class AnalyticsTest { private val analyticsClient = mockk(relaxed = true) private val visited = mockk(relaxed = true) - private val service = SearchModule().providesSearchApi() - private val repository = SearchRepo(service) - private val viewModel = SearchViewModel(analyticsClient, visited, repository) + private val searchApi = mockk(relaxed = true) + private val searchLocalDataSource = mockk(relaxed = true) + private val repository = SearchRepo(searchApi, searchLocalDataSource) private val dispatcher = UnconfinedTestDispatcher() private val searchTerm = "search term" + private lateinit var viewModel: SearchViewModel + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + viewModel = SearchViewModel(analyticsClient, visited, repository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `Given a page view, then log analytics`() { viewModel.onPageView() @@ -54,8 +68,6 @@ class SearchViewModelTest { @Test fun `Given a search, then log analytics`() { - Dispatchers.setMain(dispatcher) - viewModel.onSearch(searchTerm) runTest { @@ -67,8 +79,6 @@ class SearchViewModelTest { @Test fun `Given a search, and a search result is clicked, then log analytics`() { - Dispatchers.setMain(dispatcher) - runTest { viewModel.onSearchResultClicked("search result title", "search result link") @@ -80,8 +90,6 @@ class SearchViewModelTest { @Test fun `Given a search, and a search result is clicked, then log visited item`() { - Dispatchers.setMain(dispatcher) - runTest { viewModel.onSearchResultClicked("search result title", "search result link") @@ -97,13 +105,12 @@ class SearchViewModelTest { private val visited = mockk(relaxed = true) private val dispatcher = UnconfinedTestDispatcher() private val repository = mockk(relaxed = true) - private val viewModel = SearchViewModel(analyticsClient, visited, repository) private val searchTerm = "search term" private val resultWithNoSearchResponse = SearchResponse(total = 0, results = emptyList()) private val resultWithOneResult = SearchResponse( total = 1, results = listOf( - Result( + SearchResult( title = "title", description = "description", link = "link" @@ -122,13 +129,76 @@ class SearchViewModelTest { } @Test - fun `Given a search with a result, then the results and status in the view model are correct`() { - coEvery { repository.performSearch(searchTerm) } returns kotlin.Result.success(resultWithOneResult) + fun `Given a user has previous searches, when init, then emit previous searches`() { + val previousSearches = listOf("dog", "cat", "tax") + coEvery { repository.fetchPreviousSearches() } returns previousSearches + + runTest { + val viewModel = SearchViewModel(analyticsClient, visited, repository) + val result = viewModel.uiState.value as SearchUiState.Default + + assertEquals(previousSearches, result.previousSearches) + } + } + + @Test + fun `Given a user clears the search, then emit previous searches`() { + val previousSearches = listOf("pig") + coEvery { repository.fetchPreviousSearches() }returns listOf("dog") andThen previousSearches + + runTest { + val viewModel = SearchViewModel(analyticsClient, visited, repository) + viewModel.onClear() + val result = viewModel.uiState.value as SearchUiState.Default + + assertEquals(previousSearches, result.previousSearches) + } + } + + @Test + fun `Given a user removes a previous search, then update repo and emit previous searches`() { + val previousSearches = listOf("pig") + coEvery { repository.fetchPreviousSearches() }returns listOf("dog", "pig") andThen previousSearches + + runTest { + val viewModel = SearchViewModel(analyticsClient, visited, repository) + viewModel.onRemovePreviousSearch("dog") + val result = viewModel.uiState.value as SearchUiState.Default + + assertEquals(previousSearches, result.previousSearches) + } + + coVerify { + repository.removePreviousSearch("dog") + } + } + + @Test + fun `Given a user removes all previous searches, then update repo and emit previous searches`() { + coEvery { repository.fetchPreviousSearches() }returns listOf("dog", "pig") andThen emptyList() + runTest { + val viewModel = SearchViewModel(analyticsClient, visited, repository) + viewModel.onRemoveAllPreviousSearches() + val result = viewModel.uiState.value as SearchUiState.Default + + assertEquals(emptyList(), result.previousSearches) + } + + coVerify { + repository.removeAllPreviousSearches() + } + } + + @Test + fun `Given a search with a result, then emit search results`() { + coEvery { repository.performSearch(searchTerm) } returns Result.success(resultWithOneResult) + + val viewModel = SearchViewModel(analyticsClient, visited, repository) viewModel.onSearch(searchTerm) runTest { - val result = viewModel.uiState.first() as SearchUiState.Default + val result = viewModel.uiState.value as SearchUiState.Results assertEquals(searchTerm, result.searchTerm) assertEquals(1, result.searchResults.size) @@ -136,51 +206,54 @@ class SearchViewModelTest { } @Test - fun `Given a search without any results, then the results and status in the view model are correct`() { - coEvery { repository.performSearch(searchTerm) } returns kotlin.Result.success(resultWithNoSearchResponse) + fun `Given a search without any results, then emit empty state`() { + coEvery { repository.performSearch(searchTerm) } returns Result.success(resultWithNoSearchResponse) + val viewModel = SearchViewModel(analyticsClient, visited, repository) viewModel.onSearch(searchTerm) runTest { - val result = viewModel.uiState.first() as SearchUiState.Empty - - assertEquals(searchTerm, result.searchTerm) + val result = viewModel.uiState.value + assertTrue(result is SearchUiState.Error.Empty) } } @Test - fun `Given a search when the device is offline, then the results and status in the view model are correct`() { - coEvery { repository.performSearch(searchTerm) } returns kotlin.Result.failure(DeviceOfflineException()) + fun `Given a search when the device is offline, then emit offline state`() { + coEvery { repository.performSearch(searchTerm) } returns Result.failure(DeviceOfflineException()) + val viewModel = SearchViewModel(analyticsClient, visited, repository) viewModel.onSearch(searchTerm) runTest { - val result = viewModel.uiState.first() as SearchUiState.Offline - assertEquals(searchTerm, result.searchTerm) + val result = viewModel.uiState.value + assertTrue(result is SearchUiState.Error.Offline) } } @Test - fun `Given a search when the Search API is unavailable, then the results and status in the view model are correct`() { - coEvery { repository.performSearch(searchTerm) } returns kotlin.Result.failure(ServiceNotRespondingException()) + fun `Given a search when the Search API is unavailable, then emit service error state`() { + coEvery { repository.performSearch(searchTerm) } returns Result.failure(ServiceNotRespondingException()) + val viewModel = SearchViewModel(analyticsClient, visited, repository) viewModel.onSearch(searchTerm) runTest { - val result = viewModel.uiState.first() as SearchUiState.ServiceError - assertEquals(searchTerm, result.searchTerm) + val result = viewModel.uiState.value + assertTrue(result is SearchUiState.Error.ServiceError) } } @Test - fun `Given a search that returns an error, then the results and status in the view model are correct`() { - coEvery { repository.performSearch(searchTerm) } returns kotlin.Result.failure(ApiException()) + fun `Given a search that returns an error, then emit service error state`() { + coEvery { repository.performSearch(searchTerm) } returns Result.failure(ApiException()) + val viewModel = SearchViewModel(analyticsClient, visited, repository) viewModel.onSearch(searchTerm) runTest { - val result = viewModel.uiState.first() as SearchUiState.ServiceError - assertEquals(searchTerm, result.searchTerm) + val result = viewModel.uiState.value as SearchUiState + assertTrue(result is SearchUiState.Error.ServiceError) } } } diff --git a/feature/search/src/test/kotlin/uk/govuk/app/search/data/SearchRepoTest.kt b/feature/search/src/test/kotlin/uk/govuk/app/search/data/SearchRepoTest.kt index 7cc66d27..38c2b75e 100644 --- a/feature/search/src/test/kotlin/uk/govuk/app/search/data/SearchRepoTest.kt +++ b/feature/search/src/test/kotlin/uk/govuk/app/search/data/SearchRepoTest.kt @@ -1,113 +1,147 @@ package uk.govuk.app.search.data import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import retrofit2.HttpException -import uk.govuk.app.networking.domain.ApiException -import uk.govuk.app.networking.domain.DeviceOfflineException -import uk.govuk.app.networking.domain.ServiceNotRespondingException +import uk.govuk.app.search.data.local.SearchLocalDataSource +import uk.govuk.app.search.data.local.model.LocalSearchItem import uk.govuk.app.search.data.remote.SearchApi -import uk.govuk.app.search.data.remote.model.Result import uk.govuk.app.search.data.remote.model.SearchResponse +import uk.govuk.app.search.data.remote.model.SearchResult import uk.govuk.app.search.domain.SearchConfig import java.net.UnknownHostException class SearchRepoTest { private val searchApi = mockk(relaxed = true) + private val localDataSource = mockk(relaxed = true) private val searchTerm = "search term" - private val resultWithNoSearchResponse = SearchResponse(total = 0, results = emptyList()) - private val resultWithOneResult = SearchResponse( + private val responseWithNoSearchResults = SearchResponse(total = 0, results = emptyList()) + private val responseWithOneSearchResult = SearchResponse( total = 1, results = listOf( - Result( + SearchResult( title = "title", description = "description", link = "link" ) ) ) + private lateinit var searchRepo: SearchRepo - @Test - fun `initSearch returns Success status when results are found`() { - coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } returns resultWithOneResult + @Before + fun setup() { + searchRepo = SearchRepo(searchApi, localDataSource) + } - val repo = SearchRepo(searchApi) + @Test + fun `Fetch previous searches returns previous searches`() { + coEvery { localDataSource.fetchPreviousSearches() } returns listOf( + LocalSearchItem().apply { searchTerm = "dog" }, + LocalSearchItem().apply { searchTerm = "cat" }, + LocalSearchItem().apply { searchTerm = "tax" } + ) - val expected = kotlin.Result.success(resultWithOneResult) + val expected = listOf("dog", "cat", "tax") runTest { - val actual = repo.performSearch(searchTerm, 10) + val actual = searchRepo.fetchPreviousSearches() assertEquals(expected, actual) } } @Test - fun `initSearch returns Empty status when no results are found`() { - coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } returns resultWithNoSearchResponse + fun `Remove previous search updates local data source`() { + runTest { + searchRepo.removePreviousSearch("searchTerm") + } - val repo = SearchRepo(searchApi) + coVerify { + localDataSource.removePreviousSearch("searchTerm") + } + } - val expected = kotlin.Result.success(resultWithNoSearchResponse) + @Test + fun `Remove all previous searches updates local data source`() { + runTest { + searchRepo.removeAllPreviousSearches() + } + + coVerify { + localDataSource.removeAllPreviousSearches() + } + } + + @Test + fun `Perform search returns Success status when results are found`() { + coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } returns responseWithOneSearchResult + + val expected = Result.success(responseWithOneSearchResult) runTest { - val actual = repo.performSearch(searchTerm, 10) + val actual = searchRepo.performSearch(searchTerm, 10) assertEquals(expected, actual) } } @Test - fun `initSearch returns DeviceOffline status when the device is offline`() { - coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } throws UnknownHostException() + fun `Perform search returns Empty status when no results are found`() { + coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } returns responseWithNoSearchResults + + val expected = Result.success(responseWithNoSearchResults) + + runTest { + val actual = searchRepo.performSearch(searchTerm, 10) + assertEquals(expected, actual) + } + } - val repo = SearchRepo(searchApi) + @Test + fun `Perform search returns DeviceOffline status when the device is offline`() { + coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } throws UnknownHostException() runTest { - val result = repo.performSearch(searchTerm, 10) + val result = searchRepo.performSearch(searchTerm, 10) assertTrue(result.isFailure) } } @Test - fun `initSearch returns ServiceNotResponding status when the Search API is offline`() { + fun `Perform search returns ServiceNotResponding status when the Search API is offline`() { val httpException = mockk(relaxed = true) coEvery { httpException.code() } returns 503 coEvery { httpException.message() } returns "Service Unavailable" coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } throws httpException - val repo = SearchRepo(searchApi) - runTest { - val result = repo.performSearch(searchTerm, 10) + val result = searchRepo.performSearch(searchTerm, 10) assertTrue(result.isFailure) } } @Test - fun `initSearch returns Error status when any unknown error occurs that has a message`() { + fun `Perform search returns Error status when any unknown error occurs that has a message`() { val message = "Something very bad happened" coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } throws Exception(message) - val repo = SearchRepo(searchApi) - runTest { - val result = repo.performSearch(searchTerm, 10) + val result = searchRepo.performSearch(searchTerm, 10) assertTrue(result.isFailure) } } @Test - fun `initSearch returns Error status when any unknown error occurs that has no message`() { + fun `Perform search returns Error status when any unknown error occurs that has no message`() { coEvery { searchApi.getSearchResults(searchTerm, SearchConfig.DEFAULT_RESULTS_PER_PAGE) } throws Exception() - val repo = SearchRepo(searchApi) - runTest { - val result = repo.performSearch(searchTerm, 10) + val result = searchRepo.performSearch(searchTerm, 10) assertTrue(result.isFailure) } } diff --git a/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchDataStoreTest.kt b/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchDataStoreTest.kt new file mode 100644 index 00000000..67d814a1 --- /dev/null +++ b/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchDataStoreTest.kt @@ -0,0 +1,66 @@ +package uk.govuk.app.search.data.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SearchDataStoreTest { + + private val dataStore = mockk>() + private val preferences = mockk() + + @Test + fun `Given realm key is null, then return null`() { + val datastore = SearchDataStore(dataStore) + + every { dataStore.data } returns flowOf(preferences) + every { preferences[stringPreferencesKey(SearchDataStore.REALM_SEARCH_KEY)] } returns null + + runTest { + assertNull(datastore.getRealmSearchKey()) + } + } + + @Test + fun `Given realm key is not null, then return realm key`() { + val datastore = SearchDataStore(dataStore) + + every { dataStore.data } returns flowOf(preferences) + every { preferences[stringPreferencesKey(SearchDataStore.REALM_SEARCH_KEY)] } returns "realmKey" + + runTest { + assertEquals("realmKey", datastore.getRealmSearchKey()) + } + } + + @Test + fun `Given realm iv is null, then return null`() { + val datastore = SearchDataStore(dataStore) + + every { dataStore.data } returns flowOf(preferences) + every { preferences[stringPreferencesKey(SearchDataStore.REALM_SEARCH_IV)] } returns null + + runTest { + assertNull(datastore.getRealmSearchIv()) + } + } + + @Test + fun `Given realm iv is not null, then return realm iv`() { + val datastore = SearchDataStore(dataStore) + + every { dataStore.data } returns flowOf(preferences) + every { preferences[stringPreferencesKey(SearchDataStore.REALM_SEARCH_IV)] } returns "realmIv" + + runTest { + assertEquals("realmIv", datastore.getRealmSearchIv()) + } + } +} \ No newline at end of file diff --git a/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSourceTest.kt b/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSourceTest.kt new file mode 100644 index 00000000..89a7d42a --- /dev/null +++ b/feature/search/src/test/kotlin/uk/govuk/app/search/data/local/SearchLocalDataSourceTest.kt @@ -0,0 +1,246 @@ +package uk.govuk.app.search.data.local + +import io.mockk.coEvery +import io.mockk.mockk +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.ext.query +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import uk.govuk.app.search.data.local.model.LocalSearchItem + +class SearchLocalDataSourceTest { + + private val realmProvider = mockk(relaxed = true) + + private lateinit var realm: Realm + + private lateinit var localDataSource: SearchLocalDataSource + + @Before + fun setup() { + val config = RealmConfiguration.Builder(schema = setOf(LocalSearchItem::class)) + .inMemory() // In-memory Realm for testing + .build() + + // Open the Realm instance + realm = Realm.open(config) + + coEvery { realmProvider.open() } returns realm + + localDataSource = SearchLocalDataSource(realmProvider) + } + + @After + fun tearDown() { + realm.close() + } + + @Test + fun `Given previous searches in realm, when fetch previous searches, then return previous searches`() { + val expected = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + LocalSearchItem().apply { + searchTerm = "cat" + timestamp = 1 + } + ) + + runTest { + realm.write { + copyToRealm(expected[0]) + copyToRealm(expected[1]) + } + + val previousSearches = localDataSource.fetchPreviousSearches() + + assertEquals(2, previousSearches.size) + assertEquals("cat", previousSearches[0].searchTerm) + assertEquals("dog", previousSearches[1].searchTerm) + } + } + + @Test + fun `Given there are less then 5 previous searches, when a user performs a new search, then insert into realm`() { + val previousSearches = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + LocalSearchItem().apply { + searchTerm = "cat" + timestamp = 1 + }, + LocalSearchItem().apply { + searchTerm = "pig" + timestamp = 2 + }, + LocalSearchItem().apply { + searchTerm = "badger" + timestamp = 3 + } + ) + + runTest { + realm.write { + copyToRealm(previousSearches[0]) + copyToRealm(previousSearches[1]) + copyToRealm(previousSearches[2]) + copyToRealm(previousSearches[3]) + } + + localDataSource.insertOrUpdatePreviousSearch("fox") + + val results = realm.query().find().map { it.searchTerm } + assertEquals(5, results.size) + assertTrue(results.contains("dog")) + assertTrue(results.contains("cat")) + assertTrue(results.contains("pig")) + assertTrue(results.contains("badger")) + assertTrue(results.contains("fox")) + } + } + + // Insert > 5 + @Test + fun `Given there are 5 or more previous searches, when a user performs a new search, then insert into realm and remove the oldest search`() { + val previousSearches = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + LocalSearchItem().apply { + searchTerm = "cat" + timestamp = 1 + }, + LocalSearchItem().apply { + searchTerm = "pig" + timestamp = 2 + }, + LocalSearchItem().apply { + searchTerm = "badger" + timestamp = 3 + }, + LocalSearchItem().apply { + searchTerm = "fox" + timestamp = 4 + } + ) + + runTest { + realm.write { + copyToRealm(previousSearches[0]) + copyToRealm(previousSearches[1]) + copyToRealm(previousSearches[2]) + copyToRealm(previousSearches[3]) + copyToRealm(previousSearches[4]) + } + + localDataSource.insertOrUpdatePreviousSearch("duck") + + val results = realm.query().find().map { it.searchTerm } + assertEquals(5, results.size) + assertTrue(results.contains("cat")) + assertTrue(results.contains("pig")) + assertTrue(results.contains("badger")) + assertTrue(results.contains("fox")) + assertTrue(results.contains("duck")) + assertFalse(results.contains("dog")) + } + } + + @Test + fun `Given there is an existing previous search, when a user performs the same search again, then update the search timestamp`() { + val previousSearches = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + ) + + runTest { + realm.write { + copyToRealm(previousSearches[0]) + } + + localDataSource.insertOrUpdatePreviousSearch("dog") + + val results = realm.query().find() + assertEquals(1, results.size) + assertEquals("dog", results[0].searchTerm) + assertTrue(results[0].timestamp != previousSearches[0].timestamp) + } + } + + @Test + fun `Given a user removes a previous search, then delete from realm`() { + val expected = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + LocalSearchItem().apply { + searchTerm = "cat" + timestamp = 1 + } + ) + + runTest { + realm.write { + copyToRealm(expected[0]) + copyToRealm(expected[1]) + } + + var previousSearches = localDataSource.fetchPreviousSearches() + assertEquals(2, previousSearches.size) + assertEquals("cat", previousSearches[0].searchTerm) + assertEquals("dog", previousSearches[1].searchTerm) + + localDataSource.removePreviousSearch("cat") + + previousSearches = localDataSource.fetchPreviousSearches() + + assertEquals(1, previousSearches.size) + assertEquals("dog", previousSearches[0].searchTerm) + } + } + + @Test + fun `Given a user removes all previous searches, then delete all from realm`() { + val expected = listOf( + LocalSearchItem().apply { + searchTerm = "dog" + timestamp = 0 + }, + LocalSearchItem().apply { + searchTerm = "cat" + timestamp = 1 + } + ) + + runTest { + realm.write { + copyToRealm(expected[0]) + copyToRealm(expected[1]) + } + + var previousSearches = localDataSource.fetchPreviousSearches() + assertEquals(2, previousSearches.size) + assertEquals("cat", previousSearches[0].searchTerm) + assertEquals("dog", previousSearches[1].searchTerm) + + localDataSource.removeAllPreviousSearches() + + previousSearches = localDataSource.fetchPreviousSearches() + assertTrue(previousSearches.isEmpty()) + } + } +} \ No newline at end of file