diff --git a/README.md b/README.md index b748875..185a8eb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,226 @@ _Recommendation: Use _it alongside Jetpack Compose_._ ## Demo -_:construction: WIP :construction:_ +### Imaginary Weather app :cloud::sunny::umbrella: + +> TL;DR: You'll find the code of the entire weather app below. If you're already sold to use Ivy FRP => skip to Installation. + +#### Data (boring) +```Kotlin +data class Temperature( + val value: Float, + val unit: TemperatureUnit +) + +enum class TemperatureUnit { + CELSIUS, FAHRENHEIT +} + +data class UserPreference( + val temperatureUnit: TemperatureUnit +) +``` + +#### IO (boring) +```Kotlin +data class WeatherResponse( + val temperature: Temperature +) + +interface WeatherService { + @GET("/api/weather") + suspend fun getWeather(): WeatherResponse +} + +interface UserPreferenceService { + @GET("/preference/temp-unit") + suspend fun getTempUnit(): UserPreference + + @POST("/preference/temp-unit") + suspend fun updateTempUnit( + @Query("unit") temperatureUnit: TemperatureUnit + ) +} +``` + +#### Actions +**ConvertTempAct.kt** +```Kotlin +class ConvertTempAct @Inject constructor() : FPAction() { + + override suspend fun Input.compose(): suspend () -> Temperature = + this asParamTo ::convertValue then { convertedValue -> + Temperature(convertedValue, toUnit) + } + + private fun convertValue(input: Input): Float = with(input.temperature) { + if (unit == input.toUnit) value else { + when (input.toUnit) { + TemperatureUnit.CELSIUS -> fahrenheitToCelsius(value) + TemperatureUnit.FAHRENHEIT -> celsiusToFahrenheit(value) + } + } + } + + //X°F = (Y°C × 9/5) + 32 + private fun celsiusToFahrenheit(celsius: Float): Float = (celsius * 9 / 5) + 32 + + //X°C = (Y°F - 32) / (9/5) + private fun fahrenheitToCelsius(fahrenheit: Float): Float = (fahrenheit - 32) / (9 / 5) + + data class Input( + val temperature: Temperature, + val toUnit: TemperatureUnit + ) +} +``` + +**CurrentTempAct.kt** +```Kotlin +class CurrentTempAct @Inject constructor( + private val weatherService: WeatherService, + private val convertTempAct: ConvertTempAct +) : FPAction>() { + override suspend fun TemperatureUnit.compose(): suspend () -> Res = tryOp( + operation = weatherService::getWeather + ) mapSuccess { response -> + ConvertTempAct.Input( + temperature = response.temperature, + toUnit = this //TemperatureUnit + ) + } mapSuccess convertTempAct mapError { + "Failed to fetch weather: ${it.message}" + } +} +``` + +**UserPreferencesAct.kt** +```Kotlin +class UserPreferenceAct @Inject constructor( + private val userPreferenceService: UserPreferenceService +) : FPAction>() { + override suspend fun Unit.compose(): suspend () -> Res = tryOp( + operation = userPreferenceService::getTempUnit + ) mapError { + "Failed to fetch user's preference: ${it.message}" + } +} +``` + +**UpdateUserPreferencesAct.kt** +```Kotlin +class UpdateUserPreferenceAct @Inject constructor( + private val userPreferenceService: UserPreferenceService +) : FPAction>() { + override suspend fun TemperatureUnit.compose(): suspend () -> Res = tryOp( + operation = this asParamTo userPreferenceService::updateTempUnit + ) mapError { + "Failed to update user preference: ${it.message}" + } +} +``` + +#### ViewModel + +**WeatherState.kt** +```Kotlin +sealed class WeatherState { + object Loading : WeatherState() + + data class Error(val errReason: String) : WeatherState() + + data class Success( + val tempUnit: TemperatureUnit, + val temp: Float + ) : WeatherState() +} +``` + +**WeatherEvent.kt** +```Kotlin +sealed class WeatherEvent { + object LoadWeather : WeatherEvent() + + data class UpdateTempUnit(val unit: TemperatureUnit) : WeatherEvent() +} +``` + +**WeatherViewModel.kt** +```Kotlin +@HiltViewModel +class WeatherViewModel @Inject constructor( + private val userPreferenceAct: UserPreferenceAct, + private val updateUserPreferenceAct: UpdateUserPreferenceAct, + private val currentTempAct: CurrentTempAct, +) : FRPViewModel() { + override val _state: MutableStateFlow = MutableStateFlow(WeatherState.Loading) + + override suspend fun handleEvent(event: WeatherEvent): suspend () -> WeatherState = + when (event) { + is WeatherEvent.LoadWeather -> loadWeather() + is WeatherEvent.UpdateTempUnit -> updateTempUnit(event) + } + + private suspend fun loadWeather(): suspend () -> WeatherState = suspend { + updateState { WeatherState.Loading } + Unit + } then userPreferenceAct mapSuccess { + it.temperatureUnit //loadTempFor() expects TemperatureUnit + } then ::loadTempFor + + private suspend fun updateTempUnit( + event: WeatherEvent.UpdateTempUnit + ): suspend () -> WeatherState = suspend { + updateState { WeatherState.Loading } + event.unit + } then updateUserPreferenceAct mapSuccess { + event.unit + } then ::loadTempFor + + private suspend fun loadTempFor(tempRes: Res) = + tempRes.lambda() thenIfSuccess currentTempAct thenInvokeAfter { + when (it) { + is Res.Ok -> WeatherState.Success( + temp = it.data.value, + tempUnit = it.data.unit + ) + is Res.Err -> WeatherState.Error(errReason = it.error) + } + } +} +``` + +#### UI + +**WeatherScreen.kt** +```Kotlin +data class WeatherScreen( + val title: String +) : Screen + +@Composable +fun BoxWithConstraintsScope.WeatherScreen(screen: WeatherScreen) { + FRP( + initialEvent = WeatherEvent.LoadWeather + ) { state, onEvent -> + UI(title = screen.title, state = state, onEvent = onEvent) + } +} + +@Composable +private fun UI( + title: String, + state: WeatherState, + + onEvent: (WeatherEvent) -> Unit +) { + //UI goes here..... + //When Celsius is clicked: + // onEvent(WeatherEvent.UpdateTempUnit(TemperatureUnit.CELSIUS)) +} +``` + +Find the full sample **[here](sample/src/main/java/com/ivy/sample/demo/weather)**. ### Key Features @@ -23,7 +242,7 @@ _:construction: WIP :construction:_ - `@Composable FRP(){ UI() }`: functional-reactive UI implementation in Jetpack Compose. - `Res.Ok / Res.Err` result type: monadic result type supporting success and error composition. - - `thenR`: calls the next function only on success (OK) + - `thenIfSuccess`: calls the next function only on success (OK) - `mapSuccess`: maps only the success type if the result is OK - `mapError`: maps only the error type if the result is Err @@ -106,7 +325,7 @@ implementation("com.githubILIYANGERMANOV:ivy-frp:0.0.0") implementation 'com.githubILIYANGERMANOV:ivy-frp:0.0.0' ``` -## Usage +## Docs ### _:construction: WIP... :construction:_ diff --git a/sample/src/main/java/com/ivy/sample/demo/weather/ui/WeatherViewModel.kt b/sample/src/main/java/com/ivy/sample/demo/weather/ui/WeatherViewModel.kt index b7d920b..fd4804c 100644 --- a/sample/src/main/java/com/ivy/sample/demo/weather/ui/WeatherViewModel.kt +++ b/sample/src/main/java/com/ivy/sample/demo/weather/ui/WeatherViewModel.kt @@ -55,5 +55,4 @@ class WeatherViewModel @Inject constructor( is Res.Err -> WeatherState.Error(errReason = it.error) } } - } \ No newline at end of file