├── .gitignore ├── README.md ├── RecipesApp.png ├── WeatherAppScreenshots.png ├── app ├── .gitignore ├── build.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── local.properties ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── acme │ │ └── weather │ │ └── app │ │ └── model │ │ └── repository │ │ └── database │ │ └── WeatherEntityCrudTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── acme │ │ │ └── weather │ │ │ ├── DebugTree.kt │ │ │ ├── WeatherApplication.kt │ │ │ ├── app │ │ │ ├── model │ │ │ │ ├── api │ │ │ │ │ ├── ForecastData.kt │ │ │ │ │ ├── Location.kt │ │ │ │ │ ├── Temperature.kt │ │ │ │ │ ├── Weather.kt │ │ │ │ │ └── WeatherIcon.kt │ │ │ │ └── repository │ │ │ │ │ ├── WeatherRepository.kt │ │ │ │ │ ├── database │ │ │ │ │ ├── WeatherDatabase.kt │ │ │ │ │ ├── dao │ │ │ │ │ │ └── WeatherDao.kt │ │ │ │ │ └── entity │ │ │ │ │ │ ├── TemperatureEntity.kt │ │ │ │ │ │ └── WeatherEntity.kt │ │ │ │ │ ├── geolocation │ │ │ │ │ └── WeatherLocationService.kt │ │ │ │ │ └── network │ │ │ │ │ ├── WeatherApi.kt │ │ │ │ │ ├── WeatherApiResponseModels.kt │ │ │ │ │ └── WeatherForecastService.kt │ │ │ ├── view │ │ │ │ ├── BindingAdapters.java │ │ │ │ ├── LocationDialogFragment.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── WeatherDetailFragment.kt │ │ │ │ ├── WeatherListFragment.kt │ │ │ │ └── WeatherRecyclerAdapter.kt │ │ │ └── viewmodel │ │ │ │ ├── WeatherDetailViewModel.kt │ │ │ │ ├── WeatherItemViewModel.kt │ │ │ │ ├── WeatherListViewModel.kt │ │ │ │ └── WeatherViewModelFactory.java │ │ │ ├── common │ │ │ ├── di │ │ │ │ ├── AppComponent.kt │ │ │ │ ├── AppInjector.java │ │ │ │ ├── AppModule.kt │ │ │ │ ├── FragmentBuildersModule.java │ │ │ │ ├── Injectable.kt │ │ │ │ ├── MainActivityModule.java │ │ │ │ ├── NetworkModule.kt │ │ │ │ ├── ViewModelKey.java │ │ │ │ └── ViewModelModule.java │ │ │ └── navigation │ │ │ │ ├── DialogNavHostFragment.kt │ │ │ │ ├── DialogNavigator.kt │ │ │ │ ├── NavigationExtensions.kt │ │ │ │ └── NavigationResult.kt │ │ │ └── security │ │ │ ├── view │ │ │ ├── EnterCredentials.kt │ │ │ └── SecureFragment.kt │ │ │ └── viewmodel │ │ │ └── AuthenticationViewModel.kt │ └── res │ │ ├── anim │ │ ├── fade_in.xml │ │ ├── fade_out.xml │ │ ├── slide_in_left.xml │ │ ├── slide_in_right.xml │ │ ├── slide_out_left.xml │ │ └── slide_out_right.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── enter_credentials.xml │ │ ├── location_add_dialog.xml │ │ ├── weather_detail_fragment.xml │ │ ├── weather_item.xml │ │ └── weather_list_fragment.xml │ │ ├── menu │ │ ├── global_menu.xml │ │ ├── simulate_notification_with_deeplink.xml │ │ └── weather_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_cloudy.png │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ ├── ic_launcher_round.png │ │ ├── ic_partlycloudy.png │ │ ├── ic_rainy.png │ │ ├── ic_snowy.png │ │ ├── ic_sunny.png │ │ └── ic_thunderstorm.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ ├── login_graph.xml │ │ └── weather_graph.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── release │ └── java │ │ └── com │ │ └── acme │ │ └── weather │ │ ├── ReleaseTree.kt │ │ └── WeatherApplication.kt │ └── test │ └── java │ └── com │ └── acme │ └── weather │ └── app │ └── model │ └── api │ ├── TemperatureTest.kt │ └── WeatherIconTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Weather 2 | This is a demo app built for teaching concepts. It shows the weather for various cities by zip. 3 | 4 | ### Usage 5 | 6 | 1. Add a zip code. 7 | 2. It reverse geocodes the lat/long and location name from the zip code. 8 | 3. Uses the [darksky.net](https://darksky.net) weather API to fetch current forecast data. 9 | 4. Stores all of the data on device, persistent across launches. 10 | 5. Weather forecast data is cached, but refreshed by the repository on launch. 11 | 12 | ### Libraries + Concepts 13 | * *Architectural Patterns* - Model-View-ViewModel, Repository, Dependency Injection. 14 | * *Android GeoCoder API* - For reverse geocoding the zip code. 15 | * *Architecture Compnents* - LiveData, Room, ViewModel, Navigation Architecture Components. 16 | 17 | ### Screenshots 18 | ![Get the weatherList forecast in your city!](WeatherAppScreenshots.png). 19 | 20 | ### Status 21 | This is a work in progress, still adding tests and cleaning up organization. 22 | 23 | ### What this app is and isn't 24 | 25 | #### Features 26 | This app is meant for educational purposes as a reference to blog posts and other teachings. 27 | It is not meant to be a feature complete product. 28 | 29 | #### Best Practices 30 | The code, while is meant to be written and tested with best practices, may not always reflect the latest. 31 | For example, this project makes use of Anko and an early version of Coroutines that may not reflect current best practices. 32 | 33 | #### Collaboration 34 | Since this app is a personal playground used for teaching purposes, I would prefer not to accept any PRs or Issues at this time. Though suggestions are always welcome. You can reach out to me on Twitter @[emmax](https://twitter.com/emmax). 35 | -------------------------------------------------------------------------------- /RecipesApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericmaxwell2003/Weather/747e5e6dfb1fba6cf0806a2402eeeb81d6919f58/RecipesApp.png -------------------------------------------------------------------------------- /WeatherAppScreenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericmaxwell2003/Weather/747e5e6dfb1fba6cf0806a2402eeeb81d6919f58/WeatherAppScreenshots.png -------------------------------------------------------------------------------- /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 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'androidx.navigation.safeargs' 6 | 7 | android { 8 | compileSdkVersion 28 9 | buildToolsVersion '28.0.3' 10 | defaultConfig { 11 | applicationId "com.acme.weather" 12 | minSdkVersion 21 13 | targetSdkVersion 28 14 | versionCode 1 15 | versionName "1.0" 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | buildTypes { 19 | debug { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 22 | } 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | dataBinding { 29 | enabled = true 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation fileTree(dir: 'libs', include: ['*.jar']) 35 | 36 | // Android Standard Libs 37 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 38 | implementation 'com.google.android.material:material:1.0.0' 39 | implementation 'androidx.appcompat:appcompat:1.0.2' 40 | implementation 'androidx.cardview:cardview:1.0.0' 41 | implementation 'androidx.recyclerview:recyclerview:1.0.0' 42 | implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' 43 | implementation 'androidx.room:room-runtime:2.0.0' 44 | implementation 'androidx.room:room-rxjava2:2.0.0' 45 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 46 | kapt 'androidx.lifecycle:lifecycle-compiler:2.0.0' 47 | kapt 'androidx.room:room-compiler:2.0.0' 48 | annotationProcessor 'androidx.room:room-compiler:2.0.0' 49 | implementation "android.arch.navigation:navigation-fragment-ktx:1.0.0" 50 | implementation "android.arch.navigation:navigation-ui-ktx:1.0.0" 51 | 52 | 53 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0' 54 | implementation 'androidx.fragment:fragment-ktx:1.1.0-alpha06' 55 | 56 | 57 | // Kotlin 58 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 59 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0" 60 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.0' 61 | implementation "org.jetbrains.anko:anko:0.10.3" 62 | 63 | 64 | // Timber 65 | implementation 'com.jakewharton.timber:timber:4.7.1' 66 | 67 | // Retrofit & Plugins 68 | implementation 'com.squareup.retrofit2:retrofit:2.5.0' 69 | implementation 'com.squareup.retrofit2:converter-moshi:2.3.0' 70 | implementation 'com.squareup.okhttp3:okhttp:3.12.0' 71 | implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0' 72 | 73 | // Dagger 74 | implementation "com.google.dagger:dagger:2.19" 75 | implementation "com.google.dagger:dagger-android:2.16" 76 | implementation "com.google.dagger:dagger-android-support:2.16" 77 | kapt "com.google.dagger:dagger-compiler:2.19" 78 | kapt "com.google.dagger:dagger-android-processor:2.16" 79 | 80 | // Testing-only dependencies 81 | testImplementation 'junit:junit:4.12' 82 | androidTestImplementation 'androidx.test:runner:1.1.1' 83 | androidTestImplementation 'androidx.test:rules:1.1.1' 84 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' 85 | androidTestImplementation 'androidx.arch.core:core-testing:2.0.1' 86 | androidTestImplementation('androidx.test.espresso:espresso-contrib:3.1.0') { 87 | exclude group: 'com.android.support', module: 'appcompat-v7' 88 | exclude group: 'com.android.support', module: 'support-v4' 89 | exclude module: 'recyclerview-v7' 90 | } 91 | androidTestImplementation 'androidx.annotation:annotation:1.0.2' 92 | androidTestImplementation 'androidx.legacy:legacy-support-v4:1.0.0' 93 | androidTestImplementation 'androidx.recyclerview:recyclerview:1.0.0' 94 | } 95 | repositories { 96 | mavenCentral() 97 | } 98 | -------------------------------------------------------------------------------- /app/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericmaxwell2003/Weather/747e5e6dfb1fba6cf0806a2402eeeb81d6919f58/app/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Mar 14 14:59:16 EDT 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip 7 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/local.properties: -------------------------------------------------------------------------------- 1 | ## This file is automatically generated by Android Studio. 2 | # Do not modify this file -- YOUR CHANGES WILL BE ERASED! 3 | # 4 | # This file must *NOT* be checked into Version Control Systems, 5 | # as it contains information specific to your local configuration. 6 | # 7 | # Location of the SDK. This is only used by Gradle. 8 | # For customization when using a Version Control System, please read the 9 | # header note. 10 | #Wed Mar 14 14:59:11 EDT 2018 11 | sdk.dir=/Users/emaxwell/Library/Android/sdk 12 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/acme/weather/app/model/repository/database/WeatherEntityCrudTest.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.database 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import com.acme.weather.app.model.repository.database.dao.WeatherDao 7 | import com.acme.weather.app.model.repository.database.entity.TemperatureEntity 8 | import com.acme.weather.app.model.repository.database.entity.WeatherEntity 9 | import org.hamcrest.Matchers.equalTo 10 | import org.junit.After 11 | import org.junit.Assert.assertThat 12 | import org.junit.Before 13 | import org.junit.Rule 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | 17 | 18 | @RunWith(AndroidJUnit4ClassRunner::class) 19 | class WeatherEntityCrudTest { 20 | 21 | private lateinit var weatherDao: WeatherDao 22 | private lateinit var db: WeatherDatabase 23 | 24 | @get:Rule 25 | val instantExecuteRule = InstantTaskExecutorRule() 26 | 27 | @Before 28 | fun createDb() { 29 | val context = InstrumentationRegistry.getInstrumentation().targetContext 30 | db = WeatherDatabase.create(context, true) 31 | weatherDao = db.weatherDao() 32 | } 33 | 34 | @After 35 | fun closeDb() { 36 | db.close() 37 | } 38 | 39 | @Test 40 | fun testCreateAndRetrieveWeather() { 41 | val weather = createWeather() 42 | val pk = weatherDao.insertOrUpdate(weather) 43 | val byPk = weatherDao.byId(pk) 44 | byPk.observeForever { 45 | assertThat(it?.zip, equalTo(weather.zip)) 46 | assertThat(it?.latitude, equalTo(weather.latitude)) 47 | assertThat(it?.longitude, equalTo(weather.longitude)) 48 | assertThat(it?.forecast, equalTo(weather.forecast)) 49 | assertThat(it?.locationName, equalTo(weather.locationName)) 50 | assertThat(it?.currentTemp?.celsius, equalTo(weather.currentTemp?.celsius)) 51 | assertThat(it?.currentTemp?.fahrenheit, equalTo(weather.currentTemp?.fahrenheit)) 52 | assertThat(it?.highTemp?.celsius, equalTo(weather.highTemp?.celsius)) 53 | assertThat(it?.highTemp?.fahrenheit, equalTo(weather.highTemp?.fahrenheit)) 54 | assertThat(it?.lowTemp?.celsius, equalTo(weather.lowTemp?.celsius)) 55 | assertThat(it?.lowTemp?.fahrenheit, equalTo(weather.lowTemp?.fahrenheit)) 56 | } 57 | } 58 | 59 | @Test 60 | fun testFindByZip() { 61 | val springfieldWeather = createWeather().apply { zip = "45503" } 62 | val columbusWeather = createWeather().apply { zip = "43231" } 63 | 64 | weatherDao.insertOrUpdate(springfieldWeather) 65 | weatherDao.insertOrUpdate(columbusWeather) 66 | 67 | val byZip = weatherDao.byZip("45503") 68 | byZip.observeForever { 69 | assertThat(it?.zip, equalTo("45503")) 70 | } 71 | } 72 | 73 | @Test 74 | fun testFindAll() { 75 | val springfieldWeather = createWeather().apply { zip = "45503" } 76 | val columbusWeather = createWeather().apply { zip = "43231" } 77 | 78 | weatherDao.insertOrUpdate(springfieldWeather) 79 | weatherDao.insertOrUpdate(columbusWeather) 80 | 81 | val all = weatherDao.findAll() 82 | all.observeForever {} 83 | assertThat(all.value?.count(), equalTo(2)) 84 | } 85 | 86 | @Test 87 | fun testDelete() { 88 | val weather = createWeather() 89 | val id = weatherDao.insertOrUpdate(weather) 90 | weather.id = id 91 | 92 | val all = weatherDao.findAll() 93 | all.observeForever {} 94 | 95 | // Assert 1 entry 96 | assertThat(all.value?.count(), equalTo(1)) 97 | 98 | // Delete it 99 | weatherDao.delete(weather) 100 | 101 | // Assert 0 entries 102 | assertThat(all.value?.count(), equalTo(0)) 103 | } 104 | 105 | 106 | private fun createWeather() = WeatherEntity().apply { 107 | latitude = "13" 108 | longitude = "52" 109 | zip = "90706" 110 | forecast = "Sunny with perfect temperatures and never a chance of rain." 111 | locationName = "Bellflower, CA" 112 | currentTemp = createTemperature() 113 | highTemp = createTemperature() 114 | lowTemp = createTemperature() 115 | } 116 | 117 | private fun createTemperature() = TemperatureEntity().apply { 118 | fahrenheit = 212 119 | celsius = 100 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/DebugTree.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather 2 | 3 | import timber.log.Timber 4 | 5 | class DebugTree : Timber.DebugTree() { 6 | 7 | override fun createStackElementTag(element: StackTraceElement): String { 8 | return super.createStackElementTag(element) + "at ${element.lineNumber}" 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/WeatherApplication.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import com.acme.weather.common.di.AppInjector 6 | import dagger.android.DispatchingAndroidInjector 7 | import dagger.android.HasActivityInjector 8 | import timber.log.Timber 9 | import javax.inject.Inject 10 | 11 | class WeatherApplication : Application(), HasActivityInjector { 12 | 13 | @Inject 14 | lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | if(BuildConfig.DEBUG) { 19 | Timber.plant(DebugTree()) 20 | } 21 | AppInjector.init(this) 22 | } 23 | 24 | override fun activityInjector(): DispatchingAndroidInjector { 25 | return dispatchingAndroidInjector 26 | } 27 | 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/api/ForecastData.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.api 2 | 3 | data class ForecastData( 4 | val current: Temperature? = null, 5 | val high: Temperature? = null, 6 | val low: Temperature? = null, 7 | val forecast: String? = null, 8 | val weatherIcon: WeatherIcon) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/api/Location.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.api 2 | 3 | data class Location( 4 | val zip: String, 5 | val latitude: String, 6 | val longitude: String, 7 | val locationName: String) -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/api/Temperature.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.api 2 | 3 | import kotlin.math.roundToInt 4 | 5 | data class Temperature(val fahrenheit: Int, val celsius: Int) { 6 | 7 | companion object { 8 | 9 | fun fromCelsius(c: Int) : Temperature { 10 | return Temperature(fahrenheit = (c * 1.8 + 32).roundToInt(), celsius = c) 11 | } 12 | 13 | fun fromFahrenheit(f: Int) : Temperature { 14 | return Temperature(celsius = ((f - 32) / 1.8).roundToInt(), fahrenheit = f) 15 | } 16 | 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/api/Weather.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.api 2 | 3 | data class Weather( 4 | val id: Long?, 5 | val location: Location, 6 | val forecastData: ForecastData? = null) 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/api/WeatherIcon.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.api 2 | 3 | import com.acme.weather.R 4 | 5 | sealed class WeatherIcon(val description: String, 6 | val iconResourceId: Int, 7 | val backgroundColorResourceId: Int) { 8 | 9 | companion object { 10 | /** 11 | * Find proper WeatherIcon for description, or default to SUNNY. 12 | */ 13 | fun fromDescription(description: String?) = when (description) { 14 | "clear-day", "clear-night" -> Sunny(description) 15 | "snow", "sleet" -> Snowy(description) 16 | "rain" -> Rainy(description) 17 | "hail", "thunderstorm", "tornado" -> Thunderstorm(description) 18 | "wind", "fog", "cloudy" -> Cloudy(description) 19 | "partly-cloudy-day", "partly-cloudy-night" -> PartlyCloudy(description) 20 | else -> Sunny("clear-day") 21 | } 22 | } 23 | } 24 | 25 | class Sunny(desc: String) : WeatherIcon(desc, R.mipmap.ic_sunny, R.color.bg_sunny) 26 | class Snowy(desc: String) : WeatherIcon(desc, R.mipmap.ic_snowy, R.color.bg_snowy) 27 | class Rainy(desc: String) : WeatherIcon(desc, R.mipmap.ic_rainy, R.color.bg_rainy) 28 | class Thunderstorm(desc: String) : WeatherIcon(desc, R.mipmap.ic_thunderstorm, R.color.bg_thunderstorm) 29 | class Cloudy(desc: String) : WeatherIcon(desc, R.mipmap.ic_cloudy, R.color.bg_cloudy) 30 | class PartlyCloudy(desc: String) : WeatherIcon(desc, R.mipmap.ic_partlycloudy, R.color.bg_partlycloudy) 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/WeatherRepository.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.Transformations 5 | import com.acme.weather.app.model.api.* 6 | import com.acme.weather.app.model.repository.database.dao.WeatherDao 7 | import com.acme.weather.app.model.repository.database.entity.TemperatureEntity 8 | import com.acme.weather.app.model.repository.database.entity.WeatherEntity 9 | import com.acme.weather.app.model.repository.network.WeatherForecastService 10 | import org.jetbrains.anko.doAsync 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | 14 | @Singleton 15 | class WeatherRepository @Inject constructor( 16 | private val weatherDao: WeatherDao, private val weatherForecastService: WeatherForecastService) { 17 | 18 | val weatherList: LiveData> by lazy { 19 | updateLocalCache() // refresh underlying data in remote service. 20 | fetchWeatherFromDatabase() // perform the LiveData query to return local results. 21 | } 22 | 23 | fun byIdentifier(id: Long) : LiveData { 24 | return Transformations.map(weatherDao.byId(id), { 25 | entityToDto(it) 26 | }) 27 | } 28 | 29 | fun byZipCode(zipCode: String) : LiveData { 30 | return Transformations.map(weatherDao.byZip(zipCode), { 31 | entityToDto(it) 32 | }) 33 | } 34 | 35 | fun removeWeatherLocation(id: Long) { 36 | doAsync { 37 | weatherDao.delete(weatherDao.byIdSync(id)) 38 | } 39 | } 40 | 41 | fun addWeatherLocation(location: Location) { 42 | 43 | doAsync { 44 | val entity = WeatherEntity().apply { 45 | zip = location.zip 46 | latitude = location.latitude 47 | longitude = location.longitude 48 | locationName = location.locationName 49 | } 50 | 51 | weatherDao.insertOrUpdate(entity) 52 | updateLocalCache() 53 | } 54 | } 55 | 56 | private fun updateLocalCache() { 57 | doAsync { 58 | val weather = weatherDao.findAllSync() 59 | weather.forEach { w -> 60 | val forecastData = weatherForecastService 61 | .getWeatherForecast(latitude = w.latitude, longitude = w.longitude) 62 | applyForecast(w, forecastData) 63 | weatherDao.insertOrUpdate(w) 64 | } 65 | } 66 | } 67 | 68 | private fun fetchWeatherFromDatabase() : LiveData> { 69 | return Transformations.map(weatherDao.findAll()) { weatherObjects: List -> 70 | weatherObjects.map { entityToDto(it) } 71 | } 72 | } 73 | 74 | private fun entityToDto(it: WeatherEntity) : Weather { 75 | return Weather( 76 | id = it.id, 77 | location = Location( 78 | zip = it.zip, 79 | latitude = it.latitude, 80 | longitude = it.longitude, 81 | locationName = it.locationName 82 | ), 83 | forecastData = ForecastData( 84 | current = entityToDto(it.currentTemp), 85 | high = entityToDto(it.highTemp), 86 | low = entityToDto(it.lowTemp), 87 | weatherIcon = WeatherIcon.fromDescription(it.iconDescription), 88 | forecast = it.forecast)) 89 | } 90 | 91 | private fun entityToDto(it: TemperatureEntity) : Temperature { 92 | return Temperature(fahrenheit = it.fahrenheit, celsius = it.celsius) 93 | } 94 | 95 | private fun applyForecast(w: WeatherEntity, forecastData: ForecastData) { 96 | w.forecast = forecastData.forecast 97 | w.iconDescription = forecastData.weatherIcon.description 98 | w.currentTemp = TemperatureEntity().apply { 99 | fahrenheit = forecastData.current?.fahrenheit ?: 0 100 | celsius = forecastData.current?.celsius ?: 0 101 | } 102 | w.highTemp = TemperatureEntity().apply { 103 | fahrenheit = forecastData.high?.fahrenheit ?: 0 104 | celsius = forecastData.high?.celsius ?: 0 105 | } 106 | w.lowTemp = TemperatureEntity().apply { 107 | fahrenheit = forecastData.low?.fahrenheit ?: 0 108 | celsius = forecastData.low?.celsius ?: 0 109 | } 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/database/WeatherDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.database 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import com.acme.weather.app.model.repository.database.dao.WeatherDao 8 | import com.acme.weather.app.model.repository.database.entity.WeatherEntity 9 | 10 | 11 | @Database(entities = arrayOf(WeatherEntity::class), version = 1) 12 | abstract class WeatherDatabase : RoomDatabase() { 13 | 14 | abstract fun weatherDao(): WeatherDao 15 | 16 | companion object { 17 | fun create(context: Context, useInMemory : Boolean): WeatherDatabase { 18 | 19 | return if(useInMemory) { 20 | Room.inMemoryDatabaseBuilder(context, WeatherDatabase::class.java) 21 | .allowMainThreadQueries() 22 | .build() 23 | } else { 24 | Room.databaseBuilder(context, WeatherDatabase::class.java, "weatherList.db") 25 | .fallbackToDestructiveMigration() 26 | .build() 27 | } 28 | } 29 | } 30 | 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/database/dao/WeatherDao.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.database.dao 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.* 5 | import com.acme.weather.app.model.repository.database.entity.WeatherEntity 6 | 7 | 8 | @Dao 9 | interface WeatherDao { 10 | 11 | @Query("SELECT * FROM weather") 12 | fun findAll(): LiveData> 13 | 14 | @Query("SELECT * FROM weather") 15 | fun findAllSync(): List 16 | 17 | @Query("SELECT * FROM weather WHERE id = (:id)") 18 | fun byId(id: Long): LiveData 19 | 20 | @Query("SELECT * FROM weather WHERE id = (:id)") 21 | fun byIdSync(id: Long): WeatherEntity 22 | 23 | @Query("SELECT * FROM weather WHERE zip = (:zip)") 24 | fun byZip(zip: String): LiveData 25 | 26 | @Insert(onConflict = OnConflictStrategy.REPLACE) 27 | fun insertOrUpdate(weatherEntity: WeatherEntity) : Long 28 | 29 | @Delete 30 | fun delete(weatherEntity: WeatherEntity) 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/database/entity/TemperatureEntity.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.database.entity 2 | 3 | class TemperatureEntity { 4 | var fahrenheit: Int = 0 5 | var celsius: Int = 0 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/database/entity/WeatherEntity.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.database.entity 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "Weather") 8 | class WeatherEntity { 9 | 10 | @PrimaryKey(autoGenerate = true) 11 | var id: Long? = null 12 | 13 | var latitude: String = "" 14 | var longitude: String = "" 15 | var locationName: String = "" 16 | var zip: String = "" 17 | 18 | var iconDescription: String? = null 19 | var forecast: String? = null 20 | 21 | @Embedded(prefix = "current_") 22 | var currentTemp: TemperatureEntity = TemperatureEntity() 23 | 24 | @Embedded(prefix = "high_") 25 | var highTemp: TemperatureEntity = TemperatureEntity() 26 | 27 | @Embedded(prefix = "low_") 28 | var lowTemp: TemperatureEntity = TemperatureEntity() 29 | 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/geolocation/WeatherLocationService.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.geolocation 2 | 3 | import android.location.Geocoder 4 | import com.acme.weather.app.model.api.Location 5 | import javax.inject.Inject 6 | 7 | class WeatherLocationService @Inject constructor(val geoCoder: Geocoder) { 8 | 9 | fun locationForZip(zip: String) : Location? { 10 | val address = geoCoder.getFromLocationName(zip, 1).firstOrNull() 11 | return if(address == null) { 12 | null 13 | } else { 14 | Location(locationName = address.locality, 15 | zip = address.postalCode, 16 | longitude = address.longitude.toString(), 17 | latitude = address.latitude.toString()) 18 | } 19 | 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/network/WeatherApi.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.network 2 | 3 | import io.reactivex.Single 4 | import retrofit2.http.GET 5 | import retrofit2.http.Path 6 | 7 | 8 | interface WeatherApi { 9 | 10 | 11 | @GET("{latLong}?exclude=flags,alerts,hourly,minutely&units=us") 12 | fun getWeatherForCoordinate(@Path("latLong") latLong: String) : Single 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/network/WeatherApiResponseModels.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.network 2 | 3 | data class WeatherApiResponse( 4 | var latitude: String?, 5 | var longitude: String?, 6 | var currently: Currently?, 7 | var daily: Daily?) 8 | 9 | data class Currently( 10 | var time: Long?, 11 | var icon: String?, 12 | var temperature: Double?) 13 | 14 | data class Daily( 15 | var summary: String?, 16 | var data: List?) 17 | 18 | data class ExtraData( 19 | var time: Long?, 20 | var temperatureHigh: Double?, 21 | var temperatureLow: Double?) 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/model/repository/network/WeatherForecastService.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.model.repository.network 2 | 3 | import com.acme.weather.app.model.api.ForecastData 4 | import com.acme.weather.app.model.api.Temperature 5 | import com.acme.weather.app.model.api.WeatherIcon 6 | import javax.inject.Inject 7 | import kotlin.math.roundToInt 8 | 9 | class WeatherForecastService @Inject constructor(private val weatherApi: WeatherApi) { 10 | 11 | /** 12 | * Blocking call to get weatherList forecast data given (lat,long) coordinates. 13 | */ 14 | fun getWeatherForecast(latitude: String, longitude: String): ForecastData { 15 | 16 | val latLong = "${latitude},${longitude}" 17 | val apiResp = weatherApi 18 | .getWeatherForCoordinate(latLong) 19 | .blockingGet() 20 | 21 | return responseToDto(apiResp) 22 | } 23 | 24 | private fun responseToDto(apiResp: WeatherApiResponse): ForecastData { 25 | 26 | return ForecastData( 27 | forecast = apiResp.daily?.summary, 28 | 29 | weatherIcon = WeatherIcon.fromDescription(apiResp.currently?.icon), 30 | 31 | current = Temperature.fromFahrenheit( 32 | apiResp.currently?.temperature?.roundToInt() ?: 0), 33 | 34 | high = Temperature.fromFahrenheit( 35 | apiResp.daily?.data 36 | ?.sortedByDescending { it.temperatureHigh } 37 | ?.firstOrNull() 38 | ?.temperatureHigh 39 | ?.roundToInt() ?: 0), 40 | 41 | low = Temperature.fromFahrenheit( 42 | apiResp.daily?.data 43 | ?.sortedByDescending { it.temperatureLow } 44 | ?.firstOrNull() 45 | ?.temperatureLow 46 | ?.roundToInt() ?: 0)) 47 | } 48 | 49 | 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/BindingAdapters.java: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view; 2 | 3 | 4 | import android.os.Bundle; 5 | import android.view.View; 6 | import android.widget.ImageView; 7 | import android.widget.TextView; 8 | 9 | import androidx.annotation.ColorRes; 10 | import androidx.annotation.DrawableRes; 11 | import androidx.annotation.NonNull; 12 | import androidx.annotation.Nullable; 13 | import androidx.databinding.BindingAdapter; 14 | import androidx.fragment.app.Fragment; 15 | 16 | public class BindingAdapters { 17 | 18 | @BindingAdapter("app:weatherIcon") 19 | public static void showWeatherIcon(ImageView imageView, @DrawableRes int id) { 20 | if(id != 0) { 21 | imageView.setImageResource(id); 22 | } 23 | } 24 | 25 | @BindingAdapter("app:weatherBackground") 26 | public static void setWeatherBackground(View view, @ColorRes int colorId) { 27 | if(colorId != 0) { 28 | view.setBackgroundResource(colorId); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/LocationDialogFragment.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view 2 | 3 | import android.app.Activity 4 | import android.app.Dialog 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.view.LayoutInflater 8 | import android.widget.EditText 9 | import androidx.appcompat.app.AlertDialog 10 | import androidx.fragment.app.DialogFragment 11 | import com.acme.weather.R 12 | import kotlinx.android.synthetic.main.location_add_dialog.view.* 13 | 14 | class LocationDialogFragment : DialogFragment() { 15 | 16 | lateinit var zipCodeEditText: EditText 17 | 18 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 19 | 20 | val v = LayoutInflater.from(activity).inflate(R.layout.location_add_dialog, null) 21 | zipCodeEditText = v.zip_code 22 | 23 | return AlertDialog.Builder(context!!) 24 | .setView(v) 25 | .setPositiveButton(R.string.location_add_button_label) { _, _ -> 26 | sendResult(Activity.RESULT_OK, zipCodeEditText.text?.toString() ?: "") 27 | } 28 | .setNegativeButton(R.string.location_cancel_button_label) { _, _ -> 29 | sendResult(Activity.RESULT_CANCELED, "") 30 | } 31 | .create() 32 | 33 | } 34 | 35 | fun sendResult(resultCode: Int, zipCode: String) { 36 | val intent = Intent() 37 | intent.putExtra(EXTRA_LOCATION, zipCode) 38 | targetFragment?.onActivityResult(targetRequestCode, resultCode, intent) 39 | // (requireActivity() as MainActivity).navigateBackWithResult(Bundle().apply { putString(EXTRA_LOCATION, zipCode) }) 40 | } 41 | 42 | companion object { 43 | val EXTRA_LOCATION = "com.acme.weather.app.view.EXTRA_LOCATION" 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view 2 | 3 | import android.os.Bundle 4 | import android.view.Menu 5 | import android.view.MenuInflater 6 | import android.view.MenuItem 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.FragmentManager 10 | import androidx.lifecycle.ViewModelProviders 11 | import androidx.navigation.NavController 12 | import androidx.navigation.findNavController 13 | import androidx.navigation.ui.AppBarConfiguration 14 | import androidx.navigation.ui.NavigationUI 15 | import com.acme.weather.R 16 | import com.acme.weather.common.navigation.NavigationResult 17 | import com.acme.weather.common.navigation.isStartDestination 18 | import com.acme.weather.security.viewmodel.AuthenticationViewModel 19 | import dagger.android.DispatchingAndroidInjector 20 | import dagger.android.support.HasSupportFragmentInjector 21 | import timber.log.Timber 22 | import javax.inject.Inject 23 | import kotlin.properties.Delegates 24 | 25 | class MainActivity : AppCompatActivity(), HasSupportFragmentInjector { 26 | 27 | @Inject 28 | lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector 29 | 30 | private lateinit var navController: NavController 31 | val authenticationViewModel by lazy { ViewModelProviders.of(this).get(AuthenticationViewModel::class.java) } 32 | 33 | var showMenu = true 34 | set(value) { 35 | if(value != field) { 36 | field = value 37 | invalidateOptionsMenu() 38 | } 39 | } 40 | 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContentView(R.layout.activity_main) 44 | 45 | navController = findNavController(R.id.weather_nav_fragment) 46 | 47 | // Prevent up on weather list and the login screens 48 | val appBarConfig = 49 | AppBarConfiguration(setOf(R.id.weather_list, R.id.enter_credentials)) 50 | 51 | NavigationUI.setupActionBarWithNavController(this, navController, appBarConfig) 52 | 53 | navController.addOnDestinationChangedListener { controller, destination, arguments -> 54 | showMenu = (destination.id != R.id.enter_credentials) 55 | } 56 | } 57 | 58 | override fun onSupportNavigateUp(): Boolean { 59 | return navController.navigateUp() || super.onSupportNavigateUp() 60 | } 61 | 62 | override fun supportFragmentInjector(): DispatchingAndroidInjector { 63 | return dispatchingAndroidInjector 64 | } 65 | 66 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 67 | if(showMenu) { 68 | menuInflater.inflate(R.menu.global_menu, menu) 69 | return true 70 | } else { 71 | return super.onCreateOptionsMenu(menu) 72 | } 73 | } 74 | 75 | override fun onOptionsItemSelected(item: MenuItem?): Boolean { 76 | 77 | if(item?.itemId == R.id.logout) { 78 | authenticationViewModel.logout() 79 | return true 80 | } else { 81 | return super.onOptionsItemSelected(item) 82 | } 83 | 84 | } 85 | // 86 | // fun navigateBackWithResult(result: Bundle) { 87 | // val childFragmentManager = supportFragmentManager.findFragmentById(R.id.weather_nav_fragment)?.childFragmentManager 88 | // var backStackListener: FragmentManager.OnBackStackChangedListener by Delegates.notNull() 89 | // backStackListener = FragmentManager.OnBackStackChangedListener { 90 | // (childFragmentManager?.fragments?.get(0) as NavigationResult).onNavigationResult(result) 91 | // childFragmentManager.removeOnBackStackChangedListener(backStackListener) 92 | // } 93 | // childFragmentManager?.addOnBackStackChangedListener(backStackListener) 94 | // navController.popBackStack() 95 | // } 96 | 97 | } 98 | 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/WeatherDetailFragment.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.os.Build 7 | import android.os.Bundle 8 | import android.view.* 9 | import androidx.core.app.NotificationCompat 10 | import androidx.core.content.ContextCompat.getSystemService 11 | import androidx.databinding.DataBindingUtil 12 | import androidx.fragment.app.Fragment 13 | import androidx.lifecycle.Observer 14 | import androidx.lifecycle.ViewModelProvider 15 | import androidx.lifecycle.ViewModelProviders 16 | import androidx.navigation.NavDeepLinkBuilder 17 | import androidx.navigation.fragment.navArgs 18 | import com.acme.weather.R 19 | import com.acme.weather.WeatherApplication 20 | import com.acme.weather.databinding.WeatherDetailFragmentBinding 21 | import com.acme.weather.common.di.Injectable 22 | import com.acme.weather.app.viewmodel.WeatherDetailViewModel 23 | import com.acme.weather.app.viewmodel.WeatherItemViewModel 24 | import com.acme.weather.security.view.SecureFragment 25 | import timber.log.Timber 26 | import javax.inject.Inject 27 | 28 | class WeatherDetailFragment : SecureFragment(), Injectable { 29 | 30 | @Inject 31 | lateinit var viewModelFactory: ViewModelProvider.Factory 32 | 33 | @Inject 34 | lateinit var notificationManager: NotificationManager 35 | 36 | private lateinit var binding: WeatherDetailFragmentBinding 37 | 38 | val args: WeatherDetailFragmentArgs by navArgs() 39 | 40 | override fun onCreate(savedInstanceState: Bundle?) { 41 | super.onCreate(savedInstanceState) 42 | setHasOptionsMenu(true) 43 | } 44 | 45 | override fun onCreateView(inflater: LayoutInflater, 46 | container: ViewGroup?, 47 | savedInstanceState: Bundle?): View? { 48 | 49 | binding = DataBindingUtil.inflate(inflater, 50 | R.layout.weather_detail_fragment, 51 | container, false) 52 | return binding.root 53 | } 54 | 55 | override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { 56 | menuInflater.inflate(R.menu.simulate_notification_with_deeplink, menu) 57 | super.onCreateOptionsMenu(menu, menuInflater) 58 | } 59 | 60 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 61 | when (item.itemId) { 62 | R.id.simulate_deep_link -> { 63 | scheduleNotificationForCity() 64 | return true 65 | } 66 | else -> return super.onContextItemSelected(item) 67 | } 68 | } 69 | 70 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 71 | 72 | super.onViewCreated(view, savedInstanceState) 73 | 74 | val zipCode = args.zipCode 75 | val shouldShowFahrenheit = args.useFahrenheit 76 | 77 | val viewModel = ViewModelProviders 78 | .of(this, viewModelFactory) 79 | .get(WeatherDetailViewModel::class.java) 80 | 81 | if(viewModel.weatherViewModel == null) { 82 | viewModel.setZipCode(zipCode, shouldShowFahrenheit) 83 | } 84 | 85 | viewModel.weatherViewModel?.observe(this, Observer { weather: WeatherItemViewModel? -> 86 | if(weather != null) { 87 | binding.vm = weather 88 | } 89 | }) 90 | 91 | } 92 | 93 | val CHANNEL_ID = "weather.acme.com.channel" 94 | 95 | private fun createNotificationChannel() { 96 | // Create the NotificationChannel, but only on API 26+ because 97 | // the NotificationChannel class is new and not in the support library 98 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 99 | val name = "ACME_WEATHER" 100 | val descriptionText = "Weather Information" 101 | val importance = NotificationManager.IMPORTANCE_DEFAULT 102 | val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { 103 | description = descriptionText 104 | } 105 | // Register the channel with the system 106 | notificationManager.createNotificationChannel(channel) 107 | } 108 | } 109 | 110 | 111 | fun scheduleNotificationForCity() { 112 | 113 | 114 | // If I weren't already here.... 115 | val pendingIntent = navController.createDeepLink() 116 | .setGraph(R.navigation.weather_graph) 117 | .setDestination(R.id.weather_detail) 118 | .setArguments(arguments) 119 | .createPendingIntent() 120 | 121 | createNotificationChannel() 122 | 123 | val builder = NotificationCompat.Builder(requireContext(), CHANNEL_ID) 124 | .setSmallIcon(android.R.drawable.ic_menu_compass) 125 | .setContentTitle("Weather Report") 126 | .setContentText("Weather now in ${args.zipCode}") 127 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 128 | // Set the intent that will fire when the user taps the notification 129 | .setContentIntent(pendingIntent) 130 | .setAutoCancel(true) 131 | 132 | notificationManager.notify(1, builder.build()) 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/WeatherListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view 2 | 3 | import android.app.Activity.RESULT_CANCELED 4 | import android.app.Activity.RESULT_OK 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.os.Bundle 8 | import android.view.* 9 | import androidx.databinding.DataBindingUtil 10 | import androidx.lifecycle.Observer 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.lifecycle.ViewModelProviders 13 | import androidx.navigation.fragment.findNavController 14 | import androidx.recyclerview.widget.DividerItemDecoration 15 | import com.acme.weather.R 16 | import com.acme.weather.app.view.WeatherListFragmentDirections.navigateToWeatherDetails 17 | import com.acme.weather.app.viewmodel.DEFAULT 18 | import com.acme.weather.app.viewmodel.LOCATION_ADD_FAILED 19 | import com.acme.weather.app.viewmodel.LOCATION_ADD_PENDING 20 | import com.acme.weather.app.viewmodel.WeatherListViewModel 21 | import com.acme.weather.common.di.Injectable 22 | import com.acme.weather.databinding.WeatherListFragmentBinding 23 | import com.acme.weather.security.view.SecureFragment 24 | import com.google.android.material.snackbar.Snackbar 25 | import timber.log.Timber 26 | import javax.inject.Inject 27 | 28 | 29 | class WeatherListFragment : SecureFragment(), Injectable { 30 | 31 | @Inject 32 | lateinit var viewModelFactory: ViewModelProvider.Factory 33 | 34 | private lateinit var binding: WeatherListFragmentBinding 35 | private lateinit var weatherRecyclerAdapter: WeatherRecyclerAdapter 36 | private lateinit var weatherListViewModel: WeatherListViewModel 37 | 38 | val REQUEST_LOCATION = 0 39 | val DIALOG_LOCATION = "DialogLocation" 40 | 41 | override fun onAttach(context: Context) { 42 | Timber.d("onAttach") 43 | super.onAttach(context) 44 | } 45 | 46 | override fun onCreate(savedInstanceState: Bundle?) { 47 | Timber.d("onCreate") 48 | super.onCreate(savedInstanceState) 49 | setHasOptionsMenu(true) 50 | } 51 | 52 | override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { 53 | menuInflater.inflate(R.menu.weather_menu, menu) 54 | super.onCreateOptionsMenu(menu, menuInflater) 55 | } 56 | 57 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 58 | when (item.itemId) { 59 | R.id.toggle_units -> { 60 | weatherListViewModel.toggleUnitOfMeasurement() 61 | return true 62 | } 63 | else -> return super.onContextItemSelected(item) 64 | } 65 | } 66 | 67 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, 68 | savedInstanceState: Bundle?): View? { 69 | 70 | binding = DataBindingUtil.inflate( 71 | LayoutInflater.from(context), 72 | R.layout.weather_list_fragment, container, false) 73 | 74 | return binding.root 75 | 76 | } 77 | 78 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 79 | 80 | super.onViewCreated(view, savedInstanceState) 81 | 82 | weatherListViewModel = ViewModelProviders 83 | .of(this, viewModelFactory) 84 | .get(WeatherListViewModel::class.java) 85 | 86 | weatherRecyclerAdapter = WeatherRecyclerAdapter( 87 | onItemClick = { zip -> showDetail(zip) }, 88 | onItemLongClick = { id -> weatherListViewModel.onLocationDeleted(id) }) 89 | 90 | val recyclerView = binding.weatherListRecyclerView 91 | recyclerView.setHasFixedSize(true) 92 | recyclerView.adapter = weatherRecyclerAdapter 93 | recyclerView.addItemDecoration( 94 | DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) 95 | 96 | binding.vm = weatherListViewModel 97 | 98 | weatherListViewModel.weatherList.observe(this, Observer { weatherSummaryList -> 99 | if (weatherSummaryList != null) { 100 | weatherRecyclerAdapter.setWeatherList(weatherSummaryList) 101 | } 102 | }) 103 | 104 | weatherListViewModel.shouldPreferFahrenheit.observe(this, Observer { 105 | weatherRecyclerAdapter.setUnitOfTemperaturePreference(shouldShowFahrenheit = it ?: true) 106 | }) 107 | 108 | weatherListViewModel.state.observe(this, Observer { state -> 109 | when (state) { 110 | is DEFAULT -> hideProgressDialog() 111 | is LOCATION_ADD_PENDING -> showProgressDialog() 112 | is LOCATION_ADD_FAILED -> showError(state.error) 113 | null -> hideProgressDialog() 114 | } 115 | }) 116 | 117 | binding.fabAddZip.setOnClickListener { showZipDialog() } 118 | } 119 | 120 | fun showProgressDialog() { 121 | binding.progressBar.visibility = View.VISIBLE 122 | } 123 | 124 | fun hideProgressDialog() { 125 | binding.progressBar.visibility = View.INVISIBLE 126 | } 127 | 128 | fun showError(error: String) { 129 | hideProgressDialog() 130 | val v = view 131 | if(v != null) { 132 | Snackbar.make(v, error, Snackbar.LENGTH_SHORT) 133 | } 134 | } 135 | 136 | fun showDetail(zipCode: String) { 137 | 138 | val directions = 139 | navigateToWeatherDetails(zipCode) 140 | .setUseFahrenheit(true) 141 | 142 | findNavController().navigate(directions) 143 | 144 | } 145 | 146 | // fun showZipDialog() { 147 | // hideProgressDialog() 148 | // 149 | // val directions = WeatherListFragmentDirections.navigateToAddLocationDialog() 150 | // findNavController().navigate(directions) 151 | // } 152 | 153 | fun showZipDialog() { 154 | hideProgressDialog() 155 | val fm = fragmentManager 156 | fm?.let { fragmentMgr -> 157 | val locationDialog = LocationDialogFragment() 158 | locationDialog.setTargetFragment(this, REQUEST_LOCATION) 159 | locationDialog.show(fragmentMgr, DIALOG_LOCATION) 160 | } 161 | } 162 | 163 | 164 | // override fun onNavigationResult(result: Bundle) { 165 | // result.getString(LocationDialogFragment.EXTRA_LOCATION)?.let { zip -> 166 | // weatherListViewModel.onLocationEntered(zip) 167 | // } 168 | // } 169 | 170 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 171 | 172 | if(requestCode == REQUEST_LOCATION) { 173 | 174 | when(resultCode) { 175 | RESULT_OK -> data?.getStringExtra(LocationDialogFragment.EXTRA_LOCATION)?.let { zip -> 176 | weatherListViewModel.onLocationEntered(zip) 177 | } 178 | RESULT_CANCELED -> Timber.i("User cancelled add location") 179 | else -> Timber.e("Unknown resquestCode [${requestCode}]") 180 | } 181 | 182 | } 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/view/WeatherRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.view 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.databinding.DataBindingUtil 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.acme.weather.R 8 | import com.acme.weather.databinding.WeatherItemBinding 9 | import com.acme.weather.app.model.api.Weather 10 | import com.acme.weather.app.viewmodel.WeatherItemViewModel 11 | 12 | class WeatherRecyclerAdapter( 13 | private val onItemClick: (zipCode: String) -> Unit, 14 | private val onItemLongClick: (id: Long) -> Unit) 15 | : RecyclerView.Adapter() { 16 | 17 | private var showFahrenheit = true 18 | private var weatherList = emptyList() 19 | 20 | fun setUnitOfTemperaturePreference(shouldShowFahrenheit: Boolean) { 21 | this.showFahrenheit = shouldShowFahrenheit 22 | notifyDataSetChanged() 23 | } 24 | 25 | fun setWeatherList(weatherList: List) { 26 | this.weatherList = weatherList 27 | notifyDataSetChanged() 28 | } 29 | 30 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeViewHolder { 31 | val binding = DataBindingUtil 32 | .inflate(LayoutInflater.from(parent.context), 33 | R.layout.weather_item, 34 | parent, false) 35 | return RecipeViewHolder(binding) 36 | } 37 | 38 | override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) { 39 | val weatherItem = weatherList[position] 40 | val weatherItemVm = WeatherItemViewModel(weatherList[position], showFahrenheit) 41 | holder.binding.weatherItem.apply { 42 | setOnClickListener { 43 | onItemClick(weatherItem.location.zip) 44 | } 45 | setOnLongClickListener { 46 | if(weatherItem.id != null) { 47 | onItemLongClick(weatherItem.id) 48 | } 49 | true 50 | } 51 | } 52 | 53 | holder.binding.vm = weatherItemVm 54 | holder.binding.executePendingBindings() 55 | } 56 | 57 | override fun getItemCount() = weatherList.size 58 | 59 | class RecipeViewHolder(val binding: WeatherItemBinding) : 60 | RecyclerView.ViewHolder(binding.root) 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/viewmodel/WeatherDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.viewmodel 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.Transformations 5 | import androidx.lifecycle.ViewModel 6 | import com.acme.weather.app.model.repository.WeatherRepository 7 | import javax.inject.Inject 8 | 9 | class WeatherDetailViewModel @Inject constructor( 10 | private val weatherRepository: WeatherRepository) : ViewModel() { 11 | 12 | var weatherViewModel: LiveData? = null 13 | 14 | /** 15 | * Set the id of the weatherList to show and whether to show celsius or fahrenheit. 16 | * Ideally the preference on which unit to display should come from 17 | * some sort of persistent setting, but this is just a simple example. 18 | */ 19 | fun setWeatherId(id: Long, shouldShowFahrenheit: Boolean) { 20 | weatherViewModel = Transformations.map(weatherRepository.byIdentifier(id)) { 21 | WeatherItemViewModel(weather = it, showFahrenheit = shouldShowFahrenheit) 22 | } 23 | } 24 | 25 | fun setZipCode(zipCode: String, shouldShowFahrenheit: Boolean) { 26 | weatherViewModel = Transformations.map(weatherRepository.byZipCode(zipCode)) { 27 | WeatherItemViewModel(weather = it, showFahrenheit = shouldShowFahrenheit) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/viewmodel/WeatherItemViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.viewmodel 2 | 3 | import com.acme.weather.app.model.api.Weather 4 | 5 | /** 6 | * Simplified Value Model object to display weatherList. 7 | */ 8 | class WeatherItemViewModel(weather: Weather, val showFahrenheit: Boolean) { 9 | 10 | val bgColor = weather.forecastData?.weatherIcon?.backgroundColorResourceId ?: 0 11 | val location = weather.location.locationName 12 | val weatherIconContentDesc = weather.forecastData?.weatherIcon?.description 13 | val weatherIcon = weather.forecastData?.weatherIcon?.iconResourceId ?: 0 14 | val forecast = weather.forecastData?.forecast 15 | 16 | val currentTemp = weather.forecastData?.current?.run { if(showFahrenheit) fahrenheit else celsius } 17 | val highTemp = weather.forecastData?.high?.run { if(showFahrenheit) fahrenheit else celsius } 18 | val lowTemp = weather.forecastData?.low?.run { if(showFahrenheit) fahrenheit else celsius } 19 | 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/viewmodel/WeatherListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.viewmodel 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import com.acme.weather.app.model.repository.WeatherRepository 6 | import com.acme.weather.app.model.repository.geolocation.WeatherLocationService 7 | import org.jetbrains.anko.doAsync 8 | import timber.log.Timber 9 | import javax.inject.Inject 10 | 11 | sealed class State 12 | class DEFAULT : State() 13 | class LOCATION_ADD_PENDING : State() 14 | class LOCATION_ADD_FAILED(val error: String) : State() 15 | 16 | class WeatherListViewModel @Inject constructor( 17 | val weatherRepository: WeatherRepository, 18 | val weatherLocationService: WeatherLocationService) : ViewModel() { 19 | 20 | val weatherList = weatherRepository.weatherList 21 | 22 | val shouldPreferFahrenheit = MutableLiveData().apply { value = true } 23 | val state = MutableLiveData().apply{ value = DEFAULT() } 24 | 25 | fun onLocationEntered(zip: String) { 26 | Timber.i("onLocationEntered: ${zip}") 27 | state.value = LOCATION_ADD_PENDING() 28 | doAsync { 29 | val location = weatherLocationService.locationForZip(zip) 30 | if(location != null) { 31 | weatherRepository.addWeatherLocation(location = location) 32 | state.postValue(DEFAULT()) // post, automatically emits to UI thread. 33 | } else { 34 | state.postValue(LOCATION_ADD_FAILED("Location not found")) 35 | state.postValue(DEFAULT()) 36 | } 37 | } 38 | } 39 | 40 | fun onLocationDeleted(id: Long) { 41 | weatherRepository.removeWeatherLocation(id) 42 | } 43 | 44 | fun toggleUnitOfMeasurement() { 45 | val currentState: Boolean = shouldPreferFahrenheit.value ?: true 46 | shouldPreferFahrenheit.value = !currentState 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/app/viewmodel/WeatherViewModelFactory.java: -------------------------------------------------------------------------------- 1 | package com.acme.weather.app.viewmodel; 2 | 3 | import androidx.lifecycle.ViewModel; 4 | import androidx.lifecycle.ViewModelProvider; 5 | 6 | import java.util.Map; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Provider; 10 | import javax.inject.Singleton; 11 | 12 | @Singleton 13 | public class WeatherViewModelFactory implements ViewModelProvider.Factory { 14 | private final Map, Provider> creators; 15 | 16 | @Inject 17 | public WeatherViewModelFactory( 18 | Map, Provider> creators) { 19 | this.creators = creators; 20 | } 21 | 22 | @SuppressWarnings("unchecked") 23 | @Override 24 | public T create(Class modelClass) { 25 | Provider creator = creators.get(modelClass); 26 | if (creator == null) { 27 | for (Map.Entry, Provider> entry : creators.entrySet()) { 28 | if (modelClass.isAssignableFrom(entry.getKey())) { 29 | creator = entry.getValue(); 30 | break; 31 | } 32 | } 33 | } 34 | if (creator == null) { 35 | throw new IllegalArgumentException("unknown model class " + modelClass); 36 | } 37 | try { 38 | return (T) creator.get(); 39 | } catch (Exception e) { 40 | throw new RuntimeException(e); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.di 2 | 3 | import android.app.Application 4 | import com.acme.weather.WeatherApplication 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import dagger.android.AndroidInjectionModule 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | @Component(modules = arrayOf( 12 | AndroidInjectionModule::class, 13 | AppModule::class, 14 | MainActivityModule::class)) 15 | 16 | interface AppComponent { 17 | @Component.Builder 18 | interface Builder { 19 | @BindsInstance fun application(application: Application): Builder 20 | fun build(): AppComponent 21 | } 22 | 23 | fun inject(weatherApp: WeatherApplication) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/AppInjector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.acme.weather.common.di; 18 | 19 | import android.app.Activity; 20 | import android.app.Application; 21 | import android.os.Bundle; 22 | 23 | import androidx.fragment.app.Fragment; 24 | import androidx.fragment.app.FragmentActivity; 25 | import androidx.fragment.app.FragmentManager; 26 | 27 | import com.acme.weather.WeatherApplication; 28 | 29 | import dagger.android.AndroidInjection; 30 | import dagger.android.support.AndroidSupportInjection; 31 | import dagger.android.support.HasSupportFragmentInjector; 32 | 33 | /** 34 | * Helper class to automatically inject fragments if they implement {@link Injectable}. 35 | */ 36 | public class AppInjector { 37 | private AppInjector() {} 38 | public static void init(WeatherApplication weatherApplication) { 39 | DaggerAppComponent.builder().application(weatherApplication) 40 | .build().inject(weatherApplication); 41 | weatherApplication 42 | .registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { 43 | @Override 44 | public void onActivityCreated(Activity activity, Bundle savedInstanceState) { 45 | handleActivity(activity); 46 | } 47 | 48 | @Override 49 | public void onActivityStarted(Activity activity) { 50 | 51 | } 52 | 53 | @Override 54 | public void onActivityResumed(Activity activity) { 55 | 56 | } 57 | 58 | @Override 59 | public void onActivityPaused(Activity activity) { 60 | 61 | } 62 | 63 | @Override 64 | public void onActivityStopped(Activity activity) { 65 | 66 | } 67 | 68 | @Override 69 | public void onActivitySaveInstanceState(Activity activity, Bundle outState) { 70 | 71 | } 72 | 73 | @Override 74 | public void onActivityDestroyed(Activity activity) { 75 | 76 | } 77 | }); 78 | } 79 | 80 | private static void handleActivity(Activity activity) { 81 | if (activity instanceof HasSupportFragmentInjector) { 82 | AndroidInjection.inject(activity); 83 | } 84 | if (activity instanceof FragmentActivity) { 85 | ((FragmentActivity) activity).getSupportFragmentManager() 86 | .registerFragmentLifecycleCallbacks( 87 | new FragmentManager.FragmentLifecycleCallbacks() { 88 | @Override 89 | public void onFragmentCreated(FragmentManager fm, Fragment f, 90 | Bundle savedInstanceState) { 91 | if (f instanceof Injectable) { 92 | AndroidSupportInjection.inject(f); 93 | } 94 | } 95 | }, true); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.di 2 | 3 | import android.app.Application 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.location.Geocoder 7 | import com.acme.weather.app.model.repository.WeatherRepository 8 | import com.acme.weather.app.model.repository.database.WeatherDatabase 9 | import com.acme.weather.app.model.repository.database.dao.WeatherDao 10 | import com.acme.weather.app.model.repository.geolocation.WeatherLocationService 11 | import com.acme.weather.app.model.repository.network.WeatherForecastService 12 | import dagger.Module 13 | import dagger.Provides 14 | import javax.inject.Singleton 15 | 16 | @Module(includes = arrayOf(ViewModelModule::class, NetworkModule::class)) 17 | class AppModule { 18 | 19 | @Singleton 20 | @Provides 21 | fun provideDb(app: Application): WeatherDatabase { 22 | return WeatherDatabase.create(app, false) 23 | } 24 | 25 | @Singleton 26 | @Provides 27 | fun provideWeatherDao(db: WeatherDatabase): WeatherDao { 28 | return db.weatherDao() 29 | } 30 | 31 | @Singleton 32 | @Provides 33 | fun weatherRepository(dao: WeatherDao, service: WeatherForecastService): WeatherRepository { 34 | return WeatherRepository(dao, service) 35 | } 36 | 37 | @Singleton 38 | @Provides 39 | fun weatherLocationService(geocoder: Geocoder) : WeatherLocationService { 40 | return WeatherLocationService(geocoder) 41 | } 42 | 43 | @Singleton 44 | @Provides 45 | fun geoCoder(context: Application) : Geocoder { 46 | return Geocoder(context) 47 | } 48 | 49 | @Singleton 50 | @Provides 51 | fun notificationManager(context: Application) : NotificationManager { 52 | return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/FragmentBuildersModule.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.acme.weather.common.di; 18 | 19 | import com.acme.weather.app.view.WeatherDetailFragment; 20 | import com.acme.weather.app.view.WeatherListFragment; 21 | 22 | import dagger.Module; 23 | import dagger.android.ContributesAndroidInjector; 24 | 25 | @Module 26 | public abstract class FragmentBuildersModule { 27 | 28 | @ContributesAndroidInjector 29 | abstract WeatherListFragment weatherListFragment(); 30 | 31 | @ContributesAndroidInjector 32 | abstract WeatherDetailFragment weatherDetailFragment(); 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/Injectable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.acme.weather.common.di 17 | 18 | /** 19 | * Marks an activity / fragment injectable. 20 | */ 21 | interface Injectable -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/MainActivityModule.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.acme.weather.common.di; 18 | 19 | import com.acme.weather.app.view.MainActivity; 20 | 21 | import dagger.Module; 22 | import dagger.android.ContributesAndroidInjector; 23 | 24 | @Module 25 | abstract class MainActivityModule { 26 | @ContributesAndroidInjector(modules = FragmentBuildersModule.class) 27 | abstract MainActivity contributeMainActivity(); 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.di 2 | 3 | import android.app.Application 4 | import com.acme.weather.app.model.repository.network.WeatherApi 5 | import com.acme.weather.app.model.repository.network.WeatherForecastService 6 | import com.squareup.moshi.Moshi 7 | import dagger.Module 8 | import dagger.Provides 9 | import okhttp3.OkHttpClient 10 | import retrofit2.Retrofit 11 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 12 | import retrofit2.converter.moshi.MoshiConverterFactory 13 | 14 | @Module 15 | open class NetworkModule { 16 | 17 | @Provides 18 | internal fun retrofitBuilder(okHttpClientBuilder: OkHttpClient.Builder): Retrofit.Builder { 19 | return Retrofit.Builder() 20 | .baseUrl("${BASE_URL}/${API_KEY}") 21 | .addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().build())) 22 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 23 | .client(okHttpClientBuilder.build()) 24 | } 25 | 26 | @Provides 27 | internal open fun okHttpClientBuilder(application: Application): OkHttpClient.Builder { 28 | return OkHttpClient.Builder() 29 | } 30 | 31 | @Provides 32 | internal fun retrofit(okHttpClientBuilder: OkHttpClient.Builder): Retrofit { 33 | return retrofitBuilder(okHttpClientBuilder).build() 34 | } 35 | 36 | @Provides 37 | internal fun weatherForecastService(weatherApi: WeatherApi) : WeatherForecastService { 38 | return WeatherForecastService(weatherApi) 39 | } 40 | 41 | @Provides 42 | internal fun weatherApi(retrofit: Retrofit): WeatherApi { 43 | return retrofit.create(WeatherApi::class.java) 44 | } 45 | 46 | companion object { 47 | val BASE_URL = "https://api.darksky.net/forecast" 48 | val API_KEY = "56e3245918103f95ef6f2e5cc9c4063b/" 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/ViewModelKey.java: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.di; 2 | 3 | import androidx.lifecycle.ViewModel; 4 | 5 | import java.lang.annotation.Documented; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.RetentionPolicy; 9 | import java.lang.annotation.Target; 10 | 11 | import dagger.MapKey; 12 | 13 | @Documented 14 | @Target({ElementType.METHOD}) 15 | @Retention(RetentionPolicy.RUNTIME) 16 | @MapKey 17 | @interface ViewModelKey { 18 | Class value(); 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/di/ViewModelModule.java: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.di; 2 | 3 | import androidx.lifecycle.ViewModel; 4 | import androidx.lifecycle.ViewModelProvider; 5 | 6 | import com.acme.weather.app.viewmodel.WeatherDetailViewModel; 7 | import com.acme.weather.app.viewmodel.WeatherListViewModel; 8 | import com.acme.weather.app.viewmodel.WeatherViewModelFactory; 9 | 10 | import dagger.Binds; 11 | import dagger.Module; 12 | import dagger.multibindings.IntoMap; 13 | 14 | @Module 15 | public abstract class ViewModelModule { 16 | 17 | @Binds 18 | @IntoMap 19 | @ViewModelKey(WeatherListViewModel.class) 20 | abstract ViewModel bindWeatherListViewModel(WeatherListViewModel weatherListViewModel); 21 | 22 | @Binds 23 | @IntoMap 24 | @ViewModelKey(WeatherDetailViewModel.class) 25 | abstract ViewModel bindWeatherDetailViewModel(WeatherDetailViewModel weatherDetailViewModel); 26 | 27 | @Binds 28 | abstract ViewModelProvider.Factory bindViewModelFactory( 29 | WeatherViewModelFactory factory); 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/navigation/DialogNavHostFragment.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.navigation 2 | 3 | import androidx.navigation.Navigator 4 | import androidx.navigation.fragment.FragmentNavigator 5 | import androidx.navigation.fragment.NavHostFragment 6 | import androidx.navigation.plusAssign 7 | 8 | /** 9 | * A [NavHostFragment] who supports navigation to [DialogFragment]. 10 | */ 11 | class DialogNavHostFragment : NavHostFragment() { 12 | 13 | override fun createFragmentNavigator(): Navigator { 14 | navController.navigatorProvider += DialogNavigator(requireContext(), childFragmentManager) 15 | return super.createFragmentNavigator() 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/navigation/DialogNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.navigation 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.util.AttributeSet 6 | import androidx.fragment.app.DialogFragment 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.FragmentManager 9 | import androidx.navigation.NavDestination 10 | import androidx.navigation.NavOptions 11 | import androidx.navigation.Navigator 12 | import com.acme.weather.R 13 | import org.jetbrains.anko.bundleOf 14 | import java.util.* 15 | 16 | /** 17 | * Allows to navigate to some [DialogFragment]. 18 | * 19 | * Usage: add some dialog element in your navigation graph 20 | * ``` 21 | * 23 | * 24 | * ``` 25 | * Use [DialogNavHostFragment] as your [androidx.navigation.NavHost] in your layout 26 | * or add the DialogNavigator to your [NavigatorProvider] 27 | */ 28 | @Navigator.Name("dialog") 29 | class DialogNavigator( 30 | private val context: Context, 31 | private val fragmentManager: FragmentManager 32 | ) : Navigator() { 33 | 34 | private var lastBackStackEntry: FragmentManager.BackStackEntry? = null 35 | private val backstack: Deque = ArrayDeque() 36 | private var pendingPopBackStack = false 37 | 38 | private val onBackstackChangedListener: FragmentManager.OnBackStackChangedListener = 39 | FragmentManager.OnBackStackChangedListener { 40 | if (pendingPopBackStack) { 41 | val entry = fragmentManager.findLastBackStackEntry { it.name == FRAGMENT_BACKSTACK_NAME } 42 | pendingPopBackStack = (entry != null && entry == lastBackStackEntry) 43 | lastBackStackEntry = entry 44 | return@OnBackStackChangedListener 45 | } 46 | if (lastBackStackEntry != null && fragmentManager.noneBackStackEntry { it == lastBackStackEntry }) { 47 | backstack.removeLast() 48 | } 49 | lastBackStackEntry = fragmentManager.findLastBackStackEntry { it.name == FRAGMENT_BACKSTACK_NAME } 50 | } 51 | 52 | override fun navigate( 53 | destination: Destination, args: Bundle?, navOptions: NavOptions?, navigatorExtras: Extras? 54 | ): NavDestination? { 55 | val lastDialogFragment = instantiateFragment(destination.className!!, args) 56 | val tr = fragmentManager.beginTransaction().addToBackStack(FRAGMENT_BACKSTACK_NAME) 57 | 58 | lastDialogFragment.show(tr, destination.id.toString()) 59 | backstack.addLast(destination.id) 60 | 61 | // we don't want the destination to be added to the NavController stack, 62 | // because it will update the whole 63 | // navigation chrome (global AppBar, NavigationView, etc) 64 | return null 65 | } 66 | 67 | private fun instantiateFragment(className: String, args: Bundle?): DialogFragment { 68 | return Fragment.instantiate(context, className, args) as DialogFragment 69 | } 70 | 71 | override fun createDestination(): Destination = Destination(this) 72 | 73 | override fun popBackStack(): Boolean { 74 | val lastDialogFragment = 75 | fragmentManager.findFragmentByTag(backstack.lastOrNull()?.toString()) as? DialogFragment ?: return false 76 | lastDialogFragment.dismiss() 77 | backstack.removeLast() 78 | pendingPopBackStack = true 79 | return true 80 | } 81 | 82 | /* These 2 lifecycle methods should be handled in the NavHost of this navigator. 83 | * When the NavHost add them it should configure them or call some method so 84 | * that they can configure there listeners. However, this make a strong coupling between a Navigator and 85 | * its host implementation. 86 | * 87 | * The NavigatorProvider of NavController add a Navigator.OnBackStackChangedListener 88 | * who calls these methods */ 89 | override fun onBackPressAdded() { 90 | fragmentManager.addOnBackStackChangedListener(onBackstackChangedListener) 91 | } 92 | 93 | override fun onBackPressRemoved() { 94 | fragmentManager.removeOnBackStackChangedListener(onBackstackChangedListener) 95 | } 96 | 97 | override fun onSaveState(): Bundle? { 98 | return bundleOf(KEY_BACKSTACK_ID to backstack.toIntArray()) 99 | } 100 | 101 | override fun onRestoreState(savedState: Bundle) { 102 | savedState.getIntArray(KEY_BACKSTACK_ID)?.let { 103 | backstack.clear() 104 | for (id in it) { 105 | backstack.addLast(id) 106 | } 107 | } 108 | lastBackStackEntry = fragmentManager.findLastBackStackEntry { it.name == FRAGMENT_BACKSTACK_NAME } 109 | } 110 | 111 | class Destination(navigator: DialogNavigator) : NavDestination(navigator) { 112 | var className: String? = null 113 | get() = checkNotNull(field) { "Dialog name was not set" } 114 | 115 | override fun onInflate(context: Context, attrs: AttributeSet) { 116 | super.onInflate(context, attrs) 117 | val attributes = context.resources.obtainAttributes(attrs, R.styleable.DialogNavigator) 118 | className = attributes.getString(R.styleable.DialogNavigator_android_name) 119 | attributes.recycle() 120 | } 121 | } 122 | 123 | companion object { 124 | private const val KEY_BACKSTACK_ID = "com.geekorum.geekdroid:navigation:backstack_ids" 125 | private const val FRAGMENT_BACKSTACK_NAME = "com.geekorum.geekdroid:navigation:backstack" 126 | } 127 | 128 | private inline fun FragmentManager.findLastBackStackEntry( 129 | predicate: (FragmentManager.BackStackEntry) -> Boolean 130 | ): FragmentManager.BackStackEntry? { 131 | for (i in backStackEntryCount - 1 downTo 0) { 132 | val backStackEntry = getBackStackEntryAt(i) 133 | if (predicate(backStackEntry)) { 134 | return backStackEntry 135 | } 136 | } 137 | return null 138 | } 139 | 140 | private inline fun FragmentManager.noneBackStackEntry( 141 | predicate: (FragmentManager.BackStackEntry) -> Boolean 142 | ): Boolean { 143 | for (i in 0 until backStackEntryCount) { 144 | val backStackEntry = getBackStackEntryAt(i) 145 | if (predicate(backStackEntry)) { 146 | return false 147 | } 148 | } 149 | return true 150 | } 151 | } 152 | 153 | 154 | -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/navigation/NavigationExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.navigation 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavDestination 5 | 6 | fun NavController.isStartDestination(destination: NavDestination?) = destination?.id == graph.startDestination 7 | 8 | val NavController.parentDestination get() = currentDestination?.parent -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/common/navigation/NavigationResult.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.common.navigation 2 | 3 | import android.os.Bundle 4 | 5 | interface NavigationResult { 6 | fun onNavigationResult(result: Bundle) 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/acme/weather/security/view/EnterCredentials.kt: -------------------------------------------------------------------------------- 1 | package com.acme.weather.security.view 2 | 3 | 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.Button 9 | import android.widget.EditText 10 | import androidx.activity.OnBackPressedCallback 11 | import androidx.fragment.app.Fragment 12 | import androidx.fragment.app.activityViewModels 13 | import androidx.lifecycle.Observer 14 | import androidx.navigation.fragment.findNavController 15 | import com.acme.weather.R 16 | import com.acme.weather.common.navigation.isStartDestination 17 | import com.acme.weather.common.navigation.parentDestination 18 | import com.acme.weather.security.viewmodel.AuthenticationViewModel 19 | import com.acme.weather.security.viewmodel.AuthenticationViewModel.AuthenticationStatus.AUTHENTICATED 20 | import com.acme.weather.security.viewmodel.AuthenticationViewModel.AuthenticationStatus.USER_DECLINED 21 | 22 | 23 | class EnterCredentials : Fragment() { 24 | 25 | val authenticationViewModel by activityViewModels() 26 | 27 | val usernameEditText by lazy { requireView().findViewById(R.id.username_edit_text) } 28 | val passwordEditText by lazy { requireView().findViewById(R.id.password_edit_text) } 29 | 30 | val navController by lazy { findNavController() } 31 | 32 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, 33 | savedInstanceState: Bundle?) = 34 | inflater.inflate(R.layout.enter_credentials, container, false) 35 | 36 | 37 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 38 | 39 | view.findViewById