Issue#41 Copy full example into separate module with Hilt Integration
This commit is contained in:
parent
69e76dc0da
commit
52a99a82fc
229 changed files with 8416 additions and 11 deletions
1
hilt/hilt-app-shared-test/.gitignore
vendored
Normal file
1
hilt/hilt-app-shared-test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
49
hilt/hilt-app-shared-test/build.gradle
Normal file
49
hilt/hilt-app-shared-test/build.gradle
Normal 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)
|
||||
}
|
||||
0
hilt/hilt-app-shared-test/consumer-rules.pro
Normal file
0
hilt/hilt-app-shared-test/consumer-rules.pro
Normal file
21
hilt/hilt-app-shared-test/proguard-rules.pro
vendored
Normal file
21
hilt/hilt-app-shared-test/proguard-rules.pro
vendored
Normal 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
|
||||
5
hilt/hilt-app-shared-test/src/main/AndroidManifest.xml
Normal file
5
hilt/hilt-app-shared-test/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1
hilt/hilt-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
127
hilt/hilt-app/build.gradle
Normal file
127
hilt/hilt-app/build.gradle
Normal 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'
|
||||
0
hilt/hilt-app/consumer-rules.pro
Normal file
0
hilt/hilt-app/consumer-rules.pro
Normal file
21
hilt/hilt-app/proguard-rules.pro
vendored
Normal file
21
hilt/hilt-app/proguard-rules.pro
vendored
Normal 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
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
44
hilt/hilt-app/src/main/AndroidManifest.xml
Normal file
44
hilt/hilt-app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.fnives.test.showcase.hilt
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TestShowcaseApplication : Application()
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
10
hilt/hilt-app/src/main/res/drawable/favorite_24.xml
Normal file
10
hilt/hilt-app/src/main/res/drawable/favorite_24.xml
Normal 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>
|
||||
10
hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml
Normal file
10
hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
11
hilt/hilt-app/src/main/res/drawable/logout_24.xml
Normal file
11
hilt/hilt-app/src/main/res/drawable/logout_24.xml
Normal 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>
|
||||
92
hilt/hilt-app/src/main/res/drawable/show_password.xml
Normal file
92
hilt/hilt-app/src/main/res/drawable/show_password.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
54
hilt/hilt-app/src/main/res/layout/activity_main.xml
Normal file
54
hilt/hilt-app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
16
hilt/hilt-app/src/main/res/layout/activity_splash.xml
Normal file
16
hilt/hilt-app/src/main/res/layout/activity_splash.xml
Normal 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>
|
||||
56
hilt/hilt-app/src/main/res/layout/item_favourite_content.xml
Normal file
56
hilt/hilt-app/src/main/res/layout/item_favourite_content.xml
Normal 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>
|
||||
9
hilt/hilt-app/src/main/res/menu/main.xml
Normal file
9
hilt/hilt-app/src/main/res/menu/main.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png
Normal file
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
BIN
hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue