Issue#41 Copy full example into separate module with Hilt Integration

This commit is contained in:
Gergely Hegedus 2022-09-27 17:16:05 +03:00
parent 69e76dc0da
commit 52a99a82fc
229 changed files with 8416 additions and 11 deletions

View file

@ -15,6 +15,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.test.KoinTest
import java.io.IOException import java.io.IOException
/** /**
@ -23,7 +24,7 @@ import java.io.IOException
* https://developer.android.com/training/data-storage/room/migrating-db-versions * https://developer.android.com/training/data-storage/room/migrating-db-versions
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
open class MigrationToLatestInstrumentedSharedTest { open class MigrationToLatestInstrumentedSharedTest : KoinTest {
@get:Rule @get:Rule
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation()) val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())

View file

@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.home
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.activity.safeClose import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
@ -21,11 +20,9 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.RuleChain import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest import org.koin.test.KoinTest
@Suppress("TestFunctionName") @Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
open class MainActivityInstrumentedSharedTest : KoinTest { open class MainActivityInstrumentedSharedTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<MainActivity> private lateinit var activityScenario: ActivityScenario<MainActivity>

View file

@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.login
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.R import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
@ -16,11 +15,9 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.RuleChain import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest import org.koin.test.KoinTest
@Suppress("TestFunctionName") @Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
open class AuthActivityInstrumentedSharedTest : KoinTest { open class AuthActivityInstrumentedSharedTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<AuthActivity> private lateinit var activityScenario: ActivityScenario<AuthActivity>

View file

@ -41,12 +41,8 @@ android {
} }
sourceSets { sourceSets {
androidTest {
// assets.srcDirs += files("$projectDir/schemas".toString())
}
test { test {
java.srcDirs += "src/robolectricTest/java" java.srcDirs += "src/robolectricTest/java"
// resources.srcDirs += files("$projectDir/schemas".toString())
} }
} }

View file

@ -3,6 +3,7 @@ buildscript {
ext.kotlin_version = "1.6.10" ext.kotlin_version = "1.6.10"
ext.detekt_version = "1.19.0" ext.detekt_version = "1.19.0"
ext.navigation_version = "2.4.2" ext.navigation_version = "2.4.2"
ext.hilt_version = "2.40.5"
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
@ -13,6 +14,7 @@ buildscript {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
} }
} }

1
hilt/hilt-app-shared-test/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,49 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 31
defaultConfig {
minSdk 21
targetSdk 31
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
sourceSets {
main {
assets.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
resources.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
// since it itself contains the Test it doesn't have tests of it's own
disableTestTasks(this)
dependencies {
implementation project(":hilt:hilt-app")
implementation project(':test-util-android')
implementation testFixtures(project(':hilt:hilt-core'))
implementation "com.google.dagger:hilt-android-testing:$hilt_version"
implementation project(':test-util-shared-robolectric')
api project(':hilt:hilt-network-di-test-util')
applyAppSharedTestDependenciesTo(this)
}

View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.fnives.test.showcase.hilt.test.shared">
</manifest>

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.hilt.test.shared.di
import org.fnives.test.showcase.hilt.BuildConfig
object TestBaseUrlHolder {
var url = BuildConfig.BASE_URL
}

View file

@ -0,0 +1,79 @@
package org.fnives.test.showcase.hilt.test.shared.storage.migration
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity
import org.fnives.test.showcase.hilt.storage.migation.Migration1To2
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
/**
* reference:
* https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975
* https://developer.android.com/training/data-storage/room/migrating-db-versions
*/
@RunWith(AndroidJUnit4::class)
open class MigrationToLatestInstrumentedSharedTest {
@get:Rule
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
private fun getMigratedRoomDatabase(): LocalDatabase {
val database: LocalDatabase = Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
LocalDatabase::class.java,
TEST_DB
)
.addMigrations(Migration1To2())
.build()
// close the database and release any stream resources when the test finishes
helper.closeWhenFinished(database)
return database
}
@Test
@Throws(IOException::class)
open fun migrate1To2() {
val expectedEntities = setOf(
FavouriteEntity("123"),
FavouriteEntity("124"),
FavouriteEntity("125")
)
val version1DB = helper.createDatabase(
name = TEST_DB,
version = 1
)
version1DB.run {
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")")
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)")
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)")
}
version1DB.close()
val version2DB = helper.runMigrationsAndValidate(
name = TEST_DB,
version = 2,
validateDroppedTables = true,
Migration1To2()
)
version2DB.close()
val favouriteDao = getMigratedRoomDatabase().favouriteDao
val entities = runBlocking { favouriteDao.get().first() }.toSet()
Assert.assertEquals(expectedEntities, entities)
}
companion object {
private const val TEST_DB = "migration-test"
}
}

View file

@ -0,0 +1,37 @@
package org.fnives.test.showcase.hilt.test.shared.testutils
import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate
import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
class MockServerScenarioSetupTestRule : TestRule {
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun apply(base: Statement, description: Description): Statement = createStatement(base)
private fun createStatement(base: Statement) = object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
before()
try {
base.evaluate()
} finally {
after()
}
}
}
private fun before() {
val (mockServerScenarioSetup, url) = HttpsConfigurationModuleTemplate.startWithHTTPSMockWebServer()
TestBaseUrlHolder.url = url
this.mockServerScenarioSetup = mockServerScenarioSetup
}
private fun after() {
mockServerScenarioSetup.stop()
}
}

View file

@ -0,0 +1,32 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor
import org.fnives.test.showcase.hilt.ui.shared.executor.TaskExecutor
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Similar Test Rule to InstantTaskExecutorRule just for the [AsyncTaskExecutor] to make AsyncDiffUtil synchronized.
*/
class AsyncDiffUtilInstantTestRule : TestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
AsyncTaskExecutor.delegate = object : TaskExecutor {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}
override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
}
base.evaluate()
AsyncTaskExecutor.delegate = null
}
}
}

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceNotIdle
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources
import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseDispatcherTestRule : TestRule {
lateinit var testDispatcher: TestDispatcher
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val dispatcher = StandardTestDispatcher()
testDispatcher = dispatcher
TestDatabaseInitialization.dispatcher = dispatcher
base.evaluate()
}
}
fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent {
testDispatcher.advanceUntilIdleWithIdlingResources()
}
fun advanceUntilIdle() = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceUntilIdle()
}
fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
}
companion object {
fun TestDispatcher.advanceUntilIdleWithIdlingResources() {
scheduler.advanceUntilIdle() // advance until a request is sent
while (anyResourceNotIdle()) { // check if any request is in progress
awaitIdlingResources() // complete all requests and other idling resources
scheduler.advanceUntilIdle() // run coroutines after request is finished
}
scheduler.advanceUntilIdle()
}
}
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) {
override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) {
TestDatabaseInitialization.dispatcher = testDispatcher
}
}

View file

@ -0,0 +1,23 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization
import javax.inject.Inject
class NetworkSynchronizationHelper @Inject constructor(private val networkSynchronization: NetworkSynchronization) {
private val compositeDisposable = CompositeDisposable()
fun setup() {
networkSynchronization.networkIdlingResources().map {
IdlingResourceDisposable(it)
}.forEach {
compositeDisposable.add(it)
}
}
fun dispose() {
compositeDisposable.dispose()
}
}

View file

