├── app ├── .gitignore ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── weather │ │ │ │ ├── data │ │ │ │ ├── base │ │ │ │ │ └── DataModel.kt │ │ │ │ ├── model │ │ │ │ │ ├── Cloud.kt │ │ │ │ │ ├── Coord.kt │ │ │ │ │ ├── Wind.kt │ │ │ │ │ ├── FeelLike.kt │ │ │ │ │ ├── WeatherItem.kt │ │ │ │ │ ├── Sys.kt │ │ │ │ │ ├── Temp.kt │ │ │ │ │ ├── Main.kt │ │ │ │ │ ├── CurrentWeather.kt │ │ │ │ │ ├── Hourly.kt │ │ │ │ │ ├── Current.kt │ │ │ │ │ └── Daily.kt │ │ │ │ ├── local │ │ │ │ │ └── pref │ │ │ │ │ │ ├── PrefsHelper.kt │ │ │ │ │ │ └── AppPrefs.kt │ │ │ │ ├── remote │ │ │ │ │ ├── response │ │ │ │ │ │ ├── ServerErrorResponse.kt │ │ │ │ │ │ └── OneCallResponse.kt │ │ │ │ │ ├── interceptor │ │ │ │ │ │ └── HeaderInterceptor.kt │ │ │ │ │ ├── factory │ │ │ │ │ │ └── FlowCallAdapterFactory.kt │ │ │ │ │ ├── api │ │ │ │ │ │ └── WeatherApi.kt │ │ │ │ │ ├── mapper │ │ │ │ │ │ └── ExceptionMapper.kt │ │ │ │ │ ├── adapter │ │ │ │ │ │ └── FlowCallAdapter.kt │ │ │ │ │ ├── exception │ │ │ │ │ │ └── RetrofitException.kt │ │ │ │ │ └── builder │ │ │ │ │ │ └── RetrofitBuilder.kt │ │ │ │ ├── AddressRepositoryImpl.kt │ │ │ │ ├── Constants.kt │ │ │ │ ├── di │ │ │ │ │ ├── NetworkModule.kt │ │ │ │ │ └── RepositoryModule.kt │ │ │ │ └── WeatherRepositoryImpl.kt │ │ │ │ ├── presentation │ │ │ │ ├── base │ │ │ │ │ ├── ViewDataModel.kt │ │ │ │ │ ├── ModelMapper.kt │ │ │ │ │ ├── ViewState.kt │ │ │ │ │ ├── BaseViewModel.kt │ │ │ │ │ └── ExceptionHandleView.kt │ │ │ │ ├── utils │ │ │ │ │ └── LazyListUtils.kt │ │ │ │ ├── ui │ │ │ │ │ ├── day │ │ │ │ │ │ ├── SevenDaysViewModel.kt │ │ │ │ │ │ └── SevenDaysScreen.kt │ │ │ │ │ ├── theme │ │ │ │ │ │ ├── Shape.kt │ │ │ │ │ │ ├── Color.kt │ │ │ │ │ │ ├── Theme.kt │ │ │ │ │ │ └── Type.kt │ │ │ │ │ ├── custom │ │ │ │ │ │ ├── Loading.kt │ │ │ │ │ │ ├── WeatherSnackbarHost.kt │ │ │ │ │ │ └── BackgroundImage.kt │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── WeatherAppState.kt │ │ │ │ │ ├── WeatherApp.kt │ │ │ │ │ └── home │ │ │ │ │ │ ├── HourlyWeatherItem.kt │ │ │ │ │ │ ├── HomeViewModel.kt │ │ │ │ │ │ └── HomeScreen.kt │ │ │ │ ├── MainApplication.kt │ │ │ │ ├── model │ │ │ │ │ ├── factory │ │ │ │ │ │ ├── CurrentWeatherViewDataModelFactory.kt │ │ │ │ │ │ └── HourlyWeatherViewDataModelFactory.kt │ │ │ │ │ ├── HourlyWeatherViewDataModel.kt │ │ │ │ │ └── CurrentWeatherViewDataModel.kt │ │ │ │ └── di │ │ │ │ │ ├── AppModule.kt │ │ │ │ │ └── CoroutinesModule.kt │ │ │ │ └── domain │ │ │ │ ├── model │ │ │ │ ├── Tag.kt │ │ │ │ ├── Redirect.kt │ │ │ │ └── Dialog.kt │ │ │ │ ├── repository │ │ │ │ ├── AddressRepository.kt │ │ │ │ └── WeatherRepository.kt │ │ │ │ ├── annotation │ │ │ │ ├── Redirect.kt │ │ │ │ ├── TagName.kt │ │ │ │ ├── Action.kt │ │ │ │ └── ExceptionType.kt │ │ │ │ ├── usecase │ │ │ │ ├── UseCase.kt │ │ │ │ ├── address │ │ │ │ │ └── GetLastCityUseCase.kt │ │ │ │ └── weather │ │ │ │ │ ├── GetLastCityUseCase.kt │ │ │ │ │ ├── GetDailyWeatherUseCase.kt │ │ │ │ │ ├── GetCurrentWeatherByCityUseCase.kt │ │ │ │ │ └── GetHourlyWeatherUseCase.kt │ │ │ │ ├── di │ │ │ │ ├── CoroutinesQualifiers.kt │ │ │ │ └── MainThreadHandler.kt │ │ │ │ ├── exception │ │ │ │ └── BaseException.kt │ │ │ │ └── Builders.kt │ │ ├── res │ │ │ ├── font │ │ │ │ ├── montserrat_black.ttf │ │ │ │ ├── montserrat_bold.ttf │ │ │ │ ├── montserrat_light.ttf │ │ │ │ ├── montserrat_thin.ttf │ │ │ │ ├── montserrat_italic.ttf │ │ │ │ ├── montserrat_medium.ttf │ │ │ │ ├── montserrat_regular.ttf │ │ │ │ ├── montserrat_bolditalic.ttf │ │ │ │ ├── montserrat_extrabold.ttf │ │ │ │ ├── montserrat_extralight.ttf │ │ │ │ ├── montserrat_semibold.ttf │ │ │ │ ├── montserrat_thinitalic.ttf │ │ │ │ ├── montserrat_blackitalic.ttf │ │ │ │ ├── montserrat_lightitalic.ttf │ │ │ │ ├── montserrat_mediumitalic.ttf │ │ │ │ ├── montserrat_extrabolditalic.ttf │ │ │ │ ├── montserrat_semibolditalic.ttf │ │ │ │ └── montserrat_extralightitalic.ttf │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── drawable-xxxhdpi │ │ │ │ ├── background.png │ │ │ │ └── background_night.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── values │ │ │ │ ├── arrs.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── themes.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable │ │ │ │ ├── bg_current.xml │ │ │ │ ├── ic_action_bar_search.xml │ │ │ │ ├── ic_menu_drawer.xml │ │ │ │ ├── ic_cloud.xml │ │ │ │ ├── bg_info.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── weather │ │ │ └── Example.kt │ ├── dev │ │ └── res │ │ │ └── values │ │ │ └── config.xml │ └── prod │ │ └── res │ │ └── values │ │ └── config.xml ├── ktlint.gradle ├── proguard-rules.pro └── build.gradle ├── images ├── data-flow.jpg ├── error-flow.jpg └── weather-app.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .editorconfig ├── gradle.properties ├── .gitignore ├── settings.gradle ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── main.yml ├── README.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /images/data-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/images/data-flow.jpg -------------------------------------------------------------------------------- /images/error-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/images/error-flow.jpg -------------------------------------------------------------------------------- /images/weather-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/images/weather-app.png -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/base/DataModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.base 2 | 3 | open class DataModel 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_black.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_thin.ttf -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/base/ViewDataModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.base 2 | 3 | open class ViewDataModel 4 | -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_italic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_bolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_bolditalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_extrabold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_extralight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_extralight.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_semibold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_thinitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_thinitalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/drawable-xxxhdpi/background.png -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_blackitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_blackitalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_lightitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_lightitalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_mediumitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_mediumitalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_extrabolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_extrabolditalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_semibolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_semibolditalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/background_night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/drawable-xxxhdpi/background_night.png -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_extralightitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/font/montserrat_extralightitalic.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachhoan88/MAD-Clean-Architecture/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ktlint_code_style = ktlint_official 3 | ktlint_standard = disabled 4 | 5 | 6 | ij_kotlin_allow_trailing_comma=true 7 | ij_kotlin_allow_trailing_comma_on_call_site=true -------------------------------------------------------------------------------- /app/src/main/res/values/arrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Today 5 | Tomorrow 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/model/Tag.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.model 2 | 3 | import com.example.weather.domain.annotation.TagName 4 | 5 | data class Tag(@TagName val name: String, val message: String?) 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/model/Redirect.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.model 2 | import com.example.weather.domain.annotation.Redirect 3 | 4 | data class Redirect(@Redirect val redirect: Int, val redirectObject: Any? = null) 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/repository/AddressRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.repository 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface AddressRepository { 6 | fun getLastCityName(): Flow 7 | } 8 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | kotlin.code.style=official 5 | android.defaults.buildfeatures.buildconfig=true 6 | android.nonTransitiveRClass=false 7 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /app/src/test/java/com/example/weather/Example.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather 2 | 3 | class Example { 4 | fun test() { 5 | val user = User(1, "Example") 6 | val otherUser = user.copy(id = 2) 7 | } 8 | } 9 | 10 | data class User(val id: Int, val name: String) 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 17 10:41:22 ICT 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Cloud.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Cloud( 7 | @SerializedName("all") val all: Int 8 | ) : DataModel() 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/utils/LazyListUtils.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.utils 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | 5 | val LazyListState.isScrolled: Boolean 6 | get() = firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 7 | -------------------------------------------------------------------------------- /app/src/dev/res/values/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://api.openweathermap.org/data/2.5/ 4 | RANDOM_UUID 5 | WEATHER_APP_ID 6 | -------------------------------------------------------------------------------- /app/src/prod/res/values/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://api.openweathermap.org/data/2.5/ 4 | RANDOM_UUID 5 | WEATHER_APP_ID 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/local/pref/PrefsHelper.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.local.pref 2 | 3 | interface PrefsHelper { 4 | fun isFirstRun(): Boolean 5 | 6 | fun setFirstRun(enable: Boolean = false) 7 | 8 | fun saveLastCity(cityName: String) 9 | 10 | fun getLastCity(): String 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/response/ServerErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class ServerErrorResponse( 6 | @SerializedName("cod") val code: Int, 7 | @SerializedName("message") val message: String? 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/day/SevenDaysViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.day 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.hilt.android.lifecycle.HiltViewModel 5 | import javax.inject.Inject 6 | 7 | @HiltViewModel 8 | class SevenDaysViewModel @Inject constructor() : ViewModel() 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Coord.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Coord( 7 | @SerializedName("lat") val lat: Double, 8 | @SerializedName("lon") val long: Double 9 | ) : DataModel() 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | buildSrc/build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | key.properties 18 | keystore 19 | app/dev/* 20 | app/prod/* 21 | .idea/* -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/annotation/Redirect.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.annotation 2 | 3 | import androidx.annotation.IntDef 4 | import com.example.weather.domain.annotation.Redirect.Companion.OPEN_HOME_SCREEN 5 | 6 | @IntDef(OPEN_HOME_SCREEN) 7 | annotation class Redirect { 8 | companion object { 9 | const val OPEN_HOME_SCREEN = 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Wind.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Wind( 7 | @SerializedName("speed") val speed: Double, 8 | @SerializedName("deg") val deg: Double, 9 | @SerializedName("gust") val gust: Double 10 | ) : DataModel() 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/base/ModelMapper.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.base 2 | 3 | import com.example.weather.data.base.DataModel 4 | 5 | interface ModelMapper { 6 | fun mapperToViewDataModel(dataModel: R): T 7 | 8 | fun mapperToDataModel(viewDataModel: ViewDataModel): DataModel { 9 | TODO("maybe not implement") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_current.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/annotation/TagName.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.annotation 2 | 3 | import androidx.annotation.StringDef 4 | import com.example.weather.domain.annotation.TagName.Companion.PASSWORD_INCORRECT_TAG 5 | 6 | @StringDef(PASSWORD_INCORRECT_TAG) 7 | annotation class TagName { 8 | companion object { 9 | const val PASSWORD_INCORRECT_TAG = "password_incorrect_tag" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val WeatherShapes = Shapes( 8 | small = RoundedCornerShape(8.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/FeelLike.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class FeelLike( 7 | @SerializedName("day") val day: Double, 8 | @SerializedName("night") val night: Double, 9 | @SerializedName("eve") val eve: Double, 10 | @SerializedName("morn") val morn: Double 11 | ) : DataModel() 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_action_bar_search.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/WeatherItem.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class WeatherItem( 7 | @SerializedName("id") val id: Int, 8 | @SerializedName("main") val main: String, 9 | @SerializedName("description") val description: String?, 10 | @SerializedName("icon") val icon: String? 11 | ) : DataModel() 12 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | jcenter() // Warning: this repository is going to shut down soon 15 | } 16 | } 17 | rootProject.name = "Weather" 18 | include ':app' 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Sys.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Sys( 7 | @SerializedName("type") val type: Int, 8 | @SerializedName("id") val id: Int, 9 | @SerializedName("country") val country: String, 10 | @SerializedName("sunrise") val sunrise: Long, 11 | @SerializedName("sunset") val sunset: Long 12 | ) : DataModel() 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/annotation/Action.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.annotation 2 | 3 | import androidx.annotation.IntDef 4 | import com.example.weather.domain.annotation.Action.Companion.CLOSE_SESSION 5 | import com.example.weather.domain.annotation.Action.Companion.RELOAD_PAGE 6 | 7 | @IntDef(RELOAD_PAGE, CLOSE_SESSION) 8 | annotation class Action { 9 | companion object { 10 | const val RELOAD_PAGE = 1 11 | const val CLOSE_SESSION = 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation 2 | 3 | import android.app.Application 4 | import com.example.weather.BuildConfig 5 | import dagger.hilt.android.HiltAndroidApp 6 | import timber.log.Timber 7 | 8 | @HiltAndroidApp 9 | class MainApplication : Application() { 10 | 11 | override fun onCreate() { 12 | super.onCreate() 13 | if (BuildConfig.DEBUG) { 14 | Timber.plant(Timber.DebugTree()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/model/Dialog.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.model 2 | import com.example.weather.domain.annotation.Action 3 | 4 | data class Dialog( 5 | val title: String? = null, 6 | val message: String? = null, 7 | val positiveMessage: String? = null, 8 | @Action val positiveAction: Int? = null, 9 | val positiveObject: Any? = null, 10 | val negativeMessage: String? = null, 11 | @Action val negativeAction: Int? = null, 12 | val negativeObject: Any? = null 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Temp.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Temp( 7 | @SerializedName("day") val dt: Double, 8 | @SerializedName("min") val min: Double, 9 | @SerializedName("max") val max: Double, 10 | @SerializedName("night") val night: Double, 11 | @SerializedName("eve") val eve: Double, 12 | @SerializedName("morn") val morn: Double 13 | ) : DataModel() 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/base/ViewState.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.base 2 | 3 | import com.example.weather.domain.exception.BaseException 4 | 5 | open class ViewState( 6 | open val isLoading: Boolean = false, 7 | open val exception: BaseException? = null 8 | ) 9 | 10 | fun Throwable.toBaseException(): BaseException { 11 | return when (this) { 12 | is BaseException -> this 13 | else -> BaseException.AlertException(code = -1, message = this.message ?: "") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.base 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.example.weather.domain.usecase.UseCase 5 | import kotlinx.coroutines.flow.StateFlow 6 | 7 | abstract class BaseViewModel( 8 | private vararg val useCases: UseCase<*, *>? 9 | ) : ViewModel() { 10 | 11 | abstract val state: StateFlow 12 | 13 | override fun onCleared() { 14 | useCases.forEach { it?.onCleared() } 15 | super.onCleared() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/repository/WeatherRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.repository 2 | 3 | import com.example.weather.data.model.CurrentWeather 4 | import com.example.weather.data.model.Daily 5 | import com.example.weather.data.model.Hourly 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface WeatherRepository { 9 | fun getCurrentWeather(city: String): Flow 10 | 11 | fun getHourlyWeather(lat: Double, lon: Double): Flow> 12 | 13 | fun getDailyWeather(lat: Double, lon: Double): Flow> 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/AddressRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data 2 | 3 | import com.example.weather.data.local.pref.PrefsHelper 4 | import com.example.weather.domain.asFlow 5 | import com.example.weather.domain.repository.AddressRepository 6 | import kotlinx.coroutines.flow.Flow 7 | import javax.inject.Inject 8 | 9 | class AddressRepositoryImpl @Inject constructor( 10 | private val prefsHelper: PrefsHelper 11 | ) : AddressRepository { 12 | 13 | override fun getLastCityName(): Flow { 14 | return prefsHelper.getLastCity().asFlow() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/model/factory/CurrentWeatherViewDataModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.model.factory 2 | 3 | import com.example.weather.presentation.model.CurrentWeatherViewDataModel 4 | 5 | fun createCurrentWeather() = CurrentWeatherViewDataModel( 6 | currentTime = "Sunday, 28 November", 7 | city = "HANOI", 8 | country = "VIETNAM", 9 | currentTemp = "24", 10 | humidity = "39%", 11 | wind = "6 km/h", 12 | visibility = "10 km", 13 | realFeel = "23º", 14 | currentIcon = "https://openweathermap.org/img/wn/04d@4x.png" 15 | ) 16 | -------------------------------------------------------------------------------- /app/ktlint.gradle: -------------------------------------------------------------------------------- 1 | configurations { 2 | ktlint 3 | } 4 | 5 | dependencies { 6 | ktlint "com.pinterest:ktlint:0.50.0" 7 | } 8 | 9 | task ktlint(type: JavaExec, group: "verification") { 10 | description = "Check Kotlin code style." 11 | classpath = configurations.ktlint 12 | main = "com.pinterest.ktlint.Main" 13 | args "src/**/*.kt" 14 | } 15 | check.dependsOn ktlint 16 | 17 | task ktlintFormat(type: JavaExec, group: "formatting") { 18 | description = "Fix Kotlin code style deviations." 19 | classpath = configurations.ktlint 20 | main = "com.pinterest.ktlint.Main" 21 | args "-F", "src/**/*.kt" 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/UseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.flowOn 6 | 7 | abstract class UseCase(private val coroutineDispatcher: CoroutineDispatcher) { 8 | operator fun invoke(parameters: P? = null): Flow = execute(parameters) 9 | .flowOn(coroutineDispatcher) 10 | 11 | protected abstract fun execute(params: P? = null): Flow 12 | 13 | // Clear anything when call different viewModelScope 14 | fun onCleared() {} 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/di/CoroutinesQualifiers.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.BINARY) 6 | @Qualifier 7 | annotation class DefaultDispatcher 8 | 9 | @Retention(AnnotationRetention.BINARY) 10 | @Qualifier 11 | annotation class IoDispatcher 12 | 13 | @Retention(AnnotationRetention.BINARY) 14 | @Qualifier 15 | annotation class MainDispatcher 16 | 17 | @Retention(AnnotationRetention.BINARY) 18 | @Qualifier 19 | annotation class MainImmediateDispatcher 20 | 21 | @Retention(AnnotationRetention.BINARY) 22 | @Qualifier 23 | annotation class ApplicationScope 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) 9 | val Background = Color(0xFF3E4067) 10 | val Red200 = Color(0xfff297a2) 11 | val Red300 = Color(0xffea6d7e) 12 | val Red700 = Color(0xffdd0d3c) 13 | val Red800 = Color(0xffd00036) 14 | val Red900 = Color(0xffc20029) 15 | val White60 = Color(0x66FFFFFF) 16 | 17 | // #3E4067 18 | val LightColor = Color(0xFF3E4067) 19 | val DarkColor = Color(0xFFFFFFFF) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Main.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Main( 7 | @SerializedName("temp") val temp: Double, 8 | @SerializedName("feels_like") val feelsLike: Double, 9 | @SerializedName("temp_min") val tempMin: Double, 10 | @SerializedName("temp_max") val tempMax: Double, 11 | @SerializedName("pressure") val pressure: Double, 12 | @SerializedName("humidity") val humidity: Int, 13 | @SerializedName("sea_level") val seaLevel: Double, 14 | @SerializedName("grnd_level") val grndLevel: Double 15 | ) : DataModel() 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/model/factory/HourlyWeatherViewDataModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.model.factory 2 | 3 | import com.example.weather.presentation.model.HourlyWeatherViewDataModel 4 | 5 | fun createHourlyWeather() = HourlyWeatherViewDataModel( 6 | dt = 1L, 7 | hour = "09:00", 8 | temp = "16", 9 | humidity = "43%", 10 | wind = "2 km/h", 11 | visibility = "10 Km", 12 | realFeel = "15º", 13 | icon = "https://openweathermap.org/img/wn/04d@4x.png" 14 | ) 15 | 16 | fun createHourlyWeathers() = (10..30).map { hourly -> 17 | val currentHour = if (hourly > 23) hourly - 23 else hourly 18 | createHourlyWeather().copy(hour = String.format("%02d:00", currentHour)) 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/response/OneCallResponse.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.response 2 | 3 | import com.example.weather.data.model.Current 4 | import com.example.weather.data.model.Daily 5 | import com.example.weather.data.model.Hourly 6 | import com.google.gson.annotations.SerializedName 7 | 8 | data class OneCallResponse( 9 | @SerializedName("lat") val lat: Double, 10 | @SerializedName("lon") val lon: Double, 11 | @SerializedName("timezone") val timezone: String, 12 | @SerializedName("timezone_offset") val timezoneOffset: Int, 13 | @SerializedName("current") val current: Current, 14 | @SerializedName("hourly") val hourly: List?, 15 | @SerializedName("daily") val daily: List? 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/address/GetLastCityUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase.address 2 | 3 | import com.example.weather.domain.di.IoDispatcher 4 | import com.example.weather.domain.repository.AddressRepository 5 | import com.example.weather.domain.usecase.UseCase 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | class GetLastCityUseCase @Inject constructor( 11 | private val addressRepository: AddressRepository, 12 | @IoDispatcher private val dispatcher: CoroutineDispatcher 13 | ) : UseCase(dispatcher) { 14 | 15 | override fun execute(params: Void?): Flow { 16 | return addressRepository.getLastCityName() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/weather/GetLastCityUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase.weather 2 | 3 | import com.example.weather.domain.di.IoDispatcher 4 | import com.example.weather.domain.repository.AddressRepository 5 | import com.example.weather.domain.usecase.UseCase 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | class GetLastCityUseCase @Inject constructor( 11 | private val addressRepository: AddressRepository, 12 | @IoDispatcher private val dispatcher: CoroutineDispatcher 13 | ) : UseCase(dispatcher) { 14 | 15 | override fun execute(params: Void?): Flow { 16 | return addressRepository.getLastCityName() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/custom/Loading.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.custom 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.wrapContentSize 6 | import androidx.compose.material.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | 11 | @Composable 12 | fun FullScreenLoading(modifier: Modifier = Modifier) { 13 | Box( 14 | modifier = modifier 15 | .then( 16 | Modifier 17 | .fillMaxSize() 18 | .wrapContentSize(Alignment.Center) 19 | ) 20 | ) { 21 | CircularProgressIndicator() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data 2 | 3 | object Constants { 4 | object HttpClient { 5 | const val CONNECT_TIMEOUT = 10L 6 | const val READ_TIMEOUT = 10L 7 | const val WRITE_TIMEOUT = 10L 8 | const val CONNECTION_TIME_OUT_MLS = CONNECT_TIMEOUT * 1000L 9 | } 10 | 11 | object Authentication { 12 | const val MAX_RETRY = 1 13 | } 14 | 15 | object DateFormat { 16 | const val EEEE_dd_MMMM = "EEEE',' dd MMMM" 17 | const val DEFAULT_FORMAT = "dd-mm-yyyy" 18 | const val HH_mm = "HH:mm" 19 | } 20 | 21 | object OpenWeather { 22 | const val WEATHER_ICON_URL = "https://openweathermap.org/img/wn/%s@4x.png" 23 | const val WEATHER_SMALL_ICON_URL = "https://openweathermap.org/img/wn/%s.png" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_drawer.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.di 2 | 3 | import com.example.weather.domain.di.ApplicationScope 4 | import com.example.weather.domain.di.DefaultDispatcher 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.SupervisorJob 12 | import javax.inject.Singleton 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | class AppModule { 17 | 18 | @ApplicationScope 19 | @Singleton 20 | @Provides 21 | fun providesApplicationScope( 22 | @DefaultDispatcher defaultDispatcher: CoroutineDispatcher 23 | ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/CurrentWeather.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class CurrentWeather( 7 | @SerializedName("id") val id: Int, 8 | @SerializedName("name") val name: String, 9 | @SerializedName("cod") val cod: Int, 10 | @SerializedName("coord") val coord: Coord, 11 | @SerializedName("weather") val weatherItems: List, 12 | @SerializedName("base") val base: String, 13 | @SerializedName("main") val main: Main, 14 | @SerializedName("visibility") val visibility: Int, 15 | @SerializedName("wind") val wind: Wind, 16 | @SerializedName("clouds") val clouds: Cloud, 17 | @SerializedName("dt") val dt: Long, 18 | @SerializedName("sys") val sys: Sys, 19 | @SerializedName("timezone") val timezone: Int 20 | ) : DataModel() 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.di 2 | 3 | import com.example.weather.data.remote.api.WeatherApi 4 | import com.example.weather.data.remote.builder.RetrofitBuilder 5 | import com.example.weather.data.remote.interceptor.HeaderInterceptor 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import retrofit2.Retrofit 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | class NetworkModule { 16 | 17 | @Provides 18 | @Singleton 19 | fun provideRetrofit(retrofitBuilder: RetrofitBuilder, headerInterceptor: HeaderInterceptor): Retrofit = retrofitBuilder 20 | .addInterceptors(headerInterceptor) 21 | .build() 22 | 23 | @Provides 24 | @Singleton 25 | fun provideWeatherApi(retrofit: Retrofit): WeatherApi = retrofit.create(WeatherApi::class.java) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Hourly.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Hourly( 7 | @SerializedName("dt") val dt: Long, 8 | @SerializedName("temp") val temp: Double, 9 | @SerializedName("feels_like") val feelsLike: Double, 10 | @SerializedName("pressure") val pressure: Double, 11 | @SerializedName("humidity") val humidity: Int, 12 | @SerializedName("dew_point") val dewPoint: Double, 13 | @SerializedName("uvi") val uvi: Double, 14 | @SerializedName("clouds") val clouds: Double, 15 | @SerializedName("visibility") val visibility: Int, 16 | @SerializedName("wind_speed") val windSpeed: Double, 17 | @SerializedName("wind_deg") val windDeg: Int, 18 | @SerializedName("wind_gust") val windGust: Double, 19 | @SerializedName("weather") val weather: List, 20 | @SerializedName("pop") val pop: Double 21 | ) : DataModel() 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/interceptor/HeaderInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.interceptor 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import com.example.weather.BuildConfig 6 | import dagger.hilt.android.qualifiers.ApplicationContext 7 | import okhttp3.Interceptor 8 | import okhttp3.Response 9 | import javax.inject.Inject 10 | 11 | class HeaderInterceptor @Inject constructor( 12 | @ApplicationContext private val context: Context 13 | ) : Interceptor { 14 | 15 | override fun intercept(chain: Interceptor.Chain): Response { 16 | var request = chain.request() 17 | 18 | request = request.newBuilder() 19 | .addHeader("Content-Type", "application/json") 20 | .addHeader("Accept", "application/json") 21 | .addHeader("OS", "Android-${Build.VERSION.SDK_INT}") 22 | .addHeader("Version", BuildConfig.VERSION_NAME) 23 | .build() 24 | return chain.proceed(request) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/di/MainThreadHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.weather.domain.di 18 | 19 | import javax.inject.Qualifier 20 | 21 | @Qualifier 22 | @Target( 23 | AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, 24 | AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.FIELD, 25 | AnnotationTarget.VALUE_PARAMETER 26 | ) 27 | annotation class MainThreadHandler 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Current.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Current( 7 | @SerializedName("dt") val dt: Long, 8 | @SerializedName("sunrise") val sunrise: Long, 9 | @SerializedName("sunset") val sunset: Long, 10 | @SerializedName("temp") val temp: Double, 11 | @SerializedName("feels_like") val feelsLike: Double, 12 | @SerializedName("pressure") val pressure: Int, 13 | @SerializedName("humidity") val humidity: Int, 14 | @SerializedName("dew_point") val dewPoint: Double, 15 | @SerializedName("wind_speed") val windSpeed: Double, 16 | @SerializedName("wind_deg") val windDeg: Double, 17 | @SerializedName("wind_gust") val windGust: Double, 18 | @SerializedName("weather") val weather: List, 19 | @SerializedName("clouds") val clouds: Int, 20 | @SerializedName("pop") val pop: Int, 21 | @SerializedName("visibility") val visibility: Int, 22 | @SerializedName("uvi") val uvi: Double 23 | ) : DataModel() 24 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Weather Forecast 3 | Error Unknown 4 | No internet connection 5 | Error code: %d 6 | Can not connect to `%s`, please check it 7 | º 8 | % 9 | km/h 10 | km 11 | Menu 12 | Search city 13 | Humidity 14 | Visibility 15 | Wind 16 | RealFeel 17 | City name input invalid 18 | Lat, long invalid 19 | 0 20 | Close 21 | Remove 22 | Next 7 days 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/di/CoroutinesModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.di 2 | 3 | import com.example.weather.domain.di.DefaultDispatcher 4 | import com.example.weather.domain.di.IoDispatcher 5 | import com.example.weather.domain.di.MainDispatcher 6 | import com.example.weather.domain.di.MainImmediateDispatcher 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import kotlinx.coroutines.CoroutineDispatcher 12 | import kotlinx.coroutines.Dispatchers 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object CoroutinesModule { 17 | 18 | @DefaultDispatcher 19 | @Provides 20 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 21 | 22 | @IoDispatcher 23 | @Provides 24 | fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO 25 | 26 | @MainDispatcher 27 | @Provides 28 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main 29 | 30 | @MainImmediateDispatcher 31 | @Provides 32 | fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.di 2 | 3 | import com.example.weather.data.AddressRepositoryImpl 4 | import com.example.weather.data.WeatherRepositoryImpl 5 | import com.example.weather.data.local.pref.AppPrefs 6 | import com.example.weather.data.local.pref.PrefsHelper 7 | import com.example.weather.domain.repository.AddressRepository 8 | import com.example.weather.domain.repository.WeatherRepository 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.components.SingletonComponent 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | class RepositoryModule { 18 | 19 | @Provides 20 | @Singleton 21 | fun providePrefHelper(appPrefs: AppPrefs): PrefsHelper { 22 | return appPrefs 23 | } 24 | 25 | @Provides 26 | @Singleton 27 | fun providerWeatherRepository(weatherRepositoryImpl: WeatherRepositoryImpl): WeatherRepository = weatherRepositoryImpl 28 | 29 | @Provides 30 | @Singleton 31 | fun providerAddressRepository(addressRepositoryImpl: AddressRepositoryImpl): AddressRepository = addressRepositoryImpl 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/model/Daily.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.model 2 | 3 | import com.example.weather.data.base.DataModel 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Daily( 7 | @SerializedName("dt") val dt: Long, 8 | @SerializedName("sunrise") val sunrise: Long, 9 | @SerializedName("sunset") val sunset: Long, 10 | @SerializedName("moonrise") val moonrise: Long, 11 | @SerializedName("moonset") val moonset: Long, 12 | @SerializedName("moon_phase") val moonPhase: Double, 13 | @SerializedName("temp") val temp: Temp, 14 | @SerializedName("feels_like") val feelsLike: FeelLike, 15 | @SerializedName("pressure") val pressure: Int, 16 | @SerializedName("humidity") val humidity: Int, 17 | @SerializedName("dew_point") val dewPoint: Double, 18 | @SerializedName("wind_speed") val windSpeed: Double, 19 | @SerializedName("wind_deg") val windDeg: Double, 20 | @SerializedName("wind_gust") val windGust: Double, 21 | @SerializedName("weather") val weather: List, 22 | @SerializedName("clouds") val clouds: Int, 23 | @SerializedName("pop") val pop: Int, 24 | @SerializedName("uvi") val uvi: Double 25 | ) : DataModel() 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val LightThemeColors = lightColors( 11 | primary = LightColor, 12 | primaryVariant = Purple700, 13 | onPrimary = LightColor, 14 | secondary = Teal200, 15 | error = Red800, 16 | onSecondary = Color.Black 17 | ) 18 | 19 | private val DarkThemeColors = darkColors( 20 | primary = DarkColor, 21 | primaryVariant = Purple700, 22 | onPrimary = DarkColor, 23 | secondary = Red300, 24 | error = Red200, 25 | onSecondary = Color.White 26 | ) 27 | 28 | @Composable 29 | fun WeatherTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { 30 | MaterialTheme( 31 | colors = if (darkTheme) DarkThemeColors else LightThemeColors, 32 | typography = WeatherTypography, 33 | shapes = WeatherShapes, 34 | content = content 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/custom/WeatherSnackbarHost.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.custom 2 | 3 | import androidx.compose.foundation.layout.widthIn 4 | import androidx.compose.foundation.layout.wrapContentWidth 5 | import androidx.compose.material.Snackbar 6 | import androidx.compose.material.SnackbarData 7 | import androidx.compose.material.SnackbarHost 8 | import androidx.compose.material.SnackbarHostState 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import com.google.accompanist.insets.systemBarsPadding 14 | 15 | /** 16 | * [SnackbarHost] that is configured for insets and large screens 17 | */ 18 | @Composable 19 | fun WeatherSnackbarHost( 20 | hostState: SnackbarHostState, 21 | modifier: Modifier = Modifier, 22 | snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) } 23 | ) { 24 | SnackbarHost( 25 | hostState = hostState, 26 | modifier = modifier 27 | .systemBarsPadding() 28 | // Limit the Snackbar width for large screens 29 | .wrapContentWidth(align = Alignment.Start) 30 | .widthIn(max = 550.dp), 31 | snackbar = snackbar 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/local/pref/AppPrefs.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.local.pref 2 | 3 | import android.content.Context 4 | import androidx.core.content.edit 5 | import dagger.hilt.android.qualifiers.ApplicationContext 6 | import javax.inject.Inject 7 | 8 | class AppPrefs @Inject constructor( 9 | @ApplicationContext private val context: Context 10 | ) : PrefsHelper { 11 | 12 | private val sharedPreferences by lazy { 13 | context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) 14 | } 15 | 16 | override fun isFirstRun(): Boolean { 17 | return sharedPreferences.getBoolean(FIRST_RUN_TAG, true) 18 | } 19 | 20 | override fun setFirstRun(enable: Boolean) { 21 | sharedPreferences.edit { putBoolean(FIRST_RUN_TAG, enable) } 22 | } 23 | 24 | override fun saveLastCity(cityName: String) { 25 | sharedPreferences.edit { putString(LAST_CITY_TAG, cityName) } 26 | } 27 | 28 | override fun getLastCity(): String { 29 | return sharedPreferences.getString(LAST_CITY_TAG, null) ?: DEFAULT_CITY_NAME 30 | } 31 | 32 | companion object { 33 | private const val FIRST_RUN_TAG = "first_run" 34 | private const val LAST_CITY_TAG = "country" 35 | private const val DEFAULT_CITY_NAME = "Hanoi" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | *Replace this paragraph with a description of what this PR is doing. If you're modifying existing behavior, describe the existing behavior, how this PR is changing it, and what motivated the change. If you're changing visual properties, consider including before/after screenshots (and runnable code snippets to reproduce them).* 4 | 5 | ## Related Tickets/Issues 6 | *Replace this paragraph with a list of issues related to this PR from our. Indicate, which of these issues are resolved or fixed by this PR.* 7 | 8 | ## Evidence (Screenshot or Video) 9 | 10 | ## Tests 11 | 12 | I added the following tests: 13 | 14 | *Replace this with a list of the tests that you added as part of this PR. A change in behaviour with no test covering it 15 | will likely get reverted accidentally sooner or later. PRs must include tests for all changed/updated/fixed behaviors. See [Test Coverage].* 16 | 17 | ## Checklist 18 | 19 | Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes (`[x]`). This will ensure a smooth and quick review process. 20 | 21 | - [ ] I had build successful before create PR 22 | - [ ] I updated `status` my task 23 | - [ ] All existing and new tests are passing. 24 | - [ ] I am willing to follow-up on review comments in a timely manner. 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/factory/FlowCallAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.factory 2 | 3 | import com.example.weather.data.remote.adapter.FlowCallAdapter 4 | import com.example.weather.data.remote.mapper.ExceptionMapper 5 | import kotlinx.coroutines.flow.Flow 6 | import retrofit2.CallAdapter 7 | import retrofit2.Retrofit 8 | import java.lang.reflect.ParameterizedType 9 | import java.lang.reflect.Type 10 | 11 | class FlowCallAdapterFactory constructor( 12 | private val exceptionMapper: ExceptionMapper 13 | ) : CallAdapter.Factory() { 14 | override fun get( 15 | returnType: Type, 16 | annotations: Array, 17 | retrofit: Retrofit 18 | ): CallAdapter<*, *>? { 19 | // Check api service not use the Flow class 20 | if (getRawType(returnType) != Flow::class.java) { 21 | return null 22 | } 23 | 24 | check(returnType is ParameterizedType) { "Flow return type must be parameterized as Flow or Flow" } 25 | val responseType = getParameterUpperBound(0, returnType) 26 | 27 | return FlowCallAdapter(retrofit, exceptionMapper, responseType) 28 | } 29 | 30 | companion object { 31 | @JvmStatic 32 | fun create(exceptionMapper: ExceptionMapper) = FlowCallAdapterFactory(exceptionMapper) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/api/WeatherApi.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.api 2 | 3 | import com.example.weather.data.model.CurrentWeather 4 | import com.example.weather.data.remote.response.OneCallResponse 5 | import kotlinx.coroutines.flow.Flow 6 | import retrofit2.http.GET 7 | import retrofit2.http.Query 8 | 9 | interface WeatherApi { 10 | 11 | @GET("weather") 12 | fun getCurrentWeather( 13 | @Query("q") city: String, 14 | @Query("units") units: String = "metric", 15 | @Query("lang") lang: String = "en", 16 | @Query("appid") appId: String 17 | ): Flow 18 | 19 | @GET("onecall") 20 | fun getHourlyWeather( 21 | @Query("lat") lat: Double, 22 | @Query("lon") long: Double, 23 | @Query("lang") lang: String = "en", 24 | @Query("units") units: String = "metric", 25 | @Query("exclude") exclude: String = "minutely,daily", 26 | @Query("appid") appId: String 27 | ): Flow 28 | 29 | @GET("onecall") 30 | fun getDailyWeather( 31 | @Query("lat") lat: Double, 32 | @Query("lon") long: Double, 33 | @Query("lang") lang: String = "en", 34 | @Query("units") units: String = "metric", 35 | @Query("exclude") exclude: String = "minutely,hourly", 36 | @Query("appid") appId: String 37 | ): Flow 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/weather/GetDailyWeatherUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase.weather 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.model.Daily 6 | import com.example.weather.domain.asFlow 7 | import com.example.weather.domain.di.IoDispatcher 8 | import com.example.weather.domain.exception.BaseException 9 | import com.example.weather.domain.repository.WeatherRepository 10 | import com.example.weather.domain.usecase.UseCase 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.flow.Flow 14 | import javax.inject.Inject 15 | 16 | class GetDailyWeatherUseCase @Inject constructor( 17 | private val weatherRepository: WeatherRepository, 18 | @IoDispatcher private val dispatcher: CoroutineDispatcher, 19 | @ApplicationContext private val context: Context 20 | ) : UseCase>(dispatcher) { 21 | 22 | override fun execute(params: Params?): Flow> { 23 | if (params != null) { 24 | return weatherRepository.getDailyWeather(params.lat, params.long) 25 | } 26 | 27 | return BaseException.AlertException(-1, context.getString(R.string.lat_lon_invalid)).asFlow() 28 | } 29 | 30 | data class Params(val lat: Double, val long: Double) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/weather/GetCurrentWeatherByCityUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase.weather 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.model.CurrentWeather 6 | import com.example.weather.domain.asFlow 7 | import com.example.weather.domain.di.IoDispatcher 8 | import com.example.weather.domain.exception.BaseException 9 | import com.example.weather.domain.repository.WeatherRepository 10 | import com.example.weather.domain.usecase.UseCase 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.flow.Flow 14 | import javax.inject.Inject 15 | 16 | class GetCurrentWeatherByCityUseCase @Inject constructor( 17 | private val weatherRepository: WeatherRepository, 18 | @IoDispatcher private val dispatcher: CoroutineDispatcher, 19 | @ApplicationContext private val context: Context 20 | ) : UseCase(dispatcher) { 21 | 22 | override fun execute(params: Params?): Flow { 23 | if (params?.city?.isNotEmpty() == true) { 24 | return weatherRepository.getCurrentWeather(params.city) 25 | } 26 | 27 | return BaseException.AlertException(-1, context.getString(R.string.city_input_invalid)).asFlow() 28 | } 29 | 30 | data class Params(val city: String) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/custom/BackgroundImage.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.custom 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.wrapContentSize 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.painter.Painter 12 | import androidx.compose.ui.layout.ContentScale 13 | 14 | @Composable 15 | fun BackgroundImage( 16 | modifier: Modifier = Modifier, 17 | painter: Painter, 18 | contentScale: ContentScale = ContentScale.FillWidth, 19 | description: String? = null, 20 | alignment: Alignment = Alignment.Center, 21 | alpha: Float = 1f, 22 | content: @Composable () -> Unit 23 | ) { 24 | Box( 25 | modifier = modifier 26 | .wrapContentSize(align = Alignment.BottomCenter) 27 | .background(color = MaterialTheme.colors.background) 28 | ) { 29 | Image( 30 | painter = painter, 31 | contentDescription = description, 32 | contentScale = contentScale, 33 | modifier = Modifier.matchParentSize(), 34 | alignment = alignment, 35 | alpha = alpha 36 | ) 37 | content() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.runtime.SideEffect 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.core.view.WindowCompat 10 | import com.example.weather.presentation.ui.theme.WeatherTheme 11 | import com.google.accompanist.insets.ProvideWindowInsets 12 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 13 | import dagger.hilt.android.AndroidEntryPoint 14 | 15 | @AndroidEntryPoint 16 | class MainActivity : ComponentActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | // This app draws behind the system bars, so we want to handle fitting system windows 20 | WindowCompat.setDecorFitsSystemWindows(window, false) 21 | 22 | setContent { 23 | WeatherTheme { 24 | ProvideWindowInsets { 25 | val systemUiController = rememberSystemUiController() 26 | val darkIcons = MaterialTheme.colors.isLight 27 | SideEffect { 28 | systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = darkIcons) 29 | } 30 | 31 | WeatherApp() 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/WeatherAppState.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.runtime.saveable.rememberSaveable 7 | import androidx.navigation.NavHostController 8 | import androidx.navigation.compose.rememberNavController 9 | 10 | sealed class Screen(val route: String) { 11 | object Home : Screen("home") 12 | object NextSevenDays : Screen("sevenDays/{lat}-{long}") { 13 | fun createRoute(lat: Float, long: Float) = "sevenDays/$lat-$long" 14 | } 15 | } 16 | 17 | @Composable 18 | fun rememberWeatherAppState( 19 | controller: NavHostController = rememberNavController() 20 | ) = remember(controller) { 21 | WeatherAppState(controller) 22 | } 23 | 24 | @Composable 25 | fun rememberLazyListState( 26 | vararg inputs: Any?, 27 | initialFirstVisibleItemIndex: Int = 0, 28 | initialFirstVisibleItemScrollOffset: Int = 0 29 | ): LazyListState { 30 | return rememberSaveable(inputs, saver = LazyListState.Saver) { 31 | LazyListState( 32 | initialFirstVisibleItemIndex, 33 | initialFirstVisibleItemScrollOffset 34 | ) 35 | } 36 | } 37 | 38 | class WeatherAppState(val controller: NavHostController) { 39 | fun navigateToSevenDays(lat: Float, long: Float) { 40 | controller.navigate(Screen.NextSevenDays.createRoute(lat, long)) 41 | } 42 | 43 | fun navigateBack() { 44 | controller.popBackStack() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/annotation/ExceptionType.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.annotation 2 | 3 | import androidx.annotation.IntDef 4 | import com.example.weather.domain.annotation.ExceptionType.Companion.ALERT 5 | import com.example.weather.domain.annotation.ExceptionType.Companion.DIALOG 6 | import com.example.weather.domain.annotation.ExceptionType.Companion.INLINE 7 | import com.example.weather.domain.annotation.ExceptionType.Companion.ON_PAGE 8 | import com.example.weather.domain.annotation.ExceptionType.Companion.REDIRECT 9 | import com.example.weather.domain.annotation.ExceptionType.Companion.SNACK 10 | import com.example.weather.domain.annotation.ExceptionType.Companion.TOAST 11 | 12 | /** 13 | * Clear exception from Throwable 14 | * @param SNACK is type of show message via Snack bar 15 | * @param TOAST is type of show message via Toast 16 | * @param INLINE is type of show or hide view warning, example: password in correct hint of password field 17 | * @param ALERT is type of show message type Alert Dialog, but only message & button `OK` 18 | * @param DIALOG is type of show Alert Dialog, with multiple attributes: title, message, positive, negative & action 19 | * @param REDIRECT is type of auto-redirect with view, action or finished, ... 20 | * @param ON_PAGE is type of show message on center screen, maybe show retry button 21 | */ 22 | @IntDef(SNACK, TOAST, INLINE, ALERT, DIALOG, REDIRECT, ON_PAGE) 23 | annotation class ExceptionType { 24 | companion object { 25 | const val SNACK = 1 26 | const val TOAST = 2 27 | const val INLINE = 3 28 | const val ALERT = 4 29 | const val DIALOG = 5 30 | const val REDIRECT = 6 31 | const val ON_PAGE = 7 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/exception/BaseException.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.exception 2 | 3 | import com.example.weather.domain.annotation.ExceptionType 4 | import com.example.weather.domain.model.Dialog 5 | import com.example.weather.domain.model.Redirect 6 | import com.example.weather.domain.model.Tag 7 | 8 | sealed class BaseException( 9 | open val code: Int, 10 | @ExceptionType val type: Int, 11 | override val message: String?, 12 | val hashCode: String? = "${System.nanoTime()}" 13 | ) : Throwable(message) { 14 | 15 | data class AlertException( 16 | override val code: Int, 17 | override val message: String, 18 | val title: String? = null 19 | ) : BaseException(code, ExceptionType.ALERT, message) 20 | 21 | data class InlineException( 22 | override val code: Int, 23 | val tags: List 24 | ) : BaseException(code, ExceptionType.INLINE, null) 25 | 26 | data class RedirectException( 27 | override val code: Int, 28 | val redirect: Redirect 29 | ) : BaseException(code, ExceptionType.REDIRECT, null) 30 | 31 | data class SnackBarException( 32 | override val code: Int, 33 | override val message: String 34 | ) : BaseException(code, ExceptionType.SNACK, message) 35 | 36 | data class ToastException( 37 | override val code: Int, 38 | override val message: String 39 | ) : BaseException(code, ExceptionType.TOAST, message) 40 | 41 | data class DialogException( 42 | override val code: Int, 43 | val dialog: Dialog 44 | ) : BaseException(code, ExceptionType.DIALOG, null) 45 | 46 | data class OnPageException( 47 | override val code: Int, 48 | override val message: String 49 | ) : BaseException(code, ExceptionType.ON_PAGE, message) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/Builders.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain 2 | 3 | import com.example.weather.data.Constants 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.flow 6 | import kotlinx.coroutines.suspendCancellableCoroutine 7 | import java.text.SimpleDateFormat 8 | import java.util.* 9 | 10 | /** 11 | * Creates a _cold_ flow that produces values from the given Throwable. 12 | */ 13 | fun Throwable.asFlow(): Flow { 14 | return flow { 15 | emit( 16 | suspendCancellableCoroutine { continuation -> 17 | continuation.cancel(this@asFlow) 18 | } 19 | ) 20 | } 21 | } 22 | 23 | /** 24 | * Creates a _cold_ flow that produces values from the given T type. 25 | */ 26 | fun T.asFlow(): Flow = flow { 27 | emit(this@asFlow) 28 | } 29 | 30 | fun Long.toDateTimeString(format: String, zone: TimeZone? = null): String { 31 | val date = Date().apply { time = this@toDateTimeString } 32 | return SimpleDateFormat(format, Locale.ENGLISH) 33 | .apply { zone?.let { timeZone = it } } 34 | .format(date) 35 | } 36 | 37 | fun Long.toDateTimeString(format: String, zoneId: Int? = null): String { 38 | val date = Date().apply { time = this@toDateTimeString } 39 | return try { 40 | SimpleDateFormat(format, Locale.ENGLISH) 41 | .apply { timeZone = TimeZone.getDefault().apply { zoneId?.let { rawOffset = it } } } 42 | .format(date) 43 | } catch (e: Exception) { 44 | SimpleDateFormat(Constants.DateFormat.DEFAULT_FORMAT, Locale.ENGLISH) 45 | .apply { timeZone = TimeZone.getDefault().apply { zoneId?.let { rawOffset = it } } } 46 | .format(date) 47 | } 48 | } 49 | 50 | fun String.toCountryName(): String { 51 | return Locale("", this).displayName 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/WeatherRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.local.pref.PrefsHelper 6 | import com.example.weather.data.model.CurrentWeather 7 | import com.example.weather.data.model.Daily 8 | import com.example.weather.data.model.Hourly 9 | import com.example.weather.data.remote.api.WeatherApi 10 | import com.example.weather.domain.repository.WeatherRepository 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.map 14 | import javax.inject.Inject 15 | 16 | class WeatherRepositoryImpl @Inject constructor( 17 | @ApplicationContext private val context: Context, 18 | private val weatherApi: WeatherApi, 19 | private val prefsHelper: PrefsHelper 20 | ) : WeatherRepository { 21 | 22 | override fun getCurrentWeather(city: String): Flow { 23 | return weatherApi.getCurrentWeather(city = city, appId = context.getString(R.string.weather_app_id)) 24 | .map { weather -> 25 | prefsHelper.saveLastCity(city) 26 | weather 27 | } 28 | } 29 | 30 | override fun getHourlyWeather(lat: Double, lon: Double): Flow> { 31 | return weatherApi.getHourlyWeather(lat = lat, long = lon, appId = context.getString(R.string.weather_app_id)) 32 | .map { response -> 33 | response.hourly ?: emptyList() 34 | } 35 | } 36 | 37 | override fun getDailyWeather(lat: Double, lon: Double): Flow> { 38 | return weatherApi.getHourlyWeather(lat = lat, long = lon, appId = context.getString(R.string.weather_app_id)) 39 | .map { response -> 40 | response.daily ?: emptyList() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weather app - Jetpack Compose Clean Architecture Example 2 | Weather app is an example for show current weather from World Cities, built with Jetpack Compose. 3 | The goal of the sample is to showcase the current UI capabilities of Compose. 4 | Design pattern: Clean Architecture & MVVM 5 | 6 | Jetpack Compose Samples 7 | 8 | ## Features 9 | The example shows current weather from World Cities and other information such as: 10 | - Weather for the next 20 hours 11 | - Tomorrow's weather 12 | - Weather for the next 7 days 13 | 14 | ## Introduction 15 | ------------- 16 | 17 | ### Data-Flow 18 | I use the `Kotlin Flow` for data stream flow 19 | ![Structure](images/data-flow.jpg "Data flow") 20 | 21 | ### Error-Flow 22 | All `Exceptions` from `API`, `Local` or `Invalid UseCase` will mapper to `BaseException` 23 | 24 | ![Exception handler](images/error-flow.jpg "Error Flow") 25 | 26 | Updating... 27 | 28 | **Credit** 29 | 30 | ### This app inspired from [Weather App Challenge] concept Designed by [Rajesh Kumar] 31 | 32 | ## License 33 | ``` 34 | Copyright 2020 The Android Open Source Project 35 | 36 | Licensed under the Apache License, Version 2.0 (the "License"); 37 | you may not use this file except in compliance with the License. 38 | You may obtain a copy of the License at 39 | 40 | https://www.apache.org/licenses/LICENSE-2.0 41 | 42 | Unless required by applicable law or agreed to in writing, software 43 | distributed under the License is distributed on an "AS IS" BASIS, 44 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 45 | See the License for the specific language governing permissions and 46 | limitations under the License. 47 | ``` 48 | 49 | 50 | [Weather App Challenge]: https://www.uplabs.com/posts/weather-app-challenge-af378b48-496a-46aa-a180-5f71ebf3cf03 51 | [Rajesh Kumar]: https://www.uplabs.com/rcrajeshkumar -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/model/HourlyWeatherViewDataModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.model 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.Constants 6 | import com.example.weather.data.model.Hourly 7 | import com.example.weather.domain.toDateTimeString 8 | import com.example.weather.presentation.base.ModelMapper 9 | import com.example.weather.presentation.base.ViewDataModel 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import javax.inject.Inject 12 | import kotlin.math.round 13 | 14 | data class HourlyWeatherViewDataModel( 15 | val dt: Long, 16 | val hour: String, 17 | val temp: String, 18 | val humidity: String, 19 | val wind: String, 20 | val visibility: String, 21 | val realFeel: String, 22 | val icon: String 23 | ) : ViewDataModel() 24 | 25 | class HourlyWeatherMapper @Inject constructor( 26 | @ApplicationContext private val context: Context 27 | ) : ModelMapper { 28 | 29 | override fun mapperToViewDataModel(dataModel: Hourly): HourlyWeatherViewDataModel { 30 | return HourlyWeatherViewDataModel( 31 | dt = dataModel.dt, 32 | hour = (dataModel.dt * 1000L).toDateTimeString(Constants.DateFormat.HH_mm, zone = null), 33 | temp = "${round(dataModel.temp).toInt()}", 34 | humidity = "${dataModel.humidity}${context.getString(R.string.percent)}", 35 | wind = "${round(dataModel.windSpeed).toInt()} ${context.getString(R.string.speed)}", 36 | visibility = "${dataModel.visibility / 1000} ${context.getString(R.string.km)}", 37 | realFeel = "${round(dataModel.feelsLike).toInt()}${context.getString(R.string.temp)}", 38 | icon = String.format(Constants.OpenWeather.WEATHER_SMALL_ICON_URL, dataModel.weather.first().icon) 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/WeatherApp.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui 2 | 3 | import androidx.compose.runtime.* 4 | import androidx.hilt.navigation.compose.hiltViewModel 5 | import androidx.navigation.NavType 6 | import androidx.navigation.compose.NavHost 7 | import androidx.navigation.compose.composable 8 | import androidx.navigation.navArgument 9 | import com.example.weather.presentation.ui.day.SevenDaysScreen 10 | import com.example.weather.presentation.ui.home.HomeScreen 11 | 12 | @Composable 13 | fun WeatherApp(appState: WeatherAppState = rememberWeatherAppState()) { 14 | val todayLazyListState = rememberLazyListState() 15 | val tomorrowLazyListState = rememberLazyListState() 16 | 17 | NavHost( 18 | navController = appState.controller, 19 | startDestination = Screen.Home.route 20 | ) { 21 | composable(Screen.Home.route) { 22 | HomeScreen( 23 | viewModel = hiltViewModel(), 24 | navigateToNextSevenDay = { lat, long -> 25 | appState.navigateToSevenDays(lat.toFloat(), long.toFloat()) 26 | }, 27 | todayLazyListState = todayLazyListState, 28 | tomorrowLazyListState = tomorrowLazyListState 29 | ) 30 | } 31 | 32 | composable( 33 | Screen.NextSevenDays.route, 34 | arguments = listOf( 35 | navArgument("lat") { type = NavType.FloatType }, 36 | navArgument("long") { type = NavType.FloatType } 37 | ) 38 | ) { entry -> 39 | SevenDaysScreen( 40 | viewModel = hiltViewModel(), 41 | onBackPressed = appState::navigateBack, 42 | lat = entry.arguments?.getFloat("lat")?.toDouble() ?: 0.0, 43 | long = entry.arguments?.getFloat("long")?.toDouble() ?: 0.0 44 | ) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/mapper/ExceptionMapper.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.mapper 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.remote.exception.RetrofitException 6 | import com.example.weather.domain.exception.BaseException 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import javax.inject.Inject 9 | 10 | class ExceptionMapper @Inject constructor( 11 | @ApplicationContext private val context: Context 12 | ) { 13 | fun mapperToBaseException(throwable: RetrofitException): Throwable { 14 | 15 | return when (throwable.getKind()) { 16 | RetrofitException.Kind.NETWORK -> 17 | BaseException.SnackBarException( 18 | code = -1, 19 | message = context.getString(R.string.internet_connection_error) 20 | ) 21 | 22 | RetrofitException.Kind.HTTP -> 23 | BaseException.OnPageException( 24 | code = throwable.getResponse()?.code() ?: -1, 25 | message = String.format( 26 | context.getString(R.string.url_invalid), 27 | throwable.getRetrofit()?.baseUrl() ?: "" 28 | ) 29 | ) 30 | 31 | RetrofitException.Kind.HTTP_422_WITH_DATA -> 32 | BaseException.OnPageException( 33 | code = throwable.getErrorData()?.code ?: -1, 34 | message = throwable.getErrorData()?.message?.let { 35 | "${String.format(context.getString(R.string.error_code_title), throwable.getErrorData()?.code)}\n $it" 36 | } ?: String.format( 37 | context.getString(R.string.url_invalid), 38 | throwable.getRetrofit()?.baseUrl() ?: "" 39 | ) 40 | ) 41 | 42 | else -> throwable 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: MAD Clean Architecture - Github Action 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the androidx branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | 28 | - name: Set up JDK 17 29 | uses: actions/setup-java@v3 30 | with: 31 | distribution: 'zulu' 32 | java-version: 17 33 | 34 | # Check code convention 35 | - name: Check code convention 36 | run: ./gradlew ktlint 37 | 38 | # Check lint 39 | - name: Check lint 40 | run: ./gradlew lintDevDebug 41 | 42 | # # Run unit test 43 | # - name: Run UT 44 | # run: ./gradlew fullCoverageReport 45 | # 46 | # # Upload coverage to codec 47 | # - name: Upload coverage report 48 | # run: bash <(curl -s --retry 10 https://codecov.io/bash) -f "presentation/build/reports/jacoco/fullCoverageReport/fullCoverageReport.xml" 49 | 50 | # Build debug 51 | - name: Build debug 52 | run: ./gradlew :app:assembleDevDebug 53 | 54 | # - name: upload artifact to Firebase App Distribution 55 | # uses: wzieba/Firebase-Distribution-Github-Action@v1.2.1 56 | # with: 57 | # appId: ${{ secrets.FIREBASE_APP_ID }} 58 | # token: ${{ secrets.FIREBASE_TOKEN }} 59 | # groups: testers 60 | # file: presentation/build/outputs/apk/develop/debug/presentation-develop-debug.apk -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/model/CurrentWeatherViewDataModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.model 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Immutable 5 | import com.example.weather.R 6 | import com.example.weather.data.Constants 7 | import com.example.weather.data.model.CurrentWeather 8 | import com.example.weather.domain.toCountryName 9 | import com.example.weather.domain.toDateTimeString 10 | import com.example.weather.presentation.base.ModelMapper 11 | import com.example.weather.presentation.base.ViewDataModel 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import javax.inject.Inject 14 | import kotlin.math.round 15 | 16 | @Immutable 17 | data class CurrentWeatherViewDataModel( 18 | val currentTime: String, 19 | val city: String, 20 | val country: String, 21 | val currentTemp: String, 22 | val humidity: String, 23 | val wind: String, 24 | val visibility: String, 25 | val realFeel: String, 26 | val currentIcon: String 27 | ) : ViewDataModel() 28 | 29 | class CurrentWeatherMapper @Inject constructor( 30 | @ApplicationContext 31 | private val context: Context 32 | ) : ModelMapper { 33 | 34 | override fun mapperToViewDataModel(dataModel: CurrentWeather): CurrentWeatherViewDataModel { 35 | return CurrentWeatherViewDataModel( 36 | currentTime = (dataModel.dt * 1000L).toDateTimeString(Constants.DateFormat.EEEE_dd_MMMM, dataModel.timezone), 37 | city = dataModel.name.uppercase(), 38 | country = dataModel.sys.country.toCountryName().uppercase(), 39 | currentTemp = "${dataModel.main.temp.toInt()}", 40 | humidity = "${dataModel.main.humidity}${context.getString(R.string.percent)}", 41 | wind = "${round(dataModel.wind.speed).toInt()} ${context.getString(R.string.speed)}", 42 | visibility = "${dataModel.visibility / 1000} ${context.getString(R.string.km)}", 43 | realFeel = "${round(dataModel.main.feelsLike).toInt()}${context.getString(R.string.temp)}", 44 | currentIcon = String.format(Constants.OpenWeather.WEATHER_ICON_URL, dataModel.weatherItems.first().icon) 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/domain/usecase/weather/GetHourlyWeatherUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.domain.usecase.weather 2 | 3 | import android.content.Context 4 | import com.example.weather.R 5 | import com.example.weather.data.model.Hourly 6 | import com.example.weather.domain.asFlow 7 | import com.example.weather.domain.di.IoDispatcher 8 | import com.example.weather.domain.exception.BaseException 9 | import com.example.weather.domain.repository.WeatherRepository 10 | import com.example.weather.domain.usecase.UseCase 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.map 15 | import java.util.* 16 | import javax.inject.Inject 17 | 18 | class GetHourlyWeatherUseCase @Inject constructor( 19 | private val weatherRepository: WeatherRepository, 20 | @IoDispatcher private val dispatcher: CoroutineDispatcher, 21 | @ApplicationContext private val context: Context 22 | ) : UseCase(dispatcher) { 23 | 24 | override fun execute(params: Params?): Flow { 25 | if (params != null) { 26 | return weatherRepository 27 | .getHourlyWeather(params.lat, params.long) 28 | .map { it.drop(1) } 29 | .map { hourly -> 30 | Response( 31 | today = hourly.filter { it.dt <= maxToday() }, 32 | tomorrow = hourly.filter { it.dt > maxToday() && it.dt <= maxTomorrow() } 33 | ) 34 | } 35 | } 36 | 37 | return BaseException.AlertException(-1, context.getString(R.string.lat_lon_invalid)).asFlow() 38 | } 39 | 40 | private fun maxToday(): Long { 41 | val calendar = Calendar.getInstance(TimeZone.getDefault()) 42 | calendar.add(Calendar.DATE, 1) 43 | calendar.set(Calendar.HOUR_OF_DAY, 6) 44 | calendar.set(Calendar.MINUTE, 0) 45 | calendar.set(Calendar.SECOND, 0) 46 | return calendar.timeInMillis / 1000L 47 | } 48 | 49 | private fun maxTomorrow(): Long { 50 | val calendar = Calendar.getInstance(TimeZone.getDefault()) 51 | calendar.add(Calendar.DATE, 2) 52 | calendar.set(Calendar.HOUR_OF_DAY, 6) 53 | calendar.set(Calendar.MINUTE, 0) 54 | calendar.set(Calendar.SECOND, 0) 55 | return calendar.timeInMillis / 1000L 56 | } 57 | 58 | data class Params(val lat: Double, val long: Double) 59 | 60 | data class Response(val today: List, val tomorrow: List) 61 | 62 | companion object { 63 | private const val MAX_WEATHERS_ON_DAY = 20 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/adapter/FlowCallAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.adapter 2 | 3 | import com.example.weather.data.remote.exception.RetrofitException 4 | import com.example.weather.data.remote.mapper.ExceptionMapper 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.suspendCancellableCoroutine 8 | import retrofit2.Call 9 | import retrofit2.CallAdapter 10 | import retrofit2.Callback 11 | import retrofit2.HttpException 12 | import retrofit2.Response 13 | import retrofit2.Retrofit 14 | import java.io.IOException 15 | import java.lang.reflect.Type 16 | import kotlin.coroutines.resume 17 | import kotlin.coroutines.resumeWithException 18 | 19 | class FlowCallAdapter( 20 | private val retrofit: Retrofit, 21 | private val mapper: ExceptionMapper, 22 | private val responseType: Type 23 | ) : CallAdapter> { 24 | override fun adapt(call: Call): Flow { 25 | return flow { 26 | emit( 27 | suspendCancellableCoroutine { continuation -> 28 | call.enqueue(object : Callback { 29 | override fun onFailure(call: Call, t: Throwable) { 30 | continuation.resumeWithException(mapper.mapperToBaseException(asRetrofitException(t))) 31 | } 32 | 33 | override fun onResponse(call: Call, response: Response) { 34 | try { 35 | continuation.resume(response.body()!!) 36 | } catch (e: Exception) { 37 | continuation.resumeWithException(mapper.mapperToBaseException(asRetrofitException(e, response))) 38 | } 39 | } 40 | }) 41 | continuation.invokeOnCancellation { call.cancel() } 42 | } 43 | ) 44 | } 45 | } 46 | 47 | override fun responseType() = responseType 48 | 49 | private fun asRetrofitException(throwable: Throwable, res: Response? = null): RetrofitException { 50 | // We had non-200 http error 51 | if (throwable is HttpException) { 52 | val response = throwable.response() 53 | 54 | return when (throwable.code()) { 55 | 422 -> // on out api 422's get metadata in the response. Adjust logic here based on your needs 56 | RetrofitException.httpErrorWithObject( 57 | response?.raw()?.request?.url.toString(), 58 | response, 59 | retrofit 60 | ) 61 | else -> RetrofitException.httpError( 62 | response?.raw()?.request?.url.toString(), 63 | response, 64 | retrofit 65 | ) 66 | } 67 | } 68 | 69 | if (res != null) { 70 | return RetrofitException.httpErrorWithObject( 71 | res.raw().request.url.toString(), 72 | res, 73 | retrofit 74 | ) 75 | } 76 | 77 | // A network error happened 78 | if (throwable is IOException) { 79 | return RetrofitException.networkError(throwable) 80 | } 81 | 82 | // We don't know what happened. We need to simply convert to an unknown error 83 | return RetrofitException.unexpectedError(throwable) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | import com.example.weather.buildsrc.Libs 2 | 3 | plugins { 4 | id 'com.android.application' 5 | id 'kotlin-android' 6 | id 'kotlin-kapt' 7 | id 'dagger.hilt.android.plugin' 8 | } 9 | 10 | apply(from: "ktlint.gradle") 11 | 12 | def key = new Properties() 13 | def keyFile = rootProject.file('key.properties') 14 | if (keyFile.exists()) { 15 | key.load(new FileInputStream(keyFile)) 16 | } 17 | 18 | android { 19 | compileSdk 34 20 | 21 | defaultConfig { 22 | applicationId "com.example.weather" 23 | minSdk 21 24 | targetSdk 34 25 | versionCode 5 26 | versionName "1.0.5" 27 | 28 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 29 | vectorDrawables { 30 | useSupportLibrary true 31 | } 32 | } 33 | 34 | flavorDimensions "environment" 35 | 36 | productFlavors { 37 | dev { 38 | dimension "environment" 39 | applicationIdSuffix ".dev" 40 | versionNameSuffix "-Dev" 41 | } 42 | 43 | prod { 44 | dimension "environment" 45 | } 46 | } 47 | 48 | buildTypes { 49 | release { 50 | minifyEnabled false 51 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 52 | } 53 | } 54 | compileOptions { 55 | sourceCompatibility JavaVersion.VERSION_17 56 | targetCompatibility JavaVersion.VERSION_17 57 | } 58 | kotlinOptions { 59 | jvmTarget = '17' 60 | } 61 | buildFeatures { 62 | compose true 63 | } 64 | composeOptions { 65 | kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version 66 | } 67 | packagingOptions { 68 | resources { 69 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 70 | } 71 | } 72 | 73 | kapt { 74 | correctErrorTypes true 75 | } 76 | namespace 'com.example.weather' 77 | } 78 | 79 | dependencies { 80 | implementation Libs.Kotlin.stdlib 81 | implementation Libs.Coroutines.android 82 | 83 | implementation Libs.AndroidX.appcompat 84 | implementation Libs.AndroidX.coreKtx 85 | implementation Libs.AndroidX.palette 86 | 87 | implementation Libs.AndroidX.Activity.activityCompose 88 | 89 | implementation Libs.AndroidX.Constraint.constraintLayoutCompose 90 | 91 | implementation Libs.AndroidX.Compose.foundation 92 | implementation Libs.AndroidX.Compose.material 93 | implementation Libs.AndroidX.Compose.materialIconsExtended 94 | implementation Libs.AndroidX.Compose.tooling 95 | implementation Libs.AndroidX.Compose.hilt 96 | 97 | implementation Libs.AndroidX.Lifecycle.runtime 98 | implementation Libs.AndroidX.Lifecycle.viewmodel 99 | implementation Libs.AndroidX.Lifecycle.viewModelCompose 100 | implementation Libs.AndroidX.Navigation.navigation 101 | 102 | implementation Libs.AndroidX.Window.window 103 | 104 | implementation(Libs.Hilt.android) 105 | kapt(Libs.Hilt.compiler) 106 | 107 | implementation Libs.Accompanist.pager 108 | implementation Libs.Accompanist.insets 109 | implementation Libs.Accompanist.swipeRefresh 110 | implementation Libs.Accompanist.systemUi 111 | 112 | implementation Libs.Log.timber 113 | 114 | implementation Libs.Coil.coilCompose 115 | 116 | // implementation Libs.OkHttp.okhttp 117 | implementation Libs.Retrofit.core 118 | implementation Libs.Retrofit.converter 119 | implementation Libs.OkHttp.logging 120 | 121 | implementation Libs.AndroidX.Room.runtime 122 | implementation Libs.AndroidX.Room.ktx 123 | testImplementation Libs.Coroutines.test 124 | 125 | kapt Libs.AndroidX.Room.compiler 126 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_info.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 16 | 23 | 30 | 37 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.Font 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontWeight 8 | import androidx.compose.ui.unit.sp 9 | import com.example.weather.R 10 | 11 | private val Montserrat = FontFamily( 12 | Font(R.font.montserrat_thin, FontWeight.Thin), 13 | Font(R.font.montserrat_light, FontWeight.Light), 14 | Font(R.font.montserrat_extralight, FontWeight.ExtraLight), 15 | Font(R.font.montserrat_regular, FontWeight.Normal), 16 | Font(R.font.montserrat_medium, FontWeight.Medium), 17 | Font(R.font.montserrat_semibold, FontWeight.SemiBold), 18 | Font(R.font.montserrat_bold, FontWeight.Bold), 19 | Font(R.font.montserrat_extrabold, FontWeight.ExtraBold), 20 | Font(R.font.montserrat_black, FontWeight.Black) 21 | ) 22 | 23 | val WeatherTypography = Typography( 24 | h1 = TextStyle( 25 | fontFamily = Montserrat, 26 | fontSize = 96.sp, 27 | fontWeight = FontWeight.Light, 28 | lineHeight = 117.sp, 29 | letterSpacing = (-1.5).sp 30 | ), 31 | h2 = TextStyle( 32 | fontFamily = Montserrat, 33 | fontSize = 60.sp, 34 | fontWeight = FontWeight.Light, 35 | lineHeight = 73.sp, 36 | letterSpacing = (-0.5).sp 37 | ), 38 | h3 = TextStyle( 39 | fontFamily = Montserrat, 40 | fontSize = 48.sp, 41 | fontWeight = FontWeight.Normal, 42 | lineHeight = 59.sp 43 | ), 44 | h4 = TextStyle( 45 | fontFamily = Montserrat, 46 | fontSize = 30.sp, 47 | fontWeight = FontWeight.SemiBold, 48 | lineHeight = 37.sp 49 | ), 50 | h5 = TextStyle( 51 | fontFamily = Montserrat, 52 | fontSize = 24.sp, 53 | fontWeight = FontWeight.SemiBold, 54 | lineHeight = 29.sp 55 | ), 56 | h6 = TextStyle( 57 | fontFamily = Montserrat, 58 | fontSize = 20.sp, 59 | fontWeight = FontWeight.SemiBold, 60 | lineHeight = 24.sp 61 | ), 62 | subtitle1 = TextStyle( 63 | fontFamily = Montserrat, 64 | fontSize = 16.sp, 65 | fontWeight = FontWeight.SemiBold, 66 | lineHeight = 20.sp, 67 | letterSpacing = 0.5.sp 68 | ), 69 | subtitle2 = TextStyle( 70 | fontFamily = Montserrat, 71 | fontSize = 14.sp, 72 | fontWeight = FontWeight.Medium, 73 | lineHeight = 17.sp, 74 | letterSpacing = 0.1.sp 75 | ), 76 | body1 = TextStyle( 77 | fontFamily = Montserrat, 78 | fontSize = 16.sp, 79 | fontWeight = FontWeight.Medium, 80 | lineHeight = 20.sp, 81 | letterSpacing = 0.15.sp 82 | ), 83 | body2 = TextStyle( 84 | fontFamily = Montserrat, 85 | fontSize = 14.sp, 86 | fontWeight = FontWeight.SemiBold, 87 | lineHeight = 20.sp, 88 | letterSpacing = 0.25.sp 89 | ), 90 | button = TextStyle( 91 | fontFamily = Montserrat, 92 | fontSize = 14.sp, 93 | fontWeight = FontWeight.SemiBold, 94 | lineHeight = 16.sp, 95 | letterSpacing = 1.25.sp 96 | ), 97 | caption = TextStyle( 98 | fontFamily = Montserrat, 99 | fontSize = 12.sp, 100 | fontWeight = FontWeight.SemiBold, 101 | lineHeight = 16.sp, 102 | letterSpacing = 0.sp 103 | ), 104 | overline = TextStyle( 105 | fontFamily = Montserrat, 106 | fontSize = 12.sp, 107 | fontWeight = FontWeight.SemiBold, 108 | lineHeight = 16.sp, 109 | letterSpacing = 1.sp 110 | ) 111 | ) 112 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/home/HourlyWeatherItem.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.home 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Surface 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.layout.ContentScale 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.unit.sp 17 | import coil.annotation.ExperimentalCoilApi 18 | import coil.compose.rememberImagePainter 19 | import com.example.weather.R 20 | import com.example.weather.presentation.model.HourlyWeatherViewDataModel 21 | import com.example.weather.presentation.model.factory.createHourlyWeather 22 | import com.example.weather.presentation.ui.theme.WeatherTheme 23 | 24 | @OptIn(ExperimentalCoilApi::class) 25 | @Composable 26 | fun HourlyWeatherItem( 27 | modifier: Modifier = Modifier, 28 | hourly: HourlyWeatherViewDataModel 29 | ) { 30 | Box( 31 | modifier = modifier.then( 32 | Modifier.width(64.dp) 33 | .fillMaxHeight() 34 | ) 35 | ) { 36 | Column( 37 | modifier = Modifier 38 | .fillMaxHeight() 39 | .wrapContentSize() 40 | .align(Alignment.Center), 41 | verticalArrangement = Arrangement.SpaceBetween 42 | ) { 43 | Text( 44 | text = hourly.hour, 45 | modifier = Modifier 46 | .padding(4.dp) 47 | .align(Alignment.CenterHorizontally), 48 | style = MaterialTheme.typography.body1.copy( 49 | color = Color.White, 50 | fontSize = 12.sp 51 | ) 52 | ) 53 | 54 | Image( 55 | painter = rememberImagePainter( 56 | data = hourly.icon, 57 | builder = { 58 | crossfade(true) 59 | placeholder(R.drawable.ic_cloud) 60 | error(R.drawable.ic_cloud) 61 | } 62 | ), 63 | contentDescription = null, 64 | contentScale = ContentScale.Fit, 65 | modifier = Modifier 66 | .size(32.dp) 67 | .align(Alignment.CenterHorizontally) 68 | ) 69 | 70 | Row( 71 | modifier = Modifier 72 | .padding(2.dp) 73 | .align(Alignment.CenterHorizontally) 74 | ) { 75 | Text( 76 | text = hourly.temp, 77 | modifier = Modifier.padding(top = 4.dp), 78 | style = MaterialTheme.typography.body1.copy( 79 | color = Color.White, 80 | fontSize = 18.sp 81 | ) 82 | ) 83 | 84 | Text( 85 | text = stringResource(R.string.zero), 86 | modifier = Modifier, 87 | style = MaterialTheme.typography.body1.copy( 88 | color = Color.White, 89 | fontSize = 12.sp 90 | ) 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | 97 | @Preview 98 | @Composable 99 | fun HourlyWeatherItemPreview() { 100 | WeatherTheme { 101 | Surface { 102 | HourlyWeatherItem( 103 | modifier = Modifier.height(128.dp), 104 | hourly = createHourlyWeather() 105 | ) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/day/SevenDaysScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.day 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.* 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.ArrowBack 10 | import androidx.compose.material.icons.filled.Search 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import androidx.lifecycle.viewmodel.compose.viewModel 21 | import com.example.weather.R 22 | import com.example.weather.presentation.ui.custom.BackgroundImage 23 | import com.google.accompanist.insets.statusBarsPadding 24 | 25 | @Composable 26 | fun SevenDaysScreen( 27 | viewModel: SevenDaysViewModel = viewModel(), 28 | lat: Double = 21.0245, 29 | long: Double = 105.8412, 30 | onBackPressed: () -> Unit 31 | ) { 32 | val scaffoldState = rememberScaffoldState() 33 | 34 | SevenDaysScreenContent( 35 | modifier = Modifier.statusBarsPadding(), 36 | onBackPressed = onBackPressed, 37 | scaffoldState = scaffoldState 38 | ) 39 | } 40 | 41 | @Composable 42 | fun SevenDaysScreenContent( 43 | modifier: Modifier, 44 | scaffoldState: ScaffoldState = rememberScaffoldState(), 45 | onBackPressed: (() -> Unit)? = null, 46 | ) { 47 | val drawableId = if (isSystemInDarkTheme()) R.drawable.background_night else R.drawable.background 48 | 49 | Surface(modifier = Modifier.fillMaxSize()) { 50 | BackgroundImage( 51 | modifier = Modifier.fillMaxSize(), 52 | painter = painterResource(drawableId), 53 | alignment = Alignment.TopCenter 54 | ) { 55 | Scaffold( 56 | scaffoldState = scaffoldState, 57 | topBar = { 58 | SevenDaysTopAppBar { onBackPressed?.invoke() } 59 | }, 60 | modifier = modifier, 61 | backgroundColor = Color.Transparent, 62 | content = { paddingValues -> 63 | Box(modifier = Modifier.padding(paddingValues)) 64 | } 65 | ) 66 | } 67 | } 68 | } 69 | 70 | @Composable 71 | private fun SevenDaysTopAppBar( 72 | elevation: Dp = 0.dp, 73 | onBackPressed: (() -> Unit)? = null, 74 | ) { 75 | TopAppBar( 76 | title = { 77 | Text( 78 | text = stringResource(R.string.app_name), 79 | modifier = Modifier 80 | .fillMaxSize() 81 | .padding(bottom = 4.dp, top = 12.dp), 82 | style = MaterialTheme.typography.h6.copy(color = MaterialTheme.colors.onPrimary), 83 | textAlign = TextAlign.Center 84 | ) 85 | }, 86 | navigationIcon = { 87 | IconButton(onClick = { onBackPressed?.invoke() }) { 88 | Icon( 89 | imageVector = Icons.Filled.ArrowBack, 90 | contentDescription = stringResource(R.string.close), 91 | tint = MaterialTheme.colors.primary 92 | ) 93 | } 94 | }, 95 | actions = { 96 | IconButton(onClick = { /** Not implement */ }) { 97 | Icon( 98 | imageVector = Icons.Filled.Search, 99 | contentDescription = stringResource(R.string.search_city), 100 | tint = MaterialTheme.colors.primary 101 | ) 102 | } 103 | }, 104 | backgroundColor = Color.Transparent, 105 | elevation = elevation 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/exception/RetrofitException.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.exception 2 | 3 | import com.example.weather.data.remote.response.ServerErrorResponse 4 | import okhttp3.ResponseBody 5 | import retrofit2.Converter 6 | import retrofit2.Response 7 | import retrofit2.Retrofit 8 | import timber.log.Timber 9 | import java.io.IOException 10 | 11 | class RetrofitException( 12 | private val _message: String?, 13 | private val _url: String?, 14 | private val _response: Response<*>?, 15 | private val _kind: Kind, 16 | private val _exception: Throwable?, 17 | private val _retrofit: Retrofit? 18 | ) : RuntimeException(_message, _exception) { 19 | 20 | private var _errorData: ServerErrorResponse? = null 21 | 22 | companion object { 23 | fun httpError(url: String, response: Response<*>?, retrofit: Retrofit): RetrofitException { 24 | val message = response?.code().toString() + " " + response?.message() 25 | return RetrofitException(message, url, response, Kind.HTTP, null, retrofit) 26 | } 27 | 28 | fun httpErrorWithObject( 29 | url: String, 30 | response: Response<*>?, 31 | retrofit: Retrofit 32 | ): RetrofitException { 33 | val message = response?.code().toString() + " " + response?.message() 34 | val error = RetrofitException(message, url, response, Kind.HTTP_422_WITH_DATA, null, retrofit) 35 | error.deserializeServerError() 36 | return error 37 | } 38 | 39 | fun networkError(exception: IOException): RetrofitException { 40 | return RetrofitException(exception.message, null, null, Kind.NETWORK, exception, null) 41 | } 42 | 43 | fun unexpectedError(exception: Throwable): RetrofitException { 44 | return RetrofitException( 45 | exception.message, 46 | null, 47 | null, 48 | Kind.UNEXPECTED, 49 | exception, 50 | null 51 | ) 52 | } 53 | } 54 | 55 | /** The request URL which produced the error. */ 56 | fun getUrl() = _url 57 | 58 | /** Response object containing status code, headers, body, etc. */ 59 | fun getResponse() = _response 60 | 61 | /** The event kind which triggered this error. */ 62 | fun getKind() = _kind 63 | 64 | /** The Retrofit this request was executed on */ 65 | fun getRetrofit() = _retrofit 66 | 67 | /** The data returned from the server in the response body*/ 68 | fun getErrorData(): ServerErrorResponse? = _errorData 69 | 70 | private fun deserializeServerError() { 71 | val responseBody = _response?.errorBody() 72 | if (responseBody != null) { 73 | try { 74 | _errorData = responseBody.getErrorBodyAs(ServerErrorResponse::class.java) 75 | responseBody.close() 76 | } catch (e: IOException) { 77 | Timber.e("Server error deserialization $e") 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * HTTP response body converted to specified `type`. `null` if there is no 84 | * response. 85 | * @throws IOException if unable to convert the body to the specified `type`. 86 | */ 87 | @Throws(IOException::class) 88 | fun ResponseBody.getErrorBodyAs(type: Class): T? { 89 | if (_retrofit == null) { 90 | return null 91 | } 92 | 93 | val converter: Converter = 94 | _retrofit.responseBodyConverter(type, arrayOfNulls(0)) 95 | return converter.convert(this) 96 | } 97 | 98 | enum class Kind { 99 | /** An [IOException] occurred while communicating to the server. */ 100 | NETWORK, 101 | 102 | /** A non-200 HTTP status code was received from the server. */ 103 | HTTP, 104 | HTTP_422_WITH_DATA, 105 | 106 | /** 107 | * An internal error occurred while attempting to execute a request. It is best practice to 108 | * re-throw this exception so your application crashes. 109 | */ 110 | UNEXPECTED 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/data/remote/builder/RetrofitBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.data.remote.builder 2 | 3 | import android.content.Context 4 | import com.example.weather.BuildConfig 5 | import com.example.weather.R 6 | import com.example.weather.data.Constants 7 | import com.example.weather.data.remote.factory.FlowCallAdapterFactory 8 | import com.example.weather.data.remote.mapper.ExceptionMapper 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import okhttp3.Authenticator 11 | import okhttp3.Interceptor 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.gson.GsonConverterFactory 16 | import java.util.concurrent.TimeUnit 17 | import javax.inject.Inject 18 | import javax.inject.Singleton 19 | 20 | @Singleton 21 | class RetrofitBuilder @Inject constructor( 22 | @ApplicationContext private val context: Context, 23 | private val exceptionMapper: ExceptionMapper 24 | ) { 25 | private var connectionTimeout = Constants.HttpClient.CONNECT_TIMEOUT 26 | private var writeTimeout = Constants.HttpClient.WRITE_TIMEOUT 27 | private var readTimeout = Constants.HttpClient.READ_TIMEOUT 28 | private var okHttpClientBuilder: OkHttpClient.Builder? = null 29 | private var interceptors = mutableListOf() 30 | private var logEnable: Boolean = BuildConfig.DEBUG 31 | private var isSupportAuthorization = false 32 | private var authenticator: Authenticator? = null 33 | private var baseUrl: String = context.getString(R.string.base_url) 34 | 35 | /** 36 | * Customize time out 37 | * @param connectionTimeout timeout for connection OK Http client 38 | * @param writeTimeout timeout for write data 39 | * @param readTimeout timeout for read data 40 | */ 41 | fun setTimeout( 42 | connectionTimeout: Long = Constants.HttpClient.CONNECT_TIMEOUT, 43 | writeTimeout: Long = Constants.HttpClient.WRITE_TIMEOUT, 44 | readTimeout: Long = Constants.HttpClient.READ_TIMEOUT 45 | ): RetrofitBuilder { 46 | this.connectionTimeout = connectionTimeout 47 | this.writeTimeout = writeTimeout 48 | this.readTimeout = readTimeout 49 | return this 50 | } 51 | 52 | /** 53 | * User customize ok http client 54 | * @param okHttpClientBuilder 55 | */ 56 | fun setOkHttpClientBuilder(okHttpClientBuilder: OkHttpClient.Builder): RetrofitBuilder { 57 | this.okHttpClientBuilder = okHttpClientBuilder 58 | return this 59 | } 60 | 61 | /** 62 | * add custom interceptor for ok http client 63 | * @param interceptor is a interceptor for ok http client 64 | */ 65 | fun addInterceptors(vararg interceptor: Interceptor): RetrofitBuilder { 66 | interceptors.addAll(interceptor) 67 | return this 68 | } 69 | 70 | /** 71 | * Customize show or hide logging 72 | * @param enable is status for logs 73 | */ 74 | fun loggingEnable(enable: Boolean): RetrofitBuilder { 75 | this.logEnable = enable 76 | return this 77 | } 78 | 79 | /** 80 | * Support default Authorization 81 | * @param enable is status support 82 | */ 83 | fun supportAuthorization(enable: Boolean): RetrofitBuilder { 84 | this.isSupportAuthorization = enable 85 | return this 86 | } 87 | 88 | /** 89 | * Customize authorization 90 | * @param authenticator 91 | */ 92 | fun setCustomAuthorization(authenticator: Authenticator): RetrofitBuilder { 93 | this.authenticator = authenticator 94 | return this 95 | } 96 | 97 | /** 98 | * Customize base url 99 | * @param baseUrl is base url for ok http client 100 | */ 101 | fun setBaseURL(baseUrl: String): RetrofitBuilder { 102 | this.baseUrl = baseUrl 103 | return this 104 | } 105 | 106 | /** 107 | * Make a Retrofit 108 | */ 109 | fun build(): Retrofit { 110 | val clientBuilder = okHttpClientBuilder ?: OkHttpClient.Builder().apply { 111 | connectTimeout(connectionTimeout, TimeUnit.SECONDS) 112 | writeTimeout(writeTimeout, TimeUnit.SECONDS) 113 | readTimeout(readTimeout, TimeUnit.SECONDS) 114 | 115 | if (logEnable) { 116 | addInterceptor( 117 | HttpLoggingInterceptor().apply { 118 | level = HttpLoggingInterceptor.Level.BODY 119 | } 120 | ) 121 | } 122 | 123 | interceptors.forEach { addInterceptor(it) } 124 | } 125 | 126 | return Retrofit.Builder() 127 | .baseUrl(baseUrl) 128 | .client(clientBuilder.build()) 129 | .addCallAdapterFactory(FlowCallAdapterFactory.create(exceptionMapper)) 130 | .addConverterFactory(GsonConverterFactory.create()) 131 | .build() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.home 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.example.weather.domain.exception.BaseException 5 | import com.example.weather.domain.usecase.weather.GetCurrentWeatherByCityUseCase 6 | import com.example.weather.domain.usecase.weather.GetHourlyWeatherUseCase 7 | import com.example.weather.domain.usecase.weather.GetLastCityUseCase 8 | import com.example.weather.presentation.base.BaseViewModel 9 | import com.example.weather.presentation.base.ViewState 10 | import com.example.weather.presentation.base.toBaseException 11 | import com.example.weather.presentation.model.CurrentWeatherMapper 12 | import com.example.weather.presentation.model.CurrentWeatherViewDataModel 13 | import com.example.weather.presentation.model.HourlyWeatherMapper 14 | import com.example.weather.presentation.model.HourlyWeatherViewDataModel 15 | import dagger.hilt.android.lifecycle.HiltViewModel 16 | import kotlinx.coroutines.flow.* 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | enum class WeatherIndex(private val index: Int) { 21 | Today(0), Tomorrow(1); 22 | 23 | fun value(): Int = index 24 | 25 | companion object { 26 | fun from(index: Int): WeatherIndex { 27 | return if (index == 0) Today else Tomorrow 28 | } 29 | } 30 | } 31 | 32 | sealed interface WeatherState { 33 | val weatherIndex: WeatherIndex 34 | val weathers: List 35 | 36 | data class Today( 37 | override val weatherIndex: WeatherIndex = WeatherIndex.Today, 38 | override val weathers: List = emptyList(), 39 | ) : WeatherState 40 | 41 | data class Tomorrow( 42 | override val weatherIndex: WeatherIndex = WeatherIndex.Tomorrow, 43 | override val weathers: List = emptyList(), 44 | ) : WeatherState 45 | } 46 | 47 | sealed interface SearchState { 48 | val enabled: Boolean 49 | val query: String 50 | 51 | data class Changing( 52 | override val enabled: Boolean = true, 53 | override val query: String = "" 54 | ) : SearchState 55 | 56 | data class Closed( 57 | override val enabled: Boolean = false, 58 | override val query: String = "" 59 | ) : SearchState 60 | } 61 | 62 | data class HomeViewState( 63 | override val isLoading: Boolean = false, 64 | override val exception: BaseException? = null, 65 | val currentWeather: CurrentWeatherViewDataModel? = null, 66 | val weatherState: WeatherState = WeatherState.Today(), 67 | val searchState: SearchState = SearchState.Closed() 68 | ) : ViewState(isLoading, exception) 69 | 70 | @HiltViewModel 71 | class HomeViewModel @Inject constructor( 72 | private val getCurrentWeatherByCityUseCase: GetCurrentWeatherByCityUseCase, 73 | private val weatherMapper: CurrentWeatherMapper, 74 | private val getLastCityUseCase: GetLastCityUseCase, 75 | private val getHourlyWeatherUseCase: GetHourlyWeatherUseCase, 76 | private val hourlyWeatherMapper: HourlyWeatherMapper 77 | ) : BaseViewModel() { 78 | 79 | private val _state = MutableStateFlow(HomeViewState(isLoading = true)) 80 | override val state: StateFlow 81 | get() = _state 82 | 83 | val coordinate = MutableStateFlow(Pair(0.0, 0.0)) 84 | 85 | private val todayState = MutableStateFlow(WeatherState.Today()) 86 | private val tomorrowState = MutableStateFlow(WeatherState.Tomorrow()) 87 | 88 | init { 89 | viewModelScope.launch { 90 | getLastCityUseCase.invoke() 91 | .collect { city -> 92 | getWeather(city) 93 | } 94 | } 95 | } 96 | 97 | fun getWeather(city: String) { 98 | _state.update { HomeViewState(isLoading = true) } 99 | 100 | viewModelScope.launch { 101 | getCurrentWeatherByCityUseCase.invoke(GetCurrentWeatherByCityUseCase.Params(city)) 102 | .catch { throwable -> 103 | _state.update { it.copy(isLoading = false, exception = throwable.toBaseException()) } 104 | } 105 | .map { weather -> 106 | coordinate.update { it.copy(first = weather.coord.lat, second = weather.coord.long) } 107 | getHourlyWeathers(weather.coord.lat, weather.coord.long) 108 | weatherMapper.mapperToViewDataModel(weather) 109 | } 110 | .collect { weather -> 111 | _state.update { 112 | it.copy(isLoading = false, currentWeather = weather) 113 | } 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Notify that the user when typing the search input 120 | */ 121 | fun onSearchInputChanged(searchInput: String) { 122 | _state.update { 123 | it.copy(searchState = SearchState.Changing(query = searchInput)) 124 | } 125 | } 126 | 127 | /** 128 | * Enable or disable search view 129 | */ 130 | fun enableSearchView(enabled: Boolean) { 131 | _state.update { state -> 132 | state.copy(searchState = if (enabled) SearchState.Changing() else SearchState.Closed(query = state.searchState.query)) 133 | } 134 | } 135 | 136 | /** 137 | * Change hourly weather today or tomorrow 138 | */ 139 | fun weatherIndexChanged(index: WeatherIndex) { 140 | _state.update { it.copy(weatherState = if (index == WeatherIndex.Today) todayState.value else tomorrowState.value) } 141 | } 142 | 143 | private fun getHourlyWeathers(lat: Double, long: Double) { 144 | viewModelScope.launch { 145 | getHourlyWeatherUseCase.invoke(GetHourlyWeatherUseCase.Params(lat, long)) 146 | .catch { throwable -> 147 | _state.update { it.copy(isLoading = false, exception = throwable.toBaseException()) } 148 | } 149 | .map { response -> 150 | Pair( 151 | response.today.map { hourlyWeatherMapper.mapperToViewDataModel(it) }, 152 | response.tomorrow.map { hourlyWeatherMapper.mapperToViewDataModel(it) } 153 | ) 154 | } 155 | .collect { pair -> 156 | todayState.update { WeatherState.Today(weathers = pair.first) } 157 | tomorrowState.update { WeatherState.Tomorrow(weathers = pair.second) } 158 | _state.update { it.copy(weatherState = todayState.value) } 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/base/ExceptionHandleView.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.base 2 | 3 | import android.widget.Toast 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.* 7 | import androidx.compose.runtime.* 8 | import androidx.compose.runtime.saveable.rememberSaveable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.style.TextAlign 14 | import androidx.compose.ui.unit.dp 15 | import com.example.weather.domain.exception.BaseException 16 | import com.example.weather.domain.model.Dialog 17 | import com.example.weather.domain.model.Redirect 18 | import com.example.weather.domain.model.Tag 19 | import com.example.weather.presentation.ui.custom.FullScreenLoading 20 | 21 | @Composable 22 | fun ExceptionHandleView( 23 | modifier: Modifier = Modifier, 24 | state: ViewState, 25 | snackBarHostState: SnackbarHostState, 26 | contentCleared: Boolean = false, 27 | positiveAction: ((Int?, Any?) -> Unit)? = null, 28 | negativeAction: ((Int?, Any?) -> Unit)? = null, 29 | inlineActions: ((List) -> Unit)? = null, 30 | redirectAction: ((Redirect) -> Unit)? = null, 31 | content: @Composable (ViewState) -> Unit 32 | ) { 33 | when { 34 | state.isLoading -> FullScreenLoading() 35 | state.exception != null -> ShowError( 36 | modifier = modifier, 37 | state = state, 38 | hostState = snackBarHostState, 39 | contentCleared = contentCleared, 40 | positiveAction = positiveAction, 41 | negativeAction = negativeAction, 42 | inlineActions = inlineActions, 43 | redirectAction = redirectAction, 44 | content = content 45 | ) 46 | else -> content(state) 47 | } 48 | } 49 | 50 | @Composable 51 | fun ShowError( 52 | modifier: Modifier = Modifier, 53 | state: ViewState, 54 | hostState: SnackbarHostState, 55 | contentCleared: Boolean = false, 56 | positiveAction: ((Int?, Any?) -> Unit)? = null, 57 | negativeAction: ((Int?, Any?) -> Unit)? = null, 58 | inlineActions: ((List) -> Unit)? = null, 59 | redirectAction: ((Redirect) -> Unit)? = null, 60 | content: @Composable (ViewState) -> Unit 61 | ) { 62 | Box(modifier = Modifier.fillMaxSize()) { 63 | if (!contentCleared) content(state) 64 | 65 | when (state.exception) { 66 | is BaseException.OnPageException -> 67 | ShowOnPageException(onPage = state.exception as BaseException.OnPageException) 68 | 69 | is BaseException.AlertException -> { 70 | var instanceHashCode by rememberSaveable { mutableStateOf("") } 71 | if (instanceHashCode != state.exception?.hashCode) { 72 | ShowAlertDialog(dialog = state.exception as BaseException.AlertException) { 73 | instanceHashCode = state.exception?.hashCode ?: "" 74 | } 75 | } 76 | Spacer(modifier = Modifier) 77 | } 78 | 79 | is BaseException.ToastException -> { 80 | ShowToast(toast = state.exception as BaseException.ToastException) 81 | Spacer(modifier = Modifier) 82 | } 83 | 84 | is BaseException.DialogException -> { 85 | var instanceHashCode by rememberSaveable { mutableStateOf("") } 86 | if (instanceHashCode != state.exception?.hashCode) { 87 | ShowDialog( 88 | dialog = (state.exception as BaseException.DialogException).dialog, 89 | positiveAction = positiveAction, 90 | negativeAction = negativeAction 91 | ) { 92 | instanceHashCode = state.exception?.hashCode ?: "" 93 | } 94 | } 95 | Spacer(modifier = Modifier) 96 | } 97 | 98 | is BaseException.SnackBarException -> { 99 | ShowSnackBar( 100 | hostState = hostState, 101 | message = (state.exception as BaseException.SnackBarException).message 102 | ) 103 | Spacer(modifier = Modifier) 104 | } 105 | 106 | is BaseException.InlineException -> { 107 | inlineActions?.invoke((state.exception as BaseException.InlineException).tags) 108 | Spacer(modifier = Modifier) 109 | } 110 | 111 | is BaseException.RedirectException -> { 112 | redirectAction?.invoke((state.exception as BaseException.RedirectException).redirect) 113 | Spacer(modifier = Modifier) 114 | } 115 | 116 | else -> { 117 | Spacer(modifier = Modifier) 118 | } 119 | } 120 | } 121 | } 122 | 123 | @Composable 124 | fun ShowOnPageException(onPage: BaseException.OnPageException) { 125 | Box(modifier = Modifier.fillMaxSize()) { 126 | Text( 127 | text = onPage.message, 128 | style = MaterialTheme.typography.body1, 129 | textAlign = TextAlign.Center, 130 | modifier = Modifier.align(Alignment.Center) 131 | ) 132 | } 133 | } 134 | 135 | @Composable 136 | fun ShowAlertDialog( 137 | dialog: BaseException.AlertException, 138 | onDismiss: () -> Unit 139 | ) { 140 | AlertDialog( 141 | modifier = Modifier.padding(20.dp), 142 | onDismissRequest = { onDismiss.invoke() }, 143 | title = dialog.title?.let { { Text(text = dialog.title, color = MaterialTheme.colors.onSecondary) } }, 144 | text = { 145 | Text( 146 | text = dialog.message, 147 | style = MaterialTheme.typography.body2 148 | ) 149 | }, 150 | confirmButton = { 151 | TextButton(onClick = onDismiss) { 152 | Text( 153 | text = stringResource(id = android.R.string.ok), 154 | style = MaterialTheme.typography.button, 155 | color = MaterialTheme.colors.primary 156 | ) 157 | } 158 | } 159 | ) 160 | } 161 | 162 | @Composable 163 | fun ShowToast(toast: BaseException.ToastException) { 164 | Spacer(modifier = Modifier) 165 | Toast.makeText(LocalContext.current, toast.message, Toast.LENGTH_LONG).show() 166 | } 167 | 168 | @Composable 169 | fun ShowDialog( 170 | dialog: Dialog, 171 | positiveAction: ((Int?, Any?) -> Unit)? = null, 172 | negativeAction: ((Int?, Any?) -> Unit)? = null, 173 | onDismiss: () -> Unit 174 | ) { 175 | AlertDialog( 176 | modifier = Modifier.padding(20.dp), 177 | onDismissRequest = { onDismiss.invoke() }, 178 | title = dialog.title?.let { { Text(text = dialog.title) } }, 179 | text = { 180 | dialog.message?.let { 181 | Text( 182 | text = dialog.message, 183 | style = MaterialTheme.typography.body1 184 | ) 185 | } 186 | }, 187 | confirmButton = { 188 | dialog.positiveMessage?.let { 189 | Text( 190 | text = dialog.positiveMessage, 191 | style = MaterialTheme.typography.button, 192 | color = MaterialTheme.colors.primary, 193 | modifier = Modifier 194 | .padding(15.dp) 195 | .clickable { 196 | positiveAction?.invoke(dialog.positiveAction, dialog.positiveObject) 197 | onDismiss.invoke() 198 | } 199 | ) 200 | } 201 | }, 202 | dismissButton = dialog.negativeMessage?.let { 203 | { 204 | Text( 205 | text = dialog.negativeMessage, 206 | style = MaterialTheme.typography.button, 207 | color = MaterialTheme.colors.primary, 208 | modifier = Modifier 209 | .padding(15.dp) 210 | .clickable { 211 | negativeAction?.invoke(dialog.positiveAction, dialog.positiveObject) 212 | onDismiss.invoke() 213 | } 214 | ) 215 | } 216 | } 217 | ) 218 | } 219 | 220 | @Composable 221 | fun ShowSnackBar( 222 | hostState: SnackbarHostState, 223 | message: String 224 | ) { 225 | LaunchedEffect(message) { 226 | hostState.showSnackbar(message = message) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/weather/presentation/ui/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.weather.presentation.ui.home 2 | 3 | import androidx.compose.animation.core.FastOutSlowInEasing 4 | import androidx.compose.animation.core.animateDpAsState 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.isSystemInDarkTheme 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.foundation.lazy.LazyListState 11 | import androidx.compose.foundation.lazy.LazyRow 12 | import androidx.compose.foundation.lazy.items 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.foundation.text.KeyboardActions 16 | import androidx.compose.foundation.text.KeyboardOptions 17 | import androidx.compose.material.* 18 | import androidx.compose.material.icons.Icons 19 | import androidx.compose.material.icons.filled.ArrowBack 20 | import androidx.compose.material.icons.filled.ArrowForwardIos 21 | import androidx.compose.material.icons.filled.Close 22 | import androidx.compose.material.icons.filled.Search 23 | import androidx.compose.runtime.* 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.ExperimentalComposeUiApi 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.composed 28 | import androidx.compose.ui.draw.clip 29 | import androidx.compose.ui.focus.FocusRequester 30 | import androidx.compose.ui.focus.focusRequester 31 | import androidx.compose.ui.graphics.Color 32 | import androidx.compose.ui.layout.ContentScale 33 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 34 | import androidx.compose.ui.platform.SoftwareKeyboardController 35 | import androidx.compose.ui.platform.debugInspectorInfo 36 | import androidx.compose.ui.res.painterResource 37 | import androidx.compose.ui.res.stringArrayResource 38 | import androidx.compose.ui.res.stringResource 39 | import androidx.compose.ui.text.font.FontWeight 40 | import androidx.compose.ui.text.input.ImeAction 41 | import androidx.compose.ui.text.style.TextAlign 42 | import androidx.compose.ui.tooling.preview.Preview 43 | import androidx.compose.ui.unit.Dp 44 | import androidx.compose.ui.unit.dp 45 | import androidx.compose.ui.unit.sp 46 | import androidx.lifecycle.viewmodel.compose.viewModel 47 | import coil.annotation.ExperimentalCoilApi 48 | import coil.compose.rememberImagePainter 49 | import com.example.weather.R 50 | import com.example.weather.presentation.base.ExceptionHandleView 51 | import com.example.weather.presentation.model.CurrentWeatherViewDataModel 52 | import com.example.weather.presentation.model.factory.createCurrentWeather 53 | import com.example.weather.presentation.ui.custom.BackgroundImage 54 | import com.example.weather.presentation.ui.rememberLazyListState 55 | import com.example.weather.presentation.ui.theme.WeatherTheme 56 | import com.example.weather.presentation.ui.theme.White60 57 | import com.google.accompanist.insets.statusBarsPadding 58 | import kotlinx.coroutines.launch 59 | 60 | @OptIn(ExperimentalComposeUiApi::class) 61 | @Composable 62 | fun HomeScreen( 63 | viewModel: HomeViewModel = viewModel(), 64 | navigateToNextSevenDay: (Double, Double) -> Unit, 65 | todayLazyListState: LazyListState = rememberLazyListState(), 66 | tomorrowLazyListState: LazyListState = rememberLazyListState(), 67 | ) { 68 | val viewState by viewModel.state.collectAsState() 69 | val scaffoldState = rememberScaffoldState() 70 | val requestFocus = remember { FocusRequester() } 71 | val keyboardController = LocalSoftwareKeyboardController.current 72 | val coroutineScope = rememberCoroutineScope() 73 | 74 | val listState = mapOf( 75 | WeatherIndex.Today to todayLazyListState, 76 | WeatherIndex.Tomorrow to tomorrowLazyListState, 77 | ) 78 | 79 | HomeScreenContent( 80 | modifier = Modifier 81 | .statusBarsPadding(), 82 | scaffoldState = scaffoldState, 83 | homeViewState = viewState, 84 | closeSearchView = { 85 | viewModel.enableSearchView(false) 86 | }, 87 | onSearchChange = { 88 | viewModel.onSearchInputChanged(it) 89 | }, 90 | openSearchView = { 91 | viewModel.enableSearchView(true) 92 | }, 93 | focusRequest = requestFocus, 94 | keyboardController = keyboardController, 95 | weatherLazyListState = listState, 96 | actionSearch = { 97 | coroutineScope.launch { 98 | tomorrowLazyListState.scrollToItem(0, 0) 99 | } 100 | viewModel.getWeather(viewState.searchState.query) 101 | }, 102 | onWeatherIndexChanged = { index -> 103 | viewModel.weatherIndexChanged(index) 104 | }, 105 | actionNext7Days = { 106 | navigateToNextSevenDay.invoke(viewModel.coordinate.value.first, viewModel.coordinate.value.second) 107 | }, 108 | ) 109 | } 110 | 111 | @OptIn(ExperimentalComposeUiApi::class) 112 | @Composable 113 | fun HomeScreenContent( 114 | modifier: Modifier, 115 | scaffoldState: ScaffoldState = rememberScaffoldState(), 116 | homeViewState: HomeViewState = HomeViewState(), 117 | onSearchChange: ((String) -> Unit)? = null, 118 | closeSearchView: (() -> Unit)? = null, 119 | openSearchView: (() -> Unit)? = null, 120 | focusRequest: FocusRequester = remember { FocusRequester() }, 121 | keyboardController: SoftwareKeyboardController? = null, 122 | actionSearch: (() -> Unit)? = null, 123 | onWeatherIndexChanged: ((WeatherIndex) -> Unit)? = null, 124 | weatherLazyListState: Map = mapOf(), 125 | actionNext7Days: (() -> Unit)? = null, 126 | ) { 127 | val drawableId = if (isSystemInDarkTheme()) R.drawable.background_night else R.drawable.background 128 | 129 | Surface(modifier = Modifier.fillMaxSize()) { 130 | BackgroundImage( 131 | modifier = Modifier.fillMaxSize(), 132 | painter = painterResource(drawableId), 133 | alignment = Alignment.TopCenter, 134 | ) { 135 | Scaffold( 136 | scaffoldState = scaffoldState, 137 | topBar = { 138 | HomeTopAppBar( 139 | searchQuery = homeViewState.searchState.query, 140 | onSearchChange = onSearchChange, 141 | showSearchView = homeViewState.searchState.enabled, 142 | closeSearchView = closeSearchView, 143 | openSearchView = openSearchView, 144 | focusRequest = focusRequest, 145 | keyboardController = keyboardController, 146 | actionSearch = actionSearch, 147 | ) 148 | }, 149 | modifier = modifier, 150 | backgroundColor = Color.Transparent, 151 | content = { paddingValues -> 152 | Box(modifier = Modifier.padding(paddingValues)) { 153 | val contentModifier = Modifier 154 | .fillMaxSize() 155 | .padding(all = 18.dp) 156 | 157 | ExceptionHandleView( 158 | state = homeViewState, 159 | snackBarHostState = scaffoldState.snackbarHostState, 160 | ) { 161 | if (homeViewState.currentWeather != null) { 162 | CurrentWeatherContent( 163 | modifier = contentModifier, 164 | currentWeather = homeViewState.currentWeather, 165 | weatherState = homeViewState.weatherState, 166 | weatherLazyListState = weatherLazyListState, 167 | weatherIndex = homeViewState.weatherState.weatherIndex, 168 | onWeatherIndexChanged = onWeatherIndexChanged, 169 | actionNext7Days = actionNext7Days, 170 | ) 171 | } 172 | } 173 | } 174 | }, 175 | ) 176 | } 177 | } 178 | } 179 | 180 | @Composable 181 | fun CurrentWeatherContent( 182 | modifier: Modifier = Modifier, 183 | currentWeather: CurrentWeatherViewDataModel, 184 | weatherState: WeatherState = WeatherState.Today(), 185 | weatherLazyListState: Map, 186 | weatherIndex: WeatherIndex = WeatherIndex.Today, 187 | onWeatherIndexChanged: ((WeatherIndex) -> Unit)? = null, 188 | actionNext7Days: (() -> Unit)? = null, 189 | ) { 190 | Column(modifier = modifier) { 191 | Row( 192 | modifier = Modifier 193 | .padding(top = 2.dp) 194 | .height(78.dp), 195 | ) { 196 | Column(modifier = Modifier.weight(1f)) { 197 | Text( 198 | text = currentWeather.currentTime, 199 | modifier = Modifier, 200 | style = MaterialTheme.typography.body1.copy( 201 | color = MaterialTheme.colors.onPrimary, 202 | fontSize = 12.sp, 203 | ), 204 | ) 205 | 206 | Column( 207 | modifier = Modifier 208 | .wrapContentSize(align = Alignment.BottomStart), 209 | ) { 210 | Text( 211 | text = currentWeather.city, 212 | style = MaterialTheme.typography.body1.copy( 213 | color = MaterialTheme.colors.onPrimary, 214 | fontSize = 14.sp, 215 | ), 216 | ) 217 | 218 | Text( 219 | text = currentWeather.country, 220 | style = MaterialTheme.typography.h5.copy( 221 | color = MaterialTheme.colors.onPrimary, 222 | fontSize = 24.sp, 223 | ), 224 | ) 225 | } 226 | } 227 | 228 | Row { 229 | Text( 230 | text = currentWeather.currentTemp, 231 | modifier = Modifier.fillMaxHeight().padding(top = 2.dp), 232 | textAlign = TextAlign.Center, 233 | style = MaterialTheme.typography.h4.copy( 234 | color = MaterialTheme.colors.onPrimary, 235 | fontSize = 62.sp, 236 | fontWeight = FontWeight.Bold, 237 | ), 238 | ) 239 | 240 | Text( 241 | text = stringResource(R.string.zero), 242 | style = MaterialTheme.typography.h5.copy( 243 | color = MaterialTheme.colors.onPrimary, 244 | fontSize = 25.sp, 245 | ), 246 | ) 247 | } 248 | } 249 | 250 | Spacer(modifier = Modifier.weight(1f)) 251 | 252 | CurrentWeatherInfo( 253 | modifier = Modifier.height(200.dp) 254 | .fillMaxWidth(), 255 | currentWeather = currentWeather, 256 | ) 257 | 258 | Row(modifier = Modifier.fillMaxWidth().height(48.dp)) { 259 | HomeWeatherTabs( 260 | modifier = Modifier.weight(1f), 261 | weatherIndex = weatherIndex, 262 | onWeatherIndexChanged = onWeatherIndexChanged, 263 | ) 264 | 265 | TextButton( 266 | onClick = { 267 | actionNext7Days?.invoke() 268 | }, 269 | modifier = Modifier.wrapContentWidth(), 270 | ) { 271 | Text(text = stringResource(R.string.next_7_days), color = Color.White) 272 | 273 | Icon( 274 | imageVector = Icons.Filled.ArrowForwardIos, 275 | contentDescription = stringResource(R.string.next_7_days), 276 | modifier = Modifier.padding(start = 2.dp).width(12.dp).height(12.dp), 277 | tint = Color.White, 278 | ) 279 | } 280 | } 281 | 282 | Box( 283 | modifier = Modifier 284 | .height(124.dp), 285 | ) { 286 | val state by remember(weatherIndex) { 287 | derivedStateOf { weatherLazyListState.getValue(weatherIndex) } 288 | } 289 | 290 | LazyRow(state = state) { 291 | items(weatherState.weathers) { hourly -> 292 | HourlyWeatherItem(hourly = hourly) 293 | } 294 | } 295 | } 296 | } 297 | } 298 | 299 | @OptIn(ExperimentalCoilApi::class) 300 | @Composable 301 | fun CurrentWeatherInfo( 302 | modifier: Modifier = Modifier, 303 | currentWeather: CurrentWeatherViewDataModel, 304 | ) { 305 | Box(modifier = modifier) { 306 | Image( 307 | painter = painterResource(R.drawable.bg_current), 308 | contentDescription = null, 309 | modifier = Modifier.fillMaxSize(), 310 | contentScale = ContentScale.FillBounds, 311 | alpha = 0.2f, 312 | ) 313 | 314 | Image( 315 | painter = rememberImagePainter( 316 | data = currentWeather.currentIcon, 317 | builder = { 318 | crossfade(true) 319 | placeholder(R.drawable.ic_cloud) 320 | error(R.drawable.ic_cloud) 321 | }, 322 | ), 323 | contentDescription = null, 324 | contentScale = ContentScale.Fit, 325 | modifier = Modifier 326 | .align(Alignment.Center) 327 | .size(84.dp), 328 | ) 329 | 330 | Box( 331 | modifier = Modifier 332 | .fillMaxWidth(0.5f) 333 | .fillMaxHeight(0.5f) 334 | .align(Alignment.TopStart), 335 | ) { 336 | 337 | Column( 338 | modifier = Modifier 339 | .fillMaxSize() 340 | .wrapContentSize() 341 | .align(Alignment.Center), 342 | verticalArrangement = Arrangement.SpaceAround, 343 | ) { 344 | Text( 345 | text = stringResource(R.string.humidity), 346 | modifier = Modifier.align(Alignment.CenterHorizontally), 347 | style = MaterialTheme.typography.body1.copy( 348 | color = Color.White, 349 | fontSize = 12.sp, 350 | ), 351 | ) 352 | 353 | Text( 354 | text = currentWeather.humidity, 355 | modifier = Modifier.align(Alignment.CenterHorizontally), 356 | style = MaterialTheme.typography.body1.copy( 357 | color = Color.White, 358 | fontSize = 24.sp, 359 | ), 360 | ) 361 | } 362 | } 363 | 364 | Divider( 365 | modifier = Modifier 366 | .padding(top = 24.dp) 367 | .fillMaxHeight(fraction = 0.2f) 368 | .width(1.dp) 369 | .align(Alignment.TopCenter) 370 | .background(White60), 371 | ) 372 | 373 | Box( 374 | modifier = Modifier 375 | .fillMaxWidth(0.5f) 376 | .fillMaxHeight(0.5f) 377 | .align(Alignment.TopEnd), 378 | ) { 379 | Column( 380 | modifier = Modifier 381 | .fillMaxSize() 382 | .wrapContentSize() 383 | .align(Alignment.Center), 384 | verticalArrangement = Arrangement.SpaceBetween, 385 | ) { 386 | Text( 387 | text = stringResource(R.string.wind), 388 | modifier = Modifier.align(Alignment.CenterHorizontally), 389 | style = MaterialTheme.typography.body1.copy( 390 | color = Color.White, 391 | fontSize = 12.sp, 392 | ), 393 | ) 394 | 395 | Text( 396 | text = currentWeather.wind, 397 | modifier = Modifier.align(Alignment.CenterHorizontally), 398 | style = MaterialTheme.typography.body1.copy( 399 | color = Color.White, 400 | fontSize = 24.sp, 401 | ), 402 | ) 403 | } 404 | } 405 | 406 | Divider( 407 | modifier = Modifier 408 | .padding(bottom = 24.dp) 409 | .fillMaxHeight(fraction = 0.2f) 410 | .width(1.dp) 411 | .align(Alignment.BottomCenter) 412 | .background(White60), 413 | ) 414 | 415 | Box( 416 | modifier = Modifier 417 | .fillMaxWidth(0.5f) 418 | .fillMaxHeight(0.5f) 419 | .align(Alignment.BottomStart), 420 | ) { 421 | Column( 422 | modifier = Modifier 423 | .fillMaxSize() 424 | .wrapContentSize() 425 | .align(Alignment.Center), 426 | verticalArrangement = Arrangement.SpaceBetween, 427 | ) { 428 | Text( 429 | text = stringResource(R.string.visibility), 430 | modifier = Modifier.align(Alignment.CenterHorizontally), 431 | style = MaterialTheme.typography.body1.copy( 432 | color = Color.White, 433 | fontSize = 12.sp, 434 | ), 435 | ) 436 | 437 | Text( 438 | text = currentWeather.visibility, 439 | modifier = Modifier.align(Alignment.CenterHorizontally), 440 | style = MaterialTheme.typography.body1.copy( 441 | color = Color.White, 442 | fontSize = 24.sp, 443 | ), 444 | ) 445 | } 446 | } 447 | 448 | Divider( 449 | modifier = Modifier 450 | .padding(start = 32.dp) 451 | .fillMaxWidth(fraction = 0.28f) 452 | .height(1.dp) 453 | .align(Alignment.CenterStart) 454 | .background(White60), 455 | ) 456 | 457 | Box( 458 | modifier = Modifier 459 | .fillMaxWidth(0.5f) 460 | .fillMaxHeight(0.5f) 461 | .align(Alignment.BottomEnd), 462 | ) { 463 | Column( 464 | modifier = Modifier 465 | .fillMaxSize() 466 | .wrapContentSize() 467 | .align(Alignment.Center), 468 | verticalArrangement = Arrangement.SpaceBetween, 469 | ) { 470 | Text( 471 | text = stringResource(R.string.real_feel), 472 | modifier = Modifier.align(Alignment.CenterHorizontally), 473 | style = MaterialTheme.typography.body1.copy( 474 | color = Color.White, 475 | fontSize = 12.sp, 476 | ), 477 | ) 478 | 479 | Text( 480 | text = currentWeather.realFeel, 481 | modifier = Modifier.align(Alignment.CenterHorizontally), 482 | style = MaterialTheme.typography.body1.copy( 483 | color = Color.White, 484 | fontSize = 24.sp, 485 | ), 486 | ) 487 | } 488 | } 489 | 490 | Divider( 491 | modifier = Modifier 492 | .padding(end = 32.dp) 493 | .fillMaxWidth(fraction = 0.28f) 494 | .height(1.dp) 495 | .align(Alignment.CenterEnd) 496 | .background(White60), 497 | ) 498 | } 499 | } 500 | 501 | /** 502 | * More weathers 503 | */ 504 | @Composable 505 | fun HomeWeatherTabs( 506 | tabsContent: Array = stringArrayResource(R.array.days), 507 | modifier: Modifier, 508 | weatherIndex: WeatherIndex = WeatherIndex.Today, 509 | onWeatherIndexChanged: ((WeatherIndex) -> Unit)? = null, 510 | ) { 511 | val tabRowHeight = 6.dp 512 | TabRow( 513 | modifier = modifier, 514 | selectedTabIndex = weatherIndex.value(), 515 | backgroundColor = Color.Transparent, 516 | divider = { 517 | TabRowDefaults.Divider( 518 | thickness = tabRowHeight, 519 | color = Color.Transparent, 520 | ) 521 | }, 522 | indicator = { tabPositions -> 523 | TabRowDefaults.Indicator( 524 | modifier = Modifier.customTabIndicator(tabPositions[weatherIndex.value()], tabRowHeight), 525 | height = tabRowHeight, 526 | color = Color.White, 527 | ) 528 | }, 529 | ) { 530 | tabsContent.forEachIndexed { index, text -> 531 | Tab( 532 | selected = weatherIndex.value() == index, 533 | onClick = { 534 | onWeatherIndexChanged?.invoke(WeatherIndex.from(index)) 535 | }, 536 | text = { 537 | Text( 538 | text = text, 539 | style = MaterialTheme.typography.body1, 540 | color = Color.White, 541 | ) 542 | }, 543 | ) 544 | } 545 | } 546 | } 547 | 548 | fun Modifier.customTabIndicator( 549 | currentTabPosition: TabPosition, 550 | tabRowHeight: Dp, 551 | ): Modifier = composed( 552 | inspectorInfo = debugInspectorInfo { 553 | name = "tabIndicator" 554 | value = currentTabPosition 555 | }, 556 | ) { 557 | val currentTabWidth = currentTabPosition.width 558 | val indicatorOffset by animateDpAsState( 559 | targetValue = currentTabPosition.left + currentTabWidth / 2 - tabRowHeight / 2, 560 | animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), 561 | ) 562 | fillMaxWidth() 563 | .wrapContentSize(Alignment.BottomStart) 564 | .offset(x = indicatorOffset) 565 | .clip(CircleShape) 566 | .width(tabRowHeight) 567 | } 568 | 569 | /** 570 | * TopAppBar for the Home screen 571 | */ 572 | @OptIn(ExperimentalComposeUiApi::class) 573 | @Composable 574 | private fun HomeTopAppBar( 575 | elevation: Dp = 0.dp, 576 | showSearchView: Boolean = false, 577 | searchQuery: String = "", 578 | onSearchChange: ((String) -> Unit)? = null, 579 | closeSearchView: (() -> Unit)? = null, 580 | openSearchView: (() -> Unit)? = null, 581 | actionSearch: (() -> Unit)? = null, 582 | focusRequest: FocusRequester = remember { FocusRequester() }, 583 | keyboardController: SoftwareKeyboardController? = null, 584 | ) { 585 | if (showSearchView) { 586 | Surface( 587 | modifier = Modifier.fillMaxWidth(), 588 | elevation = 8.dp, 589 | ) { 590 | Row(modifier = Modifier.fillMaxWidth()) { 591 | IconButton( 592 | onClick = { closeSearchView?.invoke() }, 593 | ) { 594 | Icon( 595 | imageVector = Icons.Filled.ArrowBack, 596 | contentDescription = stringResource(R.string.close), 597 | tint = MaterialTheme.colors.primary, 598 | ) 599 | } 600 | 601 | TextField( 602 | modifier = Modifier 603 | .focusRequester(focusRequest) 604 | .weight(1f) 605 | .background(color = Color.Transparent), 606 | value = searchQuery, 607 | shape = RoundedCornerShape(size = 0.dp), 608 | onValueChange = { value -> 609 | onSearchChange?.invoke(value) 610 | }, 611 | colors = TextFieldDefaults.textFieldColors( 612 | backgroundColor = Color.Transparent, 613 | focusedIndicatorColor = Color.Transparent, 614 | unfocusedIndicatorColor = Color.Transparent, 615 | ), 616 | placeholder = { 617 | Text( 618 | text = stringResource(R.string.search_city), 619 | ) 620 | }, 621 | trailingIcon = if (searchQuery.isNotEmpty()) { 622 | { 623 | IconButton( 624 | onClick = { onSearchChange?.invoke("") }, 625 | ) { 626 | Icon( 627 | imageVector = Icons.Filled.Close, 628 | contentDescription = stringResource(R.string.remove), 629 | tint = MaterialTheme.colors.primary, 630 | ) 631 | } 632 | } 633 | } else null, 634 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 635 | keyboardActions = KeyboardActions( 636 | onSearch = { 637 | actionSearch?.invoke() 638 | keyboardController?.hide() 639 | }, 640 | ), 641 | ) 642 | } 643 | } 644 | 645 | LaunchedEffect(keyboardController) { 646 | focusRequest.requestFocus() 647 | } 648 | } else { 649 | TopAppBar( 650 | title = { 651 | Text( 652 | text = stringResource(R.string.app_name), 653 | modifier = Modifier 654 | .fillMaxSize() 655 | .padding(bottom = 4.dp, top = 12.dp), 656 | style = MaterialTheme.typography.h6.copy(color = MaterialTheme.colors.onPrimary), 657 | textAlign = TextAlign.Center, 658 | ) 659 | }, 660 | navigationIcon = { 661 | IconButton(onClick = { closeSearchView?.invoke() }) { 662 | Icon( 663 | painter = painterResource(R.drawable.ic_menu_drawer), 664 | contentDescription = stringResource(R.string.menu), 665 | tint = MaterialTheme.colors.primary, 666 | ) 667 | } 668 | }, 669 | actions = { 670 | IconButton(onClick = { openSearchView?.invoke() }) { 671 | Icon( 672 | imageVector = Icons.Filled.Search, 673 | contentDescription = stringResource(R.string.search_city), 674 | tint = MaterialTheme.colors.primary, 675 | ) 676 | } 677 | }, 678 | backgroundColor = Color.Transparent, 679 | elevation = elevation, 680 | ) 681 | } 682 | } 683 | 684 | @OptIn(ExperimentalComposeUiApi::class) 685 | @Preview 686 | @Composable 687 | fun HomeTopAppBarPreview() { 688 | WeatherTheme { 689 | Surface { 690 | HomeTopAppBar() 691 | } 692 | } 693 | } 694 | 695 | @Preview 696 | @Composable 697 | fun CurrentWeatherInfoPreview() { 698 | WeatherTheme { 699 | Surface { 700 | CurrentWeatherInfo( 701 | modifier = Modifier.height(200.dp), 702 | currentWeather = createCurrentWeather(), 703 | ) 704 | } 705 | } 706 | } 707 | --------------------------------------------------------------------------------