mirror of
				https://github.com/AdrianKuta/android-challange-adrian-kuta.git
				synced 2025-10-31 06:33:39 +01:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			b23baa587c
			...
			ffcfc1f45b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ffcfc1f45b | ||
|   | 5d9a56d71f | ||
|   | 524a64a443 | 
| @@ -0,0 +1,8 @@ | |||||||
|  | package dev.adriankuta.flights.domain.search | ||||||
|  |  | ||||||
|  | import dev.adriankuta.flights.domain.search.entities.SearchOptions | ||||||
|  | import dev.adriankuta.flights.domain.types.Flight | ||||||
|  |  | ||||||
|  | fun interface GetFlightsSearchContentUseCase { | ||||||
|  |     suspend operator fun invoke(searchOptions: SearchOptions): Flight | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | package dev.adriankuta.flights.domain.search.entities | ||||||
|  |  | ||||||
|  | import dev.adriankuta.flights.domain.types.Airport | ||||||
|  | import java.time.LocalDate | ||||||
|  |  | ||||||
|  | data class SearchOptions( | ||||||
|  |     val origin: Airport.Departure, | ||||||
|  |     val destination: Airport.Arrival, | ||||||
|  |     val date: LocalDate, | ||||||
|  |     val adults: Int, | ||||||
|  |     val teens: Int, | ||||||
|  |     val children: Int, | ||||||
|  | ) | ||||||
| @@ -12,7 +12,7 @@ interface FlightService { | |||||||
|         "client: android", |         "client: android", | ||||||
|         "client-version: 3.300.0", |         "client-version: 3.300.0", | ||||||
|     ) |     ) | ||||||
|     @GET("v4/en-gb/Availability") |     @GET("api/v4/en-gb/Availability") | ||||||
|     @Suppress("LongParameterList") |     @Suppress("LongParameterList") | ||||||
|     suspend fun getFlights( |     suspend fun getFlights( | ||||||
|         @Query("dateout") date: String, |         @Query("dateout") date: String, | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import okhttp3.OkHttpClient | |||||||
| import okhttp3.logging.HttpLoggingInterceptor | import okhttp3.logging.HttpLoggingInterceptor | ||||||
| import retrofit2.Retrofit | import retrofit2.Retrofit | ||||||
| import retrofit2.converter.moshi.MoshiConverterFactory | import retrofit2.converter.moshi.MoshiConverterFactory | ||||||
|  | import javax.inject.Qualifier | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
|  |  | ||||||
| @Module | @Module | ||||||
| @@ -37,7 +38,8 @@ class NetworkModule { | |||||||
|  |  | ||||||
|     @Singleton |     @Singleton | ||||||
|     @Provides |     @Provides | ||||||
|     fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() |     @ServicesAPI | ||||||
|  |     fun provideServicesRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() | ||||||
|         // TODO("add URL provided in the task instructions") |         // TODO("add URL provided in the task instructions") | ||||||
|         .baseUrl("https://services-api.ryanair.com") |         .baseUrl("https://services-api.ryanair.com") | ||||||
|         .addConverterFactory(MoshiConverterFactory.create(moshi)) |         .addConverterFactory(MoshiConverterFactory.create(moshi)) | ||||||
| @@ -46,16 +48,34 @@ class NetworkModule { | |||||||
|  |  | ||||||
|     @Singleton |     @Singleton | ||||||
|     @Provides |     @Provides | ||||||
|     fun provideAirportService(retrofit: Retrofit): AirportService = |     @NativeAppsAPI | ||||||
|  |     fun provideNativeAppsRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() | ||||||
|  |         // TODO("add URL provided in the task instructions") | ||||||
|  |         .baseUrl("https://nativeapps.ryanair.com") | ||||||
|  |         .addConverterFactory(MoshiConverterFactory.create(moshi)) | ||||||
|  |         .client(client) | ||||||
|  |         .build() | ||||||
|  |  | ||||||
|  |     @Singleton | ||||||
|  |     @Provides | ||||||
|  |     fun provideAirportService(@ServicesAPI retrofit: Retrofit): AirportService = | ||||||
|         retrofit.create(AirportService::class.java) |         retrofit.create(AirportService::class.java) | ||||||
|  |  | ||||||
|     @Singleton |     @Singleton | ||||||
|     @Provides |     @Provides | ||||||
|     fun provideRoutesService(retrofit: Retrofit): RoutesService = |     fun provideRoutesService(@ServicesAPI retrofit: Retrofit): RoutesService = | ||||||
|         retrofit.create(RoutesService::class.java) |         retrofit.create(RoutesService::class.java) | ||||||
|  |  | ||||||
|     @Singleton |     @Singleton | ||||||
|     @Provides |     @Provides | ||||||
|     fun provideFlightService(retrofit: Retrofit): FlightService = |     fun provideFlightService(@NativeAppsAPI retrofit: Retrofit): FlightService = | ||||||
|         retrofit.create(FlightService::class.java) |         retrofit.create(FlightService::class.java) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @Qualifier | ||||||
|  | @Retention(AnnotationRetention.BINARY) | ||||||
|  | annotation class ServicesAPI | ||||||
|  |  | ||||||
|  | @Qualifier | ||||||
|  | @Retention(AnnotationRetention.BINARY) | ||||||
|  | annotation class NativeAppsAPI | ||||||
|   | |||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | package dev.adriankuta.flights.model.repository.di | ||||||
|  |  | ||||||
|  | import dagger.Binds | ||||||
|  | import dagger.Module | ||||||
|  | import dagger.hilt.InstallIn | ||||||
|  | import dagger.hilt.components.SingletonComponent | ||||||
|  | import dev.adriankuta.flights.domain.search.GetFlightsSearchContentUseCase | ||||||
|  | import dev.adriankuta.flights.model.repository.usecases.GetFlightsSearchContentUseCaseImpl | ||||||
|  |  | ||||||
|  | @Module | ||||||
|  | @InstallIn(SingletonComponent::class) | ||||||
|  | @Suppress("UnnecessaryAbstractClass") | ||||||
|  | internal abstract class GetFlightsSearchContentUseCaseModule { | ||||||
|  |  | ||||||
|  |     @Binds | ||||||
|  |     abstract fun bind( | ||||||
|  |         getFlightsSearchContentUseCaseImpl: GetFlightsSearchContentUseCaseImpl, | ||||||
|  |     ): GetFlightsSearchContentUseCase | ||||||
|  | } | ||||||
| @@ -0,0 +1,75 @@ | |||||||
|  | package dev.adriankuta.flights.model.repository.mappers | ||||||
|  |  | ||||||
|  | import dev.adriankuta.flights.domain.types.Flight | ||||||
|  | import dev.adriankuta.flights.domain.types.RegularFare | ||||||
|  | import dev.adriankuta.flights.domain.types.Segment | ||||||
|  | import dev.adriankuta.flights.domain.types.Trip | ||||||
|  | import dev.adriankuta.flights.domain.types.TripDate | ||||||
|  | import dev.adriankuta.flights.domain.types.TripFare | ||||||
|  | import dev.adriankuta.flights.domain.types.TripFlight | ||||||
|  | import dev.adriankuta.model.data.api.entities.FlightResponse | ||||||
|  | import dev.adriankuta.model.data.api.entities.RegularFare as ApiRegularFare | ||||||
|  | import dev.adriankuta.model.data.api.entities.Segment as ApiSegment | ||||||
|  | import dev.adriankuta.model.data.api.entities.Trip as ApiTrip | ||||||
|  | import dev.adriankuta.model.data.api.entities.TripDate as ApiTripDate | ||||||
|  | import dev.adriankuta.model.data.api.entities.TripFare as ApiTripFare | ||||||
|  | import dev.adriankuta.model.data.api.entities.TripFlight as ApiTripFlight | ||||||
|  |  | ||||||
|  | internal fun FlightResponse.toDomain(): Flight { | ||||||
|  |     return Flight( | ||||||
|  |         currency = currency ?: "", | ||||||
|  |         currPrecision = currPrecision ?: 0, | ||||||
|  |         trips = trips.orEmpty().map { it.toDomain() }, | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiTrip.toDomain(): Trip { | ||||||
|  |     return Trip( | ||||||
|  |         dates = dates.orEmpty().map { it.toDomain() }, | ||||||
|  |         origin = origin ?: "", | ||||||
|  |         destination = destination ?: "", | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiTripDate.toDomain(): TripDate { | ||||||
|  |     return TripDate( | ||||||
|  |         dateOut = dateOut ?: "", | ||||||
|  |         flights = flights.orEmpty().map { it.toDomain() }, | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiTripFlight.toDomain(): TripFlight { | ||||||
|  |     return TripFlight( | ||||||
|  |         faresLeft = faresLeft ?: 0, | ||||||
|  |         regularFare = regularFare?.toDomain() ?: RegularFare(emptyList()), | ||||||
|  |         flightNumber = flightNumber ?: "", | ||||||
|  |         dateTimes = dateTimes.orEmpty(), | ||||||
|  |         duration = duration ?: "", | ||||||
|  |         segments = segments.orEmpty().map { it.toDomain() }, | ||||||
|  |         operatedBy = operatedBy ?: "", | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiRegularFare.toDomain(): RegularFare { | ||||||
|  |     return RegularFare( | ||||||
|  |         fares = fares.orEmpty().map { it.toDomain() }, | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiTripFare.toDomain(): TripFare { | ||||||
|  |     return TripFare( | ||||||
|  |         type = type ?: "", | ||||||
|  |         amount = amount ?: 0.0, | ||||||
|  |         count = count ?: 0, | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal fun ApiSegment.toDomain(): Segment { | ||||||
|  |     return Segment( | ||||||
|  |         origin = origin ?: "", | ||||||
|  |         destination = destination ?: "", | ||||||
|  |         flightNumber = flightNumber ?: "", | ||||||
|  |         dateTimes = dateTimes.orEmpty(), | ||||||
|  |         duration = duration ?: "", | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | package dev.adriankuta.flights.model.repository.usecases | ||||||
|  |  | ||||||
|  | import dev.adriankuta.flights.domain.search.GetFlightsSearchContentUseCase | ||||||
|  | import dev.adriankuta.flights.domain.search.entities.SearchOptions | ||||||
|  | import dev.adriankuta.flights.domain.types.Flight | ||||||
|  | import dev.adriankuta.flights.model.repository.mappers.toDomain | ||||||
|  | import dev.adriankuta.model.data.api.FlightService | ||||||
|  | import java.time.format.DateTimeFormatter | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | internal class GetFlightsSearchContentUseCaseImpl @Inject constructor( | ||||||
|  |     private val flightService: FlightService, | ||||||
|  | ) : GetFlightsSearchContentUseCase { | ||||||
|  |  | ||||||
|  |     override suspend fun invoke(searchOptions: SearchOptions): Flight { | ||||||
|  |         val result = flightService.getFlights( | ||||||
|  |             origin = searchOptions.origin.code, | ||||||
|  |             destination = searchOptions.destination.code, | ||||||
|  |             date = searchOptions.date.format(DateTimeFormatter.ISO_DATE), | ||||||
|  |             adult = searchOptions.adults, | ||||||
|  |             teen = searchOptions.teens, | ||||||
|  |             child = searchOptions.children, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return result.toDomain() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,16 +1,10 @@ | |||||||
| package dev.adriankuta.flights.ui.home | package dev.adriankuta.flights.ui.home | ||||||
|  |  | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column |  | ||||||
| import androidx.compose.foundation.layout.ColumnScope |  | ||||||
| import androidx.compose.foundation.layout.IntrinsicSize |  | ||||||
| import androidx.compose.foundation.layout.Row |  | ||||||
| import androidx.compose.foundation.layout.Spacer |  | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth |  | ||||||
| import androidx.compose.foundation.layout.height |  | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.rememberScrollState | ||||||
|  | import androidx.compose.foundation.verticalScroll | ||||||
| import androidx.compose.material3.Text | import androidx.compose.material3.Text | ||||||
| import androidx.compose.material3.VerticalDivider |  | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| @@ -25,9 +19,7 @@ import dev.adriankuta.flights.domain.types.MacCity | |||||||
| import dev.adriankuta.flights.domain.types.Region | import dev.adriankuta.flights.domain.types.Region | ||||||
| import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||||
| import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||||
| import dev.adriankuta.flights.ui.home.components.AirportDropdown | import dev.adriankuta.flights.ui.home.components.SearchForm | ||||||
| import dev.adriankuta.flights.ui.home.components.DatePicker |  | ||||||
| import dev.adriankuta.flights.ui.sharedui.Counter |  | ||||||
| import java.time.LocalDate | import java.time.LocalDate | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| @@ -44,6 +36,7 @@ internal fun HomeScreen( | |||||||
|         onAdultCountChange = viewModel::updateAdultCount, |         onAdultCountChange = viewModel::updateAdultCount, | ||||||
|         onTeenCountChange = viewModel::updateTeenCount, |         onTeenCountChange = viewModel::updateTeenCount, | ||||||
|         onChildCountChange = viewModel::updateChildCount, |         onChildCountChange = viewModel::updateChildCount, | ||||||
|  |         onSearch = viewModel::search, | ||||||
|     ) |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -56,10 +49,16 @@ private fun HomeScreen( | |||||||
|     onAdultCountChange: (Int) -> Unit, |     onAdultCountChange: (Int) -> Unit, | ||||||
|     onTeenCountChange: (Int) -> Unit, |     onTeenCountChange: (Int) -> Unit, | ||||||
|     onChildCountChange: (Int) -> Unit, |     onChildCountChange: (Int) -> Unit, | ||||||
|  |     onSearch: () -> Unit, | ||||||
|     modifier: Modifier = Modifier, |     modifier: Modifier = Modifier, | ||||||
| ) { | ) { | ||||||
|     Column( |     val scrollState = rememberScrollState() | ||||||
|         modifier = modifier.padding(16.dp), |     Box( | ||||||
|  |         modifier = modifier | ||||||
|  |             .padding(16.dp) | ||||||
|  |             .verticalScroll( | ||||||
|  |                 state = scrollState, | ||||||
|  |             ), | ||||||
|     ) { |     ) { | ||||||
|         when (uiState) { |         when (uiState) { | ||||||
|             is HomeUiState.Error -> Text("Error") |             is HomeUiState.Error -> Text("Error") | ||||||
| @@ -72,90 +71,12 @@ private fun HomeScreen( | |||||||
|                 onAdultCountChange = onAdultCountChange, |                 onAdultCountChange = onAdultCountChange, | ||||||
|                 onTeenCountChange = onTeenCountChange, |                 onTeenCountChange = onTeenCountChange, | ||||||
|                 onChildCountChange = onChildCountChange, |                 onChildCountChange = onChildCountChange, | ||||||
|  |                 onSearch = onSearch, | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| private fun ColumnScope.SearchForm( |  | ||||||
|     uiState: HomeUiState.Success, |  | ||||||
|     onOriginAirportSelect: (AirportInfo) -> Unit, |  | ||||||
|     onDestinationAirportSelect: (AirportInfo) -> Unit, |  | ||||||
|     onDateSelect: (LocalDate) -> Unit, |  | ||||||
|     onAdultCountChange: (Int) -> Unit, |  | ||||||
|     onTeenCountChange: (Int) -> Unit, |  | ||||||
|     onChildCountChange: (Int) -> Unit, |  | ||||||
| ) { |  | ||||||
|     AirportDropdown( |  | ||||||
|         label = "Origin Airport", |  | ||||||
|         airports = uiState.originAirports, |  | ||||||
|         selectedAirport = uiState.selectedOriginAirport, |  | ||||||
|         onAirportSelect = onOriginAirportSelect, |  | ||||||
|         modifier = Modifier.fillMaxWidth(), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     Spacer(modifier = Modifier.height(16.dp)) |  | ||||||
|  |  | ||||||
|     AirportDropdown( |  | ||||||
|         label = "Destination Airport", |  | ||||||
|         airports = uiState.destinationAirports, |  | ||||||
|         selectedAirport = uiState.selectedDestinationAirport, |  | ||||||
|         onAirportSelect = onDestinationAirportSelect, |  | ||||||
|         modifier = Modifier.fillMaxWidth(), |  | ||||||
|         enabled = uiState.selectedOriginAirport != null, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     Spacer(modifier = Modifier.height(16.dp)) |  | ||||||
|  |  | ||||||
|     DatePicker( |  | ||||||
|         label = "Departure Date", |  | ||||||
|         selectedDate = uiState.selectedDate, |  | ||||||
|         onDateSelect = onDateSelect, |  | ||||||
|         modifier = Modifier.fillMaxWidth(), |  | ||||||
|         enabled = uiState.selectedDestinationAirport != null, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     Spacer(modifier = Modifier.height(16.dp)) |  | ||||||
|  |  | ||||||
|     Text("Passengers") |  | ||||||
|  |  | ||||||
|     Spacer(modifier = Modifier.height(8.dp)) |  | ||||||
|  |  | ||||||
|     Row( |  | ||||||
|         modifier = Modifier |  | ||||||
|             .fillMaxWidth() |  | ||||||
|             .height(intrinsicSize = IntrinsicSize.Min), |  | ||||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), |  | ||||||
|     ) { |  | ||||||
|         Counter( |  | ||||||
|             value = uiState.passengers.adultCount, |  | ||||||
|             onCountChange = onAdultCountChange, |  | ||||||
|             label = "Adults", |  | ||||||
|             modifier = Modifier.weight(1f), |  | ||||||
|             minVal = 1, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         VerticalDivider() |  | ||||||
|  |  | ||||||
|         Counter( |  | ||||||
|             value = uiState.passengers.teenCount, |  | ||||||
|             onCountChange = onTeenCountChange, |  | ||||||
|             label = "Teens", |  | ||||||
|             modifier = Modifier.weight(1f), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         VerticalDivider() |  | ||||||
|  |  | ||||||
|         Counter( |  | ||||||
|             value = uiState.passengers.childCount, |  | ||||||
|             onCountChange = onChildCountChange, |  | ||||||
|             label = "Children", |  | ||||||
|             modifier = Modifier.weight(1f), |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @PreviewDevices | @PreviewDevices | ||||||
| @Composable | @Composable | ||||||
| private fun HomeScreenLoadingPreview() { | private fun HomeScreenLoadingPreview() { | ||||||
| @@ -168,6 +89,7 @@ private fun HomeScreenLoadingPreview() { | |||||||
|             onAdultCountChange = {}, |             onAdultCountChange = {}, | ||||||
|             onTeenCountChange = {}, |             onTeenCountChange = {}, | ||||||
|             onChildCountChange = {}, |             onChildCountChange = {}, | ||||||
|  |             onSearch = {}, | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -222,6 +144,7 @@ private fun HomeScreenSuccessPreview() { | |||||||
|             onAdultCountChange = {}, |             onAdultCountChange = {}, | ||||||
|             onTeenCountChange = {}, |             onTeenCountChange = {}, | ||||||
|             onChildCountChange = {}, |             onChildCountChange = {}, | ||||||
|  |             onSearch = {}, | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope | |||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import dev.adriankuta.flights.core.util.Result | import dev.adriankuta.flights.core.util.Result | ||||||
| import dev.adriankuta.flights.core.util.asResult | import dev.adriankuta.flights.core.util.asResult | ||||||
|  | import dev.adriankuta.flights.domain.search.GetFlightsSearchContentUseCase | ||||||
| import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase | import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase | ||||||
|  | import dev.adriankuta.flights.domain.search.entities.SearchOptions | ||||||
|  | import dev.adriankuta.flights.domain.types.Airport | ||||||
| import dev.adriankuta.flights.domain.types.AirportInfo | import dev.adriankuta.flights.domain.types.AirportInfo | ||||||
| import kotlinx.coroutines.flow.Flow | import kotlinx.coroutines.flow.Flow | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
| @@ -13,12 +16,15 @@ import kotlinx.coroutines.flow.SharingStarted | |||||||
| import kotlinx.coroutines.flow.StateFlow | import kotlinx.coroutines.flow.StateFlow | ||||||
| import kotlinx.coroutines.flow.combine | import kotlinx.coroutines.flow.combine | ||||||
| import kotlinx.coroutines.flow.stateIn | import kotlinx.coroutines.flow.stateIn | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import timber.log.Timber | ||||||
| import java.time.LocalDate | import java.time.LocalDate | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class HomeScreenViewModel @Inject constructor( | class HomeScreenViewModel @Inject constructor( | ||||||
|     private val observeAirportsUseCase: ObserveAirportsUseCase, |     observeAirportsUseCase: ObserveAirportsUseCase, | ||||||
|  |     private val getFlightsSearchContentUseCase: GetFlightsSearchContentUseCase, | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |  | ||||||
|     private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) |     private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) | ||||||
| @@ -71,19 +77,41 @@ class HomeScreenViewModel @Inject constructor( | |||||||
|         selectedDate.value = date |         selectedDate.value = date | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateAdultCount(diff: Int) { |     fun updateAdultCount(count: Int) { | ||||||
|         val newCount = adultCount.value + diff |         adultCount.value = count | ||||||
|         adultCount.value = newCount |         childCount.value = childCount.value.coerceAtMost(count) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateTeenCount(diff: Int) { |     fun updateTeenCount(count: Int) { | ||||||
|         val newCount = teenCount.value + diff |         teenCount.value = count | ||||||
|         teenCount.value = newCount |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateChildCount(diff: Int) { |     fun updateChildCount(count: Int) { | ||||||
|         val newCount = childCount.value + diff |         childCount.value = count | ||||||
|         childCount.value = newCount |     } | ||||||
|  |  | ||||||
|  |     fun search() { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             val results = getFlightsSearchContentUseCase( | ||||||
|  |                 searchOptions = SearchOptions( | ||||||
|  |                     origin = Airport.Departure( | ||||||
|  |                         code = selectedOriginAirport.value?.code ?: return@launch, | ||||||
|  |                         name = selectedOriginAirport.value?.name ?: return@launch, | ||||||
|  |                         macCity = selectedOriginAirport.value?.macCity, | ||||||
|  |                     ), | ||||||
|  |                     destination = Airport.Arrival( | ||||||
|  |                         code = selectedDestinationAirport.value?.code ?: return@launch, | ||||||
|  |                         name = selectedDestinationAirport.value?.name ?: return@launch, | ||||||
|  |                         macCity = selectedDestinationAirport.value?.macCity, | ||||||
|  |                     ), | ||||||
|  |                     date = selectedDate.value, | ||||||
|  |                     adults = adultCount.value, | ||||||
|  |                     teens = teenCount.value, | ||||||
|  |                     children = childCount.value, | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |             Timber.d("Result $results") | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,6 +15,13 @@ import androidx.compose.runtime.remember | |||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import dev.adriankuta.flights.domain.types.AirportInfo | import dev.adriankuta.flights.domain.types.AirportInfo | ||||||
|  | import dev.adriankuta.flights.domain.types.City | ||||||
|  | import dev.adriankuta.flights.domain.types.Coordinates | ||||||
|  | import dev.adriankuta.flights.domain.types.Country | ||||||
|  | import dev.adriankuta.flights.domain.types.MacCity | ||||||
|  | import dev.adriankuta.flights.domain.types.Region | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||||
|  |  | ||||||
| @OptIn(ExperimentalMaterial3Api::class) | @OptIn(ExperimentalMaterial3Api::class) | ||||||
| @Composable | @Composable | ||||||
| @@ -64,3 +71,75 @@ fun AirportDropdown( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @PreviewDevices | ||||||
|  | @Composable | ||||||
|  | @Suppress("LongMethod") | ||||||
|  | private fun AirportDropdownPreview() { | ||||||
|  |     val sampleAirports = listOf( | ||||||
|  |         AirportInfo( | ||||||
|  |             code = "WAW", | ||||||
|  |             name = "Warsaw Chopin", | ||||||
|  |             seoName = "warsaw", | ||||||
|  |             isBase = true, | ||||||
|  |             timeZone = "Europe/Warsaw", | ||||||
|  |             city = City( | ||||||
|  |                 code = "WAW", | ||||||
|  |                 name = "Warsaw", | ||||||
|  |             ), | ||||||
|  |             macCity = MacCity( | ||||||
|  |                 macCode = "WAW", | ||||||
|  |             ), | ||||||
|  |             region = Region( | ||||||
|  |                 code = "PL", | ||||||
|  |                 name = "Poland", | ||||||
|  |             ), | ||||||
|  |             country = Country( | ||||||
|  |                 code = "PL", | ||||||
|  |                 name = "Poland", | ||||||
|  |                 currencyCode = "PLN", | ||||||
|  |             ), | ||||||
|  |             coordinates = Coordinates( | ||||||
|  |                 latitude = 52.1657, | ||||||
|  |                 longitude = 20.9671, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         AirportInfo( | ||||||
|  |             code = "LHR", | ||||||
|  |             name = "London Heathrow", | ||||||
|  |             seoName = "london", | ||||||
|  |             isBase = false, | ||||||
|  |             timeZone = "Europe/London", | ||||||
|  |             city = City( | ||||||
|  |                 code = "LON", | ||||||
|  |                 name = "London", | ||||||
|  |             ), | ||||||
|  |             macCity = MacCity( | ||||||
|  |                 macCode = "LON", | ||||||
|  |             ), | ||||||
|  |             region = Region( | ||||||
|  |                 code = "UK", | ||||||
|  |                 name = "United Kingdom", | ||||||
|  |             ), | ||||||
|  |             country = Country( | ||||||
|  |                 code = "UK", | ||||||
|  |                 name = "United Kingdom", | ||||||
|  |                 currencyCode = "GBP", | ||||||
|  |             ), | ||||||
|  |             coordinates = Coordinates( | ||||||
|  |                 latitude = 51.4700, | ||||||
|  |                 longitude = -0.4543, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     FlightsTheme { | ||||||
|  |         AirportDropdown( | ||||||
|  |             label = "Departure Airport", | ||||||
|  |             airports = sampleAirports, | ||||||
|  |             selectedAirport = sampleAirports.first(), | ||||||
|  |             onAirportSelect = {}, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -20,6 +20,8 @@ import androidx.compose.runtime.remember | |||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.platform.LocalFocusManager | import androidx.compose.ui.platform.LocalFocusManager | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||||
| import kotlinx.coroutines.channels.BufferOverflow | import kotlinx.coroutines.channels.BufferOverflow | ||||||
| import kotlinx.coroutines.flow.MutableSharedFlow | import kotlinx.coroutines.flow.MutableSharedFlow | ||||||
| import java.time.Instant | import java.time.Instant | ||||||
| @@ -152,3 +154,19 @@ class FutureSelectableDates : SelectableDates { | |||||||
|         return year >= now.year |         return year >= now.year | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @PreviewDevices | ||||||
|  | @Composable | ||||||
|  | private fun DatePickerPreview() { | ||||||
|  |     val today = LocalDate.now() | ||||||
|  |     val futureDate = today.plusDays(7) // A week from today | ||||||
|  |  | ||||||
|  |     FlightsTheme { | ||||||
|  |         DatePicker( | ||||||
|  |             label = "Departure Date", | ||||||
|  |             selectedDate = futureDate, | ||||||
|  |             onDateSelect = {}, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -0,0 +1,86 @@ | |||||||
|  | package dev.adriankuta.flights.ui.home.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.IntrinsicSize | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.material3.VerticalDivider | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||||
|  | import dev.adriankuta.flights.ui.home.HomeUiState | ||||||
|  | import dev.adriankuta.flights.ui.home.PassengersState | ||||||
|  | import dev.adriankuta.flights.ui.sharedui.Counter | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | internal fun PassengersOptions( | ||||||
|  |     uiState: HomeUiState.Success, | ||||||
|  |     onAdultCountChange: (Int) -> Unit, | ||||||
|  |     onTeenCountChange: (Int) -> Unit, | ||||||
|  |     onChildCountChange: (Int) -> Unit, | ||||||
|  | ) { | ||||||
|  |     Row( | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .height(intrinsicSize = IntrinsicSize.Min), | ||||||
|  |         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||||
|  |     ) { | ||||||
|  |         Counter( | ||||||
|  |             value = uiState.passengers.adultCount, | ||||||
|  |             onValueChange = onAdultCountChange, | ||||||
|  |             label = "Adults", | ||||||
|  |             modifier = Modifier.weight(1f), | ||||||
|  |             minVal = 1, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         VerticalDivider() | ||||||
|  |  | ||||||
|  |         Counter( | ||||||
|  |             value = uiState.passengers.teenCount, | ||||||
|  |             onValueChange = onTeenCountChange, | ||||||
|  |             label = "Teens", | ||||||
|  |             modifier = Modifier.weight(1f), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         VerticalDivider() | ||||||
|  |  | ||||||
|  |         Counter( | ||||||
|  |             value = uiState.passengers.childCount, | ||||||
|  |             onValueChange = onChildCountChange, | ||||||
|  |             label = "Children", | ||||||
|  |             modifier = Modifier.weight(1f), | ||||||
|  |             maxVal = uiState.passengers.adultCount, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @PreviewDevices | ||||||
|  | @Composable | ||||||
|  | private fun PassengersOptionsPreview() { | ||||||
|  |     val samplePassengersState = PassengersState( | ||||||
|  |         adultCount = 2, | ||||||
|  |         teenCount = 1, | ||||||
|  |         childCount = 1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     val sampleUiState = HomeUiState.Success( | ||||||
|  |         originAirports = emptyList(), | ||||||
|  |         destinationAirports = emptyList(), | ||||||
|  |         selectedOriginAirport = null, | ||||||
|  |         selectedDestinationAirport = null, | ||||||
|  |         selectedDate = java.time.LocalDate.now(), | ||||||
|  |         passengers = samplePassengersState, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     FlightsTheme { | ||||||
|  |         PassengersOptions( | ||||||
|  |             uiState = sampleUiState, | ||||||
|  |             onAdultCountChange = {}, | ||||||
|  |             onTeenCountChange = {}, | ||||||
|  |             onChildCountChange = {}, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,174 @@ | |||||||
|  | package dev.adriankuta.flights.ui.home.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.material3.Button | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import dev.adriankuta.flights.domain.types.AirportInfo | ||||||
|  | import dev.adriankuta.flights.domain.types.City | ||||||
|  | import dev.adriankuta.flights.domain.types.Coordinates | ||||||
|  | import dev.adriankuta.flights.domain.types.Country | ||||||
|  | import dev.adriankuta.flights.domain.types.MacCity | ||||||
|  | import dev.adriankuta.flights.domain.types.Region | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||||
|  | import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||||
|  | import dev.adriankuta.flights.ui.home.HomeUiState | ||||||
|  | import dev.adriankuta.flights.ui.home.PassengersState | ||||||
|  | import java.time.LocalDate | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | internal fun SearchForm( | ||||||
|  |     uiState: HomeUiState.Success, | ||||||
|  |     onOriginAirportSelect: (AirportInfo) -> Unit, | ||||||
|  |     onDestinationAirportSelect: (AirportInfo) -> Unit, | ||||||
|  |     onDateSelect: (LocalDate) -> Unit, | ||||||
|  |     onAdultCountChange: (Int) -> Unit, | ||||||
|  |     onTeenCountChange: (Int) -> Unit, | ||||||
|  |     onChildCountChange: (Int) -> Unit, | ||||||
|  |     onSearch: () -> Unit, | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  | ) { | ||||||
|  |     Column(modifier) { | ||||||
|  |         AirportDropdown( | ||||||
|  |             label = "Origin Airport", | ||||||
|  |             airports = uiState.originAirports, | ||||||
|  |             selectedAirport = uiState.selectedOriginAirport, | ||||||
|  |             onAirportSelect = onOriginAirportSelect, | ||||||
|  |             modifier = Modifier.fillMaxWidth(), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Spacer(modifier = Modifier.height(16.dp)) | ||||||
|  |  | ||||||
|  |         AirportDropdown( | ||||||
|  |             label = "Destination Airport", | ||||||
|  |             airports = uiState.destinationAirports, | ||||||
|  |             selectedAirport = uiState.selectedDestinationAirport, | ||||||
|  |             onAirportSelect = onDestinationAirportSelect, | ||||||
|  |             modifier = Modifier.fillMaxWidth(), | ||||||
|  |             enabled = uiState.selectedOriginAirport != null, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Spacer(modifier = Modifier.height(16.dp)) | ||||||
|  |  | ||||||
|  |         DatePicker( | ||||||
|  |             label = "Departure Date", | ||||||
|  |             selectedDate = uiState.selectedDate, | ||||||
|  |             onDateSelect = onDateSelect, | ||||||
|  |             modifier = Modifier.fillMaxWidth(), | ||||||
|  |             enabled = uiState.selectedDestinationAirport != null, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Spacer(modifier = Modifier.height(16.dp)) | ||||||
|  |  | ||||||
|  |         Text("Passengers") | ||||||
|  |  | ||||||
|  |         Spacer(modifier = Modifier.height(8.dp)) | ||||||
|  |  | ||||||
|  |         PassengersOptions(uiState, onAdultCountChange, onTeenCountChange, onChildCountChange) | ||||||
|  |  | ||||||
|  |         Spacer(modifier = Modifier.height(24.dp)) | ||||||
|  |  | ||||||
|  |         Button( | ||||||
|  |             onClick = onSearch, | ||||||
|  |             modifier = Modifier.fillMaxWidth(), | ||||||
|  |             enabled = uiState.selectedOriginAirport != null && uiState.selectedDestinationAirport != null, | ||||||
|  |         ) { | ||||||
|  |             Text("Search Flights") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @PreviewDevices | ||||||
|  | @Composable | ||||||
|  | @Suppress("LongMethod") | ||||||
|  | private fun SearchFormPreview() { | ||||||
|  |     val sampleAirports = listOf( | ||||||
|  |         AirportInfo( | ||||||
|  |             code = "WAW", | ||||||
|  |             name = "Warsaw Chopin", | ||||||
|  |             seoName = "warsaw", | ||||||
|  |             isBase = true, | ||||||
|  |             timeZone = "Europe/Warsaw", | ||||||
|  |             city = City( | ||||||
|  |                 code = "WAW", | ||||||
|  |                 name = "Warsaw", | ||||||
|  |             ), | ||||||
|  |             macCity = MacCity( | ||||||
|  |                 macCode = "WAW", | ||||||
|  |             ), | ||||||
|  |             region = Region( | ||||||
|  |                 code = "PL", | ||||||
|  |                 name = "Poland", | ||||||
|  |             ), | ||||||
|  |             country = Country( | ||||||
|  |                 code = "PL", | ||||||
|  |                 name = "Poland", | ||||||
|  |                 currencyCode = "PLN", | ||||||
|  |             ), | ||||||
|  |             coordinates = Coordinates( | ||||||
|  |                 latitude = 52.1657, | ||||||
|  |                 longitude = 20.9671, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         AirportInfo( | ||||||
|  |             code = "LHR", | ||||||
|  |             name = "London Heathrow", | ||||||
|  |             seoName = "london", | ||||||
|  |             isBase = false, | ||||||
|  |             timeZone = "Europe/London", | ||||||
|  |             city = City( | ||||||
|  |                 code = "LON", | ||||||
|  |                 name = "London", | ||||||
|  |             ), | ||||||
|  |             macCity = MacCity( | ||||||
|  |                 macCode = "LON", | ||||||
|  |             ), | ||||||
|  |             region = Region( | ||||||
|  |                 code = "UK", | ||||||
|  |                 name = "United Kingdom", | ||||||
|  |             ), | ||||||
|  |             country = Country( | ||||||
|  |                 code = "UK", | ||||||
|  |                 name = "United Kingdom", | ||||||
|  |                 currencyCode = "GBP", | ||||||
|  |             ), | ||||||
|  |             coordinates = Coordinates( | ||||||
|  |                 latitude = 51.4700, | ||||||
|  |                 longitude = -0.4543, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     val samplePassengersState = PassengersState( | ||||||
|  |         adultCount = 2, | ||||||
|  |         teenCount = 1, | ||||||
|  |         childCount = 1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     val sampleUiState = HomeUiState.Success( | ||||||
|  |         originAirports = sampleAirports, | ||||||
|  |         destinationAirports = sampleAirports.drop(1), | ||||||
|  |         selectedOriginAirport = sampleAirports.first(), | ||||||
|  |         selectedDestinationAirport = sampleAirports.last(), | ||||||
|  |         selectedDate = LocalDate.now().plusDays(7), | ||||||
|  |         passengers = samplePassengersState, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     FlightsTheme { | ||||||
|  |         SearchForm( | ||||||
|  |             uiState = sampleUiState, | ||||||
|  |             onOriginAirportSelect = {}, | ||||||
|  |             onDestinationAirportSelect = {}, | ||||||
|  |             onDateSelect = {}, | ||||||
|  |             onAdultCountChange = {}, | ||||||
|  |             onTeenCountChange = {}, | ||||||
|  |             onChildCountChange = {}, | ||||||
|  |             onSearch = {}, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -34,10 +34,11 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | |||||||
| @Composable | @Composable | ||||||
| fun Counter( | fun Counter( | ||||||
|     value: Int, |     value: Int, | ||||||
|     onCountChange: (diff: Int) -> Unit, |     onValueChange: (newValue: Int) -> Unit, | ||||||
|     modifier: Modifier = Modifier, |     modifier: Modifier = Modifier, | ||||||
|     label: String? = null, |     label: String? = null, | ||||||
|     minVal: Int = 0, |     minVal: Int = 0, | ||||||
|  |     maxVal: Int = Int.MAX_VALUE, | ||||||
| ) { | ) { | ||||||
|     val view = LocalView.current |     val view = LocalView.current | ||||||
|  |  | ||||||
| @@ -84,7 +85,7 @@ fun Counter( | |||||||
|                 modifier = Modifier.weight(1f), |                 modifier = Modifier.weight(1f), | ||||||
|                 onClick = { |                 onClick = { | ||||||
|                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) |                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) | ||||||
|                     onCountChange(-1) |                     onValueChange(value - 1) | ||||||
|                 }, |                 }, | ||||||
|                 shape = MaterialTheme.shapes.medium, |                 shape = MaterialTheme.shapes.medium, | ||||||
|                 enabled = value > minVal, |                 enabled = value > minVal, | ||||||
| @@ -99,10 +100,10 @@ fun Counter( | |||||||
|                 modifier = Modifier.weight(1f), |                 modifier = Modifier.weight(1f), | ||||||
|                 onClick = { |                 onClick = { | ||||||
|                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) |                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) | ||||||
|                     onCountChange(1) |                     onValueChange(value + 1) | ||||||
|                 }, |                 }, | ||||||
|                 shape = MaterialTheme.shapes.medium, |                 shape = MaterialTheme.shapes.medium, | ||||||
|                 enabled = value < 20, |                 enabled = value < maxVal, | ||||||
|             ) { |             ) { | ||||||
|                 Text( |                 Text( | ||||||
|                     text = "+", |                     text = "+", | ||||||
| @@ -124,7 +125,8 @@ private fun CounterPreview() { | |||||||
|         Counter( |         Counter( | ||||||
|             value = tapCounter, |             value = tapCounter, | ||||||
|             label = "Taps", |             label = "Taps", | ||||||
|             onCountChange = { tapCounter += it }, |             onValueChange = { tapCounter += it }, | ||||||
|  |             maxVal = 20, | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user