├── weatherapp ├── app │ ├── .gitignore │ ├── src │ │ ├── main │ │ │ ├── assets │ │ │ │ ├── koin.properties │ │ │ │ └── json │ │ │ │ │ ├── geocode_berlin.json │ │ │ │ │ ├── geocode_paris.json │ │ │ │ │ ├── geocode_madrid.json │ │ │ │ │ ├── geocode_toulouse.json │ │ │ │ │ ├── geocode_london.json │ │ │ │ │ ├── weather_madrid.json │ │ │ │ │ └── weather_toulouse.json │ │ │ ├── res │ │ │ │ ├── menu │ │ │ │ │ └── menu_main.xml │ │ │ │ ├── font │ │ │ │ │ ├── indieflower.ttf │ │ │ │ │ └── opensans_regular.ttf │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── values-w820dp │ │ │ │ │ └── dimens.xml │ │ │ │ ├── anim │ │ │ │ │ └── infinite_blinking_animation.xml │ │ │ │ ├── values │ │ │ │ │ ├── strings.xml │ │ │ │ │ ├── dimens.xml │ │ │ │ │ ├── colors.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── drawable │ │ │ │ │ ├── ic_edit_black_24dp.xml │ │ │ │ │ ├── ic_location_on_black_24dp.xml │ │ │ │ │ ├── ic_edit_location_black_24dp.xml │ │ │ │ │ └── ic_search_black_24dp.xml │ │ │ │ └── layout │ │ │ │ │ ├── fragment_result_list.xml │ │ │ │ │ ├── activity_splash.xml │ │ │ │ │ ├── item_weather.xml │ │ │ │ │ ├── activity_result.xml │ │ │ │ │ ├── fragment_result_header.xml │ │ │ │ │ └── activity_detail.xml │ │ │ ├── kotlin │ │ │ │ └── fr │ │ │ │ │ └── ekito │ │ │ │ │ └── myweatherapp │ │ │ │ │ ├── util │ │ │ │ │ ├── mvp │ │ │ │ │ │ ├── BaseView.kt │ │ │ │ │ │ ├── BasePresenter.kt │ │ │ │ │ │ └── RxPresenter.kt │ │ │ │ │ ├── rx │ │ │ │ │ │ ├── SchedulerProvider.kt │ │ │ │ │ │ ├── ApplicationSchedulerProvider.kt │ │ │ │ │ │ └── RxWithExt.kt │ │ │ │ │ ├── android │ │ │ │ │ │ └── FragmentActivityExt.kt │ │ │ │ │ └── mvvm │ │ │ │ │ │ ├── RxViewModel.kt │ │ │ │ │ │ └── SingleLiveEvent.kt │ │ │ │ │ ├── domain │ │ │ │ │ ├── ext │ │ │ │ │ │ ├── GeocodeExt.kt │ │ │ │ │ │ └── WeatherExt.kt │ │ │ │ │ ├── entity │ │ │ │ │ │ ├── DailyForecast.kt │ │ │ │ │ │ └── WeatherCode.kt │ │ │ │ │ └── repository │ │ │ │ │ │ └── DailyForecastRepository.kt │ │ │ │ │ ├── data │ │ │ │ │ ├── local │ │ │ │ │ │ ├── JsonReader.kt │ │ │ │ │ │ ├── JavaReader.kt │ │ │ │ │ │ ├── AndroidJsonReader.kt │ │ │ │ │ │ ├── BaseReader.kt │ │ │ │ │ │ └── FileDataSource.kt │ │ │ │ │ ├── WeatherDataSource.kt │ │ │ │ │ └── json │ │ │ │ │ │ ├── Geocode.kt │ │ │ │ │ │ └── Weather.kt │ │ │ │ │ ├── view │ │ │ │ │ ├── splash │ │ │ │ │ │ ├── SplashContract.kt │ │ │ │ │ │ ├── SplashPresenter.kt │ │ │ │ │ │ └── SplashActivity.kt │ │ │ │ │ ├── detail │ │ │ │ │ │ ├── DetailContract.kt │ │ │ │ │ │ ├── DetailPresenter.kt │ │ │ │ │ │ └── DetailActivity.kt │ │ │ │ │ └── weather │ │ │ │ │ │ ├── WeatherListContract.kt │ │ │ │ │ │ ├── list │ │ │ │ │ │ ├── WeatherItem.kt │ │ │ │ │ │ └── WeatherListAdapter.kt │ │ │ │ │ │ ├── WeatherHeaderContract.kt │ │ │ │ │ │ ├── WeatherListPresenter.kt │ │ │ │ │ │ ├── WeatherHeaderPresenter.kt │ │ │ │ │ │ ├── WeatherActivity.kt │ │ │ │ │ │ ├── WeatherListFragment.kt │ │ │ │ │ │ └── WeatherHeaderFragment.kt │ │ │ │ │ ├── MainApplication.kt │ │ │ │ │ └── di │ │ │ │ │ ├── local_datasource_module.kt │ │ │ │ │ ├── remote_datasource_module.kt │ │ │ │ │ └── app_module.kt │ │ │ └── AndroidManifest.xml │ │ ├── test │ │ │ ├── resources │ │ │ │ ├── koin.properties │ │ │ │ ├── mockito-extensions │ │ │ │ │ └── org.mockito.plugins.MockMaker │ │ │ │ └── json │ │ │ │ │ ├── geocode_berlin.json │ │ │ │ │ ├── geocode_paris.json │ │ │ │ │ ├── geocode_madrid.json │ │ │ │ │ ├── geocode_toulouse.json │ │ │ │ │ ├── geocode_london.json │ │ │ │ │ ├── weather_madrid.json │ │ │ │ │ └── weather_toulouse.json │ │ │ └── java │ │ │ │ └── fr │ │ │ │ └── ekito │ │ │ │ └── myweatherapp │ │ │ │ ├── util │ │ │ │ ├── TestSchedulerProvider.kt │ │ │ │ └── MockitoKotlinHelpers.kt │ │ │ │ ├── di │ │ │ │ └── test_modules.kt │ │ │ │ ├── ModuleCheckTest.kt │ │ │ │ ├── mock │ │ │ │ ├── MockedData.kt │ │ │ │ ├── mvp │ │ │ │ │ ├── DetailPresenterMockTest.kt │ │ │ │ │ ├── SplashPresenterMockTest.kt │ │ │ │ │ ├── WeatherListPresenterMockTest.kt │ │ │ │ │ └── WeatherHeaderPresenterMockTest.kt │ │ │ │ └── mvvm │ │ │ │ │ ├── WeatherListViewModelMockTest.kt │ │ │ │ │ ├── SplashViewModelMockTest.kt │ │ │ │ │ ├── DetailViewModelMockTest.kt │ │ │ │ │ └── WeatherHeaderViewModelMockTest.kt │ │ │ │ └── integration │ │ │ │ └── WeatherRepositoryTest.kt │ │ └── androidTest │ │ │ └── java │ │ │ └── fr │ │ │ └── ekito │ │ │ └── myweatherapp │ │ │ ├── room_test_modules.kt │ │ │ ├── WeatherRepositoryTest.kt │ │ │ └── WeatherDAOTest.kt │ └── build.gradle ├── settings.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── build.gradle ├── versions.gradle ├── gradle.properties ├── gradlew.bat ├── gradlew └── LICENSE ├── img └── workshop_logo.png ├── .gitignore └── README.md /weatherapp/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /weatherapp/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/koin.properties: -------------------------------------------------------------------------------- 1 | SERVER_URL=https://my-weather-api.herokuapp.com/ -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/koin.properties: -------------------------------------------------------------------------------- 1 | SERVER_URL=https://my-weather-api.herokuapp.com/ -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /img/workshop_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/img/workshop_logo.png -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/menu/menu_main.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /weatherapp/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/font/indieflower.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/font/indieflower.ttf -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/font/opensans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/font/opensans_regular.ttf -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.apk 2 | .idea/ 3 | *.iml 4 | .gradle 5 | local.properties 6 | /.idea/workspace.xml 7 | /.idea/libraries 8 | .DS_Store 9 | /build 10 | /captures 11 | .externalNativeBuild 12 | build/ 13 | 14 | 15 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KotlinAndroidWorkshops/2018-android-architecture-components-workshop/HEAD/weatherapp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Architecture & Architecture Components 2 | 3 | ![](./img/workshop_logo.png) 4 | 5 | ## Hands-on 6 | 7 | Go to [Hands-on page](https://github.com/Ekito/2018-android-architecture-components-workshop/wiki) hosted on wiki 8 | 9 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/mvp/BaseView.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.mvp 2 | 3 | /** 4 | * View 5 | */ 6 | interface BaseView> { 7 | 8 | fun showError(error: Throwable) 9 | 10 | val presenter: T 11 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/mvp/BasePresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.mvp 2 | 3 | /** 4 | * Presenter 5 | */ 6 | interface BasePresenter { 7 | 8 | fun subscribe(view: V) 9 | 10 | fun unSubscribe() 11 | 12 | var view : V? 13 | 14 | } -------------------------------------------------------------------------------- /weatherapp/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Nov 11 01:00:28 CET 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.5-all.zip 7 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/rx/SchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.rx 2 | 3 | import io.reactivex.Scheduler 4 | 5 | /** 6 | * Rx Scheduler Provider 7 | */ 8 | interface SchedulerProvider { 9 | fun io(): Scheduler 10 | fun ui(): Scheduler 11 | fun computation(): Scheduler 12 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/domain/ext/GeocodeExt.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.domain.ext 2 | 3 | import fr.ekito.myweatherapp.data.json.Geocode 4 | import fr.ekito.myweatherapp.data.json.Location 5 | 6 | /** 7 | * Extract Location from Geocode 8 | */ 9 | fun Geocode.getLocation(): Location? = results.firstOrNull()?.geometry?.location -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/values-w820dp/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 64dp 6 | 7 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/android/FragmentActivityExt.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UNCHECKED_CAST") 2 | 3 | package fr.ekito.myweatherapp.util.android 4 | 5 | import android.support.v4.app.FragmentActivity 6 | 7 | /** 8 | * Retrieve argument from Activity intent 9 | */ 10 | fun FragmentActivity.argument(key: String) = 11 | lazy { intent.extras[key] as? T ?: error("Intent Argument $key is missing") } 12 | 13 | -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/util/TestSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util 2 | 3 | import io.reactivex.schedulers.Schedulers 4 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 5 | 6 | class TestSchedulerProvider : SchedulerProvider { 7 | override fun io() = Schedulers.trampoline() 8 | 9 | override fun ui() = Schedulers.trampoline() 10 | 11 | override fun computation() = Schedulers.trampoline() 12 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/anim/infinite_blinking_animation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/local/JsonReader.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.local 2 | 3 | import fr.ekito.myweatherapp.data.json.Geocode 4 | import fr.ekito.myweatherapp.data.json.Location 5 | import fr.ekito.myweatherapp.data.json.Weather 6 | 7 | /** 8 | * Json reader 9 | */ 10 | interface JsonReader { 11 | fun getAllLocations(): Map 12 | fun getWeather(name: String): Weather 13 | fun getGeocode(name: String): Geocode 14 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/rx/ApplicationSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.rx 2 | 3 | import io.reactivex.android.schedulers.AndroidSchedulers 4 | import io.reactivex.schedulers.Schedulers 5 | 6 | /** 7 | * Application providers 8 | */ 9 | class ApplicationSchedulerProvider : SchedulerProvider { 10 | override fun io() = Schedulers.io() 11 | 12 | override fun ui() = AndroidSchedulers.mainThread() 13 | 14 | override fun computation() = Schedulers.computation() 15 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | My Weather App 3 | Error while loading weather 4 | Enter your location 5 | e.g: Paris 6 | SEARCH 7 | CANCEL 8 | Retry 9 | Loading location 10 | 11 | -------------------------------------------------------------------------------- /weatherapp/app/src/androidTest/java/fr/ekito/myweatherapp/room_test_modules.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp 2 | // 3 | //import android.arch.persistence.room.Room 4 | //import fr.ekito.myweatherapp.data.room.WeatherDatabase 5 | //import org.koin.dsl.module.module 6 | // 7 | //// Room In memroy database 8 | //val roomTestModule = module(override = true) { 9 | // single { 10 | // Room.inMemoryDatabaseBuilder(get(), WeatherDatabase::class.java) 11 | // .allowMainThreadQueries() 12 | // .build() 13 | // } 14 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/splash/SplashContract.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.splash 2 | 3 | import fr.ekito.myweatherapp.util.mvp.BasePresenter 4 | import fr.ekito.myweatherapp.util.mvp.BaseView 5 | 6 | /** 7 | * Weather MVP Contract 8 | */ 9 | interface SplashContract { 10 | interface View : BaseView { 11 | fun showIsLoaded() 12 | fun showIsLoading() 13 | } 14 | 15 | interface Presenter : BasePresenter { 16 | fun getLastWeather() 17 | } 18 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/drawable/ic_edit_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /weatherapp/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "versions.gradle" 2 | 3 | buildscript { 4 | apply from: "versions.gradle" 5 | 6 | repositories { 7 | google() 8 | jcenter() 9 | } 10 | dependencies { 11 | classpath "com.android.tools.build:gradle:$android_plugin_version" 12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 13 | } 14 | } 15 | 16 | allprojects { 17 | repositories { 18 | // mavenLocal() 19 | jcenter() 20 | google() 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/detail/DetailContract.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.detail 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | import fr.ekito.myweatherapp.util.mvp.BasePresenter 5 | import fr.ekito.myweatherapp.util.mvp.BaseView 6 | 7 | interface DetailContract { 8 | interface View : BaseView { 9 | fun showDetail(weather: DailyForecast) 10 | } 11 | 12 | interface Presenter : BasePresenter { 13 | fun getDetail(id: String) 14 | } 15 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/drawable/ic_location_on_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherListContract.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import fr.ekito.myweatherapp.util.mvp.BasePresenter 4 | import fr.ekito.myweatherapp.util.mvp.BaseView 5 | import fr.ekito.myweatherapp.view.weather.list.WeatherItem 6 | 7 | interface WeatherListContract { 8 | interface View : BaseView { 9 | fun showWeatherItemList(newList: List) 10 | } 11 | 12 | interface Presenter : BasePresenter { 13 | fun getWeatherList() 14 | } 15 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/list/WeatherItem.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather.list 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | 5 | /** 6 | * Result Item - Display in ResultList View Adapter 7 | */ 8 | data class WeatherItem(val id: String, val day: String, val icon: String, val color : Int) { 9 | companion object { 10 | fun from(weather: DailyForecast) = WeatherItem( 11 | weather.id, 12 | weather.day, 13 | weather.icon, 14 | weather.colorCode 15 | ) 16 | } 17 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/drawable/ic_edit_location_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /weatherapp/versions.gradle: -------------------------------------------------------------------------------- 1 | ext { 2 | // Kotlin 3 | kotlin_version = '1.2.71' 4 | koin_version = '1.0.1' 5 | 6 | // Android Sdk and tools 7 | target_sdk_version = 27 8 | compile_sdk_version = 27 9 | android_plugin_version = "3.1.4" 10 | build_tools_version = '27.0.3' 11 | 12 | // Android Components 13 | support_lib_version = '27.1.1' 14 | android_arch_version = "1.1.1" 15 | android_room_version = "1.1.1" 16 | 17 | // Other frameworks 18 | okhttp_version = '3.8.1' 19 | retrofit_version = '2.2.0' 20 | rxjava_version = '2.1.7' 21 | anko_version='0.10.4' 22 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/drawable/ic_search_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/layout/fragment_result_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/rx/RxWithExt.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.rx 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Single 5 | 6 | 7 | /** 8 | * Use SchedulerProvider configuration for Observable 9 | */ 10 | fun Completable.with(schedulerProvider: SchedulerProvider): Completable = 11 | this.observeOn(schedulerProvider.ui()).subscribeOn(schedulerProvider.io()) 12 | 13 | /** 14 | * Use SchedulerProvider configuration for Single 15 | */ 16 | fun Single.with(schedulerProvider: SchedulerProvider): Single = 17 | this.observeOn(schedulerProvider.ui()).subscribeOn(schedulerProvider.io()) -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp 2 | 3 | import android.app.Application 4 | import com.joanzapata.iconify.Iconify 5 | import com.joanzapata.iconify.fonts.WeathericonsModule 6 | import fr.ekito.myweatherapp.di.offlineWeatherApp 7 | import org.koin.android.ext.android.startKoin 8 | 9 | /** 10 | * Main Application 11 | */ 12 | class MainApplication : Application() { 13 | 14 | override fun onCreate() { 15 | super.onCreate() 16 | 17 | // start Koin context 18 | startKoin(this, offlineWeatherApp) 19 | 20 | Iconify 21 | .with(WeathericonsModule()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/di/local_datasource_module.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.di 2 | 3 | import fr.ekito.myweatherapp.data.WeatherDataSource 4 | import fr.ekito.myweatherapp.data.local.AndroidJsonReader 5 | import fr.ekito.myweatherapp.data.local.FileDataSource 6 | import fr.ekito.myweatherapp.data.local.JsonReader 7 | import org.koin.android.ext.koin.androidApplication 8 | import org.koin.dsl.module.module 9 | 10 | /** 11 | * Local Json Files Datasource 12 | */ 13 | val localAndroidDataSourceModule = module { 14 | single { AndroidJsonReader(androidApplication()) } 15 | single { FileDataSource(get(), true) } 16 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherHeaderContract.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | import fr.ekito.myweatherapp.util.mvp.BasePresenter 5 | import fr.ekito.myweatherapp.util.mvp.BaseView 6 | 7 | interface WeatherHeaderContract { 8 | interface View : BaseView { 9 | fun showWeather(location : String, weather: DailyForecast) 10 | fun showLocationSearchSucceed(location: String) 11 | fun showLocationSearchFailed(location: String, error: Throwable) 12 | } 13 | 14 | interface Presenter : BasePresenter { 15 | fun getWeatherOfTheDay() 16 | fun loadNewLocation(location: String) 17 | } 18 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/mvvm/RxViewModel.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.mvvm 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.support.annotation.CallSuper 5 | import io.reactivex.disposables.CompositeDisposable 6 | import io.reactivex.disposables.Disposable 7 | 8 | /** 9 | * ViewModel for Rx Jobs 10 | * 11 | * launch() - launch a Rx request 12 | * clear all request on stop 13 | */ 14 | abstract class RxViewModel : ViewModel() { 15 | 16 | private val disposables = CompositeDisposable() 17 | 18 | fun launch(job: () -> Disposable) { 19 | disposables.add(job()) 20 | } 21 | 22 | @CallSuper 23 | override fun onCleared() { 24 | super.onCleared() 25 | disposables.clear() 26 | } 27 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/WeatherDataSource.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data 2 | 3 | import fr.ekito.myweatherapp.data.json.Geocode 4 | import fr.ekito.myweatherapp.data.json.Weather 5 | import io.reactivex.Single 6 | import retrofit2.http.GET 7 | import retrofit2.http.Headers 8 | import retrofit2.http.Query 9 | 10 | /** 11 | * Weather datasource - Retrofit tagged 12 | */ 13 | interface WeatherDataSource { 14 | 15 | @GET("/geocode") 16 | @Headers("Content-type: application/json") 17 | fun geocode(@Query("location") address: String): Single 18 | 19 | @GET("/weather") 20 | @Headers("Content-type: application/json") 21 | fun weather(@Query("lat") lat: Double?, @Query("lon") lon: Double?, @Query("lang") lang: String): Single 22 | 23 | } 24 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/local/JavaReader.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.local 2 | 3 | import java.io.File 4 | 5 | /** 6 | * Java Json reader for Tests 7 | */ 8 | class JavaReader : BaseReader() { 9 | 10 | private fun basePath(): String? { 11 | val classLoader: ClassLoader = JavaReader::class.java.classLoader 12 | val path: String? = classLoader.getResource("json/")?.path 13 | return path 14 | } 15 | 16 | override fun getAllFiles(): List { 17 | return basePath()?.let { 18 | val list = File(it).list() 19 | list.toList() 20 | }!! 21 | } 22 | 23 | override fun readJsonFile(jsonFile: String): String = 24 | File("${basePath()}/$jsonFile").readLines().joinToString(separator = "\n") 25 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/mvp/RxPresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.mvp 2 | 3 | import android.support.annotation.CallSuper 4 | import io.reactivex.disposables.CompositeDisposable 5 | import io.reactivex.disposables.Disposable 6 | 7 | /** 8 | * Base Presenter feature - for Rx Jobs 9 | * 10 | * launch() - launch a Rx request 11 | * clear all request on stop 12 | */ 13 | abstract class RxPresenter : BasePresenter { 14 | 15 | private val disposables = CompositeDisposable() 16 | 17 | fun launch(job: () -> Disposable) { 18 | disposables.add(job()) 19 | } 20 | 21 | override fun subscribe(view: V) { 22 | this.view = view 23 | } 24 | 25 | @CallSuper 26 | override fun unSubscribe() { 27 | disposables.clear() 28 | view = null 29 | } 30 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/local/AndroidJsonReader.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.local 2 | 3 | import android.app.Application 4 | import java.io.BufferedReader 5 | import java.io.InputStreamReader 6 | 7 | /** 8 | * Read Json File from assets/json 9 | */ 10 | class AndroidJsonReader(val application: Application) : BaseReader() { 11 | 12 | override fun getAllFiles(): List = application.assets.list("json").toList() 13 | 14 | override fun readJsonFile(jsonFile: String): String { 15 | val buf = StringBuilder() 16 | val json = application.assets.open("json/" + jsonFile) 17 | BufferedReader(InputStreamReader(json, "UTF-8")) 18 | .use { 19 | val list = it.lineSequence().toList() 20 | buf.append(list.joinToString("\n")) 21 | } 22 | 23 | return buf.toString() 24 | } 25 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 16dp 5 | 16dp 6 | 7 | 16dp 8 | 9 | 8dp 10 | 2dp 11 | 12 | 44dp 13 | 44dp 14 | 15 | 13sp 16 | 17 | 14sp 18 | 16sp 19 | 18sp 20 | 19sp 21 | 24sp 22 | 34sp 23 | 24 | 72dp 25 | 26 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/detail/DetailPresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.detail 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.util.mvp.RxPresenter 5 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 6 | import fr.ekito.myweatherapp.util.rx.with 7 | 8 | class DetailPresenter( 9 | private val dailyForecastRepository: DailyForecastRepository, 10 | private val schedulerProvider: SchedulerProvider 11 | ) : RxPresenter(), DetailContract.Presenter { 12 | 13 | override var view: DetailContract.View? = null 14 | 15 | override fun getDetail(id: String) { 16 | launch { 17 | dailyForecastRepository.getWeatherDetail(id).with(schedulerProvider).subscribe( 18 | { detail -> 19 | view?.showDetail(detail) 20 | }, { error -> view?.showError(error) }) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/di/test_modules.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.di 2 | 3 | import fr.ekito.myweatherapp.data.WeatherDataSource 4 | import fr.ekito.myweatherapp.data.local.FileDataSource 5 | import fr.ekito.myweatherapp.data.local.JavaReader 6 | import fr.ekito.myweatherapp.data.local.JsonReader 7 | import fr.ekito.myweatherapp.util.TestSchedulerProvider 8 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 9 | import org.koin.dsl.module.module 10 | 11 | 12 | /** 13 | * Local java json repository 14 | */ 15 | val localJavaDatasourceModule = module(override = true) { 16 | single { JavaReader() } 17 | single { FileDataSource(get(), false) } 18 | } 19 | 20 | /** 21 | * Test Rx 22 | */ 23 | val testRxModule = module(override = true) { 24 | // provided components 25 | single { TestSchedulerProvider() } 26 | } 27 | 28 | val testWeatherApp = offlineWeatherApp + testRxModule + localJavaDatasourceModule -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/splash/SplashPresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.splash 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.util.mvp.RxPresenter 5 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 6 | import fr.ekito.myweatherapp.util.rx.with 7 | 8 | class SplashPresenter( 9 | private val dailyForecastRepository: DailyForecastRepository, 10 | private val schedulerProvider: SchedulerProvider 11 | ) : RxPresenter(), SplashContract.Presenter { 12 | 13 | override var view: SplashContract.View? = null 14 | 15 | override fun getLastWeather() { 16 | view?.showIsLoading() 17 | launch { 18 | dailyForecastRepository.getWeather().with(schedulerProvider) 19 | .toCompletable() 20 | .subscribe({ view?.showIsLoaded() }, 21 | { error -> view?.showError(error) }) 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/domain/ext/WeatherExt.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.domain.ext 2 | 3 | import fr.ekito.myweatherapp.data.json.Forecastday 4 | import fr.ekito.myweatherapp.data.json.Forecastday_ 5 | import fr.ekito.myweatherapp.data.json.Weather 6 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 7 | import fr.ekito.myweatherapp.domain.entity.PREFIX 8 | 9 | /** 10 | * Extract Weather DailyForecast list from Weather 11 | */ 12 | fun Weather.getDailyForecasts(location: String): List { 13 | val txtList: List = forecast?.txtForecast?.forecastday.orEmpty() 14 | return forecast?.simpleforecast?.forecastday.orEmpty() 15 | .map { f: Forecastday_ -> 16 | DailyForecast.from( 17 | location, 18 | f 19 | ) 20 | } 21 | .map { f -> 22 | f.copy( 23 | fullText = txtList.firstOrNull { it.title ?: "" == f.day }?.fcttext ?: "" 24 | ) 25 | } 26 | .filter { f -> !f.icon.startsWith(PREFIX) } 27 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #2196F3 4 | #1976D2 5 | #BBDEFB 6 | #ffa000 7 | #ffa000 8 | 9 | #212121 10 | #777777 11 | #212121 12 | @color/accent_dark 13 | #BDBDBD 14 | 15 | #fa315b 16 | #e4e3e4 17 | #F5F5F5 18 | #FAFAFA 19 | #FFFFFF 20 | 21 | #455a64 22 | #42a5f5 23 | #26a69a 24 | #ffb74d 25 | #e57373 26 | 27 | -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/ModuleCheckTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import fr.ekito.myweatherapp.di.offlineWeatherApp 6 | import fr.ekito.myweatherapp.di.onlineWeatherApp 7 | import fr.ekito.myweatherapp.di.testWeatherApp 8 | import org.junit.After 9 | import org.junit.Test 10 | import org.koin.dsl.module.module 11 | import org.koin.test.KoinTest 12 | import org.koin.test.checkModules 13 | import org.mockito.Mockito.mock 14 | 15 | /** 16 | * Test Koin modules 17 | */ 18 | class ModuleCheckTest : KoinTest { 19 | 20 | val mockedAndroidContext = module { 21 | single { mock(Application::class.java) } 22 | } 23 | 24 | @After 25 | fun after() { 26 | } 27 | 28 | @Test 29 | fun testRemoteConfiguration() { 30 | checkModules(onlineWeatherApp) 31 | } 32 | 33 | @Test 34 | fun testLocalConfiguration() { 35 | checkModules(offlineWeatherApp + mockedAndroidContext) 36 | } 37 | 38 | @Test 39 | fun testTestConfiguration() { 40 | checkModules(testWeatherApp + mockedAndroidContext) 41 | } 42 | } -------------------------------------------------------------------------------- /weatherapp/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | #Enable daemon 10 | org.gradle.daemon=true 11 | 12 | # Specifies the JVM arguments used for the daemon process. 13 | # The setting is particularly useful for tweaking memory settings. 14 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 15 | org.gradle.jvmargs=-Xmx3072m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 16 | 17 | # When configured, Gradle will run in incubating parallel mode. 18 | # This option should only be used with decoupled projects. More details, visit 19 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 20 | org.gradle.parallel=true 21 | 22 | # Disable configure on demand. 23 | org.gradle.configureondemand=false 24 | 25 | # Android Studio 2.2+ 26 | android.enableBuildCache=true 27 | 28 | # Kotlin 29 | kotlin.incremental=true 30 | 31 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/json/Geocode.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.json 2 | 3 | import java.util.* 4 | 5 | data class AddressComponent( 6 | val long_name: String? = null, 7 | val short_name: String? = null, 8 | val types: List = arrayListOf() 9 | ) 10 | 11 | data class Geocode( 12 | val results: List = ArrayList(), 13 | val status: String? = null 14 | ) 15 | 16 | data class Geometry( 17 | val location: Location? = null, 18 | val location_type: String? = null, 19 | val viewport: Viewport? = null 20 | ) 21 | 22 | data class Location( 23 | val lat: Double? = null, 24 | val lng: Double? = null 25 | ) 26 | 27 | data class Northeast( 28 | val lat: Double? = null, 29 | val lng: Double? = null 30 | ) 31 | 32 | data class Result( 33 | val address_components: List = ArrayList(), 34 | val formatted_address: String? = null, 35 | val geometry: Geometry? = null, 36 | val placeId: String? = null, 37 | val types: List = ArrayList() 38 | ) 39 | 40 | data class Southwest( 41 | val lat: Double? = null, 42 | val lng: Double? = null 43 | ) 44 | 45 | class Viewport( 46 | val northeast: Northeast? = null, 47 | val southwest: Southwest? = null 48 | ) -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/MockedData.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.mock 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | import fr.ekito.myweatherapp.domain.entity.Humidity 5 | import fr.ekito.myweatherapp.domain.entity.Temperature 6 | import fr.ekito.myweatherapp.domain.entity.Wind 7 | 8 | /** 9 | * Mock Weather Data 10 | */ 11 | object MockedData { 12 | 13 | val location = "Location" 14 | 15 | val mockList = listOf( 16 | DailyForecast( 17 | location, 18 | "d1", 19 | "", 20 | "", 21 | "", 22 | "", 23 | Temperature("1", "1"), 24 | Wind(0, ""), 25 | Humidity(0) 26 | ), 27 | DailyForecast( 28 | location, 29 | "d2", 30 | "", 31 | "", 32 | "", 33 | "", 34 | Temperature("1", "2"), 35 | Wind(0, ""), 36 | Humidity(0) 37 | ), 38 | DailyForecast( 39 | location, 40 | "d3", 41 | "", 42 | "", 43 | "", 44 | "", 45 | Temperature("3", "4"), 46 | Wind(0, ""), 47 | Humidity(0) 48 | ) 49 | ) 50 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherListPresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.util.mvp.RxPresenter 5 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 6 | import fr.ekito.myweatherapp.util.rx.with 7 | import fr.ekito.myweatherapp.view.weather.list.WeatherItem 8 | 9 | /** 10 | * Weather Presenter 11 | */ 12 | class WeatherListPresenter( 13 | private val dailyForecastRepository: DailyForecastRepository, 14 | private val schedulerProvider: SchedulerProvider 15 | ) : RxPresenter(), WeatherListContract.Presenter { 16 | 17 | override var view: WeatherListContract.View? = null 18 | 19 | override fun getWeatherList() { 20 | launch { 21 | dailyForecastRepository.getWeather() 22 | .with(schedulerProvider) 23 | .subscribe( 24 | { weatherList -> 25 | view?.showWeatherItemList( 26 | weatherList.map { WeatherItem.from(it) }.takeLast(weatherList.size - 1) 27 | ) 28 | }, 29 | { error -> view?.showError(error) }) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/json/geocode_berlin.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Berlin", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Berlin", 17 | "types": [ 18 | "administrative_area_level_1", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "DE", 25 | "types": [ 26 | "country", 27 | "political" 28 | ] 29 | } 30 | ], 31 | "formatted_address": "Berlin, Germany", 32 | "geometry": { 33 | "location": { 34 | "lat": 52.52000659999999, 35 | "lng": 13.404954 36 | }, 37 | "location_type": "APPROXIMATE", 38 | "viewport": { 39 | "northeast": { 40 | "lat": 52.6754542, 41 | "lng": 13.7611175 42 | }, 43 | "southwest": { 44 | "lat": 52.33962959999999, 45 | "lng": 13.0911733 46 | } 47 | } 48 | }, 49 | "place_id": "ChIJAVkDPzdOqEcRcDteW0YgIQQ", 50 | "types": [ 51 | "locality", 52 | "political" 53 | ] 54 | } 55 | ] 56 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/json/geocode_berlin.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Berlin", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Berlin", 17 | "types": [ 18 | "administrative_area_level_1", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "DE", 25 | "types": [ 26 | "country", 27 | "political" 28 | ] 29 | } 30 | ], 31 | "formatted_address": "Berlin, Germany", 32 | "geometry": { 33 | "location": { 34 | "lat": 52.52000659999999, 35 | "lng": 13.404954 36 | }, 37 | "location_type": "APPROXIMATE", 38 | "viewport": { 39 | "northeast": { 40 | "lat": 52.6754542, 41 | "lng": 13.7611175 42 | }, 43 | "southwest": { 44 | "lat": 52.33962959999999, 45 | "lng": 13.0911733 46 | } 47 | } 48 | }, 49 | "place_id": "ChIJAVkDPzdOqEcRcDteW0YgIQQ", 50 | "types": [ 51 | "locality", 52 | "political" 53 | ] 54 | } 55 | ] 56 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/local/BaseReader.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.local 2 | 3 | import com.google.gson.Gson 4 | import fr.ekito.myweatherapp.data.json.Geocode 5 | import fr.ekito.myweatherapp.data.json.Location 6 | import fr.ekito.myweatherapp.data.json.Weather 7 | 8 | /** 9 | * Common parts for Json reader 10 | */ 11 | abstract class BaseReader : JsonReader { 12 | 13 | private val gson = Gson() 14 | private val geocode_prefix = "geocode_" 15 | private val weather_prefix = "weather_" 16 | private val json_file = ".json" 17 | 18 | override fun getAllLocations(): Map { 19 | val list = getAllFiles() 20 | return list.filter { it.startsWith(geocode_prefix) }.map { 21 | val name = it.replace(geocode_prefix, "").replace(".json", "") 22 | val geocode = getGeocode(name) 23 | // pair result 24 | Pair(geocode.results[0].geometry!!.location!!, name) 25 | }.toMap() // direct to map 26 | } 27 | 28 | override fun getGeocode(name: String): Geocode = 29 | gson.fromJson(readJsonFile(geocode_prefix + name + json_file), Geocode::class.java) 30 | 31 | override fun getWeather(name: String): Weather = 32 | gson.fromJson(readJsonFile(weather_prefix + name + json_file), Weather::class.java) 33 | 34 | abstract fun getAllFiles(): List 35 | 36 | abstract fun readJsonFile(jsonFile: String): String 37 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherHeaderPresenter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.util.mvp.RxPresenter 5 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 6 | import fr.ekito.myweatherapp.util.rx.with 7 | 8 | class WeatherHeaderPresenter( 9 | private val dailyForecastRepository: DailyForecastRepository, 10 | private val schedulerProvider: SchedulerProvider 11 | ) : RxPresenter(), WeatherHeaderContract.Presenter { 12 | 13 | override var view: WeatherHeaderContract.View? = null 14 | 15 | override fun loadNewLocation(location: String) { 16 | launch { 17 | dailyForecastRepository.getWeather(location).toCompletable() 18 | .with(schedulerProvider) 19 | .subscribe( 20 | { view?.showLocationSearchSucceed(location) }, 21 | { error -> view?.showLocationSearchFailed(location, error) }) 22 | } 23 | } 24 | 25 | override fun getWeatherOfTheDay() { 26 | launch { 27 | dailyForecastRepository.getWeather() 28 | .map { it.first() } 29 | .with(schedulerProvider) 30 | .subscribe( 31 | { weather -> view?.showWeather(weather.location, weather) }, 32 | { error -> view?.showError(error) }) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/json/geocode_paris.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Paris", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Paris", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Île-de-France", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "FR", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Paris, France", 40 | "geometry": { 41 | "location": { 42 | "lat": 48.856614, 43 | "lng": 2.3522219 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 48.9021449, 49 | "lng": 2.4699208 50 | }, 51 | "southwest": { 52 | "lat": 48.815573, 53 | "lng": 2.225193 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJD7fiBh9u5kcRYJSMaMOCCwQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/json/geocode_paris.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Paris", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Paris", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Île-de-France", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "FR", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Paris, France", 40 | "geometry": { 41 | "location": { 42 | "lat": 48.856614, 43 | "lng": 2.3522219 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 48.9021449, 49 | "lng": 2.4699208 50 | }, 51 | "southwest": { 52 | "lat": 48.815573, 53 | "lng": 2.225193 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJD7fiBh9u5kcRYJSMaMOCCwQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/json/geocode_madrid.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Madrid", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "M", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Community of Madrid", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "ES", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Madrid, Spain", 40 | "geometry": { 41 | "location": { 42 | "lat": 40.4167754, 43 | "lng": -3.7037902 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 40.5638447, 49 | "lng": -3.5249115 50 | }, 51 | "southwest": { 52 | "lat": 40.3120639, 53 | "lng": -3.8341618 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJgTwKgJcpQg0RaSKMYcHeNsQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/json/geocode_toulouse.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Toulouse", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Haute-Garonne", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Occitanie", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "FR", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Toulouse, France", 40 | "geometry": { 41 | "location": { 42 | "lat": 43.604652, 43 | "lng": 1.444209 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 43.6686919, 49 | "lng": 1.515354 50 | }, 51 | "southwest": { 52 | "lat": 43.532708, 53 | "lng": 1.350328 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJ_1J17G-7rhIRMBBBL5z2BgQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/json/geocode_madrid.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Madrid", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "M", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Community of Madrid", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "ES", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Madrid, Spain", 40 | "geometry": { 41 | "location": { 42 | "lat": 40.4167754, 43 | "lng": -3.7037902 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 40.5638447, 49 | "lng": -3.5249115 50 | }, 51 | "southwest": { 52 | "lat": 40.3120639, 53 | "lng": -3.8341618 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJgTwKgJcpQg0RaSKMYcHeNsQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/json/geocode_toulouse.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "Toulouse", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "Haute-Garonne", 17 | "types": [ 18 | "administrative_area_level_2", 19 | "political" 20 | ] 21 | }, 22 | { 23 | "lang_name": null, 24 | "short_name": "Occitanie", 25 | "types": [ 26 | "administrative_area_level_1", 27 | "political" 28 | ] 29 | }, 30 | { 31 | "lang_name": null, 32 | "short_name": "FR", 33 | "types": [ 34 | "country", 35 | "political" 36 | ] 37 | } 38 | ], 39 | "formatted_address": "Toulouse, France", 40 | "geometry": { 41 | "location": { 42 | "lat": 43.604652, 43 | "lng": 1.444209 44 | }, 45 | "location_type": "APPROXIMATE", 46 | "viewport": { 47 | "northeast": { 48 | "lat": 43.6686919, 49 | "lng": 1.515354 50 | }, 51 | "southwest": { 52 | "lat": 43.532708, 53 | "lng": 1.350328 54 | } 55 | } 56 | }, 57 | "place_id": "ChIJ_1J17G-7rhIRMBBBL5z2BgQ", 58 | "types": [ 59 | "locality", 60 | "political" 61 | ] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/di/remote_datasource_module.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.di 2 | 3 | import fr.ekito.myweatherapp.data.WeatherDataSource 4 | import fr.ekito.myweatherapp.di.Properties.SERVER_URL 5 | import okhttp3.OkHttpClient 6 | import okhttp3.logging.HttpLoggingInterceptor 7 | import org.koin.dsl.module.module 8 | import retrofit2.Retrofit 9 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 10 | import retrofit2.converter.gson.GsonConverterFactory 11 | import java.util.concurrent.TimeUnit 12 | 13 | /** 14 | * Remote Web Service datasource 15 | */ 16 | val remoteDataSourceModule = module { 17 | // provided web components 18 | single { createOkHttpClient() } 19 | // Fill property 20 | single { createWebService(get(), getProperty(SERVER_URL)) } 21 | } 22 | 23 | object Properties { 24 | const val SERVER_URL = "SERVER_URL" 25 | } 26 | 27 | fun createOkHttpClient(): OkHttpClient { 28 | val httpLoggingInterceptor = HttpLoggingInterceptor() 29 | httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC 30 | return OkHttpClient.Builder() 31 | .connectTimeout(60L, TimeUnit.SECONDS) 32 | .readTimeout(60L, TimeUnit.SECONDS) 33 | .addInterceptor(httpLoggingInterceptor).build() 34 | } 35 | 36 | inline fun createWebService(okHttpClient: OkHttpClient, url: String): T { 37 | val retrofit = Retrofit.Builder() 38 | .baseUrl(url) 39 | .client(okHttpClient) 40 | .addConverterFactory(GsonConverterFactory.create()) 41 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()).build() 42 | return retrofit.create(T::class.java) 43 | } 44 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 22 | 23 | 32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/assets/json/geocode_london.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "London", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "London", 17 | "types": [ 18 | "postal_town" 19 | ] 20 | }, 21 | { 22 | "lang_name": null, 23 | "short_name": "Greater London", 24 | "types": [ 25 | "administrative_area_level_2", 26 | "political" 27 | ] 28 | }, 29 | { 30 | "lang_name": null, 31 | "short_name": "England", 32 | "types": [ 33 | "administrative_area_level_1", 34 | "political" 35 | ] 36 | }, 37 | { 38 | "lang_name": null, 39 | "short_name": "GB", 40 | "types": [ 41 | "country", 42 | "political" 43 | ] 44 | } 45 | ], 46 | "formatted_address": "London, UK", 47 | "geometry": { 48 | "location": { 49 | "lat": 51.5073509, 50 | "lng": -0.1277583 51 | }, 52 | "location_type": "APPROXIMATE", 53 | "viewport": { 54 | "northeast": { 55 | "lat": 51.6723432, 56 | "lng": 0.148271 57 | }, 58 | "southwest": { 59 | "lat": 51.38494009999999, 60 | "lng": -0.3514683 61 | } 62 | } 63 | }, 64 | "place_id": "ChIJdd4hrwug2EcRmSrV3Vo6llI", 65 | "types": [ 66 | "locality", 67 | "political" 68 | ] 69 | } 70 | ] 71 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/resources/json/geocode_london.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "results": [ 4 | { 5 | "address_components": [ 6 | { 7 | "lang_name": null, 8 | "short_name": "London", 9 | "types": [ 10 | "locality", 11 | "political" 12 | ] 13 | }, 14 | { 15 | "lang_name": null, 16 | "short_name": "London", 17 | "types": [ 18 | "postal_town" 19 | ] 20 | }, 21 | { 22 | "lang_name": null, 23 | "short_name": "Greater London", 24 | "types": [ 25 | "administrative_area_level_2", 26 | "political" 27 | ] 28 | }, 29 | { 30 | "lang_name": null, 31 | "short_name": "England", 32 | "types": [ 33 | "administrative_area_level_1", 34 | "political" 35 | ] 36 | }, 37 | { 38 | "lang_name": null, 39 | "short_name": "GB", 40 | "types": [ 41 | "country", 42 | "political" 43 | ] 44 | } 45 | ], 46 | "formatted_address": "London, UK", 47 | "geometry": { 48 | "location": { 49 | "lat": 51.5073509, 50 | "lng": -0.1277583 51 | }, 52 | "location_type": "APPROXIMATE", 53 | "viewport": { 54 | "northeast": { 55 | "lat": 51.6723432, 56 | "lng": 0.148271 57 | }, 58 | "southwest": { 59 | "lat": 51.38494009999999, 60 | "lng": -0.3514683 61 | } 62 | } 63 | }, 64 | "place_id": "ChIJdd4hrwug2EcRmSrV3Vo6llI", 65 | "types": [ 66 | "locality", 67 | "political" 68 | ] 69 | } 70 | ] 71 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/integration/WeatherRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.integration 2 | 3 | import fr.ekito.myweatherapp.di.testWeatherApp 4 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 5 | import junit.framework.Assert 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.koin.standalone.StandAloneContext.startKoin 10 | import org.koin.standalone.StandAloneContext.stopKoin 11 | import org.koin.standalone.inject 12 | import org.koin.test.KoinTest 13 | 14 | class WeatherRepositoryTest : KoinTest { 15 | 16 | val repository by inject() 17 | 18 | val location = "Paris" 19 | 20 | @Before 21 | fun before() { 22 | startKoin(testWeatherApp) 23 | } 24 | 25 | @After 26 | fun after() { 27 | stopKoin() 28 | } 29 | 30 | @Test 31 | fun testGetWeatherSuccess() { 32 | repository.getWeather(location).blockingGet() 33 | val test = repository.getWeather(location).test() 34 | test.awaitTerminalEvent() 35 | test.assertComplete() 36 | } 37 | 38 | @Test 39 | fun testCachedWeather() { 40 | val l1 = repository.getWeather("Paris").blockingGet() 41 | val l2 = repository.getWeather("Toulouse").blockingGet() 42 | val l3 = repository.getWeather().blockingGet() 43 | 44 | Assert.assertEquals(l3, l2) 45 | Assert.assertNotSame(l1, l2) 46 | } 47 | 48 | @Test 49 | fun testGetDetail() { 50 | repository.getWeather(location).blockingGet() 51 | val list = repository.getWeather(location).blockingGet() 52 | val first = list.first() 53 | val test = repository.getWeatherDetail(first.id).test() 54 | test.awaitTerminalEvent() 55 | test.assertValue { it == first } 56 | } 57 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/di/app_module.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.di 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepositoryImpl 5 | import fr.ekito.myweatherapp.util.rx.ApplicationSchedulerProvider 6 | import fr.ekito.myweatherapp.util.rx.SchedulerProvider 7 | import fr.ekito.myweatherapp.view.detail.DetailContract 8 | import fr.ekito.myweatherapp.view.detail.DetailPresenter 9 | import fr.ekito.myweatherapp.view.splash.SplashContract 10 | import fr.ekito.myweatherapp.view.splash.SplashPresenter 11 | import fr.ekito.myweatherapp.view.weather.WeatherHeaderContract 12 | import fr.ekito.myweatherapp.view.weather.WeatherHeaderPresenter 13 | import fr.ekito.myweatherapp.view.weather.WeatherListContract 14 | import fr.ekito.myweatherapp.view.weather.WeatherListPresenter 15 | import org.koin.dsl.module.module 16 | 17 | /** 18 | * App Components 19 | */ 20 | val weatherAppModule = module { 21 | // Presenter for Search View 22 | factory { SplashPresenter(get(), get()) } 23 | 24 | // Presenter for ResultHeader View 25 | factory { WeatherHeaderPresenter(get(), get()) } 26 | 27 | // Presenter for ResultList View 28 | factory { WeatherListPresenter(get(), get()) } 29 | 30 | // Presenter for Detail View 31 | factory { DetailPresenter(get(), get()) } 32 | 33 | // Weather Data Repository 34 | single { DailyForecastRepositoryImpl(get()) } 35 | 36 | // Rx Schedulers 37 | single { ApplicationSchedulerProvider() } 38 | } 39 | 40 | // Gather all app modules 41 | val onlineWeatherApp = listOf(weatherAppModule, remoteDataSourceModule) 42 | val offlineWeatherApp = listOf(weatherAppModule, localAndroidDataSourceModule) -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/local/FileDataSource.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.local 2 | 3 | import fr.ekito.myweatherapp.data.WeatherDataSource 4 | import fr.ekito.myweatherapp.data.json.Geocode 5 | import fr.ekito.myweatherapp.data.json.Weather 6 | import io.reactivex.Single 7 | import java.util.concurrent.TimeUnit 8 | 9 | /** 10 | * Read json files and render weather date 11 | */ 12 | class FileDataSource(val jsonReader: JsonReader, val delayed: Boolean) : 13 | WeatherDataSource { 14 | 15 | private val cities by lazy { jsonReader.getAllLocations() } 16 | 17 | private fun isKnownCity(address: String): Boolean = cities.values.contains(address) 18 | 19 | private fun cityFromLocation(lat: Double?, lng: Double?): String { 20 | return cities.filterKeys { it.lat == lat && it.lng == lng }.values.firstOrNull() 21 | ?: DEFAULT_CITY 22 | } 23 | 24 | override fun geocode(address: String): Single { 25 | val single = Single.create { s -> 26 | val addressToLC = address.toLowerCase() 27 | val geocode = if (isKnownCity(addressToLC)) { 28 | jsonReader.getGeocode(addressToLC) 29 | } else { 30 | jsonReader.getGeocode(DEFAULT_CITY) 31 | } 32 | s.onSuccess(geocode) 33 | } 34 | return if (delayed) single.delay(1, TimeUnit.SECONDS) else single 35 | } 36 | 37 | override fun weather(lat: Double?, lon: Double?, lang: String): Single { 38 | val single = Single.create { s -> 39 | val city = cityFromLocation(lat, lon) 40 | val weather = jsonReader.getWeather(city) 41 | s.onSuccess(weather) 42 | } 43 | return if (delayed) single.delay(1, TimeUnit.SECONDS) else single 44 | } 45 | 46 | companion object { 47 | const val DEFAULT_CITY = "toulouse" 48 | } 49 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherActivity.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import android.os.Bundle 4 | import android.support.design.widget.Snackbar 5 | import android.support.v7.app.AppCompatActivity 6 | import android.util.Log 7 | import android.view.View 8 | import fr.ekito.myweatherapp.R 9 | import kotlinx.android.synthetic.main.activity_result.* 10 | import org.jetbrains.anko.clearTask 11 | import org.jetbrains.anko.clearTop 12 | import org.jetbrains.anko.intentFor 13 | import org.jetbrains.anko.newTask 14 | 15 | /** 16 | * Weather Result View 17 | */ 18 | class WeatherActivity : AppCompatActivity() { 19 | 20 | private val TAG = this::class.java.simpleName 21 | 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | setContentView(R.layout.activity_result) 25 | 26 | val weatherTitleFragment = WeatherHeaderFragment() 27 | val resultListFragment = WeatherListFragment() 28 | 29 | supportFragmentManager 30 | .beginTransaction() 31 | .replace(R.id.weather_title, weatherTitleFragment) 32 | .commit() 33 | supportFragmentManager 34 | .beginTransaction() 35 | .replace(R.id.weather_list, resultListFragment) 36 | .commit() 37 | } 38 | 39 | fun showError(error: Throwable) { 40 | Log.e(TAG, "error $error while displaying weather") 41 | weather_views.visibility = View.GONE 42 | weather_error.visibility = View.VISIBLE 43 | Snackbar.make( 44 | weather_result, 45 | "WeatherActivity got error : $error", 46 | Snackbar.LENGTH_INDEFINITE 47 | ) 48 | .setAction(R.string.retry) { 49 | startActivity(intentFor().clearTop().clearTask().newTask()) 50 | } 51 | .show() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/layout/item_weather.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 16 | 17 | 23 | 24 | 34 | 35 | 44 | 45 | 46 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /weatherapp/app/src/androidTest/java/fr/ekito/myweatherapp/WeatherRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp 2 | // 3 | //import android.support.test.runner.AndroidJUnit4 4 | //import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 5 | //import junit.framework.Assert 6 | //import org.junit.After 7 | //import org.junit.Before 8 | //import org.junit.Test 9 | //import org.junit.runner.RunWith 10 | //import org.koin.standalone.StandAloneContext.loadKoinModules 11 | //import org.koin.standalone.StandAloneContext.stopKoin 12 | //import org.koin.standalone.inject 13 | //import org.koin.test.KoinTest 14 | // 15 | //@RunWith(AndroidJUnit4::class) 16 | //class WeatherRepositoryTest : KoinTest { 17 | // 18 | // private val weatherRepository: DailyForecastRepository by inject() 19 | // 20 | // @Before() 21 | // fun before() { 22 | // loadKoinModules(roomTestModule) 23 | // } 24 | // 25 | // @After 26 | // fun after() { 27 | // stopKoin() 28 | // } 29 | // 30 | // @Test 31 | // fun testGetDefault() { 32 | // val defaultWeather = weatherRepository.getWeather().blockingGet() 33 | // val defaultWeather2 = weatherRepository.getWeather().blockingGet() 34 | // Assert.assertEquals(defaultWeather, defaultWeather2) 35 | // } 36 | // 37 | // @Test 38 | // fun testGetWeatherDetail() { 39 | // val defaultWeather = weatherRepository.getWeather().blockingGet() 40 | // 41 | // val result = defaultWeather.first() 42 | // val first = weatherRepository.getWeatherDetail(result.id).blockingGet() 43 | // Assert.assertEquals(result, first) 44 | // } 45 | // 46 | // @Test 47 | // fun testGetLatest() { 48 | // weatherRepository.getWeather().blockingGet() 49 | // weatherRepository.getWeather("London").blockingGet() 50 | // val toulouse = weatherRepository.getWeather("Toulouse").blockingGet() 51 | // val defaultWeather3 = weatherRepository.getWeather().blockingGet() 52 | // Assert.assertEquals(defaultWeather3, toulouse) 53 | // } 54 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/util/MockitoKotlinHelpers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017, The Android Open Source Project 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 | * http://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 | package fr.ekito.myweatherapp.util 17 | 18 | /** 19 | * Helper functions that are workarounds to kotlin Runtime Exceptions when using kotlin. 20 | */ 21 | 22 | import org.mockito.ArgumentCaptor 23 | import org.mockito.Mockito 24 | 25 | object MockitoHelper { 26 | 27 | // fun given(any: T) = Mockito.`when`(any) 28 | 29 | /** 30 | * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when 31 | * null is returned. 32 | * 33 | * Generic T is nullable because implicitly bounded by Any?. 34 | */ 35 | fun eq(obj: T): T = Mockito.eq(obj) 36 | 37 | 38 | /** 39 | * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when 40 | * null is returned. 41 | */ 42 | fun any(): T = Mockito.any() 43 | 44 | 45 | /** 46 | * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException 47 | * when null is returned. 48 | */ 49 | fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() 50 | 51 | 52 | /** 53 | * Helper function for creating an argumentCaptor in kotlin. 54 | */ 55 | inline fun argumentCaptor(): ArgumentCaptor = 56 | ArgumentCaptor.forClass(T::class.java) 57 | } 58 | -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvp/DetailPresenterMockTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.mock.mvp 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 5 | import fr.ekito.myweatherapp.util.MockitoHelper 6 | import fr.ekito.myweatherapp.util.TestSchedulerProvider 7 | import fr.ekito.myweatherapp.view.detail.DetailContract 8 | import fr.ekito.myweatherapp.view.detail.DetailPresenter 9 | import io.reactivex.Single 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.mockito.BDDMockito.given 13 | import org.mockito.Mock 14 | import org.mockito.Mockito.* 15 | import org.mockito.MockitoAnnotations 16 | 17 | class DetailPresenterMockTest { 18 | 19 | lateinit var presenter: DetailContract.Presenter 20 | @Mock 21 | lateinit var view: DetailContract.View 22 | @Mock 23 | lateinit var repository: DailyForecastRepository 24 | 25 | // TODO uncomment to use LiveData in Test 26 | // @get:Rule 27 | // val rule = InstantTaskExecutorRule() 28 | 29 | @Before 30 | fun before() { 31 | MockitoAnnotations.initMocks(this) 32 | 33 | presenter = DetailPresenter(repository, TestSchedulerProvider()) 34 | presenter.view = view 35 | } 36 | 37 | @Test 38 | fun testGetLastWeather() { 39 | val weather = mock(DailyForecast::class.java) 40 | val id = "ID" 41 | 42 | given(repository.getWeatherDetail(id)).willReturn(Single.just(weather)) 43 | 44 | presenter.getDetail(id) 45 | 46 | verify(view, never()).showError(MockitoHelper.any()) 47 | verify(view).showDetail(weather) 48 | } 49 | 50 | @Test 51 | fun testGeLasttWeatherFailed() { 52 | val error = Throwable("Got error") 53 | val id = "ID" 54 | 55 | given(repository.getWeatherDetail(id)).willReturn(Single.error(error)) 56 | 57 | presenter.getDetail(id) 58 | 59 | verify(view, never()).showDetail(MockitoHelper.any()) 60 | verify(view).showError(error) 61 | } 62 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/domain/entity/DailyForecast.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.domain.entity 2 | 3 | import fr.ekito.myweatherapp.data.json.Forecastday_ 4 | import java.util.* 5 | 6 | /** 7 | * Represents our weather forecast for one day 8 | */ 9 | data class DailyForecast( 10 | val location: String, 11 | val day: String, 12 | val shortText: String, 13 | val fullText: String, 14 | val iconUrl: String, 15 | val icon: String, 16 | val temperature: Temperature, 17 | val wind: Wind, 18 | val humidity: Humidity, 19 | val id: String = UUID.randomUUID().toString() 20 | ) { 21 | val colorCode: Int by lazy { colorCodeFromTemperatureRange(temperature.high.toInt()) } 22 | 23 | private fun colorCodeFromTemperatureRange(avg: Int): Int { 24 | return when { 25 | avg in 0..8 -> 1 26 | avg in 8..13 -> 2 27 | avg in 14..20 -> 3 28 | avg > 21 -> 4 29 | else -> 0 30 | } 31 | } 32 | 33 | companion object { 34 | fun from(location: String, f: Forecastday_) = 35 | DailyForecast( 36 | location, 37 | f.date?.weekday ?: "", 38 | f.conditions ?: "", 39 | "", 40 | f.iconUrl ?: "", 41 | getWeatherCodeForIcon(f.icon ?: ""), 42 | Temperature( 43 | f.low?.celsius ?: "", 44 | f.high!!.celsius!! 45 | ), 46 | Wind(f.avewind?.kph ?: 0, f.avewind?.dir ?: ""), 47 | Humidity(f.avehumidity ?: 0) 48 | ) 49 | } 50 | } 51 | 52 | data class Wind(val kph: Int, val dir: String) { 53 | override fun toString(): String { 54 | return "$kph KPH $dir" 55 | } 56 | } 57 | 58 | data class Temperature(val low: String, val high: String) { 59 | override fun toString(): String { 60 | return "$low°C - $high°C" 61 | } 62 | } 63 | 64 | data class Humidity(val humidity: Int) { 65 | override fun toString(): String { 66 | return "$humidity %" 67 | } 68 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.splash 2 | 3 | import android.os.Bundle 4 | import android.support.design.widget.Snackbar 5 | import android.support.v7.app.AppCompatActivity 6 | import android.view.View 7 | import android.view.animation.AnimationUtils 8 | import fr.ekito.myweatherapp.R 9 | import fr.ekito.myweatherapp.view.weather.WeatherActivity 10 | import kotlinx.android.synthetic.main.activity_splash.* 11 | import org.jetbrains.anko.clearTask 12 | import org.jetbrains.anko.clearTop 13 | import org.jetbrains.anko.intentFor 14 | import org.jetbrains.anko.newTask 15 | import org.koin.android.ext.android.inject 16 | 17 | /** 18 | * Search Weather View 19 | */ 20 | class SplashActivity : AppCompatActivity(), SplashContract.View { 21 | 22 | // Presenter 23 | override val presenter: SplashContract.Presenter by inject() 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | super.onCreate(savedInstanceState) 27 | setContentView(R.layout.activity_splash) 28 | } 29 | 30 | override fun onStart() { 31 | super.onStart() 32 | // Bind View 33 | presenter.subscribe(this) 34 | presenter.getLastWeather() 35 | } 36 | 37 | override fun onStop() { 38 | presenter.unSubscribe() 39 | super.onStop() 40 | } 41 | 42 | override fun showIsLoading() { 43 | val animation = 44 | AnimationUtils.loadAnimation(applicationContext, R.anim.infinite_blinking_animation) 45 | splashIcon.startAnimation(animation) 46 | } 47 | 48 | override fun showIsLoaded() { 49 | startActivity(intentFor().clearTop().clearTask().newTask()) 50 | } 51 | 52 | override fun showError(error: Throwable) { 53 | splashIcon.visibility = View.GONE 54 | splashIconFail.visibility = View.VISIBLE 55 | Snackbar.make(splash, "SplashActivity got error : $error", Snackbar.LENGTH_INDEFINITE) 56 | .setAction(R.string.retry) { 57 | presenter.getLastWeather() 58 | } 59 | .show() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/util/mvvm/SingleLiveEvent.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.util.mvvm 2 | 3 | import android.arch.lifecycle.LifecycleOwner 4 | import android.arch.lifecycle.MutableLiveData 5 | import android.arch.lifecycle.Observer 6 | import android.support.annotation.MainThread 7 | import android.util.Log 8 | import java.util.concurrent.atomic.AtomicBoolean 9 | 10 | /** 11 | Extracted from MVVM Google Blueprints Project - 12 | https://github.com/googlesamples/android-architecture/blob/dev-todo-mvvm-live-kotlin/todoapp/app/src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.kt 13 | */ 14 | 15 | /** 16 | * A lifecycle-aware observable that sends only new updates after subscription, used for events like 17 | * navigation and Snackbar messages. 18 | * 19 | * 20 | * This avoids a common problem with events: on configuration change (like rotation) an update 21 | * can be emitted if the observer is active. This LiveData only calls the observable if there's an 22 | * explicit call to setValue() or call(). 23 | * 24 | * 25 | * Note that only one observer is going to be notified of changes. 26 | */ 27 | class SingleLiveEvent : MutableLiveData() { 28 | 29 | private val pending = AtomicBoolean(false) 30 | 31 | @MainThread 32 | override fun observe(owner: LifecycleOwner, observer: Observer) { 33 | 34 | if (hasActiveObservers()) { 35 | Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") 36 | } 37 | 38 | // Observe the internal MutableLiveData 39 | super.observe(owner, Observer { t -> 40 | if (pending.compareAndSet(true, false)) { 41 | observer.onChanged(t) 42 | } 43 | }) 44 | } 45 | 46 | @MainThread 47 | override fun setValue(t: T?) { 48 | pending.set(true) 49 | super.setValue(t) 50 | } 51 | 52 | /** 53 | * Used for cases where T is Void, to make calls cleaner. 54 | */ 55 | @MainThread 56 | fun call() { 57 | value = null 58 | } 59 | 60 | companion object { 61 | private const val TAG = "SingleLiveEvent" 62 | } 63 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/list/WeatherListAdapter.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather.list 2 | 3 | import android.content.Context 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.LinearLayout 9 | import android.widget.TextView 10 | import com.joanzapata.iconify.widget.IconTextView 11 | import fr.ekito.myweatherapp.R 12 | import fr.ekito.myweatherapp.domain.entity.getColorFromCode 13 | 14 | class WeatherListAdapter( 15 | val context: Context, 16 | var list: List, 17 | private val onDetailSelected: (WeatherItem) -> Unit 18 | ) : RecyclerView.Adapter() { 19 | 20 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeatherResultHolder { 21 | val view = LayoutInflater.from(parent.context).inflate(R.layout.item_weather, parent, false) 22 | return WeatherResultHolder(view) 23 | } 24 | 25 | override fun onBindViewHolder(holder: WeatherResultHolder, position: Int) { 26 | holder.display(list[position], context, onDetailSelected) 27 | } 28 | 29 | override fun getItemCount() = list.size 30 | 31 | inner class WeatherResultHolder(item: View) : RecyclerView.ViewHolder(item) { 32 | private val weatherItemLayout = item.findViewById(R.id.weatherItemLayout) 33 | private val weatherItemDay = item.findViewById(R.id.weatheItemrDay) 34 | private val weatherItemIcon = item.findViewById(R.id.weatherItemIcon) 35 | 36 | fun display( 37 | dailyForecastModel: WeatherItem, 38 | context: Context, 39 | onClick: (WeatherItem) -> Unit 40 | ) { 41 | weatherItemLayout.setOnClickListener { onClick(dailyForecastModel) } 42 | weatherItemDay.text = dailyForecastModel.day 43 | weatherItemIcon.text = dailyForecastModel.icon 44 | val color = context.getColorFromCode(dailyForecastModel) 45 | weatherItemDay.setTextColor(color) 46 | weatherItemIcon.setTextColor(color) 47 | } 48 | 49 | } 50 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/layout/activity_result.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 20 | 21 | 25 | 26 | 27 | 35 | 36 | 46 | 47 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/domain/repository/DailyForecastRepository.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.domain.repository 2 | 3 | import fr.ekito.myweatherapp.data.WeatherDataSource 4 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 5 | import fr.ekito.myweatherapp.domain.ext.getDailyForecasts 6 | import fr.ekito.myweatherapp.domain.ext.getLocation 7 | import io.reactivex.Single 8 | 9 | /** 10 | * Weather repository 11 | */ 12 | interface DailyForecastRepository { 13 | /** 14 | * Get weather from given location 15 | * if location is null, get last weather or default 16 | */ 17 | fun getWeather(location: String? = null): Single> 18 | 19 | /** 20 | * Get weather for given id 21 | */ 22 | fun getWeatherDetail(id: String): Single 23 | } 24 | 25 | /** 26 | * Weather repository 27 | * Make use of WeatherDataSource & add some cache 28 | */ 29 | class DailyForecastRepositoryImpl(private val weatherDatasource: WeatherDataSource) : 30 | DailyForecastRepository { 31 | 32 | private fun lastLocationFromCache() = weatherCache.firstOrNull()?.location 33 | 34 | private val weatherCache = arrayListOf() 35 | 36 | override fun getWeatherDetail(id: String): Single = 37 | Single.just(weatherCache.first { it.id == id }) 38 | 39 | override fun getWeather( 40 | location: String? 41 | ): Single> { 42 | // Take cache 43 | return if (location == null && weatherCache.isNotEmpty()) return Single.just(weatherCache) 44 | else { 45 | val targetLocation: String = location ?: lastLocationFromCache() ?: DEFAULT_LOCATION 46 | weatherCache.clear() 47 | weatherDatasource.geocode(targetLocation) 48 | .map { it.getLocation() ?: throw IllegalStateException("No Location data") } 49 | .flatMap { weatherDatasource.weather(it.lat, it.lng, DEFAULT_LANG) } 50 | .map { it.getDailyForecasts(targetLocation) } 51 | .doOnSuccess { weatherCache.addAll(it) } 52 | } 53 | } 54 | 55 | companion object { 56 | const val DEFAULT_LOCATION = "Paris" 57 | const val DEFAULT_LANG = "EN" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/detail/DetailActivity.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.detail 2 | 3 | import android.os.Bundle 4 | import android.support.design.widget.Snackbar 5 | import android.support.v7.app.AppCompatActivity 6 | import fr.ekito.myweatherapp.R 7 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 8 | import fr.ekito.myweatherapp.domain.entity.getColorFromCode 9 | import fr.ekito.myweatherapp.util.android.argument 10 | import kotlinx.android.synthetic.main.activity_detail.* 11 | import org.koin.android.ext.android.inject 12 | 13 | /** 14 | * Weather Detail View 15 | */ 16 | class DetailActivity : AppCompatActivity(), DetailContract.View { 17 | 18 | // Get all needed data 19 | private val detailId by argument(INTENT_WEATHER_ID) 20 | 21 | override val presenter: DetailContract.Presenter by inject() 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | super.onCreate(savedInstanceState) 25 | setContentView(R.layout.activity_detail) 26 | presenter.getDetail(detailId) 27 | } 28 | 29 | override fun onStart() { 30 | super.onStart() 31 | presenter.subscribe(this) 32 | } 33 | 34 | override fun onStop() { 35 | presenter.unSubscribe() 36 | super.onStop() 37 | } 38 | 39 | override fun showError(error: Throwable) { 40 | Snackbar.make( 41 | weatherItem, 42 | getString(R.string.loading_error) + " - $error", 43 | Snackbar.LENGTH_LONG 44 | ).show() 45 | } 46 | 47 | override fun showDetail(weather: DailyForecast) { 48 | weatherIcon.text = weather.icon 49 | weatherDay.text = weather.day 50 | weatherText.text = weather.fullText 51 | weatherWindText.text = weather.wind.toString() 52 | weatherTempText.text = weather.temperature.toString() 53 | weatherHumidityText.text = weather.humidity.toString() 54 | weatherItem.background.setTint(getColorFromCode(weather)) 55 | // Set back on background click 56 | weatherItem.setOnClickListener { 57 | onBackPressed() 58 | } 59 | } 60 | 61 | companion object { 62 | const val INTENT_WEATHER_ID: String = "INTENT_WEATHER_ID" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvp/SplashPresenterMockTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.mock.mvp 2 | 3 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 4 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 5 | import fr.ekito.myweatherapp.util.MockitoHelper 6 | import fr.ekito.myweatherapp.util.TestSchedulerProvider 7 | import fr.ekito.myweatherapp.view.splash.SplashContract 8 | import fr.ekito.myweatherapp.view.splash.SplashPresenter 9 | import io.reactivex.Single 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.mockito.BDDMockito.given 13 | import org.mockito.Mock 14 | import org.mockito.Mockito.* 15 | import org.mockito.MockitoAnnotations 16 | 17 | class SplashPresenterMockTest { 18 | 19 | lateinit var presenter: SplashContract.Presenter 20 | @Mock 21 | lateinit var view: SplashContract.View 22 | @Mock 23 | lateinit var repository: DailyForecastRepository 24 | 25 | // TODO uncomment to use LiveData in Test 26 | // @get:Rule 27 | // val rule = InstantTaskExecutorRule() 28 | 29 | @Before 30 | fun before() { 31 | MockitoAnnotations.initMocks(this) 32 | 33 | presenter = SplashPresenter(repository, TestSchedulerProvider()) 34 | presenter.view = view 35 | } 36 | 37 | @Test 38 | fun testGetLastWeather() { 39 | val list = listOf(mock(DailyForecast::class.java)) 40 | 41 | given(repository.getWeather()).willReturn(Single.just(list)) 42 | 43 | presenter.getLastWeather() 44 | 45 | verify(view, never()).showError(MockitoHelper.any()) 46 | inOrder(view).apply { 47 | verify(view).showIsLoading() 48 | verify(view).showIsLoaded() 49 | } 50 | } 51 | 52 | @Test 53 | fun testGetLasttWeatherFailed() { 54 | val error = Throwable("Got an error") 55 | given(repository.getWeather()).willReturn(Single.error(error)) 56 | 57 | presenter.getLastWeather() 58 | 59 | inOrder(view).apply { 60 | verify(view).showIsLoading() 61 | verify(view).showError(error) 62 | } 63 | verify(view, never()).showIsLoaded() 64 | } 65 | 66 | companion object { 67 | const val DEFAULT_LOCATION = "DEFAULT_LOCATION" 68 | } 69 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvp/WeatherListPresenterMockTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.mock.mvp 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.mock.MockedData.mockList 5 | import fr.ekito.myweatherapp.util.MockitoHelper 6 | import fr.ekito.myweatherapp.util.TestSchedulerProvider 7 | import fr.ekito.myweatherapp.view.weather.WeatherListContract 8 | import fr.ekito.myweatherapp.view.weather.WeatherListPresenter 9 | import fr.ekito.myweatherapp.view.weather.list.WeatherItem 10 | import io.reactivex.Single 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.mockito.BDDMockito.given 14 | import org.mockito.Mock 15 | import org.mockito.Mockito 16 | import org.mockito.Mockito.verify 17 | import org.mockito.MockitoAnnotations 18 | 19 | class WeatherListPresenterMockTest { 20 | 21 | lateinit var presenter: WeatherListContract.Presenter 22 | @Mock 23 | lateinit var view: WeatherListContract.View 24 | @Mock 25 | lateinit var repository: DailyForecastRepository 26 | 27 | // TODO uncomment to use LiveData in Test 28 | // @get:Rule 29 | // val rule = InstantTaskExecutorRule() 30 | 31 | @Before 32 | fun before() { 33 | MockitoAnnotations.initMocks(this) 34 | 35 | presenter = WeatherListPresenter(repository, TestSchedulerProvider()) 36 | presenter.view = view 37 | } 38 | 39 | @Test 40 | fun testDisplayList() { 41 | val location = "DEFAULT_LOCATION" 42 | given(repository.getWeather()).willReturn(Single.just(mockList)) 43 | 44 | presenter.getWeatherList() 45 | 46 | val itemList = mockList.takeLast(mockList.size - 1).map { WeatherItem.from(it) } 47 | verify(view, Mockito.never()).showError(MockitoHelper.any()) 48 | verify(view).showWeatherItemList(itemList) 49 | } 50 | 51 | @Test 52 | fun testDisplayListFailed() { 53 | val error = Throwable("Got an error") 54 | val location = "DEFAULT_LOCATION" 55 | given(repository.getWeather()).willReturn(Single.error(error)) 56 | 57 | presenter.getWeatherList() 58 | 59 | verify(view, Mockito.never()).showWeatherItemList(MockitoHelper.any()) 60 | verify(view).showError(error) 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherListFragment.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import android.os.Bundle 4 | import android.support.v4.app.Fragment 5 | import android.support.v7.widget.LinearLayoutManager 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import fr.ekito.myweatherapp.R 10 | import fr.ekito.myweatherapp.view.detail.DetailActivity 11 | import fr.ekito.myweatherapp.view.weather.list.WeatherItem 12 | import fr.ekito.myweatherapp.view.weather.list.WeatherListAdapter 13 | import kotlinx.android.synthetic.main.fragment_result_list.* 14 | import org.jetbrains.anko.startActivity 15 | import org.koin.android.ext.android.inject 16 | 17 | class WeatherListFragment : Fragment(), WeatherListContract.View { 18 | 19 | override val presenter by inject() 20 | 21 | override fun onCreateView( 22 | inflater: LayoutInflater, 23 | container: ViewGroup?, 24 | savedInstanceState: Bundle? 25 | ): View? { 26 | return inflater.inflate(R.layout.fragment_result_list, container, false) 27 | } 28 | 29 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 30 | super.onViewCreated(view, savedInstanceState) 31 | prepareListView() 32 | } 33 | 34 | private fun prepareListView() { 35 | weatherList.layoutManager = 36 | LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) 37 | weatherList.adapter = WeatherListAdapter( 38 | activity!!, 39 | emptyList(), 40 | ::onWeatherItemSelected 41 | ) 42 | } 43 | 44 | private fun onWeatherItemSelected(resultItem: WeatherItem) { 45 | activity?.startActivity( 46 | DetailActivity.INTENT_WEATHER_ID to resultItem.id 47 | ) 48 | } 49 | 50 | override fun showWeatherItemList(newList: List) { 51 | val adapter: WeatherListAdapter = weatherList.adapter as WeatherListAdapter 52 | adapter.list = newList 53 | adapter.notifyDataSetChanged() 54 | } 55 | 56 | override fun onResume() { 57 | super.onResume() 58 | presenter.subscribe(this) 59 | presenter.getWeatherList() 60 | } 61 | 62 | override fun onPause() { 63 | presenter.unSubscribe() 64 | super.onPause() 65 | } 66 | 67 | override fun showError(error: Throwable) { 68 | (activity as? WeatherActivity)?.showError(error) 69 | } 70 | } -------------------------------------------------------------------------------- /weatherapp/gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvp/WeatherHeaderPresenterMockTest.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.mock.mvp 2 | 3 | import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 4 | import fr.ekito.myweatherapp.mock.MockedData.mockList 5 | import fr.ekito.myweatherapp.util.MockitoHelper 6 | import fr.ekito.myweatherapp.util.TestSchedulerProvider 7 | import fr.ekito.myweatherapp.view.weather.WeatherHeaderContract 8 | import fr.ekito.myweatherapp.view.weather.WeatherHeaderPresenter 9 | import io.reactivex.Single 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.mockito.BDDMockito.given 13 | import org.mockito.Mock 14 | import org.mockito.Mockito.never 15 | import org.mockito.Mockito.verify 16 | import org.mockito.MockitoAnnotations 17 | 18 | class WeatherHeaderPresenterMockTest { 19 | 20 | lateinit var presenter: WeatherHeaderContract.Presenter 21 | @Mock 22 | lateinit var view: WeatherHeaderContract.View 23 | @Mock 24 | lateinit var repository: DailyForecastRepository 25 | 26 | // TODO uncomment to use LiveData in Test 27 | // @get:Rule 28 | // val rule = InstantTaskExecutorRule() 29 | 30 | @Before 31 | fun before() { 32 | MockitoAnnotations.initMocks(this) 33 | 34 | presenter = 35 | WeatherHeaderPresenter(repository, TestSchedulerProvider()) 36 | presenter.view = view 37 | } 38 | 39 | @Test 40 | fun testDisplayList() { 41 | val first = mockList.first() 42 | given(repository.getWeather()).willReturn(Single.just(mockList)) 43 | 44 | presenter.getWeatherOfTheDay() 45 | verify(view, never()).showError(MockitoHelper.any()) 46 | verify(view).showWeather(first.location, first) 47 | } 48 | 49 | @Test 50 | fun testDisplayListFailed() { 51 | val error = Throwable("Got an error") 52 | given(repository.getWeather(MockitoHelper.any())).willReturn(Single.error(error)) 53 | 54 | presenter.getWeatherOfTheDay() 55 | 56 | verify(view, never()).showWeather(MockitoHelper.any(), MockitoHelper.any()) 57 | verify(view).showError(error) 58 | } 59 | 60 | @Test 61 | fun testSearchNewLocation() { 62 | val location = "new location" 63 | given(repository.getWeather(location)).willReturn(Single.just(mockList)) 64 | presenter.loadNewLocation(location) 65 | 66 | verify(view, never()).showLocationSearchFailed(MockitoHelper.any(), MockitoHelper.any()) 67 | verify(view).showLocationSearchSucceed(location) 68 | } 69 | 70 | @Test 71 | fun testSearchNewLocationFailed() { 72 | val location = "new location" 73 | val error = Throwable("Got an error") 74 | 75 | given(repository.getWeather(location)).willReturn(Single.error(error)) 76 | presenter.loadNewLocation(location) 77 | verify(view, never()).showLocationSearchSucceed(MockitoHelper.any()) 78 | verify(view).showLocationSearchFailed(location, error) 79 | } 80 | 81 | } -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvvm/WeatherListViewModelMockTest.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp.mock.mvvm 2 | // 3 | //import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | //import android.arch.lifecycle.Observer 5 | //import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 6 | //import fr.ekito.myweatherapp.mock.MockedData.mockList 7 | //import fr.ekito.myweatherapp.util.TestSchedulerProvider 8 | //import fr.ekito.myweatherapp.view.Failed 9 | //import fr.ekito.myweatherapp.view.Loading 10 | //import fr.ekito.myweatherapp.view.ViewModelState 11 | //import fr.ekito.myweatherapp.view.weather.WeatherViewModel 12 | //import io.reactivex.Single 13 | //import org.junit.Assert 14 | //import org.junit.Before 15 | //import org.junit.Rule 16 | //import org.junit.Test 17 | //import org.mockito.ArgumentCaptor 18 | //import org.mockito.BDDMockito.given 19 | //import org.mockito.Mock 20 | //import org.mockito.Mockito 21 | //import org.mockito.Mockito.verify 22 | //import org.mockito.MockitoAnnotations 23 | // 24 | //class WeatherListViewModelMockTest { 25 | // 26 | // lateinit var viewModel: WeatherViewModel 27 | // @Mock 28 | // lateinit var view: Observer 29 | // @Mock 30 | // lateinit var repository: DailyForecastRepository 31 | // 32 | // @get:Rule 33 | // val rule = InstantTaskExecutorRule() 34 | // 35 | // @Before 36 | // fun before() { 37 | // MockitoAnnotations.initMocks(this) 38 | // 39 | // viewModel = WeatherViewModel(repository, TestSchedulerProvider()) 40 | // viewModel.states.observeForever(view) 41 | // } 42 | // 43 | // @Test 44 | // fun testDisplayList() { 45 | // given(repository.getWeather()).willReturn(Single.just(mockList)) 46 | // 47 | // viewModel.getWeather() 48 | // 49 | // // setup ArgumentCaptor 50 | // val arg = ArgumentCaptor.forClass(ViewModelState::class.java) 51 | // // Here we expect 2 calls on view.onChanged 52 | // verify(view, Mockito.times(2)).onChanged(arg.capture()) 53 | // 54 | // val states = arg.allValues 55 | // // Test obtained values in order 56 | // Assert.assertEquals(2, states.size) 57 | // Assert.assertEquals(Loading, states[0]) 58 | // Assert.assertEquals(WeatherViewModel.WeatherListLoaded.from(mockList), states[1]) 59 | // } 60 | // 61 | // @Test 62 | // fun testDisplayListFailed() { 63 | // val error = Throwable("Got an error") 64 | // given(repository.getWeather()).willReturn(Single.error(error)) 65 | // 66 | // viewModel.getWeather() 67 | // 68 | // // setup ArgumentCaptor 69 | // val arg = ArgumentCaptor.forClass(ViewModelState::class.java) 70 | // // Here we expect 2 calls on view.onChanged 71 | // verify(view, Mockito.times(2)).onChanged(arg.capture()) 72 | // 73 | // val states = arg.allValues 74 | // // Test obtained values in order 75 | // Assert.assertEquals(2, states.size) 76 | // Assert.assertEquals(Loading, states[0]) 77 | // Assert.assertEquals(Failed(error), states[1]) 78 | // } 79 | // 80 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvvm/SplashViewModelMockTest.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp.mock.mvvm 2 | // 3 | //import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | //import android.arch.lifecycle.Observer 5 | //import fr.ekito.myweatherapp.domain.entity.DailyForecast 6 | //import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 7 | //import fr.ekito.myweatherapp.util.TestSchedulerProvider 8 | //import fr.ekito.myweatherapp.view.Fail 9 | //import fr.ekito.myweatherapp.view.Pending 10 | //import fr.ekito.myweatherapp.view.Success 11 | //import fr.ekito.myweatherapp.view.ViewModelEvent 12 | //import fr.ekito.myweatherapp.view.splash.SplashViewModel 13 | //import io.reactivex.Single 14 | //import org.junit.Assert 15 | //import org.junit.Before 16 | //import org.junit.Rule 17 | //import org.junit.Test 18 | //import org.mockito.ArgumentCaptor 19 | //import org.mockito.BDDMockito.given 20 | //import org.mockito.Mock 21 | //import org.mockito.Mockito.* 22 | //import org.mockito.MockitoAnnotations 23 | // 24 | //class SplashViewModelMockTest { 25 | // 26 | // lateinit var viewModel: SplashViewModel 27 | // 28 | // @Mock 29 | // lateinit var view: Observer 30 | // 31 | // @Mock 32 | // lateinit var repository: DailyForecastRepository 33 | // 34 | // @get:Rule 35 | // val rule = InstantTaskExecutorRule() 36 | // 37 | // @Before 38 | // fun before() { 39 | // MockitoAnnotations.initMocks(this) 40 | // 41 | // viewModel = SplashViewModel(repository, TestSchedulerProvider()) 42 | // 43 | // viewModel.events.observeForever(view) 44 | // } 45 | // 46 | // @Test 47 | // fun testGetLastWeather() { 48 | // val list = listOf(mock(DailyForecast::class.java)) 49 | // 50 | // given(repository.getWeather()).willReturn(Single.just(list)) 51 | // 52 | // viewModel.getLastWeather() 53 | // 54 | // // setup ArgumentCaptor 55 | // val arg = ArgumentCaptor.forClass(ViewModelEvent::class.java) 56 | // // Here we expect 2 calls on view.onChanged 57 | // verify(view, times(2)).onChanged(arg.capture()) 58 | // 59 | // val values = arg.allValues 60 | // // Test obtained values in order 61 | // Assert.assertEquals(2, values.size) 62 | // Assert.assertEquals(Pending, values[0]) 63 | // Assert.assertEquals(Success, values[1]) 64 | // } 65 | // 66 | // @Test 67 | // fun testGetLasttWeatherFailed() { 68 | // val error = Throwable("Got an error") 69 | // given(repository.getWeather()).willReturn(Single.error(error)) 70 | // 71 | // viewModel.getLastWeather() 72 | // 73 | // // setup ArgumentCaptor 74 | // val arg = ArgumentCaptor.forClass(ViewModelEvent::class.java) 75 | // // Here we expect 2 calls on view.onChanged 76 | // verify(view, times(2)).onChanged(arg.capture()) 77 | // 78 | // val values = arg.allValues 79 | // // Test obtained values in order 80 | // Assert.assertEquals(2, values.size) 81 | // Assert.assertEquals(Pending, values[0]) 82 | // Assert.assertEquals(Fail(error), values[1]) 83 | // } 84 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/test/java/fr/ekito/myweatherapp/mock/mvvm/DetailViewModelMockTest.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp.mock.mvvm 2 | // 3 | //import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | //import android.arch.lifecycle.Observer 5 | //import fr.ekito.myweatherapp.domain.entity.DailyForecast 6 | //import fr.ekito.myweatherapp.domain.repository.DailyForecastRepository 7 | //import fr.ekito.myweatherapp.util.TestSchedulerProvider 8 | //import fr.ekito.myweatherapp.view.Failed 9 | //import fr.ekito.myweatherapp.view.Loading 10 | //import fr.ekito.myweatherapp.view.ViewModelState 11 | //import fr.ekito.myweatherapp.view.detail.DetailViewModel 12 | //import io.reactivex.Single 13 | //import org.junit.Assert.assertEquals 14 | //import org.junit.Before 15 | //import org.junit.Rule 16 | //import org.junit.Test 17 | //import org.mockito.ArgumentCaptor 18 | //import org.mockito.BDDMockito.given 19 | //import org.mockito.Mock 20 | //import org.mockito.Mockito 21 | //import org.mockito.Mockito.times 22 | //import org.mockito.Mockito.verify 23 | //import org.mockito.MockitoAnnotations 24 | // 25 | //class DetailViewModelMockTest { 26 | // 27 | // lateinit var detailViewModel: DetailViewModel 28 | // @Mock 29 | // lateinit var view: Observer 30 | // @Mock 31 | // lateinit var repository: DailyForecastRepository 32 | // 33 | // @get:Rule 34 | // val rule = InstantTaskExecutorRule() 35 | // 36 | // val id = "ID" 37 | // 38 | // @Before 39 | // fun before() { 40 | // MockitoAnnotations.initMocks(this) 41 | // 42 | // detailViewModel = DetailViewModel() 43 | // detailViewModel.schedulerProvider = TestSchedulerProvider() 44 | // detailViewModel.dailyForecastRepository = repository 45 | // 46 | // detailViewModel.states.observeForever(view) 47 | // } 48 | // 49 | // @Test 50 | // fun testGetLastWeather() { 51 | // val weather = Mockito.mock(DailyForecast::class.java) 52 | // 53 | // given(repository.getWeatherDetail(id)).willReturn(Single.just(weather)) 54 | // 55 | // detailViewModel.getDetail(id) 56 | // 57 | // // setup ArgumentCaptor 58 | // val arg = ArgumentCaptor.forClass(ViewModelState::class.java) 59 | // // Here we expect 2 calls on view.onChanged 60 | // verify(view, times(2)).onChanged(arg.capture()) 61 | // 62 | // val values = arg.allValues 63 | // // Test obtained values in order 64 | // assertEquals(2, values.size) 65 | // assertEquals(Loading, values[0]) 66 | // assertEquals(DetailViewModel.DetailLoaded(weather), values[1]) 67 | // } 68 | // 69 | // @Test 70 | // fun testGeLasttWeatherFailed() { 71 | // val error = Throwable("Got error") 72 | // 73 | // given(repository.getWeatherDetail(id)).willReturn(Single.error(error)) 74 | // 75 | // detailViewModel.getDetail(id) 76 | // 77 | // // setup ArgumentCaptor 78 | // val arg = ArgumentCaptor.forClass(ViewModelState::class.java) 79 | // // Here we expect 2 calls on view.onChanged 80 | // verify(view, times(2)).onChanged(arg.capture()) 81 | // 82 | // val values = arg.allValues 83 | // // Test obtained values in order 84 | // assertEquals(2, values.size) 85 | // assertEquals(Loading, values[0]) 86 | // assertEquals(Failed(error), values[1]) 87 | // } 88 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/domain/entity/WeatherCode.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.domain.entity 2 | 3 | import android.content.Context 4 | import fr.ekito.myweatherapp.R 5 | import fr.ekito.myweatherapp.view.weather.list.WeatherItem 6 | 7 | internal const val PREFIX = "nt_" 8 | 9 | const val CHANCE_FLURRIES = "chanceflurries" // wi-snow-wind 10 | const val CHANCE_RAIN = "chancerain" // wi-rain 11 | const val CHANCE_SLEET = "chancesleet" // wi-rain-mix 12 | const val CHANCE_SNOW = "chancesnow" // wi-snow 13 | const val CHANCE_STORMS = "chancestorms" // wi-thunderstorm 14 | 15 | const val CLEAR = "clear" // wi-day-sunny 16 | const val CLOUDY = "cloudy" // wi-cloudy 17 | const val FLURRIES = "flurries" // wi-snow-wind 18 | const val FOG = "fog" // wi-fog 19 | const val HAZY = "hazy" // wi-fog 20 | 21 | const val MOSTLY_CLOUDY = "mostlycloudy" // wi-day-cloudy 22 | const val MOSTLY_SUNNY = "mostlysunny" // wi-day-cloudy 23 | const val PARTLY_CLOUDY = "partlycloudy" // wi-day-cloudy 24 | const val PARTLY_SUNNY = "partlysunny" // wi-day-cloudy 25 | 26 | const val RAIN = "rain" // wi-rain 27 | const val SLEET = "sleet" // wi-rain-mix 28 | const val SNOW = "snow" // wi-snow 29 | const val SUNNY = "sunny" // wi-day-sunny 30 | const val TSTORMS = "tstorms" // wi-thunderstorm 31 | 32 | const val WI_SNOW_WIND = "{wi_snow_wind}" 33 | const val WI_RAIN = "{wi_rain}" 34 | const val WI_RAIN_MIX = "{wi_rain_mix}" 35 | const val WI_SNOW = "{wi_snow}" 36 | const val WI_THUNDERSTORM = "{wi_thunderstorm}" 37 | const val WI_DAY_SUNNY = "{wi_day_sunny}" 38 | const val WI_CLOUDY = "{wi_cloudy}" 39 | const val WI_FOG = "{wi_fog}" 40 | const val WI_DAY_CLOUDY = "{wi_day_cloudy}" 41 | 42 | fun getWeatherCodeForIcon(icon: String): String { 43 | return when (icon) { 44 | CHANCE_STORMS, PREFIX + CHANCE_STORMS, TSTORMS, PREFIX + TSTORMS -> WI_THUNDERSTORM 45 | CHANCE_SNOW, PREFIX + CHANCE_SNOW, SNOW, PREFIX + SNOW -> WI_SNOW 46 | CHANCE_FLURRIES, PREFIX + CHANCE_FLURRIES, FLURRIES, PREFIX + FLURRIES -> WI_SNOW_WIND 47 | CHANCE_RAIN, PREFIX + CHANCE_RAIN, RAIN, PREFIX + RAIN -> WI_RAIN 48 | CHANCE_SLEET, PREFIX + CHANCE_SLEET, SLEET, PREFIX + SLEET -> WI_RAIN_MIX 49 | FOG, PREFIX + FOG, HAZY, PREFIX + HAZY -> WI_FOG 50 | CLOUDY, PREFIX + CLOUDY -> WI_CLOUDY 51 | MOSTLY_CLOUDY, PREFIX + MOSTLY_CLOUDY, MOSTLY_SUNNY, PREFIX + MOSTLY_SUNNY, PARTLY_CLOUDY, PREFIX + PARTLY_CLOUDY, PARTLY_SUNNY, PREFIX + PARTLY_SUNNY -> WI_DAY_CLOUDY 52 | CLEAR, PREFIX + CLEAR, SUNNY, PREFIX + SUNNY -> WI_DAY_SUNNY 53 | else -> WI_DAY_CLOUDY 54 | } 55 | } 56 | 57 | @Suppress("DEPRECATION") 58 | fun Context.getColorFromCode(w: DailyForecast): Int { 59 | return when (w.colorCode) { 60 | 1 -> resources.getColor(R.color.temp_1) 61 | 2 -> resources.getColor(R.color.temp_2) 62 | 3 -> resources.getColor(R.color.temp_3) 63 | 4 -> resources.getColor(R.color.temp_4) 64 | else -> resources.getColor(R.color.temp_0) 65 | } 66 | } 67 | 68 | @Suppress("DEPRECATION") 69 | fun Context.getColorFromCode(w: WeatherItem): Int { 70 | return when (w.color) { 71 | 1 -> resources.getColor(R.color.temp_1) 72 | 2 -> resources.getColor(R.color.temp_2) 73 | 3 -> resources.getColor(R.color.temp_3) 74 | 4 -> resources.getColor(R.color.temp_4) 75 | else -> resources.getColor(R.color.temp_0) 76 | } 77 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/data/json/Weather.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.data.json 2 | 3 | import java.util.* 4 | 5 | data class Avewind( 6 | val mph: Int? = null, 7 | val kph: Int? = null, 8 | val dir: String? = null, 9 | val degrees: Int? = null 10 | ) 11 | 12 | data class Date( 13 | val epoch: String? = null, 14 | val pretty: String? = null, 15 | val day: Int? = null, 16 | val month: Int? = null, 17 | val year: Int? = null, 18 | val yday: Int? = null, 19 | val hour: Int? = null, 20 | val min: String? = null, 21 | val sec: Int? = null, 22 | val isdst: String? = null, 23 | val monthname_short: String? = null, 24 | val weekday_short: String? = null, 25 | val weekday: String? = null, 26 | val ampm: String? = null, 27 | val tz_short: String? = null, 28 | val tz_long: String? = null 29 | ) 30 | 31 | data class Features(val forecast: Int? = null) 32 | 33 | data class Forecast( 34 | val txtForecast: TxtForecast? = null, 35 | val simpleforecast: Simpleforecast? = null 36 | ) 37 | 38 | data class Forecastday( 39 | val period: Int? = null, 40 | val icon: String? = null, 41 | val iconUrl: String? = null, 42 | val title: String? = null, 43 | val fcttext: String? = null, 44 | val fcttextMetric: String? = null, 45 | val pop: String? = null 46 | ) 47 | 48 | data class Forecastday_( 49 | val date: Date? = null, 50 | val period: Int? = null, 51 | val high: High? = null, 52 | val low: Low? = null, 53 | val conditions: String? = null, 54 | val icon: String? = null, 55 | val iconUrl: String? = null, 56 | val skyicon: String? = null, 57 | val pop: Int? = null, 58 | val qpf_allday: QpfAllday? = null, 59 | val qpf_day: QpfDay? = null, 60 | val qpf_night: QpfNight? = null, 61 | val snow_allday: SnowAllday? = null, 62 | val snow_day: SnowDay? = null, 63 | val snow_night: SnowNight? = null, 64 | val maxwind: Maxwind? = null, 65 | val avewind: Avewind? = null, 66 | val avehumidity: Int? = null, 67 | val maxhumidity: Int? = null, 68 | val minhumidity: Int? = null 69 | ) 70 | 71 | data class High( 72 | val fahrenheit: String? = null, 73 | val celsius: String? = null 74 | ) 75 | 76 | data class Low( 77 | val fahrenheit: String? = null, 78 | val celsius: String? = null 79 | ) 80 | 81 | data class Maxwind( 82 | val mph: Int? = null, 83 | val kph: Int? = null, 84 | val dir: String? = null, 85 | val degrees: Int? = null 86 | ) 87 | 88 | data class QpfAllday( 89 | val `in`: Double? = null, 90 | val mm: Int? = null 91 | ) 92 | 93 | data class QpfDay( 94 | val `in`: Double? = null, 95 | val mm: Int? = null 96 | ) 97 | 98 | data class QpfNight( 99 | val `in`: Double? = null, 100 | val mm: Int? = null 101 | ) 102 | 103 | data class Response( 104 | val version: String? = null, 105 | val termsofService: String? = null, 106 | val features: Features? = null 107 | ) 108 | 109 | data class Simpleforecast(val forecastday: List = ArrayList()) 110 | 111 | data class SnowAllday( 112 | val `in`: Double? = null, 113 | val cm: Double? = null 114 | ) 115 | 116 | data class SnowDay( 117 | val `in`: Double? = null, 118 | val cm: Double? = null 119 | ) 120 | 121 | data class SnowNight( 122 | val `in`: Double? = null, 123 | val cm: Double? = null 124 | ) 125 | 126 | data class TxtForecast( 127 | val date: String? = null, 128 | val forecastday: List = ArrayList() 129 | ) 130 | 131 | data class Weather( 132 | val response: Response? = null, 133 | val forecast: Forecast? = null 134 | ) -------------------------------------------------------------------------------- /weatherapp/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | 6 | android { 7 | compileSdkVersion rootProject.ext.compile_sdk_version 8 | buildToolsVersion rootProject.ext.build_tools_version 9 | 10 | defaultConfig { 11 | minSdkVersion 21 12 | targetSdkVersion rootProject.ext.target_sdk_version 13 | applicationId "koin.sampleapp" 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 18 | 19 | // used by Room, to test migrations 20 | javaCompileOptions { 21 | annotationProcessorOptions { 22 | arguments = ["room.schemaLocation": 23 | "$projectDir/schemas".toString()] 24 | } 25 | } 26 | } 27 | testOptions { 28 | execution 'ANDROID_TEST_ORCHESTRATOR' 29 | } 30 | buildTypes { 31 | release { 32 | minifyEnabled false 33 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 34 | } 35 | } 36 | sourceSets { 37 | main.java.srcDirs += 'src/main/kotlin' 38 | test.java.srcDirs += 'src/test/kotlin' 39 | 40 | // used by Room, to test migrations 41 | androidTest.assets.srcDirs += 42 | files("$projectDir/schemas".toString()) 43 | } 44 | } 45 | 46 | dependencies { 47 | implementation fileTree(dir: 'libs', include: ['*.jar']) 48 | 49 | // Android Support 50 | implementation "com.android.support:appcompat-v7:$support_lib_version" 51 | implementation "com.android.support:design:$support_lib_version" 52 | 53 | // Android Test 54 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 55 | androidTestUtil 'com.android.support.test:orchestrator:1.0.2' 56 | 57 | // Kotlin 58 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 59 | // Anko 60 | implementation "org.jetbrains.anko:anko-commons:$anko_version" 61 | // Koin 62 | implementation "org.koin:koin-android-viewmodel:$koin_version" 63 | testImplementation "org.koin:koin-test:$koin_version" 64 | androidTestImplementation "org.koin:koin-test:$koin_version" 65 | 66 | // ViewModel and LiveData 67 | implementation "android.arch.lifecycle:extensions:$android_arch_version" 68 | annotationProcessor "android.arch.lifecycle:compiler:$android_arch_version" 69 | testImplementation "android.arch.core:core-testing:$android_arch_version" 70 | 71 | // Room 72 | implementation "android.arch.persistence.room:runtime:$android_room_version" 73 | implementation "android.arch.persistence.room:rxjava2:$android_room_version" 74 | kapt "android.arch.persistence.room:compiler:$android_room_version" 75 | annotationProcessor "android.arch.persistence.room:compiler:$android_room_version" 76 | testImplementation "android.arch.persistence.room:testing:$android_room_version" 77 | 78 | // Networking 79 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version" 80 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" 81 | implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version" 82 | implementation "com.squareup.okhttp3:okhttp:$okhttp_version" 83 | implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" 84 | 85 | // Rx 86 | implementation "io.reactivex.rxjava2:rxjava:$rxjava_version" 87 | implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' 88 | implementation 'com.android.support.constraint:constraint-layout:1.1.3' 89 | 90 | // UI 91 | implementation 'com.joanzapata.iconify:android-iconify-weathericons:2.2.2' 92 | 93 | // Gson 94 | implementation 'com.google.code.gson:gson:2.8.2' 95 | } 96 | 97 | -------------------------------------------------------------------------------- /weatherapp/app/src/androidTest/java/fr/ekito/myweatherapp/WeatherDAOTest.kt: -------------------------------------------------------------------------------- 1 | //package fr.ekito.myweatherapp 2 | // 3 | //import android.support.test.runner.AndroidJUnit4 4 | //import fr.ekito.myweatherapp.data.WeatherDataSource 5 | //import fr.ekito.myweatherapp.data.room.WeatherDAO 6 | //import fr.ekito.myweatherapp.data.room.WeatherDatabase 7 | //import fr.ekito.myweatherapp.data.room.WeatherEntity 8 | //import fr.ekito.myweatherapp.domain.ext.getDailyForecasts 9 | //import fr.ekito.myweatherapp.domain.ext.getLocation 10 | //import junit.framework.Assert 11 | //import org.junit.After 12 | //import org.junit.Before 13 | //import org.junit.Test 14 | //import org.junit.runner.RunWith 15 | //import org.koin.standalone.StandAloneContext.loadKoinModules 16 | //import org.koin.standalone.StandAloneContext.stopKoin 17 | //import org.koin.standalone.inject 18 | //import org.koin.test.KoinTest 19 | //import java.util.* 20 | // 21 | //@RunWith(AndroidJUnit4::class) 22 | //class WeatherDAOTest : KoinTest { 23 | // 24 | // val weatherDatabase: WeatherDatabase by inject() 25 | // val weatherWebDatasource: WeatherDataSource by inject() 26 | // val weatherDAO: WeatherDAO by inject() 27 | // 28 | // @Before() 29 | // fun before() { 30 | // loadKoinModules(roomTestModule) 31 | // } 32 | // 33 | // @After 34 | // fun after() { 35 | // weatherDatabase.close() 36 | // stopKoin() 37 | // } 38 | // 39 | // @Test 40 | // fun testSave() { 41 | // val location = "Paris" 42 | // 43 | // val now = Date() 44 | // val entities = getWeatherAsEntities(location, now) 45 | // 46 | // weatherDAO.saveAll(entities) 47 | // val ids = entities.map { it.id } 48 | // 49 | // val requestedEntities = ids.map { weatherDAO.findWeatherById(it).blockingGet() } 50 | // 51 | // Assert.assertEquals(entities, requestedEntities) 52 | // } 53 | // 54 | // @Test 55 | // fun testFindAllBy() { 56 | // val locationParis = "Paris" 57 | // val dateParis = Date() 58 | // val weatherParis = getWeatherAsEntities(locationParis, dateParis) 59 | // weatherDAO.saveAll(weatherParis) 60 | // 61 | // val locationTlse = "Toulouse" 62 | // val dateToulouse = Date() 63 | // val weatherToulouse = getWeatherAsEntities(locationTlse, dateToulouse) 64 | // weatherDAO.saveAll(weatherToulouse) 65 | // 66 | // val resultList = weatherDAO.findAllBy(locationTlse, dateToulouse).blockingGet() 67 | // 68 | // Assert.assertEquals(weatherToulouse, resultList) 69 | // } 70 | // 71 | // @Test 72 | // fun testFindLatest() { 73 | // val locationParis = "Paris" 74 | // val dateParis = Date() 75 | // val weatherParis = getWeatherAsEntities(locationParis, dateParis) 76 | // weatherDAO.saveAll(weatherParis) 77 | // 78 | // val locationBerlin = "Berlin" 79 | // val dateBerlin = Date() 80 | // val weatherBerlin = getWeatherAsEntities(locationBerlin, dateBerlin) 81 | // weatherDAO.saveAll(weatherBerlin) 82 | // 83 | // val locationTlse = "Toulouse" 84 | // val dateToulouse = Date() 85 | // val weatherToulouse = getWeatherAsEntities(locationTlse, dateToulouse) 86 | // weatherDAO.saveAll(weatherToulouse) 87 | // 88 | // val result: WeatherEntity = weatherDAO.findLatestWeather().blockingGet().first() 89 | // val resultList = weatherDAO.findAllBy(result.location, result.date).blockingGet() 90 | // 91 | // Assert.assertEquals(weatherToulouse, resultList) 92 | // } 93 | // 94 | // private fun getWeatherAsEntities( 95 | // locationParis: String, 96 | // dateParis: Date 97 | // ): List { 98 | // return weatherWebDatasource.geocode(locationParis) 99 | // .map { it.getLocation() } 100 | // .flatMap { weatherWebDatasource.weather(it.lat, it.lng, "EN") } 101 | // .map { it.getDailyForecasts(locationParis) } 102 | // .map { list -> list.map { WeatherEntity.from(it, dateParis) } } 103 | // .blockingGet() 104 | // } 105 | //} -------------------------------------------------------------------------------- /weatherapp/app/src/main/kotlin/fr/ekito/myweatherapp/view/weather/WeatherHeaderFragment.kt: -------------------------------------------------------------------------------- 1 | package fr.ekito.myweatherapp.view.weather 2 | 3 | import android.app.AlertDialog 4 | import android.os.Bundle 5 | import android.support.design.widget.Snackbar 6 | import android.support.v4.app.Fragment 7 | import android.text.InputType 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import android.widget.EditText 12 | import fr.ekito.myweatherapp.R 13 | import fr.ekito.myweatherapp.domain.entity.DailyForecast 14 | import fr.ekito.myweatherapp.domain.entity.getColorFromCode 15 | import fr.ekito.myweatherapp.view.detail.DetailActivity 16 | import fr.ekito.myweatherapp.view.detail.DetailActivity.Companion.INTENT_WEATHER_ID 17 | import kotlinx.android.synthetic.main.fragment_result_header.* 18 | import org.jetbrains.anko.* 19 | import org.koin.android.ext.android.inject 20 | 21 | class WeatherHeaderFragment : Fragment(), WeatherHeaderContract.View { 22 | 23 | override val presenter: WeatherHeaderContract.Presenter by inject() 24 | 25 | override fun onCreateView( 26 | inflater: LayoutInflater, 27 | container: ViewGroup?, 28 | savedInstanceState: Bundle? 29 | ): View { 30 | return inflater.inflate(R.layout.fragment_result_header, container, false) as ViewGroup 31 | } 32 | 33 | override fun onResume() { 34 | presenter.subscribe(this) 35 | presenter.getWeatherOfTheDay() 36 | super.onResume() 37 | } 38 | 39 | override fun onPause() { 40 | presenter.unSubscribe() 41 | super.onPause() 42 | } 43 | 44 | override fun showWeather(location: String, weather: DailyForecast) { 45 | weatherCity.text = location 46 | weatherCityCard.setOnClickListener { 47 | promptLocationDialog() 48 | } 49 | 50 | weatherIcon.text = weather.icon 51 | weatherDay.text = weather.day 52 | weatherTempText.text = weather.temperature.toString() 53 | weatherText.text = weather.shortText 54 | 55 | val color = context!!.getColorFromCode(weather) 56 | weatherHeader.background.setTint(color) 57 | 58 | weatherHeader.setOnClickListener { 59 | activity?.startActivity( 60 | INTENT_WEATHER_ID to weather.id 61 | ) 62 | } 63 | } 64 | 65 | private fun promptLocationDialog() { 66 | val dialog = AlertDialog.Builder(context) 67 | dialog.setTitle(getString(R.string.enter_location)) 68 | val editText = EditText(context) 69 | editText.hint = getString(R.string.location_hint) 70 | editText.maxLines = 1 71 | editText.inputType = InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS 72 | dialog.setView(editText) 73 | dialog.setPositiveButton(getString(R.string.search)) { dialogInterface, _ -> 74 | dialogInterface.dismiss() 75 | val newLocation = editText.text.trim().toString() 76 | presenter.loadNewLocation(newLocation) 77 | Snackbar.make( 78 | weatherHeader, 79 | getString(R.string.loading_location) + " $newLocation ...", 80 | Snackbar.LENGTH_LONG 81 | ) 82 | .show() 83 | } 84 | dialog.setNegativeButton(getString(R.string.cancel)) { dialogInterface, _ -> 85 | dialogInterface.dismiss() 86 | } 87 | dialog.show() 88 | } 89 | 90 | override fun showLocationSearchSucceed(location: String) { 91 | activity?.apply { 92 | startActivity( 93 | intentFor().clearTop().clearTask().newTask() 94 | ) 95 | } 96 | } 97 | 98 | override fun showLocationSearchFailed(location: String, error: Throwable) { 99 | Snackbar.make(weatherHeader, getString(R.string.loading_error), Snackbar.LENGTH_LONG) 100 | .setAction(R.string.retry) { 101 | presenter.loadNewLocation(location) 102 | } 103 | .show() 104 | } 105 | 106 | override fun showError(error: Throwable) { 107 | (activity as? WeatherActivity)?.showError(error) 108 | } 109 | } -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/layout/fragment_result_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 30 | 31 | 39 | 40 | 41 | 42 | 43 | 50 | 51 | 61 | 62 | 71 | 72 | 81 | 82 | 88 | 89 | 97 | 98 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /weatherapp/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 25 | 26 | 34 | 35 | 43 | 44 | 52 | 53 | 58 | 59 | 63 | 64 | 67 | 68 | 73 | 74 | 77 | 78 | 81 | 82 | 86 | 87 | 90 | 91 | 94 | 95 |