├── settings.gradle ├── debug.jks ├── secrets ├── keystore.jks ├── keys.properties.crypted ├── download-graphql-schema.sh ├── decrypt-keys.sh └── google-services.json.crypted ├── app ├── gradle.properties ├── src │ ├── main │ │ ├── ic_launcher-web.png │ │ ├── res │ │ │ ├── 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 │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── menu │ │ │ │ └── menu_profile.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── layout │ │ │ │ ├── activity_profile.xml │ │ │ │ └── activity_login.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── graphql │ │ │ ├── com │ │ │ │ └── flatstack │ │ │ │ │ └── android │ │ │ │ │ └── graphql │ │ │ │ │ ├── fragment │ │ │ │ │ └── User.graphql │ │ │ │ │ ├── query │ │ │ │ │ └── GetUser.graphql │ │ │ │ │ └── mutation │ │ │ │ │ └── Signin.graphql │ │ │ └── .graphqlconfig │ │ ├── java │ │ │ └── com │ │ │ │ └── flatstack │ │ │ │ └── android │ │ │ │ ├── login │ │ │ │ ├── entities │ │ │ │ │ └── LoginRequest.kt │ │ │ │ ├── LoginMapper.kt │ │ │ │ ├── LoginViewModel.kt │ │ │ │ ├── LoginRepository.kt │ │ │ │ └── LoginActivity.kt │ │ │ │ ├── model │ │ │ │ ├── entities │ │ │ │ │ ├── Status.kt │ │ │ │ │ ├── Session.kt │ │ │ │ │ └── Resource.kt │ │ │ │ ├── db │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ └── daos │ │ │ │ │ │ ├── SessionDao.kt │ │ │ │ │ │ └── ProfileDao.kt │ │ │ │ └── network │ │ │ │ │ ├── NetworkBoundResource.kt │ │ │ │ │ └── errors │ │ │ │ │ └── ErrorHandler.kt │ │ │ │ ├── util │ │ │ │ ├── recyclerview │ │ │ │ │ ├── OnItemClickListener.kt │ │ │ │ │ └── BaseHolder.kt │ │ │ │ ├── StringResource.kt │ │ │ │ ├── ViewModelFactory.kt │ │ │ │ ├── storage │ │ │ │ │ ├── IStorage.kt │ │ │ │ │ └── Storage.kt │ │ │ │ ├── LiveDataExtensions.kt │ │ │ │ ├── Keyboard.kt │ │ │ │ └── KodeinViewModelExtensions.kt │ │ │ │ ├── profile │ │ │ │ ├── entities │ │ │ │ │ └── Profile.kt │ │ │ │ ├── ProfileMapper.kt │ │ │ │ ├── ProfileViewModel.kt │ │ │ │ ├── AuthorizationModel.kt │ │ │ │ ├── ProfileRepository.kt │ │ │ │ └── ProfileActivity.kt │ │ │ │ ├── App.kt │ │ │ │ ├── di │ │ │ │ ├── kodein.kt │ │ │ │ └── modules │ │ │ │ │ ├── repoModule.kt │ │ │ │ │ ├── appModule.kt │ │ │ │ │ ├── AuthorizationInterceptor.kt │ │ │ │ │ ├── netModule.kt │ │ │ │ │ ├── viewModelModule.kt │ │ │ │ │ └── dbModule.kt │ │ │ │ ├── Router.kt │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── flatstack │ │ │ └── android │ │ │ ├── test_utils │ │ │ └── InstantLiveDataExecutor.kt │ │ │ ├── ExampleUnitTest.kt │ │ │ ├── login │ │ │ ├── LoginMapperTest.kt │ │ │ └── LoginViewModelTest.kt │ │ │ ├── util │ │ │ └── StringResourceTest.kt │ │ │ ├── InstantExecutorExtension.kt │ │ │ ├── utils │ │ │ └── storage │ │ │ │ └── RuntimeStorage.kt │ │ │ └── profile │ │ │ └── ProfileViewModelTest.kt │ └── androidTest │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── flatstack │ │ └── android │ │ ├── ScreenshotActivityRule.java │ │ └── MainScreenTest.java ├── proguard-rules.pro └── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── LICENSE ├── .travis.yml ├── gradle.properties ├── .gitignore ├── gradlew.bat ├── deps.gradle ├── .circleci └── config.yml ├── README.md ├── gradlew └── default-detekt-config.yml /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /debug.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/debug.jks -------------------------------------------------------------------------------- /secrets/keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/secrets/keystore.jks -------------------------------------------------------------------------------- /app/gradle.properties: -------------------------------------------------------------------------------- 1 | #Wed May 15 11:26:23 MSK 2019 2 | appVersionCode=1 3 | appVersionName=1.0.0 4 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /secrets/keys.properties.crypted: -------------------------------------------------------------------------------- 1 | 1URZu8Bqu4x/8p65q98WMbYopXLv7/w5qs5iSeo/bHd0Bb7k1PIART6TCTHsDZFU 2 | M+BXbPK5/syQk+F86qkbZA== 3 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/graphql/com/flatstack/android/graphql/fragment/User.graphql: -------------------------------------------------------------------------------- 1 | fragment UserGqlFragment on User { 2 | firstName 3 | lastName 4 | } 5 | -------------------------------------------------------------------------------- /app/src/main/graphql/com/flatstack/android/graphql/query/GetUser.graphql: -------------------------------------------------------------------------------- 1 | query GetUser { 2 | me { 3 | ...UserGqlFragment 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fs/android-base/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/login/entities/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login.entities 2 | 3 | data class LoginRequest(val username: String, val password: String) 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/entities/Status.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.entities 2 | 3 | enum class Status { 4 | SUCCESS, 5 | ERROR, 6 | LOADING 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/recyclerview/OnItemClickListener.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util.recyclerview 2 | 3 | interface OnItemClickListener { 4 | fun onItemClick(item: T) 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/graphql/com/flatstack/android/graphql/mutation/Signin.graphql: -------------------------------------------------------------------------------- 1 | mutation Login($email: String!, $password: String!) { 2 | signin(email: $email, password: $password) { 3 | accessToken 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /secrets/download-graphql-schema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ./gradlew downloadApolloSchema -Pcom.apollographql.apollo.endpoint=https://rails-base-graphql-api.herokuapp.com/graphql -Pcom.apollographql.apollo.schema=src/main/graphql/com/flatstack/android/schema.json 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jul 17 16:57:37 MSK 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/StringResource.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | 6 | class StringResource(private val context: Context) { 7 | fun getString(@StringRes stringRes: Int) = context.getString(stringRes) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/login/LoginMapper.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login 2 | 3 | import com.flatstack.android.graphql.mutation.LoginMutation 4 | import com.flatstack.android.model.entities.Session 5 | 6 | class LoginMapper { 7 | fun mapLogin(signIn: LoginMutation.Signin?) = Session(accessToken = signIn?.accessToken ?: "") 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_profile.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/entities/Session.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.entities 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "session") 7 | data class Session( 8 | val accessToken: String, 9 | @PrimaryKey(autoGenerate = true) 10 | val id: Int = 0 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/entities/Profile.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile.entities 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "profile") 7 | data class Profile( 8 | val firstName: String, 9 | val lastName: String, 10 | @PrimaryKey(autoGenerate = true) 11 | val id: Int = 0 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/graphql/.graphqlconfig: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GraphQL Schema", 3 | "extensions": { 4 | "endpoints": { 5 | "Base": { 6 | "url": "https://rails-base-graphql-api.herokuapp.com/graphql", 7 | "schemaPath": "com/flatstack/android/schema.json", 8 | "headers": { 9 | "user-agent": "JS GraphQL" 10 | }, 11 | "introspect": false 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/recyclerview/BaseHolder.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util.recyclerview 2 | 3 | import android.view.View 4 | import androidx.recyclerview.widget.RecyclerView 5 | import kotlinx.android.extensions.LayoutContainer 6 | 7 | abstract class BaseHolder( 8 | override val containerView: View 9 | ) : RecyclerView.ViewHolder(containerView), LayoutContainer { 10 | internal abstract fun bind(item: T) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/App.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android 2 | 3 | import android.app.Application 4 | import com.flatstack.android.di.initKodein 5 | import com.google.firebase.FirebaseApp 6 | import org.kodein.di.KodeinAware 7 | 8 | class App : Application(), KodeinAware { 9 | override val kodein = initKodein(this) 10 | 11 | override fun onCreate() { 12 | super.onCreate() 13 | FirebaseApp.initializeApp(this) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /secrets/decrypt-keys.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -z "$SECRET_ANDROID_BASE" ]; then 3 | echo 'You must provide secret key $SECRET_ANDROID_BASE' >&2 4 | exit 2 5 | else 6 | openssl aes-256-cbc -d -md sha256 -nosalt -a -pass pass:$SECRET_ANDROID_BASE -in secrets/keys.properties.crypted -out secrets/keys.properties 7 | openssl aes-256-cbc -d -md sha256 -nosalt -a -pass pass:$SECRET_ANDROID_BASE -in secrets/google-services.json.crypted -out app/google-services.json 8 | fi 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/ProfileMapper.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import com.flatstack.android.graphql.query.GetUserQuery 4 | import com.flatstack.android.profile.entities.Profile 5 | 6 | object ProfileMapper { 7 | fun mapProfile(me: GetUserQuery.Me?) = me?.fragments?.userGqlFragment.run { 8 | Profile( 9 | firstName = this?.firstName ?: "", 10 | lastName = this?.lastName ?: "" 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/test_utils/InstantLiveDataExecutor.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.test_utils 2 | 3 | import androidx.arch.core.executor.TaskExecutor 4 | 5 | object InstantLiveDataExecutor : TaskExecutor() { 6 | override fun executeOnDiskIO(runnable: Runnable) { 7 | runnable.run() 8 | } 9 | 10 | override fun postToMainThread(runnable: Runnable) { 11 | runnable.run() 12 | } 13 | 14 | override fun isMainThread(): Boolean = true 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/kodein.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di 2 | 3 | import android.app.Application 4 | import com.flatstack.android.di.modules.* 5 | import org.kodein.di.Kodein 6 | import org.kodein.di.android.x.androidXModule 7 | 8 | fun initKodein(app: Application) = 9 | Kodein.lazy { 10 | import(androidXModule(app)) 11 | import(dbModule) 12 | import(appModule) 13 | import(viewModelModule) 14 | import(netModule) 15 | import(repoModule) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import org.kodein.di.DKodein 6 | import org.kodein.di.generic.instanceOrNull 7 | 8 | class ViewModelFactory(private val injector: DKodein) : ViewModelProvider.Factory { 9 | 10 | @Suppress("UNCHECKED_CAST") 11 | override fun create(modelClass: Class): T = 12 | injector.instanceOrNull(tag = modelClass.simpleName) as T? ?: modelClass.newInstance() 13 | } 14 | -------------------------------------------------------------------------------- /app/src/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/repoModule.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import com.flatstack.android.login.LoginRepository 4 | import com.flatstack.android.profile.ProfileRepository 5 | import org.kodein.di.Kodein 6 | import org.kodein.di.generic.bind 7 | import org.kodein.di.generic.instance 8 | import org.kodein.di.generic.provider 9 | 10 | val repoModule = Kodein.Module(name = "repoModule") { 11 | bind() with provider { LoginRepository(instance(), instance(), instance(), instance()) } 12 | bind() with provider { ProfileRepository(instance(), instance(), instance()) } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.flatstack.android.model.db.daos.ProfileDao 6 | import com.flatstack.android.model.db.daos.SessionDao 7 | import com.flatstack.android.model.entities.Session 8 | import com.flatstack.android.profile.entities.Profile 9 | 10 | @Database( 11 | entities = [Profile::class, Session::class], 12 | version = 1, 13 | exportSchema = false 14 | ) 15 | abstract class AppDatabase : RoomDatabase() { 16 | abstract fun profileDao(): ProfileDao 17 | abstract fun sessionDao(): SessionDao 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/entities/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.entities 2 | 3 | import com.flatstack.android.model.entities.Status.* 4 | 5 | data class Resource(val status: Status, val data: T?, val error: String?) { 6 | companion object { 7 | fun success(data: T? = null): Resource { 8 | return Resource(SUCCESS, data, null) 9 | } 10 | 11 | fun error(error: String, data: T? = null): Resource { 12 | return Resource(ERROR, data, error) 13 | } 14 | 15 | fun loading(data: T? = null): Resource { 16 | return Resource(LOADING, data, null) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | import org.junit.jupiter.api.Assertions.assertEquals 7 | import org.junit.jupiter.api.extension.ExtendWith 8 | import org.spekframework.spek2.Spek 9 | import org.spekframework.spek2.style.specification.describe 10 | 11 | @ExtendWith(InstantExecutorExtension::class) 12 | class ExampleUnitTest : Spek({ 13 | describe("Example test") { 14 | it("should return 5") { 15 | val mockInt = mockk>() 16 | every { mockInt.value } returns 5 17 | assertEquals(5, mockInt.value) 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/login/LoginMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login 2 | 3 | import com.flatstack.android.graphql.mutation.LoginMutation 4 | import com.flatstack.android.model.entities.Session 5 | import org.junit.Test 6 | import org.junit.jupiter.api.Assertions.assertEquals 7 | 8 | internal class LoginMapperTest { 9 | @Test 10 | fun mapLogin() { 11 | val expectedAccessToken = "access_token" 12 | val expectedSignin = LoginMutation.Signin(accessToken = expectedAccessToken) 13 | val expectedSession = Session(accessToken = expectedAccessToken) 14 | 15 | val actualSession = LoginMapper().mapLogin(expectedSignin) 16 | 17 | assertEquals(actualSession, expectedSession) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/appModule.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import com.flatstack.android.Router 4 | import com.flatstack.android.login.LoginMapper 5 | import com.flatstack.android.model.network.errors.ErrorHandler 6 | import com.flatstack.android.util.StringResource 7 | import org.kodein.di.Kodein 8 | import org.kodein.di.generic.bind 9 | import org.kodein.di.generic.instance 10 | import org.kodein.di.generic.singleton 11 | 12 | val appModule = Kodein.Module(name = "appModule") { 13 | bind() with singleton { StringResource(instance()) } 14 | bind() with singleton { ErrorHandler(instance(), instance(), instance()) } 15 | bind() with singleton { Router(instance()) } 16 | bind() with singleton { LoginMapper() } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/util/StringResourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import android.content.Context 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | import org.junit.Assert 7 | import org.spekframework.spek2.Spek 8 | 9 | object StringResourceTest: Spek({ 10 | test("getString") { 11 | // Arrange 12 | val expectedStringResource = 0 13 | val expectedString = "super string" 14 | val mockContext = mockk() 15 | every { mockContext.getString(expectedStringResource) } returns expectedString 16 | val stringResource = StringResource(mockContext) 17 | 18 | // Act 19 | val actualString = stringResource.getString(expectedStringResource) 20 | 21 | // Assert 22 | Assert.assertEquals(expectedString, actualString) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/ProfileViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.ViewModel 5 | import com.flatstack.android.login.LoginRepository 6 | 7 | class ProfileViewModel( 8 | private val profileRepository: ProfileRepository, 9 | private val loginRepository: LoginRepository 10 | ) : ViewModel() { 11 | 12 | @VisibleForTesting 13 | val profileBoundResource = profileRepository.loadProfile() 14 | val profileResponse = profileBoundResource.asLiveData() 15 | 16 | override fun onCleared() { 17 | profileRepository.onDestroy() 18 | loginRepository.onDestroy() 19 | } 20 | 21 | fun updateProfile() { 22 | profileBoundResource.fetchFromNetwork() 23 | } 24 | 25 | fun logout() { 26 | loginRepository.unAuthorize() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/AuthorizationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import com.flatstack.android.profile.AuthorizationModel 4 | import kotlinx.coroutines.runBlocking 5 | import okhttp3.Interceptor 6 | import okhttp3.Response 7 | 8 | class AuthorizationInterceptor(private val authorizationModel: AuthorizationModel) : Interceptor { 9 | 10 | override fun intercept(chain: Interceptor.Chain): Response { 11 | var request = chain.request() 12 | 13 | request = request.newBuilder() 14 | .addHeader(AUTH_HEADER, getAccessToken()) 15 | .build() 16 | 17 | return chain.proceed(request) 18 | } 19 | 20 | private fun getAccessToken(): String = runBlocking { "Bearer ${authorizationModel.getToken()}" } 21 | 22 | companion object { 23 | const val AUTH_HEADER: String = "Authorization" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/netModule.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import com.apollographql.apollo.ApolloClient 4 | import com.flatstack.android.BuildConfig 5 | import okhttp3.Interceptor 6 | import okhttp3.OkHttpClient 7 | import org.kodein.di.Kodein 8 | import org.kodein.di.generic.bind 9 | import org.kodein.di.generic.instance 10 | import org.kodein.di.generic.singleton 11 | 12 | val netModule = Kodein.Module(name = "netModule") { 13 | bind() with singleton { AuthorizationInterceptor(instance()) } 14 | 15 | bind() with singleton { OkHttpClient.Builder() 16 | .addInterceptor(instance()) 17 | .build() } 18 | 19 | bind() with singleton { 20 | ApolloClient.builder() 21 | .serverUrl(BuildConfig.BASE_URL) 22 | .okHttpClient(instance()) 23 | .build() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/AuthorizationModel.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import androidx.room.withTransaction 4 | import com.flatstack.android.model.db.AppDatabase 5 | import com.flatstack.android.model.db.daos.SessionDao 6 | import com.flatstack.android.model.entities.Session 7 | 8 | class AuthorizationModel( 9 | private val sessionDao: SessionDao, 10 | private val appDatabase: AppDatabase 11 | ) { 12 | suspend fun unAuthorize() { 13 | appDatabase.withTransaction { 14 | appDatabase.clearAllTables() 15 | } 16 | } 17 | 18 | suspend fun getToken(): String = sessionDao.getToken() 19 | 20 | suspend fun setSession(session: Session) { 21 | sessionDao.insert(session) 22 | } 23 | 24 | suspend fun getSession() = sessionDao.getSession() 25 | 26 | suspend fun isAuthorized(): Boolean = getToken().isNotBlank() 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/db/daos/SessionDao.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.db.daos 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy.REPLACE 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import com.flatstack.android.model.entities.Session 9 | 10 | @Dao 11 | abstract class SessionDao { 12 | @Insert(onConflict = REPLACE) 13 | abstract suspend fun insertSession(vararg session: Session) 14 | 15 | @Query("SELECT * FROM session LIMIT 1") 16 | abstract suspend fun getSession(): Session? 17 | 18 | @Query("DELETE FROM session") 19 | abstract suspend fun clearSession() 20 | 21 | @Transaction 22 | open suspend fun insert(session: Session) { 23 | clearSession() 24 | insertSession(session) 25 | } 26 | 27 | suspend fun getToken(): String = getSession()?.accessToken ?: "" 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/viewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import com.flatstack.android.login.LoginViewModel 5 | import com.flatstack.android.profile.ProfileViewModel 6 | import com.flatstack.android.util.ViewModelFactory 7 | import com.flatstack.android.util.bindViewModel 8 | import org.kodein.di.Kodein 9 | import org.kodein.di.generic.bind 10 | import org.kodein.di.generic.instance 11 | import org.kodein.di.generic.provider 12 | import org.kodein.di.generic.singleton 13 | 14 | val viewModelModule = Kodein.Module(name = "viewModelModule") { 15 | bind() with singleton { ViewModelFactory(dkodein) } 16 | 17 | bindViewModel() with provider { LoginViewModel(instance(), instance()) } 18 | bindViewModel() with provider { ProfileViewModel(instance(), instance()) } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/db/daos/ProfileDao.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.db.daos 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy.REPLACE 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import com.flatstack.android.profile.entities.Profile 9 | 10 | @Dao 11 | abstract class ProfileDao { 12 | @Insert(onConflict = REPLACE) 13 | abstract suspend fun insertProfile(vararg profile: Profile) 14 | 15 | @Query("SELECT * FROM profile LIMIT 1") 16 | abstract suspend fun getProfile(): Profile? 17 | 18 | @Query("DELETE FROM profile") 19 | abstract suspend fun clearProfile() 20 | 21 | @Transaction 22 | open suspend fun insertUserProfile(profile: Profile) { 23 | clear() 24 | insertProfile(profile) 25 | } 26 | 27 | @Transaction 28 | open suspend fun clear() { 29 | clearProfile() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/storage/IStorage.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util.storage 2 | 3 | import java.lang.reflect.Type 4 | 5 | @Suppress("TooManyFunctions") 6 | interface IStorage { 7 | 8 | operator fun get(key: String, type: Type): T? 9 | 10 | fun put(key: String, items: Any) 11 | 12 | fun putString(key: String, str: String) 13 | 14 | fun getString(key: String): String? 15 | 16 | fun putLong(key: String, number: Long) 17 | 18 | fun getLong(key: String, defaultValue: Long): Long 19 | 20 | fun putInt(key: String, number: Int) 21 | 22 | fun getInt(key: String, defaultValue: Int): Int 23 | 24 | fun putBoolean(key: String, value: Boolean) 25 | 26 | fun getBoolean(key: String, defaultValue: Boolean): Boolean 27 | 28 | fun remove(key: String) 29 | 30 | fun putCollection(key: String, items: List) 31 | 32 | fun getCollection(key: String, type: Type): List? 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/Router.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.flatstack.android.login.LoginActivity 6 | import com.flatstack.android.profile.ProfileActivity 7 | 8 | class Router( 9 | private val appContext: Context 10 | ) { 11 | fun login(context: Context = appContext, clearStack: Boolean = false) { 12 | context.startActivity(Intent(context, LoginActivity::class.java).apply { 13 | if (clearStack) { 14 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 15 | } 16 | }) 17 | } 18 | 19 | fun profile(context: Context, clearStack: Boolean = false) { 20 | context.startActivity(Intent(context, ProfileActivity::class.java).apply { 21 | if (clearStack) { 22 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 23 | } 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.flatstack.android.profile.AuthorizationModel 6 | import kotlinx.coroutines.runBlocking 7 | import org.kodein.di.KodeinAware 8 | import org.kodein.di.android.kodein 9 | import org.kodein.di.generic.instance 10 | 11 | class MainActivity : AppCompatActivity(), KodeinAware { 12 | 13 | override val kodein by kodein() 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | val router by kodein.instance() 18 | val authorizationModel by kodein.instance() 19 | 20 | runBlocking { 21 | if (authorizationModel.isAuthorized()) { 22 | router.profile(this@MainActivity) 23 | } else { 24 | router.login(this@MainActivity) 25 | } 26 | } 27 | finish() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Android Base 3 | Login 4 | Email address 5 | John 6 | Snow 7 | Password 8 | Username and Password should not be empty 9 | Logout 10 | 11 | 12 | Unknown error 13 | 304 Not Modified 14 | 400 Bad Request 15 | Unauthorized 16 | 403 Forbidden 17 | 404 Not Found 18 | 405 Method Not Allowed 19 | 409 Conflict 20 | 500 Server Error 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Flatstack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/InstantExecutorExtension.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android 2 | 3 | import androidx.arch.core.executor.ArchTaskExecutor 4 | import androidx.arch.core.executor.TaskExecutor 5 | import org.junit.jupiter.api.extension.AfterEachCallback 6 | import org.junit.jupiter.api.extension.BeforeEachCallback 7 | import org.junit.jupiter.api.extension.ExtensionContext 8 | 9 | class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { 10 | 11 | override fun beforeEach(context: ExtensionContext?) { 12 | ArchTaskExecutor.getInstance() 13 | .setDelegate(object : TaskExecutor() { 14 | override fun executeOnDiskIO(runnable: Runnable) { 15 | runnable.run() 16 | } 17 | 18 | override fun postToMainThread(runnable: Runnable) { 19 | runnable.run() 20 | } 21 | 22 | override fun isMainThread(): Boolean = true 23 | }) 24 | } 25 | 26 | override fun afterEach(context: ExtensionContext?) { 27 | ArchTaskExecutor.getInstance().setDelegate(null) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/LiveDataExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | import com.flatstack.android.model.entities.Resource 7 | import com.flatstack.android.model.entities.Status 8 | 9 | fun LiveData>.observeBy( 10 | owner: LifecycleOwner, 11 | onNext: (T) -> Unit = {}, 12 | onError: (String) -> Unit = {}, 13 | onLoading: (Boolean) -> Unit = {}, 14 | onSuccess: () -> Unit = {} 15 | ) { 16 | observe(owner, Observer { 17 | when (it.status) { 18 | Status.LOADING -> onLoading(true) 19 | Status.SUCCESS -> { 20 | onLoading(false) 21 | onSuccess() 22 | } 23 | Status.ERROR -> { 24 | onLoading(false) 25 | it.error?.let(onError) 26 | } 27 | } 28 | it.data?.let(onNext) 29 | }) 30 | } 31 | 32 | fun T.toLiveData() = object : LiveData() { 33 | init { 34 | postValue(this@toLiveData) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/di/modules/dbModule.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.di.modules 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.flatstack.android.model.db.AppDatabase 6 | import com.flatstack.android.model.db.daos.ProfileDao 7 | import com.flatstack.android.model.db.daos.SessionDao 8 | import com.flatstack.android.profile.AuthorizationModel 9 | import org.kodein.di.Kodein 10 | import org.kodein.di.generic.bind 11 | import org.kodein.di.generic.instance 12 | import org.kodein.di.generic.singleton 13 | 14 | val dbModule = Kodein.Module(name = "dbModule") { 15 | bind() with singleton { provideDatabase(instance()) } 16 | 17 | bind() with singleton { instance().profileDao() } 18 | bind() with singleton { instance().sessionDao() } 19 | 20 | bind() with singleton { AuthorizationModel(instance(), instance()) } 21 | } 22 | 23 | private fun provideDatabase(context: Context): AppDatabase = 24 | Room.databaseBuilder(context, AppDatabase::class.java, "AppDatabase") 25 | .fallbackToDestructiveMigration() 26 | .build() 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | jdk: oraclejdk8 3 | sudo: false 4 | env: 5 | - GRADLE_OPTS='-Dorg.gradle.jvmargs="-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError"' 6 | 7 | machine: 8 | environment: 9 | GRADLE_OPTS: '-Dorg.gradle.jvmargs="-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError"' 10 | 11 | android: 12 | components: 13 | - tools 14 | - platform-tools 15 | - build-tools-28.0.3 16 | - android-28 17 | - extra-android-m2repository 18 | # Uncomment this if you need emulator 19 | # - sys-img-x86-android-23 20 | licenses: 21 | - 'android-sdk-license-.+' 22 | 23 | before_install: 24 | - export TERM=dumb 25 | - chmod +x gradlew 26 | 27 | # Uncomment this if you need emulator 28 | before_script: 29 | - sh secrets/decrypt-keys.sh 30 | # - echo no | android create avd --force -n test -t android-21 --abi x86 31 | # - emulator -avd test -no-skin -no-audio -no-window & 32 | # - android-wait-for-emulator 33 | # - adb shell input keyevent 82 & 34 | 35 | script: 36 | - ./gradlew detekt lintRelease testReleaseUnitTest 37 | 38 | branches: 39 | only: 40 | - master 41 | - production 42 | 43 | # after_success: uncomment this and add your own deployment targets -------------------------------------------------------------------------------- /app/src/androidTest/java/com/flatstack/android/ScreenshotActivityRule.java: -------------------------------------------------------------------------------- 1 | package com.flatstack.android; 2 | 3 | import android.app.Activity; 4 | import android.content.Context; 5 | import android.support.test.InstrumentationRegistry; 6 | import android.support.test.espresso.Espresso; 7 | import android.support.test.espresso.base.DefaultFailureHandler; 8 | import android.support.test.espresso.intent.rule.IntentsTestRule; 9 | 10 | import org.junit.runner.Description; 11 | import org.junit.runners.model.Statement; 12 | 13 | public class ScreenshotActivityRule extends IntentsTestRule { 14 | 15 | public ScreenshotActivityRule(Class activityClass) { 16 | super(activityClass); 17 | } 18 | 19 | @Override 20 | public Statement apply(Statement base, Description description) { 21 | String testClassName = description.getClassName(); 22 | String testMethodName = description.getMethodName(); 23 | Context context = InstrumentationRegistry.getTargetContext(); 24 | Espresso.setFailureHandler((error, matcher) -> { 25 | new DefaultFailureHandler(context).handle(error, matcher); 26 | }); 27 | return super.apply(base, description); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/Keyboard.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.view.View 6 | import android.view.inputmethod.InputMethodManager 7 | 8 | object Keyboard { 9 | 10 | fun hide(activity: Activity) { 11 | activity.currentFocus?.let { 12 | inputMethodManager(activity) 13 | ?.hideSoftInputFromWindow(it.windowToken, 0) 14 | } 15 | } 16 | 17 | fun hide(view: View) { 18 | val inputMethod = inputMethodManager(view.context) 19 | inputMethod?.let { 20 | if (it.isActive) { 21 | it.hideSoftInputFromWindow(view.windowToken, 0) 22 | } 23 | } 24 | } 25 | 26 | fun show(context: Context) { 27 | inputMethodManager(context) 28 | ?.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) 29 | } 30 | 31 | fun show(view: View) { 32 | inputMethodManager(view.context) 33 | ?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) 34 | } 35 | 36 | private fun inputMethodManager(context: Context) = 37 | context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager 38 | } 39 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx1536m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | 19 | # AndroidX package structure to make it clearer which packages are bundled with the 20 | # Android operating system, and which are packaged with your app's APK 21 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 22 | android.useAndroidX=true 23 | # Automatically convert third-party libraries to use AndroidX 24 | android.enableJetifier=true 25 | 26 | # Kotlin code style for this project: "official" or "obsolete": 27 | kotlin.code.style=official 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # IntelliJ 36 | *.iml 37 | .idea/ 38 | 39 | # Keystore files 40 | # Uncomment the following line if you do not want to check your keystore files in. 41 | #*.jks 42 | 43 | # External native build folder generated in Android Studio 2.2 and later 44 | .externalNativeBuild 45 | 46 | # Google Services (e.g. APIs or Firebase) 47 | google-services.json 48 | 49 | # Freeline 50 | freeline.py 51 | freeline/ 52 | freeline_project_description.json 53 | 54 | # fastlane 55 | fastlane/report.xml 56 | fastlane/Preview.html 57 | fastlane/screenshots 58 | fastlane/test_output 59 | fastlane/readme.md 60 | 61 | # Secret keys 62 | secrets/keys.properties 63 | 64 | # GraphQL schema and Apollo generated schema file 65 | schema.json 66 | schema.json.graphql 67 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 17 | 20 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/KodeinViewModelExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentActivity 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProviders 7 | import org.kodein.di.Kodein 8 | import org.kodein.di.KodeinAware 9 | import org.kodein.di.direct 10 | import org.kodein.di.generic.bind 11 | import org.kodein.di.generic.instance 12 | 13 | inline fun T.provideViewModel(): Lazy where T : KodeinAware, T : FragmentActivity { 14 | return lazy { ViewModelProviders.of(this, direct.instance()).get(VM::class.java) } 15 | } 16 | 17 | inline fun T.provideViewModel(): Lazy where T : KodeinAware, T : Fragment { 18 | return lazy { ViewModelProviders.of(this, direct.instance()).get(VM::class.java) } 19 | } 20 | 21 | inline fun T.provideViewModelWithActivity(): Lazy where T : KodeinAware, T : Fragment { 22 | return lazy { 23 | activity?.let { 24 | ViewModelProviders.of(it, direct.instance()).get(VM::class.java) 25 | } ?: let { 26 | ViewModelProviders.of(this, direct.instance()).get(VM::class.java) 27 | } 28 | } 29 | } 30 | 31 | inline fun Kodein.Builder.bindViewModel( 32 | overrides: Boolean? = null 33 | ): Kodein.Builder.TypeBinder { 34 | return bind(T::class.java.simpleName, overrides) 35 | } 36 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/flatstack/android/MainScreenTest.java: -------------------------------------------------------------------------------- 1 | package com.flatstack.android; 2 | 3 | import android.support.test.runner.AndroidJUnit4; 4 | 5 | import org.junit.Rule; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | 9 | import static android.support.test.espresso.Espresso.onView; 10 | import static android.support.test.espresso.action.ViewActions.click; 11 | import static android.support.test.espresso.assertion.ViewAssertions.matches; 12 | import static android.support.test.espresso.intent.Intents.intended; 13 | import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; 14 | import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; 15 | import static android.support.test.espresso.matcher.ViewMatchers.withId; 16 | import static android.support.test.espresso.matcher.ViewMatchers.withText; 17 | 18 | @RunWith(AndroidJUnit4.class) 19 | public class MainScreenTest { 20 | 21 | @Rule 22 | public ScreenshotActivityRule testRule = 23 | new ScreenshotActivityRule<>(MainActivity.class); 24 | 25 | @Test 26 | public void whenAppLaunch_androidBaseVisible() throws Exception { 27 | onView(allOf(withId(R.id.title), withText(R.string.app_name))) 28 | .check(matches(isDisplayed())); 29 | } 30 | 31 | @Test 32 | public void whenButtonClick_startedActivity() { 33 | onView(withId(R.id.button)).perform(click()); 34 | intended(hasComponent(SecondActivity.class.getName())); 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/ProfileRepository.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import com.apollographql.apollo.ApolloClient 4 | import com.apollographql.apollo.api.Response 5 | import com.apollographql.apollo.coroutines.toDeferred 6 | import com.flatstack.android.graphql.query.GetUserQuery 7 | import com.flatstack.android.model.db.daos.ProfileDao 8 | import com.flatstack.android.model.network.NetworkBoundResource 9 | import com.flatstack.android.model.network.errors.ErrorHandler 10 | import com.flatstack.android.profile.entities.Profile 11 | import kotlinx.coroutines.* 12 | 13 | class ProfileRepository( 14 | private val apolloClient: ApolloClient, 15 | private val profileDao: ProfileDao, 16 | private val errorHandler: ErrorHandler 17 | ) : CoroutineScope { 18 | override val coroutineContext = SupervisorJob() + Dispatchers.IO 19 | 20 | fun loadProfile() = 21 | object : NetworkBoundResource(coroutineContext, errorHandler) { 22 | override suspend fun createCallAsync(): Deferred> = 23 | apolloClient.query(GetUserQuery()).toDeferred() 24 | 25 | override suspend fun saveCallResult(item: GetUserQuery.Data?) { 26 | profileDao.insertUserProfile(ProfileMapper.mapProfile(item?.me)) 27 | } 28 | 29 | override suspend fun loadFromDb(): Profile? = profileDao.getProfile() 30 | } 31 | 32 | fun onDestroy() { 33 | coroutineContext.cancelChildren() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /secrets/google-services.json.crypted: -------------------------------------------------------------------------------- 1 | qxg8PpM6YTpqqtxz5C4JkpMSXVm6I5dkqyjbRaD9pFYVKuUNgTS8bMSYDZANZBcL 2 | 0lQucmEoFBuOzAISVqV0IHl0/ksiTBfodFS62eeCE9N6vHyRodWy2icx5wNpsjho 3 | 9xOBWe1hEgxPQLrPw6kPGQyLRs4x4lLApCxuB1XDephpHVU4l83JIJHxkrcdtByj 4 | lEO38Ia0wtqL2WNSG8l4bcYYbDHrUK7YvTiStOjK3Eq2XQWnE7r4wEzVd4AEcbLF 5 | yBgSuJ9F9Paofbw0I5B4foW253t7RQR5aRunAuBdjQttHtlk3kCdPkm0Iq/1f1qI 6 | PInFHgpxdlgTUp9jL6k83/Fuhpu4AlG8Fcuq7arvtC9IWdRhB4TYmosBdo3d0tld 7 | i67WdfByW29z2g1xfd29WeXLE5dS/TZp9X7jl0tdALCQJkL8b1MVPIkPj94YzWu5 8 | iAvvT+lHLHtetOK+XqSqBF8/491DZkDFYk1zIHm8M0LEc4J5/YUWYXFBf1oGQBrO 9 | qVc8S2qpXf88Eo5U7UgD18rKxbErQesRm73JaS1vRah4lLJKDjn3Ov9561KeI1XC 10 | 75xmlx4gzWahAhTKLPj2h/RLMEvPH6HXrzBxYzJtA9nlybEw8fu1Z5KSRSCx8TzS 11 | wFETMkM6tNgtMDfRUwdlrqoux2GO2ISd4W02Fh5m7NldlhZJaNeqwkyvI70nTwiI 12 | d89gCCl+sQWoxWCvBGXryfis3c17p1dQC+5xjAD/1YOp1ATK6LwcekSSpKeIC++j 13 | RM8V4K1LH2OAIwPRh/RLmVUioMl6k4chTfKUVljcLIiYwFhHqFQkDDuMz3epq6Ub 14 | agw8YMalQl5misPj837CEeIavurfoHeVwMUnt/a0bDfGud0O04fhyy3exq44vwTj 15 | 4X2Z7Ty/XrpRJVh7mgebWFiuQPWD1quXolBXAFp9xjZ3cvZC2irPZBxGdJjnMkRS 16 | 4dyKLOnzmyG8VV58loPVXk44vSZG0P1V+8Hqw6npLMtvHhQbNX9NKIFm7rd/hAzF 17 | 3oYJiDdq7jwQvtjZqT1ryvG3WRfJXY7PKUxTO99gma7Sn5haNSE4tJzj7MCzF7Wy 18 | 7wSU+bOn+NCkmsS5d64mlbFggn6x6Of1Jw2mM0d6sv/Qgce2ppVpivLf8UxF7kqj 19 | Aih0+aAc0ni825k0b4Bf+JyCfYQbO0KNk5gb0Y3h0i5rGjiNigbbJpPw3RpbNPsn 20 | Y2bb1fUpruDAZJ+JcUNJf1Bd7cVwgOUHMJrfF8d+HtBI4PZDpvZA7+HzlMY/qi3l 21 | QApAnKqUW1whI3mlS3ZItqjx1M4mEe35ANob+bZA1KUvui2wiVWlvJtbSRQAtIax 22 | 2b9n80AduJ3a9SwYUlGwYolcLfsdGKuq0wZb1MHQDeWlnZ4py7knyEj2DPmuLtcR 23 | 1GciGGxs4El68M/Ww0Gv6rZawwoFfEeEpafzYjuXqa4= 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.Transformations 7 | import androidx.lifecycle.ViewModel 8 | import com.flatstack.android.R 9 | import com.flatstack.android.model.entities.Resource 10 | import com.flatstack.android.model.entities.Session 11 | import com.flatstack.android.util.StringResource 12 | import com.flatstack.android.util.toLiveData 13 | 14 | class LoginViewModel( 15 | private val loginRepository: LoginRepository, 16 | private val stringResource: StringResource 17 | ) : ViewModel() { 18 | 19 | private val login = MutableLiveData() 20 | 21 | val loginResource: LiveData> = Transformations.switchMap(login) { 22 | it.ifExists(exist = { username, password -> 23 | loginRepository.login(username, password) 24 | }, empty = { 25 | emptyLoginResource() 26 | }) 27 | } 28 | 29 | override fun onCleared() { 30 | loginRepository.onDestroy() 31 | } 32 | 33 | fun login(username: String, password: String) { 34 | login.postValue(LoginId(username, password)) 35 | } 36 | 37 | @VisibleForTesting 38 | fun emptyLoginResource() = 39 | Resource.error(stringResource.getString(R.string.empty_error)).toLiveData() 40 | 41 | data class LoginId( 42 | val username: String, 43 | val password: String 44 | ) { 45 | fun ifExists(exist: (String, String) -> LiveData, empty: () -> LiveData): LiveData = 46 | if (username.isBlank() || password.isBlank()) { 47 | empty() 48 | } else { 49 | exist(username, password) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | #your models 2 | #if you store all model under one package then use: 3 | # -keep class your.package.name.models.** { *; } 4 | #if you store models under different models packages then use: 5 | #-keep class **.models.** { *; } 6 | 7 | #retrolambda 8 | -dontwarn java.lang.invoke.* 9 | 10 | #butterknife 11 | -keep class butterknife.** { *; } 12 | -dontwarn butterknife.internal.** 13 | -keep class **$$ViewBinder { *; } 14 | -keepclasseswithmembernames class * { 15 | @butterknife.* ; 16 | } 17 | -keepclasseswithmembernames class * { 18 | @butterknife.* ; 19 | } 20 | 21 | #picasso 22 | -dontwarn com.squareup.okhttp3.** 23 | 24 | # OkHttp 25 | -keepattributes Signature 26 | -keepattributes *Annotation* 27 | -keep class com.squareup.okhttp3.** { *; } 28 | -keep interface com.squareup.okhttp3.** { *; } 29 | -dontwarn com.squareup.okhttp3.** 30 | 31 | #rxjava 32 | -keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { 33 | long producerIndex; 34 | long consumerIndex; 35 | } 36 | -keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef { 37 | rx.internal.util.atomic.LinkedQueueNode producerNode; 38 | } 39 | -keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueConsumerNodeRef { 40 | rx.internal.util.atomic.LinkedQueueNode consumerNode; 41 | } 42 | -dontwarn sun.misc.Unsafe 43 | 44 | #start gson 45 | -keepattributes Signature 46 | # For using GSON @Expose annotation 47 | -keepattributes *Annotation* 48 | 49 | # Gson specific classes 50 | -keep class sun.misc.Unsafe { *; } 51 | #end of gson 52 | 53 | #glide 54 | -keep public class * implements com.bumptech.glide.module.GlideModule 55 | -keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { 56 | **[] $VALUES; 57 | public *; 58 | } 59 | 60 | #debug 61 | -renamesourcefileattribute SourceFile 62 | -keepattributes SourceFile,LineNumberTable 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/util/storage/Storage.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.util.storage 2 | 3 | import android.content.SharedPreferences 4 | 5 | import com.google.gson.Gson 6 | 7 | import java.lang.reflect.Type 8 | 9 | @Suppress("TooManyFunctions") 10 | class Storage(private val sp: SharedPreferences, private val gson: Gson) : IStorage { 11 | 12 | override fun get(key: String, type: Type): T? = getString(key)?.let { 13 | if (it.isEmpty()) { 14 | null 15 | } else { 16 | gson.fromJson(it, type) 17 | } 18 | } 19 | 20 | override fun put(key: String, items: Any) { 21 | sp.edit().putString(key, gson.toJson(items)).apply() 22 | } 23 | 24 | override fun putString(key: String, str: String) { 25 | sp.edit().putString(key, str).apply() 26 | } 27 | 28 | override fun getString(key: String): String? = sp.getString(key, null) 29 | 30 | override fun putLong(key: String, number: Long) { 31 | sp.edit().putLong(key, number).apply() 32 | } 33 | 34 | override fun getLong(key: String, defaultValue: Long) = sp.getLong(key, defaultValue) 35 | 36 | override fun putInt(key: String, number: Int) { 37 | sp.edit().putInt(key, number).apply() 38 | } 39 | 40 | override fun getInt(key: String, defaultValue: Int) = sp.getInt(key, defaultValue) 41 | 42 | override fun putBoolean(key: String, value: Boolean) { 43 | sp.edit().putBoolean(key, value).apply() 44 | } 45 | 46 | override fun getBoolean(key: String, defaultValue: Boolean) = sp.getBoolean(key, defaultValue) 47 | 48 | override fun remove(key: String) { 49 | sp.edit().remove(key).apply() 50 | } 51 | 52 | override fun putCollection(key: String, items: List) { 53 | put(key, items) 54 | } 55 | 56 | override fun getCollection(key: String, type: Type) = get>(key, type) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/login/LoginRepository.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login 2 | 3 | import com.apollographql.apollo.ApolloClient 4 | import com.apollographql.apollo.api.Response 5 | import com.apollographql.apollo.coroutines.toDeferred 6 | import com.flatstack.android.graphql.mutation.LoginMutation 7 | import com.flatstack.android.model.entities.Session 8 | import com.flatstack.android.model.network.NetworkBoundResource 9 | import com.flatstack.android.model.network.errors.ErrorHandler 10 | import com.flatstack.android.profile.AuthorizationModel 11 | import kotlinx.coroutines.* 12 | 13 | class LoginRepository( 14 | private val apolloClient: ApolloClient, 15 | private val authorizationModel: AuthorizationModel, 16 | private val errorHandler: ErrorHandler, 17 | private val loginMapper: LoginMapper 18 | ) : CoroutineScope { 19 | override val coroutineContext = SupervisorJob() + Dispatchers.IO 20 | 21 | fun login(username: String, password: String) = 22 | object : NetworkBoundResource(coroutineContext, errorHandler) { 23 | override suspend fun createCallAsync(): Deferred> = 24 | apolloClient.mutate(loginMutation(username, password)).toDeferred() 25 | 26 | override suspend fun saveCallResult(item: LoginMutation.Data?) { 27 | authorizationModel.setSession(loginMapper.mapLogin(item?.signin)) 28 | } 29 | 30 | override suspend fun loadFromDb() = authorizationModel.getSession() 31 | }.asLiveData() 32 | 33 | fun unAuthorize() { 34 | launch { 35 | authorizationModel.unAuthorize() 36 | } 37 | } 38 | 39 | fun onDestroy() { 40 | coroutineContext.cancelChildren() 41 | } 42 | 43 | private fun loginMutation(email: String, password: String) = 44 | LoginMutation(email = email, password = password) 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/utils/storage/RuntimeStorage.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.utils.storage 2 | 3 | import com.flatstack.android.util.storage.IStorage 4 | import java.lang.reflect.Type 5 | import java.util.* 6 | 7 | class RuntimeStorage : IStorage { 8 | 9 | private val map = HashMap() 10 | 11 | override fun get(key: String, clazz: Type): T? { 12 | return map[key] as T? 13 | } 14 | 15 | override fun putLong(key: String, number: Long) { 16 | map[key] = java.lang.Long.valueOf(number) 17 | } 18 | 19 | override fun getLong(key: String, defaultValue: Long): Long { 20 | val o = map[key] 21 | return if (o != null) o as Long else defaultValue 22 | } 23 | 24 | override fun putInt(key: String, number: Int) { 25 | map[key] = Integer.valueOf(number) 26 | } 27 | 28 | override fun getInt(key: String, defaultValue: Int): Int { 29 | val o = map[key] 30 | return if (o != null) o as Int else defaultValue 31 | } 32 | 33 | override fun putBoolean(key: String, value: Boolean) { 34 | map[key] = java.lang.Boolean.valueOf(value) 35 | } 36 | 37 | override fun getBoolean(key: String, defaultValue: Boolean): Boolean { 38 | val o = map[key] 39 | return if (o != null) o as Boolean else defaultValue 40 | } 41 | 42 | override fun putString(key: String, str: String) { 43 | map[key] = str 44 | } 45 | 46 | override fun getString(key: String): String? { 47 | val o = map[key] 48 | return if (o != null) o as String? else null 49 | } 50 | 51 | override fun remove(key: String) { 52 | map.remove(key) 53 | } 54 | 55 | override fun getCollection(key: String, type: Type): List? { 56 | return map[key] as List? 57 | } 58 | 59 | override fun put(key: String, items: Any) { 60 | map[key] = items 61 | } 62 | 63 | override fun putCollection(key: String, items: List) { 64 | map[key] = items 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/network/NetworkBoundResource.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.network 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import com.apollographql.apollo.api.Response 6 | import com.flatstack.android.model.entities.Resource 7 | import com.flatstack.android.model.network.errors.ErrorHandler 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Deferred 10 | import kotlinx.coroutines.launch 11 | import kotlin.coroutines.CoroutineContext 12 | 13 | abstract class NetworkBoundResource( 14 | override val coroutineContext: CoroutineContext, 15 | private val errorHandler: ErrorHandler 16 | ) : CoroutineScope { 17 | 18 | private val result = MutableLiveData>() 19 | 20 | init { 21 | result.postValue(Resource.loading()) 22 | fetchFromNetwork() 23 | } 24 | 25 | fun asLiveData() = result as LiveData> 26 | 27 | fun fetchFromNetwork() { 28 | launch { 29 | result.postValue(Resource.loading(loadFromDb())) 30 | val apiResponse = createCallAsync().await() 31 | if (!apiResponse.hasErrors()) { 32 | saveCallResult(processResponse(apiResponse)) 33 | result.postValue(Resource.success(loadFromDb())) 34 | } else { 35 | onFetchFailed() 36 | result.postValue(Resource.error(errorHandler.proceed(processErrorResponse(apiResponse)), loadFromDb())) 37 | } 38 | } 39 | } 40 | 41 | protected open fun onFetchFailed() {} 42 | 43 | protected open fun processResponse(response: Response) = response.data 44 | 45 | protected open fun processErrorResponse(response: Response) = response.errors?.get(0) 46 | 47 | protected abstract suspend fun saveCallResult(item: RequestType?) 48 | 49 | protected abstract suspend fun loadFromDb(): ResultType? 50 | 51 | protected abstract suspend fun createCallAsync(): Deferred> 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_profile.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 20 | 21 | 31 | 32 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/model/network/errors/ErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.model.network.errors 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import com.apollographql.apollo.api.BigDecimal 5 | import com.apollographql.apollo.api.Error 6 | import com.flatstack.android.R 7 | import com.flatstack.android.Router 8 | import com.flatstack.android.profile.AuthorizationModel 9 | import com.flatstack.android.util.StringResource 10 | import kotlinx.coroutines.runBlocking 11 | import java.net.HttpURLConnection 12 | 13 | class ErrorHandler( 14 | private val stringResource: StringResource, 15 | private val authorizationModel: AuthorizationModel, 16 | private val router: Router 17 | ) { 18 | 19 | fun proceed(error: Error?): String = error?.let { 20 | mapToStatus(it).let { code -> 21 | when (code) { 22 | HttpURLConnection.HTTP_UNAUTHORIZED -> runBlocking { unAuthorize() } 23 | } 24 | if (error.message.isEmpty()) { 25 | userMessage(it) 26 | } else { 27 | error.message 28 | } 29 | } 30 | } ?: stringResource.getString(R.string.unknown_error) 31 | 32 | @VisibleForTesting 33 | suspend fun unAuthorize() { 34 | authorizationModel.unAuthorize() 35 | router.login(clearStack = true) 36 | } 37 | 38 | private fun userMessage(error: Error) = when (mapToStatus(error)) { 39 | HttpURLConnection.HTTP_NOT_MODIFIED -> stringResource.getString(R.string.not_modified_error) 40 | HttpURLConnection.HTTP_BAD_REQUEST -> stringResource.getString(R.string.bad_request_error) 41 | HttpURLConnection.HTTP_UNAUTHORIZED -> stringResource.getString(R.string.unauthorized_error) 42 | HttpURLConnection.HTTP_FORBIDDEN -> stringResource.getString(R.string.forbidden_error) 43 | HttpURLConnection.HTTP_NOT_FOUND -> stringResource.getString(R.string.not_found_error) 44 | HttpURLConnection.HTTP_BAD_METHOD -> stringResource.getString(R.string.method_not_allowed_error) 45 | HttpURLConnection.HTTP_CONFLICT -> stringResource.getString(R.string.conflict_error) 46 | HttpURLConnection.HTTP_INTERNAL_ERROR -> stringResource.getString(R.string.server_error_error) 47 | else -> "" 48 | } 49 | 50 | private fun mapToStatus(error: Error) = 51 | ((error.customAttributes["extensions"] as LinkedHashMap<*, *>)["status"] as BigDecimal).intValueExact() 52 | } 53 | -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/login/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.login 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import android.view.inputmethod.EditorInfo 6 | import android.widget.Toast 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.flatstack.android.R 9 | import com.flatstack.android.Router 10 | import com.flatstack.android.util.observeBy 11 | import com.flatstack.android.util.provideViewModel 12 | import kotlinx.android.synthetic.main.activity_login.* 13 | import org.kodein.di.Kodein 14 | import org.kodein.di.KodeinAware 15 | import org.kodein.di.android.kodein 16 | import org.kodein.di.generic.instance 17 | 18 | class LoginActivity : AppCompatActivity(), KodeinAware { 19 | 20 | override val kodein: Kodein by kodein() 21 | 22 | private val viewModel: LoginViewModel by provideViewModel() 23 | 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | setContentView(R.layout.activity_login) 27 | 28 | viewModel.loginResource.observeBy( 29 | this, 30 | onSuccess = ::navigateToProfile, 31 | onError = ::showError, 32 | onLoading = ::setProgress 33 | ) 34 | 35 | initListeners() 36 | } 37 | 38 | private fun navigateToProfile() { 39 | val router by kodein.instance() 40 | router.profile(context = this, clearStack = true) 41 | } 42 | 43 | private fun initListeners() { 44 | bt_login.setOnClickListener { login() } 45 | et_password.apply { 46 | setOnEditorActionListener { _, actionId, _ -> 47 | if (actionId == EditorInfo.IME_ACTION_GO) { 48 | login() 49 | return@setOnEditorActionListener true 50 | } 51 | return@setOnEditorActionListener false 52 | } 53 | } 54 | } 55 | 56 | private fun login() { 57 | val username = et_login.text.toString() 58 | val password = et_password.text.toString() 59 | viewModel.login(username, password) 60 | } 61 | 62 | private fun setProgress(isLoading: Boolean) { 63 | if (isLoading) { 64 | pb_progress.visibility = View.VISIBLE 65 | bt_login.isEnabled = false 66 | } else { 67 | pb_progress.visibility = View.GONE 68 | bt_login.isEnabled = true 69 | } 70 | } 71 | 72 | private fun showError(errorText: String?) { 73 | errorText?.let { 74 | Toast.makeText(this, errorText, Toast.LENGTH_LONG).show() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/test/java/com/flatstack/android/profile/ProfileViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import androidx.arch.core.executor.ArchTaskExecutor 4 | import androidx.lifecycle.LiveData 5 | import com.flatstack.android.graphql.query.GetUserQuery 6 | import com.flatstack.android.login.LoginRepository 7 | import com.flatstack.android.model.entities.Resource 8 | import com.flatstack.android.model.network.NetworkBoundResource 9 | import com.flatstack.android.profile.entities.Profile 10 | import com.flatstack.android.test_utils.InstantLiveDataExecutor 11 | import io.mockk.* 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.newSingleThreadContext 14 | import kotlinx.coroutines.test.resetMain 15 | import kotlinx.coroutines.test.setMain 16 | import org.junit.Assert 17 | import org.spekframework.spek2.Spek 18 | 19 | object ProfileViewModelTest : Spek({ 20 | val mainThreadSurrogate = newSingleThreadContext("UI thread") 21 | 22 | beforeEachTest { 23 | Dispatchers.setMain(mainThreadSurrogate) 24 | ArchTaskExecutor.getInstance().setDelegate(InstantLiveDataExecutor) 25 | } 26 | 27 | afterEachTest { 28 | ArchTaskExecutor.getInstance().setDelegate(null) 29 | Dispatchers.resetMain() 30 | mainThreadSurrogate.close() 31 | } 32 | 33 | test("initial") { 34 | val expectedBoundResource = mockk>() 35 | val expectedLiveData = mockk>>() 36 | val mockRepository = mockk() 37 | val mockAuthenticationModel = mockk() 38 | every { mockRepository.loadProfile() } returns expectedBoundResource 39 | every { expectedBoundResource.asLiveData() } returns expectedLiveData 40 | 41 | val viewModel = ProfileViewModel(mockRepository, mockAuthenticationModel) 42 | 43 | Assert.assertEquals(viewModel.profileBoundResource, expectedBoundResource) 44 | Assert.assertEquals(viewModel.profileResponse, expectedLiveData) 45 | } 46 | 47 | test("Refresh") { 48 | val mockBoundResource = mockk>() 49 | val mockRepository = mockk() 50 | val mockAuthenticationModel = mockk() 51 | every { mockRepository.loadProfile() } returns mockBoundResource 52 | every { mockBoundResource.asLiveData() } returns mockk() 53 | every { mockBoundResource.fetchFromNetwork() } just Runs 54 | 55 | val viewModel = ProfileViewModel(mockRepository, mockAuthenticationModel) 56 | 57 | viewModel.updateProfile() 58 | 59 | verify { mockBoundResource.fetchFromNetwork() } 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/flatstack/android/profile/ProfileActivity.kt: -------------------------------------------------------------------------------- 1 | package com.flatstack.android.profile 2 | 3 | import android.os.Bundle 4 | import android.view.Menu 5 | import android.view.MenuItem 6 | import android.view.View 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 10 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener 11 | import com.flatstack.android.R 12 | import com.flatstack.android.Router 13 | import com.flatstack.android.util.observeBy 14 | import com.flatstack.android.util.provideViewModel 15 | import kotlinx.android.synthetic.main.activity_profile.* 16 | import org.kodein.di.Kodein 17 | import org.kodein.di.KodeinAware 18 | import org.kodein.di.android.kodein 19 | import org.kodein.di.generic.instance 20 | 21 | class ProfileActivity : AppCompatActivity(), KodeinAware, OnRefreshListener { 22 | 23 | override val kodein: Kodein by kodein() 24 | 25 | private val viewModel: ProfileViewModel by provideViewModel() 26 | 27 | private val refreshLayout: SwipeRefreshLayout by lazy { swipe_layout } 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | setContentView(R.layout.activity_profile) 32 | 33 | refreshLayout.setOnRefreshListener(this) 34 | 35 | viewModel.profileResponse.observeBy( 36 | this, 37 | onNext = { 38 | showProfile() 39 | showFirstName(it.firstName) 40 | showLastName(it.lastName) 41 | }, 42 | onError = ::showError, 43 | onLoading = ::visibleProgress) 44 | } 45 | 46 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 47 | menuInflater.inflate(R.menu.menu_profile, menu) 48 | return true 49 | } 50 | 51 | override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { 52 | R.id.action_logout -> { 53 | logout() 54 | true 55 | } 56 | else -> super.onOptionsItemSelected(item) 57 | } 58 | 59 | override fun onRefresh() { 60 | viewModel.updateProfile() 61 | } 62 | 63 | private fun showFirstName(firstName: String) { 64 | tv_first_name.text = firstName 65 | } 66 | 67 | private fun showLastName(lastName: String) { 68 | tv_last_name.text = lastName 69 | } 70 | 71 | private fun logout() { 72 | viewModel.logout() 73 | 74 | val router by kodein.instance() 75 | router.login(context = this, clearStack = true) 76 | } 77 | 78 | private fun visibleProgress(show: Boolean) { 79 | refreshLayout.isRefreshing = show 80 | } 81 | 82 | private fun showProfile() { 83 | cl_main.visibility = View.VISIBLE 84 | } 85 | 86 | private fun showError(errorText: String?) { 87 | errorText?.let { 88 | Toast.makeText(this, errorText, Toast.LENGTH_LONG).show() 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 17 | 18 | 33 | 34 | 49 | 50 |