From 4cd2edef48d8b7193b052070729103133e7888fd Mon Sep 17 00:00:00 2001 From: Milan Jovic Date: Thu, 18 Dec 2025 10:31:21 +0100 Subject: [PATCH] Interview tasks --- .../jetpackhomefinder/MainActivity.kt | 26 ++- .../jetpackhomefinder/model/Models.kt | 52 +++-- .../repository/RealEstateRepository.kt | 86 ++++++-- .../jetpackhomefinder/ui/RealEstateApp.kt | 206 +++++++++++++----- .../viewmodel/RealEstateViewModel.kt | 82 +++++-- 5 files changed, 335 insertions(+), 117 deletions(-) diff --git a/app/src/main/java/ch/comparis/jetpackhomefinder/MainActivity.kt b/app/src/main/java/ch/comparis/jetpackhomefinder/MainActivity.kt index 00c8527..c91deca 100644 --- a/app/src/main/java/ch/comparis/jetpackhomefinder/MainActivity.kt +++ b/app/src/main/java/ch/comparis/jetpackhomefinder/MainActivity.kt @@ -4,23 +4,31 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import ch.comparis.jetpackhomefinder.ui.RealEstateApp import ch.comparis.jetpackhomefinder.ui.theme.JetpackHomefinderTheme +import ch.comparis.jetpackhomefinder.viewmodel.RealEstateViewModel class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - JetpackHomefinderTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - RealEstateApp(modifier = Modifier.padding(innerPadding)) - } - } + + private val viewModel: RealEstateViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + JetpackHomefinderTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + RealEstateApp( + modifier = Modifier.padding(innerPadding), + vm = viewModel, + ) } + } } + } } \ No newline at end of file diff --git a/app/src/main/java/ch/comparis/jetpackhomefinder/model/Models.kt b/app/src/main/java/ch/comparis/jetpackhomefinder/model/Models.kt index 9a95b16..72815e6 100644 --- a/app/src/main/java/ch/comparis/jetpackhomefinder/model/Models.kt +++ b/app/src/main/java/ch/comparis/jetpackhomefinder/model/Models.kt @@ -6,29 +6,51 @@ import kotlinx.serialization.Serializable @OptIn(kotlinx.serialization.InternalSerializationApi::class) @Serializable data class Listing( - val id: Int, - val title: String, - val rooms: Double, - val area: Int, - @SerialName("pricePerMonth") val price: Int, - val zipCode: Int, - val city: String, - val address: String, - @SerialName("image") val imageUrl: String + val id: Int, + val title: String, + val rooms: Double, + val area: Int, + @SerialName("pricePerMonth") val price: Int, + val zipCode: Int, + val city: String, + val address: String, + @SerialName("image") val imageUrl: String ) +data class GroupedListings( + val filters: List, + val listings: List, +) + +sealed interface FilterUIModel { + data class PriceRange( + val range: IntRange, + val firstRange: Boolean, + val lastRange: Boolean, + ) : FilterUIModel + + data class ZipCode(val zipCode: Int) : FilterUIModel + data class City(val city: String) : FilterUIModel +} + +sealed interface GroupingFilter { + data class PriceRange(val ranges: List) : GroupingFilter + data object ZipCode : GroupingFilter + data object City : GroupingFilter +} + @OptIn(kotlinx.serialization.InternalSerializationApi::class) @Serializable data class ListingsMetadata( - val page: Int, - val pageSize: Int, - val totalItems: Int, - val pageCount: Int + val page: Int, + val pageSize: Int, + val totalItems: Int, + val pageCount: Int ) @OptIn(kotlinx.serialization.InternalSerializationApi::class) @Serializable data class ListingsResponse( - @SerialName("data") val listings: List, - val metadata: ListingsMetadata + @SerialName("data") val listings: List, + val metadata: ListingsMetadata ) diff --git a/app/src/main/java/ch/comparis/jetpackhomefinder/repository/RealEstateRepository.kt b/app/src/main/java/ch/comparis/jetpackhomefinder/repository/RealEstateRepository.kt index ad4e4b5..33e9a83 100644 --- a/app/src/main/java/ch/comparis/jetpackhomefinder/repository/RealEstateRepository.kt +++ b/app/src/main/java/ch/comparis/jetpackhomefinder/repository/RealEstateRepository.kt @@ -1,28 +1,86 @@ package ch.comparis.jetpackhomefinder.repository +import ch.comparis.jetpackhomefinder.model.FilterUIModel +import ch.comparis.jetpackhomefinder.model.GroupedListings +import ch.comparis.jetpackhomefinder.model.GroupingFilter import ch.comparis.jetpackhomefinder.model.Listing import ch.comparis.jetpackhomefinder.model.ListingsResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request class RealEstateRepository( - private val client: OkHttpClient = OkHttpClient(), - private val json: Json = Json { ignoreUnknownKeys = true } + private val client: OkHttpClient = OkHttpClient(), + private val json: Json = Json { ignoreUnknownKeys = true } ) { - private val url = "https://cmp-resources-prd.b-cdn.net/real-estate-listings.json" + private val url = "https://cmp-resources-prd.b-cdn.net/real-estate-listings.json" - fun fetchListings(): Result> { - return try { - val request = Request.Builder().url(url).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) return Result.failure(Exception("HTTP ${'$'}{response.code}")) - val bodyStr = response.body?.string() ?: return Result.failure(Exception("Empty body")) - val parsed = json.decodeFromString(bodyStr) - Result.success(parsed.listings) - } - } catch (e: Exception) { - Result.failure(e) + suspend fun fetchListings(): Result> { + return try { + withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext Result.failure(Exception("HTTP ${'$'}{response.code}")) + val bodyStr = response.body?.string() ?: return@withContext Result.failure(Exception("Empty body")) + val parsed = json.decodeFromString(bodyStr) + Result.success(parsed.listings) } + } + } catch (e: Exception) { + Result.failure(e) } + } + + suspend fun fetchGroupedListings( + filters: List + ): Result> { + println(">>> Filters: $filters") + return fetchListings() + .fold( + onFailure = { Result.failure(it) }, + onSuccess = { listings -> + // 0 - 999 -> 0 + // 1000 - 1999-> 1 + // 2000 - 2999-> 2 + val grouped = listings + .sortedBy { it.price } + .groupBy { listing -> + filters.map { filter -> + when (filter) { + is GroupingFilter.PriceRange -> { + val range = filter.ranges.first { range -> + listing.price in range + } + val index = filter.ranges.indexOf(range) + FilterUIModel.PriceRange( + range = range, + firstRange = index == 0, + lastRange = index == filter.ranges.size - 1, + ) + } + + GroupingFilter.City -> { + FilterUIModel.City(listing.city) + } + + GroupingFilter.ZipCode -> { + FilterUIModel.ZipCode(listing.zipCode) + } + } + } + + } + .entries + .map { (filters, listings) -> + GroupedListings( + filters = filters, + listings = listings, + ) + } + Result.success(grouped) + } + ) + } } diff --git a/app/src/main/java/ch/comparis/jetpackhomefinder/ui/RealEstateApp.kt b/app/src/main/java/ch/comparis/jetpackhomefinder/ui/RealEstateApp.kt index b0f4269..cf8005f 100644 --- a/app/src/main/java/ch/comparis/jetpackhomefinder/ui/RealEstateApp.kt +++ b/app/src/main/java/ch/comparis/jetpackhomefinder/ui/RealEstateApp.kt @@ -1,5 +1,6 @@ package ch.comparis.jetpackhomefinder.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,10 +11,12 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -30,80 +33,167 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import ch.comparis.jetpackhomefinder.model.FilterUIModel +import ch.comparis.jetpackhomefinder.model.GroupingFilter import ch.comparis.jetpackhomefinder.model.Listing import ch.comparis.jetpackhomefinder.viewmodel.RealEstateViewModel import coil.compose.AsyncImage +import kotlin.reflect.KClass @Composable -fun RealEstateApp(modifier: Modifier = Modifier, vm: RealEstateViewModel = RealEstateViewModel()) { - val state by vm.state.collectAsState() - when { - state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = state.error ?: "Unknown error") - } - else -> ListDetailLayout(modifier, state) +fun RealEstateApp( + modifier: Modifier = Modifier, + vm: RealEstateViewModel +) { + val state by vm.state.collectAsState() + when { + state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = state.error ?: "Unknown error") } + + else -> ListDetailLayout( + modifier = modifier, + state = state, + onToggleFilter = vm::toggleFilter + ) + } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalFoundationApi::class) @Composable -fun ListDetailLayout(modifier: Modifier, state: RealEstateViewModel.State) { - val navigator = rememberListDetailPaneScaffoldNavigator() +fun ListDetailLayout( + modifier: Modifier, + state: RealEstateViewModel.State, + onToggleFilter: (KClass) -> Unit +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + Column { + Row { + Checkbox( + checked = state.filters.any { it is GroupingFilter.PriceRange }, + onCheckedChange = { + onToggleFilter(GroupingFilter.PriceRange::class) + } + ) + Text(text = "Price range") + } + Row { + Checkbox( + checked = state.filters.any { it is GroupingFilter.City }, + onCheckedChange = { + onToggleFilter(GroupingFilter.City::class) + } + ) + Text(text = "City") + } + Row { + Checkbox( + checked = state.filters.any { it is GroupingFilter.ZipCode }, + onCheckedChange = { + onToggleFilter(GroupingFilter.ZipCode::class) + } + ) + Text(text = "Zip Code") + } NavigableListDetailPaneScaffold( - navigator = navigator, - listPane = { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(state.listings) { item -> - ListingCard( - modifier = Modifier.clickable { - navigator.navigateTo( - pane = ListDetailPaneScaffoldRole.Detail, - content = item.title - ) - }, - item = item - ) - } + navigator = navigator, + listPane = { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.listings.forEach { groupedListings -> + stickyHeader(groupedListings.filters.joinToString { it.toString() }) { + PriceRangeHeader( + filters = groupedListings.filters, + ) } - }, - detailPane = { - val content = navigator.currentDestination?.content?.toString() ?: "Select an item" - AnimatedPane { - Column( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primaryContainer), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = content) - } + + items(groupedListings.listings) { item -> + ListingCard( + modifier = Modifier.clickable { + navigator.navigateTo( + pane = ListDetailPaneScaffoldRole.Detail, + content = item.title + ) + }, + item = item + ) } - }, + } + } + }, + detailPane = { + val content = navigator.currentDestination?.content?.toString() ?: "Select an item" + AnimatedPane { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = content) + } + } + }, ) + } } @Composable -private fun ListingCard(modifier: Modifier = Modifier, item: Listing) { - Row(modifier = modifier.fillMaxWidth()) { - AsyncImage( - model = item.imageUrl, - contentDescription = item.title, - modifier = Modifier.size(96.dp), - contentScale = ContentScale.Crop - ) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(text = item.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(text = item.address, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(text = item.price.toString(), style = MaterialTheme.typography.bodyLarge) +private fun PriceRangeHeader( + filters: List, + modifier: Modifier = Modifier, +) { + val formattedHeader = filters.joinToString { filter -> + when (filter) { + is FilterUIModel.City -> "City: ${filter.city}" + is FilterUIModel.PriceRange -> { + val formatedRange = if (filter.firstRange) { + "< ${filter.range.last + 1}" + } else if (filter.lastRange) { + "> ${filter.range.first}" + } else { + "${filter.range.first} - ${filter.range.last + 1}" } + "Price range: $formatedRange" + } + + is FilterUIModel.ZipCode -> { + "Zip Code: ${filter.zipCode}" + } + } + } + + Text( + text = formattedHeader, + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(8.dp) + ) +} + +@Composable +private fun ListingCard(modifier: Modifier = Modifier, item: Listing) { + Row(modifier = modifier.fillMaxWidth()) { + AsyncImage( + model = item.imageUrl, + contentDescription = item.title, + modifier = Modifier.size(96.dp), + contentScale = ContentScale.Crop + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(text = item.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item.address, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item.price.toString(), style = MaterialTheme.typography.bodyLarge) } + } } \ No newline at end of file diff --git a/app/src/main/java/ch/comparis/jetpackhomefinder/viewmodel/RealEstateViewModel.kt b/app/src/main/java/ch/comparis/jetpackhomefinder/viewmodel/RealEstateViewModel.kt index 89a25f1..176f5f9 100644 --- a/app/src/main/java/ch/comparis/jetpackhomefinder/viewmodel/RealEstateViewModel.kt +++ b/app/src/main/java/ch/comparis/jetpackhomefinder/viewmodel/RealEstateViewModel.kt @@ -2,37 +2,77 @@ package ch.comparis.jetpackhomefinder.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import ch.comparis.jetpackhomefinder.model.Listing +import ch.comparis.jetpackhomefinder.model.GroupedListings +import ch.comparis.jetpackhomefinder.model.GroupingFilter import ch.comparis.jetpackhomefinder.repository.RealEstateRepository -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.reflect.KClass class RealEstateViewModel : ViewModel() { - data class State( - val isLoading: Boolean = false, - val listings: List = emptyList(), - val error: String? = null - ) + data class State( + val isLoading: Boolean = false, + val listings: List = emptyList(), + val filters: List = emptyList(), + val error: String? = null + ) - private val _state = MutableStateFlow(State(isLoading = true)) - val state: StateFlow = _state + private val _state = MutableStateFlow(State(isLoading = true)) + val state: StateFlow = _state - private val repo = RealEstateRepository() + private val repo = RealEstateRepository() - init { - refresh() + init { + refresh() + } + + fun toggleFilter(filterClass: KClass) { + println("Toggle filter: $filterClass") + if (state.value.isLoading || state.value.error != null) return + + val existingFilter = state.value.filters.firstOrNull { it::class == filterClass } + + if (existingFilter != null) { + _state.update { + it.copy(filters = it.filters - existingFilter) + } + } else { + _state.update { + it.copy( + filters = it.filters + when (filterClass) { + GroupingFilter.City::class -> GroupingFilter.City + GroupingFilter.ZipCode::class -> GroupingFilter.ZipCode + GroupingFilter::PriceRange::class -> + GroupingFilter.PriceRange( + listOf( + 0 until 1500, + 1500 until 2000, + 2000 until 3000, + 3000 until Int.MAX_VALUE, + ) + ) + + else -> return@update it + } + ) + } } - fun refresh() { - _state.value = _state.value.copy(isLoading = true, error = null) - viewModelScope.launch(Dispatchers.IO) { - val result = repo.fetchListings() - _state.value = result.fold( - onSuccess = { list -> State(isLoading = false, listings = list) }, - onFailure = { e -> State(isLoading = false, error = e.message ?: "Unknown error") } - ) - } + refresh() + } + + private fun refresh() { + _state.value = _state.value.copy(isLoading = true, error = null) + viewModelScope.launch { + val result = repo.fetchGroupedListings(state.value.filters) + _state.update { + result.fold( + onSuccess = { list -> it.copy(isLoading = false, listings = list) }, + onFailure = { e -> it.copy(isLoading = false, error = e.message ?: "Unknown error") } + ) + } } + } }