Initial commit

This commit is contained in:
2020-07-18 21:49:10 +01:00
commit e32750639f
198 changed files with 4403 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

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

99
app/build.gradle Normal file
View File

@@ -0,0 +1,99 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs"
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
defaultConfig {
applicationId "com.example.h_mal.candyspace"
minSdkVersion 24
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// To inline the bytecode built with JVM target 1.8 into
// bytecode that is being built with JVM target 1.6. (e.g. navArgs)
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'com.google.android.material:material:1.1.0'
implementation 'androidx.annotation:annotation:1.1.0'
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.preference:preference:1.1.1'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:rules:1.2.0'
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-test:1.3.71"
// android unit testing and espresso
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
implementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
//mock websever for testing retrofit responses
testImplementation "com.squareup.okhttp3:mockwebserver:4.6.0"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
//mockito and livedata testing
testImplementation 'org.mockito:mockito-inline:2.13.0'
implementation 'android.arch.core:core-testing'
androidTestImplementation 'androidx.test:rules:1.3.0-rc01'
//Retrofit and GSON
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
//Kotlin Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4"
//Kodein Dependency Injection
implementation "org.kodein.di:kodein-di-generic-jvm:6.2.1"
implementation "org.kodein.di:kodein-di-framework-android-x:6.2.1"
// Shared prefs
implementation "androidx.preference:preference-ktx:1.1.1"
// Picasso image display
implementation 'com.squareup.picasso:picasso:2.71828'
//Android Room
implementation "androidx.room:room-runtime:2.3.0-alpha01"
implementation "androidx.room:room-ktx:2.3.0-alpha01"
kapt "androidx.room:room-compiler:2.3.0-alpha01"
// Circle Image View
implementation 'com.mikhaellopez:circularimageview:4.2.0'
}

21
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,22 @@
package com.example.h_mal.stackexchangeusers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.h_mal.candyspace", appContext.packageName)
}
}

View File

