├── .gitignore ├── .travis.yml ├── README.md ├── android-wait-for-emulator ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── github │ │ └── gianpamx │ │ └── android │ │ └── architecture │ │ ├── CustomTestRunner.kt │ │ ├── app │ │ ├── TestApp.kt │ │ └── TestAppComponent.kt │ │ ├── data │ │ └── MockModule.kt │ │ └── form │ │ └── FormActivityTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── gianpamx │ │ │ └── android │ │ │ └── architecture │ │ │ ├── app │ │ │ ├── App.kt │ │ │ ├── AppComponent.kt │ │ │ ├── AppModule.kt │ │ │ ├── Binder.kt │ │ │ ├── ViewModelFactoryModule.kt │ │ │ └── ViewModelKey.kt │ │ │ ├── data │ │ │ ├── FormGateway.kt │ │ │ ├── ImagesGateway.kt │ │ │ ├── retrofit │ │ │ │ ├── AlbumDataModel.kt │ │ │ │ ├── ImageDataModel.kt │ │ │ │ ├── ImgurService.kt │ │ │ │ ├── RetrofitImages.kt │ │ │ │ └── RetrofitModule.kt │ │ │ └── room │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── FormDao.kt │ │ │ │ ├── FormRepository.kt │ │ │ │ ├── FormRoom.kt │ │ │ │ └── RoomModule.kt │ │ │ ├── entity │ │ │ ├── Exeptions.kt │ │ │ └── Form.kt │ │ │ ├── form │ │ │ ├── FormActivity.kt │ │ │ └── FormViewModel.kt │ │ │ ├── gallery │ │ │ ├── GalleryActivity.kt │ │ │ ├── GalleryAdapter.kt │ │ │ ├── GalleryViewModel.kt │ │ │ └── SquareImageView.kt │ │ │ ├── providers │ │ │ ├── AndroidDateTimeProvider.kt │ │ │ ├── AppVersionProvider.kt │ │ │ ├── DateTimeProvider.kt │ │ │ └── VersionProvider.kt │ │ │ └── usecase │ │ │ ├── GetFormUseCase.kt │ │ │ ├── GetImagesUseCase.kt │ │ │ ├── SaveFormUseCase.kt │ │ │ └── UseCaseModule.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── form_activity.xml │ │ └── gallery_activity.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-land │ │ └── gallery.xml │ │ ├── values-sw600dp-land │ │ └── gallery.xml │ │ ├── values-sw600dp-port │ │ └── gallery.xml │ │ ├── values-sw720dp-land │ │ └── gallery.xml │ │ ├── values-sw720dp-port │ │ └── gallery.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── gallery.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ ├── java │ └── io │ │ └── github │ │ └── gianpamx │ │ └── android │ │ └── architecture │ │ ├── data │ │ ├── retrofit │ │ │ └── RetrofitImagesTest.kt │ │ └── room │ │ │ └── FormRepositoryTest.kt │ │ ├── form │ │ └── FormViewModelTest.kt │ │ ├── gallery │ │ └── GalleryViewModelTest.kt │ │ └── usecase │ │ ├── GetFormUseCaseImplTest.kt │ │ ├── GetImagesUseCaseImplTest.kt │ │ └── SaveFormUseCaseImplTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── build.gradle ├── docs ├── android-clean-architecture.png ├── android-clean-architecture.xml ├── end-to-end-tests.png ├── integration-tests.png ├── layers.png ├── production-app-dagger.png ├── test-app-dagger.png └── unit-tests.png ├── 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 | /captures 8 | .externalNativeBuild 9 | /projectFilesBackup 10 | 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | 5 | before_cache: 6 | # Do not cache a few Gradle files/directories (see https://docs.travis-ci.com/user/languages/java/#Caching) 7 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 8 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 9 | 10 | cache: 11 | directories: 12 | # Android SDK 13 | - $HOME/android-sdk-dl 14 | - $HOME/android-sdk 15 | 16 | # Gradle dependencies 17 | - $HOME/.gradle/caches/ 18 | - $HOME/.gradle/wrapper/ 19 | 20 | # Android build cache (see http://tools.android.com/tech-docs/build-cache) 21 | - $HOME/.android/build-cache 22 | 23 | env: 24 | global: 25 | - ANDROID_HOME=$HOME/android-sdk 26 | - ANDROID_SDK_ROOT=$ANDROID_HOME 27 | - PATH=$ANDROID_HOME/tools/bin:$PATH 28 | - PATH=$ANDROID_HOME/platform-tools:$PATH 29 | - PATH=$ANDROID_HOME/emulator:$PATH 30 | 31 | install: 32 | # Download and unzip the Android SDK tools (if not already there thanks to the cache mechanism) 33 | # Latest version available here: https://developer.android.com/studio/index.html#downloads 34 | - if test ! -e $HOME/android-sdk-dl/sdk-tools.zip ; then curl https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip > $HOME/android-sdk-dl/sdk-tools.zip ; fi 35 | - unzip -qq -n $HOME/android-sdk-dl/sdk-tools.zip -d $HOME/android-sdk 36 | 37 | - echo yes | sdkmanager "tools" &>/dev/null 38 | - echo yes | sdkmanager "platform-tools" &>/dev/null 39 | - echo yes | sdkmanager "build-tools;27.0.2" &>/dev/null 40 | - echo yes | sdkmanager "platforms;android-27" &>/dev/null 41 | - echo yes | sdkmanager "system-images;android-19;default;armeabi-v7a" &>/dev/null 42 | - echo yes | sdkmanager "emulator" &>/dev/null 43 | - echo no | avdmanager create avd --force -n test -k "system-images;android-19;default;armeabi-v7a" &>/dev/null 44 | - emulator -avd test -no-audio -no-window & 45 | 46 | before_script: 47 | - ./gradlew :app:assembleDebug :app:assembleAndroidTest 48 | - ./android-wait-for-emulator 49 | - adb shell settings put global window_animation_scale 0 & 50 | - adb shell settings put global transition_animation_scale 0 & 51 | - adb shell settings put global animator_duration_scale 0 & 52 | - adb shell input keyevent 82 53 | 54 | script: 55 | - ./gradlew fullCoverageReport 56 | 57 | after_success: 58 | - bash <(curl -s https://codecov.io/bash) -f ./app/build/reports/jacoco/fullCoverageReport/fullCoverageReport.xml 59 | 60 | sudo: false 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Android Architecture 2 | ==================== 3 | 4 | [![Build Status](https://travis-ci.org/GianpaMX/android-architecture.svg?branch=master)](https://travis-ci.org/GianpaMX/android-architecture) 5 | [![codecov](https://codecov.io/gh/GianpaMX/android-architecture/branch/master/graph/badge.svg)](https://codecov.io/gh/GianpaMX/android-architecture) 6 | 7 | This is a project to show how to separate an app into several layers. Following 8 | [Uncle Bob Clean Architecture](https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html). 9 | 10 | ![alt Android Clean Architecture](docs/android-clean-architecture.png "Android Clean Architecture") 11 | 12 | All App code is in 13 | [app/src/main/java/io/github/gianpamx/android/architecture](app/src/main/java/io/github/gianpamx/android/architecture) 14 | and is organized in a way when you read the directory list you get an app, data, entities, form, 15 | gallery, providers and usecase. The idea is to know where the files are so, if you want to modify 16 | the gallery everything should be in there 17 | 18 | - **[form](app/src/main/java/io/github/gianpamx/android/architecture/form)**: Form feature, when the 19 | app starts it should show a form for introducing a name and a phone. It should display the current 20 | time updated every second. If the form was already filled, it should start gallery directly. 21 | Classes in here shouldn't be used outside 22 | 23 | - **[gallery](app/src/main/java/io/github/gianpamx/android/architecture/gallery)**: Gallery feature, 24 | there should be a greeting message to the named introduced in the form and a grid of images with 25 | smooth scrolling. Classes in here shouldn't be used outside 26 | 27 | - **[usecase](app/src/main/java/io/github/gianpamx/android/architecture/usecase)**: Center of the 28 | business rules. All the rules of what the app can do are here. Are constructed in a way that can 29 | be reused across all features 30 | 31 | - **[entity](app/src/main/java/io/github/gianpamx/android/architecture/entity)**: Center of the 32 | application; here there are models that holds information of what the app does 33 | 34 | - **[data](app/src/main/java/io/github/gianpamx/android/architecture/data)**: Data handling like 35 | downloading images and storing into a database 36 | 37 | - **[providers](app/src/main/java/io/github/gianpamx/android/architecture/providers)**: Provide 38 | information from app like version or from system like current time 39 | 40 | - **[app](app/src/main/java/io/github/gianpamx/android/architecture/app)**: Android Application 41 | class and everything that is instantiated with an app scope and should be available from anywhere 42 | 43 | This project is build using `Model-View-ViewModel` basically ViewModel substitutes Presenter in (MVP) 44 | so the View (activities) only interact with UseCases using the ViewModel. The view is subscribed to 45 | ViewModel changes and react to them. ViewModels should be ready to display, no complex 46 | transformations or logic here (rather than view logic) 47 | 48 | ViewModels are the bridge between Views and UseCases, they map request from the view into requests 49 | to one or many UseCases. 50 | 51 | UseCases are the business logic of the app, they abstract what the app does in terms of 52 | `input -> UseCase -> output`. They request data to Gateways or from providers, manipulates them 53 | according to a business rules. 54 | 55 | 56 | ## Testing 57 | 58 | There are two kind of tests in this project, instrumented tests and unit tests. They test different 59 | things. 60 | 61 | Instrumented Tests verify that view behaves according to business rules putting in place all 62 | the components of the app but data or providers that are mocked to create different scenarios. 63 | 64 | Uni Tests check all possible scenarios of each class except view classes, here we test if thay 65 | are behaving as expected 66 | 67 | To run all tests: 68 | ``` 69 | ./gradlew fullCoverageReport 70 | ``` 71 | -------------------------------------------------------------------------------- /android-wait-for-emulator: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Originally written by Ralf Kistner , but placed in the public domain 4 | 5 | set +e 6 | 7 | bootanim="" 8 | failcounter=0 9 | timeout_in_sec=360 10 | 11 | until [[ "$bootanim" =~ "stopped" ]]; do 12 | bootanim=`adb -e shell getprop init.svc.bootanim 2>&1 &` 13 | if [[ "$bootanim" =~ "device not found" || "$bootanim" =~ "device offline" 14 | || "$bootanim" =~ "running" ]]; then 15 | let "failcounter += 1" 16 | echo "Waiting for emulator to start..." 17 | if [[ $failcounter -gt timeout_in_sec ]]; then 18 | echo "Timeout ($timeout_in_sec seconds) reached; failed to start emulator" 19 | exit 1 20 | fi 21 | fi 22 | sleep 1 23 | done 24 | 25 | echo "Emulator is ready" 26 | -------------------------------------------------------------------------------- /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: 'jacoco' 6 | 7 | def SDK_VERSION = 27 8 | 9 | android { 10 | compileSdkVersion SDK_VERSION 11 | 12 | defaultConfig { 13 | applicationId "io.github.gianpamx.androidarchitecture" 14 | minSdkVersion 16 15 | targetSdkVersion SDK_VERSION 16 | versionCode 1 17 | versionName "1.0" 18 | testInstrumentationRunner "io.github.gianpamx.android.architecture.CustomTestRunner" 19 | } 20 | buildTypes { 21 | debug { 22 | testCoverageEnabled !project.hasProperty('android.injected.invoked.from.ide') 23 | } 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | } 30 | 31 | jacoco { 32 | toolVersion = "0.8.1" 33 | } 34 | 35 | tasks.withType(Test) { 36 | jacoco.includeNoLocationClasses = true 37 | } 38 | 39 | dependencies { 40 | def daggerVersion = '2.14.1' 41 | def archCompVersion = '1.1.1' 42 | def roomVersion = '1.1.0' 43 | def mockitoKotlinVersion = '1.5.0' 44 | def glideVersion = '4.6.1' 45 | def retrofitVersion = '2.3.0' 46 | def supportVersion = "27.1.1" 47 | def runnerVersion = "1.0.2" 48 | 49 | kapt "com.google.dagger:dagger-compiler:$daggerVersion" 50 | implementation "com.google.dagger:dagger:$daggerVersion" 51 | implementation "com.google.dagger:dagger-android:$daggerVersion" 52 | implementation "com.google.dagger:dagger-android-support:$daggerVersion" 53 | kapt "com.google.dagger:dagger-android-processor:$daggerVersion" 54 | 55 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlinVersion" 56 | implementation "com.android.support:appcompat-v7:$supportVersion" 57 | implementation "com.android.support:design:$supportVersion" 58 | 59 | implementation "android.arch.lifecycle:extensions:$archCompVersion" 60 | kapt "android.arch.lifecycle:compiler:$archCompVersion" 61 | 62 | implementation "android.arch.persistence.room:runtime:$roomVersion" 63 | kapt "android.arch.persistence.room:compiler:$roomVersion" 64 | 65 | implementation "com.github.bumptech.glide:glide:$glideVersion" 66 | kapt "com.github.bumptech.glide:annotations:$glideVersion" 67 | kapt "com.github.bumptech.glide:compiler:$glideVersion" 68 | 69 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 70 | implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" 71 | 72 | testImplementation "junit:junit:4.12" 73 | testImplementation "android.arch.core:core-testing:$archCompVersion" 74 | testImplementation "android.arch.persistence.room:testing:$roomVersion" 75 | testImplementation "com.nhaarman:mockito-kotlin:$mockitoKotlinVersion" 76 | testImplementation "com.squareup.retrofit2:retrofit-mock:$retrofitVersion" 77 | testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 78 | testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" 79 | 80 | 81 | androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 82 | androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" 83 | androidTestImplementation 'org.mockito:mockito-android:2.13.0' 84 | androidTestImplementation "com.nhaarman:mockito-kotlin:$mockitoKotlinVersion" 85 | androidTestImplementation "com.android.support.test:rules:$runnerVersion" 86 | androidTestImplementation "com.android.support.test:runner:$runnerVersion" 87 | androidTestImplementation "com.android.support.test.espresso:espresso-core:3.0.2" 88 | androidTestImplementation "android.arch.core:core-testing:$archCompVersion" 89 | androidTestImplementation('com.schibsted.spain:barista:2.3.0') { 90 | exclude group: 'com.android.support' 91 | exclude group: 'org.jetbrains.kotlin' 92 | } 93 | kaptAndroidTest "com.google.dagger:dagger-android-processor:$daggerVersion" 94 | } 95 | 96 | task fullCoverageReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'createDebugCoverageReport']) { 97 | reports { 98 | xml.enabled = true 99 | html.enabled = true 100 | } 101 | 102 | def fileFilter = [ 103 | '**/*Test*.*', 104 | '**/AutoValue_*.*', 105 | '**/*JavascriptBridge.class', 106 | '**/R.class', 107 | '**/R$*.class', 108 | '**/Manifest*.*', 109 | 'android/**/*.*', 110 | '**/BuildConfig.*', 111 | '**/*$ViewBinder*.*', 112 | '**/*$ViewInjector*.*', 113 | '**/Lambda$*.class', 114 | '**/Lambda.class', 115 | '**/*Lambda.class', 116 | '**/*Lambda*.class', 117 | '**/*$InjectAdapter.class', 118 | '**/*$ModuleAdapter.class', 119 | '**/*$ViewInjector*.class', 120 | '**/*_Impl*', 121 | '**/Dagger*Component*', 122 | '**/Dagger*Impl*', 123 | '**/Dagger*Builder*', 124 | '**/*_MembersInjector.class', //Dagger2 generated code 125 | '*/*_MembersInjector*.*', //Dagger2 generated code 126 | '**/*_*Factory*.*', //Dagger2 generated code 127 | '*/*Component*.*', //Dagger2 generated code 128 | '**/*Module*.*' //Dagger2 generated code 129 | ] 130 | def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug", excludes: fileFilter) 131 | def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debug", excludes: fileFilter) 132 | def mainSrc = "${project.projectDir}/src/main/java" 133 | 134 | sourceDirectories = files([mainSrc]) 135 | classDirectories = files([debugTree, kotlinDebugTree]) 136 | executionData = fileTree(dir: "$buildDir", includes: [ 137 | "outputs/code-coverage/connected/*coverage.ec", 138 | "jacoco/testDebugUnitTest.exec" 139 | ]) 140 | } 141 | -------------------------------------------------------------------------------- /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/io/github/gianpamx/android/architecture/CustomTestRunner.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.support.test.runner.AndroidJUnitRunner 6 | import io.github.gianpamx.android.architecture.app.TestApp 7 | 8 | 9 | class CustomTestRunner : AndroidJUnitRunner() { 10 | @Throws(InstantiationException::class, IllegalAccessException::class, ClassNotFoundException::class) 11 | override fun newApplication(cl: ClassLoader, className: String, context: Context): Application { 12 | return super.newApplication(cl, TestApp::class.java.getName(), context) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/github/gianpamx/android/architecture/app/TestApp.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | class TestApp : App() { 4 | lateinit var testAppComponent: TestAppComponent 5 | 6 | override fun onCreate() { 7 | super.onCreate() 8 | 9 | testAppComponent = DaggerTestAppComponent.builder() 10 | .application(this) 11 | .build() 12 | 13 | testAppComponent.inject(this) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/github/gianpamx/android/architecture/app/TestAppComponent.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.app.Application 4 | import dagger.BindsInstance 5 | import dagger.Component 6 | import dagger.android.AndroidInjectionModule 7 | import io.github.gianpamx.android.architecture.data.MockModule 8 | import io.github.gianpamx.android.architecture.form.FormActivityTest 9 | import io.github.gianpamx.android.architecture.usecase.UseCaseModule 10 | import javax.inject.Singleton 11 | 12 | 13 | @Singleton 14 | @Component(modules = [ 15 | AndroidInjectionModule::class, 16 | AppModule::class, 17 | Binder::class, 18 | UseCaseModule::class, 19 | MockModule::class 20 | ]) 21 | interface TestAppComponent { 22 | @Component.Builder 23 | interface Builder { 24 | 25 | @BindsInstance 26 | fun application(application: Application): Builder 27 | 28 | fun build(): TestAppComponent 29 | 30 | } 31 | 32 | fun inject(app: TestApp) 33 | 34 | fun inject(formActivityTest: FormActivityTest) 35 | } 36 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/github/gianpamx/android/architecture/data/MockModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.github.gianpamx.android.architecture.data.room.FormDao 6 | import io.github.gianpamx.android.architecture.data.room.FormRepository 7 | import org.mockito.Mockito.mock 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | class MockModule { 12 | @Provides 13 | @Singleton 14 | fun providesFormDao(): FormDao { 15 | return mock(FormDao::class.java) 16 | } 17 | 18 | @Provides 19 | @Singleton 20 | fun provideFormGateway(formDao: FormDao): FormGateway = FormRepository(formDao) 21 | 22 | @Provides 23 | @Singleton 24 | fun provideImagesGateway(): ImagesGateway { 25 | return mock(ImagesGateway::class.java) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/github/gianpamx/android/architecture/form/FormActivityTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.form 2 | 3 | 4 | import android.arch.core.executor.testing.InstantTaskExecutorRule 5 | import android.content.Intent 6 | import android.support.test.InstrumentationRegistry 7 | import android.support.test.rule.ActivityTestRule 8 | import android.support.test.runner.AndroidJUnit4 9 | import com.nhaarman.mockito_kotlin.whenever 10 | import com.schibsted.spain.barista.interaction.BaristaClickInteractions.clickOn 11 | import com.schibsted.spain.barista.interaction.BaristaEditTextInteractions.writeTo 12 | import io.github.gianpamx.android.architecture.app.TestApp 13 | import io.github.gianpamx.android.architecture.data.room.FormDao 14 | import io.github.gianpamx.android.architecture.data.room.FormRoom 15 | import io.github.gianpamx.androidarchitecture.R 16 | import org.junit.Assert.assertFalse 17 | import org.junit.Assert.assertTrue 18 | import org.junit.Before 19 | import org.junit.Rule 20 | import org.junit.Test 21 | import org.junit.runner.RunWith 22 | import org.mockito.Mockito.reset 23 | import javax.inject.Inject 24 | 25 | 26 | @RunWith(AndroidJUnit4::class) 27 | class FormActivityTest { 28 | 29 | companion object { 30 | val ANY_NAME = "Gianpa" 31 | val ANY_PHONE = "5512341234" 32 | } 33 | 34 | @Rule 35 | @JvmField 36 | var instantExecutorRule = InstantTaskExecutorRule() 37 | 38 | @Rule 39 | @JvmField 40 | var activityTestRule = ActivityTestRule(FormActivity::class.java, false, false) 41 | 42 | @Inject 43 | lateinit var formDao: FormDao 44 | 45 | @Before 46 | fun setUp() { 47 | val testApp = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestApp 48 | testApp.testAppComponent.inject(this) 49 | reset(formDao) 50 | } 51 | 52 | @Test 53 | fun sendForm() { 54 | activityTestRule.launchActivity(Intent()) 55 | writeTo(R.id.nameEditText, ANY_NAME) 56 | writeTo(R.id.phoneEditText, ANY_PHONE) 57 | clickOn(R.id.sendButton) 58 | 59 | assertTrue(activityTestRule.activity.isFinishing) 60 | } 61 | 62 | @Test 63 | fun existingFormData() { 64 | whenever(formDao.findForm()).thenReturn(FormRoom(ANY_NAME, ANY_PHONE)) 65 | 66 | activityTestRule.launchActivity(Intent()) 67 | 68 | assertTrue(activityTestRule.activity.isFinishing) 69 | } 70 | 71 | @Test 72 | fun noExistingFormData() { 73 | whenever(formDao.findForm()).thenReturn(null) 74 | 75 | activityTestRule.launchActivity(Intent()) 76 | 77 | assertFalse(activityTestRule.activity.isFinishing) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/App.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import dagger.android.DispatchingAndroidInjector 6 | import dagger.android.HasActivityInjector 7 | import javax.inject.Inject 8 | 9 | 10 | open class App : Application(), HasActivityInjector { 11 | @Inject 12 | lateinit var dispatchingActivityInjector: DispatchingAndroidInjector 13 | 14 | override fun onCreate() { 15 | super.onCreate() 16 | inject() 17 | } 18 | 19 | protected fun inject() { 20 | DaggerAppComponent.builder() 21 | .application(this) 22 | .build() 23 | .inject(this); 24 | } 25 | 26 | override fun activityInjector() = dispatchingActivityInjector 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.app.Application 4 | import dagger.BindsInstance 5 | import dagger.Component 6 | import dagger.android.AndroidInjectionModule 7 | import io.github.gianpamx.android.architecture.data.retrofit.RetrofitModule 8 | import io.github.gianpamx.android.architecture.data.room.RoomModule 9 | import io.github.gianpamx.android.architecture.usecase.UseCaseModule 10 | import javax.inject.Singleton 11 | 12 | 13 | @Singleton 14 | @Component(modules = [ 15 | AndroidInjectionModule::class, 16 | AppModule::class, 17 | Binder::class, 18 | UseCaseModule::class, 19 | RoomModule::class, 20 | RetrofitModule::class 21 | ]) 22 | interface AppComponent { 23 | @Component.Builder 24 | interface Builder { 25 | 26 | @BindsInstance 27 | fun application(application: Application): Builder 28 | 29 | fun build(): AppComponent 30 | 31 | } 32 | 33 | fun inject(app: App) 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/AppModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | import io.github.gianpamx.android.architecture.providers.AndroidDateTimeProvider 8 | import io.github.gianpamx.android.architecture.providers.AppVersionProvider 9 | import io.github.gianpamx.android.architecture.providers.DateTimeProvider 10 | import io.github.gianpamx.android.architecture.providers.VersionProvider 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | class AppModule { 15 | @Provides 16 | @Singleton 17 | fun provideContext(application: Application): Context = application 18 | 19 | @Provides 20 | @Singleton 21 | fun provideDateTimeProvider(): DateTimeProvider = AndroidDateTimeProvider() 22 | 23 | @Provides 24 | @Singleton 25 | fun provideVersionProvider(context: Context): VersionProvider = AppVersionProvider(context) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/Binder.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import dagger.Binds 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | import dagger.multibindings.IntoMap 8 | import io.github.gianpamx.android.architecture.form.FormActivity 9 | import io.github.gianpamx.android.architecture.form.FormViewModel 10 | import io.github.gianpamx.android.architecture.gallery.GalleryActivity 11 | import io.github.gianpamx.android.architecture.gallery.GalleryViewModel 12 | 13 | 14 | @Module(includes = [ViewModelFactoryModule::class]) 15 | abstract class Binder { 16 | @ContributesAndroidInjector 17 | abstract fun bindFormActivity(): FormActivity 18 | 19 | @Binds 20 | @IntoMap 21 | @ViewModelKey(FormViewModel::class) 22 | abstract fun bindMainViewModel(viewModel: FormViewModel): ViewModel 23 | 24 | 25 | @ContributesAndroidInjector 26 | abstract fun bindGalleryActivity(): GalleryActivity 27 | 28 | @Binds 29 | @IntoMap 30 | @ViewModelKey(GalleryViewModel::class) 31 | abstract fun bindGalleryViewModel(viewModel: GalleryViewModel): ViewModel 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/ViewModelFactoryModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import dagger.Module 6 | import dagger.Provides 7 | import javax.inject.Provider 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | class ViewModelFactoryModule { 12 | @Provides 13 | @Singleton 14 | fun provideViewModelFactory(creators: Map, @JvmSuppressWildcards Provider>): ViewModelProvider.Factory = object : ViewModelProvider.Factory { 15 | override fun create(modelClass: Class): T { 16 | val creator = creators[modelClass] ?: creators.entries.firstOrNull { 17 | modelClass.isAssignableFrom(it.key) 18 | }?.value ?: throw IllegalArgumentException("unknown model class $modelClass") 19 | try { 20 | @Suppress("UNCHECKED_CAST") 21 | return creator.get() as T 22 | } catch (e: Exception) { 23 | throw RuntimeException(e) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/app/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.app 2 | 3 | 4 | import android.arch.lifecycle.ViewModel 5 | import dagger.MapKey 6 | import kotlin.reflect.KClass 7 | 8 | @MustBeDocumented 9 | @Target( 10 | AnnotationTarget.FUNCTION, 11 | AnnotationTarget.PROPERTY_GETTER, 12 | AnnotationTarget.PROPERTY_SETTER 13 | ) 14 | @Retention(AnnotationRetention.RUNTIME) 15 | @MapKey 16 | annotation class ViewModelKey(val value: KClass) 17 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/FormGateway.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data 2 | 3 | import io.github.gianpamx.android.architecture.entity.Form 4 | 5 | interface FormGateway { 6 | fun persist(form: Form) 7 | fun findForm(): Form? 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/ImagesGateway.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data 2 | 3 | interface ImagesGateway { 4 | fun getAlbum(albumHash: String): List 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/retrofit/AlbumDataModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.retrofit 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | class AlbumDataModel { 6 | @SerializedName("data") 7 | var data = ArrayList() 8 | } 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/retrofit/ImageDataModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.retrofit 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | class ImageDataModel { 6 | @SerializedName("link") 7 | var link = "" 8 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/retrofit/ImgurService.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.retrofit 2 | 3 | import retrofit2.Call 4 | import retrofit2.http.GET 5 | import retrofit2.http.Headers 6 | import retrofit2.http.Path 7 | 8 | interface ImgurService { 9 | @Headers("Authorization: Client-ID e64c31050a6a35b") 10 | @GET("album/{albumHash}/images") 11 | fun album(@Path("albumHash") albumHash: String): Call 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/retrofit/RetrofitImages.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.retrofit 2 | 3 | import io.github.gianpamx.android.architecture.data.ImagesGateway 4 | import io.github.gianpamx.android.architecture.entity.ApiException 5 | 6 | class RetrofitImages(val imgurService: ImgurService) : ImagesGateway { 7 | override fun getAlbum(albumHash: String): List { 8 | val call = imgurService.album(albumHash) 9 | val response = call.execute() 10 | 11 | if (!response.isSuccessful) { 12 | throw ApiException() 13 | } 14 | 15 | return response.body()?.data?.map { it.toImage() } ?: emptyList() 16 | } 17 | } 18 | 19 | private fun ImageDataModel.toImage() = link 20 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/retrofit/RetrofitModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.retrofit 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.github.gianpamx.android.architecture.data.ImagesGateway 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.gson.GsonConverterFactory 8 | import javax.inject.Singleton 9 | 10 | 11 | @Module 12 | class RetrofitModule { 13 | @Provides 14 | @Singleton 15 | fun provideImagesGateway(imgurService: ImgurService): ImagesGateway = RetrofitImages(imgurService) 16 | 17 | @Provides 18 | @Singleton 19 | fun provideImgurService(retrofit: Retrofit) = retrofit.create(ImgurService::class.java) 20 | 21 | @Provides 22 | @Singleton 23 | fun provideRetrofit() = Retrofit.Builder().addConverterFactory(GsonConverterFactory.create()).baseUrl("https://api.imgur.com/3/").build() 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/room/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.room 2 | 3 | import android.arch.persistence.room.Database 4 | import android.arch.persistence.room.RoomDatabase 5 | 6 | @Database(entities = [FormRoom::class], version = 1, exportSchema = false) 7 | abstract class AppDatabase : RoomDatabase() { 8 | abstract fun formDao(): FormDao 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/room/FormDao.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.room 2 | 3 | import android.arch.persistence.room.Dao 4 | import android.arch.persistence.room.Insert 5 | import android.arch.persistence.room.OnConflictStrategy.REPLACE 6 | import android.arch.persistence.room.Query 7 | 8 | @Dao 9 | interface FormDao { 10 | @Insert(onConflict = REPLACE) 11 | fun insert(formRoom: FormRoom) 12 | 13 | @Query("SELECT * FROM FormRoom LIMIT 1") 14 | fun findForm(): FormRoom? 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/room/FormRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.room 2 | 3 | import io.github.gianpamx.android.architecture.data.FormGateway 4 | import io.github.gianpamx.android.architecture.entity.Form 5 | 6 | class FormRepository(val formDao: FormDao) : FormGateway { 7 | override fun findForm() = formDao.findForm()?.toForm() 8 | 9 | override fun persist(form: Form) { 10 | formDao.insert(form.toFormDao()) 11 | } 12 | } 13 | 14 | internal fun FormRoom.toForm() = Form( 15 | name = name, 16 | phone = phone 17 | ) 18 | 19 | private fun Form.toFormDao() = FormRoom( 20 | name = name, 21 | phone = phone 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/room/FormRoom.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.room 2 | 3 | import android.arch.persistence.room.Entity 4 | import android.arch.persistence.room.PrimaryKey 5 | 6 | @Entity 7 | class FormRoom { 8 | @PrimaryKey 9 | var uid: Int? = null 10 | 11 | var name: String 12 | 13 | var phone: String 14 | 15 | constructor(name: String = "", phone: String = "") { 16 | this.name = name 17 | this.phone = phone 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/data/room/RoomModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.data.room 2 | 3 | import android.arch.persistence.room.Room 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | import io.github.gianpamx.android.architecture.data.FormGateway 8 | 9 | @Module 10 | class RoomModule { 11 | @Provides 12 | fun providesAppDatabase(context: Context): AppDatabase = Room.databaseBuilder(context, AppDatabase::class.java, "database").build() 13 | 14 | @Provides 15 | fun providesFormDao(database: AppDatabase) = database.formDao() 16 | 17 | @Provides 18 | fun provideFormGateway(formDao: FormDao): FormGateway = FormRepository(formDao) 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/entity/Exeptions.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.entity 2 | 3 | class EmptyNameException : Exception() 4 | class EmptyPhoneException : Exception() 5 | class ApiException() : Exception() 6 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/entity/Form.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.entity 2 | 3 | data class Form( 4 | var name: String = "", 5 | var phone: String = "" 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/form/FormActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.form 2 | 3 | import android.arch.lifecycle.Observer 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.content.Intent 7 | import android.os.Bundle 8 | import android.support.v7.app.AppCompatActivity 9 | import android.text.format.DateFormat 10 | import dagger.android.AndroidInjection 11 | import io.github.gianpamx.android.architecture.entity.EmptyNameException 12 | import io.github.gianpamx.android.architecture.entity.EmptyPhoneException 13 | import io.github.gianpamx.android.architecture.gallery.GalleryActivity 14 | import io.github.gianpamx.androidarchitecture.R 15 | import kotlinx.android.synthetic.main.form_activity.* 16 | import java.util.* 17 | import javax.inject.Inject 18 | 19 | class FormActivity : AppCompatActivity() { 20 | 21 | @Inject 22 | lateinit var factory: ViewModelProvider.Factory 23 | 24 | lateinit var viewModel: FormViewModel; 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | AndroidInjection.inject(this) 28 | super.onCreate(savedInstanceState) 29 | viewModel = ViewModelProviders.of(this, factory).get(FormViewModel::class.java) 30 | 31 | setContentView(R.layout.form_activity) 32 | 33 | sendButton.setOnClickListener { 34 | viewModel.send(nameEditText.text.toString(), phoneEditText.text.toString()) 35 | } 36 | 37 | viewModel.isFormSaved.observe(this, formSavedObserver) 38 | viewModel.dateTime.observe(this, dateObserver) 39 | viewModel.appVersion.observe(this, appVersionObserver) 40 | viewModel.error.observe(this, errorObserver) 41 | } 42 | 43 | private val formSavedObserver = Observer { isFormSaved -> 44 | isFormSaved?.let { 45 | if (it) { 46 | finish() 47 | startActivity(Intent(this, GalleryActivity::class.java)) 48 | } 49 | } 50 | } 51 | 52 | private val dateObserver = Observer { date -> 53 | dateTimeTextView.text = DateFormat.format(getString(R.string.form_date_format), date) 54 | } 55 | 56 | private val appVersionObserver = Observer { appVersion -> 57 | versionTextView.text = getString(R.string.form_version, appVersion) 58 | } 59 | 60 | private val errorObserver = Observer { 61 | if (it is EmptyNameException) { 62 | nameEditText.error = getString(R.string.form_empty_error) 63 | } 64 | 65 | if (it is EmptyPhoneException) { 66 | phoneEditText.error = getString(R.string.form_empty_error) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/form/FormViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.form 2 | 3 | import android.arch.lifecycle.MutableLiveData 4 | import android.arch.lifecycle.ViewModel 5 | import io.github.gianpamx.android.architecture.providers.DateTimeProvider 6 | import io.github.gianpamx.android.architecture.providers.VersionProvider 7 | import io.github.gianpamx.android.architecture.usecase.GetFormUseCase 8 | import io.github.gianpamx.android.architecture.usecase.SaveFormUseCase 9 | import java.util.* 10 | import javax.inject.Inject 11 | 12 | class FormViewModel @Inject constructor( 13 | dateTimeProvider: DateTimeProvider, 14 | private val saveFormUseCase: SaveFormUseCase, 15 | private val getFormUseCase: GetFormUseCase, 16 | versionProvider: VersionProvider 17 | ) : ViewModel(), DateTimeProvider.Listener { 18 | 19 | val dateTime = MutableLiveData() 20 | val isFormSaved = MutableLiveData() 21 | val appVersion = MutableLiveData() 22 | val error = MutableLiveData() 23 | 24 | init { 25 | dateTimeProvider.start(this) 26 | 27 | appVersion.postValue(versionProvider.getVersion()) 28 | 29 | isFormSaved.postValue(false) 30 | this.getFormUseCase.execute({ 31 | isFormSaved.postValue(true) 32 | }) 33 | } 34 | 35 | override fun onTick(date: Date) { 36 | dateTime.postValue(date) 37 | } 38 | 39 | fun send(name: String?, phone: String?) { 40 | saveFormUseCase.execute(name, phone, 41 | success = { isFormSaved.postValue(true) }, 42 | failure = { error.postValue(it) } 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/gallery/GalleryActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.gallery 2 | 3 | import android.arch.lifecycle.Observer 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.os.Bundle 7 | import android.support.v7.app.AppCompatActivity 8 | import android.support.v7.widget.GridLayoutManager 9 | import dagger.android.AndroidInjection 10 | import io.github.gianpamx.androidarchitecture.R 11 | import kotlinx.android.synthetic.main.gallery_activity.* 12 | import javax.inject.Inject 13 | 14 | class GalleryActivity : AppCompatActivity() { 15 | 16 | @Inject 17 | lateinit var factory: ViewModelProvider.Factory 18 | 19 | lateinit var viewModel: GalleryViewModel 20 | 21 | lateinit var galleryAdapter: GalleryAdapter 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | AndroidInjection.inject(this); 25 | super.onCreate(savedInstanceState) 26 | 27 | viewModel = ViewModelProviders.of(this, factory).get(GalleryViewModel::class.java) 28 | 29 | setContentView(R.layout.gallery_activity) 30 | 31 | galleryAdapter = GalleryAdapter(this) 32 | configureRecyclerView() 33 | 34 | viewModel.name.observe(this, nameObserver) 35 | viewModel.images.observe(this, imagesObserver) 36 | } 37 | 38 | private fun configureRecyclerView() { 39 | galleryRecyclerView.setHasFixedSize(true) 40 | galleryRecyclerView.isDrawingCacheEnabled = true; 41 | galleryRecyclerView.layoutManager = GridLayoutManager(this, resources.getInteger(R.integer.gallery_span_count)) 42 | galleryRecyclerView.adapter = galleryAdapter 43 | } 44 | 45 | private val nameObserver = Observer { 46 | greetingEditText.text = getString(R.string.gallery_greeting, it) 47 | } 48 | 49 | private val imagesObserver = Observer> { 50 | it?.let { galleryAdapter.replaceImages(it) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/gallery/GalleryAdapter.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.gallery 2 | 3 | import android.content.Context 4 | import android.support.v7.recyclerview.extensions.AsyncListDiffer 5 | import android.support.v7.util.DiffUtil 6 | import android.support.v7.widget.RecyclerView 7 | import android.view.ViewGroup 8 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT 9 | import android.widget.ImageView 10 | import com.bumptech.glide.Glide 11 | 12 | class GalleryAdapter(private val context: Context) : RecyclerView.Adapter() { 13 | private val differ = AsyncListDiffer(this, NewCallback()) 14 | 15 | init { 16 | setHasStableIds(true) 17 | } 18 | 19 | override fun getItemId(position: Int) = differ.currentList[position].hashCode().toLong() 20 | 21 | override fun getItemCount(): Int = differ.currentList.size 22 | 23 | 24 | fun replaceImages(images: List) { 25 | differ.submitList(images) 26 | } 27 | 28 | class NewCallback : DiffUtil.ItemCallback() { 29 | override fun areItemsTheSame(oldItem: String?, newItem: String?) = oldItem == newItem 30 | override fun areContentsTheSame(oldItem: String?, newItem: String?) = oldItem == newItem 31 | } 32 | 33 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 34 | ViewHolder(with(SquareImageView(context)) { 35 | layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, 0) 36 | scaleType = ImageView.ScaleType.CENTER_CROP 37 | this 38 | }) 39 | 40 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 41 | holder.bind(differ.currentList[position]) 42 | } 43 | 44 | inner class ViewHolder(private val imageView: ImageView) : RecyclerView.ViewHolder(imageView) { 45 | fun bind(image: String) { 46 | Glide.with(context).load(image).into(imageView) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/gallery/GalleryViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.gallery 2 | 3 | import android.arch.lifecycle.MutableLiveData 4 | import android.arch.lifecycle.ViewModel 5 | import io.github.gianpamx.android.architecture.usecase.GetFormUseCase 6 | import io.github.gianpamx.android.architecture.usecase.GetImagesUseCase 7 | import javax.inject.Inject 8 | 9 | class GalleryViewModel @Inject constructor( 10 | private val getFormUseCase: GetFormUseCase, 11 | private val getImagesUseCase: GetImagesUseCase) : ViewModel() { 12 | 13 | val name = MutableLiveData() 14 | val images = MutableLiveData>() 15 | 16 | init { 17 | this.images.value = emptyList() 18 | 19 | this.getFormUseCase.execute({ form -> 20 | name.postValue(form.name) 21 | }) 22 | 23 | this.getImagesUseCase.execute({ images -> 24 | this.images.postValue(images) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/gallery/SquareImageView.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.gallery 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.widget.ImageView 6 | 7 | class SquareImageView : ImageView { 8 | constructor(context: Context) : super(context) 9 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) 10 | constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) 11 | 12 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 13 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 14 | 15 | val width = measuredWidth 16 | setMeasuredDimension(width, width) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/providers/AndroidDateTimeProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.providers 2 | 3 | import android.os.Handler 4 | import java.util.* 5 | 6 | private const val ONE_SECOND: Long = 1000 7 | 8 | class AndroidDateTimeProvider : DateTimeProvider { 9 | private var handler: Handler? = null 10 | 11 | private var isTickerActive: Boolean = false 12 | 13 | private lateinit var listener: DateTimeProvider.Listener 14 | 15 | private val ticker = object : Runnable { 16 | override fun run() { 17 | if (!isTickerActive) { 18 | handler = null; 19 | return 20 | } 21 | 22 | handler?.postDelayed(this, ONE_SECOND) 23 | listener.onTick(Date()) 24 | } 25 | } 26 | 27 | override fun start(listener: DateTimeProvider.Listener) { 28 | this.listener = listener 29 | 30 | isTickerActive = true 31 | handler = Handler() 32 | handler?.postDelayed(ticker, 0) 33 | } 34 | 35 | override fun stop() { 36 | isTickerActive = false 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/providers/AppVersionProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.providers 2 | 3 | import android.content.Context 4 | 5 | class AppVersionProvider(val context: Context) : VersionProvider { 6 | override fun getVersion(): String = context.packageManager.getPackageInfo(context.getPackageName(), 0).versionName 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/providers/DateTimeProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.providers 2 | 3 | import java.util.* 4 | 5 | interface DateTimeProvider { 6 | fun start(listener: Listener) 7 | 8 | fun stop() 9 | 10 | interface Listener { 11 | fun onTick(date: Date) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/providers/VersionProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.providers 2 | 3 | interface VersionProvider { 4 | fun getVersion(): String 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/usecase/GetFormUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.usecase 2 | 3 | import io.github.gianpamx.android.architecture.data.FormGateway 4 | import io.github.gianpamx.android.architecture.entity.Form 5 | import kotlin.concurrent.thread 6 | 7 | interface GetFormUseCase { 8 | fun execute(success: ((Form) -> Unit)? = null, failure: ((Throwable) -> Unit)? = null); 9 | } 10 | 11 | class GetFormUseCaseImpl(private val formGateway: FormGateway) : GetFormUseCase { 12 | override fun execute(success: ((Form) -> Unit)?, failure: ((Throwable) -> Unit)?) { 13 | thread { 14 | try { 15 | executeSync()?.let { success?.invoke(it) } 16 | } catch (t: Throwable) { 17 | failure?.invoke(t) 18 | } 19 | } 20 | } 21 | 22 | internal fun executeSync() = formGateway.findForm() 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/usecase/GetImagesUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.usecase 2 | 3 | import io.github.gianpamx.android.architecture.data.ImagesGateway 4 | import kotlin.concurrent.thread 5 | 6 | interface GetImagesUseCase { 7 | fun execute(success: ((images: List) -> Unit)? = null, failure: ((Throwable) -> Unit)? = null) 8 | } 9 | 10 | class GetImagesUseCaseImpl(private val imagesGateway: ImagesGateway) : GetImagesUseCase { 11 | override fun execute(success: ((images: List) -> Unit)?, failure: ((Throwable) -> Unit)?) { 12 | thread { 13 | try { 14 | val images = executeSync() 15 | success?.invoke(images) 16 | } catch (t: Throwable) { 17 | failure?.invoke(t) 18 | } 19 | } 20 | } 21 | 22 | internal fun executeSync() = imagesGateway.getAlbum("aroSU") 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/usecase/SaveFormUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.usecase 2 | 3 | import io.github.gianpamx.android.architecture.data.FormGateway 4 | import io.github.gianpamx.android.architecture.entity.EmptyNameException 5 | import io.github.gianpamx.android.architecture.entity.EmptyPhoneException 6 | import io.github.gianpamx.android.architecture.entity.Form 7 | import kotlin.concurrent.thread 8 | 9 | interface SaveFormUseCase { 10 | fun execute(name: String?, phone: String?, success: (() -> Unit)? = null, failure: ((throwable: Throwable) -> Unit)? = null) 11 | } 12 | 13 | class SaveFormUseCaseImpl(private val formGateway: FormGateway) : SaveFormUseCase { 14 | override fun execute(name: String?, phone: String?, success: (() -> Unit)?, failure: ((throwable: Throwable) -> Unit)?) { 15 | thread { 16 | try { 17 | executeSync(name, phone) 18 | success?.invoke() 19 | } catch (t: Throwable) { 20 | failure?.invoke(t) 21 | } 22 | } 23 | } 24 | 25 | internal fun executeSync(name: String?, phone: String?) { 26 | if (name.isNullOrEmpty()) throw EmptyNameException() 27 | if (phone.isNullOrEmpty()) throw EmptyPhoneException() 28 | formGateway.persist(Form(name!!, phone!!)) // Safe due to previous check 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/gianpamx/android/architecture/usecase/UseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package io.github.gianpamx.android.architecture.usecase 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.github.gianpamx.android.architecture.data.FormGateway 6 | import io.github.gianpamx.android.architecture.data.ImagesGateway 7 | 8 | @Module 9 | class UseCaseModule { 10 | @Provides 11 | fun provideSaveFormUseCase(formGateway: FormGateway): SaveFormUseCase = SaveFormUseCaseImpl(formGateway) 12 | 13 | @Provides 14 | fun provideGetFormUseCase(formGateway: FormGateway): GetFormUseCase = GetFormUseCaseImpl(formGateway) 15 | 16 | @Provides 17 | fun provideGetImagesUseCase(imagesGateway: ImagesGateway): GetImagesUseCase = GetImagesUseCaseImpl(imagesGateway) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/form_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 23 | 24 | 28 | 29 | 36 | 37 | 41 | 42 | 48 | 49 | 50 | 54 | 55 | 61 | 62 | 63 |