diff --git a/app/build.gradle b/app/build.gradle
index d97147a..c130b6e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,10 +1,10 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
+apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 30
- buildToolsVersion "30.0.0"
defaultConfig {
applicationId "com.studyfork.sfoide"
@@ -14,6 +14,8 @@ android {
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+ resValue "string", "google_maps_key", (project.findProperty("GOOGLE_MAPS_API_KEY") ?: "")
}
buildTypes {
@@ -22,16 +24,56 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+
+ // Configure only for each module that uses Java 8
+ // language features (either in its source code or
+ // through dependencies).
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ // For Kotlin projects
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ dataBinding {
+ enabled = true
+ }
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
- implementation 'androidx.core:core-ktx:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
+
+ implementation 'androidx.core:core-ktx:1.3.0'
+ implementation 'androidx.activity:activity-ktx:1.1.0'
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
+
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
+ implementation 'androidx.recyclerview:recyclerview:1.1.0'
+ implementation 'com.google.android.material:material:1.1.0'
+ implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
+
+ // test
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+ // networking
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ implementation 'com.squareup.okhttp3:logging-interceptor:4.8.0'
+
+ // log
+ implementation 'com.jakewharton.timber:timber:4.7.1'
+
+ // image
+ implementation 'com.github.bumptech.glide:glide:4.11.0'
+ kapt 'com.github.bumptech.glide:compiler:4.11.0'
+
+ // google map
+ implementation 'com.google.android.gms:play-services-maps:17.0.0'
+ implementation "com.google.android.gms:play-services-location:17.0.0"
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d17cca5..050bc06 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,20 +2,30 @@
+
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/FriendApp.kt b/app/src/main/java/com/studyfork/sfoide/FriendApp.kt
new file mode 100644
index 0000000..44c923f
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/FriendApp.kt
@@ -0,0 +1,15 @@
+package com.studyfork.sfoide
+
+import android.app.Application
+import timber.log.Timber
+
+class FriendApp : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/MainActivity.kt b/app/src/main/java/com/studyfork/sfoide/MainActivity.kt
deleted file mode 100644
index af5f449..0000000
--- a/app/src/main/java/com/studyfork/sfoide/MainActivity.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.studyfork.sfoide
-
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-
-class MainActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/datasource/RemoteFriendDataSourceImpl.kt b/app/src/main/java/com/studyfork/sfoide/data/datasource/RemoteFriendDataSourceImpl.kt
new file mode 100644
index 0000000..c648baf
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/datasource/RemoteFriendDataSourceImpl.kt
@@ -0,0 +1,46 @@
+package com.studyfork.sfoide.data.datasource
+
+import com.studyfork.sfoide.data.mapper.toEntity
+import com.studyfork.sfoide.data.model.FriendData
+import com.studyfork.sfoide.data.remote.api.FriendApi
+import com.studyfork.sfoide.data.remote.datasource.RemoteFriendDataSource
+import com.studyfork.sfoide.data.remote.response.FriendsResponse
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class RemoteFriendDataSourceImpl(
+ private val friendApi: FriendApi
+) : RemoteFriendDataSource {
+
+ override fun getFriends(
+ pageNumber: Int,
+ itemCount: Int,
+ onSuccess: (friends: List) -> Unit,
+ onError: (error: Throwable) -> Unit
+ ) {
+ friendApi.getFriends(
+ page = pageNumber,
+ results = itemCount
+ )
+ .enqueue(object : Callback {
+ override fun onFailure(call: Call, t: Throwable) {
+ onError(t)
+ }
+
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ if (response.isSuccessful) {
+ val friends =
+ response.body()?.results?.map { FriendResponse -> FriendResponse.toEntity() }
+ ?: emptyList()
+ onSuccess(friends)
+ } else {
+ onError(Throwable("network error"))
+ }
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/mapper/FriendMapper.kt b/app/src/main/java/com/studyfork/sfoide/data/mapper/FriendMapper.kt
new file mode 100644
index 0000000..517644d
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/mapper/FriendMapper.kt
@@ -0,0 +1,23 @@
+package com.studyfork.sfoide.data.mapper
+
+import com.studyfork.sfoide.data.model.CoordinatesData
+import com.studyfork.sfoide.data.model.FriendData
+import com.studyfork.sfoide.data.remote.response.FriendsResponse
+
+fun FriendsResponse.Result.toEntity(): FriendData {
+ return FriendData(
+ id = this.login?.uuid ?: "",
+ thumbnail = this.picture?.large ?: "",
+ name = this.name.toString(),
+ age = this.dob?.age ?: 0,
+ gender = this.gender ?: "",
+ country = this.nat ?: "",
+ email = this.email ?: "",
+ telephone = this.phone ?: "",
+ mobilePhone = this.cell ?: "",
+ coordinatesData = CoordinatesData(
+ latitude = this.location?.coordinates?.latitude?.toDouble() ?: 0.0,
+ longitude = this.location?.coordinates?.longitude?.toDouble() ?: 0.0
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/model/FriendData.kt b/app/src/main/java/com/studyfork/sfoide/data/model/FriendData.kt
new file mode 100644
index 0000000..e4cadc5
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/model/FriendData.kt
@@ -0,0 +1,19 @@
+package com.studyfork.sfoide.data.model
+
+data class FriendData(
+ val id: String,
+ val thumbnail: String,
+ val name: String,
+ val age: Int,
+ val gender: String,
+ val country: String,
+ val email: String,
+ val telephone: String,
+ val mobilePhone: String,
+ val coordinatesData: CoordinatesData
+)
+
+class CoordinatesData(
+ val latitude: Double,
+ val longitude: Double
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/remote/RetrofitService.kt b/app/src/main/java/com/studyfork/sfoide/data/remote/RetrofitService.kt
new file mode 100644
index 0000000..3ba319b
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/remote/RetrofitService.kt
@@ -0,0 +1,33 @@
+package com.studyfork.sfoide.data.remote
+
+import com.studyfork.sfoide.BuildConfig
+import com.studyfork.sfoide.data.remote.api.FriendApi
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+object RetrofitService {
+
+ private val retrofit = Retrofit.Builder()
+ .baseUrl("https://randomuser.me/")
+ .client(createOkHttpClient())
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ private fun createOkHttpClient(): OkHttpClient {
+ return OkHttpClient.Builder().addInterceptor(createHttpLoggingInterceptor()).build()
+ }
+
+ private fun createHttpLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ }
+ }
+
+ val friendApi = retrofit.create(FriendApi::class.java)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/remote/api/FriendApi.kt b/app/src/main/java/com/studyfork/sfoide/data/remote/api/FriendApi.kt
new file mode 100644
index 0000000..5395807
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/remote/api/FriendApi.kt
@@ -0,0 +1,15 @@
+package com.studyfork.sfoide.data.remote.api
+
+import com.studyfork.sfoide.data.remote.response.FriendsResponse
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface FriendApi {
+
+ @GET("api/")
+ fun getFriends(
+ @Query("page") page: Int,
+ @Query("results") results: Int
+ ): Call
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/remote/datasource/RemoteFriendDataSource.kt b/app/src/main/java/com/studyfork/sfoide/data/remote/datasource/RemoteFriendDataSource.kt
new file mode 100644
index 0000000..fabe7ca
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/remote/datasource/RemoteFriendDataSource.kt
@@ -0,0 +1,13 @@
+package com.studyfork.sfoide.data.remote.datasource
+
+import com.studyfork.sfoide.data.model.FriendData
+
+interface RemoteFriendDataSource {
+
+ fun getFriends(
+ pageNumber: Int,
+ itemCount: Int,
+ onSuccess: (friends: List) -> Unit,
+ onError: (error: Throwable) -> Unit
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/data/remote/response/FriendsResponse.kt b/app/src/main/java/com/studyfork/sfoide/data/remote/response/FriendsResponse.kt
new file mode 100644
index 0000000..77f3dad
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/data/remote/response/FriendsResponse.kt
@@ -0,0 +1,146 @@
+package com.studyfork.sfoide.data.remote.response
+
+import com.google.gson.annotations.SerializedName
+
+data class FriendsResponse(
+ @SerializedName("results")
+ val results: List?,
+ @SerializedName("info")
+ val info: Info?
+) {
+ data class Result(
+ @SerializedName("gender")
+ val gender: String?,
+ @SerializedName("name")
+ val name: Name?,
+ @SerializedName("location")
+ val location: Location?,
+ @SerializedName("email")
+ val email: String?,
+ @SerializedName("login")
+ val login: Login?,
+ @SerializedName("dob")
+ val dob: Dob?,
+ @SerializedName("registered")
+ val registered: Registered?,
+ @SerializedName("phone")
+ val phone: String?,
+ @SerializedName("cell")
+ val cell: String?,
+ @SerializedName("id")
+ val id: Id?,
+ @SerializedName("picture")
+ val picture: Picture?,
+ @SerializedName("nat")
+ val nat: String?
+ ) {
+ data class Name(
+ @SerializedName("title")
+ val title: String?,
+ @SerializedName("first")
+ val first: String?,
+ @SerializedName("last")
+ val last: String?
+ ) {
+ override fun toString(): String {
+ return first + last
+ }
+ }
+
+ data class Location(
+ @SerializedName("street")
+ val street: Street?,
+ @SerializedName("city")
+ val city: String?,
+ @SerializedName("state")
+ val state: String?,
+ @SerializedName("country")
+ val country: String?,
+ @SerializedName("postcode")
+ val postcode: Any?,
+ @SerializedName("coordinates")
+ val coordinates: Coordinates?,
+ @SerializedName("timezone")
+ val timezone: Timezone?
+ ) {
+ data class Street(
+ @SerializedName("number")
+ val number: Int?,
+ @SerializedName("name")
+ val name: String?
+ )
+
+ data class Coordinates(
+ @SerializedName("latitude")
+ val latitude: String?,
+ @SerializedName("longitude")
+ val longitude: String?
+ )
+
+ data class Timezone(
+ @SerializedName("offset")
+ val offset: String?,
+ @SerializedName("description")
+ val description: String?
+ )
+ }
+
+ data class Login(
+ @SerializedName("uuid")
+ val uuid: String?,
+ @SerializedName("username")
+ val username: String?,
+ @SerializedName("password")
+ val password: String?,
+ @SerializedName("salt")
+ val salt: String?,
+ @SerializedName("md5")
+ val md5: String?,
+ @SerializedName("sha1")
+ val sha1: String?,
+ @SerializedName("sha256")
+ val sha256: String?
+ )
+
+ data class Dob(
+ @SerializedName("date")
+ val date: String?,
+ @SerializedName("age")
+ val age: Int?
+ )
+
+ data class Registered(
+ @SerializedName("date")
+ val date: String?,
+ @SerializedName("age")
+ val age: Int?
+ )
+
+ data class Id(
+ @SerializedName("name")
+ val name: String?,
+ @SerializedName("value")
+ val value: String?
+ )
+
+ data class Picture(
+ @SerializedName("large")
+ val large: String?,
+ @SerializedName("medium")
+ val medium: String?,
+ @SerializedName("thumbnail")
+ val thumbnail: String?
+ )
+ }
+
+ data class Info(
+ @SerializedName("seed")
+ val seed: String?,
+ @SerializedName("results")
+ val results: Int?,
+ @SerializedName("page")
+ val page: Int?,
+ @SerializedName("version")
+ val version: String?
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/ext/ImageViewExt.kt b/app/src/main/java/com/studyfork/sfoide/ui/ext/ImageViewExt.kt
new file mode 100644
index 0000000..63ce6f8
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/ext/ImageViewExt.kt
@@ -0,0 +1,15 @@
+package com.studyfork.sfoide.ui.ext
+
+import android.widget.ImageView
+import androidx.databinding.BindingAdapter
+import com.bumptech.glide.Glide
+
+@BindingAdapter("app:loadUrl")
+fun ImageView.loadUrl(url: String) {
+ Glide.with(this)
+ .load(url)
+ .fitCenter()
+ .centerCrop()
+ .circleCrop()
+ .into(this)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/ext/TextViewExt.kt b/app/src/main/java/com/studyfork/sfoide/ui/ext/TextViewExt.kt
new file mode 100644
index 0000000..ede4984
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/ext/TextViewExt.kt
@@ -0,0 +1,61 @@
+package com.studyfork.sfoide.ui.ext
+
+import android.widget.TextView
+import androidx.databinding.BindingAdapter
+import com.studyfork.sfoide.R
+import com.studyfork.sfoide.ui.model.Friend
+
+private const val MALE = "male"
+
+@BindingAdapter("app:nameAndAge")
+fun TextView.setNameAndAge(friend: Friend) {
+ text = "${friend.name}(${friend.age})"
+}
+
+@BindingAdapter("app:gender")
+fun TextView.setGender(gender: String) {
+ text = if (gender == MALE) {
+ context.resources.getString(R.string.male)
+ } else {
+ context.resources.getString(R.string.female)
+ }
+}
+
+@BindingAdapter("app:country")
+fun TextView.setCountry(country: String) {
+ text = when (country) {
+ "AU" -> context.resources.getString(R.string.au)
+ "BR" -> context.resources.getString(R.string.br)
+ "CA" -> context.resources.getString(R.string.ca)
+ "CH" -> context.resources.getString(R.string.ch)
+ "DE" -> context.resources.getString(R.string.de)
+ "DK" -> context.resources.getString(R.string.dk)
+ "ES" -> context.resources.getString(R.string.es)
+ "FI" -> context.resources.getString(R.string.fi)
+ "FR" -> context.resources.getString(R.string.fr)
+ "GB" -> context.resources.getString(R.string.gb)
+ "IE" -> context.resources.getString(R.string.ie)
+ "IR" -> context.resources.getString(R.string.ir)
+ "NO" -> context.resources.getString(R.string.no)
+ "NL" -> context.resources.getString(R.string.nl)
+ "NZ" -> context.resources.getString(R.string.nz)
+ "TR" -> context.resources.getString(R.string.tr)
+ "US" -> context.resources.getString(R.string.us)
+ else -> ""
+ }
+}
+
+@BindingAdapter("app:email")
+fun TextView.setEmail(email: String) {
+ text = "${context.resources.getString(R.string.email)} $email"
+}
+
+@BindingAdapter("app:mobile")
+fun TextView.setMobile(mobile: String) {
+ text = "${context.resources.getString(R.string.mobile_phone)} $mobile"
+}
+
+@BindingAdapter("app:telephone")
+fun TextView.setTelephone(telephone: String) {
+ text = "${context.resources.getString(R.string.telephone)} $telephone"
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendActivity.kt b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendActivity.kt
new file mode 100644
index 0000000..c28ed89
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendActivity.kt
@@ -0,0 +1,89 @@
+package com.studyfork.sfoide.ui.friend
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.RecyclerView
+import com.studyfork.sfoide.R
+import com.studyfork.sfoide.data.datasource.RemoteFriendDataSourceImpl
+import com.studyfork.sfoide.ui.model.Friend
+import com.studyfork.sfoide.data.remote.RetrofitService
+import com.studyfork.sfoide.databinding.ActivityFriendBinding
+import com.studyfork.sfoide.ui.friend.detail.FriendDetailActivity
+import com.studyfork.sfoide.ui.utils.EndlessRecyclerViewScrollListener
+import com.studyfork.sfoide.ui.utils.EventObserver
+
+class FriendActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFriendBinding
+
+ private var lastBackPressed: Long = 0L
+
+ private val viewModel: FriendViewModel by viewModels {
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return FriendViewModel(RemoteFriendDataSourceImpl(RetrofitService.friendApi)) as T
+ }
+ }
+ }
+
+ private lateinit var friendAdapter: FriendAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_friend)
+ binding.lifecycleOwner = this
+ binding.vm = viewModel
+
+ initRecyclerView()
+ observeViewModel()
+ }
+
+ private fun initRecyclerView() {
+ friendAdapter = FriendAdapter(viewModel)
+ binding.rvMain.adapter = friendAdapter
+ binding.rvMain.addOnScrollListener(object : EndlessRecyclerViewScrollListener(binding.rvMain.layoutManager!!) {
+ override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) {
+ viewModel.loadMoreFriends()
+ }
+ })
+ }
+
+ private fun observeViewModel() {
+ with(viewModel) {
+ friendList.observe(this@FriendActivity, Observer { friends ->
+ friendAdapter.submitList(friends)
+ })
+
+ navigateDetailEvent.observe(
+ this@FriendActivity,
+ EventObserver(this@FriendActivity::navigateFriendDetailActivity)
+ )
+ }
+ }
+
+ private fun navigateFriendDetailActivity(friend: Friend) {
+ startActivity(FriendDetailActivity.createIntent(this, friend))
+ }
+
+ override fun onBackPressed() {
+ val current = System.currentTimeMillis()
+ val term = current - lastBackPressed
+ if (term > TWO_SECONDS) {
+ Toast.makeText(this, "뒤로가기를 한번 더 눌러주세요", Toast.LENGTH_SHORT).show()
+ lastBackPressed = current
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+ companion object {
+ private const val TWO_SECONDS = 2000
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendAdapter.kt b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendAdapter.kt
new file mode 100644
index 0000000..735dd05
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendAdapter.kt
@@ -0,0 +1,52 @@
+package com.studyfork.sfoide.ui.friend
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.studyfork.sfoide.R
+import com.studyfork.sfoide.ui.model.Friend
+import com.studyfork.sfoide.databinding.ItemFriendBinding
+
+class FriendAdapter(
+ val viewModel: FriendViewModel
+) : ListAdapter(object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Friend, newItem: Friend): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: Friend, newItem: Friend): Boolean {
+ return oldItem == newItem
+ }
+}) {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FriendViewHolder {
+ val binding: ItemFriendBinding = DataBindingUtil.inflate(
+ LayoutInflater.from(parent.context),
+ R.layout.item_friend,
+ parent,
+ false
+ )
+
+ val holder = FriendViewHolder(binding)
+
+ binding.root.setOnClickListener {
+ viewModel.navigateFriendDetail(getItem(holder.adapterPosition))
+ }
+
+ return holder
+ }
+
+ override fun onBindViewHolder(holder: FriendViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ class FriendViewHolder(private val binding: ItemFriendBinding) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(friend: Friend) {
+ binding.friend = friend
+ binding.executePendingBindings()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendViewModel.kt b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendViewModel.kt
new file mode 100644
index 0000000..75ccde2
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/friend/FriendViewModel.kt
@@ -0,0 +1,63 @@
+package com.studyfork.sfoide.ui.friend
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.studyfork.sfoide.data.remote.datasource.RemoteFriendDataSource
+import com.studyfork.sfoide.ui.mapper.toPresentation
+import com.studyfork.sfoide.ui.model.Friend
+import com.studyfork.sfoide.ui.utils.Event
+
+class FriendViewModel(
+ private val remoteFriendDataSource: RemoteFriendDataSource
+) : ViewModel() {
+
+ private val _friendList = MutableLiveData>()
+ val friendList: LiveData> = _friendList
+
+ private val _loading = MutableLiveData(false)
+ val loading: LiveData = _loading
+
+ private val _navigateDetailEvent = MutableLiveData>()
+ val navigateDetailEvent: LiveData> = _navigateDetailEvent
+
+ private var pageNumber: Int = 1
+
+ init {
+ fetchFriends(pageNumber)
+ }
+
+ private fun fetchFriends(pageNumber: Int, itemCount: Int = ITEM_COUNT) {
+ remoteFriendDataSource.getFriends(
+ pageNumber = pageNumber,
+ itemCount = itemCount,
+ onSuccess = { friends ->
+ _loading.value = false
+ _friendList.value =
+ (_friendList.value ?: emptyList()) + friends.map { it.toPresentation() }
+ },
+ onError = {
+ _loading.value = false
+ }
+ )
+ }
+
+ fun loadMoreFriends() {
+ fetchFriends(++pageNumber)
+ }
+
+ fun onRefresh() {
+ _friendList.value = emptyList()
+ _loading.value = true
+ pageNumber = 1
+ fetchFriends(pageNumber)
+ }
+
+ fun navigateFriendDetail(friend: Friend) {
+ _navigateDetailEvent.value = Event(friend)
+ }
+
+ companion object {
+ private const val ITEM_COUNT = 20
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailActivity.kt b/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailActivity.kt
new file mode 100644
index 0000000..74579d1
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailActivity.kt
@@ -0,0 +1,94 @@
+package com.studyfork.sfoide.ui.friend.detail
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.OnMapReadyCallback
+import com.google.android.gms.maps.SupportMapFragment
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.MarkerOptions
+import com.studyfork.sfoide.R
+import com.studyfork.sfoide.ui.model.Friend
+import com.studyfork.sfoide.databinding.ActivityFriendDetailBinding
+import com.studyfork.sfoide.ui.utils.EventObserver
+
+class FriendDetailActivity : AppCompatActivity(), OnMapReadyCallback {
+
+ private lateinit var binding: ActivityFriendDetailBinding
+
+ private val viewModel: FriendDetailViewModel by viewModels()
+
+ private var friend: Friend? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_friend_detail)
+ binding.lifecycleOwner = this
+ binding.vm = viewModel
+
+ initMap()
+ observeViewModel()
+
+ friend = intent.getParcelableExtra(EXTRA_FRIEND)
+ binding.friend = this.friend
+ }
+
+ private fun initMap() {
+ val mapFragment =
+ (supportFragmentManager.findFragmentById(R.id.map_friend_detail) as? SupportMapFragment)
+ mapFragment?.getMapAsync(this)
+ }
+
+ private fun observeViewModel() {
+ with(viewModel) {
+ navigatePhoneAppEvent.observe(
+ this@FriendDetailActivity,
+ EventObserver(this@FriendDetailActivity::openDialApp)
+ )
+
+ navigateEmailAppEvent.observe(
+ this@FriendDetailActivity,
+ EventObserver(this@FriendDetailActivity::openEmailApp)
+ )
+ }
+ }
+
+ private fun openDialApp(phone: String) {
+ startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")))
+ }
+
+ private fun openEmailApp(email: String) {
+ startActivity(Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:")
+ putExtra(Intent.EXTRA_EMAIL, email)
+ })
+ }
+
+ override fun onMapReady(map: GoogleMap?) {
+ friend?.coordinates?.let {
+ val coordinates = LatLng(it.latitude, it.longitude)
+ map?.addMarker(
+ MarkerOptions()
+ .position(coordinates)
+ )
+
+ map?.moveCamera(CameraUpdateFactory.newLatLng(coordinates))
+ }
+ }
+
+ companion object {
+
+ const val EXTRA_FRIEND = "EXTRA_FRIEND"
+ fun createIntent(context: Context, friend: Friend): Intent {
+ return Intent(context, FriendDetailActivity::class.java).apply {
+ putExtra(EXTRA_FRIEND, friend)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailViewModel.kt b/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailViewModel.kt
new file mode 100644
index 0000000..fad120e
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/friend/detail/FriendDetailViewModel.kt
@@ -0,0 +1,30 @@
+package com.studyfork.sfoide.ui.friend.detail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.studyfork.sfoide.ui.utils.Event
+import timber.log.Timber
+
+class FriendDetailViewModel : ViewModel() {
+
+ private val _navigatePhoneAppEvent = MutableLiveData>()
+ val navigatePhoneAppEvent: LiveData> = _navigatePhoneAppEvent
+
+ private val _navigateEmailAppEvent = MutableLiveData>()
+ val navigateEmailAppEvent: LiveData> = _navigateEmailAppEvent
+
+ fun navigateEmail(email: String) {
+ _navigateEmailAppEvent.value = Event(email)
+ }
+
+ fun navigatePhone(phoneNumber: String) {
+ val formattedPhoneNumber = phoneNumber.filter { it in NUMBER_FILTER }
+ Timber.e(formattedPhoneNumber)
+ _navigatePhoneAppEvent.value = Event(formattedPhoneNumber)
+ }
+
+ companion object {
+ private const val NUMBER_FILTER = "0123456789"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/mapper/FriendMapper.kt b/app/src/main/java/com/studyfork/sfoide/ui/mapper/FriendMapper.kt
new file mode 100644
index 0000000..dc548fa
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/mapper/FriendMapper.kt
@@ -0,0 +1,23 @@
+package com.studyfork.sfoide.ui.mapper
+
+import com.studyfork.sfoide.data.model.FriendData
+import com.studyfork.sfoide.ui.model.Coordinates
+import com.studyfork.sfoide.ui.model.Friend
+
+fun FriendData.toPresentation(): Friend {
+ return Friend(
+ id = this.id,
+ thumbnail = this.thumbnail,
+ name = this.name,
+ age = this.age,
+ gender = this.gender,
+ country = this.country,
+ email = this.email,
+ telephone = this.telephone,
+ mobilePhone = this.mobilePhone,
+ coordinates = Coordinates(
+ latitude = this.coordinatesData.latitude,
+ longitude = this.coordinatesData.longitude
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/model/Friend.kt b/app/src/main/java/com/studyfork/sfoide/ui/model/Friend.kt
new file mode 100644
index 0000000..aeda6a1
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/model/Friend.kt
@@ -0,0 +1,24 @@
+package com.studyfork.sfoide.ui.model
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+data class Friend(
+ val id: String,
+ val thumbnail: String,
+ val name: String,
+ val age: Int,
+ val gender: String,
+ val country: String,
+ val email: String,
+ val telephone: String,
+ val mobilePhone: String,
+ val coordinates: Coordinates
+) : Parcelable
+
+@Parcelize
+class Coordinates(
+ val latitude: Double,
+ val longitude: Double
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/utils/EndlessRecyclerViewScrollListener.kt b/app/src/main/java/com/studyfork/sfoide/ui/utils/EndlessRecyclerViewScrollListener.kt
new file mode 100644
index 0000000..70a6fd2
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/utils/EndlessRecyclerViewScrollListener.kt
@@ -0,0 +1,115 @@
+package com.studyfork.sfoide.ui.utils
+
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.StaggeredGridLayoutManager
+
+/**
+ * 원본 코드 주소
+ * https://gist.github.com/nesquena/d09dc68ff07e845cc622
+ * https://github.com/codepath/android_guides/wiki/Endless-Scrolling-with-AdapterViews-and-RecyclerView
+ */
+abstract class EndlessRecyclerViewScrollListener(private val layoutManager: RecyclerView.LayoutManager) :
+ RecyclerView.OnScrollListener() {
+ // The minimum amount of items to have below your current scroll position
+ // before loading more.
+ private var visibleThreshold = 5
+
+ // The current offset index of data you have loaded
+ private var currentPage = 0
+
+ // The total number of items in the dataset after the last load
+ private var previousTotalItemCount = 0
+
+ // True if we are still waiting for the last set of data to load.
+ private var loading = true
+
+ // Sets the starting page index
+ private val startingPageIndex = 0
+
+
+ init {
+ visibleThreshold = when (layoutManager) {
+ is GridLayoutManager -> visibleThreshold * layoutManager.spanCount
+ is StaggeredGridLayoutManager -> visibleThreshold * layoutManager.spanCount
+ else -> visibleThreshold
+ }
+
+ }
+
+ private fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
+ var maxSize = 0
+ for (i in lastVisibleItemPositions.indices) {
+ if (i == 0) {
+ maxSize = lastVisibleItemPositions[i]
+ } else if (lastVisibleItemPositions[i] > maxSize) {
+ maxSize = lastVisibleItemPositions[i]
+ }
+ }
+ return maxSize
+ }
+
+ // This happens many times a second during a scroll, so be wary of the code you place here.
+ // We are given a few useful parameters to help us work out if we need to load some more data,
+ // but first we check if we are waiting for the previous load to finish.
+ override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
+ val totalItemCount = layoutManager.itemCount
+
+ val lastVisibleItemPosition = when (layoutManager) {
+ is StaggeredGridLayoutManager -> // get maximum element within the list
+ getLastVisibleItem(layoutManager.findLastVisibleItemPositions(null))
+ is GridLayoutManager -> layoutManager.findLastVisibleItemPosition()
+ is LinearLayoutManager -> layoutManager.findLastVisibleItemPosition()
+ else -> 0
+ }
+
+ // If the total item count is zero and the previous isn't, assume the
+ // list is invalidated and should be reset back to initial state
+ // If it’s still loading, we check to see if the dataset count has
+ // changed, if so we conclude it has finished loading and update the current page
+ // number and total item count.
+
+ // If it isn’t currently loading, we check to see if we have breached
+ // the visibleThreshold and need to reload more data.
+ // If we do need to reload some more data, we execute onLoadMore to fetch the data.
+ // threshold should reflect how many total columns there are too
+
+ // If the total item count is zero and the previous isn't, assume the
+ // list is invalidated and should be reset back to initial state
+ if (totalItemCount < previousTotalItemCount) {
+ this.currentPage = this.startingPageIndex
+ this.previousTotalItemCount = totalItemCount
+ if (totalItemCount == 0) {
+ this.loading = true
+ }
+ }
+ // If it’s still loading, we check to see if the dataset count has
+ // changed, if so we conclude it has finished loading and update the current page
+ // number and total item count.
+ if (loading && totalItemCount > previousTotalItemCount) {
+ loading = false
+ previousTotalItemCount = totalItemCount
+ }
+
+ // If it isn’t currently loading, we check to see if we have breached
+ // the visibleThreshold and need to reload more data.
+ // If we do need to reload some more data, we execute onLoadMore to fetch the data.
+ // threshold should reflect how many total columns there are too
+ if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
+ currentPage++
+ onLoadMore(currentPage, totalItemCount, view)
+ loading = true
+ }
+ }
+
+ // Call this method whenever performing new searches
+ fun resetState() {
+ this.currentPage = this.startingPageIndex
+ this.previousTotalItemCount = 0
+ this.loading = true
+ }
+
+ // Defines the process for actually loading more data based on page
+ abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/utils/Event.kt b/app/src/main/java/com/studyfork/sfoide/ui/utils/Event.kt
new file mode 100644
index 0000000..40c3642
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/utils/Event.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.studyfork.sfoide.ui.utils
+
+import androidx.lifecycle.Observer
+
+/**
+ * Used as a wrapper for data that is exposed via a LiveData that represents an event.
+ */
+open class Event(private val content: T) {
+
+ @Suppress("MemberVisibilityCanBePrivate")
+ 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
+ }
+ }
+
+ /**
+ * Returns the content, even if it's already been handled.
+ */
+ fun peekContent(): T = content
+}
+
+/**
+ * An [Observer] for [Event]s, simplifying the pattern of checking if the [Event]'s content has
+ * already been handled.
+ *
+ * [onEventUnhandledContent] is *only* called if the [Event]'s contents has not been handled.
+ */
+class EventObserver(private val onEventUnhandledContent: (T) -> Unit) : Observer> {
+ override fun onChanged(event: Event?) {
+ event?.getContentIfNotHandled()?.let {
+ onEventUnhandledContent(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/utils/MyGlideModule.kt b/app/src/main/java/com/studyfork/sfoide/ui/utils/MyGlideModule.kt
new file mode 100644
index 0000000..7701792
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/utils/MyGlideModule.kt
@@ -0,0 +1,8 @@
+package com.studyfork.sfoide.ui.utils
+
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.module.AppGlideModule
+
+
+@GlideModule
+class MyGlideModule : AppGlideModule() {}
\ No newline at end of file
diff --git a/app/src/main/java/com/studyfork/sfoide/ui/widget/FriendInfoView.kt b/app/src/main/java/com/studyfork/sfoide/ui/widget/FriendInfoView.kt
new file mode 100644
index 0000000..8df44ed
--- /dev/null
+++ b/app/src/main/java/com/studyfork/sfoide/ui/widget/FriendInfoView.kt
@@ -0,0 +1,78 @@
+package com.studyfork.sfoide.ui.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import androidx.databinding.DataBindingUtil
+import com.studyfork.sfoide.R
+import com.studyfork.sfoide.ui.model.Friend
+import com.studyfork.sfoide.databinding.ViewFriendInfoBinding
+
+class FriendInfoView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ private lateinit var binding: ViewFriendInfoBinding
+
+ private val layoutId = R.layout.view_friend_info
+
+ init {
+ initBinding()
+ }
+
+ var friend: Friend? = null
+ set(value) {
+ field = value
+ value?.let {
+ fetchFriend(value)
+ }
+ }
+
+ private fun fetchFriend(value: Friend) {
+ binding.friend = value
+ }
+
+ private fun initBinding() {
+ binding = DataBindingUtil.inflate(
+ LayoutInflater.from(context),
+ layoutId,
+ this,
+ false
+ )
+ addView(binding.root)
+ }
+
+ fun setOnPhoneClickListener(onPhoneClickListener: OnPhoneClickListener) {
+ binding.tvFriendMobile.setOnClickListener {
+ friend?.mobilePhone?.let {
+ onPhoneClickListener.onPhoneClick(it)
+ }
+ }
+
+ binding.tvFriendTelephone.setOnClickListener {
+ friend?.telephone?.let {
+ onPhoneClickListener.onPhoneClick(it)
+ }
+ }
+ }
+
+ fun setOnEmailClickListener(onEmailClickListener: OnEmailClickListener) {
+ binding.tvFriendEmail.setOnClickListener {
+ friend?.email?.let {
+ onEmailClickListener.onEmailClick(it)
+ }
+ }
+ }
+
+ interface OnPhoneClickListener {
+ fun onPhoneClick(phoneNumber: String)
+ }
+
+ interface OnEmailClickListener {
+ fun onEmailClick(email: String)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_friend.xml b/app/src/main/res/layout/activity_friend.xml
new file mode 100644
index 0000000..df5867f
--- /dev/null
+++ b/app/src/main/res/layout/activity_friend.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_friend_detail.xml b/app/src/main/res/layout/activity_friend_detail.xml
new file mode 100644
index 0000000..0f517ad
--- /dev/null
+++ b/app/src/main/res/layout/activity_friend_detail.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 4fc2444..0000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_friend.xml b/app/src/main/res/layout/item_friend.xml
new file mode 100644
index 0000000..91f369f
--- /dev/null
+++ b/app/src/main/res/layout/item_friend.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_friend_info.xml b/app/src/main/res/layout/view_friend_info.xml
new file mode 100644
index 0000000..c39a3ee
--- /dev/null
+++ b/app/src/main/res/layout/view_friend_info.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 50c6e1a..588b21f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,28 @@
Sfoide
+
+ 🙋♂
+ 🙋♀
+
+ ☎️
+ 📱
+ 📧
+
+ 🇦🇺
+ 🇧🇷
+ 🇨🇦
+ 🇨🇭
+ 🇩🇪
+ 🇩🇰
+ 🇪🇸
+ 🇫🇮
+ 🇫🇷
+ 🇬🇧
+ 🇮🇪
+ 🇮🇷
+ 🇳🇴
+ 🇳🇱
+ 🇳🇿
+ 🇹🇷
+ 🇺🇸
\ No newline at end of file