Initial commit
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
99
app/build.gradle
Normal 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
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
30
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal 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>
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
153
app/src/main/res/layout/fragment_user_profile.xml
Normal 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>
|
||||
39
app/src/main/res/layout/list_item_layout.xml
Normal 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>
|
||||
34
app/src/main/res/layout/main_activity.xml
Normal 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>
|
||||
35
app/src/main/res/layout/main_fragment.xml
Normal 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>
|
||||
|
||||
45
app/src/main/res/layout/simple_item.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
26
app/src/main/res/navigation/app_navigation.xml
Normal 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>
|
||||
6
app/src/main/res/values/colors.xml
Normal 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>
|
||||
5
app/src/main/res/values/dimens.xml
Normal 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>
|
||||
8
app/src/main/res/values/strings.xml
Normal 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>
|
||||
11
app/src/main/res/values/styles.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||