@ -0,0 +1,69 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.statesetup
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.runner.intent.IntentStubberRegistry
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.ui.auth.LoginRobot
import org.fnives.test.showcase.hilt.test.shared.ui.home.HomeRobot
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.koin.test.KoinTest
object SetupAuthenticationState : KoinTest {
fun setupLogin(
mainDispatcherTestRule: MainDispatcherTestRule,
mockServerScenarioSetup: MockServerScenarioSetup,
resetIntents: Boolean = true,
) {
resetIntentsIfNeeded(resetIntents) {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"))
val activityScenario = ActivityScenario.launch(AuthActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
val loginRobot = LoginRobot()
loginRobot.setupIntentResults()
loginRobot
.setPassword("b")
.setUsername("a")
.clickOnLogin()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
activityScenario.safeClose()
}
}
fun setupLogout(
mainDispatcherTestRule: MainDispatcherTestRule,
resetIntents: Boolean = true,
) {
resetIntentsIfNeeded(resetIntents) {
val activityScenario = ActivityScenario.launch(MainActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
val homeRobot = HomeRobot()
homeRobot.setupIntentResults()
homeRobot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
activityScenario.safeClose()
}
}
private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) {
val wasInitialized = IntentStubberRegistry.isLoaded()
if (!wasInitialized) {
Intents.init()
}
action()
Intents.release()
if (resetIntents && wasInitialized) {
Intents.init()
}
}
}

View file

@ -0,0 +1,48 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.storage
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asExecutor
import org.fnives.test.showcase.hilt.di.StorageModule
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import javax.inject.Singleton
/**
* Reloads the Database module, so it uses the inMemory database with the switched out Executors.
*
* This is needed so in AndroidTests not a real File based device is used.
* This speeds tests up, and isolates them better, there will be no junk in the Database file from previous tests.
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [StorageModule::class]
)
object TestDatabaseInitialization {
var dispatcher: CoroutineDispatcher? = null
@Suppress("ObjectPropertyName")
private val _dispatcher: CoroutineDispatcher
get() = dispatcher ?: throw IllegalStateException("TestDispatcher is not initialized")
fun create(context: Context, dispatcher: CoroutineDispatcher = this._dispatcher): LocalDatabase {
val executor = dispatcher.asExecutor()
return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java)
.setTransactionExecutor(executor)
.setQueryExecutor(executor)
.allowMainThreadQueries()
.build()
}
@Singleton
@Provides
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
create(context)
}

View file

@ -0,0 +1,41 @@
package org.fnives.test.showcase.hilt.test.shared.ui
import dagger.hilt.android.testing.HiltAndroidRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.NetworkSynchronizationHelper
import org.junit.After
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
open class NetworkSynchronizedActivityTest {
@Inject
lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setup() {
setupBeforeInjection()
hiltRule.inject()
networkSynchronizationHelper.setup()
setupAfterInjection()
}
open fun setupBeforeInjection() {
}
open fun setupAfterInjection() {
}
@After
fun tearDown() {
networkSynchronizationHelper.dispose()
additionalTearDown()
}
open fun additionalTearDown() {
}
}

View file

@ -0,0 +1,138 @@
package org.fnives.test.showcase.hilt.test.shared.ui.auth
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<AuthActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: LoginRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
Intents.init()
robot = LoginRobot()
}
override fun additionalTearDown() {
Intents.release()
}
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
@Test
fun properLoginResultsInNavigationToHome() {
mockServerScenarioSetup.setScenario(
AuthScenario.Success(password = "alma", username = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setPassword("alma")
.setUsername("banan")
.assertPassword("alma")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToHome()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
/** GIVEN password and empty username WHEN signIn THEN error username is shown */
@Test
fun emptyUserNameShowsProperErrorMessage() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
/** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */
@Test
fun invalidCredentialsGivenShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
/** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */
@Test
fun networkErrorShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotNavigatedToHome()
.assertNotLoading()
}
}

View file

@ -0,0 +1,94 @@
package org.fnives.test.showcase.hilt.test.shared.ui.auth
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.annotation.StringRes
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText
import org.fnives.test.showcase.android.testutil.viewaction.progressbar.ReplaceProgressBarDrawableToStatic
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.hamcrest.core.IsNot.not
class LoginRobot {
fun setupIntentResults() {
Intents.intending(hasComponent(MainActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
/**
* Needed because Espresso idling waits until mainThread is idle.
*
* However, ProgressBar keeps the main thread active since it's animating.
*
* Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187
* In short they replace the inflater to remove animations, by using custom test runner.
*/
fun replaceProgressBar() = apply {
onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic())
}
fun setUsername(username: String): LoginRobot = apply {
onView(withId(R.id.user_edit_text))
.perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard())
}
fun setPassword(password: String): LoginRobot = apply {
onView(withId(R.id.password_edit_text))
.perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard())
}
fun clickOnLogin() = apply {
replaceProgressBar()
onView(withId(R.id.login_cta))
.perform(ViewActions.click())
}
fun assertPassword(password: String) = apply {
onView(withId((R.id.password_edit_text)))
.check(ViewAssertions.matches(ViewMatchers.withText(password)))
}
fun assertUsername(username: String) = apply {
onView(withId((R.id.user_edit_text)))
.check(ViewAssertions.matches(ViewMatchers.withText(username)))
}
fun assertErrorIsShown(@StringRes stringResID: Int) = apply {
assertSnackBarIsShownWithText(stringResID)
}
fun assertLoadingBeforeRequests() = apply {
onView(withId(R.id.loading_indicator))
.check(ViewAssertions.matches(isDisplayed()))
}
fun assertNotLoading() = apply {
onView(withId(R.id.loading_indicator))
.check(ViewAssertions.matches(not(isDisplayed())))
}
fun assertErrorIsNotShown() = apply {
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome() = apply {
intended(hasComponent(MainActivity::class.java.canonicalName))
}
fun assertNotNavigatedToHome() = apply {
notIntended(hasComponent(MainActivity::class.java.canonicalName))
}
}

View file

@ -0,0 +1,123 @@
package org.fnives.test.showcase.hilt.test.shared.ui.home
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withChild
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable
import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations
import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.FavouriteContent
import org.hamcrest.Matchers.allOf
class HomeRobot {
/**
* Needed because Espresso idling sometimes not in sync with RecyclerView's animation.
* So we simply remove the item animations, the animations should be disabled anyway for test.
*
* Reference: https://github.com/android/android-test/issues/223
*/
fun removeItemAnimations() = apply {
Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations())
}
fun assertToolbarIsShown() = apply {
Espresso.onView(withId(R.id.toolbar))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
fun setupIntentResults() {
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
fun assertNavigatedToAuth() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun assertDidNotNavigateToAuth() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun clickSignOut(setupIntentResults: Boolean = true) = apply {
if (setupIntentResults) {
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
Espresso.onView(withId(R.id.logout_cta)).perform(click())
}
fun assertContainsItem(index: Int, item: FavouriteContent) = apply {
removeItemAnimations()
val isFavouriteResourceId = if (item.isFavourite) {
R.drawable.favorite_24
} else {
R.drawable.favorite_border_24
}
Espresso.onView(withId(R.id.recycler))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
Espresso.onView(
allOf(
withChild(allOf(withText(item.content.title), withId(R.id.title))),
withChild(allOf(withText(item.content.description), withId(R.id.description))),
withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId)))
)
)
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
fun clickOnContentItem(index: Int, item: Content) = apply {
removeItemAnimations()
Espresso.onView(withId(R.id.recycler))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
Espresso.onView(
allOf(
withId(R.id.favourite_cta),
withParent(
allOf(
withChild(allOf(withText(item.title), withId(R.id.title))),
withChild(allOf(withText(item.description), withId(R.id.description)))
)
)
)
)
.perform(click())
}
fun swipeRefresh() = apply {
Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh())
}
fun assertContainsNoItems() = apply {
removeItemAnimations()
Espresso.onView(withId(R.id.recycler))
.check(matches(hasChildCount(0)))
}
fun assertContainsError() = apply {
Espresso.onView(withId(R.id.error_message))
.check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong))))
}
}

View file

@ -0,0 +1,214 @@
package org.fnives.test.showcase.hilt.test.shared.ui.home
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.AsyncDiffUtilInstantTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<MainActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: HomeRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(AsyncDiffUtilInstantTestRule())
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
super.setupAfterInjection()
robot = HomeRobot()
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
Intents.init()
}
override fun additionalTearDown() {
super.additionalTearDown()
Intents.release()
}
/** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */
@Test
fun signOutClickedResultsInNavigation() {
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToAuth()
}
/** GIVEN success response WHEN data is returned THEN it is shown on the ui */
@Test
fun successfulDataLoadingShowsTheElementsOnTheUI() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked THEN ui is updated */
@Test
fun clickingOnListElementUpdatesTheElementsFavouriteState() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked THEN ui is updated even if activity is recreated */
@Test
fun elementFavouritedIsKeptEvenIfActivityIsRecreated() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
activityScenario.safeClose()
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked then clicked again THEN ui is updated */
@Test
fun clickingAnElementMultipleTimesProperlyUpdatesIt() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN error response WHEN loaded THEN error is Shown */
@Test
fun networkErrorResultsInUIErrorStateShown() {
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertContainsNoItems()
.assertContainsError()
.assertDidNotNavigateToAuth()
}
/** GIVEN error response then success WHEN retried THEN success is shown */
@Test
fun retryingFromErrorStateAndSucceedingShowsTheData() {
mockServerScenarioSetup.setScenario(
ContentScenario.Error(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = false))
)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadFor(2000L)
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success then error WHEN retried THEN error is shown */
@Test
fun errorIsShownIfTheDataIsFetchedAndErrorIsReceived() {
mockServerScenarioSetup.setScenario(
ContentScenario.Success(usingRefreshedToken = false)
.then(ContentScenario.Error(usingRefreshedToken = false))
)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot
.assertContainsError()
.assertContainsNoItems()
.assertDidNotNavigateToAuth()
}
/** GIVEN unauthenticated then success WHEN loaded THEN success is shown */
@Test
fun authenticationIsHandledWithASingleLoading() {
mockServerScenarioSetup.setScenario(
ContentScenario.Unauthorized(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = true))
)
.setScenario(RefreshTokenScenario.Success)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */
@Test
fun sessionExpirationResultsInNavigation() {
mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false))
.setScenario(RefreshTokenScenario.Error)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToAuth()
}
}