@@ -0,0 +1,121 @@
package com.example.h_mal.stackexchangeusers.ui.main
import android.view.View
import android.view.ViewGroup
import androidx.test.InstrumentationRegistry.getTargetContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class AppUITest {
@Rule
@JvmField
var mActivityTestRule = ActivityTestRule(MainActivity::class.java)
val users = setOf("kenny", "adam", "gary", "user")
lateinit var currentUser: String
@Before
fun setUp() = runBlocking{
deletePreviousEntries()
}
private suspend fun deletePreviousEntries(){
idlingResources.increment()
AppDatabase.invoke(InstrumentationRegistry.getInstrumentation().context).getUsersDao().deleteEntries()
idlingResources.decrement()
}
@Test
fun mainActivityTest() {
val user = users.random()
onView(allOf(
withId(R.id.search_button), withContentDescription("Search"),
isDisplayed()
)
).perform(click())
onView(allOf(withId(R.id.search_src_text), isDisplayed()))
.perform(replaceText(user), closeSoftKeyboard())
onView(
allOf(
withId(R.id.search_src_text), withText(user),
isDisplayed()
)
).perform(pressImeActionButton())
val viewGroup = onView(
allOf(childAtPosition(allOf(
withId(R.id.recycler_view),
childAtPosition(withId(R.id.main), 1)
), 0),
isDisplayed()
)
)
onView(isRoot()).perform(waitFor(2000))
viewGroup.check(matches(isDisplayed()))
}
private fun waitFor(delay: Long): ViewAction? {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return isRoot()
}
override fun getDescription(): String {
return "wait for " + delay + "milliseconds"
}
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadForAtLeast(delay)
}
}
}
private fun childAtPosition(
parentMatcher: Matcher<View>, position: Int
): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("Child at position $position in parent ")
parentMatcher.describeTo(description)
}
public override fun matchesSafely(view: View): Boolean {
val parent = view.parent
return parent is ViewGroup && parentMatcher.matches(parent)
&& view == parent.getChildAt(position)
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.h_mal.stackexchangeusers">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name="com.example.h_mal.stackexchangeusers.application.AppClass"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="com.example.h_mal.stackexchangeusers.ui.main.MainActivity">
<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,41 @@
package com.example.h_mal.stackexchangeusers.application
import android.app.Application
import androidx.test.espresso.idling.CountingIdlingResource
import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.NetworkConnectionInterceptor
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.QueryParamsInterceptor
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.repositories.RepositoryImpl
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import com.example.h_mal.stackexchangeusers.ui.MainViewModelFactory
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
class AppClass : Application(), KodeinAware{
companion object{
// idling resource to be used for espresso testing
// when we need to wait for async operations to complete
val idlingResources = CountingIdlingResource("Data_loader")
}
// Kodein creation of modules to be retrieve within the app
override val kodein = Kodein.lazy {
import(androidXModule(this@AppClass))
bind() from singleton { NetworkConnectionInterceptor(instance()) }
bind() from singleton { QueryParamsInterceptor() }
bind() from singleton { ApiClass(instance(), instance())}
bind() from singleton { AppDatabase(instance()) }
bind() from singleton { PreferenceProvider(instance()) }
bind() from singleton { RepositoryImpl(instance(), instance(), instance()) }
bind() from provider { MainViewModelFactory(instance()) }
}
}

View File

@@ -0,0 +1,42 @@
package com.example.h_mal.stackexchangeusers.data.network.api
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.NetworkConnectionInterceptor
import com.example.h_mal.stackexchangeusers.data.network.api.interceptors.QueryParamsInterceptor
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
interface ApiClass {
@GET("users?")
suspend fun getUsersFromApi(@Query("inname") inname: String): Response<ApiResponse>
// invoke method creating an invocation of the api call
companion object{
operator fun invoke(
// injected @params
networkConnectionInterceptor: NetworkConnectionInterceptor,
queryParamsInterceptor: QueryParamsInterceptor
) : ApiClass {
// okHttpClient with interceptors
val okkHttpclient = OkHttpClient.Builder()
.addNetworkInterceptor(networkConnectionInterceptor)
.addInterceptor(queryParamsInterceptor)
.build()
// creation of retrofit class
return Retrofit.Builder()
.client(okkHttpclient)
.baseUrl("https://api.stackexchange.com/2.2/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiClass::class.java)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.example.h_mal.stackexchangeusers.data.network.api.interceptors
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
/**
* interceptor used to determine if we have a valid network to make network calls
*
*/
class NetworkConnectionInterceptor(
context: Context
) : Interceptor {
private val applicationContext = context.applicationContext
override fun intercept(chain: Interceptor.Chain): Response {
if (!isInternetAvailable()){
throw IOException("Make sure you have an active data connection")
}
return chain.proceed(chain.request())
}
private fun isInternetAvailable(): Boolean {
var result = false
val connectivityManager =
applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
connectivityManager?.let {
it.getNetworkCapabilities(connectivityManager.activeNetwork)?.apply {
result = when {
hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
}
return result
}
}

View File

@@ -0,0 +1,31 @@
package com.example.h_mal.stackexchangeusers.data.network.api.interceptors
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
/**
* Interceptor used to add default query parameters to api calls
*/
class QueryParamsInterceptor : Interceptor{
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val originalHttpUrl = original.url()
val url = originalHttpUrl.newBuilder()
.addQueryParameter("site", "stackoverflow")
.addQueryParameter("pagesize","20")
.addQueryParameter("order","desc")
.addQueryParameter("sort","reputation")
.build()
// Request customization: add request headers
// Request customization: add request headers
val requestBuilder: Request.Builder = original.newBuilder()
.url(url)
val request: Request = requestBuilder.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,8 @@
package com.example.h_mal.stackexchangeusers.data.network.model
data class ApiResponse(
val items : List<User>?,
val has_more : Boolean?,
val quota_max : Int?,
val quota_Remaining: Int?
)

View File

@@ -0,0 +1,16 @@
package com.example.h_mal.stackexchangeusers.data.network.model
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class BadgeCounts {
@SerializedName("bronze")
@Expose
var bronze: Int? = null
@SerializedName("silver")
@Expose
var silver: Int? = null
@SerializedName("gold")
@Expose
var gold: Int? = null
}

View File

@@ -0,0 +1,41 @@
package com.example.h_mal.stackexchangeusers.data.network.model
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
class User {
@SerializedName("badge_counts")
@Expose
var badgeCounts: BadgeCounts? = null
@SerializedName("last_modified_date")
@Expose
var lastModifiedDate: Int? = null
@SerializedName("reputation")
@Expose
var reputation: Int? = null
@SerializedName("creation_date")
@Expose
var creationDate: Int? = null
@SerializedName("user_type")
@Expose
var userType: String? = null
@SerializedName("user_id")
@Expose
var userId: Int? = null
@SerializedName("location")
@Expose
var location: String? = null
@SerializedName("website_url")
@Expose
var websiteUrl: String? = null
@SerializedName("link")
@Expose
var link: String? = null
@SerializedName("profile_image")
@Expose
var profileImage: String? = null
@SerializedName("display_name")
@Expose
var displayName: String? = null
}

View File

@@ -0,0 +1,33 @@
package com.example.h_mal.stackexchangeusers.data.network.networkUtils
import org.json.JSONException
import org.json.JSONObject
import retrofit2.Response
import java.io.IOException
abstract class ResponseUnwrap {
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun <T : Any> responseUnwrap(
call: suspend () -> Response<T>
): T {
val response = call.invoke()
if (response.isSuccessful) {
return response.body()!!
} else {
val error = response.errorBody()?.string()
val errorMessage = error?.let {
try {
JSONObject(it).getString("error_message")
} catch (e: JSONException) {
e.printStackTrace()
null
}
} ?: "Error Code: ${response.code()}"
throw IOException(errorMessage)
}
}
}

View File

@@ -0,0 +1,38 @@
package com.example.h_mal.stackexchangeusers.data.preferences
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
private const val LAST_SAVED = "late_saved"
private const val USER_SAVED = "user_saved"
class PreferenceProvider(
context: Context
) {
private val appContext = context.applicationContext
private val preference: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(appContext)
fun saveLastSavedAt(user: String, savedAt: Long) {
preference.edit().putString(
USER_SAVED,
user
).putLong(
LAST_SAVED,
savedAt
).apply()
}
fun getLastSavedAt(user: String): Long? {
val savedUser = preference.getString(USER_SAVED, null)
if (savedUser == user){
return preference.getLong(LAST_SAVED, 1595076034403)
}
return null
}
}

View File

@@ -0,0 +1,16 @@
package com.example.h_mal.stackexchangeusers.data.repositories
import androidx.lifecycle.LiveData
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
interface Repository {
fun getUsersFromDatabase(): LiveData<List<UserItem>>
fun getSingleUserFromDatabase(id: Int): LiveData<UserItem>
suspend fun saveUsersToDatabase(users: List<User>)
suspend fun getUsersFromApi(username: String): ApiResponse?
fun saveCurrentSearchToPrefs(username: String)
}

View File

@@ -0,0 +1,73 @@
package com.example.h_mal.stackexchangeusers.data.repositories
import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User
import com.example.h_mal.stackexchangeusers.data.network.networkUtils.ResponseUnwrap
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
const val MILLISECONDS_ONE_MIN = 60000
class RepositoryImpl(
private val api: ApiClass,
private val database: AppDatabase,
private val preference: PreferenceProvider
) : ResponseUnwrap(), Repository {
// Current list of users in the database
override fun getUsersFromDatabase() = database.getUsersDao().getAllUsers()
// retrieving a single user from an ID
override fun getSingleUserFromDatabase(id: Int) = database.getUsersDao().getUser(id)
// save a list of users to the room database
override suspend fun saveUsersToDatabase(users: List<User>){
val userList= getUserList(users)
database.getUsersDao().upsertNewUsers(userList)
}
// fetch users from an api call
override suspend fun getUsersFromApi(username: String): ApiResponse? {
return when (isSearchValid(username)) {
true -> responseUnwrap { api.getUsersFromApi(username) }
else -> { null }
}
}
// save current time and current search input into shared prefs
override fun saveCurrentSearchToPrefs(username: String){
val time = System.currentTimeMillis()
preference.saveLastSavedAt(username, time)
}
// boolean response of validity of search
// if the same search is taking place again with a minute return false
private fun isSearchValid(username: String): Boolean {
val time = preference.getLastSavedAt(username) ?: return true
val currentTime = System.currentTimeMillis()
val difference = currentTime - time
return difference > MILLISECONDS_ONE_MIN
}
// map user from api response to userItem entity for database
private fun getUserList(users: List<User>): List<UserItem> {
return users.map {
it.mapToUserItem()
}
}
private fun User.mapToUserItem(): UserItem {
return UserItem(
userId,
displayName,
badgeCounts?.bronze,
badgeCounts?.silver,
badgeCounts?.gold,
reputation,
creationDate,
profileImage
)
}
}

View File

@@ -0,0 +1,37 @@
package com.example.h_mal.stackexchangeusers.data.room
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
@Database(
entities = [UserItem::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun getUsersDao(): UsersDao
companion object {
@Volatile
private var instance: AppDatabase? = null
private val LOCK = Any()
// create an instance of room database or use previously created instance
operator fun invoke(context: Context) = instance ?: synchronized(LOCK) {
instance ?: buildDatabase(context).also {
instance = it
}
}
private fun buildDatabase(context: Context) =
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"MyDatabase.db"
).build()
}
}

View File

@@ -0,0 +1,28 @@
package com.example.h_mal.stackexchangeusers.data.room
import androidx.lifecycle.LiveData
import androidx.room.*
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
@Dao
interface UsersDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAllUsers(users : List<UserItem>)
@Query("SELECT * FROM UserItem")
fun getAllUsers() : LiveData<List<UserItem>>
// clear database and add new entries
@Transaction
suspend fun upsertNewUsers(users : List<UserItem>){
deleteEntries()
saveAllUsers(users)
}
@Query("DELETE FROM UserItem")
suspend fun deleteEntries()
@Query("SELECT * FROM UserItem WHERE userId = :id")
fun getUser(id: Int) : LiveData<UserItem>
}

View File

@@ -0,0 +1,17 @@
package com.example.h_mal.stackexchangeusers.data.room.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class UserItem(
@PrimaryKey(autoGenerate = false)
val userId: Int?,
val displayName: String?,
val bronze: Int?,
val silver: Int?,
val gold: Int?,
val reputation: Int?,
val creationDate: Int?,
val profileImage: String?
)

View File

@@ -0,0 +1,54 @@
package com.example.h_mal.stackexchangeusers.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources
import com.example.h_mal.stackexchangeusers.data.repositories.Repository
import com.example.h_mal.stackexchangeusers.utils.Event
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
class MainViewModel(
private val repository: Repository
) : ViewModel() {
// livedata for user items in room database
val usersLiveData = repository.getUsersFromDatabase()
val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>()
fun getUsers(username: String?){
// validate that search term is not empty
if (username.isNullOrBlank()){
operationError.postValue(Event("Enter a valid username"))
return
}
// open a coroutine on the IO thread for async operations
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try {
// get users from api
val response = repository.getUsersFromApi(username)
// null check response exists and contains list of users
response?.items?.let {
// save users to database
repository.saveUsersToDatabase(it)
// save current search entry to preferences
repository.saveCurrentSearchToPrefs(username)
}
}catch (e: IOException){
operationError.postValue(Event(e.message!!))
}finally {
operationState.postValue(Event(false))
}
}
}
fun getSingleUser(id: Int) = repository.getSingleUserFromDatabase(id)
}

View File

@@ -0,0 +1,22 @@
package com.example.h_mal.stackexchangeusers.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.h_mal.stackexchangeusers.data.repositories.Repository
/**
* Viewmodel factory for [MainViewModel]
* @repository injected into MainViewModel
*/
class MainViewModelFactory(
private val repository: Repository
) : ViewModelProvider.Factory{
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return (MainViewModel(repository)) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -0,0 +1,85 @@
package com.example.h_mal.stackexchangeusers.ui.main
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.application.AppClass
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources
import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import com.example.h_mal.stackexchangeusers.ui.MainViewModelFactory
import com.example.h_mal.stackexchangeusers.utils.Event
import com.example.h_mal.stackexchangeusers.utils.displayToast
import com.example.h_mal.stackexchangeusers.utils.hide
import com.example.h_mal.stackexchangeusers.utils.show
import kotlinx.android.synthetic.main.main_activity.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
/**
* [MainActivity] hosting the fragments and controlling a lot of the UI
*/
class MainActivity : AppCompatActivity(), KodeinAware {
//retrieve the viewmodel factory from the kodein dependency injection
override val kodein by kodein()
private val factory by instance<MainViewModelFactory>()
// Kotlin lazy instantiation of viewmodel
val viewmodel by viewModels<MainViewModel> { factory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
// setup home button for back navigation
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
// setup observers for operation live data
viewmodel.operationState.observe(this, stateObserver)
viewmodel.operationError.observe(this, errorObserver)
}
override fun onDestroy() {
super.onDestroy()
// remove observers on activity destroy
viewmodel.operationState.removeObserver(stateObserver)
viewmodel.operationError.removeObserver(errorObserver)
}
// toggle visibility of progress spinner while async operations are taking place
private val stateObserver = Observer<Event<Boolean>> {
when(it.getContentIfNotHandled()){
true -> {
progress_circular.show()
idlingResources.increment()
}
false -> {
progress_circular.hide()
idlingResources.decrement()
}
}
}
// display a toast when operation fails
private val errorObserver = Observer<Event<String>> {
it.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
}
//When home button in toolbar is pressed
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
}
return super.onOptionsItemSelected(item)
}
}

View File

@@ -0,0 +1,72 @@
package com.example.h_mal.stackexchangeusers.ui.main.pages.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.application.AppClass.Companion.idlingResources
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import com.example.h_mal.stackexchangeusers.utils.onSubmitListener
import kotlinx.android.synthetic.main.main_fragment.*
/**
* UI for the screen holding the list, search box
* Results for users will be displayed here
*/
class MainFragment : Fragment() {
/**
* get [MainViewModel] from [MainActivity] (the calling activity)
*/
val viewModel: MainViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.main_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
search_bar.apply {
// use submit listener just to retrieve input upon submission of search view
onSubmitListener {
viewModel.getUsers(query.toString().trim())
}
}
}
override fun onStart() {
super.onStart()
viewModel.usersLiveData.observe(viewLifecycleOwner, usersObserver)
}
override fun onStop() {
super.onStop()
viewModel.usersLiveData.removeObserver(usersObserver)
}
private val usersObserver = Observer<List<UserItem>> {
// create adapter the recycler view
val mAdapter = UserRecyclerViewAdapter(
requireView(),
it
)
// setup the recyclerview
recycler_view.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = mAdapter
}
}
}

View File

@@ -0,0 +1,64 @@
package com.example.h_mal.stackexchangeusers.ui.main.pages.home
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
import com.example.h_mal.stackexchangeusers.utils.loadImage
import com.example.h_mal.stackexchangeusers.utils.navigateTo
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.simple_item.view.*
/**
* Create a Recyclerview adapter used to display [UserItem]
* @param parentView is used for context and navigation
* @param items is the list of users parsed from the database
*/
class UserRecyclerViewAdapter (
val parentView: View,
val items: List<UserItem>
): RecyclerView.Adapter<RecyclerView.ViewHolder>(){
// create a viewholder with [R.layout.simple_item]
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater
.from(parentView.context)
.inflate(R.layout.simple_item, parent, false)
return ItemOne(view)
}
override fun getItemCount(): Int {
return items.size
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val view = holder as ItemOne
items[position].let {
view.bindUser(it)
}
}
// bind userItem fields to the views in the layout
internal inner class ItemOne(
override val containerView: View
) : RecyclerView.ViewHolder(containerView), LayoutContainer {
fun bindUser(userItem: UserItem){
itemView.profile_img.loadImage(userItem.profileImage, 48, 48)
itemView.text1.text = userItem.displayName
itemView.text2.text = userItem.reputation.toString()
itemView.setOnClickListener {
val action =
MainFragmentDirections.toUserProfileFragment(items[position].userId!!)
parentView.navigateTo(action)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.example.h_mal.stackexchangeusers.ui.main.pages.user
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import com.example.h_mal.stackexchangeusers.R
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import com.example.h_mal.stackexchangeusers.utils.epochToData
import com.example.h_mal.stackexchangeusers.utils.loadImage
import kotlinx.android.synthetic.main.fragment_user_profile.*
/**
* A simple [Fragment] subclass.
* Use the [UserProfileFragment.newInstance] factory method to
* create an instance of this fragment.
*/
class UserProfileFragment : Fragment() {
val viewModel: MainViewModel by activityViewModels()
var userId: Int = 0
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_user_profile, container, false)
}
//Update the data for viewbinding onResume as data would have changed when selecting a new user
override fun onResume() {
super.onResume()
userId = UserProfileFragmentArgs.fromBundle(requireArguments()).userId
viewModel.getSingleUser(userId).observe(viewLifecycleOwner, singleUserObserver)
}
override fun onPause() {
super.onPause()
viewModel.getSingleUser(id).removeObserver(singleUserObserver)
}
private val singleUserObserver = Observer<UserItem> {
username.text = it.displayName
reputation.text = it.reputation.toString()
gold_score.text = it.gold.toString()
silver_score.text = it.silver.toString()
bronze_score.text = it.bronze.toString()
date_joined.text = epochToData(it.creationDate)
imageView.loadImage(it.profileImage)
}
}

View File

@@ -0,0 +1,17 @@
package com.example.h_mal.stackexchangeusers.utils
import java.text.SimpleDateFormat
import java.util.*
fun epochToData(number: Int?): String {
number ?: return ""
return try {
val sdf = SimpleDateFormat("dd/MM/yyyy")
val netDate = Date(number.toLong() * 1000)
sdf.format(netDate)
} catch (e: Exception) {
""
}
}

View File

@@ -0,0 +1,24 @@
package com.example.h_mal.stackexchangeusers.utils
/**
* Used with livedata<T> to make observation lifecycle aware
* Display livedata response only once
*/
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set // Allow external read but not write
/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
}

View File

@@ -0,0 +1,57 @@
package com.example.h_mal.stackexchangeusers.utils
import android.content.Context
import android.view.View
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.navigation.NavDirections
import androidx.navigation.Navigation
import com.example.h_mal.stackexchangeusers.R
import com.squareup.picasso.Picasso
fun View.show() {
this.visibility = View.VISIBLE
}
fun View.hide() {
this.visibility = View.GONE
}
fun Context.displayToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun View.navigateTo(navDirections: NavDirections) {
Navigation.findNavController(this).navigate(navDirections)
}
fun ImageView.loadImage(url: String?){
Picasso.get()
.load(url)
.placeholder(R.mipmap.ic_launcher)
.into(this)
}
fun ImageView.loadImage(url: String?, height: Int, width: Int){
Picasso.get()
.load(url)
.resize(width, height)
.centerCrop()
.placeholder(R.mipmap.ic_launcher)
.into(this)
}
fun SearchView.onSubmitListener(searchSubmit: (String) -> Unit) {
this.setOnQueryTextListener(object :
SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(s: String): Boolean {
searchSubmit.invoke(s)
return true
}
override fun onQueryTextChange(s: String): Boolean {
return true
}
})
}

View File

@@ -0,0 +1,30 @@
<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: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>
<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,170 @@
<?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="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,153 @@
<?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.main.pages.user.UserProfileFragment">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintHeight_percent="0.25"
android:adjustViewBounds="true"
app:layout_constraintVertical_bias="0.12"
android:src="@mipmap/ic_launcher"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/username_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Username:"
app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/imageView" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@{user.displayName}"
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/imageView"
tools:text="Username1234" />
<TextView
android:id="@+id/reputation_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Reputation:"
app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/username_label" />
<TextView
android:id="@+id/reputation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@{user.reputation.toString()}"
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/username"
tools:text="1548252" />
<TextView
android:id="@+id/badges_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Bagdes:"
app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/reputation_label" />
<TextView
android:id="@+id/badges_gold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Gold - "
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/reputation" />
<TextView
android:id="@+id/gold_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.badgeCounts.gold.toString()}"
app:layout_constraintLeft_toRightOf="@id/badges_gold"
app:layout_constraintTop_toTopOf="@id/badges_gold"
tools:text="3554" />
<TextView
android:id="@+id/badges_silver"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Silver - "
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/badges_gold" />
<TextView
android:id="@+id/silver_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.badgeCounts.silver.toString()}"
app:layout_constraintLeft_toRightOf="@id/badges_silver"
app:layout_constraintTop_toTopOf="@id/badges_silver"
tools:text="3554" />
<TextView
android:id="@+id/badges_bronze"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Bronze - "
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/badges_silver" />
<TextView
android:id="@+id/bronze_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.badgeCounts.bronze.toString()}"
app:layout_constraintLeft_toRightOf="@id/badges_bronze"
app:layout_constraintTop_toTopOf="@id/badges_bronze"
tools:text="3554" />
<TextView
android:id="@+id/date_joined_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Date Joined:"
app:layout_constraintRight_toLeftOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/badges_bronze" />
<TextView
android:id="@+id/date_joined"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@{ConverterUtil.epochToData(user.creationDate)}"
app:layout_constraintLeft_toRightOf="@id/divider"
app:layout_constraintTop_toBottomOf="@+id/badges_bronze"
tools:text="31/01/2019" />
<Space
android:id="@+id/divider"
android:layout_width="24dp"
android:layout_height="0dp"
android:layout_marginBottom="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="user"
type="com.example.h_mal.stackexchangeusers.data.network.model.User" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" >
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="12dp"
tools:text="12345"
android:text="@{user.userId.toString()}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/textView2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="12dp"
tools:text="Username"
android:text="@{user.displayName}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toRightOf="@id/textView"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainActivity">
<fragment
android:id="@+id/container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/app_navigation"
tools:layout="@layout/main_fragment" />
<ProgressBar
android:id="@+id/progress_circular"
android:visibility="gone"
tools:visibility="visible"
android:elevation="0.2dp"
android:layout_gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,35 @@
<?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/main"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.pages.home.MainFragment">
<androidx.appcompat.widget.SearchView
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/search_bar"
tools:listitem="@layout/simple_item">
</androidx.recyclerview.widget.RecyclerView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,45 @@
<?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="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<com.mikhaellopez.circularimageview.CircularImageView
android:id="@+id/profile_img"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:src="@drawable/ic_launcher_background"
app:civ_border_width="0dp"
app:civ_shadow_radius="1dp"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="12dp"/>
<LinearLayout
app:layout_constraintLeft_toRightOf="@id/profile_img"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text1"
android:textSize="16sp"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView android:id="@+id/text2"
android:textSize="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

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.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,26 @@
<?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/app_navigation"
app:startDestination="@id/mainFragment">
<fragment
android:id="@+id/mainFragment"
android:name="com.example.h_mal.stackexchangeusers.ui.main.pages.home.MainFragment"
android:label="MainFragment"
tools:layout="@layout/main_fragment">
<action
android:id="@+id/to_userProfileFragment"
app:destination="@id/userProfileFragment" />
</fragment>
<fragment
android:id="@+id/userProfileFragment"
android:name="com.example.h_mal.stackexchangeusers.ui.main.pages.user.UserProfileFragment"
android:label="fragment_user_profile"
tools:layout="@layout/fragment_user_profile">
<argument
android:name="userId"
app:argType="integer" />
</fragment>
</navigation>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#6200EE</color>
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="activity_horizontal_margin">16dp</dimen>
</resources>

View File

@@ -0,0 +1,8 @@
<resources>
<string name="app_name">Stack Exchange Users</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="invalid_entry">Enter a valid username</string>
</resources>

View File

@@ -0,0 +1,11 @@
<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>
</style>
</resources>

View File

@@ -0,0 +1,16 @@
package com.example.h_mal.stackexchangeusers
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,93 @@
package com.example.h_mal.stackexchangeusers.data.repositories
import com.example.h_mal.stackexchangeusers.data.network.api.ApiClass
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import com.example.h_mal.stackexchangeusers.data.preferences.PreferenceProvider
import com.example.h_mal.stackexchangeusers.data.room.AppDatabase
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import retrofit2.Response
import okhttp3.ResponseBody
import okio.IOException
import kotlin.test.assertFailsWith
class RepositoryTest {
lateinit var repository: Repository
@Mock
lateinit var api: ApiClass
@Mock
lateinit var db: AppDatabase
@Mock
lateinit var prefs: PreferenceProvider
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
repository = RepositoryImpl(api, db, prefs)
}
@Test
fun fetchUserFromApi_positiveResponse() = runBlocking {
// GIVEN
val input = "12345"
val mockApiResponse = mock(ApiResponse::class.java)
val mockResponse = Response.success(mockApiResponse)
// WHEN
Mockito.`when`(api.getUsersFromApi(input)).thenReturn(
mockResponse
)
Mockito.`when`(prefs.getLastSavedAt(input)).thenReturn(null)
// THEN
val getUser = repository.getUsersFromApi(input)
assertNotNull(getUser)
assertEquals(mockApiResponse, getUser)
}
@Test
fun fetchUserFromApi_negativeResponse() = runBlocking {
//GIVEN
//mock retrofit error response
val mockBody = mock(ResponseBody::class.java)
val mockRaw = mock(okhttp3.Response::class.java)
val re = Response.error<String>(mockBody, mockRaw)
//WHEN
Mockito.`when`(api.getUsersFromApi("12345")).then { re }
//THEN - assert exception is not null
val ioExceptionReturned = assertFailsWith<IOException> {
repository.getUsersFromApi("12345")
}
assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message)
}
@Test
fun fetchUserFromApi_alreadySearched() = runBlocking {
// GIVEN
val mockApiResponse = mock(ApiResponse::class.java)
val mockResponse = Response.success(mockApiResponse)
//WHEN
Mockito.`when`(api.getUsersFromApi("12345")).thenReturn(
mockResponse
)
Mockito.`when`(prefs.getLastSavedAt("12345")).thenReturn(System.currentTimeMillis())
// THEN
val getUser = repository.getUsersFromApi("12345")
assertEquals(getUser, null)
}
}

