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