Merge pull request #4 from hakodeveloper/feature/userlist

Userlist Implementation
This commit is contained in:
Carlos Martinez
2020-02-03 15:36:28 -03:00
committed by GitHub
60 changed files with 1095 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
apply from: 'versions.gradle'
apply from: '../versions.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
@@ -15,6 +15,9 @@ android {
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
buildConfigField "String", "DB_NAME", '"friendlists.db"'
buildConfigField "String", "BASE_ENDPOINT", '"https://jsonplaceholder.typicode.com/"'
}
buildTypes {
@@ -45,4 +48,5 @@ android {
dependencies {
implementation project(":base")
implementation project(":userlist")
}

View File

@@ -1,11 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.hako.friendlists">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name=".MainApplication"
android:allowBackup="true"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme" />
android:supportsRtl="false"
android:theme="@style/AppTheme">
<activity
android:name=".view.MainActivity"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,34 @@
package com.hako.friendlists
import android.app.Application
import com.hako.userlist.di.userlistModules
import com.hako.friendlists.di.appModules
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import timber.log.Timber
@Suppress("unused")
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
setupLogger()
setupDi()
}
private fun setupLogger() {
Timber.plant(Timber.DebugTree())
}
private fun setupDi() {
startKoin {
androidContext(this@MainApplication)
modules(
listOf(
appModules,
userlistModules
)
)
}
}
}

View File

@@ -0,0 +1,18 @@
package com.hako.friendlists.di
import androidx.room.Room
import com.hako.base.domain.database.DatabaseClient
import com.hako.base.domain.network.RemoteClient
import com.hako.friendlists.BuildConfig
import org.koin.dsl.module
val appModules = module {
// Room database
single { Room.databaseBuilder(get(), DatabaseClient::class.java, BuildConfig.DB_NAME).build() }
factory { get<DatabaseClient>().userDao() }
factory { get<DatabaseClient>().albumDao() }
factory { get<DatabaseClient>().photoDao() }
// Retrofit
single { RemoteClient(BuildConfig.BASE_ENDPOINT) }
}

View File

