Initial commit
This commit is contained in:
1
feature/details/.gitignore
vendored
Normal file
1
feature/details/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
build/
|
23
feature/details/build.gradle.kts
Normal file
23
feature/details/build.gradle.kts
Normal file
@ -0,0 +1,23 @@
|
||||
@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.details"
|
||||
}
|
||||
|
||||
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.coil.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
package dev.adriankuta.pixabay.feature.details
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ThumbUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 coil.compose.AsyncImage
|
||||
import dev.adriankuta.pixabay.core.ui.ErrorMessage
|
||||
import dev.adriankuta.pixabay.core.ui.Loading
|
||||
import dev.adriankuta.pixabay.core.ui.R.drawable
|
||||
import dev.adriankuta.pixabay.core.ui.R.string
|
||||
import dev.adriankuta.pixabay.core.ui.StatsItem
|
||||
import dev.adriankuta.pixabay.data.model.PixabayImage
|
||||
|
||||
|
||||
@Composable
|
||||
fun PhotoDetailRoute(
|
||||
photoId: Int,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PhotoDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(photoId) {
|
||||
viewModel.loadImage(photoId)
|
||||
}
|
||||
|
||||
PhotoDetailScreen(
|
||||
state = uiState,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PhotoDetailScreen(
|
||||
state: PhotoDetailUiState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Scaffold { paddingValues ->
|
||||
AnimatedContent(
|
||||
state,
|
||||
label = "",
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) { targetState ->
|
||||
when (targetState) {
|
||||
PhotoDetailUiState.Error -> ErrorMessage(
|
||||
stringResource(string.something_went_wrong),
|
||||
{},
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
PhotoDetailUiState.Loading -> Loading(modifier)
|
||||
is PhotoDetailUiState.Success -> Content(
|
||||
targetState.data,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
data: PixabayImage,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier) {
|
||||
AsyncImage(
|
||||
model = data.largeImageURL,
|
||||
contentDescription = null,
|
||||
placeholder = ColorPainter(Color.Gray),
|
||||
modifier = Modifier
|
||||
.height(260.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
contentScale = ContentScale.FillHeight
|
||||
)
|
||||
Metadata(
|
||||
data,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
Text(
|
||||
data.user,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
Text(
|
||||
data.tags,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Metadata(
|
||||
data: PixabayImage,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier,
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
StatsItem(
|
||||
icon = { Icon(Icons.Rounded.ThumbUp, null) },
|
||||
text = data.likes.toString()
|
||||
)
|
||||
StatsItem(
|
||||
icon = { Icon(painterResource(drawable.ic_download), null) },
|
||||
text = data.downloads.toString()
|
||||
)
|
||||
StatsItem(
|
||||
icon = { Icon(painterResource(drawable.ic_comment), null) },
|
||||
text = data.comments.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ContentPreview() {
|
||||
val data = PixabayImage(
|
||||
id = 5679562,
|
||||
pageURL = "https://pixabay.com/photos/berries-fruit-picking-man-fruits-5679562/",
|
||||
type = "photo",
|
||||
tags = "berries, fruit picking, man",
|
||||
previewURL = "https://cdn.pixabay.com/photo/2020/10/23/18/17/berries-5679562_150.jpg",
|
||||
previewWidth = 150,
|
||||
previewHeight = 100,
|
||||
webformatURL = "https://pixabay.com/get/g716e6d144bc71b9f94b4662cbebbb9b54f7442e6d73aab55caf2b1ed03d32f373826b2c4925431333e400248113225c4032f27de0986038211769d599350ff2e_640.jpg",
|
||||
webformatWidth = 640,
|
||||
webformatHeight = 427,
|
||||
largeImageURL = "https://pixabay.com/get/g130b59fab09e4d1a755ee1f23cdaa9aefcec7da220e0f5ac2525d95e43963b069aec3f7e19de30b435f74ca4a94d1429ed78d9218dbeff8bde43b330f68a968b_1280.jpg",
|
||||
imageWidth = 3504,
|
||||
imageHeight = 2336,
|
||||
imageSize = 1682707,
|
||||
views = 12389,
|
||||
downloads = 6681,
|
||||
likes = 48,
|
||||
comments = 11,
|
||||
user_id = 17356228,
|
||||
user = "Dave_LZ",
|
||||
userImageURL = "https://cdn.pixabay.com/user/2020/08/08/19-08-33-985_250x250.jpg"
|
||||
|
||||
)
|
||||
Content(data)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package dev.adriankuta.pixabay.feature.details
|
||||
|
||||
import dev.adriankuta.pixabay.data.model.PixabayImage
|
||||
|
||||
sealed interface PhotoDetailUiState {
|
||||
data class Success(val data: PixabayImage) : PhotoDetailUiState
|
||||
data object Loading : PhotoDetailUiState
|
||||
data object Error : PhotoDetailUiState
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package dev.adriankuta.pixabay.feature.details
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.adriankuta.pixabay.data.repository.ImageRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PhotoDetailViewModel @Inject constructor(
|
||||
private val imageRepository: ImageRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _idToLoad = MutableStateFlow<Int?>(null)
|
||||
|
||||
private val loadedData = _idToLoad
|
||||
.filterNotNull()
|
||||
.map { id ->
|
||||
imageRepository.searchImageById(id)
|
||||
}
|
||||
|
||||
val uiState = loadedData
|
||||
.map { PhotoDetailUiState.Success(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
|
||||
initialValue = PhotoDetailUiState.Loading
|
||||
)
|
||||
|
||||
|
||||
fun loadImage(id: Int) {
|
||||
_idToLoad.value = id
|
||||
}
|
||||
}
|
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