├── 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 |
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 | 
20 |
21 | ### Error-Flow
22 | All `Exceptions` from `API`, `Local` or `Invalid UseCase` will mapper to `BaseException`
23 |
24 | 
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 |
--------------------------------------------------------------------------------