@@ -0,0 +1,22 @@
package com.hako.friendlists.view
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import com.hako.friendlists.R
class MainActivity : AppCompatActivity() {
private val navController by lazy { findNavController(R.id.main_fragment_container) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupNavigation()
}
private fun setupNavigation() {
navController.setGraph(R.navigation.main_navigation)
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"/>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_navigation"
app:startDestination="@id/userlist_navigation">
<include app:graph="@navigation/userlist_navigation" />
</navigation>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
<color name="colorPrimary">#37474F</color>
<color name="colorPrimaryDark">#324047</color>
<color name="colorAccent">#CBCFD1</color>
<color name="colorRed">#D32F2F</color>
</resources>

View File

@@ -1,11 +1,10 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@color/soft_background</item>
</style>
</resources>

View File

@@ -1,33 +0,0 @@
ext.deps = [:]
def versions = [:]
versions.kotlin = "1.3.41"
versions.gradle = "3.5.3"
def deps = [:]
def project = [:]
project.kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
project.gradle = "com.android.tools.build:gradle:$versions.gradle"
deps.project = project
def build_versions = [:]
build_versions.min_sdk = 21
build_versions.target_sdk = 29
build_versions.build_tools = "29.0.3"
ext.build_versions = build_versions
def kotlin = [:]
kotlin.version = "$versions.kotlin"
kotlin.std_lib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$versions.kotlin"
deps.kotlin = kotlin
ext.deps = deps
def addRepos(RepositoryHandler handler) {
handler.google()
handler.jcenter()
handler.maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
}
ext.addRepos = this.&addRepos

View File

@@ -37,6 +37,8 @@ dependencies {
api deps.androidx.lifecycle_ext
api deps.androidx.lifecycle_viewmodel
api deps.androidx.recycler_view
api deps.androidx.navigation_fragment
api deps.androidx.navigation_ui
api deps.retrofit.runtime
api deps.retrofit.gson
api deps.retrofit.rx
@@ -48,6 +50,7 @@ dependencies {
api deps.rx.android
api deps.okhttp_logging_interceptor
api deps.timber
api deps.lottie
//Testing
api deps.testing.junit
api deps.testing.koin

View File

@@ -0,0 +1,33 @@
package com.hako.base.domain
sealed class Either<out L, out R> {
data class Left<out L>(val a: L) : Either<L, Nothing>()
data class Right<out R>(val b: R) : Either<Nothing, R>()
val isRight get() = this is Right<R>
val isLeft get() = this is Left<L>
fun <L> left(a: L) = Left(a)
fun <R> right(b: R) = Right(b)
fun either(fnL: (L) -> Any, fnR: (R) -> Any): Any =
when (this) {
is Left -> fnL(a)
is Right -> fnR(b)
}
}
fun <A, B, C> ((A) -> B).c(f: (B) -> C): (A) -> C = {
f(this(it))
}
fun <T, L, R> Either<L, R>.flatMap(fn: (R) -> Either<L, T>): Either<L, T> =
when (this) {
is Either.Left -> Either.Left(
a
)
is Either.Right -> fn(b)
}
fun <T, L, R> Either<L, R>.map(fn: (R) -> (T)): Either<L, T> = this.flatMap(fn.c(::right))

View File

@@ -0,0 +1,5 @@
package com.hako.base.domain
interface UseCase <T> {
fun execute(onSuccess: (List<T>) -> Unit, onError: (Throwable) -> Unit, onLoading: () -> Unit)
}

View File

@@ -0,0 +1,17 @@
package com.hako.base.domain.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.hako.base.domain.database.dao.AlbumDao
import com.hako.base.domain.database.dao.PhotoDao
import com.hako.base.domain.database.dao.UserDao
import com.hako.base.domain.database.entities.AlbumEntity
import com.hako.base.domain.database.entities.PhotoEntity
import com.hako.base.domain.database.entities.UserEntity
@Database(entities = [UserEntity::class, AlbumEntity::class, PhotoEntity::class], version = 1, exportSchema = false)
abstract class DatabaseClient : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun albumDao(): AlbumDao
abstract fun photoDao(): PhotoDao
}

View File

@@ -0,0 +1,27 @@
package com.hako.base.domain.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.hako.base.domain.database.entities.AlbumEntity
@Dao
interface AlbumDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun save(entity: AlbumEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAll(entities: List<AlbumEntity>)
@get:Query("SELECT * FROM ${AlbumEntity.TABLE_NAME}")
val all: List<AlbumEntity>
@Query("SELECT COUNT(*) FROM ${AlbumEntity.TABLE_NAME}")
fun count(): Int
@Query("DELETE FROM ${AlbumEntity.TABLE_NAME}")
fun nukeDatabase()
}

View File

@@ -0,0 +1,27 @@
package com.hako.base.domain.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.hako.base.domain.database.entities.PhotoEntity
@Dao
interface PhotoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun save(entity: PhotoEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAll(entities: List<PhotoEntity>)
@get:Query("SELECT * FROM ${PhotoEntity.TABLE_NAME}")
val all: List<PhotoEntity>
@Query("SELECT COUNT(*) FROM ${PhotoEntity.TABLE_NAME}")
fun count(): Int
@Query("DELETE FROM ${PhotoEntity.TABLE_NAME}")
fun nukeDatabase()
}

View File

@@ -0,0 +1,30 @@
package com.hako.base.domain.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.hako.base.domain.database.entities.UserEntity
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun save(entity: UserEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAll(entities: List<UserEntity>)
@get:Query("SELECT * FROM ${UserEntity.TABLE_NAME}")
val all: List<UserEntity>
@Query("SELECT * FROM ${UserEntity.TABLE_NAME}")
fun getAllUsers(): List<UserEntity>
@Query("SELECT COUNT(*) FROM ${UserEntity.TABLE_NAME}")
fun count(): Int
@Query("DELETE FROM ${UserEntity.TABLE_NAME}")
fun nukeDatabase()
}

View File

@@ -0,0 +1,17 @@
package com.hako.base.domain.database.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = AlbumEntity.TABLE_NAME, indices = [Index(value = ["id"], unique = true)])
data class AlbumEntity(
@PrimaryKey
val id: Int,
val userId: Int,
val title: String
) {
companion object {
const val TABLE_NAME = "albums"
}
}

View File

@@ -0,0 +1,19 @@
package com.hako.base.domain.database.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = PhotoEntity.TABLE_NAME, indices = [Index(value = ["id"], unique = true)])
data class PhotoEntity(
@PrimaryKey
val id: Int,
val albumId: Int,
val title: String,
val photoUrl: String,
val thumbnailUrl: String
) {
companion object {
const val TABLE_NAME = "photos"
}
}

View File

@@ -0,0 +1,20 @@
package com.hako.base.domain.database.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = UserEntity.TABLE_NAME, indices = [Index(value = ["id"], unique = true)])
data class UserEntity(
@PrimaryKey
val id: Int,
val realName: String,
val userName: String,
val email: String,
val phone: String,
val website: String
) {
companion object {
const val TABLE_NAME = "users"
}
}

View File

@@ -0,0 +1,35 @@
package com.hako.base.domain.network
import com.hako.base.BuildConfig
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
class RemoteClient(endpoint: String) {
private val logger = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
override fun log(message: String) {
Timber.d(message)
}
}).setLevel(getLoggerLevel())
private val client = OkHttpClient.Builder()
.addInterceptor(logger)
.build()
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(endpoint)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(client)
.build()
fun <T> getClient(api: Class<T>): T = retrofit.create(api)
private fun getLoggerLevel() = when (BuildConfig.DEBUG) {
true -> HttpLoggingInterceptor.Level.BASIC
false -> HttpLoggingInterceptor.Level.NONE
}
}

View File

@@ -0,0 +1,7 @@
package com.hako.base.domain.network
sealed class RequestStatus {
object Ready : RequestStatus()
object Loading : RequestStatus()
object Errored : RequestStatus()
}

View File

@@ -0,0 +1,8 @@
package com.hako.base.extensions
import android.content.Context
import android.widget.Toast
fun Context.toast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

View File

@@ -0,0 +1,23 @@
package com.hako.base.extensions
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
fun <T> RecyclerView.Adapter<*>.autoNotify(oldList: List<T>, newList: List<T>, compare: (T, T) -> Boolean) {
val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return compare(oldList[oldItemPosition], newList[newItemPosition])
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
})
diff.dispatchUpdatesTo(this)
}

View File

@@ -0,0 +1,13 @@
package com.hako.base.extensions
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
fun <T> LiveData<T>.observeNonNull(owner: LifecycleOwner, func: (T) -> Unit) {
observe(owner, Observer {
it?.let {
func(it)
}
})
}

View File

@@ -0,0 +1,19 @@
package com.hako.base.extensions
import androidx.annotation.IdRes
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
fun AppCompatActivity.findNavHostFragment(@IdRes id: Int) =
supportFragmentManager.findFragmentById(id) as NavHostFragment
fun Fragment.findNavHostFragment(@IdRes id: Int) =
childFragmentManager.findFragmentById(id) as NavHostFragment
fun Fragment.findNavController(@IdRes id: Int) =
androidx.navigation.Navigation.findNavController(view?.findViewById(id) ?: viewNotFound(id, this))
private fun viewNotFound(@IdRes id: Int, fragment: Fragment): Nothing = throw IllegalStateException(
"View ID $id at '${fragment::class.java.simpleName}' not found."
)

View File

@@ -0,0 +1,39 @@
package com.hako.base.extensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
fun View.enable() {
isEnabled = true
}
fun View.disable() {
isEnabled = false
}
fun View.visible() {
visibility = View.VISIBLE
}
fun View.invisible() {
visibility = View.INVISIBLE
}
fun View.transparent() {
alpha = 0f
}
fun View.opaque() {
alpha = 1f
}
fun View.gone() {
visibility = View.GONE
}
fun ViewGroup.inflate(@LayoutRes layout: Int, attachToRoot: Boolean = false): View =
LayoutInflater
.from(context)
.inflate(layout, this, attachToRoot)

View File

@@ -0,0 +1,46 @@
package com.hako.base.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import com.hako.base.R
import com.hako.base.extensions.inflate
import kotlinx.android.synthetic.main.like_button.view.*
private const val LIKE_MIN_FRAME = 0
private const val LIKE_MAX_FRAME = 28
private const val LIKE_ANIM_SPEED = 1f
private const val DISLIKE_MIN_FRAME = 28
private const val DISLIKE_MAX_FRAME = 70
private const val DISLIKE_ANIM_SPEED = 2f
class LikeButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
inflate(R.layout.like_button, true)
}
fun like() {
like_button_animation_view.frame = LIKE_MAX_FRAME
}
fun dislike() {
like_button_animation_view.frame = LIKE_MIN_FRAME
}
fun playLike() {
like_button_animation_view.setMinAndMaxFrame(LIKE_MIN_FRAME, LIKE_MAX_FRAME)
like_button_animation_view.speed = LIKE_ANIM_SPEED
like_button_animation_view.playAnimation()
}
fun playDislike() {
like_button_animation_view.setMinAndMaxFrame(DISLIKE_MIN_FRAME, DISLIKE_MAX_FRAME)
like_button_animation_view.speed = DISLIKE_ANIM_SPEED
like_button_animation_view.playAnimation()
}
}

View File

@@ -0,0 +1,18 @@
package com.hako.base.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import com.hako.base.R
import com.hako.base.extensions.inflate
class LoadingOverlay @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
inflate(R.layout.loading_overlay, true)
}
}

View File

@@ -0,0 +1,18 @@
package com.hako.base.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import com.hako.base.R
import com.hako.base.extensions.inflate
class NetworkErrorOverlay @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
inflate(R.layout.network_error_overlay, true)
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape >
<solid android:color="@color/card_background" />
<corners android:radius="4dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<merge 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="wrap_content"
android:layout_height="wrap_content"
tools:parentTag="android.widget.FrameLayout">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/like_button_animation_view"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerInside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:lottie_rawRes="@raw/like_animation" />
</merge>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@color/soft_background">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/loading_overlay_animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="centerInside"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:lottie_rawRes="@raw/loading_animation" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@color/soft_background">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/loading_overlay_animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="centerInside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.48"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/network_animation" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Network error!"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="@+id/loading_overlay_animation_view"
app:layout_constraintStart_toStartOf="@+id/loading_overlay_animation_view"
app:layout_constraintTop_toBottomOf="@+id/loading_overlay_animation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="soft_background">#EEEEEE</color>
<color name="transparent">#00000000</color>
<color name="card_background">#FFFFFF</color>
</resources>

View File