View file

@ -0,0 +1,101 @@
package org.fnives.test.showcase.hilt.test.shared.ui.splash
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogout
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.splash.SplashActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<SplashActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: SplashRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mainDispatcherTestRule)
.around(mockServerScenarioSetupTestRule)
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
Intents.init()
robot = SplashRobot()
}
override fun additionalTearDown() {
Intents.release()
}
/** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */
@Test
fun loggedInStateNavigatesToHome() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertHomeIsStarted()
.assertAuthIsNotStarted()
}
/** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */
@Test
fun loggedOutStatesNavigatesToAuthentication() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertAuthIsStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedOutStatesNotEnoughTime() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertAuthIsNotStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedInStatesNotEnoughTime() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertHomeIsNotStarted()
.assertAuthIsNotStarted()
}
}

View file

@ -0,0 +1,36 @@
package org.fnives.test.showcase.hilt.test.shared.ui.splash
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.hilt.ui.home.MainActivity
class SplashRobot {
fun setupIntentResults() {
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
fun assertHomeIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
fun assertHomeIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
fun assertAuthIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun assertAuthIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
}

1
hilt/hilt-app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

127
hilt/hilt-app/build.gradle Normal file
View file

@ -0,0 +1,127 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
defaultConfig {
applicationId "org.fnives.test.showcase.hilt"
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"'
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
testInstrumentationRunner "org.fnives.test.showcase.hilt.runner.HiltTestRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
flavorDimensions 'di'
buildFeatures {
viewBinding true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = project.androidx_compose_version
}
sourceSets {
test {
java.srcDirs += "src/robolectricTest/java"
}
}
// needed for androidTest
packagingOptions {
exclude 'META-INF/LGPL2.1'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
hilt {
enableAggregatingTask = true
}
afterEvaluate {
// making sure the :mockserver is assembled after :clean when running tests
testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
}
dependencies {
implementation "androidx.core:core-ktx:$androidx_core_version"
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
implementation "com.google.android.material:material:$androidx_material_version"
implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version"
implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version"
implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
implementation "androidx.activity:activity-compose:$activity_ktx_version"
implementation "androidx.navigation:navigation-compose:$androidx_navigation"
implementation "androidx.compose.ui:ui:$androidx_compose_version"
implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version"
implementation "androidx.compose.foundation:foundation:$androidx_compose_version"
implementation "androidx.compose.material:material:$androidx_compose_version"
implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version"
implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version"
implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version"
// Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation "io.coil-kt:coil:$coil_version"
implementation "io.coil-kt:coil-compose:$coil_version"
implementation project(":hilt:hilt-core")
applyAppTestDependenciesTo(this)
applyComposeTestDependenciesTo(this)
androidTestImplementation project(':mockserver')
testImplementation project(':test-util-junit5-android')
testImplementation project(':test-util-shared-robolectric')
testImplementation project(':test-util-android')
androidTestImplementation project(':test-util-android')
androidTestImplementation project(':test-util-shared-android')
testImplementation testFixtures(project(":hilt:hilt-core"))
androidTestImplementation testFixtures(project(":hilt:hilt-core"))
testImplementation project(':hilt:hilt-app-shared-test')
androidTestImplementation project(':hilt:hilt-app-shared-test')
testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptTest "com.google.dagger:hilt-compiler:$hilt_version"
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version"
}
apply from: '../../gradlescripts/pull-screenshots.gradle'

View file

21
hilt/hilt-app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,34 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "36d840e89667f36e0c265593da36fe23",
"entities": [
{
"tableName": "FavouriteEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` TEXT NOT NULL, PRIMARY KEY(`contentId`))",
"fields": [
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"contentId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36d840e89667f36e0c265593da36fe23')"
]
}
}

View file

@ -0,0 +1,34 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "3723fe73a9d3dc43de8ff3e52ec46490",
"entities": [
{
"tableName": "FavouriteEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`content_id` TEXT NOT NULL, PRIMARY KEY(`content_id`))",
"fields": [
{
"fieldPath": "contentId",
"columnName": "content_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"content_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3723fe73a9d3dc43de8ff3e52ec46490')"
]
}
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.hilt.di
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient
import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier
import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor
import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BindsBaseOkHttpClient::class]
)
object HttpsConfigurationModule {
@Provides
@Singleton
@SessionLessQualifier
fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) =
HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor)
}

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase.hilt.di
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BaseUrlModule::class]
)
object TestBaseUrlModule {
@Provides
fun provideBaseUrl(): String = TestBaseUrlHolder.url
}

View file

@ -0,0 +1,24 @@
package org.fnives.test.showcase.hilt.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [StorageModule::class]
)
object TestDatabaseInitializationModule {
@Singleton
@Provides
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
TestDatabaseInitialization.provideLocalDatabase(context)
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.hilt.di
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [UserDataLocalStorageModule::class]
)
object TestUserDataLocalStorageModule {
var replacement: UserDataLocalStorage? = null
@Singleton
@Provides
fun provideUserDataLocalStorage(
sharedPreferencesManagerImpl: SharedPreferencesManagerImpl,
): UserDataLocalStorage = replacement ?: sharedPreferencesManagerImpl
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.runner
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.hilt.storage.migration
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest()

View file

@ -0,0 +1,10 @@
package org.fnives.test.showcase.hilt.ui.auth
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest()

View file

@ -0,0 +1,199 @@
package org.fnives.test.showcase.hilt.ui.compose
import androidx.compose.ui.test.MainTestClock
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.espresso.Espresso
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.compose.screen.AppNavigation
import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() {
private val composeTestRule = createComposeRule()
private val stateRestorationTester = StateRestorationTester(composeTestRule)
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val dispatcherTestRule = DatabaseDispatcherTestRule()
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mockServerScenarioSetupTestRule)
.around(dispatcherTestRule)
.around(composeTestRule)
.around(ScreenshotRule("test-showcase-compose"))
override fun setupBeforeInjection() {
TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage()
}
override fun setupAfterInjection() {
stateRestorationTester.setContent {
AppNavigation()
}
robot = ComposeLoginRobot(composeTestRule)
navigationRobot = ComposeNavigationRobot(composeTestRule)
}
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
@Test
fun properLoginResultsInNavigationToHome() {
mockServerScenarioSetup.setScenario(
AuthScenario.Success(password = "alma", username = "banan")
)
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot.setPassword("alma")
.setUsername("banan")
.assertUsername("banan")
.assertPassword("alma")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.awaitIdlingResources()
navigationRobot.assertHomeScreen()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
composeTestRule.mainClock.awaitIdlingResources()
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and empty username WHEN signIn THEN error username is shown */
@Test
fun emptyUserNameShowsProperErrorMessage() {
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
composeTestRule.mainClock.awaitIdlingResources()
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */
@Test
fun invalidCredentialsGivenShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(password = "alma", username = "banan")
)
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.awaitIdlingResources()
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */
@Test
fun networkErrorShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.awaitIdlingResources()
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */
@Test
fun restoringContentShowPreviousCredentials() {
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
stateRestorationTester.emulateSavedInstanceStateRestore()
composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) // ensure all time based operation run
navigationRobot.assertAuthScreen()
robot.assertUsername("alma")
.assertPassword("banan")
}
companion object {
private const val SPLASH_DELAY = 600L
// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807
/**
* Await the idling resource on a different thread while looping main.
*/
fun MainTestClock.awaitIdlingResources() {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L))
advanceTimeByFrame()
}
}
}

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.hilt.ui.compose
import android.content.Context
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag
class ComposeLoginRobot(
composeTestRule: ComposeTestRule,
) : ComposeTestRule by composeTestRule {
fun setUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
}
fun setPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
}
fun assertPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
}
fun assertUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
}
fun clickOnLogin(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
}
fun assertLoading(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
}
fun assertNotLoading(): ComposeLoginRobot = apply {
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
}
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginError)
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
}
}

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase.hilt.ui.compose
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import org.fnives.test.showcase.hilt.compose.screen.AppNavigationTag
class ComposeNavigationRobot(
private val composeTestRule: ComposeTestRule,
) {
fun assertHomeScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
}
fun assertAuthScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
}
}

