├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── RELEASING.md ├── app ├── .gitignore ├── build.gradle ├── increaseVersionCode.gradle ├── proguard-rules.pro ├── signing.gradle.example └── src │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── DINNextLTPro-Light.otf │ ├── kotlin │ │ └── com │ │ │ └── thoughtbot │ │ │ └── tropos │ │ │ ├── adapters │ │ │ └── WeatherAdapter.kt │ │ │ ├── commons │ │ │ ├── BaseActivity.kt │ │ │ ├── Presenter.kt │ │ │ ├── View.kt │ │ │ ├── ViewBinder.kt │ │ │ └── WebviewActivity.kt │ │ │ ├── data │ │ │ ├── Condition.kt │ │ │ ├── ConditionDataSource.kt │ │ │ ├── Icon.kt │ │ │ ├── LocationDataSource.kt │ │ │ ├── Preferences.kt │ │ │ ├── TemperatureDifference.kt │ │ │ ├── TimeOfDay.kt │ │ │ ├── Unit.kt │ │ │ ├── Weather.kt │ │ │ ├── WeatherDataSource.kt │ │ │ ├── WeatherService.kt │ │ │ ├── WindDirection.kt │ │ │ ├── local │ │ │ │ └── LocalPreferences.kt │ │ │ └── remote │ │ │ │ ├── ApiService.kt │ │ │ │ ├── ConditionDataService.kt │ │ │ │ ├── LocationService.kt │ │ │ │ ├── Responses.kt │ │ │ │ └── RestClient.kt │ │ │ ├── extensions │ │ │ ├── ContextExtensions.kt │ │ │ ├── DateExtensions.kt │ │ │ ├── IntExtensions.kt │ │ │ ├── ParcelExtensions.kt │ │ │ └── ViewExtensions.kt │ │ │ ├── permissions │ │ │ ├── LocationPermission.kt │ │ │ ├── Permission.kt │ │ │ ├── PermissionExtensions.kt │ │ │ └── PermissionResults.kt │ │ │ ├── refresh │ │ │ ├── DiagonalStripeView.kt │ │ │ ├── ProgressCircleDrawable.kt │ │ │ ├── PullToRefreshLayout.kt │ │ │ ├── RefreshDrawable.kt │ │ │ ├── RefreshView.kt │ │ │ └── StripeDrawable.kt │ │ │ ├── scrolling │ │ │ ├── OverScroller.kt │ │ │ ├── RecyclerViewScroller.kt │ │ │ ├── VerticalOverScroller.kt │ │ │ └── WeatherSnapHelper.kt │ │ │ ├── settings │ │ │ ├── SettingsActivity.kt │ │ │ ├── SettingsPresenter.kt │ │ │ └── SettingsView.kt │ │ │ ├── splash │ │ │ ├── SplashActivity.kt │ │ │ ├── SplashPresenter.kt │ │ │ └── SplashView.kt │ │ │ ├── transitions │ │ │ ├── CircularReveal.kt │ │ │ └── NoPauseAnimator.kt │ │ │ ├── ui │ │ │ ├── MainActivity.kt │ │ │ ├── MainPresenter.kt │ │ │ ├── MainView.kt │ │ │ └── ViewState.kt │ │ │ ├── viewholders │ │ │ ├── CurrentWeatherViewHolder.kt │ │ │ └── ForecastViewHolder.kt │ │ │ ├── viewmodels │ │ │ ├── CurrentWeatherViewModel.kt │ │ │ ├── ForecastViewModel.kt │ │ │ └── ToolbarViewModel.kt │ │ │ └── widgets │ │ │ ├── DrawableTextLabel.kt │ │ │ └── ForecastLayout.kt │ └── res │ │ ├── anim │ │ ├── slide_in.xml │ │ └── slide_out.xml │ │ ├── drawable │ │ ├── clear_day.xml │ │ ├── clear_night.xml │ │ ├── cloudy.xml │ │ ├── fog.xml │ │ ├── ic_check_selected.xml │ │ ├── ic_check_unselected.xml │ │ ├── ic_close.xml │ │ ├── ic_next.xml │ │ ├── ic_settings.xml │ │ ├── label_thermometer.xml │ │ ├── label_wind.xml │ │ ├── partly_cloudy_day.xml │ │ ├── partly_cloudy_night.xml │ │ ├── rain.xml │ │ ├── ralph.xml │ │ ├── settings_check_selector.xml │ │ ├── sleet.xml │ │ ├── snow.xml │ │ ├── stripes.xml │ │ └── wind.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_settings.xml │ │ ├── activity_splash.xml │ │ ├── activity_webview.xml │ │ ├── grid_item_current_weather.xml │ │ ├── grid_item_forecast.xml │ │ ├── label_drawable_text.xml │ │ └── layout_forecast.xml │ │ ├── menu │ │ ├── close_menu.xml │ │ └── settings_menu.xml │ │ ├── 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 │ │ ├── transition │ │ └── reveal.xml │ │ ├── values-v21 │ │ └── styles.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── kotlin │ └── com │ └── thoughtbot │ └── tropos │ ├── data │ └── TemperatureDifferenceTest.kt │ ├── settings │ └── SettingsPresenterTest.kt │ ├── splash │ └── SplashPresenterTest.kt │ ├── testUtils │ ├── Assertions.kt │ ├── FakeCondition.kt │ └── MockGeocoder.kt │ ├── ui │ └── MainPresenterTest.kt │ └── viewmodels │ ├── CurrentWeatherViewModelTest.kt │ ├── ErrorToolbarViewModelTest.kt │ ├── ForecastViewModelTest.kt │ ├── LoadingToolbarViewModelTest.kt │ └── WeatherToolbarViewModelTest.kt ├── bin └── setup ├── build.gradle ├── circle.yml ├── environmentSetup.sh ├── fastlane ├── Appfile ├── Fastfile └── README.md ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | ### Android ### 2 | # Built application files 3 | *.apk 4 | *.ap_ 5 | 6 | # Files for the Dalvik VM 7 | *.dex 8 | 9 | # Java class files 10 | *.class 11 | 12 | # Generated files 13 | gen/ 14 | 15 | # Gradle files 16 | .gradle/ 17 | build/ 18 | 19 | # Local configuration file (sdk path, etc) 20 | local.properties 21 | 22 | # Log Files 23 | *.log 24 | ### Intellij ### 25 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 26 | 27 | *.iml 28 | build 29 | 30 | ## Directory-based project format: 31 | .idea/ 32 | 33 | ## File-based project format: 34 | *.ipr 35 | *.iws 36 | 37 | ## Plugin-specific files: 38 | 39 | # IntelliJ 40 | /out/ 41 | 42 | # Keystore for signing release builds 43 | app/tropos.keystore 44 | 45 | # Credentials for accessing tropos.keystore 46 | app/signing.gradle 47 | 48 | # Credentials for shipping 49 | app/tropos.json 50 | 51 | # fastlane 52 | fastlane/report.xml 53 | fastlane/Preview.html 54 | fastlane/screenshots 55 | 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 1.1 - February 14th 2017 4 | - Add SettingsActivity 5 | - Add support for metric units 6 | - Add Fastlane 7 | - Fix mismatched Retrofit artifact versions 8 | 9 | ## Version 1.0 - January 27th 2017 10 | Initial Release 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We love pull requests from everyone. Follow the thoughtbot [code of conduct] 2 | while contributing. 3 | 4 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 5 | 6 | ## Contributing 7 | 8 | 1. Fork the repo. 9 | 2. Run the tests. We only take pull requests with passing tests, and it's 10 | great to know that you have a clean slate. 11 | 3. Add a test for your change. Only refactoring and documentation changes 12 | require no new tests. If you are adding functionality or fixing a bug, we 13 | need a test! 14 | 4. Make the test pass. 15 | 5. Push to your fork and submit a pull request. 16 | 17 | At this point you're waiting on us. We like to at least comment on, if not 18 | accept, pull requests within three business days (and, typically, one business 19 | day). We may suggest some changes or improvements or alternatives. 20 | 21 | Some things that will increase the chance that your pull request is accepted, 22 | 23 | * Include tests that fail without your code, and pass with it 24 | * Update the documentation, the surrounding one, examples elsewhere, guides, 25 | whatever is affected by your contribution 26 | * Follow the existing style of the project -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 thoughtbot, inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tropos for Android [![CircleCI](https://circleci.com/gh/thoughtbot/tropos-android/tree/master.svg?style=svg&circle-token=6998942c05bf1870244c7cc7d9a71424480e5e49)](https://circleci.com/gh/thoughtbot/tropos-android/tree/master) 2 | 3 | Get it on Google Play 4 | 5 | Weather and forecasts for humans. 6 | Information you can act on. 7 | 8 | Most weather apps throw a lot of information at you 9 | but that doesn't answer the question of "What does it feel like outside?". 10 | Tropos answers this by relating the current conditions 11 | to conditions at the same time yesterday. 12 | 13 | # API Keys 14 | API keys are brought in at build time via gradle.properties file. Visit https://developer.forecast.io/ 15 | to obtain a developerAPI key. Then place this key in your machine's ~/.gradle/gradle.properties file: 16 | 17 | `DARK_SKY_FORECAST_API_KEY=123abc` 18 | 19 | # Testing 20 | The `test` directory contains unit tests, using JUnit and Robolectric. 21 | 22 | Contributing 23 | ------------ 24 | 25 | See the [CONTRIBUTING] document. 26 | Thank you, [contributors]! 27 | 28 | [CONTRIBUTING]: CONTRIBUTING.md 29 | [contributors]: https://github.com/thoughtbot/tropos-android/graphs/contributors 30 | 31 | 32 | License 33 | ------- 34 | 35 | Tropos is Copyright (c) 2017 thoughtbot, inc. It is free software, 36 | and may be redistributed under the terms specified in the [LICENSE] file. 37 | 38 | [LICENSE]: /LICENSE 39 | 40 | About 41 | ----- 42 | 43 | ![thoughtbot](https://thoughtbot.com/logo.png) 44 | 45 | Tropos is maintained and funded by thoughtbot, inc. 46 | The names and logos for thoughtbot are trademarks of thoughtbot, inc. 47 | 48 | We love open source software! 49 | See [our other projects][community] 50 | or [hire us][hire] to help build your product. 51 | 52 | [community]: https://thoughtbot.com/community?utm_source=github 53 | [hire]: https://thoughtbot.com/hire-us?utm_source=github -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release To The Google Play Store 2 | 3 | ## Setup 4 | 5 | Run the setup script: 6 | 7 | ``` 8 | ./bin/setup 9 | ``` 10 | 11 | ## Releasing the app 12 | 13 | ``` 14 | fastlane android release 15 | ``` 16 | 17 | See the [fastlane documentation](https://github.com/thoughtbot/tropos-android/blob/master/fastlane/README.md) for more options and details 18 | 19 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | def isCircle = "true".equals(System.getenv("CIRCLECI")) 6 | 7 | ext.signing = [storeFilePath: "path/to/keystore", 8 | storePassword: "keystore password", 9 | keyAlias : "key alias", 10 | keyPassword : "key password"] 11 | 12 | if (file('signing.gradle').exists()) { 13 | apply from: 'signing.gradle' 14 | } 15 | 16 | ext { 17 | okHttpVersion = '3.5.0' 18 | retrofitVersion = '2.1.0' 19 | supportLibraryVersion = '26.0.0-beta1' 20 | } 21 | 22 | 23 | android { 24 | compileSdkVersion 28 25 | buildToolsVersion "25.0.2" 26 | defaultConfig { 27 | applicationId "com.thoughtbot.tropos" 28 | minSdkVersion 19 29 | targetSdkVersion 28 30 | versionCode 6 31 | versionName "1.1" 32 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 33 | } 34 | 35 | signingConfigs { 36 | release { 37 | storeFile file(project.signing.storeFilePath) 38 | storePassword project.signing.storePassword 39 | keyAlias project.signing.keyAlias 40 | keyPassword project.signing.keyPassword 41 | } 42 | } 43 | 44 | buildTypes { 45 | release { 46 | minifyEnabled false 47 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 48 | buildConfigField("String", "FORECAST_API_KEY", "\"" + DARK_SKY_FORECAST_API_KEY + "\"") 49 | if (isCircle) { 50 | // The release build generated on CircleCI doesn't need to be signed with our real 51 | // keystore - we just need a release build to verify that it compiles. Using the 52 | // debug keystore means we don't have to expose our keystore. 53 | } else { 54 | signingConfig signingConfigs.release 55 | } 56 | } 57 | debug { 58 | buildConfigField("String", "FORECAST_API_KEY", "\"" + DARK_SKY_FORECAST_API_KEY + "\"") 59 | } 60 | } 61 | 62 | sourceSets { 63 | main.java.srcDirs += 'src/main/kotlin' 64 | test.java.srcDirs += 'src/test/kotlin' 65 | } 66 | } 67 | 68 | 69 | dependencies { 70 | compile fileTree(dir: 'libs', include: ['*.jar']) 71 | 72 | //kotlin 73 | compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 74 | compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" 75 | compile 'org.jetbrains.anko:anko-common:0.8.3' 76 | 77 | compile 'androidx.recyclerview:recyclerview:1.0.0' 78 | compile 'com.google.android.gms:play-services-location:10.0.0' 79 | 80 | //unit tests 81 | testCompile 'junit:junit:4.12' 82 | testCompile 'org.robolectric:robolectric:3.0' 83 | testCompile 'org.robolectric:shadows-maps:3.0' 84 | testCompile 'org.mockito:mockito-core:1.10.5' 85 | testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" 86 | testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version" 87 | testCompile "com.nhaarman:mockito-kotlin:1.1.0" 88 | 89 | //automation tests 90 | androidTestCompile 'androidx.test.espresso:espresso-core:3.1.0' 91 | androidTestCompile 'androidx.test.ext:junit:1.1.1' 92 | androidTestCompile 'androidx.annotation:annotation:1.0.0' 93 | 94 | //networking 95 | compile "com.squareup.retrofit2:retrofit:${retrofitVersion}" 96 | compile "com.squareup.retrofit2:converter-gson:${retrofitVersion}" 97 | compile "com.squareup.okhttp3:logging-interceptor:${okHttpVersion}" 98 | compile 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0' 99 | 100 | //rx 101 | compile 'io.reactivex.rxjava2:rxjava:2.0.4' 102 | compile 'io.reactivex.rxjava2:rxandroid:2.0.1' 103 | 104 | //rx location 105 | compile 'com.patloew.rxlocation:rxlocation:1.0.1' 106 | 107 | //custom fonts 108 | compile 'uk.co.chrisjenx:calligraphy:2.1.0' 109 | } 110 | -------------------------------------------------------------------------------- /app/increaseVersionCode.gradle: -------------------------------------------------------------------------------- 1 | import java.util.regex.Pattern 2 | 3 | task('increaseVersionCode') << { 4 | def buildFile = file("build.gradle") 5 | def pattern = Pattern.compile("versionCode\\s+(\\d+)") 6 | def buildText = buildFile.getText() 7 | def matcher = pattern.matcher(buildText) 8 | matcher.find() 9 | def versionCode = Integer.parseInt(matcher.group(1)) 10 | def buildContent = matcher.replaceAll("versionCode " + ++versionCode) 11 | buildFile.write(buildContent) 12 | } 13 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/amanda/Library/Android/sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /app/signing.gradle.example: -------------------------------------------------------------------------------- 1 | ext.signing = [ 2 | storeFilePath : "tropos.keystore", 3 | storePassword : "", 4 | keyAlias : "tropos_keystore", 5 | keyPassword : "" 6 | ] -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/assets/DINNextLTPro-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/assets/DINNextLTPro-Light.otf -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/adapters/WeatherAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.adapters 2 | 3 | 4 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 5 | 6 | import android.view.LayoutInflater.from 7 | import android.view.ViewGroup 8 | import androidx.recyclerview.widget.GridLayoutManager 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.thoughtbot.tropos.R.layout 11 | import com.thoughtbot.tropos.data.Condition 12 | import com.thoughtbot.tropos.data.Weather 13 | import com.thoughtbot.tropos.viewholders.CurrentWeatherViewHolder 14 | import com.thoughtbot.tropos.viewholders.ForecastViewHolder 15 | 16 | class WeatherAdapter : RecyclerView.Adapter() { 17 | 18 | var weather: Weather? = null 19 | set(value) { 20 | field = value 21 | notifyDataSetChanged() 22 | } 23 | 24 | val spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { 25 | override fun getSpanSize(position: Int): Int { 26 | return if (isCurrentWeather(position)) 3 else 1 27 | } 28 | } 29 | 30 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 31 | when (viewType) { 32 | layout.grid_item_current_weather -> { 33 | val view = from(parent!!.context).inflate(layout.grid_item_current_weather, parent, false) 34 | return CurrentWeatherViewHolder(view) 35 | } 36 | layout.grid_item_forecast -> { 37 | val view = from(parent!!.context).inflate(layout.grid_item_forecast, parent, false) 38 | return ForecastViewHolder(view) 39 | } 40 | else -> throw IllegalArgumentException("$viewType is not a valid viewType") 41 | } 42 | } 43 | 44 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 45 | when (holder) { 46 | is CurrentWeatherViewHolder -> weather?.let { holder.bind(it.today, it.yesterday) } 47 | is ForecastViewHolder -> weather?.let { holder.bind(forecast(it, position)) } 48 | } 49 | } 50 | 51 | override fun getItemViewType(position: Int): Int { 52 | if (isCurrentWeather(position)) { 53 | return layout.grid_item_current_weather 54 | } else { 55 | return layout.grid_item_forecast 56 | } 57 | } 58 | 59 | override fun getItemCount(): Int { 60 | // 1 current weather + 3 daily forecasts 61 | return if (weather == null) 0 else 4 62 | } 63 | 64 | private fun isCurrentWeather(position: Int): Boolean { 65 | return position == 0 66 | } 67 | 68 | private fun forecast(weather: Weather, position: Int): Condition { 69 | //subtract one to account for the current condition 70 | return weather.nextThreeDays[position - 1] 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/commons/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.commons 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.thoughtbot.tropos.R 7 | import uk.co.chrisjenx.calligraphy.CalligraphyConfig 8 | import uk.co.chrisjenx.calligraphy.CalligraphyContextWrapper 9 | 10 | open class BaseActivity : AppCompatActivity() { 11 | 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | CalligraphyConfig.initDefault(CalligraphyConfig.Builder() 15 | .setDefaultFontPath("DINNextLTPro-Light.otf") 16 | .setFontAttrId(R.attr.fontPath) 17 | .build() 18 | ) 19 | } 20 | 21 | override fun attachBaseContext(newBase: Context?) { 22 | super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase)) 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/commons/Presenter.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.commons 2 | 3 | interface Presenter { 4 | 5 | val view: View 6 | 7 | } 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/commons/View.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.commons 2 | 3 | import android.content.Context 4 | 5 | interface View { 6 | val context: Context 7 | } 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/commons/ViewBinder.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.commons 2 | 3 | import kotlin.properties.ReadWriteProperty 4 | import kotlin.reflect.KProperty 5 | 6 | class ViewBinder(val function: (M) -> Unit) : ReadWriteProperty { 7 | private var mValue: M? = null 8 | 9 | override fun getValue(thisRef: Any, property: KProperty<*>): M { 10 | return mValue as M 11 | } 12 | 13 | override fun setValue(thisRef: Any, property: KProperty<*>, value: M) { 14 | mValue = value 15 | function(value) 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/commons/WebviewActivity.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.commons 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.KeyEvent 7 | import android.webkit.WebView 8 | import android.webkit.WebViewClient 9 | import com.thoughtbot.tropos.R 10 | import org.jetbrains.anko.find 11 | 12 | class WebviewActivity : BaseActivity() { 13 | 14 | companion object { 15 | val URL_EXTRA = "url_extra" 16 | 17 | fun createIntent(context: Context, url: String?): Intent { 18 | val intent = Intent(context, WebviewActivity::class.java) 19 | url?.let { intent.putExtra(URL_EXTRA, it) } 20 | return intent 21 | } 22 | } 23 | 24 | private val DEFAULT_URL = "https://www.thoughtbot.com" 25 | private val webview: WebView by lazy { find(R.id.webview) } 26 | 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | setContentView(R.layout.activity_webview) 30 | 31 | val url: String = intent?.getStringExtra(URL_EXTRA) ?: DEFAULT_URL 32 | webview.webViewClient = WebViewClient() 33 | webview.loadUrl(url) 34 | } 35 | 36 | override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { 37 | // Check if the key event was the Back button and if there's history 38 | if ((keyCode == KeyEvent.KEYCODE_BACK) && webview.canGoBack()) { 39 | webview.goBack() 40 | return true 41 | } 42 | // If it wasn't the Back key or there's no web page history, bubble up to the default 43 | // system behavior (probably exit the activity) 44 | return super.onKeyDown(keyCode, event) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/Condition.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import android.location.Location 4 | import android.os.Parcel 5 | import android.os.Parcelable 6 | import com.thoughtbot.tropos.extensions.createParcel 7 | import java.util.* 8 | 9 | data class Condition( 10 | val date: Date, 11 | val summary: String, 12 | val location: Location, 13 | val icon: Icon, 14 | val windSpeed: Int, 15 | val windDirection: WindDirection, 16 | val unit: Unit, 17 | val lowTemp: Int, 18 | val currentTemp: Int, 19 | val highTemp: Int) : Parcelable { 20 | 21 | companion object { 22 | @JvmField val CREATOR = createParcel(::Condition) 23 | } 24 | 25 | constructor(source: Parcel) : this( 26 | Date(source.readLong()), 27 | source.readString(), 28 | source.readParcelable(Location::class.java.classLoader), 29 | Icon.values()[source.readInt()], 30 | source.readInt(), 31 | WindDirection.values()[source.readInt()], 32 | Unit.values()[source.readInt()], 33 | source.readInt(), 34 | source.readInt(), 35 | source.readInt()) 36 | 37 | override fun describeContents() = 0 38 | 39 | override fun writeToParcel(dest: Parcel?, flags: Int) { 40 | dest?.writeLong(date.time) 41 | dest?.writeString(summary) 42 | dest?.writeParcelable(location, 0) 43 | dest?.writeInt(icon.ordinal) 44 | dest?.writeInt(windSpeed) 45 | dest?.writeInt(windDirection.ordinal) 46 | dest?.writeInt(unit.ordinal) 47 | dest?.writeInt(lowTemp) 48 | dest?.writeInt(currentTemp) 49 | dest?.writeInt(highTemp) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/ConditionDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import android.location.Location 4 | import io.reactivex.Observable 5 | import java.util.Date 6 | 7 | interface ConditionDataSource { 8 | 9 | fun fetchCondition(forLocation: Location, forDate: Date): Observable 10 | 11 | fun fetchForecast(forLocation: Location, forNumOfDaysFromToday: Int): Observable> 12 | 13 | } 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/Icon.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import com.thoughtbot.tropos.R 4 | 5 | enum class Icon { 6 | CLEAR_DAY, CLEAR_NIGHT, RAIN, SNOW, SLEET, WIND, FOG, CLOUDY, PARTLY_CLOUDY_DAY, PARTLY_CLOUDY_NIGHT; 7 | } 8 | 9 | fun Icon.iconResId(): Int { 10 | return when (this) { 11 | Icon.CLEAR_DAY -> R.drawable.clear_day 12 | Icon.CLEAR_NIGHT -> R.drawable.clear_night 13 | Icon.RAIN -> R.drawable.rain 14 | Icon.SNOW -> R.drawable.snow 15 | Icon.SLEET -> R.drawable.sleet 16 | Icon.WIND -> R.drawable.wind 17 | Icon.FOG -> R.drawable.fog 18 | Icon.CLOUDY -> R.drawable.cloudy 19 | Icon.PARTLY_CLOUDY_DAY -> R.drawable.partly_cloudy_day 20 | Icon.PARTLY_CLOUDY_NIGHT -> R.drawable.partly_cloudy_night 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/LocationDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import android.location.Location 4 | import io.reactivex.Observable 5 | 6 | interface LocationDataSource { 7 | 8 | fun fetchLocation(): Observable 9 | } 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/Preferences.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | interface Preferences { 4 | 5 | var unit: Unit 6 | 7 | } 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/TemperatureDifference.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import com.thoughtbot.tropos.data.TemperatureDifference.COLDER 4 | import com.thoughtbot.tropos.data.TemperatureDifference.COOLER 5 | import com.thoughtbot.tropos.data.TemperatureDifference.HOTTER 6 | import com.thoughtbot.tropos.data.TemperatureDifference.SAME 7 | import com.thoughtbot.tropos.data.TemperatureDifference.WARMER 8 | 9 | enum class TemperatureDifference { 10 | HOTTER, COLDER, SAME, WARMER, COOLER; 11 | } 12 | 13 | fun TemperatureDifference(today: Condition, yesterday: Condition): TemperatureDifference { 14 | val HOTTER_LIMIT: Int = 32 15 | val COLDER_LIMIT: Int = 75 16 | 17 | val diff = today.currentTemp - yesterday.currentTemp 18 | 19 | return when { 20 | diff >= 10 && today.currentTemp > HOTTER_LIMIT -> HOTTER 21 | diff > 0 -> WARMER 22 | diff == 0 -> SAME 23 | diff > -10 || today.currentTemp > COLDER_LIMIT -> COOLER 24 | else -> COLDER 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/TimeOfDay.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import com.thoughtbot.tropos.data.TimeOfDay.AFTERNOON 4 | import com.thoughtbot.tropos.data.TimeOfDay.DAY 5 | import com.thoughtbot.tropos.data.TimeOfDay.MORNING 6 | import com.thoughtbot.tropos.data.TimeOfDay.NIGHT 7 | import java.util.Date 8 | 9 | enum class TimeOfDay { 10 | MORNING, DAY, AFTERNOON, NIGHT; 11 | } 12 | 13 | fun TimeOfDay(date: Date): TimeOfDay { 14 | val hour = date.hours 15 | return when (hour) { 16 | in 0..4 -> NIGHT 17 | in 4..9 -> MORNING 18 | in 9..14 -> DAY 19 | in 14..17 -> AFTERNOON 20 | else -> NIGHT 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/Unit.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | enum class Unit { 4 | IMPERIAL, METRIC 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/Weather.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import com.thoughtbot.tropos.extensions.createParcel 6 | 7 | data class Weather(val yesterday: Condition, val today: Condition, 8 | val nextThreeDays: List) : Parcelable { 9 | 10 | companion object { 11 | @JvmField val CREATOR = createParcel(::Weather) 12 | } 13 | 14 | constructor(source: Parcel) : this( 15 | source.readParcelable(Condition::class.java.classLoader), 16 | source.readParcelable(Condition::class.java.classLoader), 17 | source.createTypedArrayList(Condition.CREATOR)) 18 | 19 | override fun describeContents() = 0 20 | 21 | override fun writeToParcel(dest: Parcel?, flags: Int) { 22 | dest?.writeParcelable(yesterday, 0) 23 | dest?.writeParcelable(today, 0) 24 | dest?.writeTypedList(nextThreeDays) 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/WeatherDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import io.reactivex.Observable 4 | 5 | interface WeatherDataSource { 6 | 7 | fun fetchWeather(): Observable 8 | } 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/WeatherService.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.thoughtbot.tropos.data.remote.ConditionDataService 6 | import com.thoughtbot.tropos.data.remote.LocationService 7 | import com.thoughtbot.tropos.extensions.dayBefore 8 | import com.thoughtbot.tropos.ui.MainActivity 9 | import io.reactivex.Observable 10 | import java.util.Date 11 | 12 | class WeatherService( 13 | val context: Context, 14 | intent: Intent?, 15 | val locationDataSource: LocationDataSource = LocationService(context), 16 | val conditionDataSource: ConditionDataSource = ConditionDataService()) : WeatherDataSource { 17 | 18 | val weather: Weather? = intent?.getParcelableExtra(MainActivity.WEATHER_EXTRA) 19 | var initialRequest = true 20 | 21 | override fun fetchWeather(): Observable { 22 | if (weather != null && initialRequest) { 23 | //only return the Weather from Intent on initial request, subsequent calls should hit the API 24 | initialRequest = false 25 | return Observable.just(weather) 26 | } else { 27 | return locationDataSource.fetchLocation() 28 | .flatMap { conditionDataSource.fetchForecast(it, 3) } 29 | .flatMap({ forecast -> 30 | conditionDataSource.fetchCondition(forecast[0].location, Date().dayBefore()) 31 | }, { forecast, yesterday -> 32 | return@flatMap Weather(yesterday, forecast[0], forecast.drop(1)) 33 | }) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/WindDirection.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import com.thoughtbot.tropos.R 4 | import com.thoughtbot.tropos.data.WindDirection.EAST 5 | import com.thoughtbot.tropos.data.WindDirection.NONE 6 | import com.thoughtbot.tropos.data.WindDirection.NORTH 7 | import com.thoughtbot.tropos.data.WindDirection.NORTH_EAST 8 | import com.thoughtbot.tropos.data.WindDirection.NORTH_WEST 9 | import com.thoughtbot.tropos.data.WindDirection.SOUTH 10 | import com.thoughtbot.tropos.data.WindDirection.SOUTH_EAST 11 | import com.thoughtbot.tropos.data.WindDirection.SOUTH_WEST 12 | import com.thoughtbot.tropos.data.WindDirection.WEST 13 | 14 | enum class WindDirection { 15 | NONE, NORTH, NORTH_EAST, EAST, SOUTH_EAST, SOUTH, SOUTH_WEST, WEST, NORTH_WEST; 16 | } 17 | 18 | fun WindDirection.labelResId(): Int { 19 | return when (this) { 20 | NORTH -> R.string.north_abbrev 21 | NORTH_EAST -> R.string.north_east_abbrev 22 | EAST -> R.string.east_abbrev 23 | SOUTH_EAST -> R.string.south_east_abbrev 24 | SOUTH -> R.string.south_abbrev 25 | SOUTH_WEST -> R.string.south_west_abrrev 26 | WEST -> R.string.west_abbrev 27 | NORTH_WEST -> R.string.north_west_abbrev 28 | NONE -> return R.string.no_direction 29 | } 30 | 31 | } 32 | 33 | fun WindDirection(windBearing: Double): WindDirection { 34 | return when (windBearing) { 35 | in 0.0..22.5 -> NORTH 36 | in 22.5..67.5 -> NORTH_EAST 37 | in 67.5..112.5 -> EAST 38 | in 112.5..157.5 -> SOUTH_EAST 39 | in 157.5..202.5 -> SOUTH 40 | in 202.5..247.5 -> SOUTH_WEST 41 | in 247.5..292.5 -> WEST 42 | in 292.5..337.5 -> NORTH_WEST 43 | else -> return NONE 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/local/LocalPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.local 2 | 3 | import android.content.Context 4 | import com.thoughtbot.tropos.data.Preferences 5 | import com.thoughtbot.tropos.data.Unit 6 | import com.thoughtbot.tropos.extensions.edit 7 | import org.jetbrains.anko.defaultSharedPreferences 8 | 9 | class LocalPreferences(val context: Context) : Preferences { 10 | 11 | val UNIT = "unit" 12 | 13 | override var unit: Unit 14 | get() { 15 | //default to Imperial 16 | return Unit.values()[context.defaultSharedPreferences.getInt(UNIT, 0)] 17 | } 18 | set(value) { 19 | context.defaultSharedPreferences.edit { putInt(UNIT, value.ordinal) } 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/remote/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.remote 2 | 3 | import io.reactivex.Observable 4 | import retrofit2.http.GET 5 | import retrofit2.http.Path 6 | 7 | interface ApiService { 8 | 9 | @GET("{latitude},{longitude}") 10 | fun fetchRemoteForecast(@Path("latitude") latitude: Double, 11 | @Path("longitude") longitude: Double): Observable 12 | 13 | @GET("{latitude},{longitude},{time}") 14 | fun fetchRemoteForecast(@Path("latitude") latitude: Double, 15 | @Path("longitude") longitude: Double, @Path("time") time: Long): Observable 16 | } 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/remote/ConditionDataService.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.remote 2 | 3 | import android.location.Location 4 | import com.thoughtbot.tropos.data.Icon 5 | import com.thoughtbot.tropos.data.Condition 6 | import com.thoughtbot.tropos.data.ConditionDataSource 7 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 8 | import com.thoughtbot.tropos.data.WindDirection 9 | import io.reactivex.Observable 10 | import io.reactivex.android.schedulers.AndroidSchedulers 11 | import io.reactivex.schedulers.Schedulers 12 | import java.util.Date 13 | 14 | class ConditionDataService(val api: ApiService = RestClient().create( 15 | ApiService::class.java)) : ConditionDataSource { 16 | 17 | override fun fetchCondition(forLocation: Location, forDate: Date): Observable { 18 | return api.fetchRemoteForecast(forLocation.latitude, forLocation.longitude, forDate.time / 1000) 19 | .subscribeOn(Schedulers.io()) 20 | .observeOn(AndroidSchedulers.mainThread()) 21 | .map { it.mapToCondition(0) } 22 | } 23 | 24 | override fun fetchForecast(forLocation: Location, 25 | forNumOfDaysFromToday: Int): Observable> { 26 | return api.fetchRemoteForecast(forLocation.latitude, forLocation.longitude) 27 | .subscribeOn(Schedulers.io()) 28 | .observeOn(AndroidSchedulers.mainThread()) 29 | .map { it.mapToConditionList(forNumOfDaysFromToday) } 30 | } 31 | 32 | private fun RemoteForecast.mapToConditionList(forNumOfDaysFromToday: Int): List { 33 | var list = emptyList() 34 | for (i in 0..forNumOfDaysFromToday) { 35 | list = list.plus(this.mapToCondition(i)) 36 | } 37 | return list 38 | } 39 | 40 | private fun RemoteForecast.mapToCondition(dayOffset: Int): Condition { 41 | return when (dayOffset) { 42 | //today 43 | 0 -> { 44 | val date = this.currently.time 45 | val summary = this.currently.summary ?: "" 46 | val location = Location("") 47 | location.longitude = this.longitude 48 | location.latitude = this.latitude 49 | val icon = this.currently.icon.mapToIcon() 50 | val windSpeed = this.currently.windSpeed?.toInt() ?: 0 51 | val windDirection = WindDirection(this.currently.windBearing ?: 0.0) 52 | val unit = IMPERIAL // API returns imperial by default 53 | val lowTemp = this.daily.data[0].temperatureMin?.toInt() ?: 0 54 | val highTemp = this.daily.data[0].temperatureMax?.toInt() ?: 0 55 | val temp = this.currently.temperature?.toInt() ?: 0 56 | 57 | Condition(date, summary, location, icon, windSpeed, windDirection, unit, lowTemp, temp, 58 | highTemp) 59 | } 60 | //this week 61 | in 1..7 -> { 62 | val date = this.daily.data[dayOffset].time 63 | val summary = this.daily.data[dayOffset].summary ?: "" 64 | val location = Location("") 65 | location.longitude = this.longitude 66 | location.latitude = this.latitude 67 | val icon = this.daily.data[dayOffset].icon.mapToIcon() 68 | val windSpeed = this.daily.data[dayOffset].windSpeed?.toInt() ?: 0 69 | val windDirection = WindDirection(this.daily.data[dayOffset].windBearing ?: 0.0) 70 | val unit = IMPERIAL // API returns imperial by default 71 | val lowTemp = this.daily.data[dayOffset].temperatureMin?.toInt() ?: 0 72 | val highTemp = this.daily.data[dayOffset].temperatureMax?.toInt() ?: 0 73 | val temp = this.daily.data[dayOffset].temperature?.toInt() ?: 0 74 | 75 | Condition(date, summary, location, icon, windSpeed, windDirection, unit, lowTemp, temp, 76 | highTemp) 77 | } 78 | else -> throw IllegalArgumentException("dayOffset must be <= 7") 79 | } 80 | } 81 | 82 | private fun String.mapToIcon(): Icon { 83 | val formatted = this.replace('-', '_').toUpperCase() 84 | return Icon.values() 85 | .find { it.name.contentEquals(formatted) } 86 | ?: throw IllegalArgumentException("$formatted is not a valid Icon") 87 | } 88 | 89 | } 90 | 91 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/remote/LocationService.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.remote 2 | 3 | import android.content.Context 4 | import android.location.Location 5 | import com.google.android.gms.location.LocationRequest 6 | import com.patloew.rxlocation.RxLocation 7 | import com.thoughtbot.tropos.data.LocationDataSource 8 | import io.reactivex.Observable 9 | import io.reactivex.android.schedulers.AndroidSchedulers 10 | import io.reactivex.schedulers.Schedulers 11 | 12 | class LocationService(val context: Context) : LocationDataSource { 13 | 14 | val locationRequest: LocationRequest = LocationRequest.create() 15 | .setPriority(LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY) 16 | .setNumUpdates(1) 17 | 18 | override fun fetchLocation(): Observable { 19 | val rxLocation = RxLocation(context) 20 | return rxLocation.settings().checkAndHandleResolution(locationRequest) 21 | .flatMapObservable { hasPermission -> 22 | if (hasPermission) { 23 | return@flatMapObservable rxLocation.location().updates(locationRequest) 24 | .subscribeOn(Schedulers.io()) 25 | .observeOn(AndroidSchedulers.mainThread()) 26 | } else { 27 | return@flatMapObservable rxLocation.location().lastLocation() 28 | .flatMapObservable { Observable.just(it) } 29 | } 30 | } 31 | } 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/remote/Responses.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.remote 2 | 3 | import java.util.Date 4 | 5 | /* 6 | * RemoteForecast represents the response from the Dark Sky API 7 | */ 8 | data class RemoteForecast( 9 | val latitude: Double, 10 | val longitude: Double, 11 | val currently: DataPoint, 12 | val daily: DataBlock 13 | ) 14 | 15 | /* 16 | * A data point object contains various properties, each representing the 17 | * average (unless otherwise specified) of a particular weather 18 | * phenomenon occurring *during* a period of time. 19 | */ 20 | data class DataPoint( 21 | val time: Date, 22 | val temperature: Double?, 23 | val temperatureMax: Double?, 24 | val temperatureMin: Double?, 25 | val icon: String, 26 | val summary: String?, 27 | val windSpeed: Double?, 28 | val windBearing: Double? 29 | ) 30 | /* 31 | * A data block object represents the various weather phenomena 32 | * occurring *over* a period of time. 33 | */ 34 | 35 | data class DataBlock( 36 | val data: List, 37 | val summary: String?, 38 | val icon: String 39 | ) 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/data/remote/RestClient.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data.remote 2 | 3 | import com.google.gson.* 4 | import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 5 | import com.thoughtbot.tropos.BuildConfig 6 | import retrofit2.Retrofit 7 | 8 | import okhttp3.HttpUrl 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import java.util.* 13 | 14 | class RestClient { 15 | 16 | fun create(apiInterface: Class): T { 17 | val retrofit = Retrofit.Builder() 18 | .baseUrl(baseUrl) 19 | .client(okHttpClient) 20 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 21 | .addConverterFactory(GsonConverterFactory.create(dateConverter())) 22 | .build() 23 | return retrofit.create(apiInterface) 24 | } 25 | 26 | private val baseUrl: HttpUrl = HttpUrl.Builder() 27 | .scheme("https") 28 | .host("api.forecast.io") 29 | .addPathSegment("forecast") 30 | .addPathSegment(BuildConfig.FORECAST_API_KEY) 31 | .addPathSegment("") 32 | .build() 33 | 34 | private val okHttpClient = OkHttpClient.Builder() 35 | .addInterceptor(loggingInterceptor).build() 36 | 37 | private val loggingInterceptor: HttpLoggingInterceptor 38 | get() { 39 | val logging = HttpLoggingInterceptor() 40 | logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE 41 | return logging 42 | } 43 | 44 | private fun dateConverter(): Gson { 45 | val builder = GsonBuilder() 46 | builder.registerTypeAdapter(Date::class.java, 47 | JsonDeserializer { json, typeOfT, context -> 48 | Date(json.asJsonPrimitive.asLong * 1000) 49 | }) 50 | return builder.create() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/extensions/ContextExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.extensions 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.content.pm.PackageManager 6 | import androidx.core.content.ContextCompat 7 | 8 | fun Context.hasPermission(permission: String): Boolean { 9 | return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED 10 | } 11 | 12 | inline fun SharedPreferences.edit(func: SharedPreferences.Editor.() -> Unit) { 13 | val editor = edit() 14 | editor.func() 15 | editor.apply() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/extensions/DateExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.extensions 2 | 3 | import java.util.Date 4 | 5 | fun Date.dayBefore(): Date { 6 | val newTime = this.time - 24 * 60 * 60 * 1000 7 | return Date(newTime) 8 | } 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/extensions/IntExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.extensions 2 | 3 | import com.thoughtbot.tropos.data.Unit 4 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 5 | import com.thoughtbot.tropos.data.Unit.METRIC 6 | 7 | fun Int.convertTemperature(from: Unit, to: Unit): Int { 8 | when (from) { 9 | IMPERIAL -> return if (to == IMPERIAL) this else ((this - 32) / 1.8).toInt() 10 | METRIC -> return if (to == METRIC) this else (this * 1.8 + 32).toInt() 11 | } 12 | } 13 | 14 | fun Int.convertSpeed(from: Unit, to: Unit): Int { 15 | when (from) { 16 | IMPERIAL -> return if (to == IMPERIAL) this else (this * 1.609344).toInt() 17 | METRIC -> return if (to == METRIC) this else (this / 1.609344).toInt() 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/extensions/ParcelExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.extensions 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | 6 | inline fun createParcel( 7 | crossinline createFromParcel: (Parcel) -> T?): Parcelable.Creator = object : Parcelable.Creator { 8 | override fun createFromParcel(source: Parcel): T? = createFromParcel(source) 9 | override fun newArray(size: Int): Array = arrayOfNulls(size) 10 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/extensions/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.extensions 2 | 3 | import android.graphics.Color 4 | import android.text.Spannable 5 | import android.text.SpannableStringBuilder 6 | import android.text.style.ForegroundColorSpan 7 | import androidx.recyclerview.widget.RecyclerView 8 | import androidx.recyclerview.widget.SnapHelper 9 | 10 | fun Int.lightenBy(amount: Float): Int { 11 | val red = ((Color.red(this) * (1 - amount) / 255 + amount) * 255).toInt() 12 | val green = ((Color.green(this) * (1 - amount) / 255 + amount) * 255).toInt() 13 | val blue = ((Color.blue(this) * (1 - amount) / 255 + amount) * 255).toInt() 14 | return Color.rgb(red, green, blue) 15 | } 16 | 17 | fun String.colorSubString(subString: String, color: Int): SpannableStringBuilder { 18 | val stringBuilder = SpannableStringBuilder(this) 19 | val colorSpan = ForegroundColorSpan(color) 20 | val start = this.indexOf(subString) 21 | val end = start + subString.length 22 | stringBuilder.setSpan(colorSpan, start, end, Spannable.SPAN_INCLUSIVE_INCLUSIVE) 23 | 24 | return stringBuilder 25 | } 26 | 27 | fun RecyclerView.attachSnapHelper(snapHelper: SnapHelper) { 28 | snapHelper.attachToRecyclerView(this) 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/permissions/LocationPermission.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.permissions 2 | 3 | import android.Manifest 4 | import android.app.Activity 5 | import android.content.Context 6 | import com.thoughtbot.tropos.extensions.hasPermission 7 | 8 | class LocationPermission(val context: Context) : Permission { 9 | 10 | override val permission: String = Manifest.permission.ACCESS_COARSE_LOCATION 11 | 12 | override val permissionRequestCode: Int = 123 13 | 14 | override fun hasPermission(): Boolean = context.hasPermission(permission) 15 | 16 | override fun requestPermission() { 17 | (context as? Activity)?.requestPermission(this) 18 | } 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/permissions/Permission.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.permissions 2 | 3 | interface Permission { 4 | 5 | val permission: String 6 | 7 | val permissionRequestCode: Int 8 | 9 | fun hasPermission(): Boolean 10 | 11 | fun requestPermission() 12 | } 13 | 14 | fun Permission.checkPermission(granted: () -> Unit, denied: () -> Unit, request: Boolean) { 15 | if (hasPermission()) { 16 | granted() 17 | } else { 18 | denied() 19 | if (request) requestPermission() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/permissions/PermissionExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.permissions 2 | 3 | import android.app.Activity 4 | import android.content.pm.PackageManager 5 | import androidx.core.app.ActivityCompat 6 | import android.util.Log 7 | 8 | fun Activity.getPermissionResults(permission: Permission, results: PermissionResults, 9 | requestCode: Int, permissions: Array, grantResults: IntArray) { 10 | 11 | if (requestCode == permission.permissionRequestCode) { 12 | val permissionIndex = permissions.indexOf(permission.permission) 13 | if (grantResults[permissionIndex] == PackageManager.PERMISSION_GRANTED) { 14 | results.onPermissionGranted() 15 | } else { 16 | val userSaidNever = !shouldShowRequestPermissionRationale(permission.permission) 17 | if (userSaidNever) { 18 | // user checked "never ask again" 19 | results.onPermissionDenied(true) 20 | } else { 21 | // we can show reasoning why 22 | results.onPermissionDenied(false) 23 | } 24 | } 25 | } 26 | } 27 | 28 | fun Activity.requestPermission(permission: Permission) { 29 | ActivityCompat.requestPermissions(this, arrayOf(permission.permission), 30 | permission.permissionRequestCode) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/permissions/PermissionResults.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.permissions 2 | 3 | interface PermissionResults { 4 | 5 | fun onPermissionGranted() 6 | 7 | fun onPermissionDenied(userSaidNever: Boolean) 8 | } 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/refresh/DiagonalStripeView.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.refresh 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.Canvas 6 | import android.graphics.Paint 7 | import android.graphics.Point 8 | import androidx.core.content.ContextCompat 9 | import android.util.AttributeSet 10 | import android.view.View 11 | import android.view.WindowManager 12 | import com.thoughtbot.tropos.R 13 | 14 | class DiagonalStripeView : View { 15 | 16 | private val COLUMN_WIDTH = 60 17 | private val paint: Paint = Paint() 18 | private val colors: IntArray 19 | val blockWidth: Int 20 | 21 | init { 22 | paint.strokeWidth = COLUMN_WIDTH.toFloat() 23 | colors = makeColors() 24 | blockWidth = COLUMN_WIDTH * colors.size 25 | } 26 | 27 | constructor(context: Context) : this(context, null) 28 | 29 | constructor(context: Context, attributeSet: AttributeSet?) : super(context, attributeSet) 30 | 31 | override fun onDraw(canvas: Canvas?) { 32 | canvas?.let { drawDiagonalLines(canvas) } 33 | } 34 | 35 | private fun drawDiagonalLines(canvas: Canvas) { 36 | //subtract column width to ensure lines fill entire screen 37 | val start = -COLUMN_WIDTH 38 | val width = canvas.width - start + COLUMN_WIDTH 39 | 40 | val x = start 41 | var y = 0 42 | while (y - width < width + COLUMN_WIDTH) { 43 | val colorIndex = y / COLUMN_WIDTH % colors.size 44 | paint.color = colors[colorIndex] 45 | canvas.drawLine(x.toFloat(), y.toFloat(), (x + width).toFloat(), (y - width).toFloat(), paint) 46 | y += COLUMN_WIDTH 47 | } 48 | } 49 | 50 | private fun makeColors(): IntArray { 51 | val tangerine = ContextCompat.getColor(context, R.color.tangerine) 52 | val skyBlueLight = ContextCompat.getColor(context, R.color.sky_blue_light) 53 | val skyBlue = ContextCompat.getColor(context, R.color.sky_blue) 54 | val burntOrange = ContextCompat.getColor(context, R.color.burnt_orange) 55 | 56 | return intArrayOf(tangerine, skyBlueLight, skyBlue, burntOrange) 57 | } 58 | } 59 | 60 | fun DiagonalStripeView.asBitmap(): Bitmap { 61 | val size = Point() 62 | (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.getSize(size) 63 | val width = size.x * 2 64 | 65 | val bitmap = Bitmap.createBitmap(width/*width*/, 1000/*height*/, Bitmap.Config.ARGB_8888) 66 | val canvas = Canvas(bitmap) 67 | draw(canvas) 68 | return bitmap 69 | } 70 | 71 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/refresh/ProgressCircleDrawable.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.refresh 2 | 3 | import android.animation.ValueAnimator 4 | import android.content.Context 5 | import android.graphics.Canvas 6 | import android.graphics.ColorFilter 7 | import android.graphics.Paint 8 | import android.graphics.Paint.Style 9 | import android.graphics.Path 10 | import android.graphics.Path.Direction 11 | import android.graphics.Path.FillType 12 | import android.graphics.Rect 13 | import android.graphics.RectF 14 | import android.graphics.Region.Op 15 | import android.graphics.drawable.Animatable 16 | import android.graphics.drawable.Drawable 17 | import androidx.core.content.ContextCompat 18 | import com.thoughtbot.tropos.R 19 | 20 | class ProgressCircleDrawable(context: Context) : Drawable(), Animatable { 21 | 22 | companion object { 23 | private val STROKE_WIDTH = 10f 24 | private val CIRCLE_RADIUS = 50f 25 | } 26 | 27 | private val MASK_COLOR: Int 28 | private val paint: Paint 29 | private var angle: Float = 0F 30 | private val circle: Path 31 | private var bounds: RectF? = null 32 | private var centerX: Float = 0F 33 | private var centerY: Float = 0F 34 | private var radius: Float = 0F 35 | private var isRunning: Boolean = false 36 | private var animator: ValueAnimator? = null 37 | 38 | init { 39 | MASK_COLOR = ContextCompat.getColor(context, R.color.secondary_background) 40 | paint = Paint(Paint.ANTI_ALIAS_FLAG) 41 | paint.color = MASK_COLOR 42 | paint.style = Style.FILL 43 | circle = Path() 44 | circle.fillType = FillType.INVERSE_WINDING 45 | radius = CIRCLE_RADIUS 46 | } 47 | 48 | override fun draw(canvas: Canvas) { 49 | 50 | canvas.save() 51 | 52 | //add circle to path 53 | circle.reset() 54 | circle.addCircle(centerX, centerY, radius + STROKE_WIDTH, Direction.CW) 55 | circle.close() 56 | 57 | //write the outer rectangular bounds of that circle to #bounds 58 | circle.computeBounds(bounds, true) 59 | 60 | //put a rectangular hole in the current clip 61 | canvas.clipRect(bounds!!, Op.DIFFERENCE) 62 | 63 | //fill everything but the clip with mask color 64 | canvas.drawColor(MASK_COLOR) 65 | 66 | //restore full canvas clip for any subsequent operations 67 | canvas.restore() 68 | 69 | //draw circle inside clipped rectangle bounds 70 | canvas.drawPath(circle, paint) 71 | 72 | //inset bounds and draw inner progress circle 73 | if (!isRunning) { 74 | bounds!!.inset(STROKE_WIDTH, STROKE_WIDTH) 75 | canvas.drawArc(bounds!!, -90f, angle, true, paint) 76 | } 77 | } 78 | 79 | override fun onBoundsChange(bounds: Rect) { 80 | super.onBoundsChange(bounds) 81 | this.bounds = RectF() 82 | centerX = bounds.centerX().toFloat() 83 | centerY = bounds.centerY().toFloat() 84 | } 85 | 86 | override fun setAlpha(alpha: Int) { 87 | 88 | } 89 | 90 | override fun setColorFilter(colorFilter: ColorFilter?) { 91 | 92 | } 93 | 94 | override fun getOpacity(): Int { 95 | return 0 96 | } 97 | 98 | fun onProgress(percent: Float) { 99 | val ringHeight = bounds!!.height() + STROKE_WIDTH * 2 100 | val currentHeight = getBounds().height().toFloat() 101 | val offset = currentHeight - ringHeight 102 | val max = currentHeight / percent 103 | 104 | // only start rotating if the entire circle is visible 105 | if (currentHeight > ringHeight) { 106 | angle = offset / (max - ringHeight) * 360 - 360 107 | } else { 108 | angle = 360f 109 | } 110 | invalidateSelf() 111 | } 112 | 113 | override fun start() { 114 | isRunning = true 115 | animator = ValueAnimator.ofFloat(CIRCLE_RADIUS + STROKE_WIDTH, 1080f) 116 | animator!!.duration = 200 117 | animator!!.addUpdateListener { animation -> 118 | radius = animation.animatedValue as Float 119 | invalidateSelf() 120 | } 121 | animator!!.start() 122 | } 123 | 124 | override fun stop() { 125 | if (animator != null) { 126 | animator!!.end() 127 | } 128 | radius = CIRCLE_RADIUS 129 | isRunning = false 130 | } 131 | 132 | override fun isRunning(): Boolean { 133 | return isRunning 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/refresh/RefreshDrawable.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.refresh 2 | 3 | import android.content.Context 4 | import android.content.res.ColorStateList 5 | import android.graphics.Canvas 6 | import android.graphics.ColorFilter 7 | import android.graphics.PorterDuff 8 | import android.graphics.Rect 9 | import android.graphics.Region 10 | import android.graphics.drawable.Drawable 11 | import android.graphics.drawable.LayerDrawable 12 | import androidx.core.graphics.drawable.DrawableCompat 13 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout.ProgressStateListener 14 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout.ProgressStateListener.Companion.INACTIVE 15 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout.ProgressStateListener.Companion.PROGRESSING 16 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout.ProgressStateListener.Companion.REFRESHING 17 | 18 | 19 | class RefreshDrawable(context: Context) : Drawable(), Drawable.Callback, ProgressStateListener { 20 | 21 | private var refreshState: Int = INACTIVE 22 | 23 | private val progressCircleLayer: ProgressCircleDrawable 24 | private val stripeBackgroundLayer: StripeDrawable 25 | private val layers: LayerDrawable 26 | 27 | init { 28 | stripeBackgroundLayer = StripeDrawable(context) 29 | progressCircleLayer = ProgressCircleDrawable(context) 30 | 31 | val layers = arrayOf(stripeBackgroundLayer, progressCircleLayer) 32 | this.layers = LayerDrawable(layers) 33 | this.layers.callback = this 34 | } 35 | 36 | override fun draw(canvas: Canvas) { 37 | layers.draw(canvas) 38 | } 39 | 40 | override fun onBoundsChange(bounds: Rect) { 41 | layers.bounds = bounds 42 | } 43 | 44 | override fun setChangingConfigurations(configs: Int) { 45 | layers.changingConfigurations = configs 46 | } 47 | 48 | override fun getChangingConfigurations(): Int { 49 | return layers.changingConfigurations 50 | } 51 | 52 | override fun setDither(dither: Boolean) { 53 | layers.setDither(dither) 54 | } 55 | 56 | override fun setFilterBitmap(filter: Boolean) { 57 | layers.isFilterBitmap = filter 58 | } 59 | 60 | override fun setAlpha(alpha: Int) { 61 | layers.alpha = alpha 62 | } 63 | 64 | override fun setColorFilter(cf: ColorFilter?) { 65 | layers.colorFilter = cf 66 | } 67 | 68 | override fun isStateful(): Boolean { 69 | return layers.isStateful 70 | } 71 | 72 | override fun setState(stateSet: IntArray): Boolean { 73 | return layers.setState(stateSet) 74 | } 75 | 76 | override fun getState(): IntArray { 77 | return layers.state 78 | } 79 | 80 | override fun jumpToCurrentState() { 81 | DrawableCompat.jumpToCurrentState(layers) 82 | } 83 | 84 | override fun getCurrent(): Drawable { 85 | return layers.current 86 | } 87 | 88 | override fun setVisible(visible: Boolean, restart: Boolean): Boolean { 89 | return super.setVisible(visible, restart) || layers.setVisible(visible, restart) 90 | } 91 | 92 | override fun getOpacity(): Int { 93 | return layers.opacity 94 | } 95 | 96 | override fun getTransparentRegion(): Region? { 97 | return layers.transparentRegion 98 | } 99 | 100 | override fun getIntrinsicWidth(): Int { 101 | return layers.intrinsicWidth 102 | } 103 | 104 | override fun getIntrinsicHeight(): Int { 105 | return layers.intrinsicHeight 106 | } 107 | 108 | override fun getMinimumWidth(): Int { 109 | return layers.minimumWidth 110 | } 111 | 112 | override fun getMinimumHeight(): Int { 113 | return layers.minimumHeight 114 | } 115 | 116 | override fun getPadding(padding: Rect): Boolean { 117 | return layers.getPadding(padding) 118 | } 119 | 120 | override fun invalidateDrawable(who: Drawable) { 121 | invalidateSelf() 122 | } 123 | 124 | override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { 125 | scheduleSelf(what, `when`) 126 | } 127 | 128 | override fun unscheduleDrawable(who: Drawable, what: Runnable) { 129 | unscheduleSelf(what) 130 | } 131 | 132 | override fun onLevelChange(level: Int): Boolean { 133 | return layers.setLevel(level) 134 | } 135 | 136 | override fun setAutoMirrored(mirrored: Boolean) { 137 | DrawableCompat.setAutoMirrored(layers, mirrored) 138 | } 139 | 140 | override fun isAutoMirrored(): Boolean { 141 | return DrawableCompat.isAutoMirrored(layers) 142 | } 143 | 144 | override fun setTint(tint: Int) { 145 | DrawableCompat.setTint(layers, tint) 146 | } 147 | 148 | override fun setTintList(tint: ColorStateList?) { 149 | DrawableCompat.setTintList(layers, tint) 150 | } 151 | 152 | override fun setTintMode(tintMode: PorterDuff.Mode) { 153 | DrawableCompat.setTintMode(layers, tintMode) 154 | } 155 | 156 | override fun setHotspot(x: Float, y: Float) { 157 | DrawableCompat.setHotspot(layers, x, y) 158 | } 159 | 160 | override fun setHotspotBounds(left: Int, top: Int, right: Int, bottom: Int) { 161 | DrawableCompat.setHotspotBounds(layers, left, top, right, bottom) 162 | } 163 | 164 | private fun showRefreshingState() { 165 | stripeBackgroundLayer.start() 166 | progressCircleLayer.start() 167 | } 168 | 169 | private fun showProgressingState(percent: Float) { 170 | progressCircleLayer.onProgress(percent) 171 | 172 | if (percent == 1f && !stripeBackgroundLayer.isRunning) { 173 | //animate stripes once circle is fully visible 174 | stripeBackgroundLayer.start() 175 | } 176 | 177 | if (percent < 1 && stripeBackgroundLayer.isRunning) { 178 | //stop animating stripes if circle isn't fully visible 179 | stripeBackgroundLayer.stop() 180 | } 181 | } 182 | 183 | private fun showInactiveState() { 184 | stripeBackgroundLayer.stop() 185 | progressCircleLayer.stop() 186 | } 187 | 188 | // RefreshStateListener 189 | override fun onStateChanged(view: PullToRefreshLayout, state: Int) { 190 | refreshState = state 191 | when (state) { 192 | REFRESHING -> showRefreshingState() 193 | INACTIVE -> showInactiveState() 194 | } 195 | } 196 | 197 | override fun onProgress(percent: Float) { 198 | //only update if we are actually progressing 199 | if (refreshState == PROGRESSING) { 200 | showProgressingState(percent) 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/refresh/RefreshView.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.refresh 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import android.view.animation.Animation 7 | import android.widget.ImageView 8 | 9 | /** 10 | * Wrapper class created to work around issues with AnimationListeners being 11 | * called before the animation is actually complete :/ 12 | **/ 13 | class RefreshView : ImageView { 14 | 15 | var listener: Animation.AnimationListener? = null 16 | private var mostRecentAnimation: Animation? = null 17 | 18 | constructor(context: Context) : this(context, null) 19 | 20 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 21 | 22 | override fun onAnimationEnd() { 23 | super.onAnimationEnd() 24 | listener?.onAnimationEnd(mostRecentAnimation) 25 | setLayerType(View.LAYER_TYPE_NONE, null) 26 | } 27 | 28 | override fun onAnimationStart() { 29 | super.onAnimationStart() 30 | listener?.onAnimationStart(animation) 31 | mostRecentAnimation = animation 32 | setLayerType(View.LAYER_TYPE_HARDWARE, null) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/refresh/StripeDrawable.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.refresh 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.Canvas 6 | import android.graphics.ColorFilter 7 | import android.graphics.Paint 8 | import android.graphics.drawable.Animatable 9 | import android.graphics.drawable.Drawable 10 | import android.os.SystemClock 11 | 12 | class StripeDrawable(context: Context) : Drawable(), Animatable, Runnable, Drawable.Callback { 13 | 14 | private val DURATION = 1000 // in ms 15 | private val FRAME_DELAY = (1000 / 60).toLong() // 60 fps 16 | 17 | private var running = false 18 | private var startTime: Long = 0 19 | 20 | private var stripes: DiagonalStripeView = DiagonalStripeView(context) 21 | private var bitmap: Bitmap = stripes.asBitmap() 22 | private var paint: Paint = Paint() 23 | 24 | override fun draw(canvas: Canvas) { 25 | val bounds = bounds 26 | if (isRunning) { 27 | //animation in progress 28 | 29 | val save = canvas.save() 30 | canvas.clipRect(bounds) 31 | 32 | val timeDiff = SystemClock.uptimeMillis() - startTime 33 | val progress = timeDiff.toFloat() / DURATION.toFloat() // 0..1 34 | val xPos = (0 - progress * stripes.blockWidth) 35 | 36 | canvas.drawBitmap(bitmap, xPos, 0F, paint) 37 | canvas.restoreToCount(save) 38 | } else { 39 | canvas.drawBitmap(bitmap, 0F, 0F, paint) 40 | } 41 | } 42 | 43 | override fun setAlpha(alpha: Int) { 44 | 45 | } 46 | 47 | override fun setColorFilter(colorFilter: ColorFilter?) { 48 | 49 | } 50 | 51 | override fun getOpacity(): Int { 52 | return 0 53 | } 54 | 55 | override fun start() { 56 | if (isRunning) { 57 | stop() 58 | } 59 | running = true 60 | startTime = SystemClock.uptimeMillis() 61 | invalidateSelf() 62 | scheduleSelf(this, startTime + FRAME_DELAY) 63 | } 64 | 65 | override fun stop() { 66 | unscheduleSelf(this) 67 | running = false 68 | } 69 | 70 | override fun isRunning(): Boolean { 71 | return running 72 | } 73 | 74 | override fun run() { 75 | invalidateSelf() 76 | val uptimeMillis = SystemClock.uptimeMillis() 77 | if (uptimeMillis + FRAME_DELAY < startTime + DURATION) { 78 | scheduleSelf(this, uptimeMillis + FRAME_DELAY) 79 | } else { 80 | running = false 81 | start() 82 | } 83 | } 84 | 85 | override fun invalidateDrawable(who: Drawable) { 86 | val callback = callback 87 | callback?.invalidateDrawable(this) 88 | } 89 | 90 | override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { 91 | val callback = callback 92 | callback?.scheduleDrawable(this, what, `when`) 93 | } 94 | 95 | override fun unscheduleDrawable(who: Drawable, what: Runnable) { 96 | val callback = callback 97 | callback?.unscheduleDrawable(this, what) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/scrolling/RecyclerViewScroller.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.scrolling 2 | 3 | 4 | import android.view.MotionEvent 5 | import androidx.recyclerview.widget.LinearLayoutManager 6 | import androidx.recyclerview.widget.RecyclerView 7 | import androidx.recyclerview.widget.StaggeredGridLayoutManager 8 | import com.thoughtbot.tropos.scrolling.OverScroller.OverScrollDirection.END 9 | 10 | class RecyclerViewScroller(override val view: RecyclerView) : Scroller { 11 | 12 | val layoutManager = view.layoutManager 13 | val orientation = (layoutManager as? LinearLayoutManager)?.orientation ?: (layoutManager as StaggeredGridLayoutManager).orientation 14 | 15 | init { 16 | if (layoutManager !is LinearLayoutManager && layoutManager !is StaggeredGridLayoutManager) { 17 | throw IllegalArgumentException("Recycler views with custom layout managers are not supported") 18 | } 19 | } 20 | 21 | override fun isInAbsoluteStart(): Boolean { 22 | if (orientation == LinearLayoutManager.HORIZONTAL) { 23 | return !view.canScrollHorizontally(-1) 24 | } else { 25 | return !view.canScrollVertically(-1) 26 | } 27 | } 28 | 29 | override fun isInAbsoluteEnd(): Boolean { 30 | if (orientation == LinearLayoutManager.HORIZONTAL) { 31 | return !view.canScrollHorizontally(1) 32 | } else { 33 | return !view.canScrollVertically(1) 34 | } 35 | } 36 | 37 | } 38 | 39 | fun RecyclerView.setVerticalEndOverScroller() { 40 | val overScroller = VerticalOverScroller(RecyclerViewScroller(this), END) 41 | addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { 42 | override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { 43 | overScroller.onTouch(rv, e) 44 | } 45 | 46 | override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { 47 | return overScroller.onTouch(rv, e) 48 | } 49 | 50 | override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} 51 | }) 52 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/scrolling/VerticalOverScroller.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.scrolling 2 | 3 | import android.view.MotionEvent 4 | import android.view.View 5 | 6 | class VerticalOverScroller(scroller: Scroller, 7 | direction: OverScrollDirection) : OverScroller(scroller, direction) { 8 | 9 | override fun translateView(view: View, offset: Float) { 10 | view.translationY = offset 11 | } 12 | 13 | override fun translateViewAndEvent(view: View, offset: Float, event: MotionEvent) { 14 | view.translationY = offset 15 | event.offsetLocation(offset - event.getY(0), 0f) 16 | } 17 | 18 | override fun setMotionAttributes(view: View, event: MotionEvent): Boolean { 19 | // We must have history available to calc the dx. Normally it's there - if it isn't temporarily, 20 | // we declare the event 'invalid' and expect it in consequent events. 21 | if (event.historySize == 0) { 22 | return false 23 | } 24 | 25 | // Allow for counter-orientation-direction operations (e.g. item swiping) to run fluently. 26 | val dy = event.getY(0) - event.getHistoricalY(0, 0) 27 | val dx = event.getX(0) - event.getHistoricalX(0, 0) 28 | if (Math.abs(dx) > Math.abs(dy)) { 29 | return false 30 | } 31 | 32 | absOffset = view.translationY 33 | deltaOffset = dy 34 | dir = deltaOffset > 0 35 | 36 | return true 37 | } 38 | 39 | override fun setAnimationAttributes(view: View) { 40 | animProperty = View.TRANSLATION_Y 41 | absOffset = view.translationY 42 | maxOffset = view.height.toFloat() 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/scrolling/WeatherSnapHelper.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.scrolling 2 | 3 | 4 | import android.view.View 5 | import androidx.recyclerview.widget.LinearLayoutManager 6 | import androidx.recyclerview.widget.LinearSnapHelper 7 | import androidx.recyclerview.widget.OrientationHelper 8 | import androidx.recyclerview.widget.RecyclerView 9 | 10 | /** 11 | * Implementation of {@link LinearSnapHelper} that is designed to work exclusively 12 | * with {@link WeatherAdapter} and its' attached {@link RecyclerView} 13 | * 14 | * The implementation will snap to either completely show or hide the {@link ForecastViewHolder} 15 | * depending on how much of it is visible on when user releases their touch 16 | **/ 17 | class WeatherSnapHelper : LinearSnapHelper() { 18 | 19 | override fun findSnapView(layoutManager: RecyclerView.LayoutManager?): View? { 20 | layoutManager as LinearLayoutManager 21 | 22 | val lastChildIndex = layoutManager.findLastVisibleItemPosition() 23 | if (lastChildIndex == RecyclerView.NO_POSITION) { 24 | return null 25 | } 26 | 27 | val lastChild = layoutManager.findViewByPosition(lastChildIndex) 28 | val orientationHelper = OrientationHelper.createVerticalHelper(layoutManager) 29 | val lastChildStart = orientationHelper.getDecoratedStart(lastChild) 30 | val lastChildHeight = orientationHelper.getDecoratedMeasurement(lastChild) 31 | val recyclerViewHeight = layoutManager.height 32 | 33 | //amount of the the forecast (last child) height that is visible on screen 34 | val visibleHeight = recyclerViewHeight - lastChildStart 35 | 36 | //if more than half of the forecast is visible, snap to it, else snap to the weather 37 | if (visibleHeight > (lastChildHeight / 2)) { 38 | return lastChild 39 | } else { 40 | return layoutManager.findViewByPosition(0) 41 | } 42 | } 43 | 44 | override fun calculateDistanceToFinalSnap(layoutManager: RecyclerView.LayoutManager, target: View): IntArray? { 45 | val out = IntArray(2) 46 | out[0] = 0 47 | 48 | val targetViewType = layoutManager.getItemViewType(target) 49 | val weatherViewType = layoutManager.getItemViewType(layoutManager.findViewByPosition(0)!!) 50 | val orientationHelper = OrientationHelper.createVerticalHelper(layoutManager) 51 | 52 | if (targetViewType == weatherViewType) { 53 | // snap to show weather 54 | out[1] = orientationHelper.getDecoratedStart(target) - orientationHelper.startAfterPadding 55 | } else { 56 | // snap to show forecasts 57 | out[1] = orientationHelper.getDecoratedEnd(target) - orientationHelper.endAfterPadding 58 | } 59 | return out 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/settings/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.settings 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import androidx.core.app.ActivityCompat 6 | import androidx.appcompat.widget.Toolbar 7 | import android.view.Menu 8 | import android.view.MenuItem 9 | import com.thoughtbot.tropos.R 10 | import com.thoughtbot.tropos.commons.BaseActivity 11 | import kotlinx.android.synthetic.main.activity_settings.settings_privacy_policy 12 | import kotlinx.android.synthetic.main.activity_settings.settings_unit_radio_group 13 | import org.jetbrains.anko.find 14 | 15 | class SettingsActivity : BaseActivity(), SettingsView { 16 | 17 | val presenter: SettingsPresenter by lazy { SettingsPresenter(this) } 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | setContentView(R.layout.activity_settings) 22 | 23 | val toolbar = find(R.id.settings_toolbar) 24 | setSupportActionBar(toolbar) 25 | 26 | settings_unit_radio_group.setOnCheckedChangeListener(presenter) 27 | settings_privacy_policy.setOnClickListener { presenter.onPrivacyClicked() } 28 | 29 | presenter.init() 30 | } 31 | 32 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 33 | menuInflater.inflate(R.menu.close_menu, menu) 34 | return true 35 | } 36 | 37 | override fun onOptionsItemSelected(item: MenuItem?): Boolean { 38 | return when (item?.itemId) { 39 | R.id.action_close -> { 40 | ActivityCompat.finishAfterTransition(this) 41 | return true 42 | } 43 | else -> super.onOptionsItemSelected(item) 44 | } 45 | } 46 | 47 | override val context: Context = this 48 | 49 | override fun checkUnitPreference(preferenceId: Int) { 50 | settings_unit_radio_group.check(preferenceId) 51 | } 52 | 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/settings/SettingsPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.settings 2 | 3 | import android.widget.RadioGroup 4 | import android.widget.RadioGroup.OnCheckedChangeListener 5 | import com.thoughtbot.tropos.R 6 | import com.thoughtbot.tropos.commons.Presenter 7 | import com.thoughtbot.tropos.commons.WebviewActivity 8 | import com.thoughtbot.tropos.data.Preferences 9 | import com.thoughtbot.tropos.data.Unit 10 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 11 | import com.thoughtbot.tropos.data.Unit.METRIC 12 | import com.thoughtbot.tropos.data.local.LocalPreferences 13 | 14 | class SettingsPresenter(override val view: SettingsView, 15 | val preferences: Preferences = LocalPreferences(view.context)) 16 | : Presenter, OnCheckedChangeListener { 17 | 18 | fun init() { 19 | view.checkUnitPreference(preferences.unit.resId()) 20 | } 21 | 22 | fun onPrivacyClicked() { 23 | val privacyUrl = "https://troposweather.com/privacy" 24 | val intent = WebviewActivity.createIntent(view.context, privacyUrl) 25 | view.context.startActivity(intent) 26 | } 27 | 28 | override fun onCheckedChanged(group: RadioGroup?, checkedId: Int) { 29 | when (checkedId) { 30 | R.id.settings_imperial_button -> preferences.unit = IMPERIAL 31 | R.id.settings_metric_button -> preferences.unit = METRIC 32 | } 33 | } 34 | 35 | private fun Unit.resId(): Int { 36 | return when (this) { 37 | IMPERIAL -> R.id.settings_imperial_button 38 | METRIC -> R.id.settings_metric_button 39 | } 40 | } 41 | 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/settings/SettingsView.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.settings 2 | 3 | import com.thoughtbot.tropos.commons.View 4 | 5 | interface SettingsView : View { 6 | 7 | fun checkUnitPreference(preferenceId: Int) 8 | 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.splash 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.core.app.ActivityCompat.finishAfterTransition 7 | import com.thoughtbot.tropos.R 8 | import com.thoughtbot.tropos.commons.BaseActivity 9 | import com.thoughtbot.tropos.permissions.getPermissionResults 10 | 11 | class SplashActivity : BaseActivity(), SplashView { 12 | 13 | val presenter: SplashPresenter by lazy { SplashPresenter(this) } 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(R.layout.activity_splash) 18 | 19 | presenter.init() 20 | } 21 | 22 | override fun onDestroy() { 23 | super.onDestroy() 24 | presenter.onDestroy() 25 | } 26 | 27 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, 28 | grantResults: IntArray) { 29 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 30 | getPermissionResults(presenter.permission, presenter, requestCode, permissions, grantResults) 31 | } 32 | 33 | override val context: Context = this 34 | 35 | override fun navigate(intent: Intent) { 36 | startActivity(intent) 37 | overridePendingTransition(R.anim.slide_in, R.anim.slide_out) 38 | finishAfterTransition(this) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/splash/SplashPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.splash 2 | 3 | import com.thoughtbot.tropos.commons.Presenter 4 | import com.thoughtbot.tropos.data.WeatherDataSource 5 | import com.thoughtbot.tropos.data.WeatherService 6 | import com.thoughtbot.tropos.permissions.LocationPermission 7 | import com.thoughtbot.tropos.permissions.Permission 8 | import com.thoughtbot.tropos.permissions.PermissionResults 9 | import com.thoughtbot.tropos.permissions.checkPermission 10 | import com.thoughtbot.tropos.ui.MainActivity 11 | import io.reactivex.disposables.Disposable 12 | 13 | class SplashPresenter(override val view: SplashView, 14 | val permission: Permission = LocationPermission(view.context), 15 | val weatherDataSource: WeatherDataSource = WeatherService(view.context, null)) 16 | : Presenter, PermissionResults { 17 | 18 | var disposable: Disposable? = null 19 | 20 | fun init() { 21 | permission.checkPermission({ fetchWeather() }, { }, true) 22 | } 23 | 24 | fun onDestroy() { 25 | disposable?.dispose() 26 | } 27 | 28 | override fun onPermissionGranted() { 29 | fetchWeather() 30 | } 31 | 32 | override fun onPermissionDenied(userSaidNever: Boolean) { 33 | view.navigate(MainActivity.createIntent(view.context, null)) 34 | } 35 | 36 | private fun fetchWeather() { 37 | disposable = weatherDataSource.fetchWeather() 38 | .subscribe({ 39 | view.navigate(MainActivity.createIntent(view.context, it)) 40 | }, { error -> 41 | view.navigate(MainActivity.createIntent(view.context, null)) 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/splash/SplashView.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.splash 2 | 3 | import android.content.Intent 4 | import com.thoughtbot.tropos.commons.View 5 | 6 | interface SplashView : View { 7 | fun navigate(intent: Intent) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/transitions/CircularReveal.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.transitions 2 | 3 | import android.R.attr 4 | import android.animation.Animator 5 | import android.content.Context 6 | import android.graphics.Point 7 | import androidx.annotation.IdRes 8 | import android.transition.TransitionValues 9 | import android.transition.Visibility 10 | import android.util.AttributeSet 11 | import android.view.View 12 | import android.view.View.NO_ID 13 | import android.view.ViewAnimationUtils 14 | import android.view.ViewGroup 15 | 16 | 17 | /** 18 | * The inspiration, and a good deal of the actual code for this class, came from https://github.com/nickbutcher/plaid 19 | **/ 20 | class CircularReveal : Visibility { 21 | 22 | var startRadius: Float = 0f 23 | var endRadius: Float = 0f 24 | @IdRes var centerOnId = NO_ID 25 | var centerOn: View? = null 26 | 27 | constructor() : super() 28 | 29 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) 30 | 31 | override fun onAppear(sceneRoot: ViewGroup, view: View?, 32 | startValues: TransitionValues, 33 | endValues: TransitionValues): Animator? { 34 | if (view == null || view.height === 0 || view.width === 0) return null 35 | val center = ensureCenterPoint(sceneRoot, view) 36 | val animator = NoPauseAnimator(ViewAnimationUtils.createCircularReveal( 37 | view, 38 | center.x, 39 | center.y, 40 | startRadius, 41 | maxRadius(center, view))) 42 | animator.duration = 500 43 | return animator 44 | } 45 | 46 | override fun onDisappear(sceneRoot: ViewGroup, view: View?, 47 | startValues: TransitionValues, 48 | endValues: TransitionValues): Animator? { 49 | if (view == null || view.height == 0 || view.width == 0) return null 50 | val center = ensureCenterPoint(sceneRoot, view) 51 | val animator = NoPauseAnimator(ViewAnimationUtils.createCircularReveal( 52 | view, 53 | center.x, 54 | center.y, 55 | maxRadius(center, view), 56 | endRadius)) 57 | animator.duration = 500 58 | return animator 59 | } 60 | 61 | private fun ensureCenterPoint(sceneRoot: ViewGroup, view: View): Point { 62 | if (centerOn != null || centerOnId != NO_ID) { 63 | val source: View = centerOn ?: sceneRoot.findViewById(centerOnId) 64 | // use window location to allow views in diff hierarchies 65 | val loc = IntArray(2) 66 | source.getLocationInWindow(loc) 67 | val srcX = loc[0] + source.getWidth() / 2 68 | val srcY = loc[1] + source.getHeight() / 2 69 | view.getLocationInWindow(loc) 70 | return Point(srcX - loc[0], srcY - loc[1]) 71 | } 72 | // else use the upper right hand corner where the action menu button lives 73 | return actionMenuLocation(view) 74 | } 75 | 76 | private fun maxRadius(center: Point, view: View): Float { 77 | return Math.hypot(Math.max(center.x, view.width - center.x).toDouble(), 78 | Math.max(center.y, view.height - center.y).toDouble()).toFloat() 79 | } 80 | 81 | private fun actionMenuLocation(view: View): Point { 82 | val attrs = intArrayOf(attr.actionBarSize) 83 | val actionBarSize = view.context.theme.obtainStyledAttributes(attrs).getDimension(0, 0F) 84 | val x = (view.right - (actionBarSize / 2)).toInt() 85 | val y = (actionBarSize / 2).toInt() 86 | return Point(x, y) 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/transitions/NoPauseAnimator.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.transitions 2 | 3 | import android.animation.Animator 4 | import android.animation.TimeInterpolator 5 | 6 | /** 7 | * https://halfthought.wordpress.com/2014/11/07/reveal-transition/ 8 | * 9 | * Interrupting Activity transitions can yield an OperationNotSupportedException when the 10 | * transition tries to pause the animator. rip. We can fix this by wrapping the 11 | * Animator and not calling any onPause or onResume functions 12 | */ 13 | class NoPauseAnimator(private val animator: Animator) : Animator() { 14 | 15 | override fun getStartDelay(): Long { 16 | return animator.startDelay 17 | } 18 | 19 | override fun setStartDelay(startDelay: Long) { 20 | animator.startDelay = startDelay 21 | } 22 | 23 | override fun setDuration(duration: Long): Animator { 24 | animator.duration = duration 25 | return this 26 | } 27 | 28 | override fun getDuration(): Long { 29 | return animator.duration 30 | } 31 | 32 | override fun setInterpolator(value: TimeInterpolator) { 33 | animator.interpolator = value 34 | } 35 | 36 | override fun isRunning(): Boolean { 37 | return animator.isRunning 38 | } 39 | 40 | override fun start() { 41 | animator.start() 42 | } 43 | 44 | override fun cancel() { 45 | animator.cancel() 46 | } 47 | 48 | override fun addListener(listener: Animator.AnimatorListener) { 49 | animator.addListener(listener) 50 | } 51 | 52 | override fun removeAllListeners() { 53 | animator.removeAllListeners() 54 | } 55 | 56 | override fun removeListener(listener: Animator.AnimatorListener) { 57 | animator.removeListener(listener) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.ui 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.core.app.ActivityOptionsCompat 7 | import androidx.appcompat.widget.Toolbar 8 | import android.view.Menu 9 | import android.view.MenuItem 10 | import android.view.View 11 | import androidx.recyclerview.widget.GridLayoutManager 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.thoughtbot.tropos.R 14 | import com.thoughtbot.tropos.adapters.WeatherAdapter 15 | import com.thoughtbot.tropos.commons.BaseActivity 16 | import com.thoughtbot.tropos.commons.ViewBinder 17 | import com.thoughtbot.tropos.data.Weather 18 | import com.thoughtbot.tropos.extensions.attachSnapHelper 19 | import com.thoughtbot.tropos.permissions.getPermissionResults 20 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout 21 | import com.thoughtbot.tropos.refresh.RefreshDrawable 22 | import com.thoughtbot.tropos.scrolling.WeatherSnapHelper 23 | import com.thoughtbot.tropos.scrolling.setVerticalEndOverScroller 24 | import com.thoughtbot.tropos.settings.SettingsActivity 25 | import kotlinx.android.synthetic.main.activity_main.error_text 26 | import kotlinx.android.synthetic.main.activity_main.footer 27 | import kotlinx.android.synthetic.main.activity_main.toolbar_city 28 | import kotlinx.android.synthetic.main.activity_main.toolbar_last_update 29 | import org.jetbrains.anko.find 30 | 31 | class MainActivity : BaseActivity(), MainView { 32 | companion object { 33 | val WEATHER_EXTRA = "weather_extra" 34 | 35 | fun createIntent(context: Context, weather: Weather?): Intent { 36 | val intent = Intent(context, MainActivity::class.java) 37 | weather?.let { intent.putExtra(WEATHER_EXTRA, it) } 38 | return intent 39 | } 40 | } 41 | 42 | val presenter: MainPresenter by lazy { MainPresenter(this, intent) } 43 | val recyclerView: RecyclerView by lazy { findViewById(R.id.recycler_view) as RecyclerView } 44 | val adapter: WeatherAdapter by lazy { WeatherAdapter() } 45 | val layoutManager: GridLayoutManager by lazy { GridLayoutManager(this, 3) } 46 | val pullToRefreshLayout by lazy { find(R.id.pull_to_refresh) } 47 | 48 | override fun onCreate(savedInstanceState: Bundle?) { 49 | super.onCreate(savedInstanceState) 50 | setContentView(R.layout.activity_main) 51 | 52 | val toolbar = find(R.id.toolbar) 53 | setSupportActionBar(toolbar) 54 | 55 | recyclerView.adapter = adapter 56 | layoutManager.spanSizeLookup = adapter.spanSizeLookup 57 | recyclerView.layoutManager = layoutManager 58 | recyclerView.attachSnapHelper(WeatherSnapHelper()) 59 | recyclerView.setVerticalEndOverScroller() 60 | 61 | pullToRefreshLayout.setRefreshingDrawable(RefreshDrawable(this)) 62 | pullToRefreshLayout.refreshListener = presenter 63 | } 64 | 65 | override fun onStart() { 66 | super.onStart() 67 | presenter.onStart() 68 | } 69 | 70 | 71 | override fun onDestroy() { 72 | super.onDestroy() 73 | presenter.onDestroy() 74 | } 75 | 76 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, 77 | grantResults: IntArray) { 78 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 79 | getPermissionResults(presenter.permission, presenter, requestCode, permissions, grantResults) 80 | } 81 | 82 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 83 | menuInflater.inflate(R.menu.settings_menu, menu) 84 | return true 85 | } 86 | 87 | override fun onOptionsItemSelected(item: MenuItem?): Boolean { 88 | return when (item?.itemId) { 89 | R.id.action_settings -> { 90 | val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this).toBundle() 91 | startActivity(Intent(this, SettingsActivity::class.java), bundle) 92 | return true 93 | } 94 | else -> super.onOptionsItemSelected(item) 95 | } 96 | } 97 | 98 | override val context: Context = this 99 | 100 | override var viewState: ViewState by ViewBinder { 101 | when (it) { 102 | is ViewState.Weather -> { 103 | toolbar_city.text = it.toolbarViewModel.title() 104 | toolbar_last_update.text = it.toolbarViewModel.subtitle() 105 | 106 | adapter.weather = it.weather 107 | recyclerView.itemAnimator?.isRunning { 108 | pullToRefreshLayout.setRefreshing(false) 109 | } 110 | 111 | footer.visibility = View.VISIBLE 112 | error_text.visibility = View.GONE 113 | } 114 | is ViewState.Loading -> { 115 | toolbar_city.text = it.toolbarViewModel.title() 116 | toolbar_last_update.text = it.toolbarViewModel.subtitle() 117 | 118 | footer.visibility = View.GONE 119 | error_text.visibility = View.GONE 120 | } 121 | is ViewState.Error -> { 122 | toolbar_city.text = it.toolbarViewModel.title() 123 | toolbar_last_update.text = it.toolbarViewModel.subtitle() 124 | 125 | pullToRefreshLayout.setRefreshing(false) 126 | 127 | footer.visibility = View.GONE 128 | error_text.visibility = View.VISIBLE 129 | error_text.text = it.errorMessage 130 | } 131 | } 132 | } 133 | 134 | } 135 | 136 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/ui/MainPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.ui 2 | 3 | import android.content.Intent 4 | import com.thoughtbot.tropos.R 5 | import com.thoughtbot.tropos.commons.Presenter 6 | import com.thoughtbot.tropos.data.WeatherDataSource 7 | import com.thoughtbot.tropos.data.WeatherService 8 | import com.thoughtbot.tropos.permissions.LocationPermission 9 | import com.thoughtbot.tropos.permissions.Permission 10 | import com.thoughtbot.tropos.permissions.PermissionResults 11 | import com.thoughtbot.tropos.permissions.checkPermission 12 | import com.thoughtbot.tropos.refresh.PullToRefreshLayout.RefreshListener 13 | import com.thoughtbot.tropos.viewmodels.ErrorToolbarViewModel 14 | import com.thoughtbot.tropos.viewmodels.LoadingToolbarViewModel 15 | import com.thoughtbot.tropos.viewmodels.WeatherToolbarViewModel 16 | import io.reactivex.disposables.Disposable 17 | 18 | class MainPresenter(override val view: MainView, 19 | intent: Intent?, 20 | val weatherDataSource: WeatherDataSource = WeatherService(view.context, intent), 21 | val permission: Permission = LocationPermission(view.context)) 22 | : Presenter, RefreshListener, PermissionResults { 23 | 24 | var disposable: Disposable? = null 25 | 26 | fun onStart() { 27 | permission.checkPermission({ updateWeather() }, { onPermissionDenied(false) }, true) 28 | } 29 | 30 | fun updateWeather() { 31 | view.viewState = ViewState.Loading(LoadingToolbarViewModel(view.context)) 32 | disposable = weatherDataSource.fetchWeather() 33 | .subscribe({ 34 | view.viewState = ViewState.Weather(WeatherToolbarViewModel(view.context, it.today), it) 35 | }, { error -> 36 | val message = error.message ?: "" 37 | val errorMessage = view.context.getString(R.string.generic_error_message, message) 38 | view.viewState = ViewState.Error(ErrorToolbarViewModel(view.context), errorMessage) 39 | }) 40 | } 41 | 42 | override fun onRefresh() { 43 | permission.checkPermission({ updateWeather() }, { onPermissionDenied(false) }, true) 44 | } 45 | 46 | fun onDestroy() { 47 | disposable?.dispose() 48 | } 49 | 50 | override fun onPermissionGranted() { 51 | updateWeather() 52 | } 53 | 54 | override fun onPermissionDenied(userSaidNever: Boolean) { 55 | val errorMessage = view.context.getString(R.string.missing_location_permission_error) 56 | view.viewState = ViewState.Error(ErrorToolbarViewModel(view.context), errorMessage) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/ui/MainView.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.ui 2 | 3 | import com.thoughtbot.tropos.commons.View 4 | import com.thoughtbot.tropos.ui.ViewState 5 | 6 | interface MainView : View { 7 | 8 | var viewState: ViewState 9 | 10 | } 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/ui/ViewState.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.ui 2 | 3 | import com.thoughtbot.tropos.viewmodels.ToolbarViewModel 4 | 5 | sealed class ViewState(val toolbarViewModel: ToolbarViewModel) { 6 | 7 | class Loading(toolbarViewModel: ToolbarViewModel) : ViewState(toolbarViewModel) 8 | 9 | class Weather(toolbarViewModel: ToolbarViewModel, 10 | val weather: com.thoughtbot.tropos.data.Weather) : ViewState(toolbarViewModel) 11 | 12 | class Error(toolbarViewModel: ToolbarViewModel, val errorMessage: String) : ViewState( 13 | toolbarViewModel) 14 | 15 | } 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/viewholders/CurrentWeatherViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewholders 2 | 3 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 4 | import android.view.View 5 | import android.widget.ImageView 6 | import android.widget.TextView 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.thoughtbot.tropos.R.id 9 | import com.thoughtbot.tropos.data.Condition 10 | import com.thoughtbot.tropos.viewmodels.CurrentWeatherViewModel 11 | import com.thoughtbot.tropos.widgets.DrawableTextLabel 12 | import org.jetbrains.anko.find 13 | 14 | class CurrentWeatherViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 15 | 16 | fun bind(today: Condition, yesterday: Condition) { 17 | val viewModel = CurrentWeatherViewModel(itemView.context, today = today, yesterday = yesterday) 18 | 19 | itemView.find(id.weather_icon).setBackgroundResource(viewModel.icon) 20 | itemView.find(id.weather_summary).text = viewModel.weatherSummary() 21 | itemView.find(id.temperature_label).setText(viewModel.temperatures()) 22 | itemView.find(id.temperature_label).setDrawable(viewModel.temperatureIcon) 23 | itemView.find(id.wind_label).setText(viewModel.wind()) 24 | itemView.find(id.wind_label).setDrawable(viewModel.windIcon) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/viewholders/ForecastViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewholders 2 | 3 | import android.view.View 4 | import androidx.recyclerview.widget.RecyclerView 5 | import com.thoughtbot.tropos.data.Condition 6 | import com.thoughtbot.tropos.viewmodels.ForecastViewModel 7 | import com.thoughtbot.tropos.widgets.ForecastLayout 8 | 9 | class ForecastViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 10 | 11 | fun bind(condition: Condition) { 12 | val viewModel = ForecastViewModel(itemView.context, condition = condition) 13 | itemView as ForecastLayout 14 | itemView.setIcon(viewModel.icon) 15 | itemView.setDay(viewModel.day) 16 | itemView.setHighTemp(viewModel.highTemp()) 17 | itemView.setLowTemp(viewModel.lowTemp()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/viewmodels/CurrentWeatherViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import androidx.core.content.ContextCompat.getColor 5 | import android.text.SpannableStringBuilder 6 | import com.thoughtbot.tropos.R 7 | import com.thoughtbot.tropos.R.color 8 | import com.thoughtbot.tropos.R.drawable 9 | import com.thoughtbot.tropos.R.string 10 | import com.thoughtbot.tropos.data.* 11 | import com.thoughtbot.tropos.data.TemperatureDifference.COLDER 12 | import com.thoughtbot.tropos.data.TemperatureDifference.COOLER 13 | import com.thoughtbot.tropos.data.TemperatureDifference.HOTTER 14 | import com.thoughtbot.tropos.data.TemperatureDifference.SAME 15 | import com.thoughtbot.tropos.data.TemperatureDifference.WARMER 16 | import com.thoughtbot.tropos.data.TimeOfDay.AFTERNOON 17 | import com.thoughtbot.tropos.data.TimeOfDay.DAY 18 | import com.thoughtbot.tropos.data.TimeOfDay.MORNING 19 | import com.thoughtbot.tropos.data.TimeOfDay.NIGHT 20 | import com.thoughtbot.tropos.data.Unit 21 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 22 | import com.thoughtbot.tropos.data.Unit.METRIC 23 | import com.thoughtbot.tropos.data.local.LocalPreferences 24 | import com.thoughtbot.tropos.extensions.colorSubString 25 | import com.thoughtbot.tropos.extensions.convertSpeed 26 | import com.thoughtbot.tropos.extensions.convertTemperature 27 | import com.thoughtbot.tropos.extensions.lightenBy 28 | 29 | class CurrentWeatherViewModel(val context: Context, 30 | val preferences: Preferences = LocalPreferences(context), 31 | val today: Condition, val yesterday: Condition) { 32 | 33 | fun weatherSummary(): SpannableStringBuilder { 34 | val adjective = tempDifference().name.toLowerCase() 35 | val todayDescription = context.getString(timeOfDay().asPresentDayDescription()) 36 | val yesterdayDescription = context.getString(timeOfDay().asPreviousDayDescription()) 37 | val format = tempDifference().summaryFormat() 38 | val fullSummary = context.getString(format, adjective, todayDescription, yesterdayDescription) 39 | return fullSummary.colorSubString(adjective, temperatureDifferenceColor()) 40 | } 41 | 42 | val icon: Int = today.icon.iconResId() 43 | 44 | fun temperatures(): SpannableStringBuilder { 45 | val high = today.highTemp.convertTemperature(today.unit, preferences.unit) 46 | val low = today.lowTemp.convertTemperature(today.unit, preferences.unit) 47 | val current = today.currentTemp.convertTemperature(today.unit, preferences.unit) 48 | 49 | val fullString = context.getString(R.string.formatted_temperature_string, high, current, low) 50 | val today = context.getString(string.temperature, current) 51 | return fullString.colorSubString(today, temperatureDifferenceColor()) 52 | } 53 | 54 | val temperatureIcon = drawable.label_thermometer 55 | 56 | fun wind(): String { 57 | val windDirection = context.getString(today.windDirection.labelResId()) 58 | val windSpeed = today.windSpeed.convertSpeed(today.unit, preferences.unit) 59 | val stringFormat = if (preferences.unit == IMPERIAL) R.string.formatted_imperial_wind_string else R.string.formatted_metric_wind_string 60 | return context.getString(stringFormat, windSpeed, windDirection) 61 | } 62 | 63 | val windIcon = drawable.label_wind 64 | 65 | private fun tempDifference(): TemperatureDifference { 66 | return TemperatureDifference(today, yesterday) 67 | } 68 | 69 | private fun timeOfDay(): TimeOfDay { 70 | return TimeOfDay(today.date) 71 | } 72 | 73 | private fun temperatureDifferenceColor(): Int { 74 | val color = when (tempDifference()) { 75 | SAME -> getColor(context, android.R.color.white) 76 | HOTTER -> getColor(context, color.burnt_orange) 77 | WARMER -> getColor(context, color.tangerine) 78 | COOLER -> getColor(context, color.sky_blue) 79 | COLDER -> getColor(context, color.sky_blue_light) 80 | } 81 | 82 | if (tempDifference() == COOLER || tempDifference() == WARMER) { 83 | val amount = (Math.min(Math.abs(today.currentTemp - yesterday.currentTemp), 10)) / 10.0 84 | val lighterAmount = Math.min(1 - amount, 0.8).toFloat() 85 | 86 | return color.lightenBy(lighterAmount) 87 | } else { 88 | return color 89 | } 90 | } 91 | 92 | private fun TimeOfDay.asPresentDayDescription(): Int { 93 | when (this) { 94 | MORNING -> return string.present_morning 95 | DAY -> return string.present_day 96 | AFTERNOON -> return string.present_afternoon 97 | NIGHT -> return string.present_night 98 | else -> throw IllegalArgumentException("$this is not a valid TimeOfDay") 99 | } 100 | } 101 | 102 | private fun TimeOfDay.asPreviousDayDescription(): Int { 103 | when (this) { 104 | MORNING -> return string.previous_morning 105 | DAY -> return string.previous_day 106 | AFTERNOON -> return string.previous_afternoon 107 | NIGHT -> return string.previous_night 108 | else -> throw IllegalArgumentException("$this is not a valid TimeOfDay") 109 | } 110 | } 111 | 112 | private fun TemperatureDifference.summaryFormat(): Int { 113 | return if (this == SAME) string.same_temperature_format else string.different_temperature_format 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/viewmodels/ForecastViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.thoughtbot.tropos.R.string 5 | import com.thoughtbot.tropos.data.Condition 6 | import com.thoughtbot.tropos.data.Preferences 7 | import com.thoughtbot.tropos.data.iconResId 8 | import com.thoughtbot.tropos.data.local.LocalPreferences 9 | import com.thoughtbot.tropos.extensions.convertTemperature 10 | import java.text.SimpleDateFormat 11 | 12 | class ForecastViewModel(val context: Context, 13 | val preferences: Preferences = LocalPreferences(context), val condition: Condition) { 14 | 15 | val icon: Int = condition.icon.iconResId() 16 | 17 | val day: String = SimpleDateFormat("EEE").format(condition.date) 18 | 19 | fun highTemp(): String { 20 | val highTemp = condition.highTemp.convertTemperature(condition.unit, preferences.unit) 21 | return context.getString(string.temperature, highTemp) 22 | } 23 | 24 | fun lowTemp(): String { 25 | val lowTemp = condition.lowTemp.convertTemperature(condition.unit, preferences.unit) 26 | return context.getString(string.temperature, lowTemp) 27 | } 28 | 29 | } 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/viewmodels/ToolbarViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import android.location.Address 5 | import android.location.Geocoder 6 | import com.thoughtbot.tropos.R.string 7 | import com.thoughtbot.tropos.data.Condition 8 | import java.text.SimpleDateFormat 9 | import java.util.Date 10 | import java.util.Locale 11 | 12 | interface ToolbarViewModel { 13 | 14 | fun title(): String 15 | 16 | fun subtitle(): String 17 | 18 | } 19 | 20 | class LoadingToolbarViewModel(val context: Context) : ToolbarViewModel { 21 | 22 | override fun title(): String { 23 | return context.getString(string.checking_weather) 24 | } 25 | 26 | override fun subtitle(): String { 27 | val date = Date() 28 | val formattedDate = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) 29 | return context.getString(string.updated_at, formattedDate) 30 | } 31 | 32 | } 33 | 34 | class WeatherToolbarViewModel(val context: Context, 35 | val condition: Condition) : ToolbarViewModel { 36 | 37 | override fun subtitle(): String { 38 | val date = condition.date 39 | val formattedDate = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) 40 | return context.getString(string.updated_at, formattedDate) 41 | } 42 | 43 | override fun title(): String { 44 | val latitude = condition.location.latitude 45 | val longitude = condition.location.longitude 46 | val address: List
? = Geocoder(context).getFromLocation(latitude, longitude, 1) 47 | val city: String? = address?.firstOrNull()?.locality 48 | return city ?: "$latitude, $longitude" 49 | } 50 | } 51 | 52 | class ErrorToolbarViewModel(val context: Context) : ToolbarViewModel { 53 | 54 | override fun title(): String { 55 | return context.getString(string.update_failed) 56 | } 57 | 58 | override fun subtitle(): String { 59 | val date = Date() 60 | val formattedDate = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) 61 | return context.getString(string.updated_at, formattedDate) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/widgets/DrawableTextLabel.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.widgets 2 | 3 | import android.content.Context 4 | import android.content.res.TypedArray 5 | import android.graphics.drawable.Drawable 6 | import androidx.annotation.DrawableRes 7 | import android.text.SpannableStringBuilder 8 | import android.util.AttributeSet 9 | import android.view.LayoutInflater 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import android.widget.ImageView 13 | import android.widget.TextView 14 | import com.thoughtbot.tropos.R 15 | 16 | 17 | class DrawableTextLabel : ViewGroup { 18 | 19 | private val image: ImageView 20 | private val titleTextView: TextView 21 | private val spacing: Int 22 | private val imageHeight: Int 23 | private val imageWidth: Int 24 | 25 | constructor(context: Context) : this(context, null) 26 | 27 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { 28 | 29 | LayoutInflater.from(context).inflate(R.layout.label_drawable_text, this) 30 | image = findViewById(R.id.label_image) as ImageView 31 | titleTextView = findViewById(R.id.label_title) as TextView 32 | 33 | val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.DrawableTextLabel) 34 | 35 | val drawable: Drawable? = a.getDrawable(R.styleable.DrawableTextLabel_label_drawable) 36 | val title: String? = a.getString(R.styleable.DrawableTextLabel_label_title_text) 37 | 38 | spacing = a.getDimension(R.styleable.DrawableTextLabel_label_spacing, 0F).toInt() 39 | imageHeight = a.getDimension(R.styleable.DrawableTextLabel_label_drawable_height, -1F).toInt() 40 | imageWidth = a.getDimension(R.styleable.DrawableTextLabel_label_drawable_width, -1F).toInt() 41 | 42 | drawable?.let { setDrawable(it) } 43 | title?.let { setText(it) } 44 | 45 | a.recycle() 46 | } 47 | 48 | override fun checkLayoutParams(p: LayoutParams?): Boolean { 49 | return p is MarginLayoutParams 50 | } 51 | 52 | override fun generateDefaultLayoutParams(): LayoutParams { 53 | return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) 54 | } 55 | 56 | override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams { 57 | return MarginLayoutParams(context, attrs) 58 | } 59 | 60 | override fun generateLayoutParams(p: LayoutParams?): LayoutParams { 61 | return generateDefaultLayoutParams() 62 | } 63 | 64 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 65 | var widthUsed: Int = 0 66 | var heightUsed: Int = 0 67 | 68 | // Image goes to the left: measure and reserve horizontal space 69 | measureChildWithMargins(image, widthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed) 70 | widthUsed += widthWithMargins(image, getImageWidth()) 71 | 72 | // Use remaining space center the TextView vertically 73 | var textWidth: Int = 0 74 | measureChildWithMargins(titleTextView, widthMeasureSpec, widthUsed, heightMeasureSpec, 75 | heightUsed) 76 | heightUsed += heightWithMargins(titleTextView, titleTextView.measuredHeight) 77 | textWidth = Math.max(textWidth, widthWithMargins(titleTextView, titleTextView.measuredWidth)) 78 | 79 | widthUsed += textWidth 80 | 81 | // handle the case where the image is taller than the TextView 82 | heightUsed = Math.max(heightWithMargins(image, getImageHeight()), heightUsed) 83 | 84 | widthUsed += paddingLeft + paddingRight 85 | heightUsed += paddingTop + paddingBottom 86 | 87 | setMeasuredDimension(resolveSize(widthUsed, widthMeasureSpec), 88 | resolveSize(heightUsed, heightMeasureSpec)) 89 | } 90 | 91 | override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { 92 | var x = paddingLeft 93 | var y = paddingTop 94 | 95 | val innerHeight = height - paddingTop - paddingBottom 96 | 97 | // center image vertically 98 | val dy = Math.max(0, (innerHeight - heightWithMargins(image, getImageHeight())) / 2) 99 | placeChild(image, getImageWidth(), getImageHeight(), x, y + dy) 100 | 101 | x += widthWithMargins(image, getImageWidth()) 102 | 103 | // center text 104 | var textHeight = 0 105 | textHeight += heightWithMargins(titleTextView, titleTextView.measuredHeight) 106 | 107 | y += Math.max(0, (innerHeight - textHeight) / 2) 108 | 109 | placeChild(titleTextView, titleTextView.measuredWidth, titleTextView.measuredHeight, 110 | x + spacing, y) 111 | } 112 | 113 | private fun placeChild(child: View, width: Int, height: Int, left: Int, top: Int) { 114 | val lp: MarginLayoutParams = child.layoutParams as MarginLayoutParams 115 | child.layout(left + lp.leftMargin, top + lp.topMargin, left + lp.leftMargin + width, 116 | top + lp.topMargin + height) 117 | } 118 | 119 | private fun getImageHeight(): Int { 120 | return if (this.imageHeight == -1) this.image.measuredHeight else this.imageHeight 121 | } 122 | 123 | private fun getImageWidth(): Int { 124 | return if (this.imageWidth == -1) this.image.measuredWidth else this.imageWidth 125 | } 126 | 127 | private fun widthWithMargins(child: View, width: Int): Int { 128 | val lp: MarginLayoutParams = child.layoutParams as MarginLayoutParams 129 | return width + lp.leftMargin + lp.rightMargin 130 | } 131 | 132 | private fun heightWithMargins(child: View, height: Int): Int { 133 | val lp: MarginLayoutParams = child.layoutParams as MarginLayoutParams 134 | return height + lp.topMargin + lp.bottomMargin 135 | } 136 | 137 | fun setDrawable(image: Drawable) { 138 | this.image.setImageDrawable(image) 139 | } 140 | 141 | fun setDrawable(@DrawableRes drawableResId: Int) { 142 | this.image.setImageResource(drawableResId) 143 | } 144 | 145 | fun setText(caption: String) { 146 | this.titleTextView.text = caption 147 | } 148 | 149 | fun setText(caption: SpannableStringBuilder) { 150 | this.titleTextView.text = caption 151 | } 152 | 153 | } 154 | 155 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/thoughtbot/tropos/widgets/ForecastLayout.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.widgets 2 | 3 | import android.content.Context 4 | import androidx.annotation.DrawableRes 5 | import android.util.AttributeSet 6 | import android.view.LayoutInflater 7 | import android.widget.ImageView 8 | import android.widget.LinearLayout 9 | import android.widget.TextView 10 | import com.thoughtbot.tropos.R 11 | 12 | class ForecastLayout : LinearLayout { 13 | private val icon: ImageView 14 | private val day: TextView 15 | private val highTemp: TextView 16 | private val lowTemp: TextView 17 | 18 | constructor(context: Context) : this(context, null) 19 | 20 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { 21 | LayoutInflater.from(context).inflate(R.layout.layout_forecast, this) 22 | icon = findViewById(R.id.image_forecast_icon) as ImageView 23 | day = findViewById(R.id.text_forecast_day) as TextView 24 | highTemp = findViewById(R.id.text_forecast_high) as TextView 25 | lowTemp = findViewById(R.id.text_forecast_low) as TextView 26 | } 27 | 28 | 29 | fun setIcon(@DrawableRes imageId: Int) { 30 | this.icon.setImageResource(imageId) 31 | } 32 | 33 | fun setDay(day: String) { 34 | this.day.text = day 35 | } 36 | 37 | fun setHighTemp(temp: String) { 38 | this.highTemp.text = temp 39 | } 40 | 41 | fun setLowTemp(temp: String) { 42 | this.lowTemp.text = temp 43 | } 44 | } 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in.xml: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out.xml: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/clear_day.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/clear_night.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/cloudy.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/fog.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 18 | 23 | 28 | 33 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check_selected.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 10 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check_unselected.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_next.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 10 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/label_thermometer.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 23 | 24 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/label_wind.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 25 | 40 | 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/partly_cloudy_day.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/partly_cloudy_night.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rain.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ralph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 16 | 77 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/settings_check_selector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sleet.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/snow.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/stripes.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 16 | 20 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/wind.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 20 | 21 | 27 | 28 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 54 | 55 | 65 | 66 | 75 | 76 | 87 | 88 | 95 | 96 | 97 | 98 | 102 | 103 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 22 | 23 | 27 | 28 | 36 | 37 | 45 | 46 | 47 | 48 | 53 | 54 | 61 | 62 | 67 | 68 | 78 | 79 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_webview.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/grid_item_current_weather.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | 21 | 31 | 32 | 48 | 49 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/res/layout/grid_item_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/label_drawable_text.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 22 | 23 | 30 | 31 | 40 | 41 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/menu/close_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/menu/settings_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/transition/reveal.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values-v21/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @color/dark_gray 4 | @color/dark_gray 5 | @color/tangerine 6 | 7 | #191929 8 | #202032 9 | #0F0F19 10 | #5E5B54 11 | #A1A7B1 12 | 13 | #E43B24 14 | #F39307 15 | #15A7E8 16 | #62CBEE 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 5 | 48dp 6 | 28dp 7 | 8dp 8 | //drawable_label_width + drawable_label_spacing 9 | 36dp 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Tropos 3 | 4 | //directions 5 | 6 | N 7 | NE 8 | E 9 | SE 10 | S 11 | SW 12 | W 13 | NW 14 | 15 | //main 16 | %1$d° / %2$d° / %3$d° 17 | %1$d mph %2$s 18 | %1$d km/h %2$s 19 | %d° 20 | Powered by Dark Sky 21 | thoughtbot 22 | 🙅🏾 🙅🏼 🙅🏻\n\nLocation sharing is turned off. \n\nEnable location sharing in Settings to help us give you the most accurate weather predictions 23 | 🙅🏾 🙅🏼 🙅🏻\n\n%s 24 | 25 | //toolbar 26 | Updated at %s 27 | Checking Weather… 28 | Update Failed 29 | 30 | //time of day 31 | tonight 32 | this morning 33 | today 34 | this afternoon 35 | last night 36 | yesterday morning 37 | yesterday 38 | yesterday afternoon 39 | 40 | //temperature formats 41 | It\'s %s %s than %s. 42 | It\'s the %s %s as %s. 43 | 44 | //settings 45 | Metric 46 | Imperial 47 | info 48 | Privacy Policy 49 | Settings 50 | Settings 51 | Handcrafted with 💜 by thoughtbot 52 | Close 53 | unit system 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 19 | 20 | 28 | 29 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/data/TemperatureDifferenceTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.data 2 | 3 | import com.thoughtbot.tropos.BuildConfig 4 | import com.thoughtbot.tropos.data.TemperatureDifference.COLDER 5 | import com.thoughtbot.tropos.data.TemperatureDifference.COOLER 6 | import com.thoughtbot.tropos.data.TemperatureDifference.HOTTER 7 | import com.thoughtbot.tropos.data.TemperatureDifference.SAME 8 | import com.thoughtbot.tropos.data.TemperatureDifference.WARMER 9 | import com.thoughtbot.tropos.testUtils.fakeCondition 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricGradleTestRunner 13 | import org.robolectric.annotation.Config 14 | import kotlin.test.assertEquals 15 | 16 | @RunWith(RobolectricGradleTestRunner::class) 17 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 18 | class TemperatureDifferenceTest { 19 | @Test 20 | fun testGetTempDiff_SAME() { 21 | val today = fakeCondition(currentTemp = 0) 22 | val yesterday = fakeCondition(currentTemp = 0) 23 | 24 | val actual = TemperatureDifference(today, yesterday) 25 | val expected = SAME 26 | assertEquals(actual, expected) 27 | 28 | } 29 | 30 | @Test 31 | fun testGetTempDiff_WARMER() { 32 | val today = fakeCondition(currentTemp = 5) 33 | val yesterday = fakeCondition(currentTemp = 0) 34 | 35 | val actual = TemperatureDifference(today, yesterday) 36 | val expected = WARMER 37 | assertEquals(actual, expected) 38 | } 39 | 40 | @Test 41 | fun testGetTempDiff_HOTTER() { 42 | val today = fakeCondition(currentTemp = 100) 43 | val yesterday = fakeCondition(currentTemp = 80) 44 | 45 | val actual = TemperatureDifference(today, yesterday) 46 | val expected = HOTTER 47 | assertEquals(actual, expected) 48 | } 49 | 50 | @Test 51 | fun testGetTempDiff_COLDER() { 52 | val today = fakeCondition(currentTemp = 0) 53 | val yesterday = fakeCondition(currentTemp = 10) 54 | 55 | val actual = TemperatureDifference(today, yesterday) 56 | val expected = COLDER 57 | assertEquals(actual, expected) 58 | } 59 | 60 | @Test 61 | fun testGetTempDiff_COOLER() { 62 | val today = fakeCondition(currentTemp = 0) 63 | val yesterday = fakeCondition(currentTemp = 9) 64 | 65 | val actual = TemperatureDifference(today, yesterday) 66 | val expected = COOLER 67 | assertEquals(actual, expected) 68 | } 69 | 70 | } 71 | 72 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/settings/SettingsPresenterTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.settings 2 | 3 | import android.content.Context 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.nhaarman.mockito_kotlin.whenever 7 | import com.thoughtbot.tropos.BuildConfig 8 | import com.thoughtbot.tropos.R 9 | import com.thoughtbot.tropos.data.Condition 10 | import com.thoughtbot.tropos.data.Preferences 11 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 12 | import com.thoughtbot.tropos.data.Unit.METRIC 13 | import com.thoughtbot.tropos.data.Weather 14 | import com.thoughtbot.tropos.testUtils.fakeCondition 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.robolectric.RobolectricGradleTestRunner 19 | import org.robolectric.RuntimeEnvironment 20 | import org.robolectric.annotation.Config 21 | 22 | @RunWith(RobolectricGradleTestRunner::class) 23 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 24 | class SettingsPresenterTest() { 25 | 26 | lateinit var context: Context 27 | val view = mock() 28 | val preferences = mock() 29 | val mockWeather: Weather = Weather(fakeCondition(), fakeCondition(), 30 | listOf(fakeCondition(), fakeCondition(), fakeCondition())) 31 | 32 | @Before 33 | fun setup() { 34 | RuntimeEnvironment.application.let { context = it } 35 | } 36 | 37 | @Test 38 | fun testInitialValue_metric() { 39 | whenever(preferences.unit).thenReturn(METRIC) 40 | 41 | val presenter = SettingsPresenter(view, preferences) 42 | 43 | presenter.init() 44 | 45 | verify(view).checkUnitPreference(R.id.settings_metric_button) 46 | } 47 | 48 | @Test 49 | fun testInitialValue_imperial() { 50 | whenever(preferences.unit).thenReturn(IMPERIAL) 51 | 52 | val presenter = SettingsPresenter(view, preferences) 53 | 54 | presenter.init() 55 | 56 | verify(view).checkUnitPreference(R.id.settings_imperial_button) 57 | } 58 | 59 | @Test 60 | fun testCheckChange_toImperial() { 61 | val presenter = SettingsPresenter(view, preferences) 62 | 63 | presenter.onCheckedChanged(null, R.id.settings_imperial_button) 64 | 65 | verify(preferences).unit = IMPERIAL 66 | } 67 | 68 | @Test 69 | fun testCheckChange_toMetric() { 70 | val presenter = SettingsPresenter(view, preferences) 71 | 72 | presenter.onCheckedChanged(null, R.id.settings_metric_button) 73 | 74 | verify(preferences).unit = METRIC 75 | } 76 | 77 | } 78 | 79 | 80 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/splash/SplashPresenterTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.splash 2 | 3 | import android.content.Context 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.verify 6 | import com.nhaarman.mockito_kotlin.verifyNoMoreInteractions 7 | import com.nhaarman.mockito_kotlin.whenever 8 | import com.thoughtbot.tropos.BuildConfig 9 | import com.thoughtbot.tropos.data.Weather 10 | import com.thoughtbot.tropos.data.WeatherDataSource 11 | import com.thoughtbot.tropos.permissions.Permission 12 | import com.thoughtbot.tropos.testUtils.fakeCondition 13 | import com.thoughtbot.tropos.ui.MainActivity 14 | import io.reactivex.Observable 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.robolectric.RobolectricGradleTestRunner 19 | import org.robolectric.RuntimeEnvironment 20 | import org.robolectric.annotation.Config 21 | 22 | @RunWith(RobolectricGradleTestRunner::class) 23 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 24 | class SplashPresenterTest() { 25 | 26 | lateinit var context: Context 27 | private val view = mock() 28 | private val permission = mock() 29 | private val weatherDataSource = mock() 30 | private val mockWeather: Weather = Weather(fakeCondition(), fakeCondition(), 31 | listOf(fakeCondition(), fakeCondition(), fakeCondition())) 32 | 33 | @Before 34 | fun setup() { 35 | RuntimeEnvironment.application.let { context = it } 36 | } 37 | 38 | @Test 39 | fun testInit_hasPermission() { 40 | val presenter = SplashPresenter(view, permission, weatherDataSource) 41 | whenever(view.context).thenReturn(context) 42 | stubWeather() 43 | stubPermission(true) 44 | 45 | presenter.init() 46 | 47 | val intent = MainActivity.createIntent(context, mockWeather) 48 | verify(view).navigate(intent) 49 | } 50 | 51 | @Test 52 | fun testInit_doesNotHavePermission() { 53 | val presenter = SplashPresenter(view, permission, weatherDataSource) 54 | whenever(view.context).thenReturn(context) 55 | stubWeather() 56 | stubPermission(false) 57 | 58 | presenter.init() 59 | 60 | verifyNoMoreInteractions(view) 61 | } 62 | 63 | fun stubWeather() { 64 | whenever(weatherDataSource.fetchWeather()).thenReturn(Observable.just(mockWeather)) 65 | } 66 | 67 | fun stubPermission(hasPermission: Boolean) { 68 | whenever(permission.hasPermission()).thenReturn(hasPermission) 69 | } 70 | 71 | } 72 | 73 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/testUtils/Assertions.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.testUtils 2 | 3 | import kotlin.test.assertEquals 4 | 5 | fun assertStringEquals(expected: CharSequence, actual: CharSequence) = 6 | assertEquals(expected.toString(), actual.toString()) 7 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/testUtils/FakeCondition.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.testUtils 2 | 3 | import android.location.Location 4 | import com.thoughtbot.tropos.data.Condition 5 | import com.thoughtbot.tropos.data.Icon 6 | import com.thoughtbot.tropos.data.Unit 7 | import com.thoughtbot.tropos.data.WindDirection 8 | import java.util.* 9 | 10 | 11 | fun fakeCondition(unit: Unit = Unit.IMPERIAL, currentTemp: Int = 52): Condition { 12 | val cal = Calendar.getInstance() 13 | cal.set(2017, 0, 11, 16, 16, 29) 14 | val date = cal.time 15 | val summary = "Mostly Cloudy" 16 | val location = Location("") 17 | location.longitude = -122.4375671 18 | location.latitude = 37.8032493 19 | val icon = Icon.PARTLY_CLOUDY_DAY 20 | val windSpeed = 4 21 | val windDirection = WindDirection(171.0) 22 | val lowTemp = 48 23 | val highTemp = 54 24 | 25 | return Condition(date, summary, location, icon, windSpeed, windDirection, unit, lowTemp, 26 | currentTemp, highTemp) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/testUtils/MockGeocoder.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.testUtils 2 | 3 | import android.location.Address 4 | import android.location.Geocoder 5 | import org.robolectric.annotation.Implementation 6 | import org.robolectric.annotation.Implements 7 | import org.robolectric.shadows.maps.ShadowGeocoder 8 | import java.util.Locale 9 | 10 | 11 | @Implements(Geocoder::class) 12 | class MockGeocoder : ShadowGeocoder() { 13 | 14 | @Implementation 15 | override fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List
{ 16 | val address: Address = Address(Locale.ENGLISH) 17 | address.locality = "San Francisco" 18 | return listOf(address) 19 | } 20 | 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/ui/MainPresenterTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.ui 2 | 3 | import android.content.Context 4 | import com.nhaarman.mockito_kotlin.isA 5 | import com.nhaarman.mockito_kotlin.mock 6 | import com.nhaarman.mockito_kotlin.verify 7 | import com.nhaarman.mockito_kotlin.whenever 8 | import com.thoughtbot.tropos.BuildConfig 9 | import com.thoughtbot.tropos.data.Condition 10 | import com.thoughtbot.tropos.data.Weather 11 | import com.thoughtbot.tropos.data.WeatherDataSource 12 | import com.thoughtbot.tropos.permissions.Permission 13 | import com.thoughtbot.tropos.testUtils.fakeCondition 14 | import io.reactivex.Observable 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.robolectric.RobolectricGradleTestRunner 19 | import org.robolectric.RuntimeEnvironment 20 | import org.robolectric.annotation.Config 21 | 22 | @RunWith(RobolectricGradleTestRunner::class) 23 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 24 | class MainPresenterTest() { 25 | 26 | private lateinit var context: Context 27 | private val view = mock() 28 | private val permission = mock() 29 | private val weatherDataSource = mock() 30 | private val mockWeather: Weather = Weather(fakeCondition(), fakeCondition(), 31 | listOf(fakeCondition(), fakeCondition(), fakeCondition())) 32 | 33 | @Before 34 | fun setup() { 35 | RuntimeEnvironment.application.let { context = it } 36 | } 37 | 38 | @Test 39 | fun testOnResume_hasPermission() { 40 | val presenter = MainPresenter(view, null, weatherDataSource, permission) 41 | whenever(view.context).thenReturn(context) 42 | stubWeather() 43 | stubPermission(true) 44 | 45 | presenter.onStart() 46 | 47 | verify(view).viewState = isA() 48 | verify(view).viewState = isA() 49 | } 50 | 51 | @Test 52 | fun testOnResume_doesNotHavePermission() { 53 | val presenter = MainPresenter(view, null, weatherDataSource, permission) 54 | whenever(view.context).thenReturn(context) 55 | stubWeather() 56 | stubPermission(false) 57 | 58 | presenter.onStart() 59 | 60 | verify(view).viewState = isA() 61 | } 62 | 63 | fun stubWeather() { 64 | whenever(weatherDataSource.fetchWeather()).thenReturn(Observable.just(mockWeather)) 65 | } 66 | 67 | fun stubPermission(hasPermission: Boolean) { 68 | whenever(permission.hasPermission()).thenReturn(hasPermission) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/viewmodels/CurrentWeatherViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.whenever 6 | import com.thoughtbot.tropos.BuildConfig 7 | import com.thoughtbot.tropos.R.drawable 8 | import com.thoughtbot.tropos.data.Preferences 9 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 10 | import com.thoughtbot.tropos.data.Unit.METRIC 11 | import com.thoughtbot.tropos.testUtils.assertStringEquals 12 | import com.thoughtbot.tropos.testUtils.fakeCondition 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.robolectric.RobolectricGradleTestRunner 17 | import org.robolectric.RuntimeEnvironment 18 | import org.robolectric.annotation.Config 19 | import kotlin.test.assertEquals 20 | 21 | @RunWith(RobolectricGradleTestRunner::class) 22 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 23 | class CurrentWeatherViewModelTest() { 24 | 25 | private lateinit var context: Context 26 | private val preferences = mock() 27 | 28 | @Before 29 | fun setup() { 30 | RuntimeEnvironment.application.let { context = it } 31 | } 32 | 33 | @Test 34 | fun testWeatherSummary() { 35 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 36 | val expected = "It's the same this afternoon as yesterday afternoon." 37 | val actual = viewModel.weatherSummary() 38 | 39 | assertStringEquals(expected, actual) 40 | } 41 | 42 | @Test 43 | fun testIcon() { 44 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 45 | val expected = drawable.partly_cloudy_day 46 | val actual = viewModel.icon 47 | 48 | assertEquals(expected, actual) 49 | } 50 | 51 | @Test 52 | fun testTemperatures_imperial_to_imperial() { 53 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 54 | whenever(preferences.unit).thenReturn(IMPERIAL) 55 | 56 | val expected = "54° / 52° / 48°" 57 | val actual = viewModel.temperatures() 58 | 59 | assertStringEquals(expected, actual) 60 | } 61 | 62 | @Test 63 | fun testTemperatures_imperial_to_metric() { 64 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 65 | whenever(preferences.unit).thenReturn(METRIC) 66 | 67 | val expected = "12° / 11° / 8°" 68 | val actual = viewModel.temperatures() 69 | 70 | assertStringEquals(expected, actual) 71 | } 72 | 73 | @Test 74 | fun testTemperatures_metric_to_metric() { 75 | val metricCondition = fakeCondition(unit = METRIC) 76 | val viewModel = CurrentWeatherViewModel(context, preferences, metricCondition, fakeCondition()) 77 | whenever(preferences.unit).thenReturn(METRIC) 78 | 79 | val expected = "54° / 52° / 48°" 80 | val actual = viewModel.temperatures() 81 | 82 | assertStringEquals(expected, actual) 83 | } 84 | 85 | @Test 86 | fun testTemperatures_metric_to_imperial() { 87 | val metricCondition = fakeCondition(unit = METRIC) 88 | val viewModel = CurrentWeatherViewModel(context, preferences, metricCondition, fakeCondition()) 89 | whenever(preferences.unit).thenReturn(IMPERIAL) 90 | 91 | val expected = "129° / 125° / 118°" 92 | val actual = viewModel.temperatures() 93 | 94 | assertStringEquals(expected, actual) 95 | } 96 | 97 | @Test 98 | fun testWind_imperial_to_imperial() { 99 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 100 | whenever(preferences.unit).thenReturn(IMPERIAL) 101 | 102 | val expected = "4 mph S" 103 | val actual = viewModel.wind() 104 | 105 | assertEquals(expected, actual) 106 | } 107 | 108 | @Test 109 | fun testWind_imperial_to_metric() { 110 | val viewModel = CurrentWeatherViewModel(context, preferences, fakeCondition(), fakeCondition()) 111 | whenever(preferences.unit).thenReturn(METRIC) 112 | 113 | val expected = "6 km/h S" 114 | val actual = viewModel.wind() 115 | 116 | assertEquals(expected, actual) 117 | } 118 | 119 | @Test 120 | fun testWind_metric_to_metric() { 121 | val metricCondition = fakeCondition(unit = METRIC) 122 | val viewModel = CurrentWeatherViewModel(context, preferences, metricCondition, fakeCondition()) 123 | whenever(preferences.unit).thenReturn(METRIC) 124 | 125 | val expected = "4 km/h S" 126 | val actual = viewModel.wind() 127 | 128 | assertEquals(expected, actual) 129 | } 130 | 131 | @Test 132 | fun testWind_metric_to_imperial() { 133 | val metricCondition = fakeCondition(unit = METRIC) 134 | val viewModel = CurrentWeatherViewModel(context, preferences, metricCondition, fakeCondition()) 135 | whenever(preferences.unit).thenReturn(IMPERIAL) 136 | 137 | val expected = "2 mph S" 138 | val actual = viewModel.wind() 139 | 140 | assertEquals(expected, actual) 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/viewmodels/ErrorToolbarViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.thoughtbot.tropos.BuildConfig 5 | import org.junit.Before 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.robolectric.RobolectricGradleTestRunner 9 | import org.robolectric.RuntimeEnvironment 10 | import org.robolectric.annotation.Config 11 | import java.text.SimpleDateFormat 12 | import java.util.Date 13 | import java.util.Locale 14 | import kotlin.test.assertEquals 15 | 16 | @RunWith(RobolectricGradleTestRunner::class) 17 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 18 | class ErrorToolbarViewModelTest() { 19 | 20 | private lateinit var context: Context 21 | 22 | @Before 23 | fun setup() { 24 | RuntimeEnvironment.application.let { context = it } 25 | } 26 | 27 | @Test 28 | fun testTitle() { 29 | val viewModel = ErrorToolbarViewModel(context) 30 | val expected = "Update Failed" 31 | val actual = viewModel.title() 32 | 33 | assertEquals(expected, actual) 34 | } 35 | 36 | @Test 37 | fun testSubtitle() { 38 | val viewModel = ErrorToolbarViewModel(context) 39 | val formattedDate = SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date()) 40 | val expected = "Updated at $formattedDate" 41 | val actual = viewModel.subtitle() 42 | 43 | assertEquals(expected, actual) 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/viewmodels/ForecastViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.whenever 6 | import com.thoughtbot.tropos.BuildConfig 7 | import com.thoughtbot.tropos.R.drawable 8 | import com.thoughtbot.tropos.data.Condition 9 | import com.thoughtbot.tropos.data.Preferences 10 | import com.thoughtbot.tropos.data.Unit.IMPERIAL 11 | import com.thoughtbot.tropos.data.Unit.METRIC 12 | import com.thoughtbot.tropos.testUtils.fakeCondition 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.robolectric.RobolectricGradleTestRunner 17 | import org.robolectric.RuntimeEnvironment 18 | import org.robolectric.annotation.Config 19 | import kotlin.test.assertEquals 20 | 21 | 22 | @RunWith(RobolectricGradleTestRunner::class) 23 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 24 | class ForecastViewModelTest() { 25 | 26 | private lateinit var context: Context 27 | private val preferences = mock() 28 | 29 | @Before 30 | fun setup() { 31 | RuntimeEnvironment.application.let { context = it } 32 | } 33 | 34 | @Test 35 | fun testIcon() { 36 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 37 | val expected = drawable.partly_cloudy_day 38 | val actual = viewModel.icon 39 | 40 | assertEquals(expected, actual) 41 | } 42 | 43 | @Test 44 | fun testDay() { 45 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 46 | val expected = "Wed" 47 | val actual = viewModel.day 48 | 49 | assertEquals(expected, actual) 50 | } 51 | 52 | @Test 53 | fun testHighTemp_imperial_to_imperial() { 54 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 55 | whenever(preferences.unit).thenReturn(IMPERIAL) 56 | 57 | val expected = "54°" 58 | val actual = viewModel.highTemp() 59 | 60 | assertEquals(expected, actual) 61 | } 62 | 63 | @Test 64 | fun testHighTemp_imperial_to_metric() { 65 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 66 | whenever(preferences.unit).thenReturn(METRIC) 67 | 68 | val expected = "12°" 69 | val actual = viewModel.highTemp() 70 | 71 | assertEquals(expected, actual) 72 | } 73 | 74 | @Test 75 | fun testHighTemp_metric_to_metric() { 76 | val metricCondition = fakeCondition(unit = METRIC) 77 | val viewModel = ForecastViewModel(context, preferences, metricCondition) 78 | whenever(preferences.unit).thenReturn(METRIC) 79 | 80 | val expected = "54°" 81 | val actual = viewModel.highTemp() 82 | 83 | assertEquals(expected, actual) 84 | } 85 | 86 | @Test 87 | fun testHighTemp_metric_to_imperial() { 88 | val metricCondition = fakeCondition(unit = METRIC) 89 | val viewModel = ForecastViewModel(context, preferences, metricCondition) 90 | whenever(preferences.unit).thenReturn(IMPERIAL) 91 | 92 | val expected = "129°" 93 | val actual = viewModel.highTemp() 94 | 95 | assertEquals(expected, actual) 96 | } 97 | 98 | @Test 99 | fun testLowTemp_imperial_to_imperial() { 100 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 101 | whenever(preferences.unit).thenReturn(IMPERIAL) 102 | 103 | val expected = "48°" 104 | val actual = viewModel.lowTemp() 105 | 106 | assertEquals(expected, actual) 107 | } 108 | 109 | @Test 110 | fun testLowTemp_imperial_to_metric() { 111 | val viewModel = ForecastViewModel(context, preferences, fakeCondition()) 112 | whenever(preferences.unit).thenReturn(METRIC) 113 | 114 | val expected = "8°" 115 | val actual = viewModel.lowTemp() 116 | 117 | assertEquals(expected, actual) 118 | } 119 | 120 | @Test 121 | fun testLowTemp_metric_to_metric() { 122 | val metricCondition = fakeCondition(unit = METRIC) 123 | val viewModel = ForecastViewModel(context, preferences, metricCondition) 124 | whenever(preferences.unit).thenReturn(METRIC) 125 | 126 | val expected = "48°" 127 | val actual = viewModel.lowTemp() 128 | 129 | assertEquals(expected, actual) 130 | } 131 | 132 | @Test 133 | fun testLowTemp_metric_to_imperial() { 134 | val metricCondition = fakeCondition(unit = METRIC) 135 | val viewModel = ForecastViewModel(context, preferences, metricCondition) 136 | whenever(preferences.unit).thenReturn(IMPERIAL) 137 | 138 | val expected = "118°" 139 | val actual = viewModel.lowTemp() 140 | 141 | assertEquals(expected, actual) 142 | } 143 | } 144 | 145 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/viewmodels/LoadingToolbarViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.thoughtbot.tropos.BuildConfig 5 | import org.junit.Before 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.robolectric.RobolectricGradleTestRunner 9 | import org.robolectric.RuntimeEnvironment 10 | import org.robolectric.annotation.Config 11 | import java.text.SimpleDateFormat 12 | import java.util.Date 13 | import java.util.Locale 14 | import kotlin.test.assertEquals 15 | 16 | @RunWith(RobolectricGradleTestRunner::class) 17 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 18 | class LoadingToolbarViewModelTest() { 19 | 20 | private lateinit var context: Context 21 | 22 | @Before 23 | fun setup() { 24 | RuntimeEnvironment.application.let { context = it } 25 | } 26 | 27 | @Test 28 | fun testTitle() { 29 | val viewModel = LoadingToolbarViewModel(context) 30 | val expected = "Checking Weather…" 31 | val actual = viewModel.title() 32 | 33 | assertEquals(expected, actual) 34 | } 35 | 36 | @Test 37 | fun testSubtitle() { 38 | val viewModel = LoadingToolbarViewModel(context) 39 | val formattedDate = SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date()) 40 | val expected = "Updated at $formattedDate" 41 | val actual = viewModel.subtitle() 42 | 43 | assertEquals(expected, actual) 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/test/kotlin/com/thoughtbot/tropos/viewmodels/WeatherToolbarViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.thoughtbot.tropos.viewmodels 2 | 3 | import android.content.Context 4 | import com.thoughtbot.tropos.BuildConfig 5 | import com.thoughtbot.tropos.testUtils.fakeCondition 6 | import com.thoughtbot.tropos.testUtils.MockGeocoder 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.robolectric.RobolectricGradleTestRunner 11 | import org.robolectric.RuntimeEnvironment 12 | import org.robolectric.annotation.Config 13 | import kotlin.test.assertEquals 14 | 15 | @RunWith(RobolectricGradleTestRunner::class) 16 | @Config(constants = BuildConfig::class, sdk = intArrayOf(21)) 17 | class WeatherToolbarViewModelTest() { 18 | 19 | private lateinit var context: Context 20 | 21 | @Before 22 | fun setup() { 23 | RuntimeEnvironment.application.let { context = it } 24 | } 25 | 26 | @Test 27 | @Config(shadows = arrayOf(MockGeocoder::class)) 28 | fun testTitle() { 29 | val viewModel = WeatherToolbarViewModel(context, fakeCondition()) 30 | val expected = "San Francisco" 31 | val actual = viewModel.title() 32 | 33 | assertEquals(expected, actual) 34 | } 35 | 36 | @Test 37 | fun testSubtitle() { 38 | val viewModel = WeatherToolbarViewModel(context, fakeCondition()) 39 | val expected = "Updated at 4:16 PM" 40 | val actual = viewModel.subtitle() 41 | 42 | assertEquals(expected, actual) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit if any subcommand fails 4 | set -e 5 | 6 | # check if keystore exists 7 | if [ ! -e "app/tropos.keystore" ]; then 8 | echo "File \`app/tropos.keystore\` does not exist, this will prevent you from 9 | creating release builds." 10 | echo "To fix this, download the keystore from 11 | thoughtbot 1Password and paste it into \`app/tropos.keystore\`.\n" 12 | fi 13 | 14 | # check if keystore credentials exists, if not, copy from the example 15 | test -e app/signing.gradle || cp app/signing.gradle.example app/signing.gradle 16 | 17 | # if we copied from the example, prompt user to fetch and paste credentials 18 | if cmp -s app/signing.gradle.example app/signing.gradle 19 | then 20 | echo "You are missing the keystore credentials, which are required to sign 21 | and create release builds. Get the credentials from thoughtbot 1Password and 22 | paste the values into \`app/signing.gradle\`." 23 | fi 24 | 25 | # check if signing config exists 26 | if [ ! -e "app/tropos.json" ]; then 27 | echo "File \`app/tropos.json\` does not exist, this will prevent you from 28 | shipping builds to the Play store." 29 | echo "To fix this, download tropos.json from thoughtbot 1Password and paste 30 | it into \`app/tropos.json\`.\n" 31 | fi 32 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.kotlin_version = '1.3.10' 5 | 6 | repositories { 7 | jcenter() 8 | google() 9 | } 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:3.5.3' 12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 13 | classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlin_version" 14 | 15 | // NOTE: Do not place your application dependencies here; they belong 16 | // in the individual module build.gradle files 17 | } 18 | } 19 | 20 | allprojects { 21 | repositories { 22 | jcenter() 23 | google() 24 | } 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Build configuration for Circle CI 3 | # 4 | general: 5 | artifacts: 6 | - "app/build/outputs/apk" 7 | - "app/build/reports/tests" 8 | - "app/build/outputs/androidTest-results" 9 | 10 | machine: 11 | java: 12 | version: oraclejdk8 13 | environment: 14 | ANDROID_HOME: /usr/local/android-sdk-linux 15 | 16 | dependencies: 17 | pre: 18 | - source environmentSetup.sh && get_android_sdk_25 19 | - mkdir -p $ANDROID_HOME"/licenses" 20 | - echo $ANDROID_SDK_LICENSE > $ANDROID_HOME"/licenses/android-sdk-license" 21 | override: 22 | - source environmentSetup.sh && copy_env_vars_to_gradle_properties 23 | # we are cheating because there is a problem with constraint layout ATM 24 | # see: https://code.google.com/p/android/issues/detail?id=212128 25 | - ./gradlew dependencies || true 26 | - ./gradlew clean assembleRelease -PdisablePreDex 27 | 28 | test: 29 | override: 30 | - ./gradlew testRelease 31 | -------------------------------------------------------------------------------- /environmentSetup.sh: -------------------------------------------------------------------------------- 1 | # Environment Setup which is required for Circle CI 2 | 3 | function copy_env_vars_to_gradle_properties { 4 | GRADLE_PROPERTIES=$HOME"/.gradle/gradle.properties" 5 | export GRADLE_PROPERTIES 6 | 7 | if [ ! -f "$GRADLE_PROPERTIES" ]; then 8 | echo "Gradle Properties does not exist" 9 | 10 | echo "Creating Gradle Properties directory and file..." 11 | mkdir -p "$HOME/.gradle/" 12 | touch "$GRADLE_PROPERTIES" 13 | 14 | echo "Writing DARK_SKY_FORECAST_API_KEY to gradle.properties..." 15 | echo "DARK_SKY_FORECAST_API_KEY=$DARK_SKY_FORECAST_API_KEY_ENV_VAR" >> $GRADLE_PROPERTIES 16 | fi 17 | } 18 | 19 | function affirmative_android_update { 20 | echo y | android update sdk --no-ui --all --filter "$1" 21 | } 22 | 23 | function get_android_sdk_25 { 24 | # fix the CircleCI path 25 | # export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools:$PATH" 26 | 27 | DEPS="$ANDROID_HOME/installed-dependencies" 28 | 29 | if [ ! -e $DEPS ]; then 30 | echo Fetch and install Android SDK 25 31 | echo y | android update sdk --no-ui --all --filter tools,platform-tools,build-tools-25.0.2,android-25,extra-google-m2repository,extra-google-google_play_services,extra-android-m2repository,extra-android-support 32 | # create the folder so we won't do this again (this is soooo Apache Ant right here) 33 | touch $DEPS 34 | fi 35 | } 36 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file "app/tropos.json" 2 | package_name "com.thoughtbot.tropos" 3 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # vim: ft=ruby 2 | # Customise this file, documentation can be found here: 3 | # https://github.com/KrauseFx/fastlane/tree/master/docs 4 | # All available actions: https://github.com/KrauseFx/fastlane/blob/master/docs/Actions.md 5 | # can also be listed using the `fastlane actions` command 6 | 7 | default_platform :android 8 | 9 | platform :android do 10 | before_all do 11 | gradle(task: 'increaseVersionCode') 12 | end 13 | 14 | desc "Generate a Signed APK" 15 | lane :build do 16 | gradle(task: 'clean') 17 | gradle(task: "assemble", build_type: "Release") 18 | end 19 | 20 | desc "Build the app and deploy it to Beta" 21 | lane :beta do 22 | build 23 | git_add(path: "app/build.gradle") 24 | git_commit(path: "app/build.gradle", message: "Bump version number") 25 | supply(track: "beta", apk: "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}") 26 | end 27 | 28 | desc "Build the app and deploy it to Alpha" 29 | lane :alpha do 30 | build 31 | git_add(path: "app/build.gradle") 32 | git_commit(path: "app/build.gradle", message: "Bump version number") 33 | supply(track: "alpha", apk: "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}") 34 | end 35 | 36 | desc "Build the app and deploy it to Production" 37 | lane :release do 38 | build 39 | git_add(path: "app/build.gradle") 40 | git_commit(path: "app/build.gradle", message: "Bump version number") 41 | supply(apk: "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}") 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | ## Choose your installation method: 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
Homebrew 16 | Installer Script 17 | Rubygems 18 |
macOSmacOSmacOS or Linux with Ruby 2.0.0 or above
brew cask install fastlaneDownload the zip file. Then double click on the install script (or run it in a terminal window).sudo gem install fastlane -NV
30 | # Available Actions 31 | ## Android 32 | ### android build 33 | ``` 34 | fastlane android build 35 | ``` 36 | Build the app 37 | ### android beta 38 | ``` 39 | fastlane android beta 40 | ``` 41 | Build the app and deploy it to the Beta channel in the Google Play Store 42 | ### android release 43 | ``` 44 | fastlane android release 45 | ``` 46 | Build the app and deploy it to the Alpha (Production) channel in the Google Play Store 47 | 48 | ---- 49 | 50 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. 51 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 52 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 53 | -------------------------------------------------------------------------------- /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 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | android.enableJetifier=true 13 | android.useAndroidX=true 14 | org.gradle.jvmargs=-Xmx1536m 15 | 16 | # When configured, Gradle will run in incubating parallel mode. 17 | # This option should only be used with decoupled projects. More details, visit 18 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 19 | # org.gradle.parallel=true 20 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/tropos-android/f78bf3c84ea91c08d200da6ad851920fcc259f82/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Feb 14 10:56:16 EST 2020 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-5.6.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------