@@ -7,6 +7,7 @@ versions.androidx_core = "1.1.0"
versions.androidx_constraint_layout = "1.1.3"
versions.androidx_lifecycle = "2.2.0"
versions.androidx_recycler_view = "1.1.0"
versions.androidx_navigation = "2.2.0"
versions.okhttp_logging_interceptor = "4.3.1"
versions.retrofit = "2.7.1"
versions.timber = "4.7.1"
@@ -18,6 +19,7 @@ versions.junit = "4.13"
versions.test = "1.2.0"
versions.test_ext = "1.1.1"
versions.espresso = "3.2.0"
versions.lottie = "3.3.1"
def deps = [:]
@@ -33,6 +35,8 @@ androidx.constraint_layout = "androidx.constraintlayout:constraintlayout:$versio
androidx.lifecycle_ext = "androidx.lifecycle:lifecycle-extensions:$versions.androidx_lifecycle"
androidx.lifecycle_viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.androidx_lifecycle"
androidx.recycler_view = "androidx.recyclerview:recyclerview:$versions.androidx_recycler_view"
androidx.navigation_fragment = "androidx.navigation:navigation-fragment-ktx:$versions.androidx_navigation"
androidx.navigation_ui = "androidx.navigation:navigation-ui-ktx:$versions.androidx_navigation"
deps.androidx = androidx
def retrofit = [:]
@@ -72,5 +76,6 @@ deps.testing = testing
deps.okhttp_logging_interceptor = "com.squareup.okhttp3:logging-interceptor:$versions.okhttp_logging_interceptor"
deps.timber = "com.jakewharton.timber:timber:$versions.timber"
deps.lottie = "com.airbnb.android:lottie:$versions.lottie"
ext.deps = deps

View File

