initial commit

This commit is contained in:
Gergely Hegedus 2021-04-07 21:12:10 +03:00
parent 85ef73b2ba
commit 90a9426b7d
221 changed files with 7611 additions and 0 deletions

View file

@ -0,0 +1,28 @@
<?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">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name=".TestShowcaseApplication"
android:theme="@style/Theme.TestShowCase"
tools:ignore="AllowBackup">
<activity android:name=".ui.splash.SplashActivity">
<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"/>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase
import android.app.Application
import org.fnives.test.showcase.di.BaseUrlProvider
import org.fnives.test.showcase.di.createAppModules
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class TestShowcaseApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@TestShowcaseApplication)
modules(createAppModules(BaseUrlProvider.get()))
}
}
}

View file

@ -0,0 +1,9 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.BuildConfig
import org.fnives.test.showcase.model.network.BaseUrl
object BaseUrlProvider {
fun get() = BaseUrl(BuildConfig.BASE_URL)
}

View file

@ -0,0 +1,55 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.storage.LocalDatabase
import org.fnives.test.showcase.storage.SharedPreferencesManagerImpl
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl
import org.fnives.test.showcase.ui.auth.AuthViewModel
import org.fnives.test.showcase.ui.home.MainViewModel
import org.fnives.test.showcase.ui.splash.SplashViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.dsl.module
fun createAppModules(baseUrl: BaseUrl): List<Module> {
return createCoreModule(
baseUrl = baseUrl,
true,
userDataLocalStorageProvider = { get<SharedPreferencesManagerImpl>() },
sessionExpirationListenerProvider = { get<SessionExpirationListenerImpl>() },
favouriteContentLocalStorageProvider = { get<FavouriteContentLocalStorageImpl>() }
)
.plus(storageModule())
.plus(authModule())
.plus(appModule())
.plus(favouriteModule())
.plus(splashModule())
.toList()
}
fun storageModule() = module {
single { SharedPreferencesManagerImpl.create(androidContext()) }
single { DatabaseInitialization.create(androidContext()) }
}
fun authModule() = module {
viewModel { AuthViewModel(get()) }
}
fun appModule() = module {
single { SessionExpirationListenerImpl(androidContext()) }
}
fun splashModule() = module {
viewModel { SplashViewModel(get()) }
}
fun favouriteModule() = module {
single { get<LocalDatabase>().favouriteDao }
viewModel { MainViewModel(get(), get(), get(), get(), get()) }
single { FavouriteContentLocalStorageImpl(get()) }
}

View file

@ -0,0 +1,21 @@
package org.fnives.test.showcase.session
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.ui.auth.AuthActivity
class SessionExpirationListenerImpl(private val context: Context) : SessionExpirationListener {
override fun onSessionExpired() {
Handler(Looper.getMainLooper()).post {
context.startActivity(
AuthActivity.getStartIntent(context)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}

View file

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

View file

@ -0,0 +1,58 @@
package org.fnives.test.showcase.storage
import android.content.Context
import android.content.SharedPreferences
import org.fnives.test.showcase.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,13 @@
package org.fnives.test.showcase.storage.database
import android.content.Context
import androidx.room.Room
import org.fnives.test.showcase.storage.LocalDatabase
object DatabaseInitialization {
fun create(context: Context): LocalDatabase =
Room.databaseBuilder(context, LocalDatabase::class.java, "local_database")
.allowMainThreadQueries()
.build()
}

View file

@ -0,0 +1,19 @@
package org.fnives.test.showcase.storage.favourite
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
class FavouriteContentLocalStorageImpl(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.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,7 @@
package org.fnives.test.showcase.storage.favourite
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class FavouriteEntity(@PrimaryKey val contentId: String)

View file

@ -0,0 +1,55 @@
package org.fnives.test.showcase.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 org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding
import org.fnives.test.showcase.ui.home.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
class AuthActivity : AppCompatActivity() {
private val viewModel by viewModel<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(MainActivity.getStartIntent(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,66 @@
package org.fnives.test.showcase.ui.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.fnives.test.showcase.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
import org.fnives.test.showcase.ui.shared.Event
class AuthViewModel(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.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,51 @@
package org.fnives.test.showcase.ui.home
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ItemFavouriteContentBinding
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.ui.shared.ViewBindingAdapter
import org.fnives.test.showcase.ui.shared.layoutInflater
import org.fnives.test.showcase.ui.shared.loadRoundedImage
class FavouriteContentAdapter(
private val listener: OnFavouriteItemClicked,
) : ListAdapter<FavouriteContent, ViewBindingAdapter<ItemFavouriteContentBinding>>(
DiffUtilItemCallback()
) {
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,68 @@
package org.fnives.test.showcase.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 org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ActivityMainBinding
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.shared.VerticalSpaceItemDecoration
import org.fnives.test.showcase.ui.shared.getThemePrimaryColor
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : AppCompatActivity() {
private val viewModel by viewModel<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(AuthActivity.getStartIntent(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,81 @@
package org.fnives.test.showcase.ui.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.FetchContentUseCase
import org.fnives.test.showcase.core.content.GetAllContentUseCase
import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.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
import org.fnives.test.showcase.ui.shared.Event
class MainViewModel(
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
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.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.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.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.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.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,27 @@
package org.fnives.test.showcase.ui.splash
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.fnives.test.showcase.R
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
class SplashActivity : AppCompatActivity() {
private val viewModel by viewModel<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 -> MainActivity.getStartIntent(this)
SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this)
null -> return@observe
}
startActivity(intent)
finishAffinity()
}
}
}

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.ui.splash
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.ui.shared.Event
class SplashViewModel(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,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,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_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: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 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: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 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: 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,13 @@
<resources>
<string name="app_name">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>