View file

@ -0,0 +1,10 @@
package org.fnives.test.showcase.hilt.ui.home
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest()

View file

@ -0,0 +1,10 @@
package org.fnives.test.showcase.hilt.ui.splash
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest()

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.fnives.test.showcase.hilt">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".TestShowcaseApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TestShowCase"
tools:ignore="AllowBackup,DataExtractionRules">
<activity
android:name=".ui.splash.SplashActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.home.MainActivity" />
<activity android:name=".ui.auth.AuthActivity" />
<activity
android:name=".compose.ComposeActivity"
android:configChanges="colorMode|density|fontScale|fontWeightAdjustment|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:taskAffinity="org.fnives.test.showcase.compose"
android:icon="@mipmap/ic_compose_launcher"
android:roundIcon="@mipmap/ic_compose_launcher_round"
android:label="@string/app_name_compose"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package org.fnives.test.showcase.hilt
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TestShowcaseApplication : Application()

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.hilt.compose
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import com.google.accompanist.insets.ProvideWindowInsets
import dagger.hilt.android.AndroidEntryPoint
import org.fnives.test.showcase.hilt.compose.screen.AppNavigation
@AndroidEntryPoint
class ComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestShowCaseApp()
}
}
}
@Composable
fun TestShowCaseApp() {
ProvideWindowInsets {
MaterialTheme {
AppNavigation()
}
}
}

View file

@ -0,0 +1,81 @@
package org.fnives.test.showcase.hilt.compose.screen
import androidx.compose.foundation.background
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.delay
import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreen
import org.fnives.test.showcase.hilt.compose.screen.auth.rememberAuthScreenState
import org.fnives.test.showcase.hilt.compose.screen.home.HomeScreen
import org.fnives.test.showcase.hilt.compose.screen.home.rememberHomeScreenState
import org.fnives.test.showcase.hilt.compose.screen.splash.SplashScreen
import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase
@Composable
fun AppNavigation(
isUserLogeInUseCase: IsUserLoggedInUseCase = AppNavigationEntryPoint.get().isUserLoggedInUseCase
) {
val navController = rememberNavController()
LaunchedEffect(isUserLogeInUseCase) {
val loginStateRoute = if (isUserLogeInUseCase.invoke()) RouteTag.HOME else RouteTag.AUTH
if (navController.currentDestination?.route == loginStateRoute) return@LaunchedEffect
delay(500)
navController.navigate(
route = loginStateRoute,
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.SPLASH, inclusive = true).build()
)
}
NavHost(
navController,
startDestination = RouteTag.SPLASH,
modifier = Modifier.background(MaterialTheme.colors.surface)
) {
composable(RouteTag.SPLASH) { SplashScreen() }
composable(RouteTag.AUTH) {
AuthScreen(
modifier = Modifier.testTag(AppNavigationTag.AuthScreen),
authScreenState = rememberAuthScreenState(
onLoginSuccess = {
navController.navigate(
route = RouteTag.HOME,
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.AUTH, inclusive = true).build()
)
}
)
)
}
composable(RouteTag.HOME) {
HomeScreen(
modifier = Modifier.testTag(AppNavigationTag.HomeScreen),
homeScreenState = rememberHomeScreenState(
onLogout = {
navController.navigate(
route = RouteTag.AUTH,
navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.HOME, inclusive = true).build()
)
}
)
)
}
}
}
object RouteTag {
const val HOME = "Home"
const val AUTH = "Auth"
const val SPLASH = "Splash"
}
object AppNavigationTag {
const val AuthScreen = "AppNavigationTag.AuthScreen"
const val HomeScreen = "AppNavigationTag.HomeScreen"
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.hilt.compose.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase
object AppNavigationEntryPoint {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppNavigationDependencies {
val isUserLoggedInUseCase: IsUserLoggedInUseCase
}
@Composable
fun get(): AppNavigationDependencies {
val context = LocalContext.current.applicationContext
return remember { EntryPoints.get(context, AppNavigationDependencies::class.java) }
}
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.hilt.compose.screen.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.core.login.LoginUseCase
object AuthEntryPoint {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AuthDependencies {
val loginUseCase: LoginUseCase
}
@Composable
fun get(): AuthDependencies {
val context = LocalContext.current.applicationContext
return remember { EntryPoints.get(context, AuthDependencies::class.java) }
}
}

View file

@ -0,0 +1,211 @@
package org.fnives.test.showcase.hilt.compose.screen.auth
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Snackbar
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.accompanist.insets.statusBarsPadding
import org.fnives.test.showcase.hilt.R
@Composable
fun AuthScreen(
modifier: Modifier = Modifier,
authScreenState: AuthScreenState = rememberAuthScreenState()
) {
ConstraintLayout(modifier.fillMaxSize()) {
val (title, credentials, snackbar, loading, login) = createRefs()
Title(
modifier = Modifier
.statusBarsPadding()
.constrainAs(title) { top.linkTo(parent.top) }
)
CredentialsFields(
authScreenState = authScreenState,
modifier = Modifier.constrainAs(credentials) {
top.linkTo(title.bottom)
bottom.linkTo(login.top)
}
)
Snackbar(
authScreenState = authScreenState,
modifier = Modifier.constrainAs(snackbar) {
bottom.linkTo(login.top)
}
)
if (authScreenState.loading) {
CircularProgressIndicator(
Modifier
.testTag(AuthScreenTag.LoadingIndicator)
.constrainAs(loading) {
bottom.linkTo(login.top)
centerHorizontallyTo(parent)
}
)
}
LoginButton(
modifier = Modifier
.constrainAs(login) { bottom.linkTo(parent.bottom) }
.padding(16.dp),
onClick = { authScreenState.onLogin() }
)
}
}
@Composable
private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
Column(
modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
UsernameField(authScreenState)
PasswordField(authScreenState)
}
}
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class)
@Composable
private fun PasswordField(authScreenState: AuthScreenState) {
var passwordVisible by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = authScreenState.password,
label = { Text(text = stringResource(id = R.string.password)) },
placeholder = { Text(text = stringResource(id = R.string.password)) },
trailingIcon = {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password)
Icon(
painter = rememberAnimatedVectorPainter(image, passwordVisible),
contentDescription = null,
modifier = Modifier
.clickable { passwordVisible = !passwordVisible }
.testTag(AuthScreenTag.PasswordVisibilityToggle)
)
},
onValueChange = { authScreenState.onPasswordChanged(it) },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
authScreenState.onLogin()
}),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.testTag(AuthScreenTag.PasswordInput)
)
}
@Composable
private fun UsernameField(authScreenState: AuthScreenState) {
OutlinedTextField(
value = authScreenState.username,
label = { Text(text = stringResource(id = R.string.username)) },
placeholder = { Text(text = stringResource(id = R.string.username)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
onValueChange = { authScreenState.onUsernameChanged(it) },
modifier = Modifier
.fillMaxWidth()
.testTag(AuthScreenTag.UsernameInput)
)
}
@Composable
private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
val snackbarState = remember { SnackbarHostState() }
val error = authScreenState.error
LaunchedEffect(error) {
if (error != null) {
snackbarState.showSnackbar(error.name)
authScreenState.dismissError()
}
}
SnackbarHost(hostState = snackbarState, modifier) {
val stringId = error?.stringResId()
if (stringId != null) {
Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError))
}
}
}
}
@Composable
private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(modifier) {
Button(
onClick = onClick,
Modifier
.fillMaxWidth()
.testTag(AuthScreenTag.LoginButton)
) {
Text(text = "Login")
}
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.login_title),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4
)
}
private fun AuthScreenState.ErrorType.stringResId() = when (this) {
AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid
AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong
AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid
AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid
}
object AuthScreenTag {
const val UsernameInput = "AuthScreenTag.UsernameInput"
const val PasswordInput = "AuthScreenTag.PasswordInput"
const val LoadingIndicator = "AuthScreenTag.LoadingIndicator"
const val LoginButton = "AuthScreenTag.LoginButton"
const val LoginError = "AuthScreenTag.LoginError"
const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle"
}

