Initial commit

This commit is contained in:
2024-07-24 13:17:25 +02:00
commit 0e15203725
197 changed files with 5944 additions and 0 deletions

1
feature/details/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build/

View 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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
View File

@ -0,0 +1 @@
build/

View 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)
}

View File

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

View File

@ -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")
)
}

View File

@ -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"
}
}

View File

@ -0,0 +1,8 @@
package dev.adriankuta.pixabay.feature.search
import dev.adriankuta.pixabay.data.model.PixabayImage
data class SearchUiState(
val query: String = ""
)

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="search">Search</string>
</resources>

View File

@ -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>