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

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

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

@ -0,0 +1 @@
/build

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.TestShowCase" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_horizontal_margin">24dp</dimen>
<dimen name="default_button_height">56dp</dimen>
<dimen name="default_margin">16dp</dimen>
<dimen name="toolbar_elevation">8dp</dimen>
<dimen name="content_img_height">120dp</dimen>
<dimen name="padding">6dp</dimen>
<dimen name="touch_target_size">48dp</dimen>
<dimen name="rounded_corner">12dp</dimen>
</resources>

View file

@ -0,0 +1,14 @@
<resources>
<string name="app_name">Hilt Test ShowCase</string>
<string name="app_name_compose">Hilt Compose Test ShowCase</string>
<string name="login">Login</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="username_is_invalid">Username is not filled properly!</string>
<string name="password_is_invalid">Password is not filled properly!</string>
<string name="credentials_invalid">No User with given credentials!</string>
<string name="something_went_wrong">Something went wrong!</string>
<string name="login_title">Mock Login</string>
<string name="content">Content</string>
<string name="logout">Logout</string>
</resources>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.TestShowCase" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

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