View file

@ -0,0 +1,109 @@
package org.fnives.test.showcase.hilt.compose.screen.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.fnives.test.showcase.hilt.core.login.LoginUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer
@Composable
fun rememberAuthScreenState(
stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main },
loginUseCase: LoginUseCase = AuthEntryPoint.get().loginUseCase,
onLoginSuccess: () -> Unit = {},
): AuthScreenState {
return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) {
AuthScreenState(stateScope, loginUseCase, onLoginSuccess)
}
}
class AuthScreenState(
private val stateScope: CoroutineScope,
private val loginUseCase: LoginUseCase,
private val onLoginSuccess: () -> Unit = {},
) {
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<ErrorType?>(null)
private set
fun onUsernameChanged(username: String) {
this.username = username
}
fun onPasswordChanged(password: String) {
this.password = password
}
fun onLogin() {
if (loading) {
return
}
loading = true
stateScope.launch {
val credentials = LoginCredentials(
username = username,
password = password
)
when (val response = loginUseCase.invoke(credentials)) {
is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR
is Answer.Success -> processLoginStatus(response.data)
}
loading = false
}
}
private fun processLoginStatus(loginStatus: LoginStatus) {
when (loginStatus) {
LoginStatus.SUCCESS -> onLoginSuccess()
LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS
LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME
LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD
}
}
fun dismissError() {
error = null
}
enum class ErrorType {
INVALID_CREDENTIALS,
GENERAL_NETWORK_ERROR,
UNSUPPORTED_USERNAME,
UNSUPPORTED_PASSWORD
}
companion object {
private const val USERNAME = "USERNAME"
private const val PASSWORD = "PASSWORD"
fun getSaver(
stateScope: CoroutineScope,
loginUseCase: LoginUseCase,
onLoginSuccess: () -> Unit,
): Saver<AuthScreenState, *> = mapSaver(
save = { mapOf(USERNAME to it.username, PASSWORD to it.password) },
restore = {
AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply {
onUsernameChanged(it.getOrElse(USERNAME) { "" } as String)
onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String)
}
}
)
}
}

View file

@ -0,0 +1,33 @@
package org.fnives.test.showcase.hilt.compose.screen.home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase
import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase
import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.hilt.core.login.LogoutUseCase
object HomeEntryPoint {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface MainDependencies {
val getAllContentUseCase: GetAllContentUseCase
val logoutUseCase: LogoutUseCase
val fetchContentUseCase: FetchContentUseCase
val addContentToFavouriteUseCase: AddContentToFavouriteUseCase
val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase
}
@Composable
fun get(): MainDependencies {
val context = LocalContext.current.applicationContext
return remember { EntryPoints.get(context, MainDependencies::class.java) }
}
}

View file

@ -0,0 +1,125 @@
package org.fnives.test.showcase.hilt.compose.screen.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.model.content.FavouriteContent
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
homeScreenState: HomeScreenState = rememberHomeScreenState()
) {
Column(modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Title(Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.logout_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
modifier = Modifier
.padding(16.dp)
.clickable { homeScreenState.onLogout() }
)
}
Box {
if (homeScreenState.isError) {
ErrorText(Modifier.align(Alignment.Center))
}
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading),
onRefresh = {
homeScreenState.onRefresh()
}
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(homeScreenState.content) { item ->
Item(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
favouriteContent = item,
onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) }
)
}
}
}
}
}
}
@Composable
private fun Item(
modifier: Modifier = Modifier,
favouriteContent: FavouriteContent,
onFavouriteToggle: () -> Unit,
) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Image(
painter = rememberImagePainter(favouriteContent.content.imageUrl.url),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.height(120.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
)
Column(
Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
Text(text = favouriteContent.content.title)
Text(text = favouriteContent.content.description)
}
val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
Image(
painter = painterResource(id = favouriteIcon),
contentDescription = null,
Modifier.clickable { onFavouriteToggle() }
)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.login_title),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4
)
}
@Composable
private fun ErrorText(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.something_went_wrong),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4,
textAlign = TextAlign.Center
)
}

View file

@ -0,0 +1,133 @@
package org.fnives.test.showcase.hilt.compose.screen.home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase
import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase
import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.hilt.core.login.LogoutUseCase
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.shared.Resource
@Composable
fun rememberHomeScreenState(
stateScope: CoroutineScope = rememberCoroutineScope(),
mainDependencies: HomeEntryPoint.MainDependencies = HomeEntryPoint.get(),
onLogout: () -> Unit = {},
) =
rememberHomeScreenState(
stateScope = stateScope,
getAllContentUseCase = mainDependencies.getAllContentUseCase,
logoutUseCase = mainDependencies.logoutUseCase,
fetchContentUseCase = mainDependencies.fetchContentUseCase,
addContentToFavouriteUseCase = mainDependencies.addContentToFavouriteUseCase,
removeContentFromFavouritesUseCase = mainDependencies.removeContentFromFavouritesUseCase,
onLogout = onLogout,
)
@Suppress("LongParameterList")
@Composable
fun rememberHomeScreenState(
stateScope: CoroutineScope = rememberCoroutineScope(),
getAllContentUseCase: GetAllContentUseCase,
logoutUseCase: LogoutUseCase,
fetchContentUseCase: FetchContentUseCase,
addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase,
onLogout: () -> Unit = {},
): HomeScreenState {
return remember {
HomeScreenState(
stateScope,
getAllContentUseCase,
logoutUseCase,
fetchContentUseCase,
addContentToFavouriteUseCase,
removeContentFromFavouritesUseCase,
onLogout,
)
}
}
@Suppress("LongParameterList")
class HomeScreenState(
private val stateScope: CoroutineScope,
private val getAllContentUseCase: GetAllContentUseCase,
private val logoutUseCase: LogoutUseCase,
private val fetchContentUseCase: FetchContentUseCase,
private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase,
private val logoutEvent: () -> Unit,
) {
var loading by mutableStateOf(false)
private set
var isError by mutableStateOf(false)
private set
var content by mutableStateOf<List<FavouriteContent>>(emptyList())
private set
init {
stateScope.launch {
fetch().collect {
content = it
}
}
}
private fun fetch() = getAllContentUseCase.get()
.mapNotNull {
when (it) {
is Resource.Error -> {
isError = true
loading = false
return@mapNotNull emptyList<FavouriteContent>()
}
is Resource.Loading -> {
isError = false
loading = true
return@mapNotNull null
}
is Resource.Success -> {
isError = false
loading = false
return@mapNotNull it.data
}
}
}
fun onLogout() {
stateScope.launch {
logoutUseCase.invoke()
logoutEvent()
}
}
fun onRefresh() {
if (loading) return
loading = true
stateScope.launch {
fetchContentUseCase.invoke()
}
}
fun onFavouriteToggleClicked(contentId: ContentId) {
stateScope.launch {
val item = content.firstOrNull { it.content.id == contentId } ?: return@launch
if (item.isFavourite) {
removeContentFromFavouritesUseCase.invoke(contentId)
} else {
addContentToFavouriteUseCase.invoke(contentId)
}
}
}
}

View file

@ -0,0 +1,37 @@
package org.fnives.test.showcase.hilt.compose.screen.splash
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.fnives.test.showcase.hilt.R
@Composable
fun SplashScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(colorResource(R.color.purple_700)),
contentAlignment = Alignment.Center
) {
val resourceId = if (VERSION.SDK_INT >= VERSION_CODES.N) {
R.drawable.ic_launcher_foreground
} else {
R.mipmap.ic_launcher_round
}
Image(
painter = painterResource(resourceId),
contentDescription = null,
modifier = Modifier.size(120.dp)
)
}
}

View file

@ -0,0 +1,42 @@
package org.fnives.test.showcase.hilt.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener
import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.hilt.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteContentLocalStorageImpl
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object AppModule {
@Provides
fun enableLogging(): Boolean = true
@Singleton
@Provides
fun provideFavouriteDao(localDatabase: LocalDatabase) =
localDatabase.favouriteDao
@Provides
fun provideSharedPreferencesManagerImpl(@ApplicationContext context: Context) =
SharedPreferencesManagerImpl.create(context)
@Provides
fun provideFavouriteContentLocalStorage(
favouriteContentLocalStorageImpl: FavouriteContentLocalStorageImpl
): FavouriteContentLocalStorage = favouriteContentLocalStorageImpl
@Provides
internal fun bindSessionExpirationListener(
sessionExpirationListenerImpl: SessionExpirationListenerImpl
): SessionExpirationListener = sessionExpirationListenerImpl
}

View file

@ -0,0 +1,16 @@
package org.fnives.test.showcase.hilt.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.BuildConfig
@InstallIn(SingletonComponent::class)
@Module
object BaseUrlModule {
@Provides
fun provideBaseUrl(): String = BuildConfig.BASE_URL
}

View file

@ -0,0 +1,20 @@
package org.fnives.test.showcase.hilt.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.storage.database.DatabaseInitialization
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object StorageModule {
@Singleton
@Provides
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
DatabaseInitialization.create(context)
}

View file

@ -0,0 +1,20 @@
package org.fnives.test.showcase.hilt.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object UserDataLocalStorageModule {
@Singleton
@Provides
fun provideUserDataLocalStorage(
sharedPreferencesManagerImpl: SharedPreferencesManagerImpl
): UserDataLocalStorage = sharedPreferencesManagerImpl
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.hilt.session
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
import javax.inject.Inject
class SessionExpirationListenerImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : SessionExpirationListener {
override fun onSessionExpired() {
Handler(Looper.getMainLooper()).post {
context.startActivity(
IntentCoordinator.authActivitygetStartIntent(context)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}

View file

@ -0,0 +1,16 @@
package org.fnives.test.showcase.hilt.storage
import androidx.room.Database
import androidx.room.RoomDatabase
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteDao
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity
@Database(
entities = [FavouriteEntity::class],
version = 2,
exportSchema = true
)
abstract class LocalDatabase : RoomDatabase() {
abstract val favouriteDao: FavouriteDao
}

View file

@ -0,0 +1,68 @@
package org.fnives.test.showcase.hilt.storage
import android.content.Context
import android.content.SharedPreferences
import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.model.session.Session
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SharedPreferencesManagerImpl(
private val sharedPreferences: SharedPreferences
) : UserDataLocalStorage {
override var session: Session? by SessionDelegate(SESSION_KEY)
private class SessionDelegate(private val key: String) :
ReadWriteProperty<SharedPreferencesManagerImpl, Session?> {
override fun setValue(
thisRef: SharedPreferencesManagerImpl,
property: KProperty<*>,
value: Session?
) {
if (value == null) {
thisRef.sharedPreferences.edit().remove(key).apply()
} else {
val values = setOf(
ACCESS_TOKEN_KEY + value.accessToken,
REFRESH_TOKEN_KEY + value.refreshToken
)
thisRef.sharedPreferences.edit().putStringSet(key, values).apply()
}
}
override fun getValue(
thisRef: SharedPreferencesManagerImpl,
property: KProperty<*>
): Session? {
val values = thisRef.sharedPreferences.getStringSet(key, null)?.toList()
val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) }
?.drop(ACCESS_TOKEN_KEY.length) ?: return null
val refreshToken = values.firstOrNull { it.startsWith(REFRESH_TOKEN_KEY) }
?.drop(REFRESH_TOKEN_KEY.length) ?: return null
return Session(accessToken = accessToken, refreshToken = refreshToken)
}
companion object {
private const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY"
private const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY"
}
}
companion object {
private const val SESSION_KEY = "SESSION_KEY"
private const val SESSION_SHARED_PREFERENCES_NAME = "SESSION_SHARED_PREFERENCES_NAME"
fun create(context: Context): SharedPreferencesManagerImpl {
val sharedPreferences = context.getSharedPreferences(
SESSION_SHARED_PREFERENCES_NAME,
Context.MODE_PRIVATE
)
return SharedPreferencesManagerImpl(sharedPreferences)
}
}
}

View file

@ -0,0 +1,15 @@
package org.fnives.test.showcase.hilt.storage.database
import android.content.Context
import androidx.room.Room
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.storage.migation.Migration1To2
object DatabaseInitialization {
fun create(context: Context): LocalDatabase =
Room.databaseBuilder(context, LocalDatabase::class.java, "local_database")
.addMigrations(Migration1To2())
.allowMainThreadQueries()
.build()
}

View file

@ -0,0 +1,23 @@
package org.fnives.test.showcase.hilt.storage.favourite
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
import javax.inject.Inject
class FavouriteContentLocalStorageImpl @Inject constructor(
private val favouriteDao: FavouriteDao
) : FavouriteContentLocalStorage {
override fun observeFavourites(): Flow<List<ContentId>> =
favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) }
override suspend fun markAsFavourite(contentId: ContentId) {
favouriteDao.addFavourite(FavouriteEntity(contentId.id))
}
override suspend fun deleteAsFavourite(contentId: ContentId) {
favouriteDao.deleteFavourite(FavouriteEntity(contentId.id))
}
}

View file

@ -0,0 +1,21 @@
package org.fnives.test.showcase.hilt.storage.favourite
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface FavouriteDao {
@Query("SELECT * FROM FavouriteEntity")
fun get(): Flow<List<FavouriteEntity>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addFavourite(favouriteEntity: FavouriteEntity)
@Delete
suspend fun deleteFavourite(favouriteEntity: FavouriteEntity)
}

View file

@ -0,0 +1,11 @@
package org.fnives.test.showcase.hilt.storage.favourite
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class FavouriteEntity(
@ColumnInfo(name = "content_id")
@PrimaryKey val contentId: String
)

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.hilt.storage.migation
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration1To2 : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE FavouriteEntity RENAME TO FavouriteEntityOld")
database.execSQL("CREATE TABLE FavouriteEntity(content_id TEXT NOT NULL PRIMARY KEY)")
database.execSQL("INSERT INTO FavouriteEntity(content_id) SELECT contentId FROM FavouriteEntityOld")
database.execSQL("DROP TABLE FavouriteEntityOld")
}
}

View file

@ -0,0 +1,15 @@
package org.fnives.test.showcase.hilt.ui
import android.content.Context
import android.content.Intent
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.hilt.ui.home.MainActivity
object IntentCoordinator {
fun mainActivitygetStartIntent(context: Context): Intent =
MainActivity.getStartIntent(context)
fun authActivitygetStartIntent(context: Context): Intent =
AuthActivity.getStartIntent(context)
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.ui
import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStoreOwner
import androidx.activity.viewModels as androidxViewModel
inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(): Lazy<T> =
when (this) {
is ComponentActivity -> androidxViewModel()
else -> throw IllegalStateException("Only supports activity viewModel for now")
}

View file

@ -0,0 +1,57 @@
package org.fnives.test.showcase.hilt.ui.auth
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.databinding.ActivityAuthenticationBinding
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
import org.fnives.test.showcase.hilt.ui.viewModels
@AndroidEntryPoint
class AuthActivity : AppCompatActivity() {
private val viewModel by viewModels<AuthViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityAuthenticationBinding.inflate(layoutInflater)
viewModel.loading.observe(this) {
binding.loadingIndicator.isVisible = it == true
}
viewModel.password.observe(this, SetTextIfNotSameObserver(binding.passwordEditText))
binding.passwordEditText.doAfterTextChanged { viewModel.onPasswordChanged(it?.toString().orEmpty()) }
viewModel.username.observe(this, SetTextIfNotSameObserver(binding.userEditText))
binding.userEditText.doAfterTextChanged { viewModel.onUsernameChanged(it?.toString().orEmpty()) }
binding.loginCta.setOnClickListener {
viewModel.onLogin()
}
viewModel.error.observe(this) {
val stringResId = it?.consume()?.stringResId() ?: return@observe
Snackbar.make(binding.snackbarHolder, stringResId, Snackbar.LENGTH_LONG).show()
}
viewModel.navigateToHome.observe(this) {
it.consume() ?: return@observe
startActivity(IntentCoordinator.mainActivitygetStartIntent(this))
finishAffinity()
}
setContentView(binding.root)
}
companion object {
private fun AuthViewModel.ErrorType.stringResId() = when (this) {
AuthViewModel.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid
AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong
AuthViewModel.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid
AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid
}
fun getStartIntent(context: Context): Intent = Intent(context, AuthActivity::class.java)
}
}

View file

@ -0,0 +1,69 @@
package org.fnives.test.showcase.hilt.ui.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.fnives.test.showcase.hilt.core.login.LoginUseCase
import org.fnives.test.showcase.hilt.ui.shared.Event
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(private val loginUseCase: LoginUseCase) : ViewModel() {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username
private val _password = MutableLiveData<String>()
val password: LiveData<String> = _password
private val _loading = MutableLiveData<Boolean>(false)
val loading: LiveData<Boolean> = _loading
private val _error = MutableLiveData<Event<ErrorType>>()
val error: LiveData<Event<ErrorType>> = _error
private val _navigateToHome = MutableLiveData<Event<Unit>>()
val navigateToHome: LiveData<Event<Unit>> = _navigateToHome
fun onPasswordChanged(password: String) {
_password.value = password
}
fun onUsernameChanged(username: String) {
_username.value = username
}
fun onLogin() {
if (_loading.value == true) return
_loading.value = true
viewModelScope.launch {
val credentials = LoginCredentials(
username = _username.value.orEmpty(),
password = _password.value.orEmpty()
)
when (val response = loginUseCase.invoke(credentials)) {
is Answer.Error -> _error.value = Event(ErrorType.GENERAL_NETWORK_ERROR)
is Answer.Success -> processLoginStatus(response.data)
}
_loading.postValue(false)
}
}
private fun processLoginStatus(loginStatus: LoginStatus) {
when (loginStatus) {
LoginStatus.SUCCESS -> _navigateToHome.value = Event(Unit)
LoginStatus.INVALID_CREDENTIALS -> _error.value = Event(ErrorType.INVALID_CREDENTIALS)
LoginStatus.INVALID_USERNAME -> _error.value = Event(ErrorType.UNSUPPORTED_USERNAME)
LoginStatus.INVALID_PASSWORD -> _error.value = Event(ErrorType.UNSUPPORTED_PASSWORD)
}
}
enum class ErrorType {
INVALID_CREDENTIALS,
GENERAL_NETWORK_ERROR,
UNSUPPORTED_USERNAME,
UNSUPPORTED_PASSWORD
}
}

View file

@ -0,0 +1,13 @@
package org.fnives.test.showcase.hilt.ui.auth
import android.widget.EditText
import androidx.lifecycle.Observer
class SetTextIfNotSameObserver(private val editText: EditText) : Observer<String> {
override fun onChanged(t: String?) {
val current = editText.text?.toString()
if (current != t) {
editText.setText(t)
}
}
}

View file

@ -0,0 +1,55 @@
package org.fnives.test.showcase.hilt.ui.home
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.databinding.ItemFavouriteContentBinding
import org.fnives.test.showcase.hilt.ui.shared.ViewBindingAdapter
import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor
import org.fnives.test.showcase.hilt.ui.shared.layoutInflater
import org.fnives.test.showcase.hilt.ui.shared.loadRoundedImage
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
class FavouriteContentAdapter(
private val listener: OnFavouriteItemClicked,
) : ListAdapter<FavouriteContent, ViewBindingAdapter<ItemFavouriteContentBinding>>(
AsyncDifferConfig.Builder(DiffUtilItemCallback())
.setBackgroundThreadExecutor(AsyncTaskExecutor.iOThreadExecutor)
.build()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewBindingAdapter<ItemFavouriteContentBinding> =
ViewBindingAdapter(ItemFavouriteContentBinding.inflate(parent.layoutInflater(), parent, false)).apply {
viewBinding.favouriteCta.setOnClickListener {
if (adapterPosition in 0 until itemCount) {
listener.onFavouriteToggleClicked(getItem(adapterPosition).content.id)
}
}
}
override fun onBindViewHolder(holder: ViewBindingAdapter<ItemFavouriteContentBinding>, position: Int) {
val item = getItem(position)
holder.viewBinding.img.loadRoundedImage(item.content.imageUrl)
holder.viewBinding.title.text = item.content.title
holder.viewBinding.description.text = item.content.description
val favouriteResId = if (item.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
holder.viewBinding.favouriteCta.setImageResource(favouriteResId)
}
interface OnFavouriteItemClicked {
fun onFavouriteToggleClicked(contentId: ContentId)
}
class DiffUtilItemCallback : DiffUtil.ItemCallback<FavouriteContent>() {
override fun areItemsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean =
oldItem.content.id == newItem.content.id
override fun areContentsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean =
oldItem == newItem
override fun getChangePayload(oldItem: FavouriteContent, newItem: FavouriteContent): Any? = oldItem
}
}

View file

@ -0,0 +1,72 @@
package org.fnives.test.showcase.hilt.ui.home
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.databinding.ActivityMainBinding
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
import org.fnives.test.showcase.hilt.ui.shared.VerticalSpaceItemDecoration
import org.fnives.test.showcase.hilt.ui.shared.getThemePrimaryColor
import org.fnives.test.showcase.hilt.ui.viewModels
import org.fnives.test.showcase.model.content.ContentId
@AndroidEntryPoint
open class MainActivity : AppCompatActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
binding.toolbar.menu?.findItem(R.id.logout_cta)?.setOnMenuItemClickListener {
viewModel.onLogout()
true
}
binding.swipeRefreshLayout.setColorSchemeColors(binding.swipeRefreshLayout.getThemePrimaryColor())
binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.onRefresh()
}
val adapter = FavouriteContentAdapter(viewModel.mapToAdapterListener())
binding.recycler.layoutManager = LinearLayoutManager(this)
binding.recycler.addItemDecoration(
VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.padding))
)
binding.recycler.adapter = adapter
viewModel.content.observe(this) {
adapter.submitList(it.orEmpty())
}
viewModel.errorMessage.observe(this) {
binding.errorMessage.isVisible = it == true
}
viewModel.navigateToAuth.observe(this) {
it.consume() ?: return@observe
startActivity(IntentCoordinator.authActivitygetStartIntent(this))
finishAffinity()
}
viewModel.loading.observe(this) {
if (binding.swipeRefreshLayout.isRefreshing != it) {
binding.swipeRefreshLayout.isRefreshing = it == true
}
}
setContentView(binding.root)
}
companion object {
fun getStartIntent(context: Context): Intent = Intent(context, MainActivity::class.java)
private fun MainViewModel.mapToAdapterListener(): FavouriteContentAdapter.OnFavouriteItemClicked =
object : FavouriteContentAdapter.OnFavouriteItemClicked {
override fun onFavouriteToggleClicked(contentId: ContentId) {
this@mapToAdapterListener.onFavouriteToggleClicked(contentId)
}
}
}
}

View file

@ -0,0 +1,84 @@
package org.fnives.test.showcase.hilt.ui.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase
import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase
import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.hilt.core.login.LogoutUseCase
import org.fnives.test.showcase.hilt.ui.shared.Event
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.shared.Resource
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val getAllContentUseCase: GetAllContentUseCase,
private val logoutUseCase: LogoutUseCase,
private val fetchContentUseCase: FetchContentUseCase,
private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase
) : ViewModel() {
private val _loading = MutableLiveData<Boolean>()
val loading: LiveData<Boolean> = _loading
private val _content: LiveData<List<FavouriteContent>> = liveData {
getAllContentUseCase.get().collect {
when (it) {
is Resource.Error -> {
_errorMessage.value = true
_loading.value = false
emit(emptyList<FavouriteContent>())
}
is Resource.Loading -> {
_errorMessage.value = false
_loading.value = true
}
is Resource.Success -> {
_errorMessage.value = false
_loading.value = false
emit(it.data)
}
}
}
}
val content: LiveData<List<FavouriteContent>> = _content
private val _errorMessage = MutableLiveData<Boolean>(false)
val errorMessage: LiveData<Boolean> = _errorMessage.distinctUntilChanged()
private val _navigateToAuth = MutableLiveData<Event<Unit>>()
val navigateToAuth: LiveData<Event<Unit>> = _navigateToAuth
fun onLogout() {
viewModelScope.launch {
logoutUseCase.invoke()
_navigateToAuth.value = Event(Unit)
}
}
fun onRefresh() {
if (_loading.value == true) return
_loading.value = true
viewModelScope.launch {
fetchContentUseCase.invoke()
}
}
fun onFavouriteToggleClicked(contentId: ContentId) {
viewModelScope.launch {
val content = _content.value?.firstOrNull { it.content.id == contentId } ?: return@launch
if (content.isFavourite) {
removeContentFromFavouritesUseCase.invoke(contentId)
} else {
addContentToFavouriteUseCase.invoke(contentId)
}
}
}
}

View file

@ -0,0 +1,11 @@
package org.fnives.test.showcase.hilt.ui.shared
@Suppress("DataClassContainsFunctions")
data class Event<T : Any>(private val data: T) {
private var consumed: Boolean = false
fun consume(): T? = data.takeUnless { consumed }?.also { consumed = true }
fun peek() = data
}

