diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index ea4e5d0..a8fce2b 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,7 +1,9 @@ + + - + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index df5f35d..a01a3e5 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..7bf8b74 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/.idea/.gitignore b/app/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/app/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/app/.idea/gradle.xml b/app/.idea/gradle.xml new file mode 100644 index 0000000..91f278a --- /dev/null +++ b/app/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/.idea/misc.xml b/app/.idea/misc.xml new file mode 100644 index 0000000..f845854 --- /dev/null +++ b/app/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/.idea/modules.xml b/app/.idea/modules.xml new file mode 100644 index 0000000..e1f9e1c --- /dev/null +++ b/app/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 71490b5..3785980 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-kapt' } android { @@ -29,10 +30,15 @@ android { kotlinOptions { jvmTarget = '1.8' } + + dataBinding { + enabled = true + } } dependencies { - + implementation 'androidx.activity:activity-ktx:1.2.2' + implementation 'androidx.fragment:fragment-ktx:1.3.3' implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'com.google.android.material:material:1.4.0' @@ -40,4 +46,25 @@ dependencies { testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + //view + implementation 'androidx.recyclerview:recyclerview:1.1.0' + + //livedata + def lifecycle_version = "2.2.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + + //glide + implementation "com.github.bumptech.glide:glide:4.11.0" + compileOnly 'com.google.android.wearable:wearable:2.8.1' + kapt "com.github.bumptech.glide:compiler:4.11.0" + + //retrofit + implementation 'com.squareup.retrofit2:retrofit:2.7.2' + implementation 'com.squareup.retrofit2:converter-gson:2.7.1' + implementation 'com.squareup.okhttp3:logging-interceptor:4.2.1' + + // Gson + implementation 'com.google.code.gson:gson:2.8.6' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8508fca..a68d23e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + diff --git a/app/src/main/java/place/pic/android/plus/data/model/User.kt b/app/src/main/java/place/pic/android/plus/data/model/User.kt new file mode 100644 index 0000000..83b32c8 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/data/model/User.kt @@ -0,0 +1,12 @@ +package place.pic.android.plus.data.model + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +data class User( + val imageUrl: String, + val name: String, + val id: Long +) diff --git a/app/src/main/java/place/pic/android/plus/data/model/UserDetail.kt b/app/src/main/java/place/pic/android/plus/data/model/UserDetail.kt new file mode 100644 index 0000000..5929597 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/data/model/UserDetail.kt @@ -0,0 +1,25 @@ +package place.pic.android.plus.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Created By kimdahyee + * on 11월 03일, 2020 + */ + +data class UserDetail( + @SerializedName("avatar_url") + val imageUrl: String?, + + @SerializedName("login") + val name: String, + + @SerializedName("bio") + val bio: String?, + + @SerializedName("followers") + val followers: Int, + + @SerializedName("html_url") + val htmlUrl: String +) diff --git a/app/src/main/java/place/pic/android/plus/data/remote/GithubApiService.kt b/app/src/main/java/place/pic/android/plus/data/remote/GithubApiService.kt new file mode 100644 index 0000000..7e2ab04 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/data/remote/GithubApiService.kt @@ -0,0 +1,27 @@ +package place.pic.android.plus.data.remote + +import place.pic.android.plus.data.model.UserDetail +import place.pic.android.plus.data.remote.response.UserSearchResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +interface GithubApiService { + @GET("search/users") + fun requestUserSearch( + @Query("q") param: String? + ): Call + + @GET("users/{username}") + fun requestUserDetail( + @Header("accept") token: String, + @Path("username") login: String + ): Call +} diff --git a/app/src/main/java/place/pic/android/plus/data/remote/GithubApiServiceImpl.kt b/app/src/main/java/place/pic/android/plus/data/remote/GithubApiServiceImpl.kt new file mode 100644 index 0000000..eb6897c --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/data/remote/GithubApiServiceImpl.kt @@ -0,0 +1,19 @@ +package place.pic.android.plus.data.remote + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +object GithubApiServiceImpl { + private val retrofit: Retrofit + get() = Retrofit.Builder() + .baseUrl("https://api.github.com/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val service: GithubApiService = retrofit.create(GithubApiService::class.java) +} diff --git a/app/src/main/java/place/pic/android/plus/data/remote/response/UserSearchResponse.kt b/app/src/main/java/place/pic/android/plus/data/remote/response/UserSearchResponse.kt new file mode 100644 index 0000000..b0cba54 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/data/remote/response/UserSearchResponse.kt @@ -0,0 +1,33 @@ +package place.pic.android.plus.data.remote.response + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +data class UserSearchResponse( + val total_count: Int, + val incomplete_results: Boolean, + val items: List +) { + data class Data( + val login: String, + val id: Long, + val node_id: String, + val avatar_url: String, + val url: String, + val html_url: String, + val followers_url: String, + val following_url: String, + val gists_url: String, + val starred_url: String, + val subscriptions_url: String, + val organizations_url: String, + val repos_url: String, + val events_url: String, + val received_events_url: String, + val type: String, + val site_admin: Boolean, + val score: Double + ) +} diff --git a/app/src/main/java/place/pic/android/plus/MainActivity.kt b/app/src/main/java/place/pic/android/plus/ui/MainActivity.kt similarity index 80% rename from app/src/main/java/place/pic/android/plus/MainActivity.kt rename to app/src/main/java/place/pic/android/plus/ui/MainActivity.kt index b8670ea..5d1e727 100644 --- a/app/src/main/java/place/pic/android/plus/MainActivity.kt +++ b/app/src/main/java/place/pic/android/plus/ui/MainActivity.kt @@ -1,11 +1,12 @@ -package place.pic.android.plus - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} +package place.pic.android.plus.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import place.pic.android.plus.R + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/detail/view/UserDetailActivity.kt b/app/src/main/java/place/pic/android/plus/ui/detail/view/UserDetailActivity.kt new file mode 100644 index 0000000..6101d89 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/detail/view/UserDetailActivity.kt @@ -0,0 +1,37 @@ +package place.pic.android.plus.ui.detail.view + +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import place.pic.android.plus.R +import place.pic.android.plus.databinding.ActivityUserDatailBinding +import place.pic.android.plus.ui.detail.viewmodel.UserDetailViewModel + +class UserDetailActivity : AppCompatActivity() { + + private val userDetailViewModel: UserDetailViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + init() + checkErrorAndRequest() + } + + private fun init() { + val binding = + DataBindingUtil.setContentView(this, R.layout.activity_user_datail) + binding.viewModel = userDetailViewModel + binding.lifecycleOwner = this + } + + private fun checkErrorAndRequest() { + val username = intent.getStringExtra("username").toString() + if (username.isEmpty()) { + Log.d("error check", "userDetailActivity username is null") + return + } + userDetailViewModel.requestUserDetail(username) + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/detail/viewmodel/UserDetailViewModel.kt b/app/src/main/java/place/pic/android/plus/ui/detail/viewmodel/UserDetailViewModel.kt new file mode 100644 index 0000000..81a7a46 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/detail/viewmodel/UserDetailViewModel.kt @@ -0,0 +1,50 @@ +package place.pic.android.plus.ui.detail.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import place.pic.android.plus.data.model.UserDetail +import place.pic.android.plus.data.remote.GithubApiServiceImpl +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +/** + * Created By kimdahyee + * on 11월 03일, 2020 + */ + +class UserDetailViewModel : ViewModel() { + + private val token = "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" + + private val _userDetail = MutableLiveData() + val userDetail: LiveData + get() = _userDetail + + fun requestUserDetail(login: String) { + GithubApiServiceImpl.service.requestUserDetail( + token = token, + login = login + ).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + _userDetail.value = response.body() + } + + if (response.code() in 400..499) { + Log.d("error code 4xx", "Client errors") + } + + if (response.code() in 500..599) { + Log.d("error code 5xx", "Server errors") + } + } + + override fun onFailure(call: Call, t: Throwable) { + t.message?.let { Log.d("fail", it) } + } + }) + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/search/adapter/UserSearchAdapter.kt b/app/src/main/java/place/pic/android/plus/ui/search/adapter/UserSearchAdapter.kt new file mode 100644 index 0000000..06bb77f --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/search/adapter/UserSearchAdapter.kt @@ -0,0 +1,68 @@ +package place.pic.android.plus.ui.search.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import place.pic.android.plus.data.model.User +import place.pic.android.plus.databinding.ItemUserSearchBinding + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +class UserSearchAdapter : RecyclerView.Adapter() { + + private val data: MutableList = mutableListOf() + private lateinit var itemClickListener: ItemClickListener + // private var itemClickListener: ((user: User) -> Unit)? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ItemUserSearchBinding.inflate(layoutInflater, parent, false) + return UserViewHolder(binding) + } + + override fun onBindViewHolder(holder: UserViewHolder, position: Int) { + holder.bind(data[position]) + holder.itemView.setOnClickListener { + itemClickListener.onClick(it, position) + } + } + + override fun getItemCount(): Int { + return data.size + } + + class UserViewHolder( + private val binding: ItemUserSearchBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(user: User) { + binding.user = user + // binding.clickListener = createUserItemClickListener(user) + } + } + + interface ItemClickListener { + fun onClick(view: View, position: Int) + } + + fun setItemClickListener(itemClickListener: ItemClickListener) { + this.itemClickListener = itemClickListener + } + + fun setItem(users: List) { + data.clear() + data.addAll(users) + notifyDataSetChanged() + } + + /*fun setItemClickListener(itemClickListener: ((user: User) -> Unit)?) { + this.itemClickListener = itemClickListener + } + + fun createUserItemClickListener(user: User) = View.OnClickListener { + itemClickListener?.invoke(user) + }*/ +} diff --git a/app/src/main/java/place/pic/android/plus/ui/search/view/UserSearchActivity.kt b/app/src/main/java/place/pic/android/plus/ui/search/view/UserSearchActivity.kt new file mode 100644 index 0000000..1966189 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/search/view/UserSearchActivity.kt @@ -0,0 +1,72 @@ +package place.pic.android.plus.ui.search.view + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import place.pic.android.plus.R +import place.pic.android.plus.data.model.User +import place.pic.android.plus.databinding.ActivitySearchBinding +import place.pic.android.plus.ui.detail.view.UserDetailActivity +import place.pic.android.plus.ui.search.adapter.UserSearchAdapter +import place.pic.android.plus.ui.search.viewmodel.UserSearchViewModel + +class UserSearchActivity : AppCompatActivity() { + + private val userSearchViewModel: UserSearchViewModel by viewModels() + private val userSearchAdapter by lazy { UserSearchAdapter() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + init() + inputErrorCheck() + searchUser() + } + + private fun init() { + val binding = + DataBindingUtil.setContentView(this, R.layout.activity_search) + binding.lifecycleOwner = this + binding.viewModel = userSearchViewModel + binding.rcvUserSearch.adapter = userSearchAdapter + } + + private fun inputErrorCheck() { + userSearchViewModel.showErrorToast.observe(this) { + it.getContentIfNotHandled().let { + Toast.makeText(this, "검색어를 입력하세요!", Toast.LENGTH_SHORT).show() + } + } + } + + private fun searchUser() { + userSearchViewModel.users.observe(this) { it -> + hideKeyboard() + userSearchAdapter.setItem(it) + userSearchAdapter.setItemClickListener(object : UserSearchAdapter.ItemClickListener { + override fun onClick(view: View, position: Int) { + onUserItemClick(it[position]) + } + }) + // userSearchAdapter.setItemClickListener { onUserItemClick(it) } + } + } + + private fun onUserItemClick(user: User) { + val intent = Intent(this, UserDetailActivity::class.java) + intent.putExtra("username", user.name) + startActivity(intent) + } + + private fun hideKeyboard() { + currentFocus?.run { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + } + } +} diff --git a/app/src/main/java/place/pic/android/plus/ui/search/viewmodel/UserSearchViewModel.kt b/app/src/main/java/place/pic/android/plus/ui/search/viewmodel/UserSearchViewModel.kt new file mode 100644 index 0000000..feb0e0a --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/ui/search/viewmodel/UserSearchViewModel.kt @@ -0,0 +1,88 @@ +package place.pic.android.plus.ui.search.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import place.pic.android.plus.data.model.User +import place.pic.android.plus.data.remote.GithubApiServiceImpl +import place.pic.android.plus.data.remote.response.UserSearchResponse +import place.pic.android.plus.utils.Event +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +/** + * Created By kimdahyee + * on 11월 02일, 2020 + */ + +class UserSearchViewModel : ViewModel() { + + private val list = mutableListOf() + val searchQuery = MutableLiveData("") + + private val _users = MutableLiveData>() + val users: LiveData> + get() = _users + + private fun addUser(user: User) { + list.add(user) + _users.value = list + } + + private val _showErrorToast = MutableLiveData>() + val showErrorToast: LiveData> + get() = _showErrorToast + + private fun sendErrorEvent() { + _showErrorToast.value = Event(true) + } + + private fun requestUserSearch(query: String) { + GithubApiServiceImpl.service.requestUserSearch( + param = query + ).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + if (response.isSuccessful) { // 200~300 + Log.d("server check", "통신 성공") + list.clear() + response.body()!!.items.forEach { + addUser( + User( + imageUrl = it.avatar_url, + name = it.login, + id = it.id + ) + ) + } + } + + // 4xx Client error 5xx server error + if (response.code() in 400..499) { + Log.d("error code 4xx", "Client errors") + } + + if (response.code() in 500..599) { + Log.d("error code 5xx", "Server errors") + } + } + + override fun onFailure(call: Call, t: Throwable) { + // 통신 자체가 실패 + t.message?.let { Log.d("fail", it) } + } + }) + } + + fun onSearchButtonClick(input: String) { + if (input.isEmpty()) { + sendErrorEvent() + return + } + requestUserSearch(input) + } +} diff --git a/app/src/main/java/place/pic/android/plus/utils/Event.kt b/app/src/main/java/place/pic/android/plus/utils/Event.kt new file mode 100644 index 0000000..c680b8b --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/utils/Event.kt @@ -0,0 +1,29 @@ +package place.pic.android.plus.utils + +/** + * Created By kimdahyee + * on 11월 28일, 2020 + */ + +open class Event(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 + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} diff --git a/app/src/main/java/place/pic/android/plus/utils/adapter/UserBindingAdapter.kt b/app/src/main/java/place/pic/android/plus/utils/adapter/UserBindingAdapter.kt new file mode 100644 index 0000000..6c8c749 --- /dev/null +++ b/app/src/main/java/place/pic/android/plus/utils/adapter/UserBindingAdapter.kt @@ -0,0 +1,20 @@ +package place.pic.android.plus.utils.adapter + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide + +/** + * Created By kimdahyee + * on 12월 05일, 2020 + */ + +class UserBindingAdapter { + companion object { + @JvmStatic + @BindingAdapter("userImageUrl") + fun loadUserImage(imageView: ImageView, url: String?) { + Glide.with(imageView.context).load(url).into(imageView) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ca0c0de..539db92 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".ui.MainActivity"> + + + + + + + + + + +