View File

@@ -0,0 +1,75 @@
package com.example.h_mal.stackexchangeusers.ui.main
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.example.h_mal.stackexchangeusers.data.network.model.ApiResponse
import com.example.h_mal.stackexchangeusers.data.network.model.User
import com.example.h_mal.stackexchangeusers.data.repositories.Repository
import com.example.h_mal.stackexchangeusers.data.repositories.RepositoryImpl
import com.example.h_mal.stackexchangeusers.data.room.entities.UserItem
import com.example.h_mal.stackexchangeusers.ui.MainViewModel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import java.io.IOException
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MainViewModelTest {
@get:Rule
var rule: TestRule = InstantTaskExecutorRule()
lateinit var viewModel: MainViewModel
@Mock
lateinit var repository: Repository
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = MainViewModel(repository)
}
@Test
fun getApiFromRepository_SuccessfulReturn() = runBlocking{
//GIVEN
val mockApiResponse = mock(ApiResponse::class.java)
//WHEN
Mockito.`when`(repository.getUsersFromApi("12345")).thenReturn(mockApiResponse)
//THEN
viewModel.getUsers("12345")
viewModel.operationState.observeForever{
it.getContentIfNotHandled()?.let {result ->
assertFalse { result }
}
}
}
@Test
fun getApiFromRepository_unsuccessfulReturn() = runBlocking{
// WHEN
Mockito.`when`(repository.getUsersFromApi("12345")).thenAnswer{ throw IOException("throwed") }
// THEN
viewModel.getUsers("fsdfsdf")
viewModel.operationError.observeForever{
it.getContentIfNotHandled()?.let {result ->
assertEquals(result, "throwed")
}
}
}
}