View file

@ -0,0 +1,13 @@
package org.fnives.test.showcase.hilt.ui.shared
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.set(0, 0, 0, verticalSpaceHeight)
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.hilt.ui.shared
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
class ViewBindingAdapter<T : ViewBinding>(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root)

View file

@ -0,0 +1,24 @@
package org.fnives.test.showcase.hilt.ui.shared
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import coil.load
import coil.transform.RoundedCornersTransformation
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.model.content.ImageUrl
fun View.layoutInflater(): LayoutInflater = LayoutInflater.from(context)
fun ImageView.loadRoundedImage(imageUrl: ImageUrl) {
load(imageUrl.url) {
transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.rounded_corner)))
}
}
fun View.getThemePrimaryColor(): Int {
val value = TypedValue()
context.theme.resolveAttribute(R.attr.colorPrimary, value, true)
return value.data
}

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.hilt.ui.shared.executor
import java.util.concurrent.Executor
/**
* Basic copy of [ArchTaskExecutor][androidx.arch.core.executor.ArchTaskExecutor], needed because that is restricted to Library.
*
* Intended to be used for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] so it can be synchronized with Espresso.
*
* Workaround until https://github.com/android/android-test/issues/382 is fixed finally.
*/
object AsyncTaskExecutor : TaskExecutor {
val mainThreadExecutor = Executor { command -> postToMainThread(command) }
val iOThreadExecutor = Executor { command -> executeOnDiskIO(command) }
var delegate: TaskExecutor? = null
private val defaultExecutor by lazy { DefaultTaskExecutor() }
private val executor get() = delegate ?: defaultExecutor
override fun executeOnDiskIO(runnable: Runnable) {
executor.executeOnDiskIO(runnable)
}
override fun postToMainThread(runnable: Runnable) {
executor.postToMainThread(runnable)
}
}

View file

@ -0,0 +1,34 @@
package org.fnives.test.showcase.hilt.ui.shared.executor
import android.os.Build
import android.os.Handler
import android.os.Looper
import java.util.concurrent.Executors
/**
* Basic copy of [androidx.arch.core.executor.DefaultTaskExecutor], needed because that is restricted to Library.
* With a Flavour of [androidx.recyclerview.widget.AsyncDifferConfig].
* Used within [AsyncTaskExecutor].
*
* Intended to be used for AsyncDiffUtil so it can be synchronized with Espresso.
*/
class DefaultTaskExecutor : TaskExecutor {
private val diskIO = Executors.newFixedThreadPool(2)
private val mMainHandler: Handler by lazy { createAsync(Looper.getMainLooper()) }
override fun executeOnDiskIO(runnable: Runnable) {
diskIO.execute(runnable)
}
override fun postToMainThread(runnable: Runnable) {
mMainHandler.post(runnable)
}
private fun createAsync(looper: Looper): Handler =
if (Build.VERSION.SDK_INT >= 28) {
Handler.createAsync(looper)
} else {
Handler(looper)
}
}

View file

@ -0,0 +1,10 @@
package org.fnives.test.showcase.hilt.ui.shared.executor
/**
* Define TaskExecutor intended for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig]
*/
interface TaskExecutor {
fun executeOnDiskIO(runnable: Runnable)
fun postToMainThread(runnable: Runnable)
}

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.hilt.ui.splash
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.ui.IntentCoordinator
import org.fnives.test.showcase.hilt.ui.viewModels
@SuppressLint("CustomSplashScreen")
@AndroidEntryPoint
open class SplashActivity : AppCompatActivity() {
private val viewModel by viewModels<SplashViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
viewModel.navigateTo.observe(this) {
val intent = when (it.consume()) {
SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this)
SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this)
null -> return@observe
}
startActivity(intent)
finishAffinity()
}
}
}

View file

@ -0,0 +1,31 @@
package org.fnives.test.showcase.hilt.ui.splash
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.hilt.ui.shared.Event
import javax.inject.Inject
@HiltViewModel
class SplashViewModel @Inject constructor(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() {
private val _navigateTo = MutableLiveData<Event<NavigateTo>>()
val navigateTo: LiveData<Event<NavigateTo>> = _navigateTo
init {
viewModelScope.launch {
delay(500L)
val navigationEvent = if (isUserLoggedInUseCase.invoke()) NavigateTo.HOME else NavigateTo.AUTHENTICATION
_navigateTo.value = Event(navigationEvent)
}
}
enum class NavigateTo {
HOME, AUTHENTICATION
}
}

View file

@ -0,0 +1,46 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- shadow -->
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- "compose icon" -->
<path
android:pathData="M20,34 L20,68 L40,88"
android:strokeWidth="15"
android:strokeColor="#132d3d" />
<path
android:pathData="M40,88 L68,68 L68,34"
android:strokeWidth="15"
android:strokeColor="#4d7fe0" />
<path
android:pathData="M18,38 L44,18 L72,38"
android:strokeWidth="15"
android:strokeColor="#6bcd85" />
<!-- android head -->
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="49.59793"
android:startX="42.9492"
android:endY="92.4963"
android:endX="85.84757"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/purple_700"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface"
android:autoMirrored="true">
<path
android:fillColor="@color/white"
android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="NewApi">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp">
<path
android:name="strike_through"
android:pathData="@string/path_password_strike_through"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"
tools:ignore="PrivateResource" />
<group>
<clip-path
android:name="eye_mask"
android:pathData="@string/path_password_eye_mask_strike_through"
tools:ignore="PrivateResource" />
<path
android:fillColor="@android:color/white"
android:name="eye"
android:pathData="@string/path_password_eye"
tools:ignore="PrivateResource" />
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="pathData"
android:valueFrom="@string/path_password_eye_mask_strike_through"
android:valueTo="@string/path_password_eye_mask_visible"
android:valueType="pathType"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
</animated-vector>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:minHeight="?attr/actionBarSize"
android:elevation="@dimen/toolbar_elevation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/login_title" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/user_input"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:hint="@string/username"
app:layout_constraintBottom_toTopOf="@id/password_input"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintVertical_bias="0.2"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/user_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:lines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_input"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/default_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:hint="@string/password"
app:layout_constraintBottom_toTopOf="@id/login_cta"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/user_input"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:lines="1" />
</com.google.android.material.textfield.TextInputLayout>
<ProgressBar
android:id="@+id/loading_indicator"
style="?attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/default_margin"
app:layout_constraintBottom_toTopOf="@id/login_cta"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/snackbar_holder"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
app:layout_constraintBottom_toTopOf="@id/login_cta"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/login_cta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/default_margin"
app:layout_constraintHeight_min="@dimen/default_button_height"
android:text="@string/login"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.home.MainActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:background="?attr/colorSurface"
android:elevation="@dimen/toolbar_elevation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/main"
app:title="@string/content" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/item_favourite_content" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/error_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:text="@string/something_went_wrong"
android:gravity="center"
android:textAppearance="?attr/textAppearanceHeadline4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="?attr/colorSurface">
<ImageView
android:layout_width="@dimen/content_img_height"
android:layout_gravity="center"
app:srcCompat="@mipmap/ic_launcher_round"
android:layout_height="@dimen/content_img_height"
tools:ignore="ContentDescription" />
</FrameLayout>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/img"
android:layout_width="@dimen/content_img_height"
android:layout_height="@dimen/content_img_height"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/default_margin"
android:layout_marginBottom="@dimen/padding"
android:textAppearance="?attr/textAppearanceHeadline6"
app:layout_constraintBottom_toTopOf="@id/description"
app:layout_constraintStart_toEndOf="@id/img"
app:layout_constraintEnd_toStartOf="@id/favourite_cta"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@tools:sample/last_names" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/default_margin"
android:layout_marginTop="@dimen/padding"
android:textAppearance="?attr/textAppearanceSubtitle2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/favourite_cta"
app:layout_constraintStart_toEndOf="@id/img"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="@tools:sample/last_names" />
<ImageView
android:id="@+id/favourite_cta"
android:background="?attr/selectableItemBackgroundBorderless"
android:layout_width="@dimen/touch_target_size"
android:layout_height="@dimen/touch_target_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@drawable/favorite_24" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/logout_cta"
android:icon="@drawable/logout_24"
android:title="@string/logout"
app:showAsAction="always" />
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_compose_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_compose_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Some files were not shown because too many files have changed in this diff Show more