@@ -39,7 +39,3 @@ detekt {
task clean(type: Delete) {
delete rootProject.buildDir
}
repositories {
google()
}

29
core.gradle Normal file
View File

@@ -0,0 +1,29 @@
apply from: '../versions.gradle'
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion build_versions.target_sdk
buildToolsVersion build_versions.build_tools
defaultConfig {
minSdkVersion build_versions.min_sdk
targetSdkVersion build_versions.target_sdk
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
testOptions {
unitTests.returnDefaultValues = true
}
lintOptions {
abortOnError false
}
}

View File

@@ -1,2 +1,2 @@
include ':app', ':base'
include ':app', ':base', ':userlist'
rootProject.name='Friendlists'

View File

@@ -63,7 +63,7 @@ complexity:
threshold: 150
LongMethod:
active: true
threshold: 20
threshold: 40
LongParameterList:
active: false
threshold: 6

1
userlist/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

6
userlist/build.gradle Normal file
View File

@@ -0,0 +1,6 @@
apply plugin: 'com.android.library'
apply from: '../core.gradle'
dependencies {
implementation project(':base')
}

View File

21
userlist/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,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.hako.friendlist_userlist" />

View File

@@ -0,0 +1,17 @@
package com.hako.userlist.di
import com.hako.base.domain.network.RemoteClient
import com.hako.userlist.domain.datasource.UserlistDatasource
import com.hako.userlist.domain.datasource.UserlistRemoteApi
import com.hako.userlist.domain.usecase.GetUsers
import com.hako.userlist.viewmodel.UserlistViewmodel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val userlistModules = module {
factory { get<RemoteClient>().getClient(UserlistRemoteApi::class.java) }
factory { UserlistDatasource() }
factory { GetUsers(get()) }
viewModel { UserlistViewmodel() }
}

View File

@@ -0,0 +1,13 @@
package com.hako.userlist.domain.datasource
import com.hako.userlist.model.User
import io.reactivex.Single
import org.koin.core.KoinComponent
import org.koin.core.get
class UserlistDatasource : KoinComponent, UserlistRemoteApi {
private val api: UserlistRemoteApi = get()
override fun getUsers(): Single<List<User>> = api.getUsers()
}

View File

@@ -0,0 +1,11 @@
package com.hako.userlist.domain.datasource
import com.hako.userlist.model.User
import io.reactivex.Single
import retrofit2.http.GET
interface UserlistRemoteApi {
@GET("/users")
fun getUsers(): Single<List<User>>
}

View File

@@ -0,0 +1,43 @@
package com.hako.userlist.domain.usecase
import com.hako.base.domain.UseCase
import com.hako.base.domain.database.dao.UserDao
import com.hako.userlist.domain.datasource.UserlistDatasource
import com.hako.userlist.model.UserViewable
import com.hako.userlist.model.toUserEntity
import com.hako.userlist.model.toUserViewable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import org.koin.core.KoinComponent
import org.koin.core.get
class GetUsers(private val dao: UserDao) : KoinComponent,
UseCase<UserViewable> {
private val api: UserlistDatasource = get()
override fun execute(
onSuccess: (List<UserViewable>) -> Unit,
onError: (Throwable) -> Unit,
onLoading: () -> Unit
) {
Single.fromCallable { dao.getAllUsers() }
.subscribeOn(Schedulers.io())
.doOnError { onError(it) }
.doOnSuccess { dbUsers ->
if (dbUsers.isEmpty() || dbUsers.count() == 0) {
api.getUsers()
.doOnSuccess {
dao.saveAll(it.map { user -> user.toUserEntity() })
onSuccess(dao.getAllUsers().map { user -> user.toUserViewable() })
}
.doOnSubscribe { onLoading() }
.subscribeOn(Schedulers.io())
.subscribe({}, { onError(it) })
} else {
onSuccess(dbUsers.map { it.toUserViewable() })
}
}
.subscribe()
}
}

View File

@@ -0,0 +1,83 @@
package com.hako.userlist.feature
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.hako.base.domain.network.RequestStatus
import com.hako.base.extensions.gone
import com.hako.base.extensions.observeNonNull
import com.hako.base.extensions.toast
import com.hako.base.extensions.visible
import com.hako.userlist.model.UserViewable
import com.hako.userlist.viewmodel.UserlistViewmodel
import com.hako.userlist.widget.UserlistAdapter
import com.hako.friendlist_userlist.R
import kotlinx.android.synthetic.main.fragment_userlist.*
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class UserlistFragment : Fragment() {
private val viewModel: UserlistViewmodel by viewModel()
private val chatAdapter by lazy { UserlistAdapter() }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_userlist, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setRecycler()
setObservers()
viewModel.fetchUsers()
}
private fun setObservers() {
viewModel.data.observeNonNull(this) {
it.either(::handleFetchError, ::handleFetchSuccess)
}
viewModel.requestStatus.observeNonNull(this) {
when (it) {
RequestStatus.Ready -> {
fragment_userlist_error_overlay.gone()
fragment_userlist_loading_overlay.gone()
}
RequestStatus.Loading -> {
fragment_userlist_error_overlay.gone()
fragment_userlist_loading_overlay.visible()
}
RequestStatus.Errored -> {
fragment_userlist_error_overlay.visible()
fragment_userlist_loading_overlay.gone()
}
}
}
}
private fun handleFetchError(throwable: Throwable) {
Timber.e(throwable)
}
private fun handleFetchSuccess(users: List<UserViewable>) {
chatAdapter.addAll(users)
}
private fun setRecycler() {
fragment_userlist_recycler_container.apply {
layoutManager = LinearLayoutManager(context)
adapter = chatAdapter.apply {
onItemClick = {
context.toast(it.realName)
}
onFavoriteClick = {
context.toast(it.userName)
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
package com.hako.userlist.model
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.hako.base.domain.database.entities.UserEntity
import kotlinx.android.parcel.Parcelize
@Parcelize
data class User(
@SerializedName("id") val id: Int,
@SerializedName("name") val realName: String,
@SerializedName("username") val userName: String,
@SerializedName("email") val email: String,
@SerializedName("phone") val phone: String,
@SerializedName("website") val website: String
) : Parcelable
data class UserViewable(
val id: Int,
val realName: String,
val userName: String,
var isFavorite: Boolean = false
)
fun User.toUserEntity() = UserEntity(this.id, this.realName, this.userName, this.email, this.phone, this.website)
fun UserEntity.toUserViewable() = UserViewable(this.id, this.realName, this.userName)

View File

@@ -0,0 +1,36 @@
package com.hako.userlist.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.hako.base.domain.network.RequestStatus
import com.hako.base.domain.network.RequestStatus.Ready
import com.hako.base.domain.network.RequestStatus.Loading
import com.hako.base.domain.network.RequestStatus.Errored
import com.hako.base.domain.Either
import com.hako.userlist.domain.usecase.GetUsers
import com.hako.userlist.model.UserViewable
import org.koin.core.KoinComponent
import org.koin.core.get
class UserlistViewmodel : ViewModel(), KoinComponent {
val data = MutableLiveData<Either<Throwable, List<UserViewable>>>()
val requestStatus = MutableLiveData<RequestStatus>()
private val getUsers: GetUsers = get()
fun fetchUsers() {
getUsers.execute(
onSuccess = {
requestStatus.postValue(Ready)
data.postValue(Either.Right(it))
},
onLoading = {
requestStatus.postValue(Loading)
},
onError = {
requestStatus.postValue(Errored)
data.postValue(Either.Left(it))
})
}
}

View File

@@ -0,0 +1,62 @@
package com.hako.userlist.widget
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.hako.base.extensions.autoNotify
import com.hako.userlist.model.UserViewable
import com.hako.friendlist_userlist.R
import kotlinx.android.synthetic.main.item_user_card.view.*
import kotlin.properties.Delegates
class UserlistAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items by Delegates.observable(emptyList<UserViewable>()) { _, oldList, newList ->
autoNotify(oldList, newList) { old, new -> old.id == new.id }
notifyDataSetChanged()
}
var onItemClick: (UserViewable) -> Unit = { }
var onFavoriteClick: (UserViewable) -> Unit = { }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
UserViewHolder(
LayoutInflater
.from(parent.context)
.inflate(R.layout.item_user_card, parent, false),
onItemClick,
onFavoriteClick
)
fun getItem(position: Int) = items[position]
fun addAll(list: List<UserViewable>) {
items = list
}
override fun getItemCount() = items.size
override fun onBindViewHolder(viewholder: RecyclerView.ViewHolder, position: Int) =
when (viewholder) {
is UserViewHolder -> viewholder.bind(items[position])
else -> throw NoWhenBranchMatchedException("Undefined viewholder")
}
}
class UserViewHolder(private val view: View,
private val onItemClick: (UserViewable) -> Unit,
private val onFavoriteClick: (UserViewable) -> Unit) :
RecyclerView.ViewHolder(view) {
fun bind(user: UserViewable) = with(view) {
item_user_card_real_name.text = user.realName
item_user_card_user_name.text = user.userName
item_user_card_container.setOnClickListener {
onItemClick(user)
}
item_user_card_like_button.setOnClickListener {
onFavoriteClick(user)
}
}
}

View File

@@ -0,0 +1,38 @@
<?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"
android:orientation="vertical"
android:id="@+id/fragment_userlist_base">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fragment_userlist_recycler_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.hako.base.widgets.LoadingOverlay
android:id="@+id/fragment_userlist_loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.hako.base.widgets.NetworkErrorOverlay
android:id="@+id/fragment_userlist_error_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
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,44 @@
<?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:id="@+id/item_user_card_container"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_card"
android:elevation="2dp"
android:orientation="vertical">
<TextView
android:id="@+id/item_user_card_real_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:textSize="18sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Real Name" />
<TextView
android:id="@+id/item_user_card_user_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="@+id/item_user_card_real_name"
app:layout_constraintTop_toBottomOf="@+id/item_user_card_real_name"
tools:text="User Name" />
<com.hako.base.widgets.LikeButton
android:id="@+id/item_user_card_like_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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:id="@+id/userlist_navigation"
app:startDestination="@id/userListFragment">
<fragment
android:id="@+id/userListFragment"
android:name="com.hako.userlist.feature.UserlistFragment"
tools:layout="@layout/fragment_userlist"
android:label="UserListFragment" />
</navigation>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">userlist</string>
</resources>