Initial commit
This commit is contained in:
1
feature/search/.gitignore
vendored
Normal file
1
feature/search/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
build/
|
26
feature/search/build.gradle.kts
Normal file
26
feature/search/build.gradle.kts
Normal file
@ -0,0 +1,26 @@
|
||||
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
|
||||
plugins {
|
||||
alias(libs.plugins.convention.android.library)
|
||||
alias(libs.plugins.convention.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.adriankuta.pixabay.feature.search"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":data"))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
4
feature/search/src/main/AndroidManifest.xml
Normal file
4
feature/search/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application android:networkSecurityConfig="@xml/network_security_config" />
|
||||
</manifest>
|
@ -0,0 +1,151 @@
|
||||
package dev.adriankuta.pixabay.feature.search
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import coil.compose.AsyncImage
|
||||
import dev.adriankuta.pixabay.core.ui.ErrorMessage
|
||||
import dev.adriankuta.pixabay.core.ui.Loading
|
||||
import dev.adriankuta.pixabay.core.ui.SearchField
|
||||
import dev.adriankuta.pixabay.data.model.PixabayImage
|
||||
|
||||
@Composable
|
||||
fun SearchRoute(
|
||||
onGoToItem: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SearchScreenViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val imagePagingItems = viewModel.pagingDataFlow.collectAsLazyPagingItems()
|
||||
SearchScreen(
|
||||
state = state,
|
||||
pagingData = imagePagingItems,
|
||||
onGoToItem = onGoToItem,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SearchScreen(
|
||||
state: SearchUiState,
|
||||
pagingData: LazyPagingItems<PixabayImage>,
|
||||
onGoToItem: (Int) -> Unit,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SearchField(
|
||||
query = state.query,
|
||||
onQueryChange = onQueryChange,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
hint = stringResource(R.string.search)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Content(
|
||||
modifier.padding(paddingValues),
|
||||
pagingData,
|
||||
onGoToItem
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Content(
|
||||
modifier: Modifier = Modifier,
|
||||
pagingData: LazyPagingItems<PixabayImage>,
|
||||
onGoToItem: (Int) -> Unit
|
||||
) {
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(pagingData.itemCount) { index ->
|
||||
val item = pagingData[index]!!
|
||||
ListItem(item, onGoToItem, index)
|
||||
}
|
||||
|
||||
pagingData.apply {
|
||||
when {
|
||||
loadState.refresh is LoadState.Loading -> {
|
||||
item { Loading() }
|
||||
}
|
||||
|
||||
loadState.refresh is LoadState.Error -> {
|
||||
val error = pagingData.loadState.refresh as LoadState.Error
|
||||
item {
|
||||
ErrorMessage(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp),
|
||||
message = error.error.localizedMessage!!,
|
||||
onClickRetry = { retry() })
|
||||
}
|
||||
}
|
||||
|
||||
loadState.append is LoadState.Loading -> {
|
||||
item { Loading() }
|
||||
}
|
||||
|
||||
loadState.append is LoadState.Error -> {
|
||||
val error = pagingData.loadState.append as LoadState.Error
|
||||
item {
|
||||
ErrorMessage(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
message = error.error.localizedMessage!!,
|
||||
onClickRetry = { retry() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListItem(
|
||||
item: PixabayImage,
|
||||
onGoToItem: (Int) -> Unit,
|
||||
index: Int
|
||||
) {
|
||||
ListItem(headlineContent = { Text(text = item.user) },
|
||||
supportingContent = { Text(text = item.tags) },
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = item.previewURL,
|
||||
contentDescription = null,
|
||||
Modifier.fillMaxWidth(0.4f),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
onGoToItem(item.id)
|
||||
}
|
||||
.testTag("item_$index")
|
||||
)
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
|
||||
package dev.adriankuta.pixabay.feature.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.adriankuta.pixabay.data.model.PixabayImage
|
||||
import dev.adriankuta.pixabay.data.repository.ImageRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SearchScreenViewModel @Inject constructor(
|
||||
private val imageRepository: ImageRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _query = MutableStateFlow(INITIAL_QUERY)
|
||||
val state: StateFlow<SearchUiState>
|
||||
|
||||
val pagingDataFlow: Flow<PagingData<PixabayImage>>
|
||||
|
||||
init {
|
||||
pagingDataFlow = _query
|
||||
.debounce(300)
|
||||
.flatMapLatest { searchImages(query = it) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
state = _query
|
||||
.map { search ->
|
||||
SearchUiState(query = search)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
|
||||
initialValue = SearchUiState()
|
||||
)
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
_query.value = query
|
||||
}
|
||||
|
||||
private fun searchImages(query: String): Flow<PagingData<PixabayImage>> =
|
||||
imageRepository.getSearchResultStream(query)
|
||||
|
||||
companion object {
|
||||
const val INITIAL_QUERY = "fruits"
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package dev.adriankuta.pixabay.feature.search
|
||||
|
||||
import dev.adriankuta.pixabay.data.model.PixabayImage
|
||||
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = ""
|
||||
)
|
4
feature/search/src/main/res/values/strings.xml
Normal file
4
feature/search/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="search">Search</string>
|
||||
</resources>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">pixabay.com</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
Reference in New